oficina / home Public

Clone
git clone https://amadan.net/oficina/home

Plain git — no account needed to clone.

Download

Download this file

1package home
2
3import (
4 "context"
5 "errors"
6 "strings"
7 "sync"
8 "testing"
9 "time"
10
11 "github.com/keymaildev/signin"
12
13 "amadan.net/oficina/home/internal/home/sealtest"
14)
15
16// putRawLink inserts a link row directly, bypassing PutLink.
17//
18// This exists because PutLink sweeps expired rows immediately after
19// inserting, so a link written through it with a past expiry is deleted
20// by its own call before TakeLink ever sees it. Every "expired" case
21// written through PutLink was therefore testing "never existed", and the
22// expiry predicate had no test that could fail — replacing
23// `expires_at > ?` with `? IS NOT NULL` passed the whole suite.
24func putRawLink(t *testing.T, app *testApp, hash, address, purpose string, expires time.Time) {
25 t.Helper()
26 sealed, err := app.secrets.Sealer.Seal(sealPurposeAddress, []byte(address))
27 if err != nil {
28 t.Fatal(err)
29 }
30 if _, err := app.db.Writer().Exec(
31 `INSERT INTO signin_links (token_hash, sealed_address, purpose, expires_at)
32 VALUES (?, ?, ?, ?)`,
33 hash, sealed, purpose, expires.UTC().Format(linkTimeFormat)); err != nil {
34 t.Fatal(err)
35 }
36}
37
38// TakeLink must refuse a link past its expiry. signin's own contract is
39// explicit that this is the store's job: "enforcing expiry is entirely
40// the implementer's obligation; Flow performs no expiry check of its own
41// on the value this returns."
42func TestTakeLinkRefusesAnExpiredLink(t *testing.T) {
43 app := newTestApp(t)
44 ctx := context.Background()
45
46 putRawLink(t, app, "expired", "person@example.invalid", signin.PurposeSignin,
47 time.Now().Add(-time.Minute))
48
49 addr, ok, err := app.links.TakeLink(ctx, "expired", signin.PurposeSignin)
50 if err != nil {
51 t.Fatalf("TakeLink: %v", err)
52 }
53 if ok {
54 t.Fatal("an expired link was redeemed")
55 }
56 if addr != "" {
57 t.Fatalf("TakeLink returned %q for an expired link", addr)
58 }
59
60 // And a link that has NOT expired is still taken, or the test above
61 // would pass on a store that refuses everything.
62 putRawLink(t, app, "live", "person@example.invalid", signin.PurposeSignin,
63 time.Now().Add(time.Minute))
64 if _, ok, err := app.links.TakeLink(ctx, "live", signin.PurposeSignin); err != nil || !ok {
65 t.Fatalf("a live link was refused (ok=%v err=%v)", ok, err)
66 }
67}
68
69// errWrongAddress is reported from a racing goroutine, where t.Errorf
70// would be called from a non-test goroutine.
71var errWrongAddress = errors.New("TakeLink returned the wrong address")
72
73// The whole point of the column. A link's address is at rest between
74// send and click; it is sealed there, and this reads the raw file, the
75// WAL and the SHM to prove it.
76func TestPutLinkSealsTheAddress(t *testing.T) {
77 app := newTestApp(t)
78 ctx := context.Background()
79
80 err := app.links.PutLink(ctx, "hash-1", sealtest.CanaryAddress, signin.PurposeSignin,
81 time.Now().Add(15*time.Minute))
82 if err != nil {
83 t.Fatalf("PutLink: %v", err)
84 }
85
86 sealtest.AssertNoPlaintext(t, app.dbPath)
87}
88
89func TestTakeLinkReturnsTheAddressOnce(t *testing.T) {
90 app := newTestApp(t)
91 ctx := context.Background()
92 const addr = "person@example.invalid"
93
94 if err := app.links.PutLink(ctx, "hash-2", addr, signin.PurposeSignin,
95 time.Now().Add(15*time.Minute)); err != nil {
96 t.Fatalf("PutLink: %v", err)
97 }
98
99 got, ok, err := app.links.TakeLink(ctx, "hash-2", signin.PurposeSignin)
100 if err != nil {
101 t.Fatalf("TakeLink: %v", err)
102 }
103 if !ok {
104 t.Fatal("first TakeLink reported ok == false")
105 }
106 if got != addr {
107 t.Fatalf("TakeLink = %q, want %q", got, addr)
108 }
109
110 // A second take must be indistinguishable from a link that never
111 // existed: the row is gone, not marked.
112 _, ok, err = app.links.TakeLink(ctx, "hash-2", signin.PurposeSignin)
113 if err != nil {
114 t.Fatalf("second TakeLink: %v", err)
115 }
116 if ok {
117 t.Fatal("second TakeLink succeeded; the link was redeemed twice")
118 }
119}
120
121// Unknown, expired and already-used must all be "no row", so that a
122// handler cannot tell them apart and turn the difference into an oracle.
123func TestTakeLinkIsIndistinguishableAcrossFailures(t *testing.T) {
124 app := newTestApp(t)
125 ctx := context.Background()
126
127 // Direct, not through PutLink: PutLink's own sweep would delete this
128 // row before TakeLink ran, and the case would silently become
129 // "never existed".
130 putRawLink(t, app, "expired", "person@example.invalid", signin.PurposeSignin,
131 time.Now().Add(-time.Minute))
132 if err := app.links.PutLink(ctx, "wrong-purpose", "person@example.invalid",
133 "something-else", time.Now().Add(15*time.Minute)); err != nil {
134 t.Fatalf("PutLink: %v", err)
135 }
136
137 for _, tc := range []struct{ name, hash, purpose string }{
138 {"never existed", "no-such-hash", signin.PurposeSignin},
139 {"expired", "expired", signin.PurposeSignin},
140 {"wrong purpose", "wrong-purpose", signin.PurposeSignin},
141 } {
142 t.Run(tc.name, func(t *testing.T) {
143 addr, ok, err := app.links.TakeLink(ctx, tc.hash, tc.purpose)
144 if err != nil {
145 t.Fatalf("TakeLink: %v", err)
146 }
147 if ok {
148 t.Fatal("TakeLink reported ok == true")
149 }
150 if addr != "" {
151 t.Fatalf("TakeLink returned %q alongside ok == false", addr)
152 }
153 })
154 }
155}
156
157// Two clicks on the same link, at the same moment, must mint one
158// session and not two. That is a property of the single DELETE …
159// RETURNING statement, so it is asserted rather than assumed.
160//
161// The plan asks for this "against both pools". It cannot be: a DELETE
162// goes to the writer, and rastrillo's writer pool is capped at one
163// connection, so there is no second pool for this statement to reach.
164// Said here rather than left looking like an omission. What the test
165// does exercise is real — a SELECT-then-DELETE implementation loses it.
166func TestConcurrentTakeLinkYieldsOneWinner(t *testing.T) {
167 app := newTestApp(t)
168 ctx := context.Background()
169 const addr = "person@example.invalid"
170
171 if err := app.links.PutLink(ctx, "race", addr, signin.PurposeSignin,
172 time.Now().Add(15*time.Minute)); err != nil {
173 t.Fatalf("PutLink: %v", err)
174 }
175
176 const racers = 16
177 var (
178 wg sync.WaitGroup
179 mu sync.Mutex
180 winners int
181 errs []error
182 )
183 start := make(chan struct{})
184 for range racers {
185 wg.Add(1)
186 go func() {
187 defer wg.Done()
188 <-start
189 got, ok, err := app.links.TakeLink(ctx, "race", signin.PurposeSignin)
190 mu.Lock()
191 defer mu.Unlock()
192 if err != nil {
193 errs = append(errs, err)
194 return
195 }
196 if ok {
197 winners++
198 if got != addr {
199 errs = append(errs, errWrongAddress)
200 }
201 }
202 }()
203 }
204 close(start)
205 wg.Wait()
206
207 for _, err := range errs {
208 t.Errorf("racer: %v", err)
209 }
210 if winners != 1 {
211 t.Fatalf("%d racers redeemed the link, want exactly 1", winners)
212 }
213}
214
215func TestSweepExpiredLinksRemovesOnlyExpiredOnes(t *testing.T) {
216 app := newTestApp(t)
217 ctx := context.Background()
218
219 // The expired row goes in directly, for the same reason: written
220 // through PutLink it would be swept by its own call, and the sweep
221 // under test would have nothing left to remove.
222 putRawLink(t, app, "old", "a@example.invalid", signin.PurposeSignin,
223 time.Now().Add(-time.Hour))
224 if err := app.links.PutLink(ctx, "live", "b@example.invalid",
225 signin.PurposeSignin, time.Now().Add(time.Hour)); err != nil {
226 t.Fatal(err)
227 }
228
229 if err := app.links.sweep(ctx, time.Now()); err != nil {
230 t.Fatalf("sweep: %v", err)
231 }
232
233 var n int
234 if err := app.db.Writer().QueryRow("SELECT count(*) FROM signin_links").Scan(&n); err != nil {
235 t.Fatal(err)
236 }
237 if n != 1 {
238 t.Fatalf("%d rows survived the sweep, want 1", n)
239 }
240 if _, ok, err := app.links.TakeLink(ctx, "live", signin.PurposeSignin); err != nil || !ok {
241 t.Fatalf("the live link did not survive the sweep (ok=%v err=%v)", ok, err)
242 }
243}
244
245// Ruling 3: HMAC(pepper, lowercased address). Three properties matter —
246// it folds case, it is stable, and it is pepper-dependent. The third is
247// what makes a leaked database without the pepper useless.
248func TestBlindIndex(t *testing.T) {
249 pepper := []byte("pepper-one-pepper-one-pepper-one")
250 other := []byte("pepper-two-pepper-two-pepper-two")
251
252 a := blindIndex(pepper, "Person@Example.Invalid")
253 b := blindIndex(pepper, "person@example.invalid")
254 if a != b {
255 t.Errorf("blind index does not fold case: %q vs %q", a, b)
256 }
257 if a != blindIndex(pepper, "person@example.invalid") {
258 t.Error("blind index is not stable across calls")
259 }
260 if a == blindIndex(other, "person@example.invalid") {
261 t.Error("blind index does not depend on the pepper")
262 }
263 if strings.Contains(a, "@") || strings.Contains(a, "example") {
264 t.Errorf("blind index %q carries the address", a)
265 }
266}
267
268// A returning person is the same person. With a UNIQUE blind index and
269// no upsert, every visit would mint a fresh ref and every session,
270// credential and recovery code would attach to an identity nobody can
271// return to.
272func TestFindOrMintPersonIsStable(t *testing.T) {
273 app := newTestApp(t)
274 ctx := context.Background()
275
276 first, err := app.findOrMintPerson(ctx, "Person@Example.Invalid")
277 if err != nil {
278 t.Fatalf("findOrMintPerson: %v", err)
279 }
280 if !ValidPersonRef(first) {
281 t.Fatalf("minted %q, which ValidPersonRef rejects", first)
282 }
283
284 again, err := app.findOrMintPerson(ctx, "person@example.invalid")
285 if err != nil {
286 t.Fatalf("findOrMintPerson: %v", err)
287 }
288 if again != first {
289 t.Errorf("the same address minted two refs: %q then %q", first, again)
290 }
291
292 other, err := app.findOrMintPerson(ctx, "someone-else@example.invalid")
293 if err != nil {
294 t.Fatal(err)
295 }
296 if other == first {
297 t.Error("two addresses share one person ref")
298 }
299}
300
301// The people table must never hold a readable address either.
302func TestFindOrMintPersonStoresNoAddress(t *testing.T) {
303 app := newTestApp(t)
304 if _, err := app.findOrMintPerson(context.Background(), sealtest.CanaryAddress); err != nil {
305 t.Fatalf("findOrMintPerson: %v", err)
306 }
307 sealtest.AssertNoPlaintext(t, app.dbPath)
308}
309
310// created_at is coarsened to the day. Strictly less information, and
311// people.created_at has no freshness role that a time of day would serve.
312func TestPersonCreatedAtIsCoarsenedToTheDay(t *testing.T) {
313 app := newTestApp(t)
314 ref, err := app.findOrMintPerson(context.Background(), "person@example.invalid")
315 if err != nil {
316 t.Fatal(err)
317 }
318 var createdAt string
319 if err := app.db.Writer().
320 QueryRow("SELECT created_at FROM people WHERE person_ref = ?", ref).
321 Scan(&createdAt); err != nil {
322 t.Fatal(err)
323 }
324 if len(createdAt) != len("2006-01-02") {
325 t.Errorf("created_at = %q, want a date with no time of day", createdAt)
326 }
327}
328