/** * SA-series โ€” Server administration UI (Fase 13.5.2). * * SA-01 Ana (seed admin) sees the /admin link in the sidebar and can reach * /admin/collectives. Borja (member) does NOT see the link, and * visiting /admin directly redirects to /. * SA-02 Admin soft-deletes a throwaway collective via the UI, then restores * it. The status badge flips deleted โ†” active. The audit log page * lists both actions. * SA-03 Admin promotes Carmen to server_admin from /admin/admins; the entry * appears in the list. Then revokes her. Trying to revoke the sole * remaining admin is blocked by the server-side guard and the UI * surfaces an error (without breaking the page). * SA-04 Admin sets a server-level default-section override OFF for "notes" * from /admin/server; a member of the seed collective who had notes * ON locally sees the section disappear from their nav within a * reload (no realtime on server_settings โ€” acceptable; the page * re-evaluates on next mount). * * Idempotency: every test creates its own throwaway collective via direct * SQL through the in-page Supabase client (`__sb`). The afterEach clears it. * Promotion state for Carmen is rolled back via direct DELETE. */ import { test, expect, type Page, type Browser } from '@playwright/test'; import { USERS, COLLECTIVE_NAME } from '../fixtures/users.js'; import { loginAs } from '../fixtures/login.js'; import { loginAsAdmin } from '../fixtures/admin-login.js'; const CARMEN_ID = '33333333-3333-3333-3333-333333333333'; const SEED_COLLECTIVE_ID = 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa'; // Track collectives + extra admins created during the suite so we can clean // them up. The afterAll runs once after the whole file. const throwawayCollectiveIds = new Set(); const extraAdminUserIds = new Set([CARMEN_ID]); // Carmen may be promoted in SA-03 async function withAnaPage(browser: Browser, fn: (page: Page) => Promise): Promise { const ctx = await browser.newContext(); const page = await ctx.newPage(); try { await loginAsAdmin(page); return await fn(page); } finally { await ctx.close(); } } async function createThrowawayCollective(page: Page, name: string): Promise { await page.waitForFunction(() => Boolean((window as unknown as { __sb?: unknown }).__sb), null, { timeout: 5_000 }); const id = await page.evaluate(async (n) => { const supabase = ( window as unknown as { __sb: import('@supabase/supabase-js').SupabaseClient } ).__sb; const { data, error } = await supabase.rpc('create_collective', { p_name: n, p_emoji: '๐Ÿงช' }); if (error) throw new Error(error.message); return (data as { id: string }).id; }, name); throwawayCollectiveIds.add(id); return id; } async function resetUserAndCollectiveFlags(browser: Browser): Promise { const ctx = await browser.newContext(); const page = await ctx.newPage(); try { await loginAsAdmin(page); await page.waitForFunction(() => Boolean((window as unknown as { __sb?: unknown }).__sb), null, { timeout: 5_000 }); await page.evaluate(async () => { const supabase = ( window as unknown as { __sb: import('@supabase/supabase-js').SupabaseClient } ).__sb; await supabase .from('users') .update({ feature_flags: {} }) .eq('id', '11111111-1111-1111-1111-111111111111'); await supabase .from('users') .update({ feature_flags: {} }) .eq('id', '22222222-2222-2222-2222-222222222222'); await supabase .from('collectives') .update({ feature_flags: {} }) .eq('id', 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa'); // Restore server_settings to "no opinion" for every known section // so subsequent suites (e.g. section-visibility SV-02) see a clean // server layer. admin_clear_default_section removes the key from // the JSONB; `true` would NOT be equivalent to "no opinion" โ€” it // would explicitly override any collective OFF. for (const s of ['lists', 'tasks', 'notes', 'search']) { try { await supabase.rpc('admin_clear_default_section', { p_section: s }); } catch { /* idempotent */ } } }); } finally { await ctx.close(); } } test.afterAll(async ({ browser }) => { // Best-effort cleanup. Use Ana's session (admin) so the hard-delete RPC // is callable for any leftover throwaway collectives. const ctx = await browser.newContext(); const page = await ctx.newPage(); try { await loginAsAdmin(page); await page.waitForFunction(() => Boolean((window as unknown as { __sb?: unknown }).__sb), null, { timeout: 5_000 }); await page.evaluate( async ({ collectives, extraAdmins }) => { const supabase = ( window as unknown as { __sb: import('@supabase/supabase-js').SupabaseClient } ).__sb; for (const id of collectives) { try { await supabase.rpc('admin_hard_delete_collective', { p_collective_id: id, p_force: true }); } catch { /* may already be gone */ } } for (const u of extraAdmins) { try { await supabase.rpc('revoke_server_admin', { p_user: u }); } catch { /* may not be an admin */ } } await supabase .from('users') .update({ feature_flags: {} }) .neq('id', '00000000-0000-0000-0000-000000000000'); await supabase .from('collectives') .update({ feature_flags: {} }) .neq('id', '00000000-0000-0000-0000-000000000000'); for (const s of ['lists', 'tasks', 'notes', 'search']) { try { await supabase.rpc('admin_clear_default_section', { p_section: s }); } catch { /* idempotent */ } } }, { collectives: Array.from(throwawayCollectiveIds), extraAdmins: Array.from(extraAdminUserIds) } ); } catch { // Don't mask the real test failure. } finally { await ctx.close(); } }); test.describe('Server administration UI', () => { test('SA-01: Ana sees the admin link + can reach /admin; Borja does not and gets bounced from /admin', async ({ browser }) => { // Two full login flows (Ana + Borja) push this past 30s when the dev // DB has accumulated state from prior suites. 60s mirrors SA-04. test.setTimeout(60_000); // Ana await withAnaPage(browser, async (page) => { await expect(page.getByTestId('sidebar-admin-link')).toBeVisible(); await page.getByTestId('sidebar-admin-link').click(); await expect(page).toHaveURL(/\/admin\/collectives$/); await expect(page.getByTestId('admin-banner')).toBeVisible(); await expect(page.getByTestId('admin-collectives-table')).toBeVisible(); }); // Borja โ€” no admin link, direct hit redirects. const borjaCtx = await browser.newContext(); const borjaPage = await borjaCtx.newPage(); try { await loginAs(borjaPage, USERS.borja); await expect(borjaPage.getByTestId('sidebar-admin-link')).toHaveCount(0); await borjaPage.goto('/admin'); // The (admin)/+layout.svelte `$effect` redirects non-admins to /. await expect(borjaPage).toHaveURL(/\/lists$|\/onboarding$|\/$/, { timeout: 10_000 }); await expect(borjaPage.getByTestId('admin-banner')).toHaveCount(0); } finally { await borjaCtx.close(); } }); test('SA-02: soft-delete + restore round-trip + audit log entries', async ({ browser }) => { await withAnaPage(browser, async (page) => { const cid = await createThrowawayCollective(page, `SA-02 ${Date.now()}`); await page.goto('/admin/collectives'); await expect(page.getByTestId(`admin-collective-row-${cid}`)).toBeVisible(); // Soft-delete. await page.getByTestId(`admin-soft-delete-${cid}`).click(); await expect(page.getByTestId('admin-soft-delete-modal')).toBeVisible(); await page.getByTestId('admin-modal-reason').fill('SA-02 reason'); await page.getByTestId('admin-modal-confirm').click(); await expect(page.getByTestId('admin-soft-delete-modal')).toHaveCount(0); // Status badge flipped. await expect(page.getByTestId(`admin-collective-status-${cid}`)).toContainText( /Soft-deleted|Borrado/i ); // Restore. await page.getByTestId(`admin-restore-${cid}`).click(); await expect(page.getByTestId('admin-restore-modal')).toBeVisible(); await page.getByTestId('admin-modal-confirm').click(); await expect(page.getByTestId('admin-restore-modal')).toHaveCount(0); await expect(page.getByTestId(`admin-collective-status-${cid}`)).toContainText( /Active|Activo/i ); // Audit log shows both rows for this target. await page.goto('/admin/audit'); await expect(page.getByTestId('admin-audit-table')).toBeVisible(); const actions = await page .getByTestId('admin-audit-table') .locator(`tr:has-text("${cid}")`) .allTextContents(); const joined = actions.join('\n'); expect(joined).toMatch(/soft_delete_collective/); expect(joined).toMatch(/restore_collective/); }); }); test('SA-03: promote + revoke another admin; sole-admin revoke is blocked', async ({ browser }) => { await withAnaPage(browser, async (page) => { await page.goto('/admin/admins'); // Promote Carmen. await page.getByTestId('admin-promote-open').click(); await expect(page.getByTestId('admin-promote-modal')).toBeVisible(); await page.getByTestId('admin-promote-email').fill(USERS.carmen.email); await page.getByTestId('admin-modal-confirm').click(); await expect(page.getByTestId('admin-promote-modal')).toHaveCount(0); await expect(page.getByTestId(`admin-admin-row-${CARMEN_ID}`)).toBeVisible(); // Revoke Carmen โ€” succeeds (Ana remains). await page.getByTestId(`admin-revoke-${CARMEN_ID}`).click(); await expect(page.getByTestId(`admin-admin-row-${CARMEN_ID}`)).toHaveCount(0); // Now Ana is the sole admin. The UI replaces the Revoke button with // "you (cannot revoke yourself while sole admin)". const anaRow = page.getByTestId(`admin-admin-row-${USERS.ana.id}`); await expect(anaRow).toContainText(/cannot revoke yourself|auto-revocarte/i); await expect(page.getByTestId(`admin-revoke-${USERS.ana.id}`)).toHaveCount(0); }); }); test('SA-04: server-level OFF for notes wins over collective ON', async ({ browser }) => { // Two full login flows + a /lists hydration push this past the 30s default // under accumulated dev-DB load. 60s is comfortable headroom; the test // runs in ~7s on a clean DB. test.setTimeout(60_000); await withAnaPage(browser, async (page) => { // Make sure collective has notes ON explicitly (admin override). await page.evaluate(async () => { const supabase = ( window as unknown as { __sb: import('@supabase/supabase-js').SupabaseClient } ).__sb; await supabase .from('collectives') .update({ feature_flags: { notes: true } }) .eq('id', 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa'); }); // Set server-level notes OFF. await page.goto('/admin/server'); await expect(page.getByTestId('admin-server-sections')).toBeVisible(); await page.getByTestId('admin-server-off-notes').click(); await expect(page.getByTestId('admin-server-section-state-notes')).toContainText(/OFF/i); }); // Borja (member of seed collective) loads /lists; the Notes entry must // not be in his nav. There's no realtime on server_settings โ€” a fresh // page load is the contract. const borjaCtx = await browser.newContext(); const borjaPage = await borjaCtx.newPage(); try { await loginAs(borjaPage, USERS.borja); await borjaPage.goto('/lists'); await expect(borjaPage.getByTestId('desktop-sidebar')).toBeVisible(); // The sidebar nav anchor for notes carries `data-section="notes"`. await expect( borjaPage.getByTestId('desktop-sidebar').locator('a[data-section="notes"]') ).toHaveCount(0); } finally { await borjaCtx.close(); } await resetUserAndCollectiveFlags(browser); }); });