| 1 | package store_test |
| 2 | |
| 3 | // This file closes a gap Task 2 explicitly left open. Task 2 built the |
| 4 | // plaintext gate and proved it can fail, but only against a plain file |
| 5 | // standing in for a database, with a synthetic -wal sidecar it seeded |
| 6 | // itself. There was no real store and no real SQLite driver yet. |
| 7 | // |
| 8 | // Now there is one. These tests prove, against the actual rastrillo/db |
| 9 | // driver in actual WAL mode: |
| 10 | // |
| 11 | // 1. TestUncheckpointedWriteLandsInRealWALFile: a write that is never |
| 12 | // checkpointed really does put bytes in <db>-wal on disk (not just in |
| 13 | // an in-process buffer), and sealtest.AssertNoPlaintext really does |
| 14 | // find a plaintext canary there. |
| 15 | // 2. TestSealedWriteIsUnflushedBeforeExplicitCheckpoint: nothing between |
| 16 | // an insert and the gate implicitly checkpoints or vacuums the file, so |
| 17 | // TestInputEventIsSealedOnDisk's own checkpoint() call is doing real |
| 18 | // work — the gate in that test is inspecting a file settled by an |
| 19 | // explicit action, not one that happened to already be settled for an |
| 20 | // unrelated reason. |
| 21 | |
| 22 | import ( |
| 23 | "bytes" |
| 24 | "os" |
| 25 | "os/exec" |
| 26 | "path/filepath" |
| 27 | "strings" |
| 28 | "testing" |
| 29 | "time" |
| 30 | |
| 31 | "amadan.net/oficina/sheets/internal/sheets/seal" |
| 32 | "amadan.net/oficina/sheets/internal/sheets/sealtest" |
| 33 | "amadan.net/oficina/sheets/internal/sheets/store" |
| 34 | ) |
| 35 | |
| 36 | // TestUncheckpointedWriteLandsInRealWALFile writes a plaintext canary |
| 37 | // directly through the real driver — standing in for a sealing bug, the |
| 38 | // same way step 5 of the task brief breaks AppendInput — in WAL mode, and |
| 39 | // deliberately never checkpoints. It then confirms two things in order: |
| 40 | // |
| 41 | // - mechanically, without going through sealtest at all, that the bytes |
| 42 | // are really sitting in <db>-wal on disk and NOT yet in the main file; |
| 43 | // if this failed, the whole proof would be worthless because it would |
| 44 | // mean the write never left memory, or WAL mode was not actually on; |
| 45 | // - that sealtest.AssertNoPlaintext, run against exactly this |
| 46 | // uncheckpointed file, reports the canary living in the -wal file. |
| 47 | // |
| 48 | // The second half runs sealtest.AssertNoPlaintext in a subprocess: that |
| 49 | // function calls t.Fatal on a real leak, and here a leak is exactly what |
| 50 | // we expect, so the failure has to be captured rather than allowed to fail |
| 51 | // this test's own build. |
| 52 | func TestUncheckpointedWriteLandsInRealWALFile(t *testing.T) { |
| 53 | const canary = "CANARY-CELL-VALUE-8f3a" |
| 54 | |
| 55 | dir := t.TempDir() |
| 56 | dbPath := filepath.Join(dir, "test.db") |
| 57 | db := openTestDB(t, dbPath) |
| 58 | |
| 59 | var jm string |
| 60 | if err := db.Raw("PRAGMA journal_mode").Scan(&jm).Error; err != nil { |
| 61 | t.Fatal(err) |
| 62 | } |
| 63 | if jm != "wal" { |
| 64 | t.Fatalf("journal_mode = %q, want wal (rastrillo/db.Open's DSN should have set this)", jm) |
| 65 | } |
| 66 | |
| 67 | // A raw insert of plaintext bypassing seal.Sealer entirely — this is |
| 68 | // what a sealing bug looks like on the wire, not what AppendInput does |
| 69 | // today. |
| 70 | if err := db.Exec( |
| 71 | `INSERT INTO events (kind, tab_id, actor_id, payload_sealed, trigger_id, derived_count, created_at) |
| 72 | VALUES (?, ?, ?, ?, ?, ?, ?)`, |
| 73 | store.KindInput, uint64(1), uint64(7), []byte(canary), uint64(0), 0, time.Now(), |
| 74 | ).Error; err != nil { |
| 75 | t.Fatalf("raw insert: %v", err) |
| 76 | } |
| 77 | |
| 78 | walBytes, err := os.ReadFile(dbPath + "-wal") |
| 79 | if err != nil { |
| 80 | t.Fatalf("read WAL file: %v (a missing WAL file here would mean the write never reached disk before a checkpoint, making this proof meaningless)", err) |
| 81 | } |
| 82 | if len(walBytes) == 0 { |
| 83 | t.Fatal("WAL file is empty; the write did not land there") |
| 84 | } |
| 85 | if !bytes.Contains(walBytes, []byte(canary)) { |
| 86 | t.Fatal("canary not found in the uncheckpointed WAL file bytes; cannot confirm the write actually reached <db>-wal on disk") |
| 87 | } |
| 88 | mainBytes, err := os.ReadFile(dbPath) |
| 89 | if err != nil { |
| 90 | t.Fatalf("read main db file: %v", err) |
| 91 | } |
| 92 | if bytes.Contains(mainBytes, []byte(canary)) { |
| 93 | t.Fatal("canary already present in the main db file; this write was not actually sitting only in the WAL, which defeats the point of this test") |
| 94 | } |
| 95 | |
| 96 | // Now ask the real gate. Run in a subprocess because AssertNoPlaintext |
| 97 | // is expected to Fatal here, and that is the assertion, not a bug. |
| 98 | if os.Getenv("SHEETS_WAL_GATE_SUBPROCESS") == "1" { |
| 99 | sealtest.AssertNoPlaintext(t, dbPath) |
| 100 | return |
| 101 | } |
| 102 | cmd := exec.Command(os.Args[0], |
| 103 | "-test.run=^TestUncheckpointedWriteLandsInRealWALFile$", "-test.v") |
| 104 | cmd.Env = append(os.Environ(), "SHEETS_WAL_GATE_SUBPROCESS=1") |
| 105 | // The subprocess re-executes this whole test function, including the |
| 106 | // setup above, into a NEW temp dir (t.TempDir() differs per process), |
| 107 | // so it performs its own insert rather than reusing dbPath. That is |
| 108 | // fine: the property under test — an uncheckpointed real WAL write is |
| 109 | // caught — does not depend on reusing the parent's files. |
| 110 | out, runErr := cmd.CombinedOutput() |
| 111 | if runErr == nil { |
| 112 | t.Fatalf("expected the gate subprocess to fail against an uncheckpointed WAL write; it exited 0:\n%s", out) |
| 113 | } |
| 114 | if !strings.Contains(string(out), "test.db-wal") { |
| 115 | t.Fatalf("gate subprocess failed, but not evidently by finding the canary in the WAL file; output:\n%s", out) |
| 116 | } |
| 117 | if !strings.Contains(string(out), canary) { |
| 118 | t.Fatalf("gate subprocess failure did not name the canary; output:\n%s", out) |
| 119 | } |
| 120 | } |
| 121 | |
| 122 | // TestSealedWriteIsUnflushedBeforeExplicitCheckpoint proves that nothing |
| 123 | // between AppendInput and the gate implicitly checkpoints (or vacuums) the |
| 124 | // database. If it did, TestInputEventIsSealedOnDisk's checkpoint() call |
| 125 | // would be inspecting a file that was already settled for some unrelated |
| 126 | // reason — a gate that would appear to work but would actually be agreeing |
| 127 | // with itself, exactly what the brief warns against. |
| 128 | func TestSealedWriteIsUnflushedBeforeExplicitCheckpoint(t *testing.T) { |
| 129 | dir := t.TempDir() |
| 130 | dbPath := filepath.Join(dir, "test.db") |
| 131 | db := openTestDB(t, dbPath) |
| 132 | s, err := seal.New(make32()) |
| 133 | if err != nil { |
| 134 | t.Fatal(err) |
| 135 | } |
| 136 | |
| 137 | if err := store.AppendInput(db, s, store.InputEvent{ |
| 138 | TabID: 1, RowID: 1, ColID: 1, ActorID: 7, |
| 139 | NewRaw: "CANARY-CELL-VALUE-8f3a", |
| 140 | }); err != nil { |
| 141 | t.Fatal(err) |
| 142 | } |
| 143 | |
| 144 | // Read back exactly the row just written, so the check below can prove |
| 145 | // THIS write's bytes are unflushed — not merely that the WAL file is |
| 146 | // non-empty, which a completely unrelated pending write could also |
| 147 | // satisfy and would say nothing about this insert specifically. |
| 148 | var written store.Event |
| 149 | if err := db.Where("kind = ?", store.KindInput).Order("id DESC"). |
| 150 | First(&written).Error; err != nil { |
| 151 | t.Fatalf("read back the just-written event: %v", err) |
| 152 | } |
| 153 | if len(written.PayloadSealed) == 0 { |
| 154 | t.Fatal("just-written event has no sealed payload to trace into the WAL") |
| 155 | } |
| 156 | |
| 157 | // Before any explicit checkpoint call: the WAL file must already exist, |
| 158 | // be non-empty, and contain THIS row's sealed payload bytes verbatim |
| 159 | // (the AES-GCM ciphertext is unique per call, courtesy of its random |
| 160 | // nonce, so this is not a coincidental match). If any of that is not |
| 161 | // so, the insert above was already folded into the main file by |
| 162 | // something other than an explicit checkpoint — a connection-close |
| 163 | // checkpoint, pool churn, migrate.Apply side effects, or journal_mode |
| 164 | // defaulting to something other than WAL — and the sealed-path test's |
| 165 | // checkpoint() call would be a no-op. |
| 166 | info, err := os.Stat(dbPath + "-wal") |
| 167 | if err != nil { |
| 168 | t.Fatalf("stat WAL before any explicit checkpoint: %v", err) |
| 169 | } |
| 170 | if info.Size() == 0 { |
| 171 | t.Fatal("WAL file is empty before the explicit checkpoint call; the write was already flushed by something implicit, which would make checkpoint() a no-op") |
| 172 | } |
| 173 | walBytes, err := os.ReadFile(dbPath + "-wal") |
| 174 | if err != nil { |
| 175 | t.Fatalf("read WAL before any explicit checkpoint: %v", err) |
| 176 | } |
| 177 | if !bytes.Contains(walBytes, written.PayloadSealed) { |
| 178 | t.Fatal("this write's sealed payload is not present in the uncheckpointed WAL bytes; a non-empty WAL file is not evidence that THIS write specifically is still unflushed") |
| 179 | } |
| 180 | |
| 181 | var autoVacuum int |
| 182 | if err := db.Raw("PRAGMA auto_vacuum").Scan(&autoVacuum).Error; err != nil { |
| 183 | t.Fatal(err) |
| 184 | } |
| 185 | if autoVacuum != 0 { |
| 186 | t.Fatalf("auto_vacuum = %d, want 0 (off); an active auto-vacuum could rewrite pages behind the gate's back", autoVacuum) |
| 187 | } |
| 188 | |
| 189 | // checkpoint() itself is exercised, and its precondition proven |
| 190 | // meaningful, by TestInputEventIsSealedOnDisk. This test's job stops |
| 191 | // at proving that precondition — that there is real, unflushed work |
| 192 | // for it to do. |
| 193 | } |
| 194 | |