mattrguerra/meridian-ops-boardmainPrivate on GitHub, published here in full

fathom-gating.test.ts

Language TypeScript10.5 KB245 lines

apps/dashboard/test/fathom-gating.test.ts

  1. /**
  2. * The participant gate, asserted against rendered HTML.
  3. *
  4. * HTML and not a view-model, because the page is what a person receives. A
  5. * view-model test proves a filter returns the right rows; it does not prove that
  6. * no other part of the template reached past the filter and printed a meeting
  7. * title in a summary line, an unowned-items panel, or a list of unrecognised
  8. * names. Any surface can add one of those for a good reason without knowing
  9. * about the others; only the finished document can prove there are none.
  10. *
  11. * This is the security control, not a second layer: Fathom's own permissions
  12. * cannot gate the board (every meeting reads `shared_with: no_teams`, and keys
  13. * return meetings their owner did not record). Why it reads that way is not
  14. * established, and the suite defends the observation, not a reason for it.
  15. *
  16. * The fixture's meeting 0 is a leadership call attended by Sam and Nora only.
  17. * Marco, Rosa, Rafael, Ines and Theo must find no trace of it.
  18. */
  19. import { describe, expect, it } from 'vitest'
  20. import { FIXTURE_SNAPSHOT, FIXTURE_MEETINGS } from '../src/fixtures.js'
  21. import { headline, type Snapshot } from '../src/collect.js'
  22. import { render } from '../src/render.js'
  23. import { buildBriefingPayload } from '../src/briefing-payload.js'
  24. import { buildViewModel, meetingsFor } from '../src/view-model.js'
  25. import { COMPANY_VIEWER, viewerFor } from '../src/viewer.js'
  26. import { bucketByTopic } from '../../../config/fathom-topics.js'
  27. import { esc } from '../src/html.js'
  28. /**
  29. * Assertions go through esc() and the render's own 28-character title
  30. * truncation. Comparing raw strings against a rendered page silently passes:
  31. * an apostrophe becomes ' and a long title is cut, so `not.toContain(raw)`
  32. * is true whether the gate works or not. That is a test that has stopped
  33. * checking anything, which this suite has already been bitten by twice.
  34. */
  35. const asRendered = (text: string) => esc(text)
  36. const titleAsRendered = (title: string) => esc(title.slice(0, 28))
  37. const snap = FIXTURE_SNAPSHOT
  38. const page = (email: string | null) =>
  39. render(snap, headline(snap), [], '2026-08-12', null, email ? viewerFor(email) : COMPANY_VIEWER)
  40. /** The leadership meeting the fixture reserves for Sam and Nora. */
  41. const secret = FIXTURE_MEETINGS[0]!
  42. const ATTENDEES = { sam: 'hello@meridiandisplay.example', nora: 'nora.l@meridiandisplay.example' }
  43. const OUTSIDERS = {
  44. marco: 'studio@meridiandisplay.example',
  45. rosa: 'accounts@meridiandisplay.example',
  46. rafael: 'orderdesk@meridiandisplay.example',
  47. ines: 'projects@meridiandisplay.example',
  48. theo: 'frontdesk@meridiandisplay.example',
  49. }
  50. describe('the fixture actually exercises the gate', () => {
  51. it('reserves meeting 0 to two people, so the tests below can mean something', () => {
  52. // A fixture where everyone attends everything would let a broken gate pass.
  53. expect(secret.participants).toEqual(['Sam Whitaker', 'Nora Lindqvist'])
  54. expect(secret.items.length).toBeGreaterThan(0)
  55. expect(secret.title).toMatch(/Leadership/i)
  56. })
  57. })
  58. describe('a non-attendee finds NO TRACE of the meeting in their page', () => {
  59. it.each(Object.entries(OUTSIDERS))('%s cannot see the title', (_who, email) => {
  60. const html = page(email)
  61. expect(html).not.toContain(titleAsRendered(secret.title))
  62. expect(html).not.toContain(secret.title)
  63. })
  64. it.each(Object.entries(OUTSIDERS))('%s cannot see any item text', (_who, email) => {
  65. const html = page(email)
  66. for (const item of secret.items) {
  67. expect(html).not.toContain(asRendered(item.text))
  68. expect(html).not.toContain(item.text)
  69. }
  70. })
  71. it.each(Object.entries(OUTSIDERS))('%s cannot see the recording URL', (_who, email) => {
  72. expect(page(email)).not.toContain(secret.url)
  73. })
  74. })
  75. describe('an attendee does see it, otherwise the gate is just a mute button', () => {
  76. it.each(Object.entries(ATTENDEES))('%s sees the title', (_who, email) => {
  77. expect(page(email)).toContain(titleAsRendered(secret.title))
  78. })
  79. it('an attendee sees ALL of its items, not only the ones assigned to them', () => {
  80. // You were in the room, you heard all of it. What this buys is a
  81. // record of what the meeting produced and who owns each piece.
  82. const html = page(ATTENDEES.sam)
  83. for (const item of secret.items) expect(html).toContain(asRendered(item.text))
  84. })
  85. })
  86. describe('the company page carries no meeting content at all', () => {
  87. // The company view gets nothing at all: not titles, not counts. Even
  88. // "Leadership sync · 4 items" discloses that the meeting happened and that
  89. // somebody was left out of it.
  90. const html = page(null)
  91. it('shows no meeting titles', () => {
  92. for (const m of FIXTURE_MEETINGS) {
  93. expect(html).not.toContain(titleAsRendered(m.title))
  94. expect(html).not.toContain(m.title)
  95. }
  96. })
  97. it('shows no action item text from any meeting', () => {
  98. for (const m of FIXTURE_MEETINGS) {
  99. for (const item of m.items) {
  100. expect(html).not.toContain(asRendered(item.text))
  101. expect(html).not.toContain(item.text)
  102. }
  103. }
  104. })
  105. it('no longer renders the panels that used to leak', () => {
  106. expect(html).not.toMatch(/Unowned commitments/i)
  107. expect(html).not.toMatch(/Nobody owns this/i)
  108. expect(html).not.toMatch(/Unrecognized Fathom names/i)
  109. })
  110. })
  111. describe('the briefing payload is gated too, being its own leak path', () => {
  112. const payload = buildBriefingPayload(snap)
  113. const person = (name: string) => payload.people.find((p) => p.name === name)!
  114. it('gives an attendee the meeting', () => {
  115. expect(person('Sam Whitaker').meetings?.map((m) => m.title)).toContain(secret.title)
  116. })
  117. it('withholds it from everyone who was not there', () => {
  118. for (const name of ['Marco Ibáñez', 'Rosa Galván', 'Rafael Montero', 'Ines Carvalho', 'Theo Bassett']) {
  119. expect(person(name).meetings?.map((m) => m.title) ?? []).not.toContain(secret.title)
  120. }
  121. })
  122. it('keeps meeting content out of the company and exec slices entirely', () => {
  123. // These feed the company and all-hands briefings, which everyone reads. A
  124. // model handed a leadership meeting here would summarise it to the team.
  125. const shared = JSON.stringify({ company: payload.company, exec: payload.exec })
  126. for (const item of secret.items) expect(shared).not.toContain(item.text)
  127. expect(shared).not.toContain(secret.title)
  128. // The payload is JSON, not HTML, so raw text is the right comparison here.
  129. })
  130. })
  131. describe('meetingsFor fails closed', () => {
  132. it('gives the company viewer nothing', () => {
  133. expect(meetingsFor(snap, COMPANY_VIEWER)).toEqual([])
  134. })
  135. it('gives an authenticated address nobody has claimed nothing', () => {
  136. // A real @meridiandisplay.example sign-in that is not on the roster.
  137. expect(meetingsFor(snap, viewerFor('newhire@meridiandisplay.example'))).toEqual([])
  138. })
  139. it('gives a null viewer nothing', () => {
  140. expect(meetingsFor(snap, null)).toEqual([])
  141. })
  142. it('returns NULL, not [], when Fathom is not configured', () => {
  143. // Not-tracked and no-meetings must stay distinguishable: one of them means
  144. // the person has a clear desk and the other means we never looked.
  145. const unset: Snapshot = { ...snap, fathomConfigured: false }
  146. expect(meetingsFor(unset, viewerFor(ATTENDEES.sam))).toBeNull()
  147. })
  148. it('returns NULL when the fetch failed, rather than an empty desk', () => {
  149. const broken: Snapshot = {
  150. ...snap,
  151. meetings: { ok: false, data: [], error: 'HTTP 500' },
  152. }
  153. expect(meetingsFor(broken, viewerFor(ATTENDEES.sam))).toBeNull()
  154. })
  155. it('does not admit someone the board could not resolve', () => {
  156. // An unmatched participant is a question for a human, not a viewer. Letting
  157. // one through would be resolving-by-guess on an access list.
  158. const withUnmatched: Snapshot = {
  159. ...snap,
  160. meetings: {
  161. ok: true,
  162. error: null,
  163. data: [{ ...secret, participants: [], unmatchedParticipants: ['Director MeridianDisplay'] }],
  164. },
  165. }
  166. expect(meetingsFor(withUnmatched, viewerFor(ATTENDEES.sam))).toEqual([])
  167. })
  168. })
  169. describe('not-tracked never renders as a clear desk', () => {
  170. it('says so on the page when Fathom is unconfigured', () => {
  171. const unset: Snapshot = { ...snap, fathomConfigured: false }
  172. const html = render(unset, headline(unset), [], '2026-08-12', null, viewerFor(ATTENDEES.sam))
  173. expect(html).toMatch(/not tracked/i)
  174. expect(html).not.toMatch(/Nothing came out of your meetings/i)
  175. })
  176. })
  177. /**
  178. * The topic buttons are inside the gate.
  179. *
  180. * Deriving a stable, board-wide button set (from `snap.meetings` rather than
  181. * from the viewer's own items) would disclose the existence of meetings a
  182. * person was not in. Different people seeing different buttons is the correct
  183. * behaviour, not a bug to smooth over.
  184. */
  185. describe('topic buttons cannot leak a meeting you were not in', () => {
  186. it('gives the company page no topic buttons at all', () => {
  187. const html = page(null)
  188. // The MARKUP, not the class name: .topic-panel is in the inlined
  189. // stylesheet on every page whether or not any panel is rendered.
  190. expect(html).not.toContain('data-topic=')
  191. expect(html).not.toContain('data-topic-panel=')
  192. })
  193. it('builds a viewer’s buttons only from items they can already see', () => {
  194. // Every label rendered must be derivable from that viewer's own
  195. // commitments. If a button appears for a topic none of their items match,
  196. // it came from somewhere it should not have.
  197. for (const email of ['hello@meridiandisplay.example', 'studio@meridiandisplay.example']) {
  198. const viewer = viewerFor(email)
  199. const vm = buildViewModel(snap, headline(snap), [], '2026-08-12', viewer)
  200. // myDays is the per-person list; the card rendered for this viewer is
  201. // their own, which is the only one whose commitments passed the gate.
  202. const own = vm.myDays.flatMap((m) => m.commitments)
  203. const expected = new Set(bucketByTopic(own).map((b) => b.slug))
  204. const html = page(email)
  205. const rendered = new Set([...html.matchAll(/data-topic="([^"]+)"/g)].map((m) => m[1]!))
  206. expect(rendered, email).toEqual(expected)
  207. }
  208. })
  209. it('still says "not tracked" rather than showing zero buttons', () => {
  210. // A person with no Fathom key and a person with no action items must not
  211. // look the same. An empty row of buttons would make them identical.
  212. const dark: Snapshot = { ...snap, fathomConfigured: false }
  213. const html = render(dark, headline(dark), [], '2026-08-12', null, viewerFor('hello@meridiandisplay.example'))
  214. expect(html).toMatch(/not tracked/i)
  215. expect(html).not.toContain('data-topic=')
  216. })
  217. })