When `PRIVATE_MODE=true`, every API path except a small allowlist
(`/health`, `/auth/{config,login,logout,register}`) requires a valid
session cookie or bearer token — anonymous reads are rejected with
401. Self-registration is force-disabled in private mode regardless
of `ALLOW_SELF_REGISTER`, so a locked-down instance flips with a
single switch (admins still mint accounts via `POST /admin/users`).
The backend gate is a tower middleware that reuses the existing
`CurrentUser` extractor, so the cookie + bearer paths cannot drift
from per-handler auth. `/auth/config` now exposes the flag plus the
effective `self_register_enabled` value so the frontend can render
the navbar correctly on the first paint.
On the frontend, a new universal root `+layout.ts` fetches the
config and redirects anonymous visitors to `/login?next=<path>`
before page-specific loads fire. The redirect is UX only — the
backend middleware is the source of truth, so crafted requests
still 401.
Defaults stay public (`PRIVATE_MODE=false`); existing deployments
need no env change.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
102 lines
3.6 KiB
TypeScript
102 lines
3.6 KiB
TypeScript
import { test, expect, type Page } from '@playwright/test';
|
|
|
|
// Network-level mocks for the private-mode UX. The backend integration
|
|
// tests (api_private_mode.rs) cover the actual gate; here we only
|
|
// verify that the SvelteKit universal load redirects anonymous
|
|
// visitors to /login and then back to where they were going.
|
|
|
|
const userFixture = {
|
|
id: 'user-1',
|
|
username: 'alice',
|
|
created_at: '2026-01-01T00:00:00Z',
|
|
is_admin: false
|
|
};
|
|
const emptyPage = { items: [], page: { limit: 50, offset: 0, total: null } };
|
|
|
|
async function stubPrivateInstance(page: Page) {
|
|
let loggedIn = false;
|
|
|
|
// The flag that flips the gate on. Frontend reads it in
|
|
// `+layout.ts` to decide whether to redirect.
|
|
await page.route('**/api/v1/auth/config', async (route) => {
|
|
await route.fulfill({
|
|
status: 200,
|
|
contentType: 'application/json',
|
|
body: JSON.stringify({
|
|
self_register_enabled: false,
|
|
private_mode: true
|
|
})
|
|
});
|
|
});
|
|
|
|
await page.route('**/api/v1/auth/me', async (route) => {
|
|
if (loggedIn) {
|
|
await route.fulfill({
|
|
status: 200,
|
|
contentType: 'application/json',
|
|
body: JSON.stringify({ user: userFixture })
|
|
});
|
|
} else {
|
|
await route.fulfill({
|
|
status: 401,
|
|
contentType: 'application/json',
|
|
body: JSON.stringify({
|
|
error: { code: 'unauthenticated', message: 'unauthenticated' }
|
|
})
|
|
});
|
|
}
|
|
});
|
|
|
|
await page.route('**/api/v1/auth/login', async (route) => {
|
|
loggedIn = true;
|
|
await route.fulfill({
|
|
status: 200,
|
|
contentType: 'application/json',
|
|
body: JSON.stringify({ user: userFixture })
|
|
});
|
|
});
|
|
|
|
// The real backend would 401 these too in private mode; we stub
|
|
// success so the post-login navigation can render the home page
|
|
// without an additional redirect cycle.
|
|
await page.route('**/api/v1/mangas*', async (route) => {
|
|
await route.fulfill({
|
|
status: 200,
|
|
contentType: 'application/json',
|
|
body: JSON.stringify(emptyPage)
|
|
});
|
|
});
|
|
}
|
|
|
|
test('private mode: anonymous visit to / redirects to /login?next=%2F', async ({ page }) => {
|
|
await stubPrivateInstance(page);
|
|
await page.goto('/');
|
|
await expect(page).toHaveURL(/\/login\?next=%2F$/);
|
|
await expect(page.getByTestId('login-username')).toBeVisible();
|
|
});
|
|
|
|
test('private mode: register link is hidden', async ({ page }) => {
|
|
await stubPrivateInstance(page);
|
|
await page.goto('/login');
|
|
await expect(page.getByTestId('nav-login')).toBeVisible();
|
|
// self_register_enabled is the effective value (false in private
|
|
// mode regardless of ALLOW_SELF_REGISTER), so the navbar must
|
|
// never render the register affordance here.
|
|
await expect(page.getByTestId('nav-register')).toHaveCount(0);
|
|
});
|
|
|
|
test('private mode: after login the user lands back on the requested page', async ({ page }) => {
|
|
await stubPrivateInstance(page);
|
|
|
|
// Visit a deep link → bounced to /login with next= preserving it.
|
|
await page.goto('/');
|
|
await expect(page).toHaveURL(/\/login\?next=%2F$/);
|
|
|
|
await page.getByTestId('login-username').fill('alice');
|
|
await page.getByTestId('login-password').fill('hunter2hunter2');
|
|
await page.getByTestId('login-submit').click();
|
|
|
|
// Authenticated → can now reach the home page without bouncing.
|
|
await expect(page.getByTestId('session-user')).toContainText('alice');
|
|
});
|