feat: add backup recovery, setup wizard and user-view guards
This commit is contained in:
@@ -0,0 +1,214 @@
|
||||
// Fixture-only user-view regression checks. No real accounts or backend writes are used.
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const { chromium } = require(process.env.REVIEW_PLAYWRIGHT || 'playwright');
|
||||
const base = process.env.REVIEW_BASE || 'http://127.0.0.1:3114';
|
||||
const output = process.env.REVIEW_DIR;
|
||||
const previewKey = 'magent_user_view_preview';
|
||||
|
||||
async function setPreview(page, enabled) {
|
||||
await page.evaluate(({ key, enabled }) => {
|
||||
if (enabled) sessionStorage.setItem(key, '1');
|
||||
else sessionStorage.removeItem(key);
|
||||
window.dispatchEvent(new CustomEvent('magent:user-view-change', { detail: { enabled } }));
|
||||
}, { key: previewKey, enabled });
|
||||
}
|
||||
|
||||
async function reviewUserView(browser) {
|
||||
const context = await browser.newContext();
|
||||
try {
|
||||
await context.addCookies([{ name: 'magent_logged_in', value: '1', url: base }]);
|
||||
const calls = [];
|
||||
const errors = [];
|
||||
const securityErrors = [];
|
||||
let role = 'admin';
|
||||
const features = { stats: true, requests: true, new_requests: true, issues: true, invites: true, ignore_profile_limits: false };
|
||||
const user = () => ({ id: 1, username: 'Fixture account', role, features, invite_management_enabled: false });
|
||||
const snapshot = {
|
||||
request_id: '99', title: 'Fixture movie', year: 2026, request_type: 'movie', state: 'AVAILABLE',
|
||||
state_reason: 'Available on the fixture media server.', timeline: [], actions: [],
|
||||
presentation: {
|
||||
status: { label: 'Available to watch', meaning: 'Ready on the fixture server.' },
|
||||
pipeline: [{ id: 'available', label: 'Available to watch', state: 'complete', summary: 'Ready', link: 'https://watch.example.test/' }],
|
||||
},
|
||||
};
|
||||
const issue = {
|
||||
id: 1, kind: 'issue', title: 'Fixture playback issue', description: 'Fixture only.', status: 'new', priority: 'normal',
|
||||
created_by_username: 'Other fixture user', created_at: '2026-09-18T01:00:00Z', updated_at: '2026-09-18T01:00:00Z',
|
||||
last_activity_at: '2026-09-18T01:00:00Z', permissions: { can_edit: true, can_comment: true, can_moderate: true, can_delete: true },
|
||||
};
|
||||
await context.route('**/api/**', async (route) => {
|
||||
const request = route.request();
|
||||
const pathname = new URL(request.url()).pathname;
|
||||
calls.push({ pathname, method: request.method() });
|
||||
const reply = (value) => route.fulfill({ json: value });
|
||||
if (pathname === '/api/setup/status') return reply({ setup_required: false, needs_admin: false });
|
||||
if (pathname === '/api/setup/state') return reply({ completed: true, step: 'review', completed_at: '2026-09-18T01:00:00Z' });
|
||||
if (pathname === '/api/admin/settings') return reply({ settings: [] });
|
||||
if (pathname === '/api/auth/me') return reply(user());
|
||||
if (pathname === '/api/auth/profile') return reply({ user: user(), stats: { total: 0, ready: 0, in_progress: 0 }, activity: { recent: [] } });
|
||||
if (pathname === '/api/auth/profile/invites') return reply({ invites: [], invite_access: { enabled: false }, master_invite: null });
|
||||
if (pathname === '/api/requests/99/snapshot') return reply(snapshot);
|
||||
if (pathname === '/api/requests/99/history') return reply({ snapshots: [] });
|
||||
if (pathname === '/api/requests/99/actions') return reply({ actions: [] });
|
||||
if (pathname === '/api/requests/99/language') return reply({ language: null });
|
||||
if (pathname === '/api/insights') return reply({
|
||||
state: 'not_configured', is_admin: role === 'admin',
|
||||
requests: { total: 0, pending: 0, approved: 0, declined: 0, available: 0, failed: 0, movies: 0, tv: 0, recent: [] },
|
||||
});
|
||||
if (pathname === '/api/portal/overview') return reply({ overview: { by_kind: { issue: 1 } } });
|
||||
if (pathname === '/api/portal/items') return reply({ items: [issue], total: 1, has_more: false });
|
||||
if (pathname === '/api/portal/items/1') return reply({
|
||||
item: issue,
|
||||
comments: [{ id: 1, item_id: 1, author_username: 'Fixture admin', author_role: 'admin', message: 'Private fixture note', is_internal: true, created_at: issue.created_at }],
|
||||
activity: [{ id: 1, item_id: 1, event_type: 'internal_note_added', actor_username: 'Fixture admin', actor_role: 'admin', message: 'Private fixture activity', created_at: issue.created_at }],
|
||||
});
|
||||
if (pathname.includes('/events/stream')) return route.fulfill({ contentType: 'text/event-stream', body: ': fixture\n\n' });
|
||||
if (pathname.includes('/branding/')) return route.fulfill({ status: 404 });
|
||||
return reply({ items: [], total: 0, services: [], navigation: { showRequests: true } });
|
||||
});
|
||||
const page = await context.newPage();
|
||||
page.on('pageerror', (error) => errors.push(error.message));
|
||||
page.on('console', (entry) => {
|
||||
if (/content security policy|blocked by cors policy/i.test(entry.text())) securityErrors.push(entry.text());
|
||||
});
|
||||
const enterPreview = page.getByRole('button', { name: 'View as user', exact: true });
|
||||
const blocked = page.getByRole('heading', { name: 'Administrator tools are hidden', exact: true });
|
||||
const advanced = page.getByRole('button', { name: /Advanced details/ });
|
||||
const adminNavigation = page.locator('.header-actions a[href="/admin"], .workspace-mobile-nav a[href="/admin"], .signed-in-dropdown a[href="/admin"]');
|
||||
const privilegedReads = (entries) => entries.filter((call) => (
|
||||
/^\/api\/admin(?:\/|$)/.test(call.pathname)
|
||||
|| call.pathname === '/api/status/services'
|
||||
|| /^\/api\/setup\/(state|complete|bootstrap)$/.test(call.pathname)
|
||||
));
|
||||
const histories = (entries) => entries.filter((call) => /^\/api\/requests\/99\/(history|actions)$/.test(call.pathname));
|
||||
const assertHiddenNavigation = async () => {
|
||||
assert.equal(await adminNavigation.count(), 0, 'Desktop, mobile and account menu must not include configuration');
|
||||
await page.locator('.avatar-button').click();
|
||||
assert.equal(await page.locator('.signed-in-dropdown a[href="/admin"]').count(), 0, 'The open account menu must not contain Settings');
|
||||
await page.locator('.avatar-button').click();
|
||||
};
|
||||
const screenshot = async (name) => {
|
||||
if (output) await page.screenshot({ path: path.join(output, name), fullPage: true });
|
||||
};
|
||||
|
||||
await page.setViewportSize({ width: 1440, height: 1000 });
|
||||
await page.goto(`${base}/admin`);
|
||||
await page.getByRole('heading', { name: 'Settings', exact: true }).waitFor();
|
||||
await page.getByText('Advanced tools', { exact: true }).waitFor();
|
||||
assert.equal(await page.locator('.header-actions a[href="/admin"]').count(), 1);
|
||||
const beforeToggle = calls.length;
|
||||
await enterPreview.click();
|
||||
await blocked.waitFor();
|
||||
assert.equal(await page.getByRole('heading', { name: 'Settings', exact: true }).count(), 0, 'Entering preview must unmount the current config page');
|
||||
assert.equal(await page.getByText('Advanced tools', { exact: true }).count(), 0, 'Advanced tools must not merely be hidden by CSS');
|
||||
await assertHiddenNavigation();
|
||||
assert.deepEqual(privilegedReads(calls.slice(beforeToggle)), [], 'Entering preview must not start admin reads');
|
||||
await screenshot('user-view-blocked-desktop.png');
|
||||
assert.equal(await page.evaluate((key) => sessionStorage.getItem(key), previewKey), '1');
|
||||
|
||||
for (const route of ['/admin', '/admin/general', '/admin/backups', '/admin/users', '/admin/issues', '/users', '/users/2', '/setup']) {
|
||||
const beforeVisit = calls.length;
|
||||
await page.goto(base + route);
|
||||
await blocked.waitFor();
|
||||
assert.deepEqual(privilegedReads(calls.slice(beforeVisit)), [], `Preview must not mount admin data loaders on ${route}`);
|
||||
assert.equal(await page.getByRole('button', { name: 'Exit user view', exact: true }).count() > 0, true, `${route} must provide an exit`);
|
||||
}
|
||||
await page.getByRole('button', { name: 'Exit user view', exact: true }).first().click();
|
||||
await page.getByRole('heading', { name: 'Connect your apps', exact: true }).waitFor();
|
||||
assert.equal(await page.evaluate((key) => sessionStorage.getItem(key), previewKey), null);
|
||||
|
||||
for (const width of [1440, 390]) {
|
||||
await page.setViewportSize({ width, height: 1000 });
|
||||
await page.goto(`${base}/requests/99`);
|
||||
await page.getByRole('heading', { name: 'Fixture movie', exact: true }).waitFor();
|
||||
await advanced.waitFor();
|
||||
await enterPreview.click();
|
||||
await advanced.waitFor({ state: 'detached' });
|
||||
await assertHiddenNavigation();
|
||||
assert(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth), `User view must not overflow at ${width}px`);
|
||||
await screenshot(`user-view-request-${width}.png`);
|
||||
const beforeReload = calls.length;
|
||||
await page.reload();
|
||||
await page.getByRole('heading', { name: 'Fixture movie', exact: true }).waitFor();
|
||||
assert.equal(await advanced.count(), 0, 'Reloading must not expose diagnostics before preview initializes');
|
||||
assert.deepEqual(histories(calls.slice(beforeReload)), [], 'Reloading in preview must not request admin histories');
|
||||
await assertHiddenNavigation();
|
||||
await page.getByRole('button', { name: 'Exit user view', exact: true }).first().click();
|
||||
await advanced.waitFor();
|
||||
assert.equal(await page.locator('.header-actions a[href="/admin"]').count(), 1, 'Exiting restores the admin navigation');
|
||||
}
|
||||
|
||||
await page.goto(`${base}/insights`);
|
||||
await page.getByRole('link', { name: 'Connect Jellystat', exact: true }).waitFor();
|
||||
await enterPreview.click();
|
||||
await page.getByRole('link', { name: 'Connect Jellystat', exact: true }).waitFor({ state: 'detached' });
|
||||
await page.getByText('Viewing stats will appear here once your administrator connects Jellystat.', { exact: true }).waitFor();
|
||||
|
||||
await page.goto(`${base}/profile/invites`);
|
||||
await page.getByRole('heading', { name: 'Invites are not enabled for your account', exact: true }).waitFor();
|
||||
assert.equal(await page.getByRole('heading', { name: 'Create an invite', exact: true }).count(), 0, 'Preview must not retain the admin-only invite bypass');
|
||||
|
||||
await setPreview(page, false);
|
||||
await page.goto(`${base}/portal/issues`);
|
||||
await page.getByRole('button', { name: /Fixture playback issue/ }).click();
|
||||
const issueDialog = page.getByRole('dialog', { name: 'Issue #1', exact: true });
|
||||
await issueDialog.getByRole('button', { name: 'Delete issue', exact: true }).waitFor();
|
||||
await issueDialog.getByRole('checkbox', { name: 'Internal comment (admin only)', exact: true }).check();
|
||||
await issueDialog.getByLabel('Add comment', { exact: true }).fill('Private fixture draft');
|
||||
await issueDialog.getByRole('button', { name: 'Delete issue', exact: true }).click();
|
||||
await issueDialog.getByRole('button', { name: 'Delete permanently', exact: true }).waitFor();
|
||||
await issueDialog.getByText('Private fixture note', { exact: true }).waitFor();
|
||||
await setPreview(page, true);
|
||||
await issueDialog.getByRole('button', { name: 'Delete issue', exact: true }).waitFor({ state: 'detached' });
|
||||
assert.equal(await issueDialog.getByRole('button', { name: 'Delete permanently', exact: true }).count(), 0);
|
||||
assert.equal(await issueDialog.getByRole('checkbox', { name: 'Internal comment (admin only)', exact: true }).count(), 0);
|
||||
assert.equal(await issueDialog.getByLabel('Priority', { exact: true }).count(), 0);
|
||||
assert.equal(await issueDialog.getByLabel('Assignee username', { exact: true }).count(), 0);
|
||||
assert.equal(await issueDialog.getByText('Private fixture note', { exact: true }).count(), 0);
|
||||
assert.equal(await issueDialog.getByText('Private fixture activity', { exact: true }).count(), 0);
|
||||
assert.equal(await issueDialog.getByLabel('Add comment', { exact: true }).inputValue(), '', 'An internal draft must not become a public comment when leaving admin mode');
|
||||
assert.equal(await issueDialog.getByRole('button', { name: 'Save changes', exact: true }).isDisabled(), true);
|
||||
await screenshot('user-view-issue-moderation-hidden.png');
|
||||
issue.created_by_username = 'Fixture account';
|
||||
await page.reload();
|
||||
await page.getByRole('button', { name: /Fixture playback issue/ }).click();
|
||||
await issueDialog.getByLabel('Title', { exact: true }).waitFor();
|
||||
assert.equal(await issueDialog.getByRole('button', { name: 'Save changes', exact: true }).isEnabled(), true, 'Preview must preserve editing of the account\'s own issues');
|
||||
|
||||
role = 'user';
|
||||
await setPreview(page, false);
|
||||
for (const enabled of [false, true]) {
|
||||
await page.goto(`${base}/requests/99`);
|
||||
await page.getByRole('heading', { name: 'Fixture movie', exact: true }).waitFor();
|
||||
await setPreview(page, enabled);
|
||||
await assertHiddenNavigation();
|
||||
assert.equal(await enterPreview.count(), 0, 'Ordinary users must not gain an admin toggle');
|
||||
assert.equal(await advanced.count(), 0, 'Changing the preview flag must not grant admin diagnostics');
|
||||
await page.reload();
|
||||
await page.getByRole('heading', { name: 'Fixture movie', exact: true }).waitFor();
|
||||
assert.equal(await advanced.count(), 0);
|
||||
}
|
||||
await setPreview(page, false);
|
||||
for (const route of ['/admin', '/admin/backups', '/users/2']) {
|
||||
const beforeVisit = calls.length;
|
||||
await page.goto(base + route);
|
||||
await page.getByRole('heading', { name: 'Administrator access required', exact: true }).waitFor();
|
||||
assert.deepEqual(privilegedReads(calls.slice(beforeVisit)), [], `A regular account must not mount ${route}`);
|
||||
}
|
||||
|
||||
assert.deepEqual(errors, [], 'No uncaught browser errors');
|
||||
assert.deepEqual(securityErrors, [], 'Preview must hydrate without CSP or CORS errors');
|
||||
assert.equal(calls.some((call) => call.method !== 'GET'), false, 'Preview controls must not change backend permissions or settings');
|
||||
console.log('User view passed: immediate admin-page unmount, direct admin/users/setup route blocking without privileged reads, desktop/mobile/account navigation, request diagnostics, reload persistence, exit restoration, stats/invite controls, issue moderation and private notes/drafts, own-issue editing, and no ordinary-user privilege escalation. API traffic used fixtures only.');
|
||||
} finally {
|
||||
await context.close();
|
||||
}
|
||||
}
|
||||
|
||||
(async () => {
|
||||
if (output) fs.mkdirSync(output, { recursive: true });
|
||||
const browser = await chromium.launch({ headless: true, executablePath: process.env.REVIEW_CHROMIUM || undefined });
|
||||
try { await reviewUserView(browser); } finally { await browser.close(); }
|
||||
})().catch((error) => { console.error(error); process.exitCode = 1; });
|
||||
Reference in New Issue
Block a user