fathom-gating.test.ts
Language TypeScript10.5 KB245 lines
/*** The participant gate, asserted against rendered HTML.** HTML and not a view-model, because the page is what a person receives. A* view-model test proves a filter returns the right rows; it does not prove that* no other part of the template reached past the filter and printed a meeting* title in a summary line, an unowned-items panel, or a list of unrecognised* names. Any surface can add one of those for a good reason without knowing* about the others; only the finished document can prove there are none.** This is the security control, not a second layer: Fathom's own permissions* cannot gate the board (every meeting reads `shared_with: no_teams`, and keys* return meetings their owner did not record). Why it reads that way is not* established, and the suite defends the observation, not a reason for it.** The fixture's meeting 0 is a leadership call attended by Sam and Nora only.* Marco, Rosa, Rafael, Ines and Theo must find no trace of it.*/import { describe, expect, it } from 'vitest'import { FIXTURE_SNAPSHOT, FIXTURE_MEETINGS } from '../src/fixtures.js'import { headline, type Snapshot } from '../src/collect.js'import { render } from '../src/render.js'import { buildBriefingPayload } from '../src/briefing-payload.js'import { buildViewModel, meetingsFor } from '../src/view-model.js'import { COMPANY_VIEWER, viewerFor } from '../src/viewer.js'import { bucketByTopic } from '../../../config/fathom-topics.js'import { esc } from '../src/html.js'/*** Assertions go through esc() and the render's own 28-character title* truncation. Comparing raw strings against a rendered page silently passes:* an apostrophe becomes ' and a long title is cut, so `not.toContain(raw)`* is true whether the gate works or not. That is a test that has stopped* checking anything, which this suite has already been bitten by twice.*/const asRendered = (text: string) => esc(text)const titleAsRendered = (title: string) => esc(title.slice(0, 28))const snap = FIXTURE_SNAPSHOTconst page = (email: string | null) =>render(snap, headline(snap), [], '2026-08-12', null, email ? viewerFor(email) : COMPANY_VIEWER)/** The leadership meeting the fixture reserves for Sam and Nora. */const secret = FIXTURE_MEETINGS[0]!const ATTENDEES = { sam: 'hello@meridiandisplay.example', nora: 'nora.l@meridiandisplay.example' }const OUTSIDERS = {marco: 'studio@meridiandisplay.example',rosa: 'accounts@meridiandisplay.example',rafael: 'orderdesk@meridiandisplay.example',ines: 'projects@meridiandisplay.example',theo: 'frontdesk@meridiandisplay.example',}describe('the fixture actually exercises the gate', () => {it('reserves meeting 0 to two people, so the tests below can mean something', () => {// A fixture where everyone attends everything would let a broken gate pass.expect(secret.participants).toEqual(['Sam Whitaker', 'Nora Lindqvist'])expect(secret.items.length).toBeGreaterThan(0)expect(secret.title).toMatch(/Leadership/i)})})describe('a non-attendee finds NO TRACE of the meeting in their page', () => {it.each(Object.entries(OUTSIDERS))('%s cannot see the title', (_who, email) => {const html = page(email)expect(html).not.toContain(titleAsRendered(secret.title))expect(html).not.toContain(secret.title)})it.each(Object.entries(OUTSIDERS))('%s cannot see any item text', (_who, email) => {const html = page(email)for (const item of secret.items) {expect(html).not.toContain(asRendered(item.text))expect(html).not.toContain(item.text)}})it.each(Object.entries(OUTSIDERS))('%s cannot see the recording URL', (_who, email) => {expect(page(email)).not.toContain(secret.url)})})describe('an attendee does see it, otherwise the gate is just a mute button', () => {it.each(Object.entries(ATTENDEES))('%s sees the title', (_who, email) => {expect(page(email)).toContain(titleAsRendered(secret.title))})it('an attendee sees ALL of its items, not only the ones assigned to them', () => {// You were in the room, you heard all of it. What this buys is a// record of what the meeting produced and who owns each piece.const html = page(ATTENDEES.sam)for (const item of secret.items) expect(html).toContain(asRendered(item.text))})})describe('the company page carries no meeting content at all', () => {// The company view gets nothing at all: not titles, not counts. Even// "Leadership sync · 4 items" discloses that the meeting happened and that// somebody was left out of it.const html = page(null)it('shows no meeting titles', () => {for (const m of FIXTURE_MEETINGS) {expect(html).not.toContain(titleAsRendered(m.title))expect(html).not.toContain(m.title)}})it('shows no action item text from any meeting', () => {for (const m of FIXTURE_MEETINGS) {for (const item of m.items) {expect(html).not.toContain(asRendered(item.text))expect(html).not.toContain(item.text)}}})it('no longer renders the panels that used to leak', () => {expect(html).not.toMatch(/Unowned commitments/i)expect(html).not.toMatch(/Nobody owns this/i)expect(html).not.toMatch(/Unrecognized Fathom names/i)})})describe('the briefing payload is gated too, being its own leak path', () => {const payload = buildBriefingPayload(snap)const person = (name: string) => payload.people.find((p) => p.name === name)!it('gives an attendee the meeting', () => {expect(person('Sam Whitaker').meetings?.map((m) => m.title)).toContain(secret.title)})it('withholds it from everyone who was not there', () => {for (const name of ['Marco Ibáñez', 'Rosa Galván', 'Rafael Montero', 'Ines Carvalho', 'Theo Bassett']) {expect(person(name).meetings?.map((m) => m.title) ?? []).not.toContain(secret.title)}})it('keeps meeting content out of the company and exec slices entirely', () => {// These feed the company and all-hands briefings, which everyone reads. A// model handed a leadership meeting here would summarise it to the team.const shared = JSON.stringify({ company: payload.company, exec: payload.exec })for (const item of secret.items) expect(shared).not.toContain(item.text)expect(shared).not.toContain(secret.title)// The payload is JSON, not HTML, so raw text is the right comparison here.})})describe('meetingsFor fails closed', () => {it('gives the company viewer nothing', () => {expect(meetingsFor(snap, COMPANY_VIEWER)).toEqual([])})it('gives an authenticated address nobody has claimed nothing', () => {// A real @meridiandisplay.example sign-in that is not on the roster.expect(meetingsFor(snap, viewerFor('newhire@meridiandisplay.example'))).toEqual([])})it('gives a null viewer nothing', () => {expect(meetingsFor(snap, null)).toEqual([])})it('returns NULL, not [], when Fathom is not configured', () => {// Not-tracked and no-meetings must stay distinguishable: one of them means// the person has a clear desk and the other means we never looked.const unset: Snapshot = { ...snap, fathomConfigured: false }expect(meetingsFor(unset, viewerFor(ATTENDEES.sam))).toBeNull()})it('returns NULL when the fetch failed, rather than an empty desk', () => {const broken: Snapshot = {...snap,meetings: { ok: false, data: [], error: 'HTTP 500' },}expect(meetingsFor(broken, viewerFor(ATTENDEES.sam))).toBeNull()})it('does not admit someone the board could not resolve', () => {// An unmatched participant is a question for a human, not a viewer. Letting// one through would be resolving-by-guess on an access list.const withUnmatched: Snapshot = {...snap,meetings: {ok: true,error: null,data: [{ ...secret, participants: [], unmatchedParticipants: ['Director MeridianDisplay'] }],},}expect(meetingsFor(withUnmatched, viewerFor(ATTENDEES.sam))).toEqual([])})})describe('not-tracked never renders as a clear desk', () => {it('says so on the page when Fathom is unconfigured', () => {const unset: Snapshot = { ...snap, fathomConfigured: false }const html = render(unset, headline(unset), [], '2026-08-12', null, viewerFor(ATTENDEES.sam))expect(html).toMatch(/not tracked/i)expect(html).not.toMatch(/Nothing came out of your meetings/i)})})/*** The topic buttons are inside the gate.** Deriving a stable, board-wide button set (from `snap.meetings` rather than* from the viewer's own items) would disclose the existence of meetings a* person was not in. Different people seeing different buttons is the correct* behaviour, not a bug to smooth over.*/describe('topic buttons cannot leak a meeting you were not in', () => {it('gives the company page no topic buttons at all', () => {const html = page(null)// The MARKUP, not the class name: .topic-panel is in the inlined// stylesheet on every page whether or not any panel is rendered.expect(html).not.toContain('data-topic=')expect(html).not.toContain('data-topic-panel=')})it('builds a viewer’s buttons only from items they can already see', () => {// Every label rendered must be derivable from that viewer's own// commitments. If a button appears for a topic none of their items match,// it came from somewhere it should not have.for (const email of ['hello@meridiandisplay.example', 'studio@meridiandisplay.example']) {const viewer = viewerFor(email)const vm = buildViewModel(snap, headline(snap), [], '2026-08-12', viewer)// myDays is the per-person list; the card rendered for this viewer is// their own, which is the only one whose commitments passed the gate.const own = vm.myDays.flatMap((m) => m.commitments)const expected = new Set(bucketByTopic(own).map((b) => b.slug))const html = page(email)const rendered = new Set([...html.matchAll(/data-topic="([^"]+)"/g)].map((m) => m[1]!))expect(rendered, email).toEqual(expected)}})it('still says "not tracked" rather than showing zero buttons', () => {// A person with no Fathom key and a person with no action items must not// look the same. An empty row of buttons would make them identical.const dark: Snapshot = { ...snap, fathomConfigured: false }const html = render(dark, headline(dark), [], '2026-08-12', null, viewerFor('hello@meridiandisplay.example'))expect(html).toMatch(/not tracked/i)expect(html).not.toContain('data-topic=')})})