carlos / platform Public

Clone
git clone https://amadan.net/carlos/platform

Plain git — no account needed to clone.

Download

Download this file

1package main
2
3import (
4 "bytes"
5 "context"
6 "crypto/ecdh"
7 "crypto/sha256"
8 "database/sql"
9 "encoding/hex"
10 "encoding/json"
11 "errors"
12 "fmt"
13 "io"
14 "net/http"
15 "net/http/httptest"
16 "os"
17 "path/filepath"
18 "strings"
19 "sync/atomic"
20 "testing"
21 "time"
22
23 lsfile "github.com/benbjohnson/litestream/file"
24 "github.com/superfly/ltx"
25
26 "github.com/carlosframework/platform/internal/dbdoor"
27 "github.com/carlosframework/platform/internal/dbdoor/dbdoortest"
28 "github.com/carlosframework/platform/internal/dbreads"
29)
30
31// These feed readDBForward crafted line streams, sealed with real keys:
32// unit tests of the CLI's half (dbforward_e2e_test.go runs the binary).
33
34const fwdID = "dbr_aaaaaaaaaaaaaaaaaaaaaaaaaa"
35
36// stream builds a reads-route body: admitted, picked, the chunks, then end.
37type stream struct {
38 t *testing.T
39 pub *ecdh.PublicKey
40 buf bytes.Buffer
41}
42
43func newStream(t *testing.T, key *ecdh.PrivateKey) *stream {
44 s := &stream{t: t, pub: key.PublicKey()}
45 s.line(dbreads.Line{Admitted: fwdID})
46 s.line(dbreads.Line{Picked: "uk-1"})
47 return s
48}
49
50func (s *stream) line(l dbreads.Line) {
51 b, _ := json.Marshal(l)
52 s.buf.Write(append(b, '\n'))
53}
54
55func (s *stream) chunk(seq int64, last bool, plain []byte) {
56 sealed, err := dbreads.SealChunk(s.pub, fwdID, seq, dbreads.StreamRows, last, plain)
57 if err != nil {
58 s.t.Fatal(err)
59 }
60 s.line(dbreads.Line{Chunk: seq, Data: sealed})
61}
62
63func (s *stream) result(state, code string, last int64, sum dbreads.Summary) {
64 sealed, _ := dbreads.SealSummary(s.pub, fwdID, sum)
65 s.line(dbreads.Line{Result: &dbreads.Result{State: state, Code: code, LastSeq: last, Sealed: sealed}})
66}
67
68func summaryOf(rows string) dbreads.Summary {
69 h := sha256.Sum256([]byte(rows))
70 return dbreads.Summary{TXID: 7, At: time.Date(2026, 10, 7, 14, 0, 0, 0, time.UTC), Bytes: int64(len(rows)), SHA256: hex.EncodeToString(h[:])}
71}
72
73func readFwd(t *testing.T, s *stream, key *ecdh.PrivateKey) (dbForwardAnswer, string, error) {
74 t.Helper()
75 var status bytes.Buffer
76 sink := &memSink{}
77 id, res, err := readDBForward(context.Background(), &s.buf, key, "shop.example.com", "uk", dbfReading, &status, sink, newDBFChunkBound(dbreads.MaxOutputBytes, dbreads.ChunkBytes))
78 if err != nil {
79 return dbForwardAnswer{}, status.String(), err
80 }
81 ans, err := openDBForward(id, key, sink, *res, "shop.example.com", "uk")
82 return ans, status.String(), err
83}
84
85func TestForwardedQueryPrintsOnlyACheckedAnswer(t *testing.T) {
86 t.Parallel()
87 key, _ := dbreads.NewReplyKey()
88 s := newStream(t, key)
89 s.chunk(1, false, []byte("n\n"))
90 s.chunk(2, true, []byte("30\n"))
91 s.result(dbreads.StateDone, "", 2, summaryOf("n\n30\n"))
92 ans, status, err := readFwd(t, s, key)
93 if err != nil || string(ans.rows) != "n\n30\n" || ans.summary.TXID != 7 {
94 t.Fatalf("%q %v", ans.rows, err)
95 }
96 if status != fmt.Sprintf(dbfReading, "shop.example.com", "uk")+"\n" {
97 t.Fatalf("status %q", status)
98 }
99}
100
101// T11's unit half (codex 6): a dropped or bad last chunk and a summary that
102// does not add up each print nothing.
103func TestForwardedQueryRefusesAnAnswerThatDoesNotCheckOut(t *testing.T) {
104 t.Parallel()
105 unchecked := fmt.Sprintf(dbfUnchecked, "uk")
106 for name, build := range map[string]func(s *stream){
107 "the last chunk dropped": func(s *stream) {
108 s.chunk(1, false, []byte("n\n"))
109 s.result(dbreads.StateDone, "", 2, summaryOf("n\n30\n"))
110 },
111 "the last chunk not sealed as last": func(s *stream) {
112 s.chunk(1, false, []byte("n\n"))
113 s.chunk(2, false, []byte("30\n"))
114 s.result(dbreads.StateDone, "", 2, summaryOf("n\n30\n"))
115 },
116 "a hash mismatch": func(s *stream) {
117 s.chunk(1, true, []byte("n\n31\n"))
118 s.result(dbreads.StateDone, "", 1, summaryOf("n\n30\n"))
119 },
120 "a chunk out of order": func(s *stream) {
121 s.chunk(2, true, []byte("30\n"))
122 s.result(dbreads.StateDone, "", 1, summaryOf("30\n"))
123 },
124 } {
125 key, _ := dbreads.NewReplyKey()
126 s := newStream(t, key)
127 build(s)
128 ans, _, err := readFwd(t, s, key)
129 if err == nil || err.Error() != unchecked || ans.rows != nil {
130 t.Errorf("%s: %q, %v", name, ans.rows, err)
131 }
132 }
133}
134
135func TestForwardedQueryEndsWithoutAResult(t *testing.T) {
136 t.Parallel()
137 key, _ := dbreads.NewReplyKey()
138 s := newStream(t, key)
139 s.chunk(1, false, []byte("n\n"))
140 if _, _, err := readFwd(t, s, key); err == nil || err.Error() != fmt.Sprintf(dbfUnfinished, "shop.example.com", "uk") {
141 t.Fatalf("no result: %v", err)
142 }
143 key2, _ := dbreads.NewReplyKey()
144 s2 := &stream{t: t, pub: key2.PublicKey()}
145 s2.line(dbreads.Line{Admitted: fwdID})
146 s2.line(dbreads.Line{Wait: dbreads.WaitPickup})
147 s2.line(dbreads.Line{Ended: dbreads.EndedNotPicked})
148 if _, _, err := readFwd(t, s2, key2); err == nil || err.Error() != fmt.Sprintf(dbfNotPicked, "uk") {
149 t.Fatalf("not picked: %v", err)
150 }
151}
152
153func TestForwardedQueryMapsEveryCode(t *testing.T) {
154 t.Parallel()
155 host := "shop.example.com"
156 for code, want := range map[string]string{
157 dbreads.CodeTooLarge: fmt.Sprintf(dbfTooLarge, host, "3.0GB", "2.0GB", "uk"),
158 dbreads.CodeNoRoom: fmt.Sprintf(dbfNoRoom, "uk", host),
159 dbreads.CodeTimeout: dbfTimeout,
160 dbreads.CodeSQLError: "no such table: nope",
161 dbreads.CodeNothingReplicated: dbRestoreError(host, dbdoorErrNothing()).Error(),
162 dbreads.CodeChangedTwice: dbRestoreError(host, errVanished()).Error(),
163 dbreads.CodeInterrupted: fmt.Sprintf(dbfUnfinished, host, "uk"),
164 } {
165 key, _ := dbreads.NewReplyKey()
166 s := newStream(t, key)
167 sum := dbreads.Summary{Restored: 3 << 30, Cap: 2 << 30, Error: "no such table: nope"}
168 s.result(dbreads.StateFailed, code, 0, sum)
169 if _, _, err := readFwd(t, s, key); err == nil || err.Error() != want {
170 t.Errorf("%s: %v, want %q", code, err, want)
171 }
172 }
173 for code, want := range map[string]string{
174 dbreads.CodeRecordChanged: fmt.Sprintf(dbfChanged, host),
175 dbreads.CodeMoving: fmt.Sprintf(dbfMovingCLI, host),
176 } {
177 key, _ := dbreads.NewReplyKey()
178 s := newStream(t, key)
179 s.result(dbreads.StateRefused, code, 0, dbreads.Summary{})
180 if _, _, err := readFwd(t, s, key); err == nil || err.Error() != want {
181 t.Errorf("%s: %v", code, err)
182 }
183 }
184}
185
186func TestForwardedQueryOutputCapPrintsTheRows(t *testing.T) {
187 t.Parallel()
188 key, _ := dbreads.NewReplyKey()
189 s := newStream(t, key)
190 s.chunk(1, true, []byte("cut"))
191 s.result(dbreads.StateDone, dbreads.CodeOutputCap, 1, summaryOf("cut"))
192 ans, _, err := readFwd(t, s, key)
193 if err != nil || !ans.capped || string(ans.rows) != "cut" {
194 t.Fatalf("%+v %v", ans, err)
195 }
196}
197
198// Review Focus 2: a full-size chunk line, about 350 KiB, is read.
199func TestForwardedQueryReadsAFullSizeChunkLine(t *testing.T) {
200 t.Parallel()
201 key, _ := dbreads.NewReplyKey()
202 rows := strings.Repeat("x", dbreads.ChunkBytes)
203 s := newStream(t, key)
204 s.chunk(1, false, []byte(rows))
205 s.chunk(2, true, nil)
206 s.result(dbreads.StateDone, "", 2, summaryOf(rows))
207 ans, _, err := readFwd(t, s, key)
208 if err != nil || len(ans.rows) != dbreads.ChunkBytes {
209 t.Fatalf("%d bytes, %v", len(ans.rows), err)
210 }
211}
212
213// Review Focus 1: Ctrl-C mid-stream is "stopped", with nothing printed.
214func TestForwardedQueryCtrlCPrintsNothing(t *testing.T) {
215 t.Parallel()
216 key, _ := dbreads.NewReplyKey()
217 s := newStream(t, key)
218 s.chunk(1, false, []byte("n\n"))
219 ctx, cancel := context.WithCancel(context.Background())
220 cancel()
221 var status bytes.Buffer
222 _, _, err := readDBForward(ctx, &cutReader{r: &s.buf}, key, "shop.example.com", "uk", dbfReading, &status, &memSink{}, newDBFChunkBound(dbreads.MaxOutputBytes, dbreads.ChunkBytes))
223 if err == nil || err.Error() != "stopped" {
224 t.Fatalf("%v", err)
225 }
226}
227
228// Plan review finding 37: what the CLI holds before a result is bounded by
229// count as well as bytes, and a malformed chunk is refused before it is
230// kept. Each stream is otherwise one the CLI would accept, so each case is
231// red under its own mutation of dbfChunkBound.admit: drop the size floor
232// ("a chunk shorter than its seal" then ends DBF-12, the stream having no
233// result, instead of DBF-13 at once); drop the empty-chunk rule or the
234// count bound (the stream then opens and prints).
235func TestForwardedQueryBoundsChunkCountAndSize(t *testing.T) {
236 t.Parallel()
237 key, _ := dbreads.NewReplyKey()
238 unchecked := fmt.Sprintf(dbfUnchecked, "uk")
239 many := newDBFChunkBound(dbreads.MaxOutputBytes, dbreads.ChunkBytes).maxChunks + 1
240 for name, write := range map[string]func(s *stream){
241 "a chunk shorter than its seal": func(s *stream) {
242 s.line(dbreads.Line{Chunk: 1, Data: []byte("short")})
243 },
244 "a chunk after an empty one": func(s *stream) {
245 s.chunk(1, false, nil)
246 s.chunk(2, true, []byte("n\n"))
247 s.result(dbreads.StateDone, "", 2, summaryOf("n\n"))
248 },
249 "more chunks than a read can make": func(s *stream) {
250 for seq := int64(1); seq <= many; seq++ {
251 s.chunk(seq, seq == many, []byte("x"))
252 }
253 s.result(dbreads.StateDone, "", many, summaryOf(strings.Repeat("x", int(many))))
254 },
255 } {
256 s := newStream(t, key)
257 write(s)
258 var status bytes.Buffer
259 sink := &memSink{}
260 id, res, err := readDBForward(context.Background(), &s.buf, key, "shop.example.com", "uk", dbfReading, &status, sink, newDBFChunkBound(dbreads.MaxOutputBytes, dbreads.ChunkBytes))
261 if err == nil {
262 _, err = openDBForward(id, key, sink, *res, "shop.example.com", "uk")
263 }
264 if err == nil || err.Error() != unchecked {
265 t.Errorf("%s: %v", name, err)
266 }
267 }
268}
269
270// Plan review finding 36: SQL that JSON would rewrite is refused before
271// anything is sent. Mutation: drop the check (red: the request goes out).
272func TestForwardedQueryRefusesSQLThatIsNotUTF8(t *testing.T) {
273 t.Parallel()
274 var sent atomic.Bool
275 ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
276 sent.Store(true)
277 http.Error(w, "no", http.StatusTeapot)
278 }))
279 defer ts.Close()
280 target := dbTarget{sess: &apiSession{c: newConsoleClient(ts.URL, "tok"), console: ts.URL}, acct: apiAcct{Seg: "acme"},
281 app: "shop", host: "shop.example.com"}
282 for _, q := range []string{"SELECT '\xff'", "SELECT '�'"} {
283 err := dbQueryForward(context.Background(), target, q, dbdoor.Forward{Region: "uk", Ops: []string{dbreads.OpQuery}})
284 if err == nil || err.Error() != fmt.Sprintf(dbfNotUTF8, "uk") {
285 t.Errorf("%q: %v", q, err)
286 }
287 }
288 if sent.Load() {
289 t.Fatal("a query JSON would rewrite was sent")
290 }
291}
292
293// cutReader ends like a connection the context closed.
294type cutReader struct{ r *bytes.Buffer }
295
296func (c *cutReader) Read(p []byte) (int, error) {
297 if c.r.Len() == 0 {
298 return 0, context.Canceled
299 }
300 return c.r.Read(p)
301}
302
303// The errors the in-region restore answers with, for the mapping test.
304func dbdoorErrNothing() error { return dbdoor.ErrNothingReplicated }
305
306func errVanished() error { return os.ErrNotExist }
307
308// pullStream is a pull's body for the files of dir's replica, in one chunk.
309func pullStream(t *testing.T, key *ecdh.PrivateKey, dir string, mutate func(s *dbreads.Summary, files [][]byte)) *stream {
310 t.Helper()
311 return pullStreamN(t, key, dir, 1, mutate)
312}
313
314// pullStreamN is pullStream with the files' bytes split across n chunks.
315func pullStreamN(t *testing.T, key *ecdh.PrivateKey, dir string, n int, mutate func(s *dbreads.Summary, files [][]byte)) *stream {
316 t.Helper()
317 rd := lsfile.NewReplicaClient(dir)
318 plan, err := dbdoor.PlanFrom(context.Background(), rd, time.Time{})
319 if err != nil {
320 t.Fatal(err)
321 }
322 var files [][]byte
323 var all bytes.Buffer
324 for _, f := range plan.Planned {
325 rc, err := rd.OpenLTXFile(context.Background(), f.Level, ltx.TXID(f.MinTXID), ltx.TXID(f.MaxTXID), 0, 0)
326 if err != nil {
327 t.Fatal(err)
328 }
329 b, _ := io.ReadAll(rc)
330 rc.Close()
331 files = append(files, b)
332 all.Write(b)
333 }
334 sum := sha256.Sum256(all.Bytes())
335 s := dbreads.Summary{TXID: plan.TXID, At: plan.At, Files: plan.Planned, Bytes: int64(all.Len()), SHA256: hex.EncodeToString(sum[:])}
336 if mutate != nil {
337 mutate(&s, files)
338 }
339 st := newStream(t, key)
340 b := all.Bytes()
341 per := (len(b) + n - 1) / n
342 for seq := 1; seq <= n; seq++ {
343 part := b[min((seq-1)*per, len(b)):min(seq*per, len(b))]
344 sealed, err := dbreads.SealChunk(key.PublicKey(), fwdID, int64(seq), dbreads.StreamLTX, seq == n, part)
345 if err != nil {
346 t.Fatal(err)
347 }
348 st.line(dbreads.Line{Chunk: int64(seq), Data: sealed})
349 }
350 st.result(dbreads.StateDone, "", int64(n), s)
351 return st
352}
353
354func readPull(t *testing.T, st *stream, key *ecdh.PrivateKey) (*dbdoor.Restored, error) {
355 t.Helper()
356 return readPullIn(t, st, key, t.TempDir())
357}
358
359func readPullIn(t *testing.T, st *stream, key *ecdh.PrivateKey, dir string) (*dbdoor.Restored, error) {
360 t.Helper()
361 sink := &fileSink{dir: filepath.Join(dir, "sealed")}
362 id, res, err := readDBForward(context.Background(), &st.buf, key, "shop.example.com", "uk", dbfFetching, io.Discard, sink, newDBFChunkBound(dbreads.MaxPullBytes, dbreads.PullChunkBytes))
363 if err != nil {
364 return nil, err
365 }
366 return openDBPull(context.Background(), id, key, sink, *res, "shop.example.com", "uk", dir)
367}
368
369func TestForwardedPullRestoresTheFiles(t *testing.T) {
370 t.Parallel()
371 dir := t.TempDir()
372 dbdoortest.Replicate(t, dir, 3)
373 for _, n := range []int{1, 4} {
374 key, _ := dbreads.NewReplyKey()
375 out := t.TempDir()
376 r, err := readPullIn(t, pullStreamN(t, key, dir, n, nil), key, out)
377 if err != nil {
378 t.Fatalf("%d chunks: %v", n, err)
379 }
380 db, _ := sql.Open("sqlite", "file:"+r.Path+"?mode=ro")
381 var rows int
382 err = db.QueryRow("SELECT count(*) FROM t").Scan(&rows)
383 db.Close()
384 if err != nil || rows != 30 {
385 t.Fatalf("%d chunks: %d rows, %v", n, rows, err)
386 }
387 // Only the copy is left: each spooled chunk went once its bytes were
388 // in the files (so the two are never both whole on the disk), and
389 // the files went once the copy was restored. dbPullForward removes
390 // the empty spool directory.
391 if left, _ := os.ReadDir(filepath.Join(out, "sealed")); len(left) != 0 {
392 t.Errorf("%d chunks: %d spooled chunks are still there", n, len(left))
393 }
394 if _, err := os.Stat(filepath.Join(out, "replica")); !os.IsNotExist(err) {
395 t.Errorf("%d chunks: the files are still there (%v)", n, err)
396 }
397 }
398}
399
400// Review Focus 3 and plan review findings 22 and 24: a file list that does
401// not add up is DBF-13, never a panic, and no file is created before the
402// list is checked whole. Mutations: drop checkPullManifest's per-file size
403// bounds (red: "sizes -1 and N+1" panics or creates files); drop its sum
404// (red: "sizes that do not sum" creates the first file before failing);
405// drop the duplicate check (red: "a file twice" creates a file); drop the
406// level bound (red: "level 12").
407func TestForwardedPullRefusesFilesThatDoNotAddUp(t *testing.T) {
408 t.Parallel()
409 dir := t.TempDir()
410 dbdoortest.Replicate(t, dir, 3)
411 for name, mutate := range map[string]func(s *dbreads.Summary, files [][]byte){
412 "sizes that do not sum": func(s *dbreads.Summary, _ [][]byte) { s.Files[0].Size++ },
413 "a list that ends elsewhere": func(s *dbreads.Summary, _ [][]byte) { s.TXID++ },
414 "no files": func(s *dbreads.Summary, _ [][]byte) { s.Files = nil },
415 "sizes -1 and N+1": func(s *dbreads.Summary, _ [][]byte) {
416 s.Files[1].Size += s.Files[0].Size + 1
417 s.Files[0].Size = -1
418 },
419 "a file twice": func(s *dbreads.Summary, _ [][]byte) {
420 s.Files[1] = s.Files[0]
421 s.Bytes = 0
422 for _, f := range s.Files {
423 s.Bytes += f.Size
424 }
425 },
426 "level 12": func(s *dbreads.Summary, _ [][]byte) { s.Files[0].Level = 12 },
427 "a range that runs back": func(s *dbreads.Summary, _ [][]byte) { s.Files[0].MinTXID = s.Files[0].MaxTXID + 1 },
428 "a size past the pull's cap": func(s *dbreads.Summary, _ [][]byte) { s.Files[0].Size = dbreads.MaxPullBytes + 1 },
429 } {
430 key, _ := dbreads.NewReplyKey()
431 out := t.TempDir()
432 func() {
433 defer func() {
434 if p := recover(); p != nil {
435 t.Errorf("%s: panic %v", name, p)
436 }
437 }()
438 if _, err := readPullIn(t, pullStream(t, key, dir, mutate), key, out); err == nil || err.Error() != fmt.Sprintf(dbfUnchecked, "uk") {
439 t.Errorf("%s: %v", name, err)
440 }
441 }()
442 if _, err := os.Stat(filepath.Join(out, "replica")); !os.IsNotExist(err) {
443 t.Errorf("%s: a file was created before the list was checked (%v)", name, err)
444 }
445 }
446}
447
448// The other endings of a pull: too-large is DBF-17 with the planned bytes
449// and the cap, no-room DBF-24, and the rest the query's mapping.
450func TestForwardedPullEndings(t *testing.T) {
451 t.Parallel()
452 for name, c := range map[string]struct {
453 code string
454 sum dbreads.Summary
455 want string
456 }{
457 "too large": {dbreads.CodeTooLarge, dbreads.Summary{Restored: 3 << 30, Cap: 2 << 30},
458 fmt.Sprintf(dbfPullTooLarge, "shop.example.com", humanBytes(ptr(int64(3<<30))), humanBytes(ptr(int64(2<<30))), "uk")},
459 "no room": {dbreads.CodeNoRoom, dbreads.Summary{}, fmt.Sprintf(dbfPullNoRoom, "uk", "shop.example.com")},
460 "nothing replicated": {dbreads.CodeNothingReplicated, dbreads.Summary{}, dbRestoreError("shop.example.com", dbdoor.ErrNothingReplicated).Error()},
461 } {
462 key, _ := dbreads.NewReplyKey()
463 st := newStream(t, key)
464 st.result(dbreads.StateFailed, c.code, 0, c.sum)
465 if _, err := readPull(t, st, key); err == nil || err.Error() != c.want {
466 t.Errorf("%s: %v, want %q", name, err, c.want)
467 }
468 }
469}
470
471func ptr[T any](v T) *T { return &v }
472
473// Plan review finding 37, at the spool: a chunk shorter than a sealed
474// empty one creates no spool file, and a stream of tiny chunks stops at the
475// bound's count, so the spool never holds more files than a read can make.
476// (TestForwardedQueryBoundsChunkCountAndSize pins the count's derivation; a
477// bound of 10 keeps this test small.) Mutation: add to the sink before
478// admit (red: a file for the short chunk, eleven files for the tiny ones).
479func TestForwardedPullSpoolsNoMalformedOrExcessChunk(t *testing.T) {
480 t.Parallel()
481 key, _ := dbreads.NewReplyKey()
482 for name, write := range map[string]func(s *stream) int{
483 "a chunk shorter than its seal": func(s *stream) int {
484 s.line(dbreads.Line{Chunk: 1, Data: []byte("short")})
485 return 0
486 },
487 "tiny chunks past the count": func(s *stream) int {
488 for seq := int64(1); seq <= 11; seq++ {
489 sealed, _ := dbreads.SealChunk(key.PublicKey(), fwdID, seq, dbreads.StreamLTX, false, []byte("x"))
490 s.line(dbreads.Line{Chunk: seq, Data: sealed})
491 }
492 return 10
493 },
494 } {
495 s := newStream(t, key)
496 want := write(s)
497 dir := filepath.Join(t.TempDir(), "sealed")
498 sink := &fileSink{dir: dir}
499 _, _, err := readDBForward(context.Background(), &s.buf, key, "shop.example.com", "uk", dbfFetching, io.Discard, sink,
500 &dbfChunkBound{maxBytes: dbreads.MaxPullBytes, maxChunks: 10})
501 files, _ := os.ReadDir(dir)
502 if err == nil || err.Error() != fmt.Sprintf(dbfUnchecked, "uk") || len(files) != want {
503 t.Errorf("%s: %v, %d spool files, want %d", name, err, len(files), want)
504 }
505 }
506}
507
508// standInConsole answers the list door with the forward marker offering a
509// pull, and the reads route with body (then holds the request open until
510// the client goes when hold is set). It is a stand-in for the console, so
511// that these tests run dbPull itself.
512func standInConsole(t *testing.T, body []byte, hold bool) dbTarget {
513 t.Helper()
514 ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
515 if !strings.HasSuffix(r.URL.Path, "/reads") {
516 w.Header().Set("Content-Type", "application/json")
517 w.WriteHeader(http.StatusConflict)
518 json.NewEncoder(w).Encode(dbdoor.ForwardBody{Error: "shop.example.com keeps its data in region uk.",
519 Forward: 1, Region: "uk", ForwardOps: []string{dbreads.OpQuery, dbreads.OpPull}})
520 return
521 }
522 io.Copy(io.Discard, r.Body)
523 w.Header().Set("Content-Type", "application/x-ndjson")
524 w.WriteHeader(http.StatusOK)
525 w.Write(body)
526 w.(http.Flusher).Flush()
527 if hold {
528 <-r.Context().Done()
529 }
530 }))
531 t.Cleanup(ts.Close)
532 return dbTarget{sess: &apiSession{c: newConsoleClient(ts.URL, "tok"), console: ts.URL}, acct: apiAcct{Seg: "acme"},
533 app: "shop", host: "shop.example.com"}
534}
535
536// leftBehind is what a pull left in its temp dir and at --out.
537func leftBehind(t *testing.T, tmp, out string) {
538 t.Helper()
539 if _, err := os.Stat(out); !os.IsNotExist(err) {
540 t.Errorf("something is at --out: %v", err)
541 }
542 if left, _ := filepath.Glob(filepath.Join(tmp, "carlos-db-*")); len(left) != 0 {
543 t.Errorf("the pull's directory is still there: %v", left)
544 }
545}
546
547// Review Focus 1 and plan review finding 23: a spool that fails part way,
548// through dbPull itself, leaves nothing at --out and removes its directory.
549// Not parallel: it sets TMPDIR and dbPullSpoolWrite. Mutation: drop
550// dbPullForward's deferred RemoveAll (red: carlos-db-* is left).
551func TestForwardedPullLeavesNothingWhenTheSpoolFails(t *testing.T) {
552 tmp := t.TempDir()
553 t.Setenv("TMPDIR", tmp)
554 writes := 0
555 dbPullSpoolWrite = func(name string, b []byte, mode os.FileMode) error {
556 if writes++; writes == 3 {
557 return errors.New("no space left on device")
558 }
559 return os.WriteFile(name, b, mode)
560 }
561 t.Cleanup(func() { dbPullSpoolWrite = os.WriteFile })
562 dir := t.TempDir()
563 dbdoortest.Replicate(t, dir, 3)
564 // The stand-in's chunks are sealed to a key the CLI does not hold; the
565 // pull fails at the spool, before anything is opened.
566 key, _ := dbreads.NewReplyKey()
567 st := pullStreamN(t, key, dir, 4, nil)
568 out := filepath.Join(t.TempDir(), "copy.db")
569 if err := dbPull(context.Background(), standInConsole(t, st.buf.Bytes(), false), out); err == nil {
570 t.Fatal("a spool that failed was accepted")
571 }
572 if writes != 3 {
573 t.Fatalf("%d spool writes: the failure was not part way", writes)
574 }
575 leftBehind(t, tmp, out)
576}
577
578// Review Focus 4 and plan review finding 23: Ctrl-C while chunks are
579// spooling, through dbPull itself, is "stopped", with nothing at --out and
580// the directory removed. Not parallel: it sets TMPDIR and dbPullSpoolWrite.
581// Mutation: drop dbPullForward's deferred RemoveAll (red). The binary's
582// SIGINT is Task 9's TestDBForwardPullInterruptedLeavesNothing.
583func TestForwardedPullCtrlCLeavesNothing(t *testing.T) {
584 tmp := t.TempDir()
585 t.Setenv("TMPDIR", tmp)
586 ctx, cancel := context.WithCancel(context.Background())
587 defer cancel()
588 dbPullSpoolWrite = func(name string, b []byte, mode os.FileMode) error {
589 err := os.WriteFile(name, b, mode)
590 cancel() // Ctrl-C once the first chunk is on disk
591 return err
592 }
593 t.Cleanup(func() { dbPullSpoolWrite = os.WriteFile })
594 dir := t.TempDir()
595 dbdoortest.Replicate(t, dir, 3)
596 key, _ := dbreads.NewReplyKey()
597 st := pullStreamN(t, key, dir, 4, nil)
598 // Admitted, picked and the first chunk only (newStream writes admitted
599 // and picked first), then the stand-in holds the request.
600 lines := bytes.SplitAfter(st.buf.Bytes(), []byte("\n"))
601 out := filepath.Join(t.TempDir(), "copy.db")
602 err := dbPull(ctx, standInConsole(t, bytes.Join(lines[:3], nil), true), out)
603 if err == nil || err.Error() != "stopped" {
604 t.Fatalf("%v", err)
605 }
606 leftBehind(t, tmp, out)
607}
608
609// pullStreamOf is a pull's body for the given bytes and summary, one chunk.
610func pullStreamOf(t *testing.T, key *ecdh.PrivateKey, b []byte, s dbreads.Summary) *stream {
611 t.Helper()
612 st := newStream(t, key)
613 sealed, err := dbreads.SealChunk(key.PublicKey(), fwdID, 1, dbreads.StreamLTX, true, b)
614 if err != nil {
615 t.Fatal(err)
616 }
617 st.line(dbreads.Line{Chunk: 1, Data: sealed})
618 st.result(dbreads.StateDone, "", 1, s)
619 return st
620}
621
622// Astra round 1 on plan 3, finding 1, at the CLI: a valid snapshot of
623// transaction 1, listed as [1,2] with a summary at 2 that adds up in every
624// other way, is DBF-13 and restores nothing: the CLI checks each rebuilt
625// file's LTX header against its manifest entry. Mutation: drop the header
626// comparison (red: it restores and reports transaction 2).
627func TestForwardedPullRefusesARelabelledSnapshot(t *testing.T) {
628 t.Parallel()
629 dir := t.TempDir()
630 dbdoortest.Replicate(t, dir, 2)
631 rc, err := lsfile.NewReplicaClient(dir).OpenLTXFile(context.Background(), 0, 1, 1, 0, 0)
632 if err != nil {
633 t.Fatal(err)
634 }
635 b, _ := io.ReadAll(rc)
636 rc.Close()
637 sum := sha256.Sum256(b)
638 s := dbreads.Summary{TXID: 2, Files: []dbdoor.LTXFile{{Level: 0, MinTXID: 1, MaxTXID: 2, Size: int64(len(b))}},
639 Bytes: int64(len(b)), SHA256: hex.EncodeToString(sum[:])}
640 key, _ := dbreads.NewReplyKey()
641 out := t.TempDir()
642 r, err := readPullIn(t, pullStreamOf(t, key, b, s), key, out)
643 if err == nil || err.Error() != fmt.Sprintf(dbfUnchecked, "uk") {
644 t.Fatalf("a relabelled snapshot gave %+v, %v", r, err)
645 }
646 if _, err := os.Stat(filepath.Join(out, "replica.db")); !os.IsNotExist(err) {
647 t.Fatalf("a copy was restored: %v", err)
648 }
649}
650
651// Astra round 1 on plan 3, finding 2, after the download: a pull cancelled
652// once every chunk has arrived is "stopped" and rebuilds no file. Mutation:
653// drop the rebuild loop's context check (red: the files are written and
654// restored before the cancel is seen).
655func TestForwardedPullStopsWhenCancelledAfterTheDownload(t *testing.T) {
656 t.Parallel()
657 dir := t.TempDir()
658 dbdoortest.Replicate(t, dir, 3)
659 key, _ := dbreads.NewReplyKey()
660 st := pullStreamN(t, key, dir, 4, nil)
661 out := t.TempDir()
662 sink := &fileSink{dir: filepath.Join(out, "sealed")}
663 id, res, err := readDBForward(context.Background(), &st.buf, key, "shop.example.com", "uk", dbfFetching, io.Discard, sink, newDBFChunkBound(dbreads.MaxPullBytes, dbreads.PullChunkBytes))
664 if err != nil {
665 t.Fatal(err)
666 }
667 ctx, cancel := context.WithCancel(context.Background())
668 cancel()
669 if _, err := openDBPull(ctx, id, key, sink, *res, "shop.example.com", "uk", out); err == nil || err.Error() != "stopped" {
670 t.Fatalf("a cancelled pull answered %v", err)
671 }
672 for _, gone := range []string{"replica", "replica.db"} {
673 if _, err := os.Stat(filepath.Join(out, gone)); !os.IsNotExist(err) {
674 t.Errorf("%s was written after the cancel (%v)", gone, err)
675 }
676 }
677}
678