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>
308 lines
12 KiB
TypeScript
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);
|
|
});
|
|
});
|