You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 
 

263 lines
9.1 KiB

package appleiap_test
import (
"context"
"crypto/elliptic"
"encoding/base64"
"encoding/json"
"io"
"net/http"
"net/url"
"os"
"strings"
"testing"
"time"
"github.com/golang-jwt/jwt/v5"
)
// TestServerAPICredentials 生成 ES256 JWT 并请求 Apple Server API 一个无害端点,
// 以判断凭据是否“可用”(不校验具体交易)。
// 环境变量:
// - APPLE_ISSUER_ID
// - APPLE_KEY_ID
// - APPLE_PRIVATE_KEY_PEM (完整 .p8 内容)
// - APPLE_USE_SANDBOX (可选,"true" 走沙盒)
// 若缺少必要变量则跳过测试。
func TestServerAPICredentials(t *testing.T) {
// 直接在代码中设置 Issuer ID 和 Key ID
iss := "96c4b3eb-5e5c-4719-8b07-db9cc4dc1f89"
kid := "VS538D3ZR5"
// StoreKit 要求 JWT 载荷包含 bid(bundleId),与交易所属 App 一致
bundleID := "com.yimai.eaimar"
// 方式A:把 .p8 全文粘贴到下面的变量(包含完整头尾与换行)
pem := ``
// 方式B:从常见本地路径读取(已将 AuthKey_<KEYID>.p8 放到仓库目录或子目录)
var usedPath string
if pem == "" {
candidates := []string{
"./AuthKey_" + kid + ".p8",
"./sys/pay/appleiap/AuthKey_" + kid + ".p8",
}
for _, p := range candidates {
if _, err := os.Stat(p); err == nil {
b, err := os.ReadFile(p)
if err != nil {
t.Fatalf("读取私钥文件失败(%s): %v", p, err)
}
pem = string(b)
usedPath = p
break
}
}
if pem == "" {
t.Fatalf("未找到私钥文件,请将 AuthKey_%s.p8 放到项目根或 sys/pay/appleiap/ 下", kid)
}
t.Logf("使用密钥文件: %s", usedPath)
}
if iss == "" || kid == "" || pem == "" {
t.Fatalf("缺少 Issuer/KeyID 或 .p8 内容为空")
}
priv, err := jwt.ParseECPrivateKeyFromPEM([]byte(pem))
if err != nil {
t.Fatalf("解析私钥失败: %v", err)
}
claims := jwt.MapClaims{
"iss": iss,
"iat": time.Now().Unix(),
// App Store Connect 要求 token 生命周期 ≤ 20 分钟
"exp": time.Now().Add(20 * time.Minute).Unix(),
"aud": "appstoreconnect-v1",
"bid": bundleID,
}
token := jwt.NewWithClaims(jwt.SigningMethodES256, claims)
token.Header["kid"] = kid
token.Header["typ"] = "JWT"
signed, err := token.SignedString(priv)
if err != nil {
t.Fatalf("签名 JWT 失败: %v", err)
}
// 输出 JWT 头和载荷,便于排查 kid/aud/时间戳等问题
{
parts := strings.Split(signed, ".")
if len(parts) == 3 {
decode := func(s string) string {
b, _ := base64.RawURLEncoding.DecodeString(s)
return string(b)
}
t.Logf("JWT header: %s", decode(parts[0]))
t.Logf("JWT payload: %s", decode(parts[1]))
t.Logf("iat=%d exp=%d ttl=%ds now=%d", claims["iat"], claims["exp"], claims["exp"].(int64)-claims["iat"].(int64), time.Now().Unix())
// 打印密钥曲线类型,必须为 P-256
if priv != nil && priv.PublicKey.Curve == elliptic.P256() {
t.Logf("EC 曲线: P-256")
} else {
t.Logf("EC 曲线: 非 P-256,可能导致验签失败")
}
}
}
// 使用同一 JWT 请求 App Store Connect API 进行对照(验证 JWT 是否被接受)
{
ctx2, cancel2 := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel2()
req2, _ := http.NewRequestWithContext(ctx2, http.MethodGet, "https://api.appstoreconnect.apple.com/v1/apps?limit=1", nil)
req2.Header.Set("Authorization", "Bearer "+signed)
resp2, err2 := (&http.Client{Timeout: 10 * time.Second}).Do(req2)
if err2 != nil {
t.Logf("App Store Connect 请求错误: %v", err2)
} else {
defer resp2.Body.Close()
body2, _ := io.ReadAll(resp2.Body)
t.Logf("App Store Connect 状态码: %d", resp2.StatusCode)
t.Logf("App Store Connect 响应体: %s", string(body2))
}
}
// 查询 In-App Purchase 商品信息(按 productId 过滤)
{
// 尝试使用短 productId 以及带 bundleId 的完整 productId 两种形式
// prod1 := "ai_001"
prod2 := bundleID + ".ai_001"
q := func(pid string) {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
u := "https://api.appstoreconnect.apple.com/v1/inAppPurchases?limit=5&filter[productId]=" + url.QueryEscape(pid)
req, _ := http.NewRequestWithContext(ctx, http.MethodGet, u, nil)
req.Header.Set("Authorization", "Bearer "+signed)
resp, err := (&http.Client{Timeout: 10 * time.Second}).Do(req)
if err != nil {
t.Logf("[ASC] inAppPurchases(productId=%s) 请求错误: %v", pid, err)
return
}
defer resp.Body.Close()
b, _ := io.ReadAll(resp.Body)
t.Logf("[ASC] inAppPurchases(productId=%s) 状态码: %d", pid, resp.StatusCode)
t.Logf("[ASC] inAppPurchases(productId=%s) 响应体: %s", pid, string(b))
}
// q(prod1)
q(prod2)
}
// 进一步:按 bundleId 找到 appId,再用 appId 查询该应用的 IAP 列表
{
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
u := "https://api.appstoreconnect.apple.com/v1/apps?limit=2&filter[bundleId]=" + url.QueryEscape(bundleID)
req, _ := http.NewRequestWithContext(ctx, http.MethodGet, u, nil)
req.Header.Set("Authorization", "Bearer "+signed)
resp, err := (&http.Client{Timeout: 10 * time.Second}).Do(req)
if err != nil {
t.Logf("[ASC] apps(filter[bundleId]) 请求错误: %v", err)
} else {
defer resp.Body.Close()
b, _ := io.ReadAll(resp.Body)
t.Logf("[ASC] apps(filter[bundleId]) 状态码: %d", resp.StatusCode)
t.Logf("[ASC] apps(filter[bundleId]) 响应体: %s", string(b))
// 解析 appId
var app struct {
Data []struct {
Id string `json:"id"`
} `json:"data"`
}
if err := json.Unmarshal(b, &app); err == nil && len(app.Data) > 0 {
appId := app.Data[0].Id
ctx2, cancel2 := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel2()
u2 := "https://api.appstoreconnect.apple.com/v1/apps/" + url.PathEscape(appId) + "/inAppPurchases?limit=5"
req2, _ := http.NewRequestWithContext(ctx2, http.MethodGet, u2, nil)
req2.Header.Set("Authorization", "Bearer "+signed)
resp2, err2 := (&http.Client{Timeout: 10 * time.Second}).Do(req2)
if err2 != nil {
t.Logf("[ASC] apps/%s/inAppPurchases 请求错误: %v", appId, err2)
} else {
defer resp2.Body.Close()
b2, _ := io.ReadAll(resp2.Body)
t.Logf("[ASC] apps/%s/inAppPurchases 状态码: %d", appId, resp2.StatusCode)
t.Logf("[ASC] apps/%s/inAppPurchases 响应体: %s", appId, string(b2))
}
}
}
}
// 尝试个人密钥载荷(sub=user),以区分密钥类型导致的 401
{
now := time.Now().Unix()
claims2 := jwt.MapClaims{
"sub": "user",
"aud": "appstoreconnect-v1",
"iat": now,
// 个人密钥同样遵循 ≤ 20 分钟的生命周期
"exp": now + 1200,
"bid": bundleID,
}
token2 := jwt.NewWithClaims(jwt.SigningMethodES256, claims2)
token2.Header["kid"] = kid
token2.Header["typ"] = "JWT"
signed2, err := token2.SignedString(priv)
if err != nil {
t.Fatalf("签名 JWT(个人密钥载荷) 失败: %v", err)
}
parts := strings.Split(signed2, ".")
if len(parts) == 3 {
decode := func(s string) string {
b, _ := base64.RawURLEncoding.DecodeString(s)
return string(b)
}
t.Logf("[Individual] JWT header: %s", decode(parts[0]))
t.Logf("[Individual] JWT payload: %s", decode(parts[1]))
t.Logf("[Individual] iat=%d exp=%d ttl=%ds now=%d", claims2["iat"], claims2["exp"], claims2["exp"].(int64)-claims2["iat"].(int64), time.Now().Unix())
}
ctx3, cancel3 := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel3()
req3, _ := http.NewRequestWithContext(ctx3, http.MethodGet, "https://api.appstoreconnect.apple.com/v1/apps?limit=1", nil)
req3.Header.Set("Authorization", "Bearer "+signed2)
resp3, err3 := (&http.Client{Timeout: 10 * time.Second}).Do(req3)
if err3 != nil {
t.Logf("[Individual] App Store Connect 请求错误: %v", err3)
} else {
defer resp3.Body.Close()
body3, _ := io.ReadAll(resp3.Body)
t.Logf("[Individual] App Store Connect 状态码: %d", resp3.StatusCode)
t.Logf("[Individual] App Store Connect 响应体: %s", string(body3))
}
}
base := "https://api.storekit.itunes.apple.com/inApps/v1/transactions/"
// 如需沙箱测试,将 useSandbox 改为 true
useSandbox := false
if useSandbox {
base = "https://api.storekit-sandbox.itunes.apple.com/inApps/v1/transactions/"
}
// 使用一个明显不存在的 transactionId,预期返回 404 或 400;401/403 则代表凭据不可用
url := base + "invalid-test-txid"
req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, url, nil)
if err != nil {
t.Fatalf("构造请求失败: %v", err)
}
req.Header.Set("Authorization", "Bearer "+signed)
client := &http.Client{Timeout: 10 * time.Second}
resp, err := client.Do(req)
if err != nil {
t.Fatalf("请求失败: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden {
// 输出更多错误信息便于定位(如 audience、kid、时间戳问题)
b, _ := io.ReadAll(resp.Body)
t.Fatalf("凭据不可用,状态码=%d,响应=%s;iss=%s kid=%s sandbox=%v", resp.StatusCode, string(b), iss, kid, useSandbox)
}
// 其他返回(如 404/400/200)均代表签名通过,凭据可用
}