| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319 |
- import { expect, type Page, test } from '@playwright/test'
- import {
- customFieldValue,
- deleteCustomFields,
- deletePersonWithEmail,
- forgetWorkshopSetting,
- membershipOf,
- ownerOrganizationId,
- permissionDenialsFor,
- plantCustomField,
- roleIdNamed,
- seededTenantFixtures,
- setWorkshopSetting,
- type TenantFixtures,
- } from '../../support/db'
- import { settle } from '../../support/hydration'
- import { linkIn, waitForMail } from '../../support/mail'
- import { saveWorkOrder } from '../../support/work-order'
- /**
- * The role the product hands somebody at the desk.
- *
- * Every other permission test in the suite covers an extreme: `roles.spec.ts`
- * has a member with no role refused everywhere, `admin-only.spec.ts` has a
- * member with every permission still kept out of the owner's screens, and the
- * other fifty specs run as the owner, who skips the permission check
- * altogether. Nothing signed in as the built-in Member role and used the
- * ordinary pages that role is allowed to open, and two bugs lived in the gap.
- * Both were silent: nineteen pages read the workshop's currency, units and tax
- * through a call that needs `read:settings`, were refused, and fell back to the
- * built-in defaults without a word, and a work order's custom fields needed the
- * Settings permission too, so a Member saw none of them.
- *
- * The guard below is therefore not "assert the currency". A workshop whose
- * currency is USD hides that bug, and a page that quietly substitutes a
- * default never turns red. It is: using the pages this role may use writes no
- * `auth.permissionDenied` row. A refusal on a page the role is meant to reach
- * is, by definition, a page asking for something the role does not carry,
- * whatever the missing permission turns out to be.
- *
- * Two narrow checks of what the person actually sees sit on top of it, because
- * an audit table is not a user.
- */
- test.describe.configure({ mode: 'serial' })
- // The colleague starts as a stranger with no session.
- test.use({ storageState: { cookies: [], origins: [] } })
- const stamp = Date.now()
- const MEMBER = `e2e-member-${stamp}@example.com`
- const PASSWORD = `E2e-pass-${stamp}`
- const FIELD_LABEL = `E2E Paint code ${stamp}`
- const CURRENCY_KEY = 'workshop.currencyCode'
- /**
- * A currency that cannot be mistaken for the built-in fallback. `USD` is what
- * every page substitutes when the read is refused, so a workshop on USD proves
- * nothing; NOK prints "kr" and never "$".
- */
- const CURRENCY = 'NOK'
- let organizationId = ''
- let fixtures: TenantFixtures
- let memberRoleId: string | null = null
- let currencyBefore: string | null = null
- let currencyChanged = false
- const plantedFields: string[] = []
- async function signInAsMember(page: Page) {
- // The sign-in page sends anyone with a session straight on, so a fresh
- // sign-in has to drop the old cookie first.
- await page.context().clearCookies()
- await page.goto('/auth/sign-in')
- await page.locator('#email').fill(MEMBER)
- await page.locator('#password').fill(PASSWORD)
- await page.getByRole('button', { name: 'Sign In', exact: true }).click()
- await page.waitForURL((url) => !url.pathname.startsWith('/auth'), { timeout: 30_000 })
- }
- test.afterAll(async () => {
- // The workshop is left exactly as it was found: the currency back, the
- // planted field and its values gone, the colleague gone. The Member role
- // itself stays, because the product made it through its own button and it
- // is the workshop's now.
- if (currencyChanged) {
- if (currencyBefore === null) await forgetWorkshopSetting(organizationId, CURRENCY_KEY)
- else await setWorkshopSetting(organizationId, CURRENCY_KEY, currencyBefore)
- }
- await deleteCustomFields(plantedFields)
- await deletePersonWithEmail(MEMBER)
- })
- test.describe('the built-in Member role', () => {
- test('is invited with the workshop’s own Member role, and signs up from the mail', async ({
- page,
- browser,
- }) => {
- organizationId = await ownerOrganizationId()
- fixtures = await seededTenantFixtures()
- const owner = await browser.newContext({ storageState: 'e2e/.auth/owner.json' })
- const ownerPage = await owner.newPage()
- await ownerPage.goto('/settings/team')
- await settle(ownerPage)
- // The role has to be the one the app makes, not one assembled here: a
- // permission list written in a test keeps passing after the real role
- // changes underneath it. The team page offers to create the standard roles
- // while either is missing, which is the path a workshop really takes.
- const createRoles = ownerPage.getByRole('button', { name: 'Create the standard roles' })
- if (await createRoles.isVisible().catch(() => false)) {
- await createRoles.click()
- await expect(ownerPage.getByText('Standard roles created')).toBeVisible({ timeout: 30_000 })
- }
- memberRoleId = await roleIdNamed(organizationId, 'Member')
- expect(memberRoleId, 'the workshop has the built-in Member role').toBeTruthy()
- await expect(async () => {
- await ownerPage.getByRole('button', { name: 'Add', exact: true }).first().click()
- await expect(ownerPage.getByText('Someone in the office')).toBeVisible({ timeout: 2_000 })
- }).toPass({ timeout: 30_000 })
- await ownerPage.getByText('Someone in the office').click()
- await ownerPage.locator('#member-email').fill(MEMBER)
- const dialog = ownerPage.getByRole('dialog').filter({ has: ownerPage.locator('#member-email') })
- await dialog.getByRole('combobox').click()
- await ownerPage.getByRole('option', { name: 'Member', exact: true }).click()
- await ownerPage.getByRole('button', { name: 'Invite', exact: true }).click()
- await expect(ownerPage.getByText(MEMBER).first()).toBeVisible({ timeout: 30_000 })
- await owner.close()
- const invitation = await waitForMail(MEMBER)
- await page.goto(linkIn(invitation, /\/auth\/sign-up\?invite=/))
- await page.locator('#name').fill('E2E Desk Colleague')
- await page.locator('#email').fill(MEMBER)
- await page.locator('#password').fill(PASSWORD)
- await page.locator('#terms').click()
- await page.getByRole('button', { name: /create account/i }).click()
- // Inside the app, not at the door and not in onboarding: they joined a
- // workshop that already exists.
- await page.waitForURL((url) => !/^\/(auth|onboarding)/.test(url.pathname), { timeout: 30_000 })
- await expect(page.getByRole('heading', { name: 'No access yet' })).toHaveCount(0)
- // And they carry the built-in role, not an accidental blank membership.
- const membership = await membershipOf(MEMBER, organizationId)
- expect(membership.roleId).toBe(memberRoleId)
- expect(membership.role).toBe('member')
- })
- test('is refused nothing on any page the role is meant to open', async ({ page }) => {
- await signInAsMember(page)
- /**
- * Every page the Member role carries a permission for. Deliberately absent:
- * settings, billing, reports, inspections and the tire hotel, which this
- * role is meant to be refused and which the last test pins.
- */
- const pages: { url: string; shows: (page: Page) => Promise<void> }[] = [
- { url: '/', shows: (p) => expect(p.getByText('Dashboard').first()).toBeVisible() },
- {
- url: '/work-orders',
- shows: (p) => expect(p.getByText('All Work Orders').first()).toBeVisible(),
- },
- { url: '/vehicles', shows: (p) => expect(p.getByText('All Vehicles').first()).toBeVisible() },
- {
- url: `/vehicles/${fixtures.vehicleId}`,
- // A page that rendered its own record, not an empty shell.
- shows: (p) => expect(p.getByText(fixtures.vehiclePlate).first()).toBeVisible(),
- },
- {
- url: `/vehicles/${fixtures.vehicleId}/service/${fixtures.serviceRecordId}`,
- shows: (p) => expect(p.getByTestId('totals')).toBeVisible(),
- },
- {
- url: '/customers',
- shows: (p) => expect(p.getByText('All Customers').first()).toBeVisible(),
- },
- {
- url: `/customers/${fixtures.customerId}`,
- shows: (p) => expect(p.getByText(fixtures.customerName).first()).toBeVisible(),
- },
- { url: '/quotes', shows: (p) => expect(p.getByText('All Quotes').first()).toBeVisible() },
- {
- url: `/quotes/${fixtures.quoteId}`,
- shows: (p) => expect(p.getByText(fixtures.quoteNumber).first()).toBeVisible(),
- },
- { url: '/inventory', shows: (p) => expect(p.getByText('All Parts').first()).toBeVisible() },
- {
- url: '/labor-presets',
- shows: (p) => expect(p.getByText('Labor Presets').first()).toBeVisible(),
- },
- { url: '/work-board', shows: (p) => expect(p.getByText('Work Board').first()).toBeVisible() },
- {
- url: '/calendar',
- shows: (p) => expect(p.getByRole('button', { name: 'Today' }).first()).toBeVisible(),
- },
- ]
- // A second either side: the audit write is fire-and-forget, and the clock
- // here is not the clock the row is stamped with.
- const since = new Date(Date.now() - 1_000)
- const refusals: string[] = []
- let counted = 0
- for (const { url, shows } of pages) {
- await page.goto(url)
- await settle(page)
- // The refusal screen, an error boundary or an empty shell all mean the
- // page did not open, and would leave the audit log innocently empty.
- await expect(
- page.getByRole('heading', { name: 'No access yet' }),
- `${url} opens`
- ).toHaveCount(0)
- await shows(page)
- await page.waitForTimeout(2_000)
- const all = await permissionDenialsFor(MEMBER, since)
- // Attributed to the page that was open when they appeared, which is what
- // makes a failure here readable rather than a list of bare messages.
- for (const message of all.slice(counted)) refusals.push(`${url} -> ${message}`)
- counted = all.length
- }
- expect(
- refusals,
- 'a page this role may open asked for a permission the role does not carry'
- ).toEqual([])
- })
- test('sees the workshop’s own currency, not the built-in default', async ({ page, browser }) => {
- currencyBefore = await setWorkshopSetting(organizationId, CURRENCY_KEY, CURRENCY)
- currencyChanged = true
- const jobUrl = `/vehicles/${fixtures.vehicleId}/service/${fixtures.serviceRecordId}`
- // What the owner sees is the workshop's own answer, whatever the app's
- // formatting happens to be. The Member has to see the same thing rather
- // than a shape this test decided on.
- const owner = await browser.newContext({ storageState: 'e2e/.auth/owner.json' })
- const ownerPage = await owner.newPage()
- await ownerPage.goto(jobUrl)
- await settle(ownerPage)
- const ownersTotals = (await ownerPage.getByTestId('totals').innerText()).trim()
- await owner.close()
- expect(ownersTotals, 'the workshop is on NOK for this test').toContain('kr')
- await signInAsMember(page)
- await page.goto(jobUrl)
- await settle(page)
- const membersTotals = (await page.getByTestId('totals').innerText()).trim()
- expect(membersTotals).toBe(ownersTotals)
- // Said plainly as well, because the comparison above would pass if both
- // sides broke together.
- expect(membersTotals).toContain('kr')
- expect(membersTotals).not.toContain('$')
- expect(membersTotals).not.toContain('USD')
- // And on a list, which reads the currency through the same call.
- await page.goto('/work-orders')
- await settle(page)
- const list = await page.getByRole('table').first().innerText()
- expect(list).not.toContain('$')
- })
- test('sees a custom field on a work order, fills it in, and it stays', async ({ page }) => {
- const fieldId = await plantCustomField(organizationId, 'service_record', FIELD_LABEL)
- plantedFields.push(fieldId)
- await signInAsMember(page)
- const jobUrl = `/vehicles/${fixtures.vehicleId}/service/${fixtures.serviceRecordId}`
- await page.goto(jobUrl)
- await settle(page)
- // The label is the whole point: a Member used to see no custom fields at
- // all, so the workshop's own question never reached the person answering it.
- const field = page.getByTestId(`custom-field-${fieldId}`)
- await expect(field).toBeVisible()
- await expect(field.getByText(FIELD_LABEL)).toBeVisible()
- const value = `LY9C ${stamp}`
- const input = field.getByRole('textbox')
- await expect(async () => {
- await input.fill(value)
- await expect(input).toHaveValue(value)
- }).toPass({ timeout: 30_000 })
- await saveWorkOrder(page)
- // Read back from the database as well as the page: the save used to be
- // refused outright, and an input keeps what was typed into it either way.
- await expect(async () => {
- expect(await customFieldValue(fieldId, fixtures.serviceRecordId)).toBe(value)
- }).toPass({ timeout: 30_000 })
- await page.goto(jobUrl)
- await settle(page)
- await expect(page.getByTestId(`custom-field-${fieldId}`).getByRole('textbox')).toHaveValue(
- value
- )
- })
- test('is still kept out of the workshop’s settings', async ({ page }) => {
- // The other half of the rule, and the guard against widening the role to
- // make the first test pass: this table holds payment secrets, API keys and
- // the licence token, and none of it is a Member's business.
- await signInAsMember(page)
- for (const url of ['/settings', '/settings/company']) {
- await page.goto(url)
- await settle(page)
- await expect(page, `${url} is refused`).not.toHaveURL(new RegExp(`${url}$`))
- }
- })
- })
|