tenancy.spec.ts 7.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182
  1. import { expect, type Page, test } from '@playwright/test'
  2. import { attach } from '../../support/attachments'
  3. import {
  4. latestAttachmentUrl,
  5. organizationIdFor,
  6. seededTenantFixtures,
  7. type TenantFixtures,
  8. } from '../../support/db'
  9. import { shareLink } from '../../support/work-order'
  10. /**
  11. * One workshop cannot reach another's records.
  12. *
  13. * Seven unit tests already check that the queries carry an organisation id
  14. * (`src/__tests__/multitenancy/`), but they mock Prisma: they prove the code
  15. * asks the right question, not that the running app refuses the wrong one. A
  16. * missing scope on one route, a page that reads an id straight from the URL,
  17. * a file served by path rather than by owner — none of that shows up in a
  18. * mocked query.
  19. *
  20. * So a second workshop is opened here, by signing up the way a stranger
  21. * would, and then pointed at the first one's pages, documents and files. What
  22. * it must see, everywhere, is nothing.
  23. *
  24. * The share token is the exception worth stating: it is unguessable, and
  25. * holding it is how a customer was given the document. It still has to belong
  26. * to the organisation named in the link.
  27. */
  28. test.describe.configure({ mode: 'serial' })
  29. // A stranger's browser: no session, until this file makes one.
  30. test.use({ storageState: { cookies: [], origins: [] } })
  31. const stamp = Date.now()
  32. const OUTSIDER = `e2e-outsider-${stamp}@example.com`
  33. const PASSWORD = `E2e-pass-${stamp}`
  34. let seeded: TenantFixtures
  35. /** A shared invoice link from the first workshop, for the token tests. */
  36. let sharedInvoice = ''
  37. /** A file that genuinely belongs to the first workshop's own job. */
  38. let theirFileUrl = ''
  39. /** Signs the outsider in, opening their workshop on the first run. */
  40. async function signInAsOutsider(page: Page) {
  41. await page.goto('/auth/sign-in')
  42. await page.locator('#email').fill(OUTSIDER)
  43. await page.locator('#password').fill(PASSWORD)
  44. await page.getByRole('button', { name: 'Sign In', exact: true }).click()
  45. await page.waitForURL((url) => !url.pathname.startsWith('/auth'), { timeout: 30_000 })
  46. }
  47. test.beforeAll(async ({ browser }) => {
  48. seeded = await seededTenantFixtures()
  49. // A link the first workshop handed to one of its own customers, minted here
  50. // so the token tests have a real one to try in the wrong place.
  51. const owner = await browser.newPage({ storageState: 'e2e/.auth/owner.json' })
  52. await owner.goto(`/vehicles/${seeded.vehicleId}/service/${seeded.serviceRecordId}`)
  53. sharedInvoice = await shareLink(owner)
  54. // And a file of their own, put there rather than looked for: a seeded
  55. // workshop has no attachments, so a spec that goes hunting for one only
  56. // finds what another spec happened to leave behind.
  57. await attach(owner, 'Documents', {
  58. name: `e2e-tenancy-${stamp}.txt`,
  59. mimeType: 'text/plain',
  60. buffer: Buffer.from("One workshop's paperwork."),
  61. })
  62. theirFileUrl = await latestAttachmentUrl(seeded.serviceRecordId)
  63. await owner.close()
  64. })
  65. test.describe('a second workshop', () => {
  66. test('is opened by a stranger signing up', async ({ page }) => {
  67. await page.goto('/auth/sign-up')
  68. await page.locator('#name').fill('E2E Outsider')
  69. await page.locator('#email').fill(OUTSIDER)
  70. await page.locator('#password').fill(PASSWORD)
  71. await page.locator('#terms').click()
  72. await page.getByRole('button', { name: /create account/i }).click()
  73. await page.waitForURL(/\/onboarding/, { timeout: 30_000 })
  74. await page.locator('#workshopName').fill(`E2E Outsider Garage ${stamp}`)
  75. await page.locator('form button[type="submit"]').click()
  76. await page.waitForURL((url) => !/^\/(auth|onboarding)/.test(url.pathname), { timeout: 30_000 })
  77. await expect(page.getByText(`E2E Outsider Garage ${stamp}`).first()).toBeVisible()
  78. })
  79. test('sees none of the first workshop’s customers or vehicles in its own lists', async ({
  80. page,
  81. }) => {
  82. await signInAsOutsider(page)
  83. await page.goto('/customers')
  84. // The seed's customers are a fleet company and nineteen others; a fresh
  85. // workshop has none of them.
  86. await expect(page.getByText('Summit Construction')).toHaveCount(0)
  87. await expect(page.getByText('James Mitchell')).toHaveCount(0)
  88. await page.goto('/vehicles')
  89. await expect(page.getByText('Camry')).toHaveCount(0)
  90. })
  91. test('is handed nothing when it types the first workshop’s addresses', async ({ page }) => {
  92. await signInAsOutsider(page)
  93. // Not the status code: a page may answer 200 and draw an empty shell,
  94. // which is a refusal as much as a 404 is. What must never appear is a
  95. // word belonging to the other workshop.
  96. const theirs = [seeded.vehiclePlate, seeded.customerName, seeded.quoteNumber]
  97. for (const [what, url] of Object.entries({
  98. vehicle: `/vehicles/${seeded.vehicleId}`,
  99. 'work order': `/vehicles/${seeded.vehicleId}/service/${seeded.serviceRecordId}`,
  100. customer: `/customers/${seeded.customerId}`,
  101. quote: `/quotes/${seeded.quoteId}`,
  102. })) {
  103. await page.goto(url)
  104. // Still the outsider's own app, so an absence below means something.
  105. await expect(
  106. page.getByText(`E2E Outsider Garage ${stamp}`).first(),
  107. `${what} is still the outsider's app`
  108. ).toBeVisible()
  109. const shown = await page.locator('body').innerText()
  110. for (const word of theirs) {
  111. expect(shown, `${what} does not show "${word}"`).not.toContain(word)
  112. }
  113. }
  114. })
  115. test('cannot fetch the first workshop’s documents', async ({ page }) => {
  116. await signInAsOutsider(page)
  117. const invoice = await page.request.get(`/api/protected/services/${seeded.serviceRecordId}/pdf`)
  118. expect(invoice.status(), 'the invoice PDF is refused').toBe(404)
  119. const quote = await page.request.get(`/api/protected/quotes/${seeded.quoteId}/pdf`)
  120. expect(quote.status(), 'the quote PDF is refused').toBe(404)
  121. })
  122. test('cannot fetch the first workshop’s files', async ({ page }) => {
  123. await signInAsOutsider(page)
  124. // Files are served by a path that names the organisation, so this is the
  125. // one place where guessing an id would be enough if nothing checked.
  126. const file = await page.request.get(theirFileUrl)
  127. expect(file.status(), `${theirFileUrl} is refused`).toBeGreaterThanOrEqual(400)
  128. expect(file.status()).toBeLessThan(500)
  129. })
  130. test('cannot spend a share token under its own organisation', async ({ page }) => {
  131. const [, token] = new URL(sharedInvoice).pathname.split('/').slice(-2)
  132. // The outsider's real workshop, not an invented id: the question is
  133. // whether a token minted by one organisation opens under another, and a
  134. // made-up id would only prove that nonsense is refused.
  135. const outsiderOrg = await organizationIdFor(OUTSIDER)
  136. expect(outsiderOrg).not.toBe(seeded.organizationId)
  137. const response = await page.request.get(`/api/public/share/invoice/${outsiderOrg}/${token}/pdf`)
  138. expect(response.status(), 'the token does not travel between workshops').toBe(404)
  139. // Nor does a token invented from nothing.
  140. const nonsense = await page.request.get(
  141. `/api/public/share/invoice/${seeded.organizationId}/not-a-real-token/pdf`
  142. )
  143. expect(nonsense.status()).toBe(404)
  144. })
  145. test('the token still works where it belongs', async ({ page }) => {
  146. // The other half of the rule: this is a real link the first workshop gave
  147. // its customer, and it has to keep opening.
  148. const [orgId, token] = new URL(sharedInvoice).pathname.split('/').slice(-2)
  149. const response = await page.request.get(`/api/public/share/invoice/${orgId}/${token}/pdf`)
  150. expect(response.status()).toBe(200)
  151. expect(response.headers()['content-type']).toContain('application/pdf')
  152. })
  153. })