role-standing.spec.ts 8.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210
  1. import { expect, type Browser, type Page, test } from '@playwright/test'
  2. import {
  3. createAdminRole,
  4. createRoleWithEveryPermission,
  5. deleteRoles,
  6. membershipOf,
  7. ownerOrganizationId,
  8. setMembership,
  9. } from '../../support/db'
  10. import { settle } from '../../support/hydration'
  11. import { linkIn, waitForMail } from '../../support/mail'
  12. /**
  13. * Who may make somebody an admin.
  14. *
  15. * Inviting as admin is the owner's alone, and so is changing a member's
  16. * built-in role on the team page. The role picker's own action asked only
  17. * for admin standing, which a custom role can carry, so a person with such a
  18. * role could make themself, or anyone, a built-in admin, and from there edit
  19. * roles and remove members. The team page never offered them the picker; the
  20. * action behind it is what a browser can call, and what this file calls.
  21. *
  22. * The action's id is not written down anywhere a test could read it, so it is
  23. * taken from the request the owner's own click makes, and replayed with the
  24. * arguments of the caller's choosing, as a person in the browser could.
  25. */
  26. test.describe.configure({ mode: 'serial' })
  27. // Everybody starts as a stranger with no session.
  28. test.use({ storageState: { cookies: [], origins: [] } })
  29. const stamp = Date.now()
  30. const COLLEAGUE = `e2e-standing-${stamp}@example.com`
  31. const PEER = `e2e-peer-${stamp}@example.com`
  32. const PASSWORD = `E2e-pass-${stamp}`
  33. const baseURL = process.env.E2E_BASE_URL ?? 'http://127.0.0.1:3100'
  34. let organizationId = ''
  35. let adminRoleId = ''
  36. let plainRoleId = ''
  37. /** The `Next-Action` id of assignRole, learnt from the owner's click. */
  38. let assignRoleAction = ''
  39. async function signIn(page: Page, email: string) {
  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. /** The owner invites `email`, who signs up from the mail and lands in the workshop. */
  47. async function joinTeam(browser: Browser, email: string, name: string) {
  48. const owner = await browser.newContext({ storageState: 'e2e/.auth/owner.json' })
  49. const page = await owner.newPage()
  50. await page.goto('/settings/team')
  51. await settle(page)
  52. await expect(async () => {
  53. await page.getByRole('button', { name: 'Add', exact: true }).first().click()
  54. await expect(page.getByText('Someone in the office')).toBeVisible({ timeout: 2_000 })
  55. }).toPass({ timeout: 30_000 })
  56. await page.getByText('Someone in the office').click()
  57. await page.locator('#member-email').fill(email)
  58. // The form sends nothing until a role is chosen; these two start with none
  59. // and are given theirs through the database below.
  60. const dialog = page.getByRole('dialog').filter({ has: page.locator('#member-email') })
  61. await dialog.getByRole('combobox').click()
  62. await page.getByRole('option', { name: 'No role', exact: true }).click()
  63. await page.getByRole('button', { name: 'Invite', exact: true }).click()
  64. await expect(page.getByText(email).first()).toBeVisible({ timeout: 30_000 })
  65. await owner.close()
  66. const invitation = await waitForMail(email)
  67. const person = await browser.newContext({ storageState: { cookies: [], origins: [] } })
  68. const signup = await person.newPage()
  69. await signup.goto(linkIn(invitation, /\/auth\/sign-up\?invite=/))
  70. await signup.locator('#name').fill(name)
  71. await signup.locator('#email').fill(email)
  72. await signup.locator('#password').fill(PASSWORD)
  73. await signup.locator('#terms').click()
  74. await signup.getByRole('button', { name: /create account/i }).click()
  75. await signup.waitForURL((url) => !/^\/(auth|onboarding)/.test(url.pathname), {
  76. timeout: 30_000,
  77. })
  78. await person.close()
  79. }
  80. /**
  81. * Calls assignRole the way the page does: a POST to the page it lives on,
  82. * with the action id in the `Next-Action` header and the arguments as the
  83. * body. What the page would do with the answer does not matter here; the
  84. * membership is read back from the database.
  85. */
  86. async function callAssignRole(
  87. page: Page,
  88. args: { memberId: string; role: 'admin' | 'member'; roleId: string | null }
  89. ) {
  90. return page.request.post(`${baseURL}/settings/team`, {
  91. headers: {
  92. 'next-action': assignRoleAction,
  93. 'content-type': 'text/plain;charset=UTF-8',
  94. accept: 'text/x-component',
  95. origin: baseURL,
  96. },
  97. data: JSON.stringify([args]),
  98. })
  99. }
  100. test.beforeAll(async ({ browser }) => {
  101. organizationId = await ownerOrganizationId()
  102. await joinTeam(browser, COLLEAGUE, 'E2E Standing Colleague')
  103. await joinTeam(browser, PEER, 'E2E Peer')
  104. adminRoleId = await createAdminRole(organizationId, `E2E Admin Switch ${stamp}`)
  105. plainRoleId = await createRoleWithEveryPermission(organizationId, `E2E Plain ${stamp}`)
  106. })
  107. test.afterAll(async () => {
  108. // Two more ordinary members without a role, and the roles gone.
  109. if (organizationId) {
  110. for (const email of [COLLEAGUE, PEER]) {
  111. await setMembership(email, organizationId, { roleId: null, role: 'member' })
  112. }
  113. }
  114. await deleteRoles([adminRoleId, plainRoleId].filter(Boolean))
  115. })
  116. test.describe('the role picker’s action', () => {
  117. test('is learnt from the owner giving the peer a role', async ({ browser }) => {
  118. const owner = await browser.newContext({ storageState: 'e2e/.auth/owner.json' })
  119. const page = await owner.newPage()
  120. await page.goto('/settings/team')
  121. await settle(page)
  122. // Members are bordered cards, one per person, each with its own picker.
  123. // By test id: the page's own cards are bordered and rounded as well, and
  124. // one of them holds every member.
  125. const row = page.getByTestId('team-member').filter({ hasText: PEER })
  126. const action = page.waitForRequest((request) => Boolean(request.headers()['next-action']))
  127. await row.getByRole('combobox').click()
  128. await page.getByRole('option', { name: `E2E Plain ${stamp}`, exact: true }).click()
  129. assignRoleAction = (await action).headers()['next-action']
  130. expect(assignRoleAction).toBeTruthy()
  131. await expect
  132. .poll(async () => (await membershipOf(PEER, organizationId)).roleId)
  133. .toBe(plainRoleId)
  134. await owner.close()
  135. })
  136. test('does not let a custom-role admin make themself or a peer a built-in admin', async ({
  137. page,
  138. }) => {
  139. await setMembership(COLLEAGUE, organizationId, { roleId: adminRoleId, role: 'member' })
  140. await signIn(page, COLLEAGUE)
  141. const self = await membershipOf(COLLEAGUE, organizationId)
  142. const peer = await membershipOf(PEER, organizationId)
  143. await callAssignRole(page, { memberId: self.id, role: 'admin', roleId: null })
  144. await callAssignRole(page, { memberId: peer.id, role: 'admin', roleId: null })
  145. expect((await membershipOf(COLLEAGUE, organizationId)).role).toBe('member')
  146. expect(await membershipOf(PEER, organizationId)).toEqual(peer)
  147. // The action itself still works for them: a role that grants nothing on
  148. // its own is theirs to hand out.
  149. await callAssignRole(page, { memberId: peer.id, role: 'member', roleId: null })
  150. expect((await membershipOf(PEER, organizationId)).roleId).toBeNull()
  151. await callAssignRole(page, { memberId: peer.id, role: 'member', roleId: plainRoleId })
  152. expect((await membershipOf(PEER, organizationId)).roleId).toBe(plainRoleId)
  153. })
  154. test('does not let a built-in admin make a peer a built-in admin, or unmake one', async ({
  155. page,
  156. }) => {
  157. await setMembership(COLLEAGUE, organizationId, { roleId: null, role: 'admin' })
  158. await signIn(page, COLLEAGUE)
  159. const peer = await membershipOf(PEER, organizationId)
  160. await callAssignRole(page, { memberId: peer.id, role: 'admin', roleId: null })
  161. expect((await membershipOf(PEER, organizationId)).role).toBe('member')
  162. // Made an admin by the owner, the peer is out of the colleague's reach.
  163. await setMembership(PEER, organizationId, { roleId: null, role: 'admin' })
  164. await callAssignRole(page, { memberId: peer.id, role: 'member', roleId: plainRoleId })
  165. expect(await membershipOf(PEER, organizationId)).toEqual({
  166. id: peer.id,
  167. role: 'admin',
  168. roleId: null,
  169. })
  170. })
  171. test('lets the owner do both', async ({ browser }) => {
  172. const owner = await browser.newContext({ storageState: 'e2e/.auth/owner.json' })
  173. const page = await owner.newPage()
  174. await page.goto('/settings/team')
  175. const peer = await membershipOf(PEER, organizationId)
  176. await callAssignRole(page, { memberId: peer.id, role: 'member', roleId: plainRoleId })
  177. expect(await membershipOf(PEER, organizationId)).toEqual({
  178. id: peer.id,
  179. role: 'member',
  180. roleId: plainRoleId,
  181. })
  182. await callAssignRole(page, { memberId: peer.id, role: 'admin', roleId: null })
  183. expect((await membershipOf(PEER, organizationId)).role).toBe('admin')
  184. await owner.close()
  185. })
  186. })