| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228 |
- import { expect, type Browser, type Page, test } from '@playwright/test'
- import {
- contentCounts,
- createRoleWithEveryPermission,
- invitationTokenFor,
- ownerOrganizationId,
- setMembership,
- } from '../../support/db'
- import { settle } from '../../support/hydration'
- import { linkIn, waitForMail } from '../../support/mail'
- /**
- * Logged in is not allowed.
- *
- * The September 2026 audit found a class of actions and routes that checked
- * for a session and a permission, and nothing more: any member could wipe the
- * workshop's records, export the whole organisation or replace it with an
- * empty backup, change the plan and charge the card, and a settings manager
- * could invite a second address of their own as admin. All of them are
- * owner-or-admin decisions now, whatever permissions a custom role carries.
- *
- * So a colleague is given a role with every permission the app knows and no
- * admin standing, which is the member every permission check waves through,
- * and is then pointed at each of those doors.
- */
- test.describe.configure({ mode: 'serial' })
- // The colleague starts as a stranger with no session.
- test.use({ storageState: { cookies: [], origins: [] } })
- const stamp = Date.now()
- const MANAGER = `e2e-manager-${stamp}@example.com`
- const PASSWORD = `E2e-pass-${stamp}`
- const SECRET_INVITEE = `e2e-secret-${stamp}@example.com`
- const ADMIN_INVITEE = `e2e-admin-${stamp}@example.com`
- const MEMBER_INVITEE = `e2e-member-${stamp}@example.com`
- let organizationId = ''
- let roleId = ''
- async function signIn(page: Page, email: string, password: string) {
- // The sign-in page sends anyone with a session straight on, so a re-sign-in
- // after a role change has to drop the old session first.
- await page.context().clearCookies()
- await page.goto('/auth/sign-in')
- await page.locator('#email').fill(email)
- 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 })
- }
- /**
- * Opens the team page's Add dialog and sends an invitation to `email` as
- * "someone in the office", optionally as an Admin. The dialog is left open so
- * the caller can read what it said.
- */
- async function invite(page: Page, email: string, role?: 'Admin') {
- await page.goto('/settings/team')
- await settle(page)
- await expect(async () => {
- await page.getByRole('button', { name: 'Add', exact: true }).first().click()
- await expect(page.getByText('Someone in the office')).toBeVisible({ timeout: 2_000 })
- }).toPass({ timeout: 30_000 })
- await page.getByText('Someone in the office').click()
- await page.locator('#member-email').fill(email)
- if (role) {
- const dialog = page.getByRole('dialog').filter({ has: page.locator('#member-email') })
- await dialog.getByRole('combobox').click()
- await page.getByRole('option', { name: role, exact: true }).click()
- }
- await page.getByRole('button', { name: 'Invite', exact: true }).click()
- }
- /** The owner invites an address and sees it listed as pending. */
- async function ownerInvites(browser: Browser, email: string) {
- const owner = await browser.newContext({ storageState: 'e2e/.auth/owner.json' })
- const page = await owner.newPage()
- await invite(page, email)
- await expect(page.getByText(email).first()).toBeVisible({ timeout: 30_000 })
- await owner.close()
- }
- /** The API routes are written to, so the request carries the app's own origin. */
- const sameOrigin = { origin: process.env.E2E_BASE_URL ?? 'http://127.0.0.1:3100' }
- test.afterAll(async () => {
- // The last test makes the manager an admin; the workshop is left with one
- // more ordinary member, not one more admin.
- if (organizationId && roleId) {
- await setMembership(MANAGER, organizationId, { roleId, role: 'member' })
- }
- })
- test.describe('a member with every permission and no admin standing', () => {
- test('is invited by the owner, signs up, and is given the role', async ({ page, browser }) => {
- await ownerInvites(browser, MANAGER)
- const invitation = await waitForMail(MANAGER)
- await page.goto(linkIn(invitation, /\/auth\/sign-up\?invite=/))
- await page.locator('#name').fill('E2E Settings Manager')
- await page.locator('#email').fill(MANAGER)
- await page.locator('#password').fill(PASSWORD)
- await page.locator('#terms').click()
- await page.getByRole('button', { name: /create account/i }).click()
- await page.waitForURL((url) => !/^\/(auth|onboarding)/.test(url.pathname), { timeout: 30_000 })
- organizationId = await ownerOrganizationId()
- roleId = await createRoleWithEveryPermission(organizationId, `E2E Everything ${stamp}`)
- await setMembership(MANAGER, organizationId, { roleId, role: 'member' })
- // The role opens the whole application to them, which is what makes the
- // refusals below worth anything: they are not a roleless member being
- // turned away at the door.
- await signIn(page, MANAGER, PASSWORD)
- await page.goto('/settings/team')
- await expect(page.getByRole('heading', { name: 'No access yet' })).toHaveCount(0)
- await expect(page.getByText(MANAGER).first()).toBeVisible()
- })
- test('cannot export the workshop, or replace it from a backup', async ({ page }) => {
- await signIn(page, MANAGER, PASSWORD)
- for (const route of [
- 'backup/export',
- 'backup/import',
- 'backup/import-lubelog',
- 'backup/import-invoice-ninja',
- ]) {
- // Refused before the body is looked at: an import that got as far as
- // parsing would already be past the check that matters.
- const response = await page.request.post(`/api/protected/${route}`, {
- data: { version: 2, data: {} },
- headers: sameOrigin,
- })
- expect(response.status(), `${route} is refused`).toBe(403)
- expect(await response.json()).toEqual({ error: 'Forbidden' })
- }
- })
- test('cannot wipe the workshop’s records from the data page', async ({ page }) => {
- await signIn(page, MANAGER, PASSWORD)
- const before = await contentCounts(organizationId)
- // The page offers the button to anyone who can open it; the action is
- // what has to say no.
- await page.goto('/settings/data')
- await settle(page)
- const dialog = page.getByRole('dialog', { name: 'Delete Content' })
- await expect(async () => {
- await page.getByRole('button', { name: 'Delete Content', exact: true }).first().click()
- await expect(dialog).toBeVisible({ timeout: 2_000 })
- }).toPass({ timeout: 30_000 })
- await dialog.getByRole('checkbox', { disabled: false }).first().click()
- await dialog.getByPlaceholder('delete my data').fill('delete my data')
- // The confirm button counts what it would delete: "Delete 1 selected".
- await dialog.getByRole('button', { name: /^Delete \d+ selected$/ }).click()
- await expect(page.getByText('Only an owner or admin can delete workshop content')).toBeVisible({
- timeout: 30_000,
- })
- expect(await contentCounts(organizationId), 'nothing was deleted').toEqual(before)
- })
- test('cannot change the plan or reach the card', async ({ page }) => {
- await signIn(page, MANAGER, PASSWORD)
- for (const route of ['upgrade', 'checkout', 'upgrade-preview', 'billing-portal']) {
- const response = await page.request.post(`/api/protected/subscription/${route}`, {
- data: { plan: 'enterprise' },
- headers: sameOrigin,
- })
- expect(response.status(), `${route} is refused`).toBe(403)
- expect(await response.json()).toEqual({ error: 'Forbidden' })
- }
- })
- test('is not offered a way to bring people in', async ({ page }) => {
- // Inviting is an admin's call, and the rule sits in the action
- // (`canInvite`, with its own unit tests). The page agrees with it: the
- // button is not there for a member, however wide their role.
- await signIn(page, MANAGER, PASSWORD)
- await page.goto('/settings/team')
- await settle(page)
- await expect(page.getByText(MANAGER).first()).toBeVisible()
- await expect(page.getByRole('button', { name: 'Add', exact: true })).toHaveCount(0)
- })
- })
- test.describe('an invitation', () => {
- test('keeps its token in the invitee’s inbox and off the team page', async ({ browser }) => {
- // The token is the credential that lets whoever holds it join as the
- // invitee. It used to be returned to everyone who could read the team
- // page, which let a member read the token for the invited boss's address
- // and sign up with it.
- await ownerInvites(browser, SECRET_INVITEE)
- const token = await invitationTokenFor(SECRET_INVITEE, organizationId)
- expect(token, 'the invitation exists').toBeTruthy()
- const mail = await waitForMail(SECRET_INVITEE)
- expect(`${mail.html}\n${mail.text}`, 'the invitee is sent the token').toContain(token)
- const owner = await browser.newContext({ storageState: 'e2e/.auth/owner.json' })
- const html = await (await owner.request.get('/settings/team')).text()
- await owner.close()
- expect(html, 'the team page lists the invitation').toContain(SECRET_INVITEE)
- expect(html, 'without its token').not.toContain(token as string)
- })
- test('as admin can only come from the owner', async ({ page }) => {
- // The manager is made an admin: they may bring people in now, and may
- // still not hand out admin, which is how a settings manager once walked
- // in as one.
- await setMembership(MANAGER, organizationId, { roleId, role: 'admin' })
- await signIn(page, MANAGER, PASSWORD)
- await invite(page, ADMIN_INVITEE, 'Admin')
- await expect(page.getByText('Only the owner can invite admins')).toBeVisible({
- timeout: 30_000,
- })
- expect(await invitationTokenFor(ADMIN_INVITEE, organizationId), 'nothing was sent').toBeNull()
- await invite(page, MEMBER_INVITEE)
- await expect(page.getByText(MEMBER_INVITEE).first()).toBeVisible({ timeout: 30_000 })
- expect(await invitationTokenFor(MEMBER_INVITEE, organizationId)).toBeTruthy()
- })
- })
|