admin-only.spec.ts 9.6 KB

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