| 1 | package memoriatest |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "io" |
| 6 | "log/slog" |
| 7 | "net/http" |
| 8 | "net/url" |
| 9 | "path/filepath" |
| 10 | "strings" |
| 11 | "testing" |
| 12 | "time" |
| 13 | |
| 14 | "amadan.net/rastrillo/rastrillo/db" |
| 15 | |
| 16 | memoria "amadan.net/oficina/memoria/internal/memoria" |
| 17 | "amadan.net/oficina/memoria/internal/memoria/notion" |
| 18 | ) |
| 19 | |
| 20 | // testApp is the app's test surface. Declared as an interface because |
| 21 | // AppForTest returns an unexported type, which a test in another package |
| 22 | // cannot name. |
| 23 | type testApp interface { |
| 24 | Store() *memoria.Store |
| 25 | AsPerson(string) |
| 26 | SetOwners([]string) |
| 27 | UseFakeSource(memoria.SourceClient, func(context.Context, string) (string, error)) |
| 28 | WaitForSync(int64, time.Duration) bool |
| 29 | } |
| 30 | |
| 31 | // fakeSource is a Notion stand-in for the run loop: a tree, some bodies, |
| 32 | // and per-page errors. No HTTP — the client's own package tests that. |
| 33 | type fakeSource struct { |
| 34 | roots []notion.Ref |
| 35 | children map[string][]notion.Ref |
| 36 | bodies map[string]string |
| 37 | unread map[string]int |
| 38 | failWith map[string]error |
| 39 | fetches map[string]int |
| 40 | // orphans are results search returned whose parent it did not — a |
| 41 | // page inside a toggle, or a middle ancestor missing from an |
| 42 | // eventually-consistent index. They must not be treated as roots. |
| 43 | orphans []notion.Ref |
| 44 | } |
| 45 | |
| 46 | func newSource() *fakeSource { |
| 47 | return &fakeSource{ |
| 48 | children: map[string][]notion.Ref{}, bodies: map[string]string{}, |
| 49 | unread: map[string]int{}, failWith: map[string]error{}, fetches: map[string]int{}, |
| 50 | } |
| 51 | } |
| 52 | |
| 53 | func (f *fakeSource) Roots(ctx context.Context) (roots, orphans []notion.Ref, err error) { |
| 54 | if e := f.failWith["__roots__"]; e != nil { |
| 55 | return nil, nil, e |
| 56 | } |
| 57 | return f.roots, f.orphans, nil |
| 58 | } |
| 59 | |
| 60 | func (f *fakeSource) Fetch(ctx context.Context, ref notion.Ref) (*notion.Content, error) { |
| 61 | f.fetches[ref.ID]++ |
| 62 | if err := f.failWith[ref.ID]; err != nil { |
| 63 | return nil, err |
| 64 | } |
| 65 | return ¬ion.Content{ |
| 66 | Markdown: f.bodies[ref.ID], |
| 67 | Children: f.children[ref.ID], |
| 68 | UnreadBlocks: f.unread[ref.ID], |
| 69 | }, nil |
| 70 | } |
| 71 | |
| 72 | // add puts a page in the tree. parent "" makes it a root. |
| 73 | func (f *fakeSource) add(id, title, parent, body string, path ...string) { |
| 74 | ref := notion.Ref{ID: id, Title: title, Path: path, EditedTime: "t1", Kind: notion.KindPage} |
| 75 | f.bodies[id] = body |
| 76 | if parent == "" { |
| 77 | f.roots = append(f.roots, ref) |
| 78 | return |
| 79 | } |
| 80 | f.children[parent] = append(f.children[parent], ref) |
| 81 | } |
| 82 | |
| 83 | func (f *fakeSource) edit(id, body, editedTime string) { |
| 84 | f.bodies[id] = body |
| 85 | f.retime(id, editedTime) |
| 86 | } |
| 87 | |
| 88 | func (f *fakeSource) retime(id, editedTime string) { |
| 89 | for i := range f.roots { |
| 90 | if f.roots[i].ID == id { |
| 91 | f.roots[i].EditedTime = editedTime |
| 92 | } |
| 93 | } |
| 94 | for p := range f.children { |
| 95 | for i := range f.children[p] { |
| 96 | if f.children[p][i].ID == id { |
| 97 | f.children[p][i].EditedTime = editedTime |
| 98 | } |
| 99 | } |
| 100 | } |
| 101 | } |
| 102 | |
| 103 | // rename changes a page's title wherever it appears, and rewrites the |
| 104 | // inherited ancestry of everything below it — which is what Notion does |
| 105 | // NOT tell us about, since an ancestor rename leaves a descendant's own |
| 106 | // last_edited_time untouched. |
| 107 | func (f *fakeSource) rename(id, was, now string) { |
| 108 | retitle := func(r *notion.Ref) { |
| 109 | if r.ID == id { |
| 110 | r.Title = now |
| 111 | } |
| 112 | for i, seg := range r.Path { |
| 113 | if seg == was { |
| 114 | r.Path[i] = now |
| 115 | } |
| 116 | } |
| 117 | } |
| 118 | for i := range f.roots { |
| 119 | retitle(&f.roots[i]) |
| 120 | } |
| 121 | for p := range f.children { |
| 122 | for i := range f.children[p] { |
| 123 | retitle(&f.children[p][i]) |
| 124 | } |
| 125 | } |
| 126 | } |
| 127 | |
| 128 | // connectorApp builds an app with a configured connector and a fake |
| 129 | // Notion behind it. |
| 130 | func connectorApp(t *testing.T, src *fakeSource) (http.Handler, testApp, int64, *strings.Builder) { |
| 131 | t.Helper() |
| 132 | var logs strings.Builder |
| 133 | logger := slog.New(slog.NewTextHandler(&logs, &slog.HandlerOptions{Level: slog.LevelDebug})) |
| 134 | d, err := db.Open(filepath.Join(t.TempDir(), "app.db"), logger) |
| 135 | if err != nil { |
| 136 | t.Fatalf("db.Open: %v", err) |
| 137 | } |
| 138 | t.Cleanup(func() { d.Close() }) |
| 139 | mux, a, err := memoria.AppForTest(d, testOrigin, testDEK(t), logger) |
| 140 | if err != nil { |
| 141 | t.Fatalf("AppForTest: %v", err) |
| 142 | } |
| 143 | app := any(a).(testApp) |
| 144 | app.UseFakeSource(src, func(ctx context.Context, token string) (string, error) { |
| 145 | return "workspace-1", nil |
| 146 | }) |
| 147 | if err := app.Store().SaveConnector(context.Background(), "notion", "ntn_secret_token_value", "workspace-1"); err != nil { |
| 148 | t.Fatal(err) |
| 149 | } |
| 150 | c, err := app.Store().Connector(context.Background(), "notion") |
| 151 | if err != nil { |
| 152 | t.Fatal(err) |
| 153 | } |
| 154 | return mux, app, c.ID, &logs |
| 155 | } |
| 156 | |
| 157 | func sync(t *testing.T, h http.Handler, app testApp, connectorID int64) { |
| 158 | t.Helper() |
| 159 | rec := post(t, h, "/connectors/notion/sync", url.Values{}) |
| 160 | if rec.Code != http.StatusSeeOther { |
| 161 | t.Fatalf("sync: status %d, want 303", rec.Code) |
| 162 | } |
| 163 | if !app.WaitForSync(connectorID, 5*time.Second) { |
| 164 | t.Fatal("sync did not finish") |
| 165 | } |
| 166 | } |
| 167 | |
| 168 | func latest(t *testing.T, app testApp, connectorID int64) *memoria.SyncRun { |
| 169 | t.Helper() |
| 170 | run, err := app.Store().LatestRun(context.Background(), connectorID) |
| 171 | if err != nil { |
| 172 | t.Fatal(err) |
| 173 | } |
| 174 | if run == nil { |
| 175 | t.Fatal("no run recorded") |
| 176 | } |
| 177 | return run |
| 178 | } |
| 179 | |
| 180 | // TestSyncImportsTheTreeWithAncestry is the happy path, and the vacuity |
| 181 | // guard for everything below it: a gate about failures proves nothing if |
| 182 | // the success case never worked. |
| 183 | func TestSyncImportsTheTreeWithAncestry(t *testing.T) { |
| 184 | src := newSource() |
| 185 | src.add("root", "Handbook", "", "# Welcome\n\nEverything a new starter needs.") |
| 186 | src.add("kid", "Onboarding", "root", "Week one covers expenses and travel booking.", "Handbook") |
| 187 | src.add("grandkid", "Equipment", "kid", "Laptops are ordered through the request form.", "Handbook", "Onboarding") |
| 188 | |
| 189 | h, app, cid, logs := connectorApp(t, src) |
| 190 | sync(t, h, app, cid) |
| 191 | |
| 192 | run := latest(t, app, cid) |
| 193 | if run.State != memoria.RunDone { |
| 194 | t.Fatalf("run state %q, want done (error %q)\nLOGS:\n%s", run.State, run.ErrorCode, logs.String()) |
| 195 | } |
| 196 | if run.Total != 3 { |
| 197 | t.Errorf("total %d, want 3", run.Total) |
| 198 | } |
| 199 | if run.Fetched != 3 { |
| 200 | t.Errorf("fetched %d, want 3", run.Fetched) |
| 201 | } |
| 202 | // The ancestry must be searchable, or carrying it was pointless. |
| 203 | hits, err := searchAsReader(app.Store(), context.Background(), "onboarding", 10) |
| 204 | if err != nil { |
| 205 | t.Fatal(err) |
| 206 | } |
| 207 | if len(hits) == 0 { |
| 208 | t.Error("the imported ancestry is not searchable") |
| 209 | } |
| 210 | } |
| 211 | |
| 212 | // TestAFailedPageIsNamedNotAbsent. A page that has gone between |
| 213 | // enumeration and fetch must be counted and recorded, never dropped: 3 |
| 214 | // unreadable pages inside 1,930 that simply vanish read as a complete |
| 215 | // import. |
| 216 | // |
| 217 | // MUTATION-TESTED 2026-09-05: replacing the markByPage/stats.failed++ in |
| 218 | // the gone(err) branch with a bare `continue` makes this fail. Reverted. |
| 219 | func TestAFailedPageIsNamedNotAbsent(t *testing.T) { |
| 220 | src := newSource() |
| 221 | src.add("root", "Handbook", "", "Everything a new starter needs.") |
| 222 | src.add("kid", "Onboarding", "root", "Week one.", "Handbook") |
| 223 | src.failWith["kid"] = ¬ion.Error{Code: notion.CodeTraversalFailed, Status: 404} |
| 224 | |
| 225 | h, app, cid, _ := connectorApp(t, src) |
| 226 | sync(t, h, app, cid) |
| 227 | |
| 228 | run := latest(t, app, cid) |
| 229 | if run.State != memoria.RunDone { |
| 230 | t.Errorf("state %q, want done — one missing page must not fail the whole import", run.State) |
| 231 | } |
| 232 | if run.FailedPages != 1 { |
| 233 | t.Errorf("failed_pages %d, want 1 — the page vanished from the totals", run.FailedPages) |
| 234 | } |
| 235 | // The label is unconditional markup, so asserting on it proves |
| 236 | // nothing — an earlier version of this test did exactly that. The |
| 237 | // NOTE beside the count renders only when there were failures. |
| 238 | body := get(t, h, "/connectors/notion").Body.String() |
| 239 | if !strings.Contains(body, "counted here rather than left out of the totals") { |
| 240 | t.Error("the status page does not say the unreadable pages are counted rather than dropped") |
| 241 | } |
| 242 | } |
| 243 | |
| 244 | // TestTraversalFailureNeverReportsDone. A failure BELOW a page means the |
| 245 | // subtree was never enumerated. Continuing would report a complete |
| 246 | // import while missing everything under that point — a failure rendered |
| 247 | // as an absence, which this codebase ranks as the worst available. |
| 248 | // |
| 249 | // MUTATION-TESTED 2026-09-05: treating every fetch error as a per-page |
| 250 | // failure (making gone() return true unconditionally) makes this fail. |
| 251 | // Reverted. |
| 252 | func TestTraversalFailureNeverReportsDone(t *testing.T) { |
| 253 | src := newSource() |
| 254 | src.add("root", "Handbook", "", "Everything a new starter needs.") |
| 255 | src.add("kid", "Onboarding", "root", "Week one.", "Handbook") |
| 256 | src.failWith["kid"] = ¬ion.Error{Code: notion.CodeRateLimited, Status: 429} |
| 257 | |
| 258 | h, app, cid, _ := connectorApp(t, src) |
| 259 | sync(t, h, app, cid) |
| 260 | |
| 261 | run := latest(t, app, cid) |
| 262 | if run.State != memoria.RunFailed { |
| 263 | t.Fatalf("state %q, want failed — the pages below the failure were never seen", run.State) |
| 264 | } |
| 265 | if run.ErrorCode != string(notion.CodeRateLimited) { |
| 266 | t.Errorf("error_code %q, want %q", run.ErrorCode, notion.CodeRateLimited) |
| 267 | } |
| 268 | body := get(t, h, "/connectors/notion").Body.String() |
| 269 | if strings.Contains(body, "Import finished.") { |
| 270 | t.Error("a failed import is described as finished") |
| 271 | } |
| 272 | } |
| 273 | |
| 274 | // TestSyncNeverDeletesOnAbsence. memoria is replacing Notion, so a page |
| 275 | // disappearing upstream is the expected end state of a migration, not a |
| 276 | // signal to forget it. Archiving a page in Notion after migrating it |
| 277 | // must not delete it here. |
| 278 | // |
| 279 | // MUTATION-TESTED 2026-09-05: adding a diff-and-delete pass over ledger |
| 280 | // rows not seen by the latest run makes this fail. Reverted. |
| 281 | func TestSyncNeverDeletesOnAbsence(t *testing.T) { |
| 282 | src := newSource() |
| 283 | src.add("root", "Handbook", "", "Everything a new starter needs.") |
| 284 | src.add("kid", "Onboarding", "root", "Week one covers expenses.", "Handbook") |
| 285 | |
| 286 | h, app, cid, _ := connectorApp(t, src) |
| 287 | sync(t, h, app, cid) |
| 288 | if hits, _ := searchAsReader(app.Store(), context.Background(), "expenses", 10); len(hits) == 0 { |
| 289 | t.Fatal("the page was never imported, so its survival proves nothing") |
| 290 | } |
| 291 | |
| 292 | // The page is archived in Notion: it is simply no longer returned. |
| 293 | src.children["root"] = nil |
| 294 | sync(t, h, app, cid) |
| 295 | |
| 296 | run := latest(t, app, cid) |
| 297 | if run.State != memoria.RunDone { |
| 298 | t.Errorf("state %q, want done — an absence is not an error either", run.State) |
| 299 | } |
| 300 | if hits, _ := searchAsReader(app.Store(), context.Background(), "expenses", 10); len(hits) == 0 { |
| 301 | t.Error("a page that vanished from Notion was deleted from memoria") |
| 302 | } |
| 303 | } |
| 304 | |
| 305 | // TestResumeSkipsWhatIsAlreadyImported. The ledger is per connector, not |
| 306 | // per run, which is what makes a second run continue rather than start |
| 307 | // over. |
| 308 | // |
| 309 | // MUTATION-TESTED 2026-09-05: making upToDate always return false |
| 310 | // re-ingests everything and makes the skipped assertion fail. Reverted. |
| 311 | func TestResumeSkipsWhatIsAlreadyImported(t *testing.T) { |
| 312 | src := newSource() |
| 313 | src.add("root", "Handbook", "", "Everything a new starter needs.") |
| 314 | src.add("kid", "Onboarding", "root", "Week one covers expenses.", "Handbook") |
| 315 | |
| 316 | h, app, cid, _ := connectorApp(t, src) |
| 317 | sync(t, h, app, cid) |
| 318 | sync(t, h, app, cid) |
| 319 | |
| 320 | run := latest(t, app, cid) |
| 321 | if run.Skipped != 2 { |
| 322 | t.Errorf("skipped %d on an unchanged re-sync, want 2 — nothing changed upstream", run.Skipped) |
| 323 | } |
| 324 | if run.Fetched != 0 { |
| 325 | t.Errorf("fetched %d on an unchanged re-sync, want 0", run.Fetched) |
| 326 | } |
| 327 | } |
| 328 | |
| 329 | // TestEditAndAncestorRenameBothReachMemoria. |
| 330 | // |
| 331 | // The rename half is the one an earlier design missed entirely. Editing |
| 332 | // a page changes its own last_edited_time; renaming its ANCESTOR does |
| 333 | // not, so a sync keyed only on edit times leaves every descendant of a |
| 334 | // renamed page carrying stale ancestry forever, with nothing upstream |
| 335 | // ever signalling it. |
| 336 | // |
| 337 | // MUTATION-TESTED 2026-09-05: dropping the IngestedPath comparison from |
| 338 | // upToDate (leaving only the edit-time check) makes the rename half |
| 339 | // fail. Reverted. |
| 340 | func TestEditAndAncestorRenameBothReachMemoria(t *testing.T) { |
| 341 | src := newSource() |
| 342 | src.add("root", "Handbook", "", "Everything a new starter needs.") |
| 343 | src.add("kid", "Onboarding", "root", "Week one covers expenses.", "Handbook") |
| 344 | |
| 345 | h, app, cid, _ := connectorApp(t, src) |
| 346 | sync(t, h, app, cid) |
| 347 | |
| 348 | t.Run("an edit is re-ingested", func(t *testing.T) { |
| 349 | src.edit("kid", "Week one now covers parking permits.", "t2") |
| 350 | sync(t, h, app, cid) |
| 351 | hits, _ := searchAsReader(app.Store(), context.Background(), "parking permits", 10) |
| 352 | if len(hits) == 0 { |
| 353 | t.Error("an edited page was not re-ingested") |
| 354 | } |
| 355 | }) |
| 356 | |
| 357 | t.Run("an ancestor rename is re-ingested", func(t *testing.T) { |
| 358 | // Handbook becomes Playbook. The CHILD's own edit time does not |
| 359 | // move — Notion would not move it either. |
| 360 | src.rename("root", "Handbook", "Playbook") |
| 361 | sync(t, h, app, cid) |
| 362 | |
| 363 | if hits, _ := searchAsReader(app.Store(), context.Background(), "playbook", 10); len(hits) == 0 { |
| 364 | t.Error("the new ancestry is not searchable: an ancestor rename never reached memoria") |
| 365 | } |
| 366 | if hits, _ := searchAsReader(app.Store(), context.Background(), "handbook", 10); len(hits) != 0 { |
| 367 | t.Error("the old ancestry is still searchable after an ancestor rename") |
| 368 | } |
| 369 | }) |
| 370 | } |
| 371 | |
| 372 | // TestInterruptedRunDoesNotWedgeTheConnector. |
| 373 | // |
| 374 | // The run is a goroutine. When the platform sleeps the process it dies |
| 375 | // having written nothing, leaving the row "fetching" — and a |
| 376 | // single-flight check that trusted that row would refuse every later |
| 377 | // sync as already-running, permanently, on the NORMAL path. Boot |
| 378 | // reclaims them, which is sound by construction: a process that has just |
| 379 | // started cannot be running a sync. |
| 380 | // |
| 381 | // MUTATION-TESTED 2026-09-05: removing the ReclaimRuns call from App() |
| 382 | // makes this fail with "a sync is already running". Reverted. |
| 383 | func TestInterruptedRunDoesNotWedgeTheConnector(t *testing.T) { |
| 384 | src := newSource() |
| 385 | src.add("root", "Handbook", "", "Everything a new starter needs.") |
| 386 | |
| 387 | dir := t.TempDir() |
| 388 | path := filepath.Join(dir, "app.db") |
| 389 | logger := slog.New(slog.NewTextHandler(io.Discard, nil)) |
| 390 | |
| 391 | d, err := db.Open(path, logger) |
| 392 | if err != nil { |
| 393 | t.Fatal(err) |
| 394 | } |
| 395 | _, a, err := memoria.AppForTest(d, testOrigin, testDEK(t), logger) |
| 396 | if err != nil { |
| 397 | t.Fatal(err) |
| 398 | } |
| 399 | app := any(a).(testApp) |
| 400 | if err := app.Store().SaveConnector(context.Background(), "notion", "tok", "w"); err != nil { |
| 401 | t.Fatal(err) |
| 402 | } |
| 403 | c, err := app.Store().Connector(context.Background(), "notion") |
| 404 | if err != nil { |
| 405 | t.Fatal(err) |
| 406 | } |
| 407 | // A run starts and the process dies: the row stays in flight. |
| 408 | if _, err := app.Store().StartRun(context.Background(), c.ID); err != nil { |
| 409 | t.Fatal(err) |
| 410 | } |
| 411 | d.Close() |
| 412 | |
| 413 | // Boot again over the same database. |
| 414 | d2, err := db.Open(path, logger) |
| 415 | if err != nil { |
| 416 | t.Fatal(err) |
| 417 | } |
| 418 | t.Cleanup(func() { d2.Close() }) |
| 419 | h2, a2, err := memoria.AppForTest(d2, testOrigin, testDEK(t), logger) |
| 420 | if err != nil { |
| 421 | t.Fatal(err) |
| 422 | } |
| 423 | app2 := any(a2).(testApp) |
| 424 | app2.UseFakeSource(src, nil) |
| 425 | |
| 426 | rec := post(t, h2, "/connectors/notion/sync", url.Values{}) |
| 427 | if loc := rec.Header().Get("Location"); strings.Contains(loc, "err=inflight") { |
| 428 | t.Fatal("the connector is wedged: a run killed by a restart still blocks every later sync") |
| 429 | } |
| 430 | if !app2.WaitForSync(c.ID, 5*time.Second) { |
| 431 | t.Fatal("the new run did not finish") |
| 432 | } |
| 433 | } |
| 434 | |
| 435 | // TestNoNotionContentInLogs extends gate 4 to the connector. Titles, |
| 436 | // bodies, ancestry, page ids and the token are all content or |
| 437 | // coordinates, and `carlos logs` is storage we cannot grep. |
| 438 | // |
| 439 | // The failing page's TITLE is the canary, so the fetch-error path is |
| 440 | // exercised with content in scope. It is not a canary in a response |
| 441 | // body: this fake returns a Go error with no body at all, and an earlier |
| 442 | // version of this comment claimed otherwise. Notion's real 404 body does |
| 443 | // quote a page id and the connection name, and that path is covered |
| 444 | // where it can be — notion.TestErrorsCarryNoResponseBody. |
| 445 | // |
| 446 | // MUTATION-TESTED 2026-09-05: logging ref.Title at Info in the fetch |
| 447 | // loop, or %w-ing the notion error into the run's failure line, makes |
| 448 | // this fail. Reverted. |
| 449 | func TestNoNotionContentInLogs(t *testing.T) { |
| 450 | const secret = "WOMBATCONFIDENTIAL9931" |
| 451 | src := newSource() |
| 452 | src.add("root", secret+" handbook", "", "A passage about "+secret+" and revenue.") |
| 453 | src.add("kid", "Onboarding", "root", "Week one.", secret+" handbook") |
| 454 | src.failWith["kid"] = ¬ion.Error{Code: notion.CodeTraversalFailed, Status: 404} |
| 455 | |
| 456 | h, app, cid, logs := connectorApp(t, src) |
| 457 | sync(t, h, app, cid) |
| 458 | get(t, h, "/connectors/notion") |
| 459 | |
| 460 | got := logs.String() |
| 461 | if strings.Contains(got, secret) { |
| 462 | t.Errorf("a log line carries Notion content:\n%s", got) |
| 463 | } |
| 464 | if strings.Contains(got, "ntn_secret_token_value") { |
| 465 | t.Errorf("a log line carries the integration token:\n%s", got) |
| 466 | } |
| 467 | // Vacuity guard: the run must actually have logged something, or |
| 468 | // this scan proves nothing. |
| 469 | if !strings.Contains(got, "sync") { |
| 470 | t.Fatal("no sync log lines at all; this gate scanned nothing relevant") |
| 471 | } |
| 472 | } |
| 473 | |
| 474 | // TestTokenIsNeverRendered. A stored token must not come back to the |
| 475 | // page — not in a value attribute, not masked. A masked secret is still |
| 476 | // a secret in the DOM. |
| 477 | // |
| 478 | // "No substring" would be vacuous, since every single character is a |
| 479 | // substring; the test looks for the whole token and for any run of eight |
| 480 | // characters of it. |
| 481 | // |
| 482 | // MUTATION-TESTED 2026-09-05: rendering the token into the input's value |
| 483 | // makes this fail. Reverted. |
| 484 | func TestTokenIsNeverRendered(t *testing.T) { |
| 485 | const token = "ntn_secret_token_value" |
| 486 | src := newSource() |
| 487 | h, app, _, _ := connectorApp(t, src) |
| 488 | |
| 489 | // The CONNECTED page, which is the one with a stored token and a |
| 490 | // field that could carry it. An earlier version of this gate looked |
| 491 | // only at the not-yet-connected page — which has no token field at |
| 492 | // all — so rendering the token into the rotation form left it green. |
| 493 | // Found by mutation-testing this gate. |
| 494 | body := get(t, h, "/connectors/notion").Body.String() |
| 495 | if !strings.Contains(body, `name="token"`) { |
| 496 | t.Fatal("the connected page has no token field, so this gate is scanning a page that could not leak one") |
| 497 | } |
| 498 | assertNoToken(t, "connected page", body, token) |
| 499 | |
| 500 | // And the not-yet-connected page, for completeness. |
| 501 | if err := app.Store().DeleteConnector(context.Background(), "notion"); err != nil { |
| 502 | t.Fatal(err) |
| 503 | } |
| 504 | assertNoToken(t, "setup page", get(t, h, "/connectors/notion").Body.String(), token) |
| 505 | } |
| 506 | |
| 507 | // assertNoToken looks for the whole token and for any eight-character run |
| 508 | // of it. "No substring" would be vacuous — every single character is a |
| 509 | // substring of the token. |
| 510 | func assertNoToken(t *testing.T, where, body, token string) { |
| 511 | t.Helper() |
| 512 | if strings.Contains(body, token) { |
| 513 | t.Errorf("%s renders the whole token", where) |
| 514 | return |
| 515 | } |
| 516 | for i := 0; i+8 <= len(token); i++ { |
| 517 | if strings.Contains(body, token[i:i+8]) { |
| 518 | t.Errorf("%s renders %q, an 8-character run of the token", where, token[i:i+8]) |
| 519 | return |
| 520 | } |
| 521 | } |
| 522 | } |
| 523 | |
| 524 | // TestConnectorRoutesRefuseANonOwner. |
| 525 | // |
| 526 | // In slice 0 "owner" is the whole admitted set, so this gate is about |
| 527 | // the mechanism rather than a live distinction — but the mechanism has |
| 528 | // to exist and fail closed before slice 1 gives it something to |
| 529 | // separate. It could not be written at all until AppForTest grew an |
| 530 | // injectable identity. |
| 531 | // |
| 532 | // MUTATION-TESTED 2026-09-05: making requireOwner call next |
| 533 | // unconditionally makes this fail. Reverted. |
| 534 | func TestConnectorRoutesRefuseANonOwner(t *testing.T) { |
| 535 | src := newSource() |
| 536 | h, app, _, _ := connectorApp(t, src) |
| 537 | |
| 538 | if code := get(t, h, "/connectors").Code; code != http.StatusOK { |
| 539 | t.Fatalf("an owner got %d from /connectors; this gate proves nothing if nobody can reach it", code) |
| 540 | } |
| 541 | |
| 542 | app.AsPerson("stranger@example.com") |
| 543 | for _, target := range []string{"/connectors", "/connectors/notion"} { |
| 544 | // 404, not 403: a 403 confirms the route exists. |
| 545 | if code := get(t, h, target).Code; code != http.StatusNotFound { |
| 546 | t.Errorf("a non-owner got %d from %s, want 404", code, target) |
| 547 | } |
| 548 | } |
| 549 | if code := post(t, h, "/connectors/notion/sync", url.Values{}).Code; code != http.StatusNotFound { |
| 550 | t.Errorf("a non-owner could reach the sync route (%d)", code) |
| 551 | } |
| 552 | } |
| 553 | |
| 554 | // TestDisconnectKeepsTheDocuments. Parent §3.6 promises that |
| 555 | // disconnecting stops memoria seeing new changes and does not remove |
| 556 | // what memoria already holds. This is that promise as a test. |
| 557 | // |
| 558 | // MUTATION-TESTED 2026-09-05: deleting documents in DeleteConnector |
| 559 | // makes this fail. Reverted. |
| 560 | func TestDisconnectKeepsTheDocuments(t *testing.T) { |
| 561 | src := newSource() |
| 562 | src.add("root", "Handbook", "", "Everything a new starter needs about expenses.") |
| 563 | |
| 564 | h, app, cid, _ := connectorApp(t, src) |
| 565 | sync(t, h, app, cid) |
| 566 | if hits, _ := searchAsReader(app.Store(), context.Background(), "expenses", 10); len(hits) == 0 { |
| 567 | t.Fatal("nothing was imported, so keeping it proves nothing") |
| 568 | } |
| 569 | |
| 570 | if code := post(t, h, "/connectors/notion/disconnect", url.Values{}).Code; code != http.StatusSeeOther { |
| 571 | t.Fatalf("disconnect: status %d", code) |
| 572 | } |
| 573 | if _, err := app.Store().Connector(context.Background(), "notion"); err == nil { |
| 574 | t.Error("the connector survived disconnect") |
| 575 | } |
| 576 | // The DATABASE, not only the index. An earlier version of this gate |
| 577 | // asserted searchability alone, and deleting the document rows left |
| 578 | // the in-memory index still answering — so the gate passed while the |
| 579 | // documents were gone. Found by mutation-testing it. |
| 580 | docs, err := app.Store().Summaries(context.Background()) |
| 581 | if err != nil { |
| 582 | t.Fatal(err) |
| 583 | } |
| 584 | if len(docs) == 0 { |
| 585 | t.Error("disconnecting deleted the stored documents; what memoria imported, memoria keeps") |
| 586 | } |
| 587 | if hits, _ := searchAsReader(app.Store(), context.Background(), "expenses", 10); len(hits) == 0 { |
| 588 | t.Error("disconnecting removed the documents from the index") |
| 589 | } |
| 590 | } |
| 591 | |
| 592 | // TestEmptyPageIsImportedAndCounted. Real workspaces are full of |
| 593 | // placeholders. Failing the run on one lets a single empty page block an |
| 594 | // import; skipping makes it an absence with nothing to explain it. |
| 595 | // |
| 596 | // MUTATION-TESTED 2026-09-05: turning AllowTitleOnly off in Syncer.ingest |
| 597 | // makes the run record a failure instead of an empty page. Reverted. |
| 598 | func TestEmptyPageIsImportedAndCounted(t *testing.T) { |
| 599 | src := newSource() |
| 600 | src.add("root", "Quarterly planning", "", "") |
| 601 | |
| 602 | h, app, cid, _ := connectorApp(t, src) |
| 603 | sync(t, h, app, cid) |
| 604 | |
| 605 | run := latest(t, app, cid) |
| 606 | if run.Empty != 1 { |
| 607 | t.Errorf("empty %d, want 1 (failed %d)", run.Empty, run.FailedPages) |
| 608 | } |
| 609 | if hits, _ := searchAsReader(app.Store(), context.Background(), "quarterly planning", 10); len(hits) == 0 { |
| 610 | t.Error("a title-only page is not findable by the only words it has") |
| 611 | } |
| 612 | } |
| 613 | |
| 614 | // TestUnreadBlocksSurfaceOnTheStatusPage. A page that came in thin should |
| 615 | // carry a reason rather than look like a thin page. |
| 616 | func TestUnreadBlocksSurfaceOnTheStatusPage(t *testing.T) { |
| 617 | src := newSource() |
| 618 | src.add("root", "Notes", "", "A readable paragraph.") |
| 619 | src.unread["root"] = 3 |
| 620 | |
| 621 | h, app, cid, _ := connectorApp(t, src) |
| 622 | sync(t, h, app, cid) |
| 623 | |
| 624 | if run := latest(t, app, cid); run.UnreadBlocks != 3 { |
| 625 | t.Fatalf("unread_blocks %d, want 3", run.UnreadBlocks) |
| 626 | } |
| 627 | if body := get(t, h, "/connectors/notion").Body.String(); !strings.Contains(body, "does not read") { |
| 628 | t.Error("the status page does not mention blocks memoria could not read") |
| 629 | } |
| 630 | } |
| 631 | |