44096069a7
- 发送端流式(R-iOS-4):新增 FileSource 抽象(web/src/features/transfer/source.ts),iOS 经 HTTP Range 惰性拉取(原生 WKURLSchemeHandler 认 Range 头 seek 回 206),整文件不进 WebView 内存;web/桌面经 fileSource 包装字节不变。p2p/relay/transfer 改吃 FileSource - 后台续传:iOS 26 BGContinuedProcessingTask(BackgroundTaskManager + AppDelegate 注册 + BGTaskSchedulerPermittedIdentifiers),引擎传输进度驱动系统进度 UI - APNs:后端 internal/apns(ES256 .p8 JWT + HTTP/2,仿 internal/push)+ push_subscriptions.platform 列 + POST /api/push/apns/register + transfer/message 未投递分支分流;原生 PushRegistry 授权 + 设备令牌经引擎桥 registerPush 上报;aps-environment + remote-notification 后台模式 - Share Extension:CDropShare appex 抓文件 → App Group 收件箱 → cdrop://share 深链唤主程序选设备发送(只交接、不跑引擎) - 控制中心剪贴板两控件:CDropWidgets widgetkit appex(ControlWidget + AppIntent + 纯原生 ClipboardClient REST);用专用 broker 设备会话(主程序经引擎 provisionWidgetSession 代铸独立 device_id 会话存 App Group,控件自刷不与引擎抢 refresh 轮换) - 真机分发预备:committed 签名改 ad-hoc 使模拟器 entitlement 生效;真机手动签名 scaffold(Signing.xcconfig 仅 device SDK 套 Manual + gitignore 的 Local.xcconfig 填 Team/profile)+ just ios-device / ios-devices 全 CLI 装机;包名改 net.commilitia.Commilitia-Drop(含 .share/.widgets,BG task 前缀同改);REALDEVICE.md 重写为门户 + CLI 装机手册 - I18n.swift 移 Shared/ 供三 target 共用;i18n 加 ios.bg/share/control.* 键 - ultracode 多 agent 审查修复(0 HIGH,2 MED + 7 LOW):分享 send 后留收件箱致重发→move 私有暂存;WidgetSession 锁屏文件保护→UntilFirstUserAuthentication;outgoing/安全作用域登出释放;控件 device_id 登出清理;并发控件 refresh CAS-before-clear;APNs 读 reason + prune bad token + bust 过期 JWT;APNSEnv 大小写归一;Share-ext completeRequest 挪进 open 回调
397 lines
11 KiB
Go
397 lines
11 KiB
Go
package apns
|
|
|
|
import (
|
|
"context"
|
|
"crypto/ecdsa"
|
|
"crypto/elliptic"
|
|
"crypto/rand"
|
|
"crypto/x509"
|
|
"encoding/json"
|
|
"encoding/pem"
|
|
"io"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/golang-jwt/jwt/v5"
|
|
|
|
"commilitia.net/cdrop/internal/db"
|
|
"commilitia.net/cdrop/internal/push"
|
|
)
|
|
|
|
// generateTestKey creates an ephemeral P-256 key and returns it as PEM-encoded
|
|
// PKCS8 bytes alongside the typed private key (for verification).
|
|
func generateTestKey(t *testing.T) (pemBytes []byte, priv *ecdsa.PrivateKey) {
|
|
t.Helper()
|
|
priv, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
|
if err != nil {
|
|
t.Fatalf("generate key: %v", err)
|
|
}
|
|
der, err := x509.MarshalPKCS8PrivateKey(priv)
|
|
if err != nil {
|
|
t.Fatalf("marshal pkcs8: %v", err)
|
|
}
|
|
pemBytes = pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: der})
|
|
return pemBytes, priv
|
|
}
|
|
|
|
// openTestDB creates a temporary SQLite database and runs Bootstrap. Returns a
|
|
// *db.Queries backed by that DB.
|
|
func openTestDB(t *testing.T) *db.Queries {
|
|
t.Helper()
|
|
conn, err := db.Open(filepath.Join(t.TempDir(), "apns_test.db"))
|
|
if err != nil {
|
|
t.Fatalf("open test db: %v", err)
|
|
}
|
|
t.Cleanup(func() { _ = conn.Close() })
|
|
if err := db.Bootstrap(context.Background(), conn); err != nil {
|
|
t.Fatalf("bootstrap: %v", err)
|
|
}
|
|
return db.New(conn)
|
|
}
|
|
|
|
// TestNewSenderInertWhenUnconfigured verifies that a Sender with any missing
|
|
// config field is inert: Enabled reports false and Notify is a safe no-op.
|
|
func TestNewSenderInertWhenUnconfigured(t *testing.T) {
|
|
keyPEM, _ := generateTestKey(t)
|
|
|
|
cases := []struct {
|
|
name string
|
|
keyPEM []byte
|
|
keyID, teamID, topic string
|
|
}{
|
|
{"no key", nil, "KID", "TID", "topic"},
|
|
{"no keyID", keyPEM, "", "TID", "topic"},
|
|
{"no teamID", keyPEM, "KID", "", "topic"},
|
|
{"no topic", keyPEM, "KID", "TID", ""},
|
|
{"all empty", nil, "", "", ""},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
s := NewSender(nil, tc.keyPEM, tc.keyID, tc.teamID, tc.topic, "sandbox")
|
|
if s.Enabled() {
|
|
t.Fatal("inert Sender should report Enabled=false")
|
|
}
|
|
// Notify must be a safe no-op with nil queries.
|
|
s.Notify(context.Background(), "u", "d", push.Notification{Type: push.KindMessage})
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestNilSenderIsSafe verifies that a nil *Sender is safe to call.
|
|
func TestNilSenderIsSafe(t *testing.T) {
|
|
var s *Sender
|
|
if s.Enabled() {
|
|
t.Fatal("nil sender should report disabled")
|
|
}
|
|
s.Notify(context.Background(), "u", "d", push.Notification{}) // must not panic
|
|
}
|
|
|
|
// TestProviderJWT verifies the JWT structure and ES256 signature.
|
|
func TestProviderJWT(t *testing.T) {
|
|
const keyID = "TESTKEYID1"
|
|
const teamID = "TEAMID1234"
|
|
|
|
keyPEM, priv := generateTestKey(t)
|
|
s := NewSender(nil, keyPEM, keyID, teamID, "com.example.app", "sandbox")
|
|
if !s.Enabled() {
|
|
t.Fatal("sender should be enabled with a valid key")
|
|
}
|
|
|
|
jwtStr, err := s.providerJWT()
|
|
if err != nil {
|
|
t.Fatalf("providerJWT: %v", err)
|
|
}
|
|
if jwtStr == "" {
|
|
t.Fatal("providerJWT returned empty string")
|
|
}
|
|
|
|
// Parse without verification first to inspect headers.
|
|
tok, err := jwt.NewParser().ParseWithClaims(jwtStr, jwt.MapClaims{}, func(t *jwt.Token) (any, error) {
|
|
return &priv.PublicKey, nil
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("parse jwt: %v", err)
|
|
}
|
|
if !tok.Valid {
|
|
t.Fatal("jwt should be valid")
|
|
}
|
|
|
|
// Header checks.
|
|
alg, _ := tok.Header["alg"].(string)
|
|
if alg != "ES256" {
|
|
t.Errorf("header alg = %q, want ES256", alg)
|
|
}
|
|
kid, _ := tok.Header["kid"].(string)
|
|
if kid != keyID {
|
|
t.Errorf("header kid = %q, want %q", kid, keyID)
|
|
}
|
|
|
|
// Claims checks.
|
|
claims, ok := tok.Claims.(jwt.MapClaims)
|
|
if !ok {
|
|
t.Fatal("claims not MapClaims")
|
|
}
|
|
iss, _ := claims["iss"].(string)
|
|
if iss != teamID {
|
|
t.Errorf("claim iss = %q, want %q", iss, teamID)
|
|
}
|
|
iat, ok := claims["iat"]
|
|
if !ok {
|
|
t.Error("claim iat missing")
|
|
}
|
|
// iat should be a recent unix timestamp (within the last minute).
|
|
iatF, _ := iat.(float64)
|
|
iatTime := time.Unix(int64(iatF), 0)
|
|
if time.Since(iatTime) > time.Minute {
|
|
t.Errorf("claim iat too old: %v", iatTime)
|
|
}
|
|
}
|
|
|
|
// TestJWTIsCached verifies that repeated calls within jwtRefreshAge return the
|
|
// same token string, and that forcing the cache stale mints a new one.
|
|
func TestJWTIsCached(t *testing.T) {
|
|
keyPEM, _ := generateTestKey(t)
|
|
s := NewSender(nil, keyPEM, "KID", "TID", "topic", "sandbox")
|
|
|
|
first, err := s.providerJWT()
|
|
if err != nil {
|
|
t.Fatalf("first jwt: %v", err)
|
|
}
|
|
second, err := s.providerJWT()
|
|
if err != nil {
|
|
t.Fatalf("second jwt: %v", err)
|
|
}
|
|
if first != second {
|
|
t.Error("providerJWT should return the cached token within refresh window")
|
|
}
|
|
|
|
// Expire the cache.
|
|
s.mu.Lock()
|
|
s.jwtMintedAt = time.Now().Add(-(jwtRefreshAge + time.Second))
|
|
s.mu.Unlock()
|
|
|
|
third, err := s.providerJWT()
|
|
if err != nil {
|
|
t.Fatalf("third jwt: %v", err)
|
|
}
|
|
if third == first {
|
|
t.Error("providerJWT should mint a new token after refresh window expires")
|
|
}
|
|
}
|
|
|
|
// apnsServerRecorder is a minimal httptest server that records the last request
|
|
// and responds with a configurable status.
|
|
type apnsServerRecorder struct {
|
|
srv *httptest.Server
|
|
lastPath string
|
|
lastAuth string
|
|
lastTopic string
|
|
lastPushType string
|
|
lastBody []byte
|
|
respondWith int
|
|
}
|
|
|
|
func newAPNsTestServer(t *testing.T, respondWith int) *apnsServerRecorder {
|
|
t.Helper()
|
|
rec := &apnsServerRecorder{respondWith: respondWith}
|
|
rec.srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
rec.lastPath = r.URL.Path
|
|
rec.lastAuth = r.Header.Get("Authorization")
|
|
rec.lastTopic = r.Header.Get("apns-topic")
|
|
rec.lastPushType = r.Header.Get("apns-push-type")
|
|
rec.lastBody, _ = io.ReadAll(r.Body)
|
|
w.WriteHeader(rec.respondWith)
|
|
}))
|
|
t.Cleanup(rec.srv.Close)
|
|
return rec
|
|
}
|
|
|
|
// TestSendRequest asserts that Notify fires a POST with the correct path and
|
|
// headers against the APNs base URL, and that the JSON body has the expected shape.
|
|
func TestSendRequest(t *testing.T) {
|
|
const deviceToken = "abc123deadbeef"
|
|
const topic = "com.example.cdrop"
|
|
|
|
q := openTestDB(t)
|
|
keyPEM, _ := generateTestKey(t)
|
|
rec := newAPNsTestServer(t, http.StatusOK)
|
|
|
|
s := NewSender(q, keyPEM, "KID", "TID", topic, "sandbox")
|
|
if !s.Enabled() {
|
|
t.Fatal("sender must be enabled")
|
|
}
|
|
// Point the sender at the test server instead of the real APNs endpoint.
|
|
s.base = rec.srv.URL
|
|
|
|
// Insert an APNs subscription row for user "u", device "d".
|
|
now := time.Now().Unix()
|
|
if err := q.UpsertAPNsSubscription(context.Background(), db.UpsertAPNsSubscriptionParams{
|
|
ID: push.SubscriptionID(deviceToken),
|
|
UserID: "u",
|
|
DeviceName: "d",
|
|
Endpoint: deviceToken,
|
|
Locale: "en-US",
|
|
CreatedAt: now,
|
|
LastUsedAt: now,
|
|
}); err != nil {
|
|
t.Fatalf("seed subscription: %v", err)
|
|
}
|
|
|
|
s.Notify(context.Background(), "u", "d", push.Notification{
|
|
Type: push.KindTransferIncoming,
|
|
Params: map[string]string{"sender": "Mac", "filename": "a.pdf"},
|
|
Tag: "transfer:1",
|
|
URL: "/transfers",
|
|
})
|
|
|
|
// Path must be /3/device/{token}.
|
|
wantPath := "/3/device/" + deviceToken
|
|
if rec.lastPath != wantPath {
|
|
t.Errorf("path = %q, want %q", rec.lastPath, wantPath)
|
|
}
|
|
|
|
// Authorization header must start with "bearer ".
|
|
if !strings.HasPrefix(rec.lastAuth, "bearer ") {
|
|
t.Errorf("Authorization = %q, want bearer ...", rec.lastAuth)
|
|
}
|
|
|
|
// APNs headers.
|
|
if rec.lastTopic != topic {
|
|
t.Errorf("apns-topic = %q, want %q", rec.lastTopic, topic)
|
|
}
|
|
if rec.lastPushType != "alert" {
|
|
t.Errorf("apns-push-type = %q, want alert", rec.lastPushType)
|
|
}
|
|
|
|
// JSON body shape.
|
|
var body apsPayload
|
|
if err := json.Unmarshal(rec.lastBody, &body); err != nil {
|
|
t.Fatalf("parse body: %v (raw: %s)", err, rec.lastBody)
|
|
}
|
|
if body.Type != push.KindTransferIncoming {
|
|
t.Errorf("body.type = %q, want %q", body.Type, push.KindTransferIncoming)
|
|
}
|
|
if body.APS.Alert.Title != "Incoming file" {
|
|
t.Errorf("body.aps.alert.title = %q, want Incoming file", body.APS.Alert.Title)
|
|
}
|
|
if !strings.Contains(body.APS.Alert.Body, "a.pdf") {
|
|
t.Errorf("body.aps.alert.body = %q, want mention of a.pdf", body.APS.Alert.Body)
|
|
}
|
|
if body.URL != "/transfers" {
|
|
t.Errorf("body.url = %q, want /transfers", body.URL)
|
|
}
|
|
if body.APS.Sound != "default" {
|
|
t.Errorf("body.aps.sound = %q, want default", body.APS.Sound)
|
|
}
|
|
}
|
|
|
|
// TestSend410PrunesRow verifies that an HTTP 410 response from the APNs
|
|
// endpoint causes the subscription row to be deleted from the database.
|
|
func TestSend410PrunesRow(t *testing.T) {
|
|
const deviceToken = "deadbeef410token"
|
|
|
|
q := openTestDB(t)
|
|
keyPEM, _ := generateTestKey(t)
|
|
rec := newAPNsTestServer(t, http.StatusGone) // 410 = Unregistered
|
|
|
|
s := NewSender(q, keyPEM, "KID", "TID", "com.example.app", "sandbox")
|
|
s.base = rec.srv.URL
|
|
|
|
now := time.Now().Unix()
|
|
subID := push.SubscriptionID(deviceToken)
|
|
if err := q.UpsertAPNsSubscription(context.Background(), db.UpsertAPNsSubscriptionParams{
|
|
ID: subID,
|
|
UserID: "u",
|
|
DeviceName: "d",
|
|
Endpoint: deviceToken,
|
|
Locale: "zh-CN",
|
|
CreatedAt: now,
|
|
LastUsedAt: now,
|
|
}); err != nil {
|
|
t.Fatalf("seed subscription: %v", err)
|
|
}
|
|
|
|
s.Notify(context.Background(), "u", "d", push.Notification{
|
|
Type: push.KindMessage,
|
|
Params: map[string]string{
|
|
"sender": "Phone",
|
|
"text": "hello",
|
|
},
|
|
})
|
|
|
|
// The row must be gone.
|
|
subs, err := q.ListAPNsSubscriptionsByUserDevice(context.Background(), db.ListAPNsSubscriptionsByUserDeviceParams{
|
|
UserID: "u",
|
|
DeviceName: "d",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("list subs: %v", err)
|
|
}
|
|
if len(subs) != 0 {
|
|
t.Errorf("subscription row should be pruned on 410, got %d rows", len(subs))
|
|
}
|
|
}
|
|
|
|
// TestWebPushRowsNotReturnedByAPNsQuery asserts that WebPush-platform rows are
|
|
// invisible to ListAPNsSubscriptionsByUserDevice and vice versa.
|
|
func TestWebPushRowsNotReturnedByAPNsQuery(t *testing.T) {
|
|
q := openTestDB(t)
|
|
now := time.Now().Unix()
|
|
|
|
// Insert a webpush row.
|
|
const webEndpoint = "https://fcm.googleapis.com/fake"
|
|
if err := q.UpsertPushSubscription(context.Background(), db.UpsertPushSubscriptionParams{
|
|
ID: push.SubscriptionID(webEndpoint),
|
|
UserID: "u",
|
|
DeviceName: "d",
|
|
Endpoint: webEndpoint,
|
|
P256dh: "p",
|
|
Auth: "a",
|
|
UserAgent: "",
|
|
Locale: "en-US",
|
|
CreatedAt: now,
|
|
LastUsedAt: now,
|
|
}); err != nil {
|
|
t.Fatalf("upsert webpush: %v", err)
|
|
}
|
|
|
|
// APNs query must not see the webpush row.
|
|
apnsSubs, err := q.ListAPNsSubscriptionsByUserDevice(context.Background(), db.ListAPNsSubscriptionsByUserDeviceParams{
|
|
UserID: "u", DeviceName: "d",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("list apns: %v", err)
|
|
}
|
|
if len(apnsSubs) != 0 {
|
|
t.Errorf("APNs query returned %d row(s), want 0 (webpush rows must be filtered)", len(apnsSubs))
|
|
}
|
|
|
|
// WebPush query must not see apns rows.
|
|
const apnsToken = "aabbccdd1234"
|
|
if err := q.UpsertAPNsSubscription(context.Background(), db.UpsertAPNsSubscriptionParams{
|
|
ID: push.SubscriptionID(apnsToken),
|
|
UserID: "u",
|
|
DeviceName: "d",
|
|
Endpoint: apnsToken,
|
|
Locale: "zh-CN",
|
|
CreatedAt: now,
|
|
LastUsedAt: now,
|
|
}); err != nil {
|
|
t.Fatalf("upsert apns: %v", err)
|
|
}
|
|
|
|
webSubs, err := q.ListPushSubscriptionsByUserDevice(context.Background(), db.ListPushSubscriptionsByUserDeviceParams{
|
|
UserID: "u", DeviceName: "d",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("list webpush: %v", err)
|
|
}
|
|
if len(webSubs) != 1 {
|
|
t.Errorf("webpush query returned %d row(s), want 1 (apns rows must be filtered)", len(webSubs))
|
|
}
|
|
}
|