Files
RenewCMS/web/visual/rolesStates.ts
T

1058 lines
27 KiB
TypeScript

import type { Page } from 'playwright'
import { accessFor, customAccess } from '../src/test/access'
import type { ApiMock, VisualState } from './states'
type Kind = 'b' | 's' | 'o'
interface BoardItem {
key: string
kind: Kind
}
const boardGroups: { id: string; items: BoardItem[] }[] = [
{
id: 'schema',
items: [
{ key: 'schema.view', kind: 'b' },
{ key: 'schema.edit', kind: 'b' },
{ key: 'schema.delete', kind: 'b' },
],
},
{
id: 'media',
items: [
{ key: 'media.view', kind: 'b' },
{ key: 'media.upload', kind: 'b' },
{ key: 'media.delete', kind: 's' },
],
},
{
id: 'authors',
items: [
{ key: 'authors.view', kind: 'b' },
{ key: 'authors.create', kind: 'b' },
{ key: 'authors.edit', kind: 's' },
{ key: 'authors.delete', kind: 'b' },
],
},
{
id: 'activity',
items: [
{ key: 'activity.view', kind: 's' },
{ key: 'activity.export', kind: 'b' },
],
},
{
id: 'users',
items: [
{ key: 'users.view', kind: 'b' },
{ key: 'users.invite', kind: 'b' },
{ key: 'users.change_role', kind: 'b' },
{ key: 'users.remove', kind: 'b' },
],
},
{
id: 'settings',
items: [
{ key: 'settings.view', kind: 'b' },
{ key: 'settings.edit_general', kind: 'b' },
{ key: 'settings.manage_sites', kind: 'b' },
{ key: 'apikeys.view', kind: 's' },
{ key: 'apikeys.create', kind: 'b' },
{ key: 'apikeys.revoke', kind: 'b' },
{ key: 'settings.delete_site', kind: 'o' },
],
},
{
id: 'roles',
items: [
{ key: 'roles.view', kind: 'b' },
{ key: 'roles.manage', kind: 'b' },
{ key: 'roles.edit_owner_only', kind: 'o' },
],
},
{
id: 'system',
items: [
{ key: 'system.apply_updates', kind: 'o' },
{ key: 'system.edit_retention', kind: 'o' },
{ key: 'system.transfer_ownership', kind: 'o' },
],
},
]
const contentActions = ['read', 'create', 'update', 'delete', 'publish']
const permissionCatalogue = [
{
key: 'content',
items: contentActions.map((action) => ({
key: `content.${action}`,
scope_kind: action === 'create' ? 'toggle' : 'scoped',
owner_only: false,
per_type: true,
})),
},
...boardGroups.map((group) => ({
key: group.id,
items: group.items.map((item) => ({
key: item.key,
scope_kind: item.kind === 's' ? 'scoped' : 'toggle',
owner_only: item.kind === 'o',
per_type: false,
})),
})),
]
const scopeOfChar: Record<string, 'none' | 'own' | 'all'> = {
n: 'none',
o: 'own',
a: 'all',
'0': 'none',
'1': 'all',
}
type Grant = { key: string; scope: 'none' | 'own' | 'all' }
function contentGrants(chars: string, typeKey?: string): Grant[] {
return contentActions.flatMap((action, index) => {
const scope = scopeOfChar[chars[index]!]!
const key = typeKey === undefined ? `content.${action}` : `content.${action}:${typeKey}`
return typeKey !== undefined || scope !== 'none' ? [{ key, scope }] : []
})
}
function generalGrants(general: Record<string, string>): Grant[] {
return boardGroups.flatMap((group) =>
group.items.flatMap((item, index) => {
const scope = scopeOfChar[general[group.id]![index]!]!
return scope === 'none' ? [] : [{ key: item.key, scope }]
}),
)
}
interface BoardRole {
id: number
name: string
description: string
built_in: boolean
locked: boolean
members: number
general: Record<string, string>
content: string
typed?: Record<string, string>
}
const ownerGeneral = {
schema: '111',
media: '11a',
authors: '11a1',
activity: 'a1',
users: '1111',
settings: '111a111',
roles: '111',
system: '111',
}
const adminGeneral = { ...ownerGeneral, settings: '111a110', roles: '110', system: '000' }
const boardRoles: Record<string, BoardRole> = {
owner: {
id: 1,
name: 'Owner',
description: 'Everything on the site. One per site.',
built_in: true,
locked: true,
members: 1,
general: ownerGeneral,
content: 'a1aaa',
},
admin: {
id: 2,
name: 'Admin',
description: 'Everything except owner-only settings.',
built_in: true,
locked: false,
members: 2,
general: adminGeneral,
content: 'a1aaa',
},
writer: {
id: 3,
name: 'Writer',
description: "Reads everything and writes content. Can't delete or publish.",
built_in: true,
locked: false,
members: 5,
general: {
schema: '100',
media: '11n',
authors: '10o0',
activity: 'o0',
users: '0000',
settings: '000n000',
roles: '000',
system: '000',
},
content: 'a1ann',
},
contributor: {
id: 4,
name: 'Contributor',
description: 'Creates content and edits only their own.',
built_in: false,
locked: false,
members: 4,
general: {
schema: '100',
media: '11n',
authors: '10o0',
activity: 'n0',
users: '0000',
settings: '000n000',
roles: '000',
system: '000',
},
content: 'a1onn',
typed: { 'case-studies': 'a0nnn' },
},
lead: {
id: 5,
name: 'Content lead',
description: 'Edits and publishes all content.',
built_in: false,
locked: false,
members: 1,
general: {
schema: '100',
media: '11a',
authors: '10a0',
activity: 'a1',
users: '1000',
settings: '100n000',
roles: '100',
system: '000',
},
content: 'a1aaa',
},
junior: {
id: 6,
name: 'Junior writer',
description: 'Writes articles and reads online ones and their own drafts.',
built_in: false,
locked: false,
members: 2,
general: {
schema: '100',
media: '11n',
authors: '10o0',
activity: 'n0',
users: '0000',
settings: '000n000',
roles: '000',
system: '000',
},
content: 'o1onn',
},
intern: {
id: 7,
name: 'Intern',
description: 'Reads content.',
built_in: false,
locked: false,
members: 0,
general: {
schema: '000',
media: '00n',
authors: '00n0',
activity: 'n0',
users: '0000',
settings: '000n000',
roles: '000',
system: '000',
},
content: 'a0nnn',
},
}
function roleItem(
key: string,
overrides: Record<string, unknown> = {},
version = 2,
): Record<string, unknown> {
const role = boardRoles[key]!
return {
id: role.id,
name: role.name,
description: role.description,
built_in: role.built_in,
locked: role.locked,
version,
members: role.members,
grants: [
...contentGrants(role.content),
...Object.entries(role.typed ?? {}).flatMap(([typeKey, chars]) =>
contentGrants(chars, typeKey),
),
...generalGrants(role.general),
],
updated_at: '2026-10-14T10:52:00Z',
updated_by: { id: 1, name: 'Marta Alves' },
...overrides,
}
}
const JONAS = { id: 2, name: 'Jonas Weber' }
const STALE_SAVED_AT = '2026-10-27T11:02:00Z'
function roleRef(key: string) {
const role = boardRoles[key]!
return { id: role.id, name: role.name, built_in: role.built_in, locked: role.locked }
}
function session(
displayName: string,
access: ReturnType<typeof accessFor>,
id = 1,
email = 'marta@renewbuild.example',
): ApiMock {
return {
status: 200,
body: {
id,
is_verified: true,
username: displayName.split(' ')[0]!.toLowerCase(),
email,
display_name: displayName,
access,
},
}
}
const martaSession = session('Marta Alves', accessFor('owner'))
const jonasSession = session('Jonas Weber', accessFor('admin'), 2, 'jonas@renewbuild.example')
const contentTypeList: ApiMock = {
status: 200,
body: [
{
id: 1,
key: 'events',
name: 'Events',
icon: 'files',
fields: [],
created_at: '2026-09-02T09:00:00Z',
updated_at: '2026-09-02T09:00:00Z',
},
{
id: 2,
key: 'case-studies',
name: 'Case studies',
icon: 'files',
fields: [],
created_at: '2026-09-03T09:00:00Z',
updated_at: '2026-09-03T09:00:00Z',
},
],
}
const rolesList = (...keys: string[]): ApiMock => ({
status: 200,
body: keys.map((key) => roleItem(key)),
})
function boardUser(id: number, name: string, role: string, createdAt: string) {
const [first] = name.split(' ')
return {
kind: 'user',
id,
username: first!.toLowerCase(),
display_name: name,
email: `${first!.toLowerCase()}@renewbuild.example`,
role: roleRef(role),
role_assigned_by: null,
status: 'accepted',
created_at: createdAt,
}
}
function boardUsers(overrides: Record<number, string> = {}) {
const at = (id: number, name: string, role: string, day: string) =>
boardUser(id, name, overrides[id] ?? role, `2026-09-${day}T09:00:00Z`)
return [
at(1, 'Marta Alves', 'owner', '05'),
at(2, 'Jonas Weber', 'admin', '04'),
at(3, 'Ines Duarte', 'writer', '03'),
at(4, 'Pedro Lima', 'contributor', '02'),
{
kind: 'invite',
id: 9,
email: 'sofia@renewbuild.example',
role: roleRef('lead'),
status: 'pending',
created_at: '2026-09-01T09:00:00Z',
expires_at: '2026-11-01T09:00:00Z',
},
]
}
const SETTLE_MS = 250
async function openRowMenu(page: Page, name: string) {
await page.locator(`button[aria-label="Actions for ${name}"]:visible`).click()
await page.getByRole('menuitem').first().waitFor()
}
async function chooseMenuItem(page: Page, name: string, label: string) {
await openRowMenu(page, name)
await page.getByRole('menuitem', { name: label, exact: true }).click()
}
async function chooseRole(page: Page, role: string) {
await page.locator('[role="alertdialog"] label', { hasText: role }).first().click()
}
async function pressInDialog(page: Page, label: string) {
await page
.locator('[role="alertdialog"]')
.getByRole('button', { name: label, exact: true })
.click()
}
async function waitHeading(page: Page, name: string) {
await page.getByRole('heading', { name, exact: true }).first().waitFor()
}
async function settle(page: Page) {
await page.mouse.move(0, 0)
await page.waitForTimeout(SETTLE_MS)
}
function editorApi(extra: Record<string, ApiMock> = {}): Record<string, ApiMock> {
return {
'GET /v1/auth/me': martaSession,
'GET /v1/permission/': { status: 200, body: { groups: permissionCatalogue } },
'GET /v1/content-type': contentTypeList,
...extra,
}
}
async function chooseScope(page: Page, group: string, scope: 'None' | 'Own' | 'All') {
await page
.locator(`[role="radiogroup"][aria-label="${group}"]:visible`)
.getByRole('radio', { name: scope, exact: true })
.evaluate((element) => (element as HTMLElement).click())
}
async function staleEditorSteps(page: Page) {
await waitHeading(page, 'Contributor')
await chooseScope(page, 'Events: Update', 'All')
await chooseScope(page, 'Events: Delete', 'Own')
await page.getByRole('button', { name: 'Save changes', exact: true }).click()
await page.getByText('This role changed while you were editing').waitFor()
}
function staleEditorApi(): Record<string, ApiMock> {
return editorApi({
'GET /v1/role/4': {
status: 200,
bodyFor: (_url, call) =>
call === 1
? roleItem('contributor')
: roleItem('contributor', { updated_at: STALE_SAVED_AT, updated_by: JONAS }, 3),
},
'PUT /v1/role/4': { status: 409, body: 'role_conflict' },
})
}
const staleEditor: VisualState = {
path: '/settings/roles/4',
timezone: 'UTC',
api: staleEditorApi(),
steps: async (page) => {
await staleEditorSteps(page)
await settle(page)
},
}
const staleReview: VisualState = {
path: '/settings/roles/4',
timezone: 'UTC',
api: staleEditorApi(),
steps: async (page) => {
await staleEditorSteps(page)
await page.getByRole('button', { name: 'Review differences', exact: true }).click()
await page.getByRole('alertdialog').waitFor()
await settle(page)
},
}
const staleKept: VisualState = {
path: '/settings/roles/4',
timezone: 'UTC',
api: staleEditorApi(),
steps: async (page) => {
await staleEditorSteps(page)
await page.getByRole('button', { name: 'Review differences', exact: true }).click()
await pressInDialog(page, 'Keep my edits')
await page.getByText('Role reloaded').waitFor()
await settle(page)
},
}
const saveFailed: VisualState = {
path: '/settings/roles/4',
api: editorApi({
'GET /v1/role/4': { status: 200, body: roleItem('contributor') },
'PUT /v1/role/4': { status: 500 },
}),
steps: async (page) => {
await waitHeading(page, 'Contributor')
await chooseScope(page, 'Events: Update', 'All')
await chooseScope(page, 'Events: Delete', 'Own')
await page.getByRole('button', { name: 'Save changes', exact: true }).click()
await page.getByText("Couldn't save the role").waitFor()
await settle(page)
},
}
const readOwnEditor: VisualState = {
path: '/settings/roles/6',
api: editorApi({ 'GET /v1/role/6': { status: 200, body: roleItem('junior') } }),
steps: async (page) => {
await waitHeading(page, 'Junior writer')
await page.getByText('Read: Own', { exact: true }).waitFor()
await settle(page)
},
}
function usersApi(
session: ApiMock,
extra: Record<string, ApiMock> = {},
users = boardUsers(),
): Record<string, ApiMock> {
return {
'GET /v1/auth/me': session,
'GET /v1/user/': { status: 200, body: users },
'GET /v1/role/': rolesList('owner', 'admin', 'writer', 'contributor', 'lead'),
'GET /v1/content-type': contentTypeList,
...extra,
}
}
async function openChangeRole(page: Page) {
await waitHeading(page, 'Users')
await chooseMenuItem(page, 'Ines Duarte', 'Change role')
await page.locator('[role="alertdialog"] label', { hasText: 'Content lead' }).waitFor()
}
const changeStale: VisualState = {
path: '/users',
api: usersApi(martaSession, {
'GET /v1/user/': {
status: 200,
bodyFor: (_url, call) => boardUsers(call === 1 ? {} : { 3: 'admin' }),
},
'PUT /v1/user/3/role': { status: 409, body: 'user_role_changed' },
}),
steps: async (page) => {
await openChangeRole(page)
await chooseRole(page, 'Content lead')
await pressInDialog(page, 'Change role')
await page.getByText('Role changed in the meantime').waitFor()
await settle(page)
},
}
const changeFailed: VisualState = {
path: '/users',
api: usersApi(martaSession, { 'PUT /v1/user/3/role': { status: 500 } }),
steps: async (page) => {
await openChangeRole(page)
await chooseRole(page, 'Content lead')
await pressInDialog(page, 'Change role')
await page.getByText("Couldn't change the role").waitFor()
await settle(page)
},
}
async function reachTransferConfirm(page: Page) {
await waitHeading(page, 'Users')
await chooseMenuItem(page, 'Marta Alves', 'Transfer ownership')
await page.locator('[role="alertdialog"] label', { hasText: 'Jonas Weber' }).first().click()
await pressInDialog(page, 'Continue')
await page.locator('[role="alertdialog"] input').fill('jonas@renewbuild.example')
}
const transferStale: VisualState = {
path: '/users',
api: usersApi(martaSession, {
'GET /v1/user/': {
status: 200,
bodyFor: (_url, call) => boardUsers(call === 1 ? {} : { 2: 'writer' }),
},
'POST /v1/site/transfer-ownership': { status: 409, body: 'user_role_changed' },
}),
steps: async (page) => {
await reachTransferConfirm(page)
await pressInDialog(page, 'Transfer ownership')
await page.getByText('Role changed in the meantime').waitFor()
await settle(page)
},
}
async function openInvite(page: Page) {
await waitHeading(page, 'Users')
await page.getByRole('button', { name: 'Invite user', exact: true }).first().click()
await page.getByRole('dialog').waitFor()
}
async function fillInvite(page: Page, email: string) {
await page.getByPlaceholder('name@example.com').fill(email)
}
async function sendInvite(page: Page) {
await page.getByRole('button', { name: 'Send invite', exact: true }).click()
}
const inviteInvalid: VisualState = {
path: '/users',
api: usersApi(martaSession),
steps: async (page) => {
await openInvite(page)
await fillInvite(page, 'sofia@renewbuild')
await sendInvite(page)
await page.getByText('Enter a valid email address.').waitFor()
await settle(page)
},
}
const inviteDuplicate: VisualState = {
path: '/users',
api: usersApi(martaSession, {
'POST /v1/user/invite': { status: 409, body: 'email_has_account' },
}),
steps: async (page) => {
await openInvite(page)
await fillInvite(page, 'ines@renewbuild.example')
await sendInvite(page)
await page.getByText('This address already has an account.').waitFor()
await settle(page)
},
}
const inviteEscalation: VisualState = {
path: '/users',
api: usersApi(jonasSession, {
'POST /v1/user/invite': { status: 403, body: 'escalation' },
}),
steps: async (page) => {
await openInvite(page)
await fillInvite(page, 'sofia@renewbuild.example')
await sendInvite(page)
await page.getByText("Your role can't assign a role with more power than yours.").waitFor()
await settle(page)
},
}
const inviteFailed: VisualState = {
path: '/users',
api: usersApi(martaSession, { 'POST /v1/user/invite': { status: 500 } }),
steps: async (page) => {
await openInvite(page)
await fillInvite(page, 'sofia@renewbuild.example')
await sendInvite(page)
await page.getByText("Couldn't send the invite").waitFor()
await settle(page)
},
}
function deleteApi(extra: Record<string, ApiMock> = {}, roles?: ApiMock): Record<string, ApiMock> {
return {
'GET /v1/auth/me': martaSession,
'GET /v1/role/':
roles ?? rolesList('owner', 'admin', 'writer', 'contributor', 'lead', 'intern'),
'GET /v1/content-type': contentTypeList,
...extra,
}
}
async function openDelete(page: Page, name: string) {
await waitHeading(page, 'Roles')
await chooseMenuItem(page, name, 'Delete')
await page.getByRole('alertdialog').waitFor()
}
async function pickMoveTarget(page: Page, target: string) {
await page.locator('[role="alertdialog"] [aria-haspopup="listbox"]').click()
await page.getByRole('option', { name: new RegExp(`^${target}`) }).click()
}
const deleteTargetGone: VisualState = {
path: '/settings/roles',
api: deleteApi({
'POST /v1/role/4/reassign': { status: 409, body: 'target_role_not_found' },
}),
steps: async (page) => {
await openDelete(page, 'Contributor')
await pickMoveTarget(page, 'Writer')
await pressInDialog(page, 'Move 4 members and delete role')
await page.getByText('Writer no longer exists').waitFor()
await settle(page)
},
}
const deleteGainedMembers: VisualState = {
path: '/settings/roles',
api: deleteApi(
{ 'DELETE /v1/role/7': { status: 409, body: 'role_has_members' } },
{
status: 200,
bodyFor: (_url, call) =>
['owner', 'admin', 'writer', 'contributor', 'lead', 'intern'].map((key) =>
roleItem(key, call > 1 && key === 'intern' ? { members: 1 } : {}),
),
},
),
steps: async (page) => {
await openDelete(page, 'Intern')
await pressInDialog(page, 'Delete role')
await page.getByText('Role still has members').waitFor()
await settle(page)
},
}
const deleteMoveFailed: VisualState = {
path: '/settings/roles',
api: deleteApi({ 'POST /v1/role/4/reassign': { status: 500 } }),
steps: async (page) => {
await openDelete(page, 'Contributor')
await pickMoveTarget(page, 'Writer')
await pressInDialog(page, 'Move 4 members and delete role')
await page.getByText("Couldn't move the members").waitFor()
await settle(page)
},
}
const pedroSession = session(
'Pedro Lima',
customAccess(
'content.read=own, content.create, content.update=own, schema.view, media.view, media.upload, authors.view, authors.edit=own',
'Junior writer',
6,
),
4,
'pedro@renewbuild.example',
)
function articleRow(id: number, title: string, online: boolean, createdBy: number) {
return {
id,
title,
body: '',
is_online: online,
created_by: createdBy,
authors: [],
created_at: '2026-10-01T09:00:00Z',
updated_at: '2026-10-14T09:00:00Z',
}
}
const ownArticles = [
articleRow(1, 'How we price a bathroom renovation', true, 3),
articleRow(2, 'Retrofit grants in 2027', true, 2),
articleRow(3, 'Winter checklist for timber windows', true, 1),
articleRow(4, 'Choosing lime plaster', false, 4),
articleRow(5, 'Site safety checklist', false, 4),
]
function ownArticleList(all: ReturnType<typeof articleRow>[]): ApiMock {
return {
status: 200,
bodyFor: (url) => {
const status = url.searchParams.get('status') ?? ''
const items = all.filter((row) => !status || row.is_online === (status === 'online'))
const online = all.filter((row) => row.is_online).length
return {
items,
total: items.length,
page: 1,
size: 25,
counts: { all: all.length, online, draft: all.length - online, suggestions: 0 },
}
},
}
}
const articlesReadOwn: VisualState = {
path: '/article',
skip: 'clock-sensitive',
}
const articlesNoDrafts: VisualState = {
path: '/article?status=draft',
api: {
'GET /v1/auth/me': pedroSession,
'GET /v1/article/': ownArticleList(ownArticles.slice(0, 3)),
'GET /v1/site/': { status: 200, body: [] },
'GET /v1/content-type': contentTypeList,
},
steps: async (page) => {
await page.getByText('You have no drafts').waitFor()
await settle(page)
},
}
const eventRows = [
['Lime plaster workshop', '2026-11-21', '2026-10-14T09:00:00Z'],
['Tool library evening', '2026-12-05', '2026-10-12T09:00:00Z'],
['Site safety talk', '2026-12-18', '2026-10-09T09:00:00Z'],
] as const
const eventsType = {
id: 1,
key: 'events',
name: 'Events',
icon: 'files',
fields: [
{
key: 'title',
label: 'Title',
type: 'text',
required: true,
unique: false,
default: null,
min: null,
max: null,
options: [],
},
{
key: 'starts_on',
label: 'Starts on',
type: 'date',
required: true,
unique: false,
default: null,
min: null,
max: null,
options: [],
},
],
created_at: '2026-09-02T09:00:00Z',
updated_at: '2026-09-02T09:00:00Z',
}
function eventEntryList(rows: readonly (readonly [string, string, string])[]): ApiMock {
return {
status: 200,
body: {
items: rows.map(([title, startsOn, updatedAt], index) => ({
id: index + 1,
content_type: 'events',
data: { title, starts_on: startsOn },
created_by: 4,
updated_by: 4,
created_at: '2026-10-01T09:00:00Z',
updated_at: updatedAt,
relations: {},
})),
total: rows.length,
page: 1,
size: 25,
},
}
}
function entryListApi(rows: readonly (readonly [string, string, string])[]) {
return {
'GET /v1/auth/me': pedroSession,
'GET /v1/content-type': { status: 200, body: [eventsType] },
'GET /v1/content-type/events': { status: 200, body: eventsType },
'GET /v1/content-type/events/entry': eventEntryList(rows),
'GET /v1/user/lookup': { status: 200, body: [] },
}
}
const entriesReadOwn: VisualState = {
path: '/content/events',
api: entryListApi(eventRows),
steps: async (page) => {
await page.getByText("Entries by others aren't shown").waitFor()
await settle(page)
},
}
const entriesNone: VisualState = {
path: '/content/events',
api: entryListApi([]),
steps: async (page) => {
await page.getByText("You haven't created an event yet").waitFor()
await settle(page)
},
}
const toastWait = 120
async function waitToast(page: Page, title: string) {
await page.getByText(title, { exact: true }).waitFor()
await page.waitForTimeout(toastWait)
await settle(page)
}
const roleChangedToast: VisualState = {
path: '/users',
api: usersApi(martaSession, {
'PUT /v1/user/3/role': {
status: 200,
body: boardUser(3, 'Ines Duarte', 'lead', '2026-09-03T09:00:00Z'),
},
}),
steps: async (page) => {
await openChangeRole(page)
await chooseRole(page, 'Content lead')
await pressInDialog(page, 'Change role')
await waitToast(page, 'Role changed')
},
}
const roleEditedElsewhere: VisualState = {
path: '/settings/roles',
api: deleteApi(
{ 'POST /v1/role/4/reassign': { status: 409, body: 'role_conflict' } },
{
status: 200,
bodyFor: (_url, call) =>
['owner', 'admin', 'writer', 'contributor', 'lead'].map((key) =>
roleItem(
key,
call > 1 && key === 'contributor'
? { updated_by: JONAS, updated_at: STALE_SAVED_AT }
: {},
),
),
},
),
steps: async (page) => {
await openDelete(page, 'Contributor')
await pickMoveTarget(page, 'Writer')
await pressInDialog(page, 'Move 4 members and delete role')
await waitToast(page, 'Role changed elsewhere')
},
}
const roleDeletedElsewhere: VisualState = {
path: '/settings/roles',
api: deleteApi(
{ 'POST /v1/role/4/reassign': { status: 404, body: 'role_not_found' } },
rolesList('owner', 'admin', 'writer', 'contributor', 'lead'),
),
steps: async (page) => {
await openDelete(page, 'Contributor')
await pickMoveTarget(page, 'Writer')
await pressInDialog(page, 'Move 4 members and delete role')
await waitToast(page, 'That role no longer exists')
},
}
async function navigateInApp(page: Page, path: string) {
await page.evaluate((target) => {
window.history.pushState({}, '', target)
window.dispatchEvent(new PopStateEvent('popstate'))
}, path)
}
function heldRequest(page: Page, pattern: RegExp) {
let release: (status: number, body: string) => void = () => undefined
const held = new Promise<{ status: number; body: string }>((resolve) => {
release = (status, body) => resolve({ status, body })
})
void page.route(pattern, async (route) => {
const answer = await held
await route.fulfill({
status: answer.status,
contentType: 'text/plain',
body: answer.body,
})
})
return release
}
const roleNotChangedToast: VisualState = {
path: '/users',
api: usersApi(martaSession, {
'GET /v1/user/': {
status: 200,
bodyFor: (_url, call) => boardUsers(call > 1 ? { 3: 'admin' } : {}),
},
}),
steps: async (page) => {
await openChangeRole(page)
await chooseRole(page, 'Content lead')
const release = heldRequest(page, /\/v1\/user\/3\/role$/)
await pressInDialog(page, 'Change role')
await navigateInApp(page, '/settings/roles')
await waitHeading(page, 'Roles')
release(409, 'user_role_changed')
await page.waitForTimeout(toastWait)
await navigateInApp(page, '/users')
await waitToast(page, 'Role not changed')
},
}
const ownershipNotTransferredToast: VisualState = {
path: '/users',
api: usersApi(martaSession),
steps: async (page) => {
await reachTransferConfirm(page)
const release = heldRequest(page, /\/v1\/site\/transfer-ownership$/)
await pressInDialog(page, 'Transfer ownership')
await navigateInApp(page, '/settings/roles')
await waitHeading(page, 'Roles')
release(409, 'user_role_changed')
await page.waitForTimeout(toastWait)
await navigateInApp(page, '/users')
await waitToast(page, 'Ownership not transferred')
},
}
const userNotRemovedToast: VisualState = {
path: '/users',
api: usersApi(martaSession, {
'DELETE /v1/user/3': { status: 409, body: 'user_role_changed' },
}),
steps: async (page) => {
await waitHeading(page, 'Users')
await chooseMenuItem(page, 'Ines Duarte', 'Remove user')
await pressInDialog(page, 'Remove user')
await waitToast(page, 'User not removed')
},
}
export const rolesStates: Record<string, VisualState> = {
'Role changed': roleChangedToast,
'Role edited elsewhere (409)': roleEditedElsewhere,
'Role deleted elsewhere': roleDeletedElsewhere,
"Role not changed: member's role changed first (409)": roleNotChangedToast,
"Ownership not transferred: target's role changed first (409)": ownershipNotTransferredToast,
"User not removed: member's role changed first (409)": userNotRemovedToast,
'Role editor: save failed (500)': saveFailed,
'Change role: failed (500)': changeFailed,
'Delete role: target role deleted': deleteTargetGone,
'Delete role: role gained members': deleteGainedMembers,
'Delete role: move failed (500)': deleteMoveFailed,
'Invite: invalid email': inviteInvalid,
'Invite: address already has an account (409)': inviteDuplicate,
'Invite: role above yours (403)': inviteEscalation,
'Invite: send failed (500)': inviteFailed,
'Role editor: Read set to Own': readOwnEditor,
'Articles list: Read = Own': articlesReadOwn,
'Articles list: no drafts of their own': articlesNoDrafts,
'Entry list: Read = Own': entriesReadOwn,
'Entry list: no entries of their own': entriesNone,
'Stale save: banner': staleEditor,
'Stale save: review differences': staleReview,
'Stale save: edits kept after reload': staleKept,
"Change role: member's role changed first (409)": changeStale,
"Transfer ownership: target's role changed first (409)": transferStale,
}