eleven / ocho Public

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

Plain git — no account needed to clone.

Download

Download this file

1package main
2
3// 🤖 Account-merge tests (merge.go, issue #1098): the both-sides-proofs gate,
4// the empty-absorbed-vault precondition, and the move itself — everything the
5// absorbed account could sign in with ends up on the survivor, including the
6// absorbed devices' link tokens. Store-level move semantics live in the
7// vaultStore contract suite (store_test.go MergeAccounts); these tests cover
8// the HTTP contract.
9
10import (
11 "encoding/json"
12 "net/http"
13 "testing"
14 "time"
15)
16
17func mergeBody(t *testing.T, absorbProof string) []byte {
18 t.Helper()
19 b, err := json.Marshal(map[string]string{"absorb_proof": absorbProof})
20 if err != nil {
21 t.Fatalf("marshal: %v", err)
22 }
23 return b
24}
25
26func TestAccountMergeHappyPath(t *testing.T) {
27 ts, s := newTestServer(t)
28 survivor, survTok := seedUser(t, s)
29 absorbed, absTok := seedUser(t, s)
30 s.store.insertSiwaAnchor(absorbed, "merge-siwa")
31 s.store.insertEmailAnchor(absorbed, "merge-email")
32 s.store.insertOidcAnchor(absorbed, "google", "merge-google")
33 // The survivor's vault already holds the client-side union (the client
34 // merged and PUT before calling this endpoint).
35 if r := do(t, "PUT", ts.URL+"/api/blob", survTok, map[string]string{"If-Match": `"0"`}, []byte("merged-vault")); r.StatusCode != http.StatusOK {
36 t.Fatalf("survivor PUT blob: want 200, got %d", r.StatusCode)
37 }
38
39 survProof := s.keys.mintCeremonyProof(survivor)
40 absProof := s.keys.mintCeremonyProof(absorbed)
41 r := do(t, "POST", ts.URL+"/api/account/merge", survTok,
42 map[string]string{"X-Ceremony-Proof": survProof}, mergeBody(t, absProof))
43 if r.StatusCode != http.StatusOK {
44 t.Fatalf("merge: want 200, got %d", r.StatusCode)
45 }
46 var out struct {
47 Merged bool `json:"merged"`
48 Moved mergeMoved `json:"moved"`
49 }
50 if err := json.NewDecoder(r.Body).Decode(&out); err != nil {
51 t.Fatalf("bad json: %v", err)
52 }
53 if !out.Merged || out.Moved.Anchors != 3 || out.Moved.Passkeys != 1 || out.Moved.Devices != 1 {
54 t.Fatalf("merge response: %+v", out)
55 }
56
57 // The anchors listing shows every way in under the survivor: two
58 // passkeys plus the three moved anchors.
59 kinds := map[string]int{}
60 for _, a := range listAnchors(t, ts.URL, survTok) {
61 kinds[a.Kind]++
62 }
63 if kinds["passkey"] != 2 || kinds["siwa"] != 1 || kinds["email"] != 1 || kinds["google"] != 1 {
64 t.Fatalf("post-merge anchors listing: %+v", kinds)
65 }
66 // The absorbed device's link token now signs into the survivor — it sees
67 // the merged vault.
68 r = do(t, "GET", ts.URL+"/api/blob", absTok, nil, nil)
69 if r.StatusCode != http.StatusOK {
70 t.Fatalf("absorbed token GET blob: want 200, got %d", r.StatusCode)
71 }
72 if uid, ok := s.store.linkTokenUser(hashToken(absTok)); !ok || uid != survivor {
73 t.Fatalf("absorbed token resolves to %q (ok=%v), want survivor", uid, ok)
74 }
75 // The absorbed account is gone (SQLite backs the test server).
76 if s.store.(*sqliteStore).userExists(absorbed) {
77 t.Fatalf("absorbed user row survives the merge")
78 }
79 // A retry within the proofs' lifetime is a zero-count success.
80 r = do(t, "POST", ts.URL+"/api/account/merge", survTok,
81 map[string]string{"X-Ceremony-Proof": survProof}, mergeBody(t, absProof))
82 if r.StatusCode != http.StatusOK {
83 t.Fatalf("retried merge: want 200, got %d", r.StatusCode)
84 }
85 if err := json.NewDecoder(r.Body).Decode(&out); err != nil {
86 t.Fatalf("bad json: %v", err)
87 }
88 if !out.Merged || out.Moved != (mergeMoved{}) {
89 t.Fatalf("retried merge response: %+v", out)
90 }
91}
92
93// The precondition: the client moves the vault contents over (and deletes the
94// absorbed blob) BEFORE the merge — an absorbed account still holding a blob
95// is refused with a plain message, and nothing moves.
96func TestAccountMergeAbsorbedBlobStillPresent(t *testing.T) {
97 ts, s := newTestServer(t)
98 survivor, survTok := seedUser(t, s)
99 absorbed, absTok := seedUser(t, s)
100 s.store.insertEmailAnchor(absorbed, "still-here")
101 if r := do(t, "PUT", ts.URL+"/api/blob", absTok, map[string]string{"If-Match": `"0"`}, []byte("unmoved-vault")); r.StatusCode != http.StatusOK {
102 t.Fatalf("absorbed PUT blob: want 200, got %d", r.StatusCode)
103 }
104
105 survProof := s.keys.mintCeremonyProof(survivor)
106 absProof := s.keys.mintCeremonyProof(absorbed)
107 r := do(t, "POST", ts.URL+"/api/account/merge", survTok,
108 map[string]string{"X-Ceremony-Proof": survProof}, mergeBody(t, absProof))
109 if r.StatusCode != http.StatusConflict {
110 t.Fatalf("merge with absorbed blob: want 409, got %d", r.StatusCode)
111 }
112 if uid, ok := s.store.emailUser("still-here"); !ok || uid != absorbed {
113 t.Fatalf("refused merge moved an anchor: (%q, %v)", uid, ok)
114 }
115 // Once the client deletes the absorbed blob (the ceremony-gated path),
116 // the same call goes through.
117 if r := do(t, "DELETE", ts.URL+"/api/blob", "", map[string]string{"X-Ceremony-Proof": absProof}, nil); r.StatusCode != http.StatusOK {
118 t.Fatalf("absorbed blob delete: want 200, got %d", r.StatusCode)
119 }
120 r = do(t, "POST", ts.URL+"/api/account/merge", survTok,
121 map[string]string{"X-Ceremony-Proof": survProof}, mergeBody(t, absProof))
122 if r.StatusCode != http.StatusOK {
123 t.Fatalf("merge after blob delete: want 200, got %d", r.StatusCode)
124 }
125}
126
127func TestAccountMergeSameAccount(t *testing.T) {
128 ts, s := newTestServer(t)
129 survivor, survTok := seedUser(t, s)
130 proof := s.keys.mintCeremonyProof(survivor)
131 // An absorb proof for the bearer's own account is a 400, not a merge.
132 r := do(t, "POST", ts.URL+"/api/account/merge", survTok,
133 map[string]string{"X-Ceremony-Proof": proof}, mergeBody(t, proof))
134 if r.StatusCode != http.StatusBadRequest {
135 t.Fatalf("same-account merge: want 400, got %d", r.StatusCode)
136 }
137 if !s.store.(*sqliteStore).userExists(survivor) {
138 t.Fatalf("same-account merge deleted the account")
139 }
140}
141
142func TestAccountMergeAuthGating(t *testing.T) {
143 ts, s := newTestServer(t)
144 survivor, survTok := seedUser(t, s)
145 absorbed, _ := seedUser(t, s)
146 survProof := s.keys.mintCeremonyProof(survivor)
147 absProof := s.keys.mintCeremonyProof(absorbed)
148
149 // Both survivor-side headers are required: the link token names the
150 // account, the fresh proof authorises the access change.
151 if r := do(t, "POST", ts.URL+"/api/account/merge", "",
152 map[string]string{"X-Ceremony-Proof": survProof}, mergeBody(t, absProof)); r.StatusCode != http.StatusUnauthorized {
153 t.Fatalf("no bearer: want 401, got %d", r.StatusCode)
154 }
155 if r := do(t, "POST", ts.URL+"/api/account/merge", survTok, nil, mergeBody(t, absProof)); r.StatusCode != http.StatusUnauthorized {
156 t.Fatalf("no ceremony proof: want 401, got %d", r.StatusCode)
157 }
158 // A proof for a DIFFERENT account than the bearer's is refused — it isn't
159 // the survivor's ceremony.
160 if r := do(t, "POST", ts.URL+"/api/account/merge", survTok,
161 map[string]string{"X-Ceremony-Proof": absProof}, mergeBody(t, absProof)); r.StatusCode != http.StatusUnauthorized {
162 t.Fatalf("mismatched proof: want 401, got %d", r.StatusCode)
163 }
164 // Stale proofs — either side — are refused.
165 staleSurv := s.keys.mintCeremonyProofAt(survivor, time.Now().Add(-time.Minute))
166 if r := do(t, "POST", ts.URL+"/api/account/merge", survTok,
167 map[string]string{"X-Ceremony-Proof": staleSurv}, mergeBody(t, absProof)); r.StatusCode != http.StatusUnauthorized {
168 t.Fatalf("stale survivor proof: want 401, got %d", r.StatusCode)
169 }
170 staleAbs := s.keys.mintCeremonyProofAt(absorbed, time.Now().Add(-time.Minute))
171 if r := do(t, "POST", ts.URL+"/api/account/merge", survTok,
172 map[string]string{"X-Ceremony-Proof": survProof}, mergeBody(t, staleAbs)); r.StatusCode != http.StatusUnauthorized {
173 t.Fatalf("stale absorb proof: want 401, got %d", r.StatusCode)
174 }
175 if r := do(t, "POST", ts.URL+"/api/account/merge", survTok,
176 map[string]string{"X-Ceremony-Proof": survProof}, mergeBody(t, "garbage")); r.StatusCode != http.StatusUnauthorized {
177 t.Fatalf("garbage absorb proof: want 401, got %d", r.StatusCode)
178 }
179 // A missing/empty absorb_proof is a 400 (bad request, not bad auth).
180 if r := do(t, "POST", ts.URL+"/api/account/merge", survTok,
181 map[string]string{"X-Ceremony-Proof": survProof}, []byte(`{}`)); r.StatusCode != http.StatusBadRequest {
182 t.Fatalf("missing absorb_proof: want 400, got %d", r.StatusCode)
183 }
184 // Nothing above moved anything.
185 if creds, _ := s.store.credentialsFor(absorbed); len(creds) != 1 {
186 t.Fatalf("refused merges moved credentials: %d", len(creds))
187 }
188}
189
190// The last-method guard interacts naturally with a merge: the survivor gains
191// the absorbed account's methods, so a passkey it couldn't shed before (its
192// only way in) may go afterwards — and the merged-in anchor then becomes the
193// last method itself.
194func TestAccountMergeLastMethodAfterMerge(t *testing.T) {
195 ts, s := newTestServer(t)
196 survivor, survTok := seedUser(t, s)
197 absorbed, _ := seedUser(t, s)
198 s.store.insertEmailAnchor(absorbed, "merged-email")
199 survProof := s.keys.mintCeremonyProof(survivor)
200
201 creds, _ := s.store.credentialsFor(survivor)
202 survCredID := b64CredID(string(creds[0].ID))
203 // Pre-merge: the survivor's only method can't be removed.
204 if r := do(t, "DELETE", ts.URL+"/api/anchors/passkey/"+survCredID, "",
205 map[string]string{"X-Ceremony-Proof": survProof}, nil); r.StatusCode != http.StatusConflict {
206 t.Fatalf("pre-merge last passkey: want 409, got %d", r.StatusCode)
207 }
208
209 absProof := s.keys.mintCeremonyProof(absorbed)
210 if r := do(t, "POST", ts.URL+"/api/account/merge", survTok,
211 map[string]string{"X-Ceremony-Proof": survProof}, mergeBody(t, absProof)); r.StatusCode != http.StatusOK {
212 t.Fatalf("merge: want 200, got %d", r.StatusCode)
213 }
214
215 // Post-merge the account holds 2 passkeys + 1 email anchor: both passkeys
216 // may go now…
217 absCreds, _ := s.store.credentialsFor(survivor)
218 for _, c := range absCreds {
219 id := b64CredID(string(c.ID))
220 if r := do(t, "DELETE", ts.URL+"/api/anchors/passkey/"+id, "",
221 map[string]string{"X-Ceremony-Proof": survProof}, nil); r.StatusCode != http.StatusOK {
222 t.Fatalf("post-merge passkey delete: want 200, got %d", r.StatusCode)
223 }
224 }
225 // …until the merged-in email anchor is the last way in.
226 if r := do(t, "DELETE", ts.URL+"/api/anchors/email/merged-email", "",
227 map[string]string{"X-Ceremony-Proof": survProof}, nil); r.StatusCode != http.StatusConflict {
228 t.Fatalf("merged anchor as last method: want 409, got %d", r.StatusCode)
229 }
230}
231