| 1 | package memoria |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "strings" |
| 6 | "testing" |
| 7 | "time" |
| 8 | |
| 9 | "amadan.net/oficina/suite" |
| 10 | |
| 11 | "amadan.net/oficina/memoria/internal/memoria/sibling" |
| 12 | ) |
| 13 | |
| 14 | // breakerClock is a fixed "now" so the day arithmetic is testable without |
| 15 | // waiting a week. Midday, so nothing sits on a boundary by accident. |
| 16 | var breakerClock = time.Date(2026, 9, 7, 12, 0, 0, 0, time.UTC) |
| 17 | |
| 18 | func daysBefore(n int) time.Time { return breakerClock.AddDate(0, 0, -n) } |
| 19 | |
| 20 | // countOn files answers for one person on one day. |
| 21 | func countOn(t *testing.T, s *Store, app, person string, when time.Time, visible, notVisible int) { |
| 22 | t.Helper() |
| 23 | answers := make([]suite.Authorization, 0, visible+notVisible) |
| 24 | for i := 0; i < visible; i++ { |
| 25 | answers = append(answers, suite.VISIBLE) |
| 26 | } |
| 27 | for i := 0; i < notVisible; i++ { |
| 28 | answers = append(answers, suite.NOT_VISIBLE) |
| 29 | } |
| 30 | if len(answers) == 0 { |
| 31 | return |
| 32 | } |
| 33 | asker := suite.Subject{Kind: suite.SubjectAddress, Value: person} |
| 34 | if err := s.recordAuthorizationsAt(context.Background(), app, asker, answers, when); err != nil { |
| 35 | t.Fatalf("record: %v", err) |
| 36 | } |
| 37 | } |
| 38 | |
| 39 | // breakerStore is a store that has been counting long enough to have a |
| 40 | // full baseline, so a test is about the predicate rather than about the |
| 41 | // instance's age. |
| 42 | func breakerStore(t *testing.T) *Store { |
| 43 | t.Helper() |
| 44 | s := storeForTest(t) |
| 45 | if err := s.StartCounting(context.Background(), "Sheets", daysBefore(30)); err != nil { |
| 46 | t.Fatalf("StartCounting: %v", err) |
| 47 | } |
| 48 | return s |
| 49 | } |
| 50 | |
| 51 | func verdict(t *testing.T, s *Store) Breaker { |
| 52 | t.Helper() |
| 53 | b, err := s.RefusalBreaker(context.Background(), "Sheets", breakerClock) |
| 54 | if err != nil { |
| 55 | t.Fatalf("RefusalBreaker: %v", err) |
| 56 | } |
| 57 | return b |
| 58 | } |
| 59 | |
| 60 | // Gate 8: **five named cases, and three of them are cases where it must |
| 61 | // stay silent.** |
| 62 | // |
| 63 | // The silence is the hard part. A notice that fires for an ordinary person |
| 64 | // is a notice owners learn to skip, and an owner who skips it is in the |
| 65 | // same position as one who was never told — so the three quiet cases are |
| 66 | // not edge cases around the interesting one, they *are* the design. Draft |
| 67 | // 3's predicate fired on the routine case; draft 4 wrote the right |
| 68 | // sentence and kept a predicate that was a property of whoever queried |
| 69 | // that day. |
| 70 | // |
| 71 | // MUTATION-TESTED 2026-09-07 with the four the spec itself names, each |
| 72 | // verified red then reverted: |
| 73 | // |
| 74 | // (i) replace the per-asker transition with "zero VISIBLE in the |
| 75 | // window" -> the Docs-only case AND the outage case go red |
| 76 | // (ii) count VISIBLE absence instead of NOT_VISIBLE presence |
| 77 | // -> the outage case goes red alone |
| 78 | // (iii) let the baseline overlap the window |
| 79 | // -> the fires case goes red, because every firing asker is then |
| 80 | // their own baseline |
| 81 | // (iv) return "no anomaly" when the counters are missing |
| 82 | // -> the cannot-determine case goes red |
| 83 | func TestTheRefusalBreaker(t *testing.T) { |
| 84 | t.Run("fires: three people who could see, now refused", func(t *testing.T) { |
| 85 | s := breakerStore(t) |
| 86 | for _, p := range []string{"ana@x", "ben@x", "cleo@x"} { |
| 87 | countOn(t, s, "Sheets", p, daysBefore(3), 5, 0) // baseline: allowed |
| 88 | countOn(t, s, "Sheets", p, breakerClock, 0, 4) // window: refused |
| 89 | } |
| 90 | b := verdict(t, s) |
| 91 | if b.Verdict != BreakerFiring { |
| 92 | t.Errorf("verdict = %v, want firing (askers=%d)", b.Verdict, b.Askers) |
| 93 | } |
| 94 | if b.Askers != 3 { |
| 95 | t.Errorf("askers = %d, want 3", b.Askers) |
| 96 | } |
| 97 | }) |
| 98 | |
| 99 | t.Run("silent: three Docs-only members", func(t *testing.T) { |
| 100 | // The case draft 4's predicate got wrong. One person uses Sheets |
| 101 | // and three do not; the three query hard and are refused |
| 102 | // wholesale, and the Sheets user does not query that day. None of |
| 103 | // the three is in the baseline set, so no number of them can trip |
| 104 | // it — that is what makes the predicate a property of the sibling. |
| 105 | s := breakerStore(t) |
| 106 | countOn(t, s, "Sheets", "dana@x", daysBefore(3), 9, 0) // the Sheets user, baseline only |
| 107 | for _, p := range []string{"ed@x", "fay@x", "gil@x"} { |
| 108 | countOn(t, s, "Sheets", p, breakerClock, 0, 50) |
| 109 | } |
| 110 | if b := verdict(t, s); b.Verdict != BreakerQuiet { |
| 111 | t.Errorf("verdict = %v, want quiet: three people who never had "+ |
| 112 | "access to Sheets being refused by Sheets is Tuesday, and a "+ |
| 113 | "notice that fires for it is one owners learn to skip", b.Verdict) |
| 114 | } |
| 115 | }) |
| 116 | |
| 117 | t.Run("silent: a full-day outage", func(t *testing.T) { |
| 118 | // Sheets answers UNKNOWN to everything, including to baseline |
| 119 | // askers. Zero VISIBLE, and — this is the clause that does the |
| 120 | // work — no NOT_VISIBLE at all. §6.5's partial-page notice reports |
| 121 | // the outage; a breaker whose whole subject is refusal must not, |
| 122 | // or an availability problem is reported as a permissions anomaly |
| 123 | // on the day the operator most needs to read the notice correctly. |
| 124 | s := breakerStore(t) |
| 125 | for _, p := range []string{"ana@x", "ben@x", "cleo@x"} { |
| 126 | countOn(t, s, "Sheets", p, daysBefore(3), 5, 0) |
| 127 | // An outage writes nothing: UNKNOWN is counted as neither. |
| 128 | asker := suite.Subject{Kind: suite.SubjectAddress, Value: p} |
| 129 | answers := []suite.Authorization{suite.UNKNOWN, suite.UNKNOWN, suite.UNKNOWN} |
| 130 | if err := s.recordAuthorizationsAt(context.Background(), "Sheets", asker, answers, breakerClock); err != nil { |
| 131 | t.Fatalf("record: %v", err) |
| 132 | } |
| 133 | } |
| 134 | if b := verdict(t, s); b.Verdict != BreakerQuiet { |
| 135 | t.Errorf("verdict = %v, want quiet: an outage is all UNKNOWN and "+ |
| 136 | "no NOT_VISIBLE, and this breaker is about refusal", b.Verdict) |
| 137 | } |
| 138 | }) |
| 139 | |
| 140 | t.Run("silent: below the asker floor", func(t *testing.T) { |
| 141 | s := breakerStore(t) |
| 142 | for _, p := range []string{"ana@x", "ben@x"} { |
| 143 | countOn(t, s, "Sheets", p, daysBefore(3), 5, 0) |
| 144 | countOn(t, s, "Sheets", p, breakerClock, 0, 4) |
| 145 | } |
| 146 | b := verdict(t, s) |
| 147 | if b.Verdict != BreakerQuiet { |
| 148 | t.Errorf("verdict = %v, want quiet: two is below the floor", b.Verdict) |
| 149 | } |
| 150 | // And the margin is reported, so an owner can see it was two |
| 151 | // rather than none. |
| 152 | if b.Askers != 2 { |
| 153 | t.Errorf("askers = %d, want 2", b.Askers) |
| 154 | } |
| 155 | }) |
| 156 | |
| 157 | t.Run("silent: a baseline asker who did not ask", func(t *testing.T) { |
| 158 | // Absence is not refusal. Three people had access last week and |
| 159 | // simply did not query today; without the "asked in the window" |
| 160 | // clause, a quiet day reads as a wholesale refusal. |
| 161 | s := breakerStore(t) |
| 162 | for _, p := range []string{"ana@x", "ben@x", "cleo@x"} { |
| 163 | countOn(t, s, "Sheets", p, daysBefore(3), 5, 0) |
| 164 | } |
| 165 | // Somebody else is refused, so a NOT_VISIBLE exists in the window |
| 166 | // and only the per-asker clause can keep this quiet. |
| 167 | countOn(t, s, "Sheets", "zoe@x", breakerClock, 0, 1) |
| 168 | if b := verdict(t, s); b.Verdict != BreakerQuiet { |
| 169 | t.Errorf("verdict = %v, want quiet: three people not querying is "+ |
| 170 | "not three people being refused", b.Verdict) |
| 171 | } |
| 172 | }) |
| 173 | |
| 174 | t.Run("cannot determine: a fresh instance names what it lacks", func(t *testing.T) { |
| 175 | // §8.1: this is the one place where mistaking "I cannot tell" for |
| 176 | // "no anomaly" would silently disable a guard rather than |
| 177 | // mis-render a page. |
| 178 | s := storeForTest(t) |
| 179 | if err := s.StartCounting(context.Background(), "Sheets", daysBefore(2)); err != nil { |
| 180 | t.Fatalf("StartCounting: %v", err) |
| 181 | } |
| 182 | for _, p := range []string{"ana@x", "ben@x", "cleo@x"} { |
| 183 | countOn(t, s, "Sheets", p, daysBefore(1), 5, 0) |
| 184 | countOn(t, s, "Sheets", p, breakerClock, 0, 4) |
| 185 | } |
| 186 | b := verdict(t, s) |
| 187 | if b.Verdict != BreakerCannotDetermine { |
| 188 | t.Fatalf("verdict = %v, want cannot-determine: two days of history "+ |
| 189 | "cannot answer a question about seven", b.Verdict) |
| 190 | } |
| 191 | if !strings.Contains(strings.Join(b.Lacking, " "), "baseline") { |
| 192 | t.Errorf("lacking = %v, want it to name the baseline", b.Lacking) |
| 193 | } |
| 194 | }) |
| 195 | |
| 196 | t.Run("cannot determine: no counters at all", func(t *testing.T) { |
| 197 | s := storeForTest(t) |
| 198 | b := verdict(t, s) |
| 199 | if b.Verdict != BreakerCannotDetermine { |
| 200 | t.Errorf("verdict = %v: a sibling nothing has ever counted must "+ |
| 201 | "not report 'no anomaly'", b.Verdict) |
| 202 | } |
| 203 | if len(b.Lacking) == 0 { |
| 204 | t.Error("cannot-determine named nothing; §10 gate 8 requires the " + |
| 205 | "page to say WHICH of window and baseline it lacks") |
| 206 | } |
| 207 | }) |
| 208 | } |
| 209 | |
| 210 | // The counters survive a restart, which is the assertion that they are |
| 211 | // durable rather than in memory. |
| 212 | // |
| 213 | // memoria hibernates, so an in-memory window resets on every wake and a |
| 214 | // breaker that can never accumulate seven days of baseline is a breaker |
| 215 | // that is off — reading, on the owner's page, as "no anomaly". That is the |
| 216 | // failure the whole storage choice exists to prevent. |
| 217 | // |
| 218 | // MUTATION-TESTED 2026-09-07: hold the tallies in a map on the Store and |
| 219 | // this goes red on the second open. Reverted. |
| 220 | func TestBreakerCountersSurviveARestart(t *testing.T) { |
| 221 | ctx := context.Background() |
| 222 | dir := t.TempDir() |
| 223 | |
| 224 | first := storeAt(t, dir) |
| 225 | if err := first.StartCounting(ctx, "Sheets", daysBefore(30)); err != nil { |
| 226 | t.Fatalf("StartCounting: %v", err) |
| 227 | } |
| 228 | for _, p := range []string{"ana@x", "ben@x", "cleo@x"} { |
| 229 | countOn(t, first, "Sheets", p, daysBefore(3), 5, 0) |
| 230 | countOn(t, first, "Sheets", p, breakerClock, 0, 4) |
| 231 | } |
| 232 | if b := verdict(t, first); b.Verdict != BreakerFiring { |
| 233 | t.Fatalf("the fixture did not fire before the restart: %v", b.Verdict) |
| 234 | } |
| 235 | |
| 236 | // Stop and start: a new Store over the same database file, with the |
| 237 | // same DEK, exactly as a wake does. |
| 238 | second := storeAt(t, dir) |
| 239 | b, err := second.RefusalBreaker(ctx, "Sheets", breakerClock) |
| 240 | if err != nil { |
| 241 | t.Fatalf("RefusalBreaker after restart: %v", err) |
| 242 | } |
| 243 | if b.Verdict != BreakerFiring { |
| 244 | t.Errorf("verdict after restart = %v, want firing: the baseline did "+ |
| 245 | "not survive, so this instance can never accumulate one and the "+ |
| 246 | "breaker is permanently off while reading as 'no anomaly'", b.Verdict) |
| 247 | } |
| 248 | } |
| 249 | |
| 250 | // The counters are retained 8 days and no longer. |
| 251 | // |
| 252 | // §8.1's "no longer" is the load-bearing half: this is the only per-person |
| 253 | // record memoria keeps, and a retention rule nothing enforces is a |
| 254 | // sentence in a document. |
| 255 | // |
| 256 | // TWO-SIDED: the rows inside the window must survive, or "delete |
| 257 | // everything" passes. |
| 258 | // |
| 259 | // MUTATION-TESTED 2026-09-07, each verified red then reverted: |
| 260 | // |
| 261 | // prune nothing -> the old-rows assertion red |
| 262 | // prune everything -> the kept-rows assertion red |
| 263 | // off-by-one on the cutoff -> the boundary assertion red |
| 264 | func TestTalliesAreRetainedEightDaysAndNoLonger(t *testing.T) { |
| 265 | ctx := context.Background() |
| 266 | s := breakerStore(t) |
| 267 | for _, d := range []int{0, 1, 7, 8, 9, 30} { |
| 268 | countOn(t, s, "Sheets", "ana@x", daysBefore(d), 1, 0) |
| 269 | } |
| 270 | if got := tallyDays(t, s); len(got) != 6 { |
| 271 | t.Fatalf("%d tally rows seeded, want 6", len(got)) |
| 272 | } |
| 273 | |
| 274 | n, err := s.PruneTallies(ctx, breakerClock) |
| 275 | if err != nil { |
| 276 | t.Fatalf("PruneTallies: %v", err) |
| 277 | } |
| 278 | if n != 2 { |
| 279 | t.Errorf("pruned %d rows, want 2 (day 9 and day 30)", n) |
| 280 | } |
| 281 | got := tallyDays(t, s) |
| 282 | if len(got) != 4 { |
| 283 | t.Fatalf("%d rows left, want 4 (days 0, 1, 7 and 8)", len(got)) |
| 284 | } |
| 285 | |
| 286 | // **Day 7 is the oldest day anything reads** — the baseline runs |
| 287 | // `[today-7, today-1]` — and day 8 is the spare. |
| 288 | // |
| 289 | // An earlier version of this said day 8 was "the oldest baseline day", |
| 290 | // which is simply false, and a review caught the test explaining |
| 291 | // itself with the wrong reason. §8.1 says "retained 8 days and no |
| 292 | // longer" and in the same breath calls that "the window plus the |
| 293 | // baseline plus one", which is 1 + 7 + 1 = 9 distinct days. The two |
| 294 | // numbers in that sentence disagree; the code keeps nine, because the |
| 295 | // spare day is what the "plus one" is for and losing it to a prune |
| 296 | // that runs a minute before midnight would shorten a real baseline. |
| 297 | if !got[daysBefore(7).Format("2006-01-02")] { |
| 298 | t.Error("the oldest baseline day was pruned; every window is then " + |
| 299 | "computed against less history than the predicate assumes") |
| 300 | } |
| 301 | if !got[daysBefore(8).Format("2006-01-02")] { |
| 302 | t.Error("the spare day was pruned; a prune running just before " + |
| 303 | "midnight would take a day the next baseline needs") |
| 304 | } |
| 305 | if got[daysBefore(9).Format("2006-01-02")] { |
| 306 | t.Error("the day-9 row survived; this is the only per-person record " + |
| 307 | "memoria keeps and the retention rule is what bounds it") |
| 308 | } |
| 309 | } |
| 310 | |
| 311 | func tallyDays(t *testing.T, s *Store) map[string]bool { |
| 312 | t.Helper() |
| 313 | var rows []SiblingTally |
| 314 | if err := s.db.G.Find(&rows).Error; err != nil { |
| 315 | t.Fatalf("read tallies: %v", err) |
| 316 | } |
| 317 | out := map[string]bool{} |
| 318 | for _, r := range rows { |
| 319 | out[r.Day] = true |
| 320 | } |
| 321 | return out |
| 322 | } |
| 323 | |
| 324 | // The counters are sealed, and the asker is one-way. |
| 325 | // |
| 326 | // This is the only per-person record memoria keeps — "Ana was refused 40 |
| 327 | // times by Sheets on Tuesday" is a fact about Ana that nothing else in the |
| 328 | // store carries. §3.2's leak list names it; this is what holds it to what |
| 329 | // the list says. |
| 330 | // |
| 331 | // PRESENCE FIRST: the row really does hold those numbers and that person, |
| 332 | // so the absence checks are assertions rather than descriptions of an |
| 333 | // empty table. |
| 334 | // |
| 335 | // MUTATION-TESTED 2026-09-07, each verified red then reverted: |
| 336 | // |
| 337 | // store the counts as plain columns -> the sealed check red |
| 338 | // store the asker address verbatim -> the fingerprint check red |
| 339 | // use Seal rather than Fingerprint for the asker |
| 340 | // -> green on this test, and it is NOT a finding here: it would |
| 341 | // still be unreadable in the bucket. It is refused in the code |
| 342 | // for a different reason, stated at seal.PurposeAsker — a |
| 343 | // reversible id makes this table a roster of who reads what, and |
| 344 | // nothing in the predicate needs the address back. |
| 345 | func TestBreakerCountersAreSealedAndTheAskerIsOneWay(t *testing.T) { |
| 346 | s := breakerStore(t) |
| 347 | countOn(t, s, "Sheets", "ana@example.com", breakerClock, 3, 41) |
| 348 | |
| 349 | var rows []SiblingTally |
| 350 | if err := s.db.G.Find(&rows).Error; err != nil { |
| 351 | t.Fatalf("read: %v", err) |
| 352 | } |
| 353 | if len(rows) != 1 { |
| 354 | t.Fatalf("%d rows, want 1", len(rows)) |
| 355 | } |
| 356 | // Presence: the numbers really are in there. |
| 357 | got, err := s.openTally(rows[0].SealedCounts) |
| 358 | if err != nil { |
| 359 | t.Fatalf("open: %v", err) |
| 360 | } |
| 361 | if got.Visible != 3 || got.NotVisible != 41 { |
| 362 | t.Fatalf("tally = %+v, want {3 41}", got) |
| 363 | } |
| 364 | |
| 365 | blob := string(rows[0].SealedCounts) |
| 366 | if strings.Contains(blob, "41") || strings.Contains(blob, `"n"`) { |
| 367 | t.Error("the counts are legible at rest: how much one person was " + |
| 368 | "refused is a fact about that person") |
| 369 | } |
| 370 | if strings.Contains(rows[0].AskerPrint, "ana@example.com") { |
| 371 | t.Error("the asker's address is in the row; this table would be a " + |
| 372 | "roster of who reads what") |
| 373 | } |
| 374 | // And the fingerprint is stable, or the same person counts as two and |
| 375 | // the per-asker transition can never see anybody twice. |
| 376 | countOn(t, s, "Sheets", "ana@example.com", breakerClock, 1, 0) |
| 377 | if err := s.db.G.Find(&rows).Error; err != nil { |
| 378 | t.Fatalf("read: %v", err) |
| 379 | } |
| 380 | if len(rows) != 1 { |
| 381 | t.Errorf("%d rows after a second call from the same person, want 1: "+ |
| 382 | "an unstable fingerprint makes one person look like many, and "+ |
| 383 | "the per-asker transition then never sees anyone twice", len(rows)) |
| 384 | } |
| 385 | } |
| 386 | |
| 387 | // The baseline and the window do not overlap, asserted on the boundary |
| 388 | // itself rather than through the predicate. |
| 389 | // |
| 390 | // §10 gate 8's third mutation is "let the baseline overlap the window", |
| 391 | // and it expects the fires case to go red because every firing asker is |
| 392 | // then their own baseline. **That mutation survives the predicate here, |
| 393 | // and the reason is worth stating rather than hiding**: with day-granular |
| 394 | // counters an asker cannot both have a VISIBLE and count as refused on the |
| 395 | // same day — the row is one aggregate, so `visible > 0` excludes them |
| 396 | // whichever period the day belongs to. The spec's mutation is written |
| 397 | // against a rolling window, where intra-day ordering exists and today's |
| 398 | // own grant can serve as its own baseline. |
| 399 | // |
| 400 | // That is the same day-granularity tension the window comment describes, |
| 401 | // surfacing as a mutation the predicate cannot see. So the property is |
| 402 | // asserted where it IS visible: the two ranges, directly. Overlapping them |
| 403 | // goes red here even though it does not there. |
| 404 | // |
| 405 | // MUTATION-TESTED 2026-09-07, each verified red then reverted: |
| 406 | // |
| 407 | // baseTo becomes today -> red |
| 408 | // baseFrom becomes today - 6 -> red (the baseline is 7 days) |
| 409 | // the window becomes yesterday -> red |
| 410 | func TestTheBaselineAndTheWindowDoNotOverlap(t *testing.T) { |
| 411 | window := breakerWindow(breakerClock) |
| 412 | from, to := breakerBaseline(breakerClock) |
| 413 | |
| 414 | if to >= window { |
| 415 | t.Errorf("baseline ends %s and the window is %s: they overlap, so an "+ |
| 416 | "asker can be their own baseline and the predicate is vacuous", |
| 417 | to, window) |
| 418 | } |
| 419 | if want := breakerClock.AddDate(0, 0, -1).Format("2006-01-02"); to != want { |
| 420 | t.Errorf("baseline ends %s, want %s — the day before the window, with "+ |
| 421 | "no gap: a gap would silently shorten the baseline", to, want) |
| 422 | } |
| 423 | if want := breakerClock.AddDate(0, 0, -breakerBaselineDays).Format("2006-01-02"); from != want { |
| 424 | t.Errorf("baseline starts %s, want %s (%d days)", from, want, breakerBaselineDays) |
| 425 | } |
| 426 | if want := breakerClock.Format("2006-01-02"); window != want { |
| 427 | t.Errorf("window = %s, want %s", window, want) |
| 428 | } |
| 429 | } |
| 430 | |
| 431 | // An unreadable counter makes the breaker say so, not go quiet. |
| 432 | // |
| 433 | // A seal failure, a partial restore, a truncated row: whatever the cause, |
| 434 | // the period cannot be read, and a period that cannot be read is not a |
| 435 | // period with nothing in it. Skipping the row instead would make the |
| 436 | // window look quieter than it was, and quieter is the direction that turns |
| 437 | // a firing breaker into a silent one — §8.1's "a count that fails is not a |
| 438 | // count of zero", on the one guard where that mistake disables rather than |
| 439 | // mis-renders. |
| 440 | // |
| 441 | // PRESENCE FIRST: the fixture fires before the row is corrupted, so this |
| 442 | // is a statement about the corruption and not about an empty table. |
| 443 | // |
| 444 | // MUTATION-TESTED 2026-09-07, each verified red then reverted: |
| 445 | // |
| 446 | // tallyRange skips a row it cannot open -> red, the breaker goes quiet |
| 447 | // RefusalBreaker reports Quiet on a range error -> red |
| 448 | func TestAnUnreadableCounterIsNotAQuietOne(t *testing.T) { |
| 449 | s := breakerStore(t) |
| 450 | for _, p := range []string{"ana@x", "ben@x", "cleo@x"} { |
| 451 | countOn(t, s, "Sheets", p, daysBefore(3), 5, 0) |
| 452 | countOn(t, s, "Sheets", p, breakerClock, 0, 4) |
| 453 | } |
| 454 | if b := verdict(t, s); b.Verdict != BreakerFiring { |
| 455 | t.Fatalf("the fixture did not fire before corruption: %v", b.Verdict) |
| 456 | } |
| 457 | |
| 458 | // Spoil one baseline row. |
| 459 | var row SiblingTally |
| 460 | if err := s.db.G.Where("day = ?", daysBefore(3).Format("2006-01-02")).First(&row).Error; err != nil { |
| 461 | t.Fatalf("find a row: %v", err) |
| 462 | } |
| 463 | if err := s.db.G.Model(&SiblingTally{}).Where("id = ?", row.ID). |
| 464 | Update("sealed_counts", []byte("not a sealed value")).Error; err != nil { |
| 465 | t.Fatalf("corrupt: %v", err) |
| 466 | } |
| 467 | |
| 468 | b := verdict(t, s) |
| 469 | if b.Verdict == BreakerQuiet { |
| 470 | t.Error("a period the breaker cannot read was reported as 'no " + |
| 471 | "anomaly'; that is the one mistake §8.1 says disables the guard " + |
| 472 | "rather than mis-rendering the page") |
| 473 | } |
| 474 | if b.Verdict != BreakerCannotDetermine { |
| 475 | t.Errorf("verdict = %v, want cannot-determine", b.Verdict) |
| 476 | } |
| 477 | if len(b.Lacking) == 0 { |
| 478 | t.Error("cannot-determine named nothing") |
| 479 | } |
| 480 | } |
| 481 | |
| 482 | // Gate 8's placement half: **the firing notice reaches the owner and no |
| 483 | // reader.** |
| 484 | // |
| 485 | // §8.1 is explicit about why. A message telling one reader how much they |
| 486 | // were not allowed to see is a disclosure with a number in it, and §4.1 |
| 487 | // makes NOT_VISIBLE the one value that may be silent. The reader's page |
| 488 | // says what could not be shown and from which app; only the owner's says |
| 489 | // how many people a sibling stopped answering for. |
| 490 | // |
| 491 | // This is the half a unit test on the predicate cannot make: `RefusalBreaker` |
| 492 | // returning the right verdict says nothing about where the sentence lands. |
| 493 | // |
| 494 | // MUTATION-TESTED 2026-09-07, each verified red then reverted: |
| 495 | // |
| 496 | // render the breaker sentence into results.html |
| 497 | // -> the reader's-page case red |
| 498 | // drop siblingRows from the connectors handler |
| 499 | // -> the owner's-page case red |
| 500 | // move the sources route outside requireOwner |
| 501 | // -> gate 17's route enumeration red, and this test's own |
| 502 | // owner-only assertion red |
| 503 | func TestTheBreakerNoticeReachesTheOwnerAndNoReader(t *testing.T) { |
| 504 | s := breakerStore(t) |
| 505 | for _, p := range []string{"ana@x", "ben@x", "cleo@x"} { |
| 506 | countOn(t, s, "Sheets", p, daysBefore(3), 5, 0) |
| 507 | countOn(t, s, "Sheets", p, breakerClock, 0, 4) |
| 508 | } |
| 509 | s.custody = &Siblings{ |
| 510 | client: sibling.NewForTest(), |
| 511 | byName: map[string]sibling.Sibling{ |
| 512 | "Sheets": {Name: "Sheets", BaseURL: "https://acme.sheets.oficina.works", Token: "t"}, |
| 513 | }, |
| 514 | } |
| 515 | |
| 516 | b, err := s.RefusalBreaker(context.Background(), "Sheets", breakerClock) |
| 517 | if err != nil { |
| 518 | t.Fatalf("RefusalBreaker: %v", err) |
| 519 | } |
| 520 | if b.Verdict != BreakerFiring { |
| 521 | t.Fatalf("the fixture did not fire: %v — nothing below is about "+ |
| 522 | "placement if there is no notice to place", b.Verdict) |
| 523 | } |
| 524 | sentence, alarming := breakerSentence(b) |
| 525 | |
| 526 | // It names the anomaly rather than gesturing at it: an owner has to be |
| 527 | // able to act on this without opening a log. |
| 528 | if !strings.Contains(sentence, "Sheets") { |
| 529 | t.Errorf("the notice does not name the app: %q", sentence) |
| 530 | } |
| 531 | if !strings.Contains(sentence, "3") { |
| 532 | t.Errorf("the notice does not say how many people: %q", sentence) |
| 533 | } |
| 534 | // And it says nothing was deleted, because that is the question an |
| 535 | // owner reading "refusing people it used to allow" will ask next, and |
| 536 | // §8.1 promises all three breakers purge nothing. |
| 537 | if !strings.Contains(strings.ToLower(sentence), "deleted") { |
| 538 | t.Errorf("the notice does not say nothing was deleted: %q", sentence) |
| 539 | } |
| 540 | if !alarming { |
| 541 | t.Error("a firing breaker did not mark its row for attention") |
| 542 | } |
| 543 | |
| 544 | // **The placement half lives in TestTheFiringCountReachesOnlyTheOwner**, |
| 545 | // which runs a real search and reads the notices it actually produced. |
| 546 | // |
| 547 | // It was here, and it was worthless: it built a `Page` in the test, |
| 548 | // wrote a notice into it by hand, and then checked that the string it |
| 549 | // had just written did not contain the breaker's words. A review |
| 550 | // rendered the firing sentence straight into results.html and this |
| 551 | // stayed green, which is exactly what an assertion about your own |
| 552 | // fixture is worth. |
| 553 | } |
| 554 | |
| 555 | // "Cannot determine" says which half it lacks, and §10 gate 8 asserts the |
| 556 | // string. |
| 557 | // |
| 558 | // An owner told only "cannot determine" cannot tell a fresh instance from |
| 559 | // a broken one, and the two want different actions: one is wait, the other |
| 560 | // is investigate. |
| 561 | // |
| 562 | // MUTATION-TESTED 2026-09-07: return the bare phrase without the Lacking |
| 563 | // list, and this goes red. Reverted. |
| 564 | func TestCannotDetermineSaysWhichHalfIsMissing(t *testing.T) { |
| 565 | // The two combinations `RefusalBreaker` can actually produce. A |
| 566 | // window-only lack is unreachable — counting cannot begin after today |
| 567 | // without also beginning after the baseline — and a review pointed |
| 568 | // out that testing it describes a state rather than asserting one. |
| 569 | // The singular/plural split is still worth pinning, so the |
| 570 | // baseline-only case carries it. |
| 571 | for _, c := range []struct { |
| 572 | lacking []string |
| 573 | want []string |
| 574 | }{ |
| 575 | {[]string{"the baseline"}, []string{"baseline", "is missing"}}, |
| 576 | {[]string{"the baseline", "the window"}, []string{"baseline", "window", "are missing"}}, |
| 577 | } { |
| 578 | got, alarming := breakerSentence(Breaker{ |
| 579 | App: "Sheets", Verdict: BreakerCannotDetermine, Lacking: c.lacking, |
| 580 | }) |
| 581 | for _, want := range c.want { |
| 582 | if !strings.Contains(got, want) { |
| 583 | t.Errorf("lacking %v produced %q, want it to contain %q", |
| 584 | c.lacking, got, want) |
| 585 | } |
| 586 | } |
| 587 | // It must never read as reassurance. |
| 588 | if strings.Contains(got, "No refusal anomaly") { |
| 589 | t.Errorf("cannot-determine read as no-anomaly: %q", got) |
| 590 | } |
| 591 | if !strings.Contains(got, "not the same as no anomaly") { |
| 592 | t.Errorf("the notice does not distinguish itself from 'no "+ |
| 593 | "anomaly', which is the one confusion §8.1 says disables "+ |
| 594 | "the guard: %q", got) |
| 595 | } |
| 596 | if !alarming { |
| 597 | t.Errorf("cannot-determine did not mark its row for attention: %q", got) |
| 598 | } |
| 599 | } |
| 600 | } |
| 601 | |
| 602 | // A row with nothing wrong carries no alarm, and the two lines are toned |
| 603 | // independently. |
| 604 | // |
| 605 | // **This exists because looking at the rendered page found two bugs the |
| 606 | // whole Go suite passed over**, on markup every template test agreed was |
| 607 | // correct: |
| 608 | // |
| 609 | // 1. one `Alarming` flag carried both lines, so a sibling that had merely |
| 610 | // not been polled yet rendered "No refusal anomaly." inside a warning |
| 611 | // callout — a warning box with reassurance in it; |
| 612 | // 2. "not polled yet" was itself alarming, and memoria hibernates, so |
| 613 | // every row of every freshly-woken instance warned about the ordinary |
| 614 | // case. §8.1's argument about a breaker that fires for ordinary people, |
| 615 | // arriving in the freshness line instead. |
| 616 | // |
| 617 | // Both are about what a person sees and neither changes a string a test |
| 618 | // was checking for. What this pins is the property underneath: the alarm |
| 619 | // is reserved for something actually being wrong. |
| 620 | // |
| 621 | // MUTATION-TESTED 2026-09-07, each verified red then reverted: |
| 622 | // |
| 623 | // breakerSentence takes an incoming flag again -> quiet-row case red |
| 624 | // "not polled yet" sets CurrentAlarming -> quiet-row case red |
| 625 | // a firing breaker returns false -> firing case red |
| 626 | // a cannot-determine returns false -> cannot-determine red |
| 627 | func TestOnlyAWrongThingIsAlarming(t *testing.T) { |
| 628 | for _, c := range []struct { |
| 629 | name string |
| 630 | b Breaker |
| 631 | alarm bool |
| 632 | }{ |
| 633 | {"quiet", Breaker{App: "Docs", Verdict: BreakerQuiet}, false}, |
| 634 | {"firing", Breaker{App: "Sheets", Verdict: BreakerFiring, Askers: 3}, true}, |
| 635 | {"cannot determine", Breaker{App: "Cal", Verdict: BreakerCannotDetermine, |
| 636 | Lacking: []string{"the baseline"}}, true}, |
| 637 | } { |
| 638 | _, alarm := breakerSentence(c.b) |
| 639 | if alarm != c.alarm { |
| 640 | t.Errorf("%s: alarming = %v, want %v", c.name, alarm, c.alarm) |
| 641 | } |
| 642 | } |
| 643 | |
| 644 | // And the two lines cannot infect each other: a quiet breaker stays |
| 645 | // quiet whatever the freshness line is doing. Asserted by the |
| 646 | // signature — breakerSentence takes no incoming flag — and here by |
| 647 | // the value, because a signature is not a test. |
| 648 | if _, alarm := breakerSentence(Breaker{App: "Docs", Verdict: BreakerQuiet}); alarm { |
| 649 | t.Error("a quiet breaker reported an alarm; the first version " + |
| 650 | "inherited one from the freshness line and rendered " + |
| 651 | "\"No refusal anomaly.\" in a warning box") |
| 652 | } |
| 653 | } |
| 654 | |
| 655 | // **An outage plus one ordinary refusal must not fire**, and the |
| 656 | // window-global reading of §8.1's third clause let it. |
| 657 | // |
| 658 | // This is §13's finding, found again by a review that built the fixture: |
| 659 | // Sheets is down and answers UNKNOWN to every baseline asker, while one |
| 660 | // Docs-only member gets a single routine NOT_VISIBLE. Read globally, "the |
| 661 | // window contains at least one NOT_VISIBLE" is satisfied by that one |
| 662 | // query — so a 24-hour outage fires the refusal breaker, which is the |
| 663 | // exact case §8.1's prose says is excluded. |
| 664 | // |
| 665 | // Per counted asker it cannot: each of the three has zero NOT_VISIBLE of |
| 666 | // their own, so none is counted, and the stranger is not in the baseline. |
| 667 | // |
| 668 | // MUTATION-TESTED 2026-09-07, each verified red then reverted: |
| 669 | // |
| 670 | // restore the window-global anyNotVisible clause -> red, it fires |
| 671 | // count `t.Visible == 0` without requiring a NOT_VISIBLE |
| 672 | // -> red, and this is the same bug by a shorter route |
| 673 | func TestAnOutageWithOneRoutineRefusalDoesNotFire(t *testing.T) { |
| 674 | ctx := context.Background() |
| 675 | s := breakerStore(t) |
| 676 | |
| 677 | // Three people who could read from Sheets last week. |
| 678 | for _, p := range []string{"ana@x", "ben@x", "cleo@x"} { |
| 679 | countOn(t, s, "Sheets", p, daysBefore(3), 5, 0) |
| 680 | // Today Sheets is down: they ask, and get UNKNOWN. Nothing this |
| 681 | // breaker counts, so no row — which reads the same as not having |
| 682 | // asked, and that is correct: an outage tells you nothing about |
| 683 | // permissions either way. |
| 684 | asker := suite.Subject{Kind: suite.SubjectAddress, Value: p} |
| 685 | answers := []suite.Authorization{suite.UNKNOWN, suite.UNKNOWN, suite.UNKNOWN} |
| 686 | if err := s.recordAuthorizationsAt(ctx, "Sheets", asker, answers, breakerClock); err != nil { |
| 687 | t.Fatalf("record: %v", err) |
| 688 | } |
| 689 | } |
| 690 | |
| 691 | // And one person who never had Sheets access gets one ordinary |
| 692 | // refusal — the single NOT_VISIBLE that the window-global clause |
| 693 | // accepted on everybody else's behalf. |
| 694 | countOn(t, s, "Sheets", "stranger@x", breakerClock, 0, 1) |
| 695 | |
| 696 | b := verdict(t, s) |
| 697 | if b.Verdict == BreakerFiring { |
| 698 | t.Errorf("an outage plus one routine refusal fired the breaker "+ |
| 699 | "(askers=%d): the NOT_VISIBLE clause has to be a property of "+ |
| 700 | "each counted asker, or one stranger's ordinary Tuesday speaks "+ |
| 701 | "for three people who were not refused at all", b.Askers) |
| 702 | } |
| 703 | if b.Verdict != BreakerQuiet { |
| 704 | t.Errorf("verdict = %v, want quiet", b.Verdict) |
| 705 | } |
| 706 | if b.Askers != 0 { |
| 707 | t.Errorf("askers = %d, want 0: nobody was refused", b.Askers) |
| 708 | } |
| 709 | } |
| 710 | |
| 711 | // The baseline is people the sibling ALLOWED, not people who queried. |
| 712 | // |
| 713 | // `t.Visible > 0` is the whole of it. Relaxed to `>= 0` the baseline |
| 714 | // becomes "anyone who asked last week", and three Docs-only members who |
| 715 | // happened to query last week and are refused today trip it — draft 4's |
| 716 | // failure with a week's delay. A review found the clause unpinned. |
| 717 | // |
| 718 | // MUTATION-TESTED 2026-09-07: `t.Visible > 0` -> `t.Visible >= 0`. Red. |
| 719 | // Reverted. |
| 720 | func TestTheBaselineIsWhoWasAllowedNotWhoAsked(t *testing.T) { |
| 721 | s := breakerStore(t) |
| 722 | // Three people who asked Sheets last week and were refused every |
| 723 | // time. They are not in the baseline, because Sheets never allowed |
| 724 | // them anything. |
| 725 | for _, p := range []string{"ed@x", "fay@x", "gil@x"} { |
| 726 | countOn(t, s, "Sheets", p, daysBefore(3), 0, 6) |
| 727 | countOn(t, s, "Sheets", p, breakerClock, 0, 4) |
| 728 | } |
| 729 | if b := verdict(t, s); b.Verdict != BreakerQuiet { |
| 730 | t.Errorf("verdict = %v, want quiet: being refused this week the same "+ |
| 731 | "way as last week is not a transition, and a breaker that fires "+ |
| 732 | "for it fires forever", b.Verdict) |
| 733 | } |
| 734 | } |
| 735 | |
| 736 | // A mixed day is not a refusal. |
| 737 | // |
| 738 | // Somebody who saw four passages and was refused one had an ordinary day. |
| 739 | // Counting them makes the predicate "was refused at all" rather than "was |
| 740 | // refused everything", and three ordinary days then fire the breaker. A |
| 741 | // review found this clause unpinned too. |
| 742 | // |
| 743 | // MUTATION-TESTED 2026-09-07: `t.Visible == 0` -> `true`. Red. Reverted. |
| 744 | func TestAMixedDayIsNotARefusal(t *testing.T) { |
| 745 | s := breakerStore(t) |
| 746 | for _, p := range []string{"ana@x", "ben@x", "cleo@x"} { |
| 747 | countOn(t, s, "Sheets", p, daysBefore(3), 5, 0) |
| 748 | countOn(t, s, "Sheets", p, breakerClock, 4, 1) |
| 749 | } |
| 750 | if b := verdict(t, s); b.Verdict != BreakerQuiet { |
| 751 | t.Errorf("verdict = %v, want quiet: seeing four things and being "+ |
| 752 | "refused one is a Tuesday, not an anomaly", b.Verdict) |
| 753 | } |
| 754 | } |
| 755 | |