package main import ( "crypto/ecdsa" "crypto/elliptic" "crypto/rand" "crypto/x509" "encoding/json" "encoding/pem" "io" "net/http" "net/http/httptest" "path/filepath" "strings" "testing" "lchat/internal/apns" ) const testAdmin = "test-admin-token" // device tokens the stub Apple server treats specially (all valid hex). const ( liveDevice = "aaaaaaaa" deadDevice = "deadbeef" ) func testKeyPEM(t *testing.T) string { t.Helper() key, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) der, _ := x509.MarshalPKCS8PrivateKey(key) return string(pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: der})) } // newTestRelay wires the real router (s.handler) over a temp DB, with the APNs // client pointed at a stub standing in for Apple. func newTestRelay(t *testing.T) *httptest.Server { t.Helper() stub := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if strings.Contains(r.URL.Path, deadDevice) { w.WriteHeader(http.StatusGone) return } w.WriteHeader(http.StatusOK) })) c, err := apns.New(testKeyPEM(t), "KID1234567", "TEAM123456", "com.example.app") if err != nil { t.Fatal(err) } c.SetHosts(stub.URL, stub.URL) db, err := openDB(filepath.Join(t.TempDir(), "push.db")) if err != nil { t.Fatal(err) } s := &Server{ db: db, apns: c, adminHash: hashToken(testAdmin), perInstance: newRateLimiter(1000, 500), global: newRateLimiter(4000, 2000), } srv := httptest.NewServer(s.handler()) t.Cleanup(func() { srv.Close(); stub.Close(); db.sql.Close() }) return srv } func do(t *testing.T, method, url, bearer, body string) *http.Response { t.Helper() var r io.Reader if body != "" { r = strings.NewReader(body) } rq, _ := http.NewRequest(method, url, r) if bearer != "" { rq.Header.Set("Authorization", "Bearer "+bearer) } resp, err := http.DefaultClient.Do(rq) if err != nil { t.Fatal(err) } return resp } // mint creates an instance token via the admin API and returns (id, token). func mint(t *testing.T, base, label string) (string, string) { t.Helper() resp := do(t, "POST", base+"/api/admin/tokens", testAdmin, `{"label":"`+label+`"}`) defer resp.Body.Close() if resp.StatusCode != http.StatusOK { t.Fatalf("mint: status %d", resp.StatusCode) } var out struct{ ID, Token string } if err := json.NewDecoder(resp.Body).Decode(&out); err != nil { t.Fatal(err) } if out.Token == "" || out.ID == "" { t.Fatal("mint returned empty id/token") } return out.ID, out.Token } func push(t *testing.T, base, token, deviceToken, extra string) int { t.Helper() body := `{"device_token":"` + deviceToken + `","thread":"th","blob":"x"` + extra + `}` resp := do(t, "POST", base+"/api/push", token, body) resp.Body.Close() return resp.StatusCode } func TestPushAuth(t *testing.T) { base := newTestRelay(t).URL if got := push(t, base, "", liveDevice, ""); got != http.StatusUnauthorized { t.Errorf("no token: want 401, got %d", got) } if got := push(t, base, "bogus-token", liveDevice, ""); got != http.StatusUnauthorized { t.Errorf("bad token: want 401, got %d", got) } _, tok := mint(t, base, "family.example.com") if got := push(t, base, tok, liveDevice, ""); got != http.StatusOK { t.Errorf("minted token: want 200, got %d", got) } } func TestPushRevoke(t *testing.T) { base := newTestRelay(t).URL id, tok := mint(t, base, "test.example.com") if got := push(t, base, tok, liveDevice, ""); got != http.StatusOK { t.Fatalf("pre-revoke: want 200, got %d", got) } resp := do(t, "DELETE", base+"/api/admin/tokens/"+id, testAdmin, "") resp.Body.Close() if resp.StatusCode != http.StatusOK { t.Fatalf("revoke: want 200, got %d", resp.StatusCode) } if got := push(t, base, tok, liveDevice, ""); got != http.StatusUnauthorized { t.Errorf("post-revoke: want 401, got %d", got) } } func TestPushGonePassthrough(t *testing.T) { base := newTestRelay(t).URL _, tok := mint(t, base, "test.example.com") // Apple says the token is dead → relay must return a bare 410 so the // instance prunes it. if got := push(t, base, tok, deadDevice, ""); got != http.StatusGone { t.Errorf("dead device: want 410, got %d", got) } } func TestPushBadDeviceToken(t *testing.T) { base := newTestRelay(t).URL _, tok := mint(t, base, "test.example.com") if got := push(t, base, tok, "zznothex", ""); got != http.StatusBadRequest { t.Errorf("non-hex device token: want 400, got %d", got) } } func TestPushOversized(t *testing.T) { base := newTestRelay(t).URL _, tok := mint(t, base, "test.example.com") // A blob well over maxPushBytes must 413, never 410 (a false prune). big := `,"pad":"` + strings.Repeat("a", 9000) + `"` if got := push(t, base, tok, liveDevice, big); got != http.StatusRequestEntityTooLarge { t.Errorf("oversized: want 413, got %d", got) } } func TestAdminRequiresToken(t *testing.T) { base := newTestRelay(t).URL for _, tc := range []struct{ method, path string }{ {"POST", "/api/admin/tokens"}, {"GET", "/api/admin/tokens"}, {"DELETE", "/api/admin/tokens/x"}, } { resp := do(t, tc.method, base+tc.path, "wrong-admin", `{"label":"x"}`) resp.Body.Close() if resp.StatusCode != http.StatusUnauthorized { t.Errorf("%s %s without admin: want 401, got %d", tc.method, tc.path, resp.StatusCode) } } } func TestHealthz(t *testing.T) { base := newTestRelay(t).URL resp := do(t, "GET", base+"/healthz", "", "") defer resp.Body.Close() if resp.StatusCode != http.StatusOK { t.Errorf("healthz: want 200, got %d", resp.StatusCode) } } // A changelog announcement: plaintext title/body instead of a ciphertext blob // (the one non-blind payload — branding-class release notes). Same 200/410 // contract as message pushes. func TestPushChangelogAlert(t *testing.T) { base := newTestRelay(t).URL _, tok := mint(t, base, "inst") body := `{"device_token":"` + liveDevice + `","changelog":true,"title":"What's new","body":"Notes."}` if resp := do(t, "POST", base+"/api/push", tok, body); resp.StatusCode != http.StatusOK { t.Fatalf("changelog push: status %d", resp.StatusCode) } // Dead tokens prune through the changelog path too. body = `{"device_token":"` + deadDevice + `","changelog":true,"title":"What's new","body":"Notes."}` if resp := do(t, "POST", base+"/api/push", tok, body); resp.StatusCode != http.StatusGone { t.Fatalf("changelog push to dead device: status %d, want 410", resp.StatusCode) } }