Files
collective-lists/apps/web/tests/e2e/admin.test.ts
Oier Bravo Urtasun d34c578ad8 test(fase-13): bump SA-01 + SA-04 timeout to 60s
Both tests do two full Keycloak login flows + a /lists hydration. Under
accumulated dev-DB load (many leftover lists from prior suites) the
30s default occasionally trips, even though the actual work completes
in ~5–7s on a clean DB. 60s is comfortable headroom and matches the
pattern used elsewhere when a test legitimately needs more.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-18 06:31:35 +02:00

308 lines
12 KiB
TypeScript

/**
* 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<string>();
const extraAdminUserIds = new Set<string>([CARMEN_ID]); // Carmen may be promoted in SA-03
async function withAnaPage<T>(browser: Browser, fn: (page: Page) => Promise<T>): Promise<T> {
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<string> {
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<void> {
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);
});
});