admin-only.spec.ts 9.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228
  1. import { expect, type Browser, type Page, test } from '@playwright/test'
  2. import {
  3. contentCounts,
  4. createRoleWithEveryPermission,
  5. invitationTokenFor,
  6. ownerOrganizationId,
  7. setMembership,
  8. } from '../../support/db'
  9. import { settle } from '../../support/hydration'
  10. import { linkIn, waitForMail } from '../../support/mail'
  11. /**
  12. * Logged in is not allowed.
  13. *
  14. * The September 2026 audit found a class of actions and routes that checked
  15. * for a session and a permission, and nothing more: any member could wipe the
  16. * workshop's records, export the whole organisation or replace it with an
  17. * empty backup, change the plan and charge the card, and a settings manager
  18. * could invite a second address of their own as admin. All of them are
  19. * owner-or-admin decisions now, whatever permissions a custom role carries.
  20. *
  21. * So a colleague is given a role with every permission the app knows and no
  22. * admin standing, which is the member every permission check waves through,
  23. * and is then pointed at each of those doors.
  24. */
  25. test.describe.configure({ mode: 'serial' })
  26. // The colleague starts as a stranger with no session.
  27. test.use({ storageState: { cookies: [], origins: [] } })
  28. const stamp = Date.now()
  29. const MANAGER = `e2e-manager-${stamp}@example.com`
  30. const PASSWORD = `E2e-pass-${stamp}`
  31. const SECRET_INVITEE = `e2e-secret-${stamp}@example.com`
  32. const ADMIN_INVITEE = `e2e-admin-${stamp}@example.com`
  33. const MEMBER_INVITEE = `e2e-member-${stamp}@example.com`
  34. let organizationId = ''
  35. let roleId = ''
  36. async function signIn(page: Page, email: string, password: string) {
  37. // The sign-in page sends anyone with a session straight on, so a re-sign-in
  38. // after a role change has to drop the old session first.
  39. await page.context().clearCookies()
  40. await page.goto('/auth/sign-in')
  41. await page.locator('#email').fill(email)
  42. await page.locator('#password').fill(password)
  43. await page.getByRole('button', { name: 'Sign In', exact: true }).click()
  44. await page.waitForURL((url) => !url.pathname.startsWith('/auth'), { timeout: 30_000 })
  45. }
  46. /**
  47. * Opens the team page's Add dialog and sends an invitation to `email` as
  48. * "someone in the office", optionally as an Admin. The dialog is left open so
  49. * the caller can read what it said.
  50. */
  51. async function invite(page: Page, email: string, role?: 'Admin') {
  52. await page.goto('/settings/team')
  53. await settle(page)
  54. await expect(async () => {
  55. await page.getByRole('button', { name: 'Add', exact: true }).first().click()
  56. await expect(page.getByText('Someone in the office')).toBeVisible({ timeout: 2_000 })
  57. }).toPass({ timeout: 30_000 })
  58. await page.getByText('Someone in the office').click()
  59. await page.locator('#member-email').fill(email)
  60. if (role) {
  61. const dialog = page.getByRole('dialog').filter({ has: page.locator('#member-email') })
  62. await dialog.getByRole('combobox').click()
  63. await page.getByRole('option', { name: role, exact: true }).click()
  64. }
  65. await page.getByRole('button', { name: 'Invite', exact: true }).click()
  66. }
  67. /** The owner invites an address and sees it listed as pending. */
  68. async function ownerInvites(browser: Browser, email: string) {
  69. const owner = await browser.newContext({ storageState: 'e2e/.auth/owner.json' })
  70. const page = await owner.newPage()
  71. await invite(page, email)
  72. await expect(page.getByText(email).first()).toBeVisible({ timeout: 30_000 })
  73. await owner.close()
  74. }
  75. /** The API routes are written to, so the request carries the app's own origin. */
  76. const sameOrigin = { origin: process.env.E2E_BASE_URL ?? 'http://127.0.0.1:3100' }
  77. test.afterAll(async () => {
  78. // The last test makes the manager an admin; the workshop is left with one
  79. // more ordinary member, not one more admin.
  80. if (organizationId && roleId) {
  81. await setMembership(MANAGER, organizationId, { roleId, role: 'member' })
  82. }
  83. })
  84. test.describe('a member with every permission and no admin standing', () => {
  85. test('is invited by the owner, signs up, and is given the role', async ({ page, browser }) => {
  86. await ownerInvites(browser, MANAGER)
  87. const invitation = await waitForMail(MANAGER)
  88. await page.goto(linkIn(invitation, /\/auth\/sign-up\?invite=/))
  89. await page.locator('#name').fill('E2E Settings Manager')
  90. await page.locator('#email').fill(MANAGER)
  91. await page.locator('#password').fill(PASSWORD)
  92. await page.locator('#terms').click()
  93. await page.getByRole('button', { name: /create account/i }).click()
  94. await page.waitForURL((url) => !/^\/(auth|onboarding)/.test(url.pathname), { timeout: 30_000 })
  95. organizationId = await ownerOrganizationId()
  96. roleId = await createRoleWithEveryPermission(organizationId, `E2E Everything ${stamp}`)
  97. await setMembership(MANAGER, organizationId, { roleId, role: 'member' })
  98. // The role opens the whole application to them, which is what makes the
  99. // refusals below worth anything: they are not a roleless member being
  100. // turned away at the door.
  101. await signIn(page, MANAGER, PASSWORD)
  102. await page.goto('/settings/team')
  103. await expect(page.getByRole('heading', { name: 'No access yet' })).toHaveCount(0)
  104. await expect(page.getByText(MANAGER).first()).toBeVisible()
  105. })
  106. test('cannot export the workshop, or replace it from a backup', async ({ page }) => {
  107. await signIn(page, MANAGER, PASSWORD)
  108. for (const route of [
  109. 'backup/export',
  110. 'backup/import',
  111. 'backup/import-lubelog',
  112. 'backup/import-invoice-ninja',
  113. ]) {
  114. // Refused before the body is looked at: an import that got as far as
  115. // parsing would already be past the check that matters.
  116. const response = await page.request.post(`/api/protected/${route}`, {
  117. data: { version: 2, data: {} },
  118. headers: sameOrigin,
  119. })
  120. expect(response.status(), `${route} is refused`).toBe(403)
  121. expect(await response.json()).toEqual({ error: 'Forbidden' })
  122. }
  123. })
  124. test('cannot wipe the workshop’s records from the data page', async ({ page }) => {
  125. await signIn(page, MANAGER, PASSWORD)
  126. const before = await contentCounts(organizationId)
  127. // The page offers the button to anyone who can open it; the action is
  128. // what has to say no.
  129. await page.goto('/settings/data')
  130. await settle(page)
  131. const dialog = page.getByRole('dialog', { name: 'Delete Content' })
  132. await expect(async () => {
  133. await page.getByRole('button', { name: 'Delete Content', exact: true }).first().click()
  134. await expect(dialog).toBeVisible({ timeout: 2_000 })
  135. }).toPass({ timeout: 30_000 })
  136. await dialog.getByRole('checkbox', { disabled: false }).first().click()
  137. await dialog.getByPlaceholder('delete my data').fill('delete my data')
  138. // The confirm button counts what it would delete: "Delete 1 selected".
  139. await dialog.getByRole('button', { name: /^Delete \d+ selected$/ }).click()
  140. await expect(page.getByText('Only an owner or admin can delete workshop content')).toBeVisible({
  141. timeout: 30_000,
  142. })
  143. expect(await contentCounts(organizationId), 'nothing was deleted').toEqual(before)
  144. })
  145. test('cannot change the plan or reach the card', async ({ page }) => {
  146. await signIn(page, MANAGER, PASSWORD)
  147. for (const route of ['upgrade', 'checkout', 'upgrade-preview', 'billing-portal']) {
  148. const response = await page.request.post(`/api/protected/subscription/${route}`, {
  149. data: { plan: 'enterprise' },
  150. headers: sameOrigin,
  151. })
  152. expect(response.status(), `${route} is refused`).toBe(403)
  153. expect(await response.json()).toEqual({ error: 'Forbidden' })
  154. }
  155. })
  156. test('is not offered a way to bring people in', async ({ page }) => {
  157. // Inviting is an admin's call, and the rule sits in the action
  158. // (`canInvite`, with its own unit tests). The page agrees with it: the
  159. // button is not there for a member, however wide their role.
  160. await signIn(page, MANAGER, PASSWORD)
  161. await page.goto('/settings/team')
  162. await settle(page)
  163. await expect(page.getByText(MANAGER).first()).toBeVisible()
  164. await expect(page.getByRole('button', { name: 'Add', exact: true })).toHaveCount(0)
  165. })
  166. })
  167. test.describe('an invitation', () => {
  168. test('keeps its token in the invitee’s inbox and off the team page', async ({ browser }) => {
  169. // The token is the credential that lets whoever holds it join as the
  170. // invitee. It used to be returned to everyone who could read the team
  171. // page, which let a member read the token for the invited boss's address
  172. // and sign up with it.
  173. await ownerInvites(browser, SECRET_INVITEE)
  174. const token = await invitationTokenFor(SECRET_INVITEE, organizationId)
  175. expect(token, 'the invitation exists').toBeTruthy()
  176. const mail = await waitForMail(SECRET_INVITEE)
  177. expect(`${mail.html}\n${mail.text}`, 'the invitee is sent the token').toContain(token)
  178. const owner = await browser.newContext({ storageState: 'e2e/.auth/owner.json' })
  179. const html = await (await owner.request.get('/settings/team')).text()
  180. await owner.close()
  181. expect(html, 'the team page lists the invitation').toContain(SECRET_INVITEE)
  182. expect(html, 'without its token').not.toContain(token as string)
  183. })
  184. test('as admin can only come from the owner', async ({ page }) => {
  185. // The manager is made an admin: they may bring people in now, and may
  186. // still not hand out admin, which is how a settings manager once walked
  187. // in as one.
  188. await setMembership(MANAGER, organizationId, { roleId, role: 'admin' })
  189. await signIn(page, MANAGER, PASSWORD)
  190. await invite(page, ADMIN_INVITEE, 'Admin')
  191. await expect(page.getByText('Only the owner can invite admins')).toBeVisible({
  192. timeout: 30_000,
  193. })
  194. expect(await invitationTokenFor(ADMIN_INVITEE, organizationId), 'nothing was sent').toBeNull()
  195. await invite(page, MEMBER_INVITEE)
  196. await expect(page.getByText(MEMBER_INVITEE).first()).toBeVisible({ timeout: 30_000 })
  197. expect(await invitationTokenFor(MEMBER_INVITEE, organizationId)).toBeTruthy()
  198. })
  199. })