package main // The vaultStore contract, tested against the interface — not any one backend. // A future s3Store passes by adding one TestXxxVaultStore entry point that // calls testVaultStore with its own constructor; nothing here may reach into // backend internals (versions are asserted opaque: only "0" and round-tripped // values are ever compared). import ( "bytes" "encoding/base64" "errors" "testing" "github.com/go-webauthn/webauthn/webauthn" ) func TestSQLiteVaultStore(t *testing.T) { testVaultStore(t, func(t *testing.T) vaultStore { st, err := openSQLiteStore(t.TempDir() + "/store.db") if err != nil { t.Fatalf("openSQLiteStore: %v", err) } return st }) } // SQLite-specific: a version string the backend can't interpret at all (never // one of ours) is errBadVersion, which the handler maps to 400 — distinct from // a mismatch, which is a conflict. Other backends may treat garbage as a plain // mismatch, so this isn't part of the shared contract. func TestSQLitePutBlobBadVersion(t *testing.T) { st, err := openSQLiteStore(t.TempDir() + "/store.db") if err != nil { t.Fatalf("openSQLiteStore: %v", err) } for _, bad := range []string{"abc", "-1", ""} { if _, _, err := st.putBlob("u", []byte("x"), bad); !errors.Is(err, errBadVersion) { t.Fatalf("putBlob(ifMatch=%q): want errBadVersion, got %v", bad, err) } } } // SQLite-specific (backdating needs SQL): last_seen bumps at most once per // lastSeenGranularity — an hour-stale value stays put, a two-day-stale one // refreshes. Guards the no-write-per-read property an object store depends on. func TestSQLiteLastSeenCoarse(t *testing.T) { st, err := openSQLiteStore(t.TempDir() + "/store.db") if err != nil { t.Fatalf("openSQLiteStore: %v", err) } if err := st.createUser("u1"); err != nil { t.Fatalf("createUser: %v", err) } tok, err := st.createLinkToken("u1") if err != nil { t.Fatalf("createLinkToken: %v", err) } lastSeen := func() int64 { var v int64 if err := st.sql.QueryRow("SELECT last_seen FROM link_tokens WHERE token_hash = ?", hashToken(tok)).Scan(&v); err != nil { t.Fatalf("read last_seen: %v", err) } return v } backdate := func(by int64) int64 { v := lastSeen() - by if _, err := st.sql.Exec("UPDATE link_tokens SET last_seen = ? WHERE token_hash = ?", v, hashToken(tok)); err != nil { t.Fatalf("backdate: %v", err) } return v } fresh := backdate(3600) // an hour stale: within granularity, must not rewrite if _, ok := st.linkTokenUser(hashToken(tok)); !ok { t.Fatalf("linkTokenUser: want ok") } if got := lastSeen(); got != fresh { t.Fatalf("hour-stale last_seen rewritten: want %d, got %d", fresh, got) } stale := backdate(2 * lastSeenGranularity) // two days stale: must refresh if _, ok := st.linkTokenUser(hashToken(tok)); !ok { t.Fatalf("linkTokenUser: want ok") } if got := lastSeen(); got <= stale { t.Fatalf("two-day-stale last_seen not refreshed: still %d", got) } } // testVaultStore runs the shared contract suite against one backend. func testVaultStore(t *testing.T, newStore func(t *testing.T) vaultStore) { mkCred := func(id string) *webauthn.Credential { return &webauthn.Credential{ ID: []byte(id), PublicKey: []byte("pk-" + id), Authenticator: webauthn.Authenticator{ AAGUID: make([]byte, 16), SignCount: 1, }, } } t.Run("BlobCAS", func(t *testing.T) { st := newStore(t) if _, _, found, err := st.getBlob("u1"); err != nil || found { t.Fatalf("getBlob empty: want !found, got found=%v err=%v", found, err) } // A CAS write against a version that doesn't exist yet must lose, and // report the "0" sentinel as the current version. if cur, conflict, err := st.putBlob("u1", []byte("x"), "5"); err != nil || !conflict || cur != "0" { t.Fatalf("putBlob on empty with stale version: want conflict + current \"0\", got (%q, %v, %v)", cur, conflict, err) } // "0" is create-only: it claims "no blob yet". v1, conflict, err := st.putBlob("u1", []byte("one"), "0") if err != nil || conflict { t.Fatalf("create putBlob: %v conflict=%v", err, conflict) } if v1 == "" || v1 == "0" { t.Fatalf("new version must be a non-sentinel opaque string, got %q", v1) } data, got, found, err := st.getBlob("u1") if err != nil || !found || !bytes.Equal(data, []byte("one")) || got != v1 { t.Fatalf("getBlob after create: data=%q version=%q found=%v err=%v (want version %q)", data, got, found, err, v1) } // Once a blob exists, "0" must lose (create-only), naming the current version. if cur, conflict, _ := st.putBlob("u1", []byte("race"), "0"); !conflict || cur != v1 { t.Fatalf("second create: want conflict + current %q, got (%q, %v)", v1, cur, conflict) } // So must any stale non-current version. v2, conflict, err := st.putBlob("u1", []byte("two"), v1) if err != nil || conflict { t.Fatalf("CAS update: %v conflict=%v", err, conflict) } if cur, conflict, _ := st.putBlob("u1", []byte("stale"), v1); !conflict || cur != v2 { t.Fatalf("stale CAS: want conflict + current %q, got (%q, %v)", v2, cur, conflict) } // The loser's write never landed. if data, _, _, _ := st.getBlob("u1"); !bytes.Equal(data, []byte("two")) { t.Fatalf("conflicted write must not land, got %q", data) } // Users are isolated: u1's blob says nothing about u2's. if _, _, found, _ := st.getBlob("u2"); found { t.Fatalf("getBlob other user: want !found") } }) t.Run("BlobKeepLastN", func(t *testing.T) { st := newStore(t) version := "0" var wrote []string // every version handed out, oldest first for i := 0; i < blobKeepVersions+2; i++ { v, conflict, err := st.putBlob("u1", []byte{byte(i)}, version) if err != nil || conflict { t.Fatalf("put %d: %v conflict=%v", i, err, conflict) } version = v wrote = append(wrote, v) } versions, err := st.listBlobVersions("u1") if err != nil { t.Fatalf("listBlobVersions: %v", err) } if len(versions) != blobKeepVersions { t.Fatalf("want %d versions retained, got %d", blobKeepVersions, len(versions)) } // Newest first, and exactly the last blobKeepVersions writes survive. for i, v := range versions { if want := wrote[len(wrote)-1-i]; v != want { t.Fatalf("versions[%d]: want %q, got %q", i, want, v) } } }) t.Run("BlobDeleteIsPrefixDelete", func(t *testing.T) { st := newStore(t) version := "0" for i := 0; i < 3; i++ { v, _, err := st.putBlob("u1", []byte("v"), version) if err != nil { t.Fatalf("put: %v", err) } version = v } if err := st.deleteBlob("u1"); err != nil { t.Fatalf("deleteBlob: %v", err) } // Every version is gone — history included. if _, _, found, _ := st.getBlob("u1"); found { t.Fatalf("blob still readable after delete") } if versions, _ := st.listBlobVersions("u1"); len(versions) != 0 { t.Fatalf("history survives delete: %v", versions) } // And the user is back at the "0" sentinel: create-only works again. if _, conflict, err := st.putBlob("u1", []byte("fresh"), "0"); err != nil || conflict { t.Fatalf("re-create after delete: %v conflict=%v", err, conflict) } }) t.Run("Credentials", func(t *testing.T) { st := newStore(t) if err := st.createUser("u1"); err != nil { t.Fatalf("createUser: %v", err) } if creds, err := st.credentialsFor("u1"); err != nil || len(creds) != 0 { t.Fatalf("fresh user credentials: want none, got %d err=%v", len(creds), err) } c1, c2 := mkCred("cred-one"), mkCred("cred-two") if err := st.insertCredential("u1", c1); err != nil { t.Fatalf("insertCredential: %v", err) } if err := st.insertCredential("u1", c2); err != nil { t.Fatalf("insertCredential: %v", err) } creds, err := st.credentialsFor("u1") if err != nil || len(creds) != 2 { t.Fatalf("credentialsFor: want 2, got %d err=%v", len(creds), err) } // Enrolment order, with public keys intact. if !bytes.Equal(creds[0].ID, c1.ID) || !bytes.Equal(creds[1].ID, c2.ID) || !bytes.Equal(creds[0].PublicKey, c1.PublicKey) { t.Fatalf("credentials round-trip mismatch: %+v", creds) } // updateCredential persists post-ceremony state, located by credential id. c1.Authenticator.SignCount = 42 if err := st.updateCredential(c1); err != nil { t.Fatalf("updateCredential: %v", err) } creds, _ = st.credentialsFor("u1") if creds[0].Authenticator.SignCount != 42 { t.Fatalf("sign count not persisted: %d", creds[0].Authenticator.SignCount) } }) t.Run("CredentialPointer", func(t *testing.T) { st := newStore(t) st.createUser("u1") st.insertCredential("u1", mkCred("cred-one")) credID := b64CredID("cred-one") if uid, ok := st.credentialUser(credID); !ok || uid != "u1" { t.Fatalf("credentialUser: want (u1, true), got (%q, %v)", uid, ok) } if _, ok := st.credentialUser("nope"); ok { t.Fatalf("credentialUser unknown id: want !ok") } }) t.Run("DeleteCredentialLastGuard", func(t *testing.T) { st := newStore(t) st.createUser("u1") st.insertCredential("u1", mkCred("cred-one")) st.insertCredential("u1", mkCred("cred-two")) one, two := b64CredID("cred-one"), b64CredID("cred-two") // Unknown id — and someone else's id — is (false, false). if deleted, last, err := st.deleteCredential("u1", "nope"); deleted || last || err != nil { t.Fatalf("delete unknown: want (false,false), got (%v,%v,%v)", deleted, last, err) } if deleted, last, err := st.deleteCredential("u2", one); deleted || last || err != nil { t.Fatalf("delete cross-user: want (false,false), got (%v,%v,%v)", deleted, last, err) } // With two enrolled, one may go… if deleted, last, err := st.deleteCredential("u1", two); !deleted || last || err != nil { t.Fatalf("delete second: want (true,false), got (%v,%v,%v)", deleted, last, err) } // …and the pointer must stop resolving (never authoritative). if _, ok := st.credentialUser(two); ok { t.Fatalf("revoked credential pointer still resolves") } // The LAST credential is refused, and nothing is deleted. if deleted, last, err := st.deleteCredential("u1", one); deleted || !last || err != nil { t.Fatalf("delete last: want (false,true), got (%v,%v,%v)", deleted, last, err) } if creds, _ := st.credentialsFor("u1"); len(creds) != 1 { t.Fatalf("last-guard must leave the credential in place, got %d", len(creds)) } }) t.Run("LinkTokens", func(t *testing.T) { st := newStore(t) st.createUser("u1") tok1, err := st.createLinkToken("u1") if err != nil || tok1 == "" { t.Fatalf("createLinkToken: %q %v", tok1, err) } tok2, _ := st.createLinkToken("u1") // Resolution is by HASH — the tokens/ pointer. if uid, ok := st.linkTokenUser(hashToken(tok1)); !ok || uid != "u1" { t.Fatalf("linkTokenUser: want (u1, true), got (%q, %v)", uid, ok) } // The plaintext itself must NOT be a key (the store holds only hashes). if _, ok := st.linkTokenUser(tok1); ok { t.Fatalf("plaintext token resolved — store must be keyed by hash") } if _, ok := st.linkTokenUser(hashToken("wrong")); ok { t.Fatalf("unknown token hash resolved") } devices, err := st.listDevices("u1") if err != nil || len(devices) != 2 { t.Fatalf("listDevices: want 2, got %d err=%v", len(devices), err) } for _, d := range devices { if d.ID == "" || d.CreatedAt == 0 || d.LastSeen == 0 { t.Fatalf("device metadata incomplete: %+v", d) } // A device id names a token without deriving from it. if d.ID == tok1 || d.ID == tok2 || d.ID == hashToken(tok1) || d.ID == hashToken(tok2) { t.Fatalf("device id derived from token value") } } if id := st.linkTokenID(hashToken(tok1)); id != devices[0].ID && id != devices[1].ID { t.Fatalf("linkTokenID: %q not among device ids", id) } if st.linkTokenID(hashToken("wrong")) != "" { t.Fatalf("linkTokenID unknown hash: want \"\"") } }) t.Run("DeleteDeviceRevokes", func(t *testing.T) { st := newStore(t) st.createUser("u1") tok, _ := st.createLinkToken("u1") id := st.linkTokenID(hashToken(tok)) // Someone else can't revoke it. if ok, err := st.deleteDevice("u2", id); ok || err != nil { t.Fatalf("cross-user deleteDevice: want false, got %v err=%v", ok, err) } if ok, err := st.deleteDevice("u1", "nope"); ok || err != nil { t.Fatalf("unknown deleteDevice: want false, got %v err=%v", ok, err) } if ok, err := st.deleteDevice("u1", id); !ok || err != nil { t.Fatalf("deleteDevice: want true, got %v err=%v", ok, err) } // Revocation must be effective at resolution time — a stale pointer left // behind by a backend must deny, not resolve. if _, ok := st.linkTokenUser(hashToken(tok)); ok { t.Fatalf("revoked token still resolves") } if devices, _ := st.listDevices("u1"); len(devices) != 0 { t.Fatalf("revoked device still listed") } }) t.Run("AnchorListing", func(t *testing.T) { st := newStore(t) st.createUser("u1") st.insertCredential("u1", mkCred("cred-one")) if conflict, err := st.insertSiwaAnchor("u1", "subhash-1"); conflict || err != nil { t.Fatalf("insertSiwaAnchor: conflict=%v err=%v", conflict, err) } if conflict, err := st.insertEmailAnchor("u1", "emailhash-1"); conflict || err != nil { t.Fatalf("insertEmailAnchor: conflict=%v err=%v", conflict, err) } meta, err := st.listCredentialMeta("u1") if err != nil || len(meta) != 1 { t.Fatalf("listCredentialMeta: want 1, got %d err=%v", len(meta), err) } if meta[0].ID != b64CredID("cred-one") || meta[0].CreatedAt == 0 { t.Fatalf("credential meta: %+v", meta[0]) } siwa, err := st.listSiwaAnchors("u1") if err != nil || len(siwa) != 1 || siwa[0].ID != "subhash-1" || siwa[0].CreatedAt == 0 { t.Fatalf("listSiwaAnchors: %+v err=%v", siwa, err) } email, err := st.listEmailAnchors("u1") if err != nil || len(email) != 1 || email[0].ID != "emailhash-1" || email[0].CreatedAt == 0 { t.Fatalf("listEmailAnchors: %+v err=%v", email, err) } // Listings are per-user, empty (not an error) for an unknown one. if rows, err := st.listSiwaAnchors("u2"); err != nil || len(rows) != 0 { t.Fatalf("other user's siwa anchors: %+v err=%v", rows, err) } }) t.Run("DeleteAnchorLastMethodGuard", func(t *testing.T) { st := newStore(t) st.createUser("u1") st.insertCredential("u1", mkCred("cred-one")) st.insertSiwaAnchor("u1", "subhash-1") st.insertEmailAnchor("u1", "emailhash-1") // Unknown — and someone else's — hash is (false, false). if deleted, last, err := st.deleteSiwaAnchor("u1", "nope"); deleted || last || err != nil { t.Fatalf("delete unknown siwa: want (false,false), got (%v,%v,%v)", deleted, last, err) } if deleted, last, err := st.deleteSiwaAnchor("u2", "subhash-1"); deleted || last || err != nil { t.Fatalf("delete cross-user siwa: want (false,false), got (%v,%v,%v)", deleted, last, err) } // Three methods: the siwa anchor may go… if deleted, last, err := st.deleteSiwaAnchor("u1", "subhash-1"); !deleted || last || err != nil { t.Fatalf("delete siwa: want (true,false), got (%v,%v,%v)", deleted, last, err) } // …and stops resolving (a stale pointer must deny, never resolve). if _, ok := st.siwaUser("subhash-1"); ok { t.Fatalf("removed siwa anchor still resolves") } // A removed identifier is genuinely free: the same sub may anchor a // DIFFERENT account afterwards (unlink-then-relink-elsewhere). st.createUser("u2") if conflict, err := st.insertSiwaAnchor("u2", "subhash-1"); conflict || err != nil { t.Fatalf("re-anchor removed sub: conflict=%v err=%v", conflict, err) } if uid, ok := st.siwaUser("subhash-1"); !ok || uid != "u2" { t.Fatalf("re-anchored sub: want (u2, true), got (%q, %v)", uid, ok) } // Two methods left on u1 (passkey + email): the email anchor may go… if deleted, last, err := st.deleteEmailAnchor("u1", "emailhash-1"); !deleted || last || err != nil { t.Fatalf("delete email: want (true,false), got (%v,%v,%v)", deleted, last, err) } if _, ok := st.emailUser("emailhash-1"); ok { t.Fatalf("removed email anchor still resolves") } // …leaving the passkey as the LAST method, which is refused. if deleted, last, err := st.deleteCredential("u1", b64CredID("cred-one")); deleted || !last || err != nil { t.Fatalf("delete last method: want (false,true), got (%v,%v,%v)", deleted, last, err) } }) t.Run("LastPasskeyMayGoWhenAnchored", func(t *testing.T) { // The rule evolution (#1089): last-PASSKEY became last-METHOD — an // account holding an email (or SiwA) anchor may shed its final // passkey; only a removal that would leave ZERO ways in is refused. st := newStore(t) st.createUser("u1") st.insertCredential("u1", mkCred("cred-one")) st.insertEmailAnchor("u1", "emailhash-1") if deleted, last, err := st.deleteCredential("u1", b64CredID("cred-one")); !deleted || last || err != nil { t.Fatalf("delete last passkey with email anchor: want (true,false), got (%v,%v,%v)", deleted, last, err) } if _, ok := st.credentialUser(b64CredID("cred-one")); ok { t.Fatalf("removed credential still resolves") } // The email anchor is now the last method: refused. if deleted, last, err := st.deleteEmailAnchor("u1", "emailhash-1"); deleted || !last || err != nil { t.Fatalf("delete last email anchor: want (false,true), got (%v,%v,%v)", deleted, last, err) } // A siwa anchor as the only method is refused the same way. st.createUser("u2") st.insertSiwaAnchor("u2", "subhash-2") if deleted, last, err := st.deleteSiwaAnchor("u2", "subhash-2"); deleted || !last || err != nil { t.Fatalf("delete last siwa anchor: want (false,true), got (%v,%v,%v)", deleted, last, err) } }) t.Run("OidcAnchors", func(t *testing.T) { // The provider-table anchors (#1112): the siwa semantics with the // provider name beside the hash — provider-scoped resolution and // listing, the same idempotence/conflict rules, the same last-method // guard, the same re-anchor-after-removal freedom. st := newStore(t) st.createUser("u1") st.insertCredential("u1", mkCred("cred-one")) if conflict, err := st.insertOidcAnchor("u1", "google", "ghash-1"); conflict || err != nil { t.Fatalf("insertOidcAnchor: conflict=%v err=%v", conflict, err) } if uid, ok := st.oidcUser("google", "ghash-1"); !ok || uid != "u1" { t.Fatalf("oidcUser: want (u1, true), got (%q, %v)", uid, ok) } // The SAME hash under the other provider is a different anchor // entirely — nothing resolves, and claiming it conflicts with no one. if _, ok := st.oidcUser("microsoft", "ghash-1"); ok { t.Fatalf("hash resolves across providers") } // Idempotent for the same user; conflict for a different one. if conflict, err := st.insertOidcAnchor("u1", "google", "ghash-1"); conflict || err != nil { t.Fatalf("re-insert: conflict=%v err=%v", conflict, err) } st.createUser("u2") if conflict, err := st.insertOidcAnchor("u2", "google", "ghash-1"); !conflict || err != nil { t.Fatalf("cross-user insert: want conflict, got conflict=%v err=%v", conflict, err) } // Listing is provider-scoped, (id, created_at) shaped. st.insertOidcAnchor("u1", "microsoft", "mhash-1") g, err := st.listOidcAnchors("u1", "google") if err != nil || len(g) != 1 || g[0].ID != "ghash-1" || g[0].CreatedAt == 0 { t.Fatalf("listOidcAnchors google: %+v err=%v", g, err) } if m, err := st.listOidcAnchors("u1", "microsoft"); err != nil || len(m) != 1 || m[0].ID != "mhash-1" { t.Fatalf("listOidcAnchors microsoft: %+v err=%v", m, err) } if rows, err := st.listOidcAnchors("u2", "google"); err != nil || len(rows) != 0 { t.Fatalf("other user's oidc anchors: %+v err=%v", rows, err) } // Unknown and cross-user (and cross-provider) deletes are (false,false). if deleted, last, err := st.deleteOidcAnchor("u1", "google", "nope"); deleted || last || err != nil { t.Fatalf("delete unknown: got (%v,%v,%v)", deleted, last, err) } if deleted, last, err := st.deleteOidcAnchor("u2", "google", "ghash-1"); deleted || last || err != nil { t.Fatalf("delete cross-user: got (%v,%v,%v)", deleted, last, err) } if deleted, last, err := st.deleteOidcAnchor("u1", "microsoft", "ghash-1"); deleted || last || err != nil { t.Fatalf("delete cross-provider: got (%v,%v,%v)", deleted, last, err) } // Three methods on u1 (passkey + google + microsoft): the google // anchor may go, stops resolving, and its identifier is genuinely // free — the same (provider, sub) may anchor a different account. if deleted, last, err := st.deleteOidcAnchor("u1", "google", "ghash-1"); !deleted || last || err != nil { t.Fatalf("delete google anchor: got (%v,%v,%v)", deleted, last, err) } if _, ok := st.oidcUser("google", "ghash-1"); ok { t.Fatalf("removed oidc anchor still resolves") } if conflict, err := st.insertOidcAnchor("u2", "google", "ghash-1"); conflict || err != nil { t.Fatalf("re-anchor removed hash: conflict=%v err=%v", conflict, err) } if uid, ok := st.oidcUser("google", "ghash-1"); !ok || uid != "u2" { t.Fatalf("re-anchored hash: want (u2, true), got (%q, %v)", uid, ok) } // The guard counts oidc anchors: u1's passkey may go while the // microsoft anchor holds the account, and then that anchor — the // last way in — is refused. if deleted, last, err := st.deleteCredential("u1", b64CredID("cred-one")); !deleted || last || err != nil { t.Fatalf("shed passkey beside oidc anchor: got (%v,%v,%v)", deleted, last, err) } if deleted, last, err := st.deleteOidcAnchor("u1", "microsoft", "mhash-1"); deleted || !last || err != nil { t.Fatalf("delete last oidc anchor: want (false,true), got (%v,%v,%v)", deleted, last, err) } }) t.Run("MergeAccounts", func(t *testing.T) { // The account merge (#1098): everything that signs in moves from the // absorbed account to the survivor, resolution follows, and the // absorbed account's listings empty out. The blob belt, the // same-account refusal, and the zero-count retry are contract too. st := newStore(t) st.createUser("u1") st.insertCredential("u1", mkCred("cred-surv")) survTok, err := st.createLinkToken("u1") if err != nil { t.Fatalf("createLinkToken survivor: %v", err) } if _, conflict, err := st.putBlob("u1", []byte("survivor-vault"), "0"); err != nil || conflict { t.Fatalf("survivor putBlob: %v conflict=%v", err, conflict) } st.createUser("u2") st.insertCredential("u2", mkCred("cred-abs")) absTok1, _ := st.createLinkToken("u2") absTok2, _ := st.createLinkToken("u2") st.insertSiwaAnchor("u2", "merge-siwa") st.insertEmailAnchor("u2", "merge-email") st.insertOidcAnchor("u2", "google", "merge-google") // Belt: an absorbed account still holding a blob refuses, moving nothing. if _, conflict, err := st.putBlob("u2", []byte("not-yet-moved"), "0"); err != nil || conflict { t.Fatalf("absorbed putBlob: %v conflict=%v", err, conflict) } if _, err := st.mergeAccounts("u1", "u2"); !errors.Is(err, errAbsorbedHasBlob) { t.Fatalf("merge with blob present: want errAbsorbedHasBlob, got %v", err) } if uid, ok := st.siwaUser("merge-siwa"); !ok || uid != "u2" { t.Fatalf("refused merge moved an anchor: (%q, %v)", uid, ok) } // Same-account merge is a hard error (it would retire the account itself). if _, err := st.mergeAccounts("u1", "u1"); err == nil { t.Fatalf("same-account merge: want error") } // The client-first ordering: absorbed blob deleted, then merge. if err := st.deleteBlob("u2"); err != nil { t.Fatalf("deleteBlob: %v", err) } moved, err := st.mergeAccounts("u1", "u2") if err != nil { t.Fatalf("mergeAccounts: %v", err) } if moved.Anchors != 3 || moved.Passkeys != 1 || moved.Devices != 2 { t.Fatalf("moved counts: want {3 1 2}, got %+v", moved) } // Every identifier now resolves to the survivor… if uid, ok := st.siwaUser("merge-siwa"); !ok || uid != "u1" { t.Fatalf("merged siwa anchor: want (u1, true), got (%q, %v)", uid, ok) } if uid, ok := st.emailUser("merge-email"); !ok || uid != "u1" { t.Fatalf("merged email anchor: want (u1, true), got (%q, %v)", uid, ok) } if uid, ok := st.oidcUser("google", "merge-google"); !ok || uid != "u1" { t.Fatalf("merged oidc anchor: want (u1, true), got (%q, %v)", uid, ok) } if uid, ok := st.credentialUser(b64CredID("cred-abs")); !ok || uid != "u1" { t.Fatalf("merged credential: want (u1, true), got (%q, %v)", uid, ok) } // …including the absorbed devices' link tokens (they keep working, // now signed into the survivor). for _, tok := range []string{absTok1, absTok2} { if uid, ok := st.linkTokenUser(hashToken(tok)); !ok || uid != "u1" { t.Fatalf("merged link token: want (u1, true), got (%q, %v)", uid, ok) } } // The survivor's own token and blob are untouched. if uid, ok := st.linkTokenUser(hashToken(survTok)); !ok || uid != "u1" { t.Fatalf("survivor link token: want (u1, true), got (%q, %v)", uid, ok) } if data, _, found, _ := st.getBlob("u1"); !found || !bytes.Equal(data, []byte("survivor-vault")) { t.Fatalf("survivor blob touched by merge: found=%v data=%q", found, data) } // The survivor lists everything; creation times ride along. if creds, _ := st.credentialsFor("u1"); len(creds) != 2 { t.Fatalf("survivor credentials: want 2, got %d", len(creds)) } if devices, _ := st.listDevices("u1"); len(devices) != 3 { t.Fatalf("survivor devices: want 3, got %d", len(devices)) } if siwa, _ := st.listSiwaAnchors("u1"); len(siwa) != 1 || siwa[0].ID != "merge-siwa" || siwa[0].CreatedAt == 0 { t.Fatalf("survivor siwa listing: %+v", siwa) } // The absorbed account's listings are empty. if creds, _ := st.credentialsFor("u2"); len(creds) != 0 { t.Fatalf("absorbed credentials remain: %d", len(creds)) } if devices, _ := st.listDevices("u2"); len(devices) != 0 { t.Fatalf("absorbed devices remain: %d", len(devices)) } if rows, _ := st.listEmailAnchors("u2"); len(rows) != 0 { t.Fatalf("absorbed email anchors remain: %+v", rows) } // A retry (the absorb proof outlives the deleted account) is a // zero-count success, never an error. if again, err := st.mergeAccounts("u1", "u2"); err != nil || again != (mergeMoved{}) { t.Fatalf("retried merge: want zero counts, got %+v err=%v", again, err) } }) // Ceremony proofs deliberately have no contract entry: they left the store // entirely — stateless HMAC tokens, tested in stateless_test.go. } // b64CredID renders a raw credential id the way stores key it (base64url, // matching webauthn.Credential JSON round-trips). func b64CredID(raw string) string { return base64.RawURLEncoding.EncodeToString([]byte(raw)) }