tenancy.spec.ts 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252
  1. import { expect, type Page, test } from '@playwright/test'
  2. import { attach } from '../../support/attachments'
  3. import {
  4. deleteTechnicians,
  5. deleteWorkBays,
  6. insertTechnician,
  7. insertWorkBay,
  8. jobAssignment,
  9. jobCount,
  10. latestAttachmentUrl,
  11. organizationIdFor,
  12. seededTenantFixtures,
  13. type TenantFixtures,
  14. plantWorkshop,
  15. } from '../../support/db'
  16. import { shareLink } from '../../support/work-order'
  17. /**
  18. * One workshop cannot reach another's records.
  19. *
  20. * Seven unit tests already check that the queries carry an organisation id
  21. * (`src/__tests__/multitenancy/`), but they mock Prisma: they prove the code
  22. * asks the right question, not that the running app refuses the wrong one. A
  23. * missing scope on one route, a page that reads an id straight from the URL,
  24. * a file served by path rather than by owner — none of that shows up in a
  25. * mocked query.
  26. *
  27. * So a second workshop is opened here, by signing up the way a stranger
  28. * would, and then pointed at the first one's pages, documents and files. What
  29. * it must see, everywhere, is nothing.
  30. *
  31. * The share token is the exception worth stating: it is unguessable, and
  32. * holding it is how a customer was given the document. It still has to belong
  33. * to the organisation named in the link.
  34. */
  35. test.describe.configure({ mode: 'serial' })
  36. // A stranger's browser: no session, until this file makes one.
  37. test.use({ storageState: { cookies: [], origins: [] } })
  38. const stamp = Date.now()
  39. const OUTSIDER = `e2e-outsider-${stamp}@example.com`
  40. const PASSWORD = `E2e-pass-${stamp}`
  41. let seeded: TenantFixtures
  42. /** A shared invoice link from the first workshop, for the token tests. */
  43. let sharedInvoice = ''
  44. /** A file that genuinely belongs to the first workshop's own job. */
  45. let theirFileUrl = ''
  46. /** Signs the outsider in. */
  47. async function signInAsOutsider(page: Page) {
  48. await page.goto('/auth/sign-in')
  49. await page.locator('#email').fill(OUTSIDER)
  50. await page.locator('#password').fill(PASSWORD)
  51. await page.getByRole('button', { name: 'Sign In', exact: true }).click()
  52. await page.waitForURL((url) => !url.pathname.startsWith('/auth'), { timeout: 30_000 })
  53. }
  54. test.beforeAll(async ({ browser }) => {
  55. seeded = await seededTenantFixtures()
  56. // A link the first workshop handed to one of its own customers, minted here
  57. // so the token tests have a real one to try in the wrong place.
  58. const owner = await browser.newPage({ storageState: 'e2e/.auth/owner.json' })
  59. await owner.goto(`/vehicles/${seeded.vehicleId}/service/${seeded.serviceRecordId}`)
  60. sharedInvoice = await shareLink(owner)
  61. // And a file of their own, put there rather than looked for: a seeded
  62. // workshop has no attachments, so a spec that goes hunting for one only
  63. // finds what another spec happened to leave behind.
  64. await attach(owner, 'Documents', {
  65. name: `e2e-tenancy-${stamp}.txt`,
  66. mimeType: 'text/plain',
  67. buffer: Buffer.from("One workshop's paperwork."),
  68. })
  69. theirFileUrl = await latestAttachmentUrl(seeded.serviceRecordId)
  70. await owner.close()
  71. })
  72. test.describe('a second workshop', () => {
  73. test('exists beside the first, with its own owner signed in', async ({ page }) => {
  74. // A self-hosted install opens one workshop and sends every later
  75. // sign-up to ask for an invitation (single-workshop.spec.ts), so the
  76. // outsider's workshop is planted rather than signed up.
  77. await plantWorkshop({
  78. name: 'E2E Outsider',
  79. email: OUTSIDER,
  80. password: PASSWORD,
  81. workshopName: `E2E Outsider Garage ${stamp}`,
  82. })
  83. await signInAsOutsider(page)
  84. await expect(page.getByText(`E2E Outsider Garage ${stamp}`).first()).toBeVisible()
  85. })
  86. test('sees none of the first workshop’s customers or vehicles in its own lists', async ({
  87. page,
  88. }) => {
  89. await signInAsOutsider(page)
  90. await page.goto('/customers')
  91. // The seed's customers are a fleet company and nineteen others; a fresh
  92. // workshop has none of them.
  93. await expect(page.getByText('Summit Construction')).toHaveCount(0)
  94. await expect(page.getByText('James Mitchell')).toHaveCount(0)
  95. await page.goto('/vehicles')
  96. await expect(page.getByText('Camry')).toHaveCount(0)
  97. })
  98. test('is handed nothing when it types the first workshop’s addresses', async ({ page }) => {
  99. await signInAsOutsider(page)
  100. // Not the status code: a page may answer 200 and draw an empty shell,
  101. // which is a refusal as much as a 404 is. What must never appear is a
  102. // word belonging to the other workshop.
  103. const theirs = [seeded.vehiclePlate, seeded.customerName, seeded.quoteNumber]
  104. for (const [what, url] of Object.entries({
  105. vehicle: `/vehicles/${seeded.vehicleId}`,
  106. 'work order': `/vehicles/${seeded.vehicleId}/service/${seeded.serviceRecordId}`,
  107. customer: `/customers/${seeded.customerId}`,
  108. quote: `/quotes/${seeded.quoteId}`,
  109. })) {
  110. await page.goto(url)
  111. // Still the outsider's own app, so an absence below means something.
  112. await expect(
  113. page.getByText(`E2E Outsider Garage ${stamp}`).first(),
  114. `${what} is still the outsider's app`
  115. ).toBeVisible()
  116. const shown = await page.locator('body').innerText()
  117. for (const word of theirs) {
  118. expect(shown, `${what} does not show "${word}"`).not.toContain(word)
  119. }
  120. }
  121. })
  122. test('cannot fetch the first workshop’s documents', async ({ page }) => {
  123. await signInAsOutsider(page)
  124. const invoice = await page.request.get(`/api/protected/services/${seeded.serviceRecordId}/pdf`)
  125. expect(invoice.status(), 'the invoice PDF is refused').toBe(404)
  126. const quote = await page.request.get(`/api/protected/quotes/${seeded.quoteId}/pdf`)
  127. expect(quote.status(), 'the quote PDF is refused').toBe(404)
  128. })
  129. test('cannot fetch the first workshop’s files', async ({ page }) => {
  130. await signInAsOutsider(page)
  131. // Files are served by a path that names the organisation, so this is the
  132. // one place where guessing an id would be enough if nothing checked.
  133. const file = await page.request.get(theirFileUrl)
  134. expect(file.status(), `${theirFileUrl} is refused`).toBeGreaterThanOrEqual(400)
  135. expect(file.status()).toBeLessThan(500)
  136. })
  137. test('cannot spend a share token under its own organisation', async ({ page }) => {
  138. const [, token] = new URL(sharedInvoice).pathname.split('/').slice(-2)
  139. // The outsider's real workshop, not an invented id: the question is
  140. // whether a token minted by one organisation opens under another, and a
  141. // made-up id would only prove that nonsense is refused.
  142. const outsiderOrg = await organizationIdFor(OUTSIDER)
  143. expect(outsiderOrg).not.toBe(seeded.organizationId)
  144. const response = await page.request.get(`/api/public/share/invoice/${outsiderOrg}/${token}/pdf`)
  145. expect(response.status(), 'the token does not travel between workshops').toBe(404)
  146. // Nor does a token invented from nothing.
  147. const nonsense = await page.request.get(
  148. `/api/public/share/invoice/${seeded.organizationId}/not-a-real-token/pdf`
  149. )
  150. expect(nonsense.status()).toBe(404)
  151. })
  152. test('the token still works where it belongs', async ({ page }) => {
  153. // The other half of the rule: this is a real link the first workshop gave
  154. // its customer, and it has to keep opening.
  155. const [orgId, token] = new URL(sharedInvoice).pathname.split('/').slice(-2)
  156. const response = await page.request.get(`/api/public/share/invoice/${orgId}/${token}/pdf`)
  157. expect(response.status()).toBe(200)
  158. expect(response.headers()['content-type']).toContain('application/pdf')
  159. })
  160. })
  161. /**
  162. * A job booked from the work board names the technician and the bay it was
  163. * dropped on, by id, in the URL that opens the new job. Those ids have to be
  164. * the workshop's own: the technician lookup used to write the id it was given
  165. * even when it found nothing, so a job could point at another workshop's
  166. * technician, and the bay was never looked up at all.
  167. */
  168. test.describe('a job booked onto a technician and a bay', () => {
  169. const made = { technicians: [] as string[], bays: [] as string[] }
  170. let theirs = { technicianId: '', workBayId: '' }
  171. let ours = { technicianId: '', workBayId: '' }
  172. test.beforeAll(async () => {
  173. const outsiderOrg = await organizationIdFor(OUTSIDER)
  174. theirs = {
  175. technicianId: await insertTechnician(outsiderOrg, `E2E Their Tech ${stamp}`),
  176. workBayId: await insertWorkBay(outsiderOrg, `E2E Their Bay ${stamp}`),
  177. }
  178. ours = {
  179. technicianId: await insertTechnician(seeded.organizationId, `E2E Own Tech ${stamp}`),
  180. workBayId: await insertWorkBay(seeded.organizationId, `E2E Own Bay ${stamp}`),
  181. }
  182. made.technicians.push(theirs.technicianId, ours.technicianId)
  183. made.bays.push(theirs.workBayId, ours.workBayId)
  184. })
  185. test.afterAll(async () => {
  186. await deleteTechnicians(made.technicians)
  187. await deleteWorkBays(made.bays)
  188. })
  189. test('is refused when either belongs to the other workshop', async ({ browser }) => {
  190. const owner = await browser.newPage({ storageState: 'e2e/.auth/owner.json' })
  191. const before = await jobCount(seeded.vehicleId)
  192. const newJob = `/vehicles/${seeded.vehicleId}/service/new`
  193. await owner.goto(`${newJob}?boardTech=${theirs.technicianId}&boardBay=${ours.workBayId}`)
  194. await expect(owner.getByText('Technician not found')).toBeVisible()
  195. await expect(owner).toHaveURL(/\/service\/new/)
  196. await owner.goto(`${newJob}?boardTech=${ours.technicianId}&boardBay=${theirs.workBayId}`)
  197. await expect(owner.getByText('Work bay not found')).toBeVisible()
  198. await expect(owner).toHaveURL(/\/service\/new/)
  199. expect(await jobCount(seeded.vehicleId), 'no job was made').toBe(before)
  200. await owner.close()
  201. })
  202. test('opens on the workshop’s own technician and bay', async ({ browser }) => {
  203. const owner = await browser.newPage({ storageState: 'e2e/.auth/owner.json' })
  204. await owner.goto(
  205. `/vehicles/${seeded.vehicleId}/service/new?boardTech=${ours.technicianId}&boardBay=${ours.workBayId}`
  206. )
  207. await owner.waitForURL(/\/service\/(?!new)[^/]+$/, { timeout: 30_000 })
  208. const jobId = new URL(owner.url()).pathname.split('/').pop() as string
  209. expect(await jobAssignment(jobId)).toEqual({
  210. id: jobId,
  211. technicianId: ours.technicianId,
  212. workBayId: ours.workBayId,
  213. })
  214. await owner.close()
  215. })
  216. })