feat: roles SPA foundations
This commit is contained in:
1 parent
80f7037d3a
commit
907949aa12
180 files changed
+4308
-835
No files matched your search
+54
-49
@@ -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()
|
||||
|
||||
Reference in new issue
Block a user