| 1 | package main |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "database/sql" |
| 6 | "errors" |
| 7 | "os" |
| 8 | "path/filepath" |
| 9 | "testing" |
| 10 | "time" |
| 11 | |
| 12 | "reasonix/internal/config" |
| 13 | "reasonix/internal/sessioncatalog" |
| 14 | ) |
| 15 | |
| 16 | func TestSessionCatalogDeferredCorruptionReplacesOwnerAndRevokesOldReads(t *testing.T) { |
| 17 | isolateDesktopUserDirs(t) |
| 18 | path := sessioncatalog.DefaultPath() |
| 19 | seed, err := sessioncatalog.Open(t.Context(), sessioncatalog.Options{Path: path, MetadataOnly: true, StartPaused: true, RevisionFloor: 700}) |
| 20 | if err != nil { |
| 21 | t.Fatal(err) |
| 22 | } |
| 23 | if err := seed.Close(t.Context()); err != nil { |
| 24 | t.Fatal(err) |
| 25 | } |
| 26 | db, err := sql.Open("sqlite", path) |
| 27 | if err != nil { |
| 28 | t.Fatal(err) |
| 29 | } |
| 30 | // This defect is invisible to ordinary metadata queries, but the full |
| 31 | // integrity audit must still detect it before certifying this generation. |
| 32 | _, err = db.Exec(`CREATE TABLE integrity_fixture(value INTEGER CHECK(value>=0)); PRAGMA ignore_check_constraints=ON; INSERT INTO integrity_fixture VALUES(-1)`) |
| 33 | closeErr := db.Close() |
| 34 | if err != nil || closeErr != nil { |
| 35 | t.Fatal(err, closeErr) |
| 36 | } |
| 37 | if err := os.MkdirAll(config.SessionDir(), 0700); err != nil { |
| 38 | t.Fatal(err) |
| 39 | } |
| 40 | source := filepath.Join(config.SessionDir(), "untouched.jsonl") |
| 41 | body := []byte("authoritative source must not be repaired by discovery\n") |
| 42 | if err := os.WriteFile(source, body, 0600); err != nil { |
| 43 | t.Fatal(err) |
| 44 | } |
| 45 | app := NewApp() |
| 46 | app.tabsRestored = make(chan struct{}) |
| 47 | app.startSessionCatalog() |
| 48 | t.Cleanup(func() { |
| 49 | if !app.stopSessionCatalog(5 * time.Second) { |
| 50 | t.Error("replacement did not stop cleanly") |
| 51 | } |
| 52 | }) |
| 53 | old := waitForSessionCatalogForTest(t, app, nil) |
| 54 | lease, err := old.OpenReadLease(t.Context()) |
| 55 | if err != nil { |
| 56 | t.Fatal(err) |
| 57 | } |
| 58 | defer lease.Close() |
| 59 | if _, err := old.ListOrdinarySessions(lease.Context(t.Context()), sessioncatalog.OrdinaryPageRequest{Scope: "global", Limit: 50}); err != nil { |
| 60 | t.Fatalf("first metadata page waited for full audit: %v", err) |
| 61 | } |
| 62 | app.markTabsRestored() |
| 63 | select { |
| 64 | case <-old.Invalidated(): |
| 65 | case <-time.After(sessionCatalogTestDeadline): |
| 66 | t.Fatal("full background audit did not find corruption") |
| 67 | } |
| 68 | replacement := waitForSessionCatalogForTest(t, app, old) |
| 69 | if status := replacement.Status(); status.Revision <= 700 || status.QuarantinedPath == "" { |
| 70 | t.Fatalf("replacement lost revision/quarantine evidence: %+v", status) |
| 71 | } |
| 72 | if _, err := old.ListOrdinarySessions(lease.Context(context.Background()), sessioncatalog.OrdinaryPageRequest{Scope: "global"}); !errors.Is(err, sessioncatalog.ErrCatalogInvalidated) { |
| 73 | t.Fatalf("old cursor was not revoked: %v", err) |
| 74 | } |
| 75 | reader := &lazyTopicPageReader{catalog: old, lease: lease, positions: map[int]topicPagePosition{0: {}}} |
| 76 | _, _, readErr := reader.page(t.Context(), 0, 50) |
| 77 | var operationErr *SessionOperationError |
| 78 | if !errors.As(readErr, &operationErr) || operationErr.Code != "stale_cursor" { |
| 79 | t.Fatalf("replaced catalog cursor lost its typed status: %v", readErr) |
| 80 | } |
| 81 | got, err := os.ReadFile(source) |
| 82 | if err != nil || string(got) != string(body) { |
| 83 | t.Fatalf("metadata rebuild changed original content: %v", err) |
| 84 | } |
| 85 | assertSessionCatalogWatcherRunning(t, app) |
| 86 | } |
| 87 |