admin-only.spec.ts 9.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227
  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", as an Admin or with no role. The form sends
  49. * nothing until a role is chosen, so one always is. The dialog is left open
  50. * so the caller can read what it said.
  51. */
  52. async function invite(page: Page, email: string, role: 'Admin' | 'No role' = 'No role') {
  53. await page.goto('/settings/team')
  54. await settle(page)
  55. await expect(async () => {
  56. await page.getByRole('button', { name: 'Add', exact: true }).first().click()
  57. await expect(page.getByText('Someone in the office')).toBeVisible({ timeout: 2_000 })
  58. }).toPass({ timeout: 30_000 })
  59. await page.getByText('Someone in the office').click()
  60. await page.locator('#member-email').fill(email)
  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. await page.getByRole('button', { name: 'Invite', exact: true }).click()
  65. }
  66. /** The owner invites an address and sees it listed as pending. */
  67. async function ownerInvites(browser: Browser, email: string) {
  68. const owner = await browser.newContext({ storageState: 'e2e/.auth/owner.json' })
  69. const page = await owner.newPage()
  70. await invite(page, email)
  71. await expect(page.getByText(email).first()).toBeVisible({ timeout: 30_000 })
  72. await owner.close()
  73. }
  74. /** The API routes are written to, so the request carries the app's own origin. */
  75. const sameOrigin = { origin: process.env.E2E_BASE_URL ?? 'http://127.0.0.1:3100' }
  76. test.afterAll(async () => {
  77. // The last test makes the manager an admin; the workshop is left with one
  78. // more ordinary member, not one more admin.
  79. if (organizationId && roleId) {
  80. await setMembership(MANAGER, organizationId, { roleId, role: 'member' })
  81. }
  82. })
  83. test.describe('a member with every permission and no admin standing', () => {
  84. test('is invited by the owner, signs up, and is given the role', async ({ page, browser }) => {
  85. await ownerInvites(browser, MANAGER)
  86. const invitation = await waitForMail(MANAGER)
  87. await page.goto(linkIn(invitation, /\/auth\/sign-up\?invite=/))
  88. await page.locator('#name').fill('E2E Settings Manager')
  89. await page.locator('#email').fill(MANAGER)
  90. await page.locator('#password').fill(PASSWORD)
  91. await page.locator('#terms').click()
  92. await page.getByRole('button', { name: /create account/i }).click()
  93. await page.waitForURL((url) => !/^\/(auth|onboarding)/.test(url.pathname), { timeout: 30_000 })
  94. organizationId = await ownerOrganizationId()
  95. roleId = await createRoleWithEveryPermission(organizationId, `E2E Everything ${stamp}`)
  96. await setMembership(MANAGER, organizationId, { roleId, role: 'member' })
  97. // The role opens the whole application to them, which is what makes the
  98. // refusals below worth anything: they are not a roleless member being
  99. // turned away at the door.
  100. await signIn(page, MANAGER, PASSWORD)
  101. await page.goto('/settings/team')
  102. await expect(page.getByRole('heading', { name: 'No access yet' })).toHaveCount(0)
  103. await expect(page.getByText(MANAGER).first()).toBeVisible()
  104. })
  105. test('cannot export the workshop, or replace it from a backup', async ({ page }) => {
  106. await signIn(page, MANAGER, PASSWORD)
  107. for (const route of [
  108. 'backup/export',
  109. 'backup/import',
  110. 'backup/import-lubelog',
  111. 'backup/import-invoice-ninja',
  112. ]) {
  113. // Refused before the body is looked at: an import that got as far as
  114. // parsing would already be past the check that matters.
  115. const response = await page.request.post(`/api/protected/${route}`, {
  116. data: { version: 2, data: {} },
  117. headers: sameOrigin,
  118. })
  119. expect(response.status(), `${route} is refused`).toBe(403)
  120. expect(await response.json()).toEqual({ error: 'Forbidden' })
  121. }
  122. })
  123. test('cannot wipe the workshop’s records from the data page', async ({ page }) => {
  124. await signIn(page, MANAGER, PASSWORD)
  125. const before = await contentCounts(organizationId)
  126. // The page offers the button to anyone who can open it; the action is
  127. // what has to say no.
  128. await page.goto('/settings/data')
  129. await settle(page)
  130. const dialog = page.getByRole('dialog', { name: 'Delete Content' })
  131. await expect(async () => {
  132. await page.getByRole('button', { name: 'Delete Content', exact: true }).first().click()
  133. await expect(dialog).toBeVisible({ timeout: 2_000 })
  134. }).toPass({ timeout: 30_000 })
  135. await dialog.getByRole('checkbox', { disabled: false }).first().click()
  136. await dialog.getByPlaceholder('delete my data').fill('delete my data')
  137. // The confirm button counts what it would delete: "Delete 1 selected".
  138. await dialog.getByRole('button', { name: /^Delete \d+ selected$/ }).click()
  139. await expect(page.getByText('Only an owner or admin can delete workshop content')).toBeVisible({
  140. timeout: 30_000,
  141. })
  142. expect(await contentCounts(organizationId), 'nothing was deleted').toEqual(before)
  143. })
  144. test('cannot change the plan or reach the card', async ({ page }) => {
  145. await signIn(page, MANAGER, PASSWORD)
  146. for (const route of ['upgrade', 'checkout', 'upgrade-preview', 'billing-portal']) {
  147. const response = await page.request.post(`/api/protected/subscription/${route}`, {
  148. data: { plan: 'enterprise' },
  149. headers: sameOrigin,
  150. })
  151. expect(response.status(), `${route} is refused`).toBe(403)
  152. expect(await response.json()).toEqual({ error: 'Forbidden' })
  153. }
  154. })
  155. test('is not offered a way to bring people in', async ({ page }) => {
  156. // Inviting is an admin's call, and the rule sits in the action
  157. // (`canInvite`, with its own unit tests). The page agrees with it: the
  158. // button is not there for a member, however wide their role.
  159. await signIn(page, MANAGER, PASSWORD)
  160. await page.goto('/settings/team')
  161. await settle(page)
  162. await expect(page.getByText(MANAGER).first()).toBeVisible()
  163. await expect(page.getByRole('button', { name: 'Add', exact: true })).toHaveCount(0)
  164. })
  165. })
  166. test.describe('an invitation', () => {
  167. test('keeps its token in the invitee’s inbox and off the team page', async ({ browser }) => {
  168. // The token is the credential that lets whoever holds it join as the
  169. // invitee. It used to be returned to everyone who could read the team
  170. // page, which let a member read the token for the invited boss's address
  171. // and sign up with it.
  172. await ownerInvites(browser, SECRET_INVITEE)
  173. const token = await invitationTokenFor(SECRET_INVITEE, organizationId)
  174. expect(token, 'the invitation exists').toBeTruthy()
  175. const mail = await waitForMail(SECRET_INVITEE)
  176. expect(`${mail.html}\n${mail.text}`, 'the invitee is sent the token').toContain(token)
  177. const owner = await browser.newContext({ storageState: 'e2e/.auth/owner.json' })
  178. const html = await (await owner.request.get('/settings/team')).text()
  179. await owner.close()
  180. expect(html, 'the team page lists the invitation').toContain(SECRET_INVITEE)
  181. expect(html, 'without its token').not.toContain(token as string)
  182. })
  183. test('as admin can only come from the owner', async ({ page }) => {
  184. // The manager is made an admin: they may bring people in now, and may
  185. // still not hand out admin, which is how a settings manager once walked
  186. // in as one.
  187. await setMembership(MANAGER, organizationId, { roleId, role: 'admin' })
  188. await signIn(page, MANAGER, PASSWORD)
  189. await invite(page, ADMIN_INVITEE, 'Admin')
  190. await expect(page.getByText('Only the owner can invite admins')).toBeVisible({
  191. timeout: 30_000,
  192. })
  193. expect(await invitationTokenFor(ADMIN_INVITEE, organizationId), 'nothing was sent').toBeNull()
  194. await invite(page, MEMBER_INVITEE)
  195. await expect(page.getByText(MEMBER_INVITEE).first()).toBeVisible({ timeout: 30_000 })
  196. expect(await invitationTokenFor(MEMBER_INVITEE, organizationId)).toBeTruthy()
  197. })
  198. })