| 1 | package docs |
| 2 | |
| 3 | import ( |
| 4 | "bytes" |
| 5 | "context" |
| 6 | "encoding/base64" |
| 7 | "errors" |
| 8 | "log/slog" |
| 9 | "net/http" |
| 10 | "path/filepath" |
| 11 | "strings" |
| 12 | "testing" |
| 13 | "time" |
| 14 | |
| 15 | "gorm.io/gorm" |
| 16 | ) |
| 17 | |
| 18 | func activatedContentApp(t *testing.T) (*App, string) { |
| 19 | t.Helper() |
| 20 | key := base64.StdEncoding.EncodeToString(bytes.Repeat([]byte{71}, 32)) |
| 21 | cfg := Config{DBPath: filepath.Join(t.TempDir(), "docs.db"), Env: "development", ContentKey: key} |
| 22 | app, err := New(cfg) |
| 23 | if err != nil { |
| 24 | t.Fatal(err) |
| 25 | } |
| 26 | if err := app.Close(); err != nil { |
| 27 | t.Fatal(err) |
| 28 | } |
| 29 | cipher, err := newContentCipher(key, "production") |
| 30 | if err != nil { |
| 31 | t.Fatal(err) |
| 32 | } |
| 33 | if err := MaintainContent(context.Background(), ContentMaintenanceConfig{DBPath: cfg.DBPath, ContentKey: key, ExpectedFingerprint: cipher.fingerprint}); err != nil { |
| 34 | t.Fatal(err) |
| 35 | } |
| 36 | app, err = New(cfg) |
| 37 | if err != nil { |
| 38 | t.Fatal(err) |
| 39 | } |
| 40 | t.Cleanup(func() { |
| 41 | if err := app.Close(); err != nil { |
| 42 | t.Error(err) |
| 43 | } |
| 44 | }) |
| 45 | return app, key |
| 46 | } |
| 47 | |
| 48 | func TestActivatedContentPersistsSealedAndServesPlainUpdates(t *testing.T) { |
| 49 | app, _ := activatedContentApp(t) |
| 50 | ctx := context.Background() |
| 51 | owner := app.TestPerson(t, "owner@example.com") |
| 52 | doc, err := app.Store().Create(ctx, owner, "Sealed") |
| 53 | if err != nil { |
| 54 | t.Fatal(err) |
| 55 | } |
| 56 | req := app.TestRequest(t, "owner@example.com", http.MethodPost, "/d/"+doc.Sqid+"/updates", bytes.NewReader(validUpdate(t))) |
| 57 | if rec := serve(app, req); rec.Code != http.StatusNoContent { |
| 58 | t.Fatalf("POST: %d %s", rec.Code, rec.Body.String()) |
| 59 | } |
| 60 | var row DocUpdate |
| 61 | if err := app.store.db.First(&row).Error; err != nil { |
| 62 | t.Fatal(err) |
| 63 | } |
| 64 | if row.ContentEncoding != 1 || bytes.Contains(row.UpdateBin, []byte("First paragraph.")) || bytes.Equal(row.UpdateBin, validUpdate(t)) { |
| 65 | t.Fatal("persisted content is not sealed") |
| 66 | } |
| 67 | for _, suffix := range []string{"/updates", "/updates?raw=1"} { |
| 68 | rec := serve(app, app.TestRequest(t, "owner@example.com", http.MethodGet, "/d/"+doc.Sqid+suffix, nil)) |
| 69 | if rec.Code != 200 || !bytes.Equal(rec.Body.Bytes(), validUpdate(t)) { |
| 70 | t.Fatalf("GET %s: %d", suffix, rec.Code) |
| 71 | } |
| 72 | } |
| 73 | h, seq, _, err := app.store.replay(ctx, doc) |
| 74 | if err != nil { |
| 75 | t.Fatal(err) |
| 76 | } |
| 77 | defer h.Close(ctx) |
| 78 | if err := app.store.writeSnapshot(ctx, doc, h, seq); err != nil { |
| 79 | t.Fatal(err) |
| 80 | } |
| 81 | var snap DocSnapshot |
| 82 | if err := app.store.db.First(&snap).Error; err != nil { |
| 83 | t.Fatal(err) |
| 84 | } |
| 85 | if snap.ContentEncoding != 1 || bytes.Contains(snap.State, []byte("First paragraph.")) { |
| 86 | t.Fatal("snapshot is not sealed") |
| 87 | } |
| 88 | rec := serve(app, app.TestRequest(t, "owner@example.com", http.MethodGet, "/d/"+doc.Sqid+"/updates", nil)) |
| 89 | if rec.Code != 200 || bytes.Equal(rec.Body.Bytes(), snap.State) { |
| 90 | t.Fatalf("snapshot GET: %d", rec.Code) |
| 91 | } |
| 92 | if text, err := app.store.Text(ctx, doc); err != nil || !strings.Contains(text, "First paragraph.") { |
| 93 | t.Fatalf("sealed replay text: %q %v", text, err) |
| 94 | } |
| 95 | replayed, _, _, err := app.store.replay(ctx, doc) |
| 96 | if err != nil { |
| 97 | t.Fatal(err) |
| 98 | } |
| 99 | replayed.Close(ctx) |
| 100 | row.UpdateBin[len(row.UpdateBin)-1] ^= 1 |
| 101 | if err := app.store.db.Model(&row).Update("update_bin", row.UpdateBin).Error; err != nil { |
| 102 | t.Fatal(err) |
| 103 | } |
| 104 | rec = serve(app, app.TestRequest(t, "owner@example.com", http.MethodGet, "/d/"+doc.Sqid+"/updates?raw=1", nil)) |
| 105 | if rec.Code != 500 || strings.Contains(rec.Body.String(), "First paragraph.") || rec.Header().Get("Docs-Seq") != "" { |
| 106 | t.Fatal("corrupt update was exposed or acknowledged") |
| 107 | } |
| 108 | } |
| 109 | |
| 110 | func TestLegacyContentWriterRefusesAfterActivation(t *testing.T) { |
| 111 | app, err := NewForTest(t) |
| 112 | if err != nil { |
| 113 | t.Fatal(err) |
| 114 | } |
| 115 | ctx := context.Background() |
| 116 | owner := app.TestPerson(t, "owner@example.com") |
| 117 | doc, err := app.store.Create(ctx, owner, "Legacy") |
| 118 | if err != nil { |
| 119 | t.Fatal(err) |
| 120 | } |
| 121 | h, err := app.core.NewDoc(ctx, doc.GUID, doc.ClientID) |
| 122 | if err != nil { |
| 123 | t.Fatal(err) |
| 124 | } |
| 125 | defer h.Close(ctx) |
| 126 | if err := h.Apply(ctx, validUpdate(t)); err != nil { |
| 127 | t.Fatal(err) |
| 128 | } |
| 129 | cipher, err := newContentCipher("", "development") |
| 130 | if err != nil { |
| 131 | t.Fatal(err) |
| 132 | } |
| 133 | if err := convertContent(ctx, app.store.db, cipher); err != nil { |
| 134 | t.Fatal(err) |
| 135 | } |
| 136 | if seq, err := app.store.ApplyUpdate(ctx, doc, localActor(owner, "owner@example.com"), validUpdate(t)); !errors.Is(err, errContentState) || seq != 0 { |
| 137 | t.Fatalf("stale Store.ApplyUpdate committed: seq %d error %v", seq, err) |
| 138 | } |
| 139 | update := &DocUpdate{DocID: doc.ID, Seq: 1, UpdateBin: validUpdate(t), CreatedAt: time.Now().UTC()} |
| 140 | err = app.store.db.Transaction(func(tx *gorm.DB) error { |
| 141 | if err := app.store.prepareContentUpdate(tx, doc.GUID, update); err != nil { |
| 142 | return err |
| 143 | } |
| 144 | return tx.Create(update).Error |
| 145 | }) |
| 146 | if !errors.Is(err, errContentState) { |
| 147 | t.Fatalf("legacy update: %v", err) |
| 148 | } |
| 149 | if err := app.store.writeSnapshot(ctx, doc, h, 1); !errors.Is(err, errContentState) { |
| 150 | t.Fatalf("legacy snapshot: %v", err) |
| 151 | } |
| 152 | var count int64 |
| 153 | if err := app.store.db.Model(&DocUpdate{}).Count(&count).Error; err != nil || count != 0 { |
| 154 | t.Fatalf("legacy write committed: %d %v", count, err) |
| 155 | } |
| 156 | } |
| 157 | |
| 158 | func TestContentStartupRejectsMixedStateAndMaintenanceRepairsWithoutMarkerRewrite(t *testing.T) { |
| 159 | app, key := activatedContentApp(t) |
| 160 | ctx := context.Background() |
| 161 | doc, err := app.store.Create(ctx, app.TestPerson(t, "owner@example.com"), "Mixed") |
| 162 | if err != nil { |
| 163 | t.Fatal(err) |
| 164 | } |
| 165 | var before contentSealing |
| 166 | if err := app.store.db.First(&before).Error; err != nil { |
| 167 | t.Fatal(err) |
| 168 | } |
| 169 | row := DocUpdate{DocID: doc.ID, Seq: 1, UpdateBin: validUpdate(t), CreatedAt: time.Now().UTC()} |
| 170 | if err := app.store.db.Create(&row).Error; err != nil { |
| 171 | t.Fatal(err) |
| 172 | } |
| 173 | candidate, err := New(app.cfg) |
| 174 | if candidate != nil { |
| 175 | candidate.Close() |
| 176 | } |
| 177 | if !errors.Is(err, errContentMixed) { |
| 178 | t.Fatalf("mixed boot: %v", err) |
| 179 | } |
| 180 | if err := convertContent(ctx, app.store.db, app.store.content); err != nil { |
| 181 | t.Fatal(err) |
| 182 | } |
| 183 | var after contentSealing |
| 184 | if err := app.store.db.First(&after).Error; err != nil { |
| 185 | t.Fatal(err) |
| 186 | } |
| 187 | if before.KeyFingerprint != after.KeyFingerprint || !bytes.Equal(before.KeyCheck, after.KeyCheck) { |
| 188 | t.Fatal("repair rewrote activation marker") |
| 189 | } |
| 190 | if err := app.store.db.First(&row).Error; err != nil { |
| 191 | t.Fatal(err) |
| 192 | } |
| 193 | if row.ContentEncoding != 1 { |
| 194 | t.Fatal("repair retained legacy row") |
| 195 | } |
| 196 | cfg := app.cfg |
| 197 | cfg.ContentKey = base64.StdEncoding.EncodeToString(bytes.Repeat([]byte{72}, 32)) |
| 198 | candidate, err = New(cfg) |
| 199 | if candidate != nil { |
| 200 | candidate.Close() |
| 201 | } |
| 202 | if !errors.Is(err, errContentKeyMismatch) { |
| 203 | t.Fatalf("wrong-key boot: %v", err) |
| 204 | } |
| 205 | cfg.ContentKey = key |
| 206 | candidate, err = New(cfg) |
| 207 | if err != nil { |
| 208 | t.Fatal(err) |
| 209 | } |
| 210 | candidate.Close() |
| 211 | if err := app.store.db.Delete(&contentSealing{}, 1).Error; err != nil { |
| 212 | t.Fatal(err) |
| 213 | } |
| 214 | candidate, err = New(cfg) |
| 215 | if candidate != nil { |
| 216 | candidate.Close() |
| 217 | } |
| 218 | if !errors.Is(err, errContentState) { |
| 219 | t.Fatalf("missing-marker boot: %v", err) |
| 220 | } |
| 221 | } |
| 222 | |
| 223 | func TestLegacyBootDiagnosesUnactivatedContent(t *testing.T) { |
| 224 | var logs bytes.Buffer |
| 225 | app, err := NewForTestWith(t, Config{Logger: slog.New(slog.NewJSONHandler(&logs, nil))}) |
| 226 | if err != nil { |
| 227 | t.Fatal(err) |
| 228 | } |
| 229 | if app.store.content != nil { |
| 230 | t.Fatal("legacy boot activated content") |
| 231 | } |
| 232 | if !strings.Contains(logs.String(), `"level":"ERROR"`) || !strings.Contains(logs.String(), `"content_encoding":0`) { |
| 233 | t.Fatalf("legacy diagnosis absent: %s", logs.String()) |
| 234 | } |
| 235 | } |
| 236 | |
| 237 | func TestContentRepairFailurePreservesActivatedMarkerAndLegacyRows(t *testing.T) { |
| 238 | app, _ := activatedContentApp(t) |
| 239 | doc, err := app.store.Create(context.Background(), app.TestPerson(t, "owner@example.com"), "Repair") |
| 240 | if err != nil { |
| 241 | t.Fatal(err) |
| 242 | } |
| 243 | var before contentSealing |
| 244 | if err := app.store.db.First(&before).Error; err != nil { |
| 245 | t.Fatal(err) |
| 246 | } |
| 247 | rows := []DocUpdate{{DocID: doc.ID, Seq: 1, UpdateBin: validUpdate(t), CreatedAt: time.Now().UTC()}, {DocID: doc.ID, Seq: 2, UpdateBin: validUpdate(t), ContentEncoding: 99, CreatedAt: time.Now().UTC()}} |
| 248 | if err := app.store.db.Create(&rows).Error; err != nil { |
| 249 | t.Fatal(err) |
| 250 | } |
| 251 | if err := convertContent(context.Background(), app.store.db, app.store.content); !errors.Is(err, errContentEncoding) { |
| 252 | t.Fatalf("repair failure: %v", err) |
| 253 | } |
| 254 | var after contentSealing |
| 255 | if err := app.store.db.First(&after).Error; err != nil { |
| 256 | t.Fatal(err) |
| 257 | } |
| 258 | if before.Encoding != after.Encoding || before.KeyFingerprint != after.KeyFingerprint || !bytes.Equal(before.KeyCheck, after.KeyCheck) || before.Scrubbed != after.Scrubbed { |
| 259 | t.Fatal("failed repair changed marker") |
| 260 | } |
| 261 | var row DocUpdate |
| 262 | if err := app.store.db.First(&row, rows[0].ID).Error; err != nil { |
| 263 | t.Fatal(err) |
| 264 | } |
| 265 | if row.ContentEncoding != 0 || !bytes.Equal(row.UpdateBin, validUpdate(t)) { |
| 266 | t.Fatal("failed repair partially converted legacy row") |
| 267 | } |
| 268 | } |
| 269 | |
| 270 | func TestProductionBootRequiresContentKeyBeforeOpeningLegacyDatabase(t *testing.T) { |
| 271 | path := filepath.Join(t.TempDir(), "docs.db") |
| 272 | cfg := Config{DBPath: path, Env: "production", Origin: "https://docs.example.com", InstanceKey: "independent auth key", Mailer: &recordingMailer{}, AllowedAddresses: "owner@example.com"} |
| 273 | app, err := New(cfg) |
| 274 | if app != nil { |
| 275 | app.Close() |
| 276 | } |
| 277 | if !errors.Is(err, errContentKey) { |
| 278 | t.Fatalf("missing production content key: %v", err) |
| 279 | } |
| 280 | } |
| 281 | |
| 282 | func TestStaleLegacyStoreRefusesSealedUpdatesAndSnapshots(t *testing.T) { |
| 283 | app, _ := activatedContentApp(t) |
| 284 | ctx := context.Background() |
| 285 | owner := app.TestPerson(t, "owner@example.com") |
| 286 | doc, err := app.store.Create(ctx, owner, "Stale") |
| 287 | if err != nil { |
| 288 | t.Fatal(err) |
| 289 | } |
| 290 | if _, err := app.store.ApplyUpdate(ctx, doc, localActor(owner, "owner@example.com"), validUpdate(t)); err != nil { |
| 291 | t.Fatal(err) |
| 292 | } |
| 293 | h, seq, _, err := app.store.replay(ctx, doc) |
| 294 | if err != nil { |
| 295 | t.Fatal(err) |
| 296 | } |
| 297 | if err := app.store.writeSnapshot(ctx, doc, h, seq); err != nil { |
| 298 | h.Close(ctx) |
| 299 | t.Fatal(err) |
| 300 | } |
| 301 | h.Close(ctx) |
| 302 | app.store.content = nil |
| 303 | if _, err := app.store.newestSnapshot(ctx, doc.ID); !errors.Is(err, errContentEncoding) { |
| 304 | t.Fatalf("legacy snapshot read: %v", err) |
| 305 | } |
| 306 | rec := serve(app, app.TestRequest(t, "owner@example.com", http.MethodGet, "/d/"+doc.Sqid+"/updates?raw=1", nil)) |
| 307 | if rec.Code != http.StatusInternalServerError || rec.Header().Get("Docs-Seq") != "" { |
| 308 | t.Fatalf("legacy update GET: %d, seq %q", rec.Code, rec.Header().Get("Docs-Seq")) |
| 309 | } |
| 310 | } |
| 311 | |
| 312 | func TestContentStartupRejectsPlaintextSnapshotWithoutLegacyUpdates(t *testing.T) { |
| 313 | app, _ := activatedContentApp(t) |
| 314 | doc, err := app.store.Create(context.Background(), app.TestPerson(t, "owner@example.com"), "Snapshot") |
| 315 | if err != nil { |
| 316 | t.Fatal(err) |
| 317 | } |
| 318 | snapshot := DocSnapshot{DocID: doc.ID, Seq: 1, State: validUpdate(t), ABIVersion: coreABIVersion, CreatedAt: time.Now().UTC()} |
| 319 | if err := app.store.db.Create(&snapshot).Error; err != nil { |
| 320 | t.Fatal(err) |
| 321 | } |
| 322 | candidate, err := New(app.cfg) |
| 323 | if candidate != nil { |
| 324 | candidate.Close() |
| 325 | } |
| 326 | if !errors.Is(err, errContentMixed) { |
| 327 | t.Fatalf("plaintext snapshot boot: %v", err) |
| 328 | } |
| 329 | } |
| 330 | |