package main // 🤖 Account-merge tests (merge.go, issue #1098): the both-sides-proofs gate, // the empty-absorbed-vault precondition, and the move itself — everything the // absorbed account could sign in with ends up on the survivor, including the // absorbed devices' link tokens. Store-level move semantics live in the // vaultStore contract suite (store_test.go MergeAccounts); these tests cover // the HTTP contract. import ( "encoding/json" "net/http" "testing" "time" ) func mergeBody(t *testing.T, absorbProof string) []byte { t.Helper() b, err := json.Marshal(map[string]string{"absorb_proof": absorbProof}) if err != nil { t.Fatalf("marshal: %v", err) } return b } func TestAccountMergeHappyPath(t *testing.T) { ts, s := newTestServer(t) survivor, survTok := seedUser(t, s) absorbed, absTok := seedUser(t, s) s.store.insertSiwaAnchor(absorbed, "merge-siwa") s.store.insertEmailAnchor(absorbed, "merge-email") s.store.insertOidcAnchor(absorbed, "google", "merge-google") // The survivor's vault already holds the client-side union (the client // merged and PUT before calling this endpoint). if r := do(t, "PUT", ts.URL+"/api/blob", survTok, map[string]string{"If-Match": `"0"`}, []byte("merged-vault")); r.StatusCode != http.StatusOK { t.Fatalf("survivor PUT blob: want 200, got %d", r.StatusCode) } survProof := s.keys.mintCeremonyProof(survivor) absProof := s.keys.mintCeremonyProof(absorbed) r := do(t, "POST", ts.URL+"/api/account/merge", survTok, map[string]string{"X-Ceremony-Proof": survProof}, mergeBody(t, absProof)) if r.StatusCode != http.StatusOK { t.Fatalf("merge: want 200, got %d", r.StatusCode) } var out struct { Merged bool `json:"merged"` Moved mergeMoved `json:"moved"` } if err := json.NewDecoder(r.Body).Decode(&out); err != nil { t.Fatalf("bad json: %v", err) } if !out.Merged || out.Moved.Anchors != 3 || out.Moved.Passkeys != 1 || out.Moved.Devices != 1 { t.Fatalf("merge response: %+v", out) } // The anchors listing shows every way in under the survivor: two // passkeys plus the three moved anchors. kinds := map[string]int{} for _, a := range listAnchors(t, ts.URL, survTok) { kinds[a.Kind]++ } if kinds["passkey"] != 2 || kinds["siwa"] != 1 || kinds["email"] != 1 || kinds["google"] != 1 { t.Fatalf("post-merge anchors listing: %+v", kinds) } // The absorbed device's link token now signs into the survivor — it sees // the merged vault. r = do(t, "GET", ts.URL+"/api/blob", absTok, nil, nil) if r.StatusCode != http.StatusOK { t.Fatalf("absorbed token GET blob: want 200, got %d", r.StatusCode) } if uid, ok := s.store.linkTokenUser(hashToken(absTok)); !ok || uid != survivor { t.Fatalf("absorbed token resolves to %q (ok=%v), want survivor", uid, ok) } // The absorbed account is gone (SQLite backs the test server). if s.store.(*sqliteStore).userExists(absorbed) { t.Fatalf("absorbed user row survives the merge") } // A retry within the proofs' lifetime is a zero-count success. r = do(t, "POST", ts.URL+"/api/account/merge", survTok, map[string]string{"X-Ceremony-Proof": survProof}, mergeBody(t, absProof)) if r.StatusCode != http.StatusOK { t.Fatalf("retried merge: want 200, got %d", r.StatusCode) } if err := json.NewDecoder(r.Body).Decode(&out); err != nil { t.Fatalf("bad json: %v", err) } if !out.Merged || out.Moved != (mergeMoved{}) { t.Fatalf("retried merge response: %+v", out) } } // The precondition: the client moves the vault contents over (and deletes the // absorbed blob) BEFORE the merge — an absorbed account still holding a blob // is refused with a plain message, and nothing moves. func TestAccountMergeAbsorbedBlobStillPresent(t *testing.T) { ts, s := newTestServer(t) survivor, survTok := seedUser(t, s) absorbed, absTok := seedUser(t, s) s.store.insertEmailAnchor(absorbed, "still-here") if r := do(t, "PUT", ts.URL+"/api/blob", absTok, map[string]string{"If-Match": `"0"`}, []byte("unmoved-vault")); r.StatusCode != http.StatusOK { t.Fatalf("absorbed PUT blob: want 200, got %d", r.StatusCode) } survProof := s.keys.mintCeremonyProof(survivor) absProof := s.keys.mintCeremonyProof(absorbed) r := do(t, "POST", ts.URL+"/api/account/merge", survTok, map[string]string{"X-Ceremony-Proof": survProof}, mergeBody(t, absProof)) if r.StatusCode != http.StatusConflict { t.Fatalf("merge with absorbed blob: want 409, got %d", r.StatusCode) } if uid, ok := s.store.emailUser("still-here"); !ok || uid != absorbed { t.Fatalf("refused merge moved an anchor: (%q, %v)", uid, ok) } // Once the client deletes the absorbed blob (the ceremony-gated path), // the same call goes through. if r := do(t, "DELETE", ts.URL+"/api/blob", "", map[string]string{"X-Ceremony-Proof": absProof}, nil); r.StatusCode != http.StatusOK { t.Fatalf("absorbed blob delete: want 200, got %d", r.StatusCode) } r = do(t, "POST", ts.URL+"/api/account/merge", survTok, map[string]string{"X-Ceremony-Proof": survProof}, mergeBody(t, absProof)) if r.StatusCode != http.StatusOK { t.Fatalf("merge after blob delete: want 200, got %d", r.StatusCode) } } func TestAccountMergeSameAccount(t *testing.T) { ts, s := newTestServer(t) survivor, survTok := seedUser(t, s) proof := s.keys.mintCeremonyProof(survivor) // An absorb proof for the bearer's own account is a 400, not a merge. r := do(t, "POST", ts.URL+"/api/account/merge", survTok, map[string]string{"X-Ceremony-Proof": proof}, mergeBody(t, proof)) if r.StatusCode != http.StatusBadRequest { t.Fatalf("same-account merge: want 400, got %d", r.StatusCode) } if !s.store.(*sqliteStore).userExists(survivor) { t.Fatalf("same-account merge deleted the account") } } func TestAccountMergeAuthGating(t *testing.T) { ts, s := newTestServer(t) survivor, survTok := seedUser(t, s) absorbed, _ := seedUser(t, s) survProof := s.keys.mintCeremonyProof(survivor) absProof := s.keys.mintCeremonyProof(absorbed) // Both survivor-side headers are required: the link token names the // account, the fresh proof authorises the access change. if r := do(t, "POST", ts.URL+"/api/account/merge", "", map[string]string{"X-Ceremony-Proof": survProof}, mergeBody(t, absProof)); r.StatusCode != http.StatusUnauthorized { t.Fatalf("no bearer: want 401, got %d", r.StatusCode) } if r := do(t, "POST", ts.URL+"/api/account/merge", survTok, nil, mergeBody(t, absProof)); r.StatusCode != http.StatusUnauthorized { t.Fatalf("no ceremony proof: want 401, got %d", r.StatusCode) } // A proof for a DIFFERENT account than the bearer's is refused — it isn't // the survivor's ceremony. if r := do(t, "POST", ts.URL+"/api/account/merge", survTok, map[string]string{"X-Ceremony-Proof": absProof}, mergeBody(t, absProof)); r.StatusCode != http.StatusUnauthorized { t.Fatalf("mismatched proof: want 401, got %d", r.StatusCode) } // Stale proofs — either side — are refused. staleSurv := s.keys.mintCeremonyProofAt(survivor, time.Now().Add(-time.Minute)) if r := do(t, "POST", ts.URL+"/api/account/merge", survTok, map[string]string{"X-Ceremony-Proof": staleSurv}, mergeBody(t, absProof)); r.StatusCode != http.StatusUnauthorized { t.Fatalf("stale survivor proof: want 401, got %d", r.StatusCode) } staleAbs := s.keys.mintCeremonyProofAt(absorbed, time.Now().Add(-time.Minute)) if r := do(t, "POST", ts.URL+"/api/account/merge", survTok, map[string]string{"X-Ceremony-Proof": survProof}, mergeBody(t, staleAbs)); r.StatusCode != http.StatusUnauthorized { t.Fatalf("stale absorb proof: want 401, got %d", r.StatusCode) } if r := do(t, "POST", ts.URL+"/api/account/merge", survTok, map[string]string{"X-Ceremony-Proof": survProof}, mergeBody(t, "garbage")); r.StatusCode != http.StatusUnauthorized { t.Fatalf("garbage absorb proof: want 401, got %d", r.StatusCode) } // A missing/empty absorb_proof is a 400 (bad request, not bad auth). if r := do(t, "POST", ts.URL+"/api/account/merge", survTok, map[string]string{"X-Ceremony-Proof": survProof}, []byte(`{}`)); r.StatusCode != http.StatusBadRequest { t.Fatalf("missing absorb_proof: want 400, got %d", r.StatusCode) } // Nothing above moved anything. if creds, _ := s.store.credentialsFor(absorbed); len(creds) != 1 { t.Fatalf("refused merges moved credentials: %d", len(creds)) } } // The last-method guard interacts naturally with a merge: the survivor gains // the absorbed account's methods, so a passkey it couldn't shed before (its // only way in) may go afterwards — and the merged-in anchor then becomes the // last method itself. func TestAccountMergeLastMethodAfterMerge(t *testing.T) { ts, s := newTestServer(t) survivor, survTok := seedUser(t, s) absorbed, _ := seedUser(t, s) s.store.insertEmailAnchor(absorbed, "merged-email") survProof := s.keys.mintCeremonyProof(survivor) creds, _ := s.store.credentialsFor(survivor) survCredID := b64CredID(string(creds[0].ID)) // Pre-merge: the survivor's only method can't be removed. if r := do(t, "DELETE", ts.URL+"/api/anchors/passkey/"+survCredID, "", map[string]string{"X-Ceremony-Proof": survProof}, nil); r.StatusCode != http.StatusConflict { t.Fatalf("pre-merge last passkey: want 409, got %d", r.StatusCode) } absProof := s.keys.mintCeremonyProof(absorbed) if r := do(t, "POST", ts.URL+"/api/account/merge", survTok, map[string]string{"X-Ceremony-Proof": survProof}, mergeBody(t, absProof)); r.StatusCode != http.StatusOK { t.Fatalf("merge: want 200, got %d", r.StatusCode) } // Post-merge the account holds 2 passkeys + 1 email anchor: both passkeys // may go now… absCreds, _ := s.store.credentialsFor(survivor) for _, c := range absCreds { id := b64CredID(string(c.ID)) if r := do(t, "DELETE", ts.URL+"/api/anchors/passkey/"+id, "", map[string]string{"X-Ceremony-Proof": survProof}, nil); r.StatusCode != http.StatusOK { t.Fatalf("post-merge passkey delete: want 200, got %d", r.StatusCode) } } // …until the merged-in email anchor is the last way in. if r := do(t, "DELETE", ts.URL+"/api/anchors/email/merged-email", "", map[string]string{"X-Ceremony-Proof": survProof}, nil); r.StatusCode != http.StatusConflict { t.Fatalf("merged anchor as last method: want 409, got %d", r.StatusCode) } }