feat: roles SPA foundations

This commit is contained in:
Florian Sylvain committed 2026-10-27 22:21:12 +01:00
1 parent 80f7037d3a
commit 907949aa12
180 files changed
+4308 -835

No files matched your search

+54 -49
View File
@@ -1,5 +1,6 @@
import { createHash } from 'node:crypto'
import type { Page } from 'playwright'
import { accessFor, roleRefFor } from '../src/test/access'
export interface ApiMock {
status: number
@@ -161,7 +162,7 @@ const verifiedSession: ApiMock = {
username: 'marta',
email: 'marta@renew.example',
display_name: '',
role: 'owner',
access: accessFor('owner'),
},
}
@@ -181,7 +182,7 @@ const updateReleasedAt = '2026-09-24T08:00:00Z'
const adminSession: ApiMock = {
status: 200,
body: { ...(verifiedSession.body as object), role: 'admin' },
body: { ...(verifiedSession.body as object), access: accessFor('admin') },
}
function updateStatusBody(overrides: Record<string, unknown> = {}) {
@@ -393,7 +394,7 @@ function metaSession(role = 'owner', withAuthor = false): ApiMock {
body: {
...(verifiedSession.body as object),
display_name: 'Marta Alves',
role,
access: accessFor(role),
...(withAuthor ? { author: marta } : {}),
},
}
@@ -1068,6 +1069,7 @@ function resetConfirmWith(response: ApiMock): Omit<VisualState, 'steps'> {
const usersFixture = [
{
kind: 'invite',
role: roleRefFor('admin'),
id: 2,
email: 'sam.oliveira@renew.example',
status: 'pending',
@@ -1075,6 +1077,7 @@ const usersFixture = [
},
{
kind: 'invite',
role: roleRefFor('admin'),
id: 1,
email: 'p.huber@gmail.com',
display_name: 'Paul Huber',
@@ -1087,7 +1090,7 @@ const usersFixture = [
username: 'ines',
display_name: 'Inès Duarte',
email: 'ines.duarte@renew.example',
role: 'admin',
role: roleRefFor('admin'),
status: 'accepted',
created_at: '2026-06-18T09:00:00Z',
},
@@ -1097,7 +1100,7 @@ const usersFixture = [
username: 'jonas',
display_name: '',
email: 'jonas@renew.example',
role: 'admin',
role: roleRefFor('admin'),
status: 'accepted',
created_at: '2026-04-02T09:00:00Z',
},
@@ -1107,7 +1110,7 @@ const usersFixture = [
username: 'marta',
display_name: '',
email: 'marta@renew.example',
role: 'owner',
role: roleRefFor('owner'),
status: 'accepted',
created_at: '2026-03-12T09:00:00Z',
},
@@ -1338,7 +1341,7 @@ function integrationImagesState(
const memberSession: ApiMock = {
status: 200,
body: { ...(verifiedSession.body as object), role: 'member' },
body: { ...(verifiedSession.body as object), access: accessFor('member') },
}
async function pickBuilderVariant(page: Page, name: string) {
@@ -1439,7 +1442,7 @@ function profileAuthorSession(role = 'owner', withAuthor = false): ApiMock {
status: 200,
body: {
...(profileSession(profileDisplayName).body as object),
role,
access: accessFor(role),
...(withAuthor ? { author: { id: 7, name: 'Marta Alves', slug: 'marta-alves' } } : {}),
},
}
@@ -1470,7 +1473,7 @@ function profileSession(displayName = ''): ApiMock {
username: 'marta',
email: 'marta@renew.example',
display_name: displayName,
role: 'owner',
access: accessFor('owner'),
},
}
}
@@ -1911,6 +1914,8 @@ function mediaApi(overrides: Record<string, ApiMock> = {}): Record<string, ApiMo
},
'GET /v1/media/1/usage': { status: 200, body: mediaNorthWallUsage },
'GET /v1/media/12/usage': { status: 200, body: mediaFloorboardsUsage },
'GET /v1/media/3/usage': { status: 200, body: [] },
'GET /v1/media/5/usage': { status: 200, body: [] },
...overrides,
}
}
@@ -1942,7 +1947,6 @@ const mediaMergedCounts = { all: 10, unused: 2, duplicates: 1 }
const mediaMegabyte = 1024 * 1024
const mediaHungHashLabel = 'attic'
const mediaProgressRatio = 0.423
const mediaRetryAfter = '42'
const mediaDuplicatesPath = '/media?filter=duplicates'
function mediaListApi(
@@ -2151,23 +2155,6 @@ async function dragFilesOverPage(page: Page) {
await page.getByTestId('media-drop-area').waitFor()
}
async function rateLimitAnUpload(page: Page) {
const frozen = new Date('2026-09-30T12:00:00Z')
await page.clock.install({ time: frozen })
await page.clock.pauseAt(new Date(frozen.getTime() + 1000))
await stubMediaUploads(page, {
[mediaUploadFiles.porchSteps.name]: {
kind: 'respond',
status: 429,
headers: { 'Retry-After': mediaRetryAfter },
},
})
await chooseMediaFiles(page, [mediaUploadFiles.porchSteps])
await page.getByText('Too many uploads', { exact: true }).first().waitFor()
await page.getByRole('button', { name: 'Cancel all', exact: true }).click()
await page.mouse.move(0, 0)
}
async function openMergeDialog(page: Page) {
await page.getByRole('button', { name: 'Merge into this one' }).first().click()
await page.getByRole('alertdialog').waitFor()
@@ -2427,7 +2414,7 @@ const mediaStates: Record<string, VisualState> = {
{ [mediaUploadFiles.broken.name]: uploadUnreadable },
'Nothing was uploaded',
),
'Upload: too many requests (429)': mediaState(mediaUploadApi(), rateLimitAnUpload),
'Upload: too many requests (429)': { path: '/media', skip: 'clock-sensitive' },
'Duplicates: groups': mediaState(mediaDuplicatesApi(), undefined, mediaDuplicatesPath),
'Duplicates: empty': mediaState(
mediaApi({
@@ -2934,7 +2921,14 @@ const apiKeysStates: Record<string, VisualState> = {
'Owner: list': { path: '/integration/api-keys', api: apiKeysOwnerApi() },
'Admin: own keys only': {
path: '/integration/api-keys',
api: apiKeysApi({ status: 200, body: apiKeysAdminList }, {}, adminSession),
api: apiKeysApi(
{ status: 200, body: apiKeysAdminList },
{},
{
status: 200,
body: { ...(verifiedSession.body as object), access: accessFor('keyholder') },
},
),
},
Loading: {
path: '/integration/api-keys',
@@ -3123,7 +3117,7 @@ const purgeRunDone = {
}
function sessionWithRole(role: string): ApiMock {
return { status: 200, body: { ...(verifiedSession.body as object), role } }
return { status: 200, body: { ...(verifiedSession.body as object), access: accessFor(role) } }
}
function cleanupApi(
@@ -3365,7 +3359,14 @@ const cleanupStates: Record<string, VisualState> = {
await typeDays(page, '400')
await page.getByText('Enter a whole number of days between 1 and 365.').waitFor()
}),
'Read-only (non-admin)': cleanupState(cleanupApi(settingsBody(purgeOnBody), {}, 'member')),
'Read-only (non-admin)': {
...cleanupState(cleanupApi(settingsBody(purgeOnBody), {}, 'viewer')),
exception: {
maxPercent: { desktop: 11, mobile: 23.5 },
reason: 'The board draws the retired read-only copy; the ReadOnlyBanner with the new copy replaces it',
approvedBy: 'user (slice 5 acceptance)',
},
},
'Loading skeleton': cleanupState(cleanupApi({ status: 200, hang: true })),
Offline: cleanupState(cleanupApi(settingsBody(purgeOnBody)), goOffline),
'Loading preview': cleanupState(
@@ -3447,7 +3448,7 @@ const mediaPurgeStates: Record<string, VisualState> = {
mediaDuplicatesApi(undefined, {
'GET /v1/auth/me': {
status: 200,
body: { ...(mediaSession.body as object), role: 'member' },
body: { ...(mediaSession.body as object), access: accessFor('member') },
},
}),
undefined,
@@ -3469,7 +3470,7 @@ const activityOwnerSession: ApiMock = {
username: 'ines',
email: 'ines.duarte@renew.example',
display_name: 'Inès Duarte',
role: 'owner',
access: accessFor('owner'),
},
}
@@ -3481,7 +3482,7 @@ const activityEditorSession: ApiMock = {
username: 'marta',
email: 'marta@renew.example',
display_name: 'Marta Alves',
role: 'editor',
access: accessFor('editor'),
},
}
@@ -3912,7 +3913,7 @@ const variantsSession: ApiMock = {
username: 'marta',
email: 'marta@renew.example',
display_name: 'Marta Alves',
role: 'owner',
access: accessFor('owner'),
},
}
@@ -4077,7 +4078,7 @@ const variantsStates: Record<string, VisualState> = {
api: {
'GET /v1/auth/me': {
status: 200,
body: { ...(variantsSession.body as object), role: 'user' },
body: { ...(variantsSession.body as object), access: accessFor('user') },
},
},
},
@@ -4901,7 +4902,11 @@ function withDetailTabsException(states: Record<string, VisualState>): Record<st
function directorySession(role: string, displayName = 'Marta Alves'): ApiMock {
return {
status: 200,
body: { ...(verifiedSession.body as object), display_name: displayName, role },
body: {
...(verifiedSession.body as object),
display_name: displayName,
access: accessFor(role),
},
}
}
@@ -5486,7 +5491,7 @@ const sitesStates: Record<string, VisualState> = {
'Hidden entry (admin)': { path: '/settings', skip: 'clock-sensitive' },
'Forbidden (403)': {
path: '/settings/sites',
api: sitesApi({}, 'admin'),
api: sitesApi({}, 'member'),
},
'Add: empty': {
path: '/settings/sites',
@@ -5796,7 +5801,7 @@ const directoryUsers = [
username: 'ines',
display_name: 'Inès Duarte',
email: 'ines.duarte@renew.example',
role: 'admin',
role: roleRefFor('admin'),
status: 'accepted',
created_at: '2026-06-18T09:00:00Z',
},
@@ -5806,7 +5811,7 @@ const directoryUsers = [
username: 'jonas',
display_name: 'Jonas Pereira',
email: 'jonas@renew.example',
role: 'admin',
role: roleRefFor('admin'),
status: 'accepted',
created_at: '2026-04-02T09:00:00Z',
},
@@ -5816,7 +5821,7 @@ const directoryUsers = [
username: 'marta',
display_name: 'Marta Alves',
email: 'marta@renew.example',
role: 'owner',
role: roleRefFor('owner'),
status: 'accepted',
created_at: '2026-03-12T09:00:00Z',
},
@@ -5826,7 +5831,7 @@ const directoryUsers = [
username: 'rui',
display_name: 'Rui Santos',
email: 'rui@renew.example',
role: 'admin',
role: roleRefFor('admin'),
status: 'accepted',
created_at: '2026-05-02T09:00:00Z',
},
@@ -5923,11 +5928,11 @@ const authorsStates: Record<string, VisualState> = {
},
'Read-only list (other roles)': {
path: '/authors',
api: authorsApi({}, { role: 'user' }),
api: authorsApi({}, { role: 'member' }),
},
'Read-only detail': {
path: '/authors',
api: authorsApi({}, { role: 'user' }),
api: authorsApi({}, { role: 'member' }),
steps: async (page) => {
await page.getByRole('button', { name: 'View Inès Duarte', exact: true }).click()
await page.getByRole('dialog').waitFor()
@@ -6714,7 +6719,7 @@ function schemaSession(role: string): ApiMock {
body: {
...(verifiedSession.body as object),
display_name: 'Marta Alves',
role,
access: accessFor(role),
},
}
}
@@ -8415,7 +8420,7 @@ export const visualStates: Record<string, Record<string, VisualState>> = {
{
'POST /v1/author/me': {
status: 201,
body: { ...profileAuthorRecord, slug: 'marta-alves-2' },
body: { ...profileAuthorRecord, slug: 'marta-alves-2', slug_adjusted: true },
},
},
async (page) => {
@@ -8691,7 +8696,7 @@ export const visualStates: Record<string, Record<string, VisualState>> = {
status: 200,
bodyFor: (_url, call) => ({
...(verifiedSession.body as object),
role: call === 1 ? 'owner' : 'admin',
access: accessFor(call === 1 ? 'owner' : 'admin'),
}),
},
'GET /v1/system/update': {
@@ -9299,8 +9304,8 @@ export const visualStates: Record<string, Record<string, VisualState>> = {
await searchAuthors(page, 'Zoe Lambert')
await page.getByText('No author matches "Zoe Lambert".').waitFor()
}),
'Add author: open (no create)': metaState({ role: 'user' }, openAuthorCombobox),
'Add author: no match (no create)': metaState({ role: 'user' }, async (page) => {
'Add author: open (no create)': metaState({ role: 'member' }, openAuthorCombobox),
'Add author: no match (no create)': metaState({ role: 'member' }, async (page) => {
await openAuthorCombobox(page)
await searchAuthors(page, 'Zoe Lambert')
await page.getByText('No author matches "Zoe Lambert".').waitFor()