| 1 | package 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 | |
| 9 | import ( |
| 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 | |
| 20 | type anchorRow struct { |
| 21 | Kind string `json:"kind"` |
| 22 | ID string `json:"id"` |
| 23 | CreatedAt int64 `json:"created_at"` |
| 24 | } |
| 25 | |
| 26 | func 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 | |
| 41 | func 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 | |
| 99 | func 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. |
| 139 | func 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 | |
| 203 | func 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 | |