eleven / messenger Public

Clone
git clone https://amadan.net/eleven/messenger

Plain git — no account needed to clone.

Download

Download this file

1package main
2
3// 🤖 Anchor-management tests (anchors.go, issues #1089/#1098): the listing
4// shape, per-kind removal, the last-method refusal matrix, and auth gating.
5// Anchors are seeded directly through the store — the fake-Apple / fake-SES
6// rigs (siwa_test.go, email_test.go) already cover how anchors ARRIVE; these
7// cover managing them once they're there.
8
9import (
10 "encoding/base64"
11 "encoding/json"
12 "io"
13 "net/http"
14 "strings"
15 "testing"
16
17 "github.com/go-webauthn/webauthn/webauthn"
18)
19
20type anchorRow struct {
21 Kind string `json:"kind"`
22 ID string `json:"id"`
23 CreatedAt int64 `json:"created_at"`
24}
25
26func listAnchors(t *testing.T, url, token string) []anchorRow {
27 t.Helper()
28 r := do(t, "GET", url+"/api/anchors", token, nil, nil)
29 if r.StatusCode != http.StatusOK {
30 t.Fatalf("GET /api/anchors: want 200, got %d", r.StatusCode)
31 }
32 var out struct {
33 Anchors []anchorRow `json:"anchors"`
34 }
35 if err := json.NewDecoder(r.Body).Decode(&out); err != nil {
36 t.Fatalf("bad json: %v", err)
37 }
38 return out.Anchors
39}
40
41func TestAnchorsListing(t *testing.T) {
42 ts, s := newTestServer(t)
43 uid, tok := seedUser(t, s)
44 if _, err := s.store.insertSiwaAnchor(uid, "siwa-hash-1"); err != nil {
45 t.Fatalf("insertSiwaAnchor: %v", err)
46 }
47 if _, err := s.store.insertEmailAnchor(uid, "email-hash-1"); err != nil {
48 t.Fatalf("insertEmailAnchor: %v", err)
49 }
50
51 // Link-token gated, like the devices list.
52 if r := do(t, "GET", ts.URL+"/api/anchors", "", nil, nil); r.StatusCode != http.StatusUnauthorized {
53 t.Fatalf("no token: want 401, got %d", r.StatusCode)
54 }
55 if r := do(t, "GET", ts.URL+"/api/anchors", "wrong", nil, nil); r.StatusCode != http.StatusUnauthorized {
56 t.Fatalf("bad token: want 401, got %d", r.StatusCode)
57 }
58
59 // The listing is {kind, id, created_at} only — the raw body must carry no
60 // credential material (the passkey's public key stays server-side).
61 r := do(t, "GET", ts.URL+"/api/anchors", tok, nil, nil)
62 if r.StatusCode != http.StatusOK {
63 t.Fatalf("list: want 200, got %d", r.StatusCode)
64 }
65 raw, _ := io.ReadAll(r.Body)
66 if strings.Contains(string(raw), "publicKey") || strings.Contains(string(raw), base64.StdEncoding.EncodeToString([]byte("test-cose-public-key"))) {
67 t.Fatalf("anchors listing leaks credential material: %s", raw)
68 }
69 var out struct {
70 Anchors []anchorRow `json:"anchors"`
71 }
72 if err := json.Unmarshal(raw, &out); err != nil {
73 t.Fatalf("bad json: %v", err)
74 }
75 if len(out.Anchors) != 3 {
76 t.Fatalf("want 3 anchors, got %d: %+v", len(out.Anchors), out.Anchors)
77 }
78 creds, _ := s.store.credentialsFor(uid)
79 wantIDs := map[string]string{
80 "passkey": base64.RawURLEncoding.EncodeToString(creds[0].ID),
81 "siwa": "siwa-hash-1",
82 "email": "email-hash-1",
83 }
84 for _, a := range out.Anchors {
85 want, ok := wantIDs[a.Kind]
86 if !ok {
87 t.Fatalf("unexpected/duplicate kind %q: %+v", a.Kind, out.Anchors)
88 }
89 delete(wantIDs, a.Kind)
90 if a.ID != want {
91 t.Fatalf("%s id: want %q, got %q", a.Kind, want, a.ID)
92 }
93 if a.CreatedAt == 0 {
94 t.Fatalf("%s created_at missing: %+v", a.Kind, a)
95 }
96 }
97}
98
99func TestAnchorRemovalHappyPaths(t *testing.T) {
100 ts, s := newTestServer(t)
101 uid, tok := seedUser(t, s)
102 second := &webauthn.Credential{ID: []byte("cred-second"), PublicKey: []byte("pk2")}
103 if err := s.store.insertCredential(uid, second); err != nil {
104 t.Fatalf("insertCredential: %v", err)
105 }
106 s.store.insertSiwaAnchor(uid, "siwa-hash-1")
107 s.store.insertEmailAnchor(uid, "email-hash-1")
108 proof := s.keys.mintCeremonyProof(uid)
109
110 // One removal per kind — each leaves other methods, so each succeeds.
111 for _, path := range []string{
112 "/api/anchors/siwa/siwa-hash-1",
113 "/api/anchors/email/email-hash-1",
114 "/api/anchors/passkey/" + base64.RawURLEncoding.EncodeToString(second.ID),
115 } {
116 r := do(t, "DELETE", ts.URL+path, "", map[string]string{"X-Ceremony-Proof": proof}, nil)
117 if r.StatusCode != http.StatusOK {
118 t.Fatalf("DELETE %s: want 200, got %d", path, r.StatusCode)
119 }
120 }
121 // The listing reflects it: one passkey left, nothing else.
122 anchors := listAnchors(t, ts.URL, tok)
123 if len(anchors) != 1 || anchors[0].Kind != "passkey" {
124 t.Fatalf("post-removal listing: %+v", anchors)
125 }
126 // The removed identifiers no longer sign in.
127 if _, ok := s.store.siwaUser("siwa-hash-1"); ok {
128 t.Fatalf("removed siwa anchor still resolves")
129 }
130 if _, ok := s.store.emailUser("email-hash-1"); ok {
131 t.Fatalf("removed email anchor still resolves")
132 }
133}
134
135// The refusal matrix (#1089): removal is refused exactly when it would leave
136// ZERO ways into the account, and allowed whenever ANOTHER method remains —
137// whatever its kind. This is the rule evolution from the old last-passkey-only
138// guard: an anchored account may shed its final passkey.
139func TestAnchorLastMethodMatrix(t *testing.T) {
140 ts, s := newTestServer(t)
141
142 credID := func(uid string) string {
143 creds, _ := s.store.credentialsFor(uid)
144 return base64.RawURLEncoding.EncodeToString(creds[0].ID)
145 }
146 del := func(proof, path string) *http.Response {
147 return do(t, "DELETE", ts.URL+path, "", map[string]string{"X-Ceremony-Proof": proof}, nil)
148 }
149
150 // A passkey-only account keeps its passkey (pre-anchor behaviour holds).
151 uid1, _ := seedUser(t, s)
152 proof1 := s.keys.mintCeremonyProof(uid1)
153 r := del(proof1, "/api/anchors/passkey/"+credID(uid1))
154 if r.StatusCode != http.StatusConflict {
155 t.Fatalf("last passkey, no anchors: want 409, got %d", r.StatusCode)
156 }
157 // The refusal reads as plain English, not a code.
158 if body, _ := io.ReadAll(r.Body); !strings.Contains(string(body), "last way into this account") {
159 t.Fatalf("refusal message unhelpful: %s", body)
160 }
161 // With an email anchor beside it, the same passkey MAY go…
162 s.store.insertEmailAnchor(uid1, "matrix-email-1")
163 if r := del(proof1, "/api/anchors/passkey/"+credID(uid1)); r.StatusCode != http.StatusOK {
164 t.Fatalf("last passkey with email anchor: want 200, got %d", r.StatusCode)
165 }
166 // …and the email anchor is now the last method: refused.
167 if r := del(proof1, "/api/anchors/email/matrix-email-1"); r.StatusCode != http.StatusConflict {
168 t.Fatalf("last email anchor: want 409, got %d", r.StatusCode)
169 }
170
171 // Same shape with a SiwA anchor carrying the account.
172 uid2, _ := seedUser(t, s)
173 proof2 := s.keys.mintCeremonyProof(uid2)
174 s.store.insertSiwaAnchor(uid2, "matrix-siwa-2")
175 if r := del(proof2, "/api/anchors/passkey/"+credID(uid2)); r.StatusCode != http.StatusOK {
176 t.Fatalf("last passkey with siwa anchor: want 200, got %d", r.StatusCode)
177 }
178 if r := del(proof2, "/api/anchors/siwa/matrix-siwa-2"); r.StatusCode != http.StatusConflict {
179 t.Fatalf("last siwa anchor: want 409, got %d", r.StatusCode)
180 }
181
182 // An anchor may go freely while a passkey remains.
183 uid3, _ := seedUser(t, s)
184 proof3 := s.keys.mintCeremonyProof(uid3)
185 s.store.insertEmailAnchor(uid3, "matrix-email-3")
186 if r := del(proof3, "/api/anchors/email/matrix-email-3"); r.StatusCode != http.StatusOK {
187 t.Fatalf("email anchor beside a passkey: want 200, got %d", r.StatusCode)
188 }
189
190 // The legacy credentials endpoint enforces the SAME rule — one guard (in
191 // the store), two doors.
192 uid4, _ := seedUser(t, s)
193 proof4 := s.keys.mintCeremonyProof(uid4)
194 if r := del(proof4, "/api/credentials/"+credID(uid4)); r.StatusCode != http.StatusConflict {
195 t.Fatalf("legacy delete, last passkey, no anchors: want 409, got %d", r.StatusCode)
196 }
197 s.store.insertEmailAnchor(uid4, "matrix-email-4")
198 if r := del(proof4, "/api/credentials/"+credID(uid4)); r.StatusCode != http.StatusOK {
199 t.Fatalf("legacy delete, last passkey with email anchor: want 200, got %d", r.StatusCode)
200 }
201}
202
203func TestAnchorDeleteAuthGating(t *testing.T) {
204 ts, s := newTestServer(t)
205 uid, tok := seedUser(t, s)
206 s.store.insertEmailAnchor(uid, "email-hash-1")
207
208 // A link token must never remove a sign-in method (it reduces access) —
209 // same asymmetry as devices/credentials.
210 if r := do(t, "DELETE", ts.URL+"/api/anchors/email/email-hash-1", tok, nil, nil); r.StatusCode != http.StatusUnauthorized {
211 t.Fatalf("delete with link token: want 401, got %d", r.StatusCode)
212 }
213 if r := do(t, "DELETE", ts.URL+"/api/anchors/email/email-hash-1", "", nil, nil); r.StatusCode != http.StatusUnauthorized {
214 t.Fatalf("delete with no auth: want 401, got %d", r.StatusCode)
215 }
216 // Another user's fresh proof can't touch it.
217 other, _ := seedUser(t, s)
218 otherProof := s.keys.mintCeremonyProof(other)
219 if r := do(t, "DELETE", ts.URL+"/api/anchors/email/email-hash-1", "", map[string]string{"X-Ceremony-Proof": otherProof}, nil); r.StatusCode != http.StatusNotFound {
220 t.Fatalf("cross-user proof: want 404, got %d", r.StatusCode)
221 }
222 proof := s.keys.mintCeremonyProof(uid)
223 // An unknown kind is a 400 with a message, not a fall-through.
224 if r := do(t, "DELETE", ts.URL+"/api/anchors/carrier-pigeon/x", "", map[string]string{"X-Ceremony-Proof": proof}, nil); r.StatusCode != http.StatusBadRequest {
225 t.Fatalf("unknown kind: want 400, got %d", r.StatusCode)
226 }
227 // An unknown id is a 404.
228 if r := do(t, "DELETE", ts.URL+"/api/anchors/email/nope", "", map[string]string{"X-Ceremony-Proof": proof}, nil); r.StatusCode != http.StatusNotFound {
229 t.Fatalf("unknown id: want 404, got %d", r.StatusCode)
230 }
231 // The owner's fresh proof works.
232 if r := do(t, "DELETE", ts.URL+"/api/anchors/email/email-hash-1", "", map[string]string{"X-Ceremony-Proof": proof}, nil); r.StatusCode != http.StatusOK {
233 t.Fatalf("delete with fresh proof: want 200, got %d", r.StatusCode)
234 }
235}
236