4
0

member-role.spec.ts 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319
  1. import { expect, type Page, test } from '@playwright/test'
  2. import {
  3. customFieldValue,
  4. deleteCustomFields,
  5. deletePersonWithEmail,
  6. forgetWorkshopSetting,
  7. membershipOf,
  8. ownerOrganizationId,
  9. permissionDenialsFor,
  10. plantCustomField,
  11. roleIdNamed,
  12. seededTenantFixtures,
  13. setWorkshopSetting,
  14. type TenantFixtures,
  15. } from '../../support/db'
  16. import { settle } from '../../support/hydration'
  17. import { linkIn, waitForMail } from '../../support/mail'
  18. import { saveWorkOrder } from '../../support/work-order'
  19. /**
  20. * The role the product hands somebody at the desk.
  21. *
  22. * Every other permission test in the suite covers an extreme: `roles.spec.ts`
  23. * has a member with no role refused everywhere, `admin-only.spec.ts` has a
  24. * member with every permission still kept out of the owner's screens, and the
  25. * other fifty specs run as the owner, who skips the permission check
  26. * altogether. Nothing signed in as the built-in Member role and used the
  27. * ordinary pages that role is allowed to open, and two bugs lived in the gap.
  28. * Both were silent: nineteen pages read the workshop's currency, units and tax
  29. * through a call that needs `read:settings`, were refused, and fell back to the
  30. * built-in defaults without a word, and a work order's custom fields needed the
  31. * Settings permission too, so a Member saw none of them.
  32. *
  33. * The guard below is therefore not "assert the currency". A workshop whose
  34. * currency is USD hides that bug, and a page that quietly substitutes a
  35. * default never turns red. It is: using the pages this role may use writes no
  36. * `auth.permissionDenied` row. A refusal on a page the role is meant to reach
  37. * is, by definition, a page asking for something the role does not carry,
  38. * whatever the missing permission turns out to be.
  39. *
  40. * Two narrow checks of what the person actually sees sit on top of it, because
  41. * an audit table is not a user.
  42. */
  43. test.describe.configure({ mode: 'serial' })
  44. // The colleague starts as a stranger with no session.
  45. test.use({ storageState: { cookies: [], origins: [] } })
  46. const stamp = Date.now()
  47. const MEMBER = `e2e-member-${stamp}@example.com`
  48. const PASSWORD = `E2e-pass-${stamp}`
  49. const FIELD_LABEL = `E2E Paint code ${stamp}`
  50. const CURRENCY_KEY = 'workshop.currencyCode'
  51. /**
  52. * A currency that cannot be mistaken for the built-in fallback. `USD` is what
  53. * every page substitutes when the read is refused, so a workshop on USD proves
  54. * nothing; NOK prints "kr" and never "$".
  55. */
  56. const CURRENCY = 'NOK'
  57. let organizationId = ''
  58. let fixtures: TenantFixtures
  59. let memberRoleId: string | null = null
  60. let currencyBefore: string | null = null
  61. let currencyChanged = false
  62. const plantedFields: string[] = []
  63. async function signInAsMember(page: Page) {
  64. // The sign-in page sends anyone with a session straight on, so a fresh
  65. // sign-in has to drop the old cookie first.
  66. await page.context().clearCookies()
  67. await page.goto('/auth/sign-in')
  68. await page.locator('#email').fill(MEMBER)
  69. await page.locator('#password').fill(PASSWORD)
  70. await page.getByRole('button', { name: 'Sign In', exact: true }).click()
  71. await page.waitForURL((url) => !url.pathname.startsWith('/auth'), { timeout: 30_000 })
  72. }
  73. test.afterAll(async () => {
  74. // The workshop is left exactly as it was found: the currency back, the
  75. // planted field and its values gone, the colleague gone. The Member role
  76. // itself stays, because the product made it through its own button and it
  77. // is the workshop's now.
  78. if (currencyChanged) {
  79. if (currencyBefore === null) await forgetWorkshopSetting(organizationId, CURRENCY_KEY)
  80. else await setWorkshopSetting(organizationId, CURRENCY_KEY, currencyBefore)
  81. }
  82. await deleteCustomFields(plantedFields)
  83. await deletePersonWithEmail(MEMBER)
  84. })
  85. test.describe('the built-in Member role', () => {
  86. test('is invited with the workshop’s own Member role, and signs up from the mail', async ({
  87. page,
  88. browser,
  89. }) => {
  90. organizationId = await ownerOrganizationId()
  91. fixtures = await seededTenantFixtures()
  92. const owner = await browser.newContext({ storageState: 'e2e/.auth/owner.json' })
  93. const ownerPage = await owner.newPage()
  94. await ownerPage.goto('/settings/team')
  95. await settle(ownerPage)
  96. // The role has to be the one the app makes, not one assembled here: a
  97. // permission list written in a test keeps passing after the real role
  98. // changes underneath it. The team page offers to create the standard roles
  99. // while either is missing, which is the path a workshop really takes.
  100. const createRoles = ownerPage.getByRole('button', { name: 'Create the standard roles' })
  101. if (await createRoles.isVisible().catch(() => false)) {
  102. await createRoles.click()
  103. await expect(ownerPage.getByText('Standard roles created')).toBeVisible({ timeout: 30_000 })
  104. }
  105. memberRoleId = await roleIdNamed(organizationId, 'Member')
  106. expect(memberRoleId, 'the workshop has the built-in Member role').toBeTruthy()
  107. await expect(async () => {
  108. await ownerPage.getByRole('button', { name: 'Add', exact: true }).first().click()
  109. await expect(ownerPage.getByText('Someone in the office')).toBeVisible({ timeout: 2_000 })
  110. }).toPass({ timeout: 30_000 })
  111. await ownerPage.getByText('Someone in the office').click()
  112. await ownerPage.locator('#member-email').fill(MEMBER)
  113. const dialog = ownerPage.getByRole('dialog').filter({ has: ownerPage.locator('#member-email') })
  114. await dialog.getByRole('combobox').click()
  115. await ownerPage.getByRole('option', { name: 'Member', exact: true }).click()
  116. await ownerPage.getByRole('button', { name: 'Invite', exact: true }).click()
  117. await expect(ownerPage.getByText(MEMBER).first()).toBeVisible({ timeout: 30_000 })
  118. await owner.close()
  119. const invitation = await waitForMail(MEMBER)
  120. await page.goto(linkIn(invitation, /\/auth\/sign-up\?invite=/))
  121. await page.locator('#name').fill('E2E Desk Colleague')
  122. await page.locator('#email').fill(MEMBER)
  123. await page.locator('#password').fill(PASSWORD)
  124. await page.locator('#terms').click()
  125. await page.getByRole('button', { name: /create account/i }).click()
  126. // Inside the app, not at the door and not in onboarding: they joined a
  127. // workshop that already exists.
  128. await page.waitForURL((url) => !/^\/(auth|onboarding)/.test(url.pathname), { timeout: 30_000 })
  129. await expect(page.getByRole('heading', { name: 'No access yet' })).toHaveCount(0)
  130. // And they carry the built-in role, not an accidental blank membership.
  131. const membership = await membershipOf(MEMBER, organizationId)
  132. expect(membership.roleId).toBe(memberRoleId)
  133. expect(membership.role).toBe('member')
  134. })
  135. test('is refused nothing on any page the role is meant to open', async ({ page }) => {
  136. await signInAsMember(page)
  137. /**
  138. * Every page the Member role carries a permission for. Deliberately absent:
  139. * settings, billing, reports, inspections and the tire hotel, which this
  140. * role is meant to be refused and which the last test pins.
  141. */
  142. const pages: { url: string; shows: (page: Page) => Promise<void> }[] = [
  143. { url: '/', shows: (p) => expect(p.getByText('Dashboard').first()).toBeVisible() },
  144. {
  145. url: '/work-orders',
  146. shows: (p) => expect(p.getByText('All Work Orders').first()).toBeVisible(),
  147. },
  148. { url: '/vehicles', shows: (p) => expect(p.getByText('All Vehicles').first()).toBeVisible() },
  149. {
  150. url: `/vehicles/${fixtures.vehicleId}`,
  151. // A page that rendered its own record, not an empty shell.
  152. shows: (p) => expect(p.getByText(fixtures.vehiclePlate).first()).toBeVisible(),
  153. },
  154. {
  155. url: `/vehicles/${fixtures.vehicleId}/service/${fixtures.serviceRecordId}`,
  156. shows: (p) => expect(p.getByTestId('totals')).toBeVisible(),
  157. },
  158. {
  159. url: '/customers',
  160. shows: (p) => expect(p.getByText('All Customers').first()).toBeVisible(),
  161. },
  162. {
  163. url: `/customers/${fixtures.customerId}`,
  164. shows: (p) => expect(p.getByText(fixtures.customerName).first()).toBeVisible(),
  165. },
  166. { url: '/quotes', shows: (p) => expect(p.getByText('All Quotes').first()).toBeVisible() },
  167. {
  168. url: `/quotes/${fixtures.quoteId}`,
  169. shows: (p) => expect(p.getByText(fixtures.quoteNumber).first()).toBeVisible(),
  170. },
  171. { url: '/inventory', shows: (p) => expect(p.getByText('All Parts').first()).toBeVisible() },
  172. {
  173. url: '/labor-presets',
  174. shows: (p) => expect(p.getByText('Labor Presets').first()).toBeVisible(),
  175. },
  176. { url: '/work-board', shows: (p) => expect(p.getByText('Work Board').first()).toBeVisible() },
  177. {
  178. url: '/calendar',
  179. shows: (p) => expect(p.getByRole('button', { name: 'Today' }).first()).toBeVisible(),
  180. },
  181. ]
  182. // A second either side: the audit write is fire-and-forget, and the clock
  183. // here is not the clock the row is stamped with.
  184. const since = new Date(Date.now() - 1_000)
  185. const refusals: string[] = []
  186. let counted = 0
  187. for (const { url, shows } of pages) {
  188. await page.goto(url)
  189. await settle(page)
  190. // The refusal screen, an error boundary or an empty shell all mean the
  191. // page did not open, and would leave the audit log innocently empty.
  192. await expect(
  193. page.getByRole('heading', { name: 'No access yet' }),
  194. `${url} opens`
  195. ).toHaveCount(0)
  196. await shows(page)
  197. await page.waitForTimeout(2_000)
  198. const all = await permissionDenialsFor(MEMBER, since)
  199. // Attributed to the page that was open when they appeared, which is what
  200. // makes a failure here readable rather than a list of bare messages.
  201. for (const message of all.slice(counted)) refusals.push(`${url} -> ${message}`)
  202. counted = all.length
  203. }
  204. expect(
  205. refusals,
  206. 'a page this role may open asked for a permission the role does not carry'
  207. ).toEqual([])
  208. })
  209. test('sees the workshop’s own currency, not the built-in default', async ({ page, browser }) => {
  210. currencyBefore = await setWorkshopSetting(organizationId, CURRENCY_KEY, CURRENCY)
  211. currencyChanged = true
  212. const jobUrl = `/vehicles/${fixtures.vehicleId}/service/${fixtures.serviceRecordId}`
  213. // What the owner sees is the workshop's own answer, whatever the app's
  214. // formatting happens to be. The Member has to see the same thing rather
  215. // than a shape this test decided on.
  216. const owner = await browser.newContext({ storageState: 'e2e/.auth/owner.json' })
  217. const ownerPage = await owner.newPage()
  218. await ownerPage.goto(jobUrl)
  219. await settle(ownerPage)
  220. const ownersTotals = (await ownerPage.getByTestId('totals').innerText()).trim()
  221. await owner.close()
  222. expect(ownersTotals, 'the workshop is on NOK for this test').toContain('kr')
  223. await signInAsMember(page)
  224. await page.goto(jobUrl)
  225. await settle(page)
  226. const membersTotals = (await page.getByTestId('totals').innerText()).trim()
  227. expect(membersTotals).toBe(ownersTotals)
  228. // Said plainly as well, because the comparison above would pass if both
  229. // sides broke together.
  230. expect(membersTotals).toContain('kr')
  231. expect(membersTotals).not.toContain('$')
  232. expect(membersTotals).not.toContain('USD')
  233. // And on a list, which reads the currency through the same call.
  234. await page.goto('/work-orders')
  235. await settle(page)
  236. const list = await page.getByRole('table').first().innerText()
  237. expect(list).not.toContain('$')
  238. })
  239. test('sees a custom field on a work order, fills it in, and it stays', async ({ page }) => {
  240. const fieldId = await plantCustomField(organizationId, 'service_record', FIELD_LABEL)
  241. plantedFields.push(fieldId)
  242. await signInAsMember(page)
  243. const jobUrl = `/vehicles/${fixtures.vehicleId}/service/${fixtures.serviceRecordId}`
  244. await page.goto(jobUrl)
  245. await settle(page)
  246. // The label is the whole point: a Member used to see no custom fields at
  247. // all, so the workshop's own question never reached the person answering it.
  248. const field = page.getByTestId(`custom-field-${fieldId}`)
  249. await expect(field).toBeVisible()
  250. await expect(field.getByText(FIELD_LABEL)).toBeVisible()
  251. const value = `LY9C ${stamp}`
  252. const input = field.getByRole('textbox')
  253. await expect(async () => {
  254. await input.fill(value)
  255. await expect(input).toHaveValue(value)
  256. }).toPass({ timeout: 30_000 })
  257. await saveWorkOrder(page)
  258. // Read back from the database as well as the page: the save used to be
  259. // refused outright, and an input keeps what was typed into it either way.
  260. await expect(async () => {
  261. expect(await customFieldValue(fieldId, fixtures.serviceRecordId)).toBe(value)
  262. }).toPass({ timeout: 30_000 })
  263. await page.goto(jobUrl)
  264. await settle(page)
  265. await expect(page.getByTestId(`custom-field-${fieldId}`).getByRole('textbox')).toHaveValue(
  266. value
  267. )
  268. })
  269. test('is still kept out of the workshop’s settings', async ({ page }) => {
  270. // The other half of the rule, and the guard against widening the role to
  271. // make the first test pass: this table holds payment secrets, API keys and
  272. // the licence token, and none of it is a Member's business.
  273. await signInAsMember(page)
  274. for (const url of ['/settings', '/settings/company']) {
  275. await page.goto(url)
  276. await settle(page)
  277. await expect(page, `${url} is refused`).not.toHaveURL(new RegExp(`${url}$`))
  278. }
  279. })
  280. })