Files
Oxicloud/frontend/src/routes/login/page.test.ts
T
Bradley Nelson e3823ce470 test(e2e): Playwright + Vitest coverage harness and test instrumentation
Add an end-to-end and unit test suite for the SvelteKit frontend:

- Playwright e2e specs (tests/e2e/spa) with a throwaway container stack,
  codegen scenarios, and an Istanbul-based coverage report pipeline.
- Vitest unit tests across API endpoints, components, stores and composables.
- `data-testid` hooks on interactive elements (AppShell, FileViewer,
  ShareDialog, search, photos, files breadcrumbs, login/Nextcloud flows,
  public share pages) so the e2e suite can target them deterministically.
- Serve the SPA app-shell CSP from a <meta> policy (svelte.config.js) plus a
  middleware that skips the CSP header on HTML; move the Nextcloud Login Flow
  v2 grant page to the SvelteKit /nextcloud/login route.
- `just front-codegen` recipe and start-server-spa.sh harness.

Make the test environment robust and consistent:
- Install a deterministic in-memory localStorage/sessionStorage in the Vitest
  setup so storage behaves identically across Node versions (Node 26 ships a
  native Web Storage global that otherwise shadows jsdom's).
- Pin devenv to Node 26 + PostgreSQL 18 and pin every CI job to Node 26.3.0
  so the dev shell and CI run the same toolchain versions.

Repair the API/WebDAV (hurl) suite, which had drifted from the backend:
- Migrate the removed `/api/folders/{id}/listing` endpoint to `/resources`
  (cursor-paginated `{items:[{resource_type,resource}]}` shape) across the
  batch-copy, grants, nested-group, and WebDAV NC tests + the dav_helpers
  wipe routine.
- Stop photos_etag from uploading the dedup-tracked fixture so the dedup
  blob-lifecycle test can own its content-addressed blob exclusively.
- dedup_create now asserts the idempotent same-content re-upload (201 +
  existing file id) instead of the stale 409 expectation.

Generated coverage reports, nyc output and the e2e server runtime data dir
are gitignored rather than committed.
2026-06-22 00:05:06 -06:00

158 lines
6.0 KiB
TypeScript

import { it, expect, vi, beforeEach } from 'vitest';
import { render, screen, fireEvent, waitFor } from '@testing-library/svelte';
const { goto, pageState, session } = vi.hoisted(() => ({
goto: vi.fn(),
pageState: { url: new URL('http://localhost/login') } as { url: URL },
session: { user: null } as { user: unknown }
}));
vi.mock('$app/navigation', () => ({ goto }));
vi.mock('$app/state', () => ({ page: pageState }));
vi.mock('$lib/stores/session.svelte', () => ({ session }));
vi.mock('$lib/api/endpoints/auth', () => ({
exchangeOidcCode: vi.fn(),
fetchMe: vi.fn(),
getOidcProviders: vi.fn(),
getAuthStatus: vi.fn(),
login: vi.fn(),
register: vi.fn(),
sendMagicLink: vi.fn(),
setupAdmin: vi.fn()
}));
import * as auth from '$lib/api/endpoints/auth';
import LoginPage from './+page.svelte';
const m = (fn: unknown) => fn as ReturnType<typeof vi.fn>;
beforeEach(() => {
vi.clearAllMocks();
pageState.url = new URL('http://localhost/login');
session.user = null;
m(auth.fetchMe).mockResolvedValue(null);
m(auth.getOidcProviders).mockResolvedValue({ providers: [] });
m(auth.getAuthStatus).mockResolvedValue({ initialized: true });
});
it('logs in and redirects', async () => {
m(auth.login).mockResolvedValue({ user: { id: '1' } });
render(LoginPage);
await screen.findByTestId('login-form');
await fireEvent.input(screen.getByTestId('login-username-input'), { target: { value: 'admin' } });
await fireEvent.input(screen.getByTestId('login-password-input'), { target: { value: 'pw' } });
await fireEvent.click(screen.getByTestId('login-submit-btn'));
await waitFor(() => expect(auth.login).toHaveBeenCalled());
});
it('exchanges an oidc code on mount and redirects', async () => {
pageState.url = new URL('http://localhost/login?oidc_code=abc');
m(auth.exchangeOidcCode).mockResolvedValue({ id: '1' });
render(LoginPage);
await waitFor(() => expect(auth.exchangeOidcCode).toHaveBeenCalledWith('abc'));
await waitFor(() => expect(goto).toHaveBeenCalled());
});
it('skips the form when already authenticated', async () => {
m(auth.fetchMe).mockResolvedValue({ id: '1' });
render(LoginPage);
await waitFor(() => expect(goto).toHaveBeenCalled());
});
it('enters setup mode on a fresh install', async () => {
m(auth.getAuthStatus).mockResolvedValue({ initialized: false });
render(LoginPage);
await screen.findByTestId('login-setup-form');
});
it('sends a magic link', async () => {
m(auth.sendMagicLink).mockResolvedValue('sent');
render(LoginPage);
await screen.findByTestId('login-form');
await fireEvent.click(screen.getByTestId('login-magic-toggle-btn'));
await fireEvent.input(screen.getByTestId('login-magic-email-input'), {
target: { value: 'a@b.test' }
});
await fireEvent.click(screen.getByTestId('login-magic-send-btn'));
await waitFor(() => expect(auth.sendMagicLink).toHaveBeenCalledWith('a@b.test'));
});
it('registers a new account', async () => {
m(auth.register).mockResolvedValue(undefined);
render(LoginPage);
await screen.findByTestId('login-form');
await fireEvent.click(screen.getByTestId('login-to-register-btn'));
await fireEvent.input(screen.getByTestId('login-register-username-input'), {
target: { value: 'u' }
});
await fireEvent.input(screen.getByTestId('login-register-email-input'), {
target: { value: 'u@b.test' }
});
await fireEvent.input(screen.getByTestId('login-register-password-input'), {
target: { value: 'TestPassword1!' }
});
await fireEvent.input(screen.getByTestId('login-register-confirm-input'), {
target: { value: 'TestPassword1!' }
});
await fireEvent.click(screen.getByTestId('login-register-submit-btn'));
await waitFor(() => expect(auth.register).toHaveBeenCalled());
});
it('shows an error message when login fails', async () => {
m(auth.login).mockRejectedValue(new Error('bad credentials'));
render(LoginPage);
await screen.findByTestId('login-form');
await fireEvent.input(screen.getByTestId('login-username-input'), { target: { value: 'admin' } });
await fireEvent.input(screen.getByTestId('login-password-input'), { target: { value: 'wrong' } });
await fireEvent.click(screen.getByTestId('login-submit-btn'));
await waitFor(() => expect(screen.getByText('bad credentials')).toBeTruthy());
});
it('rejects a registration with mismatched passwords without calling the API', async () => {
render(LoginPage);
await screen.findByTestId('login-form');
await fireEvent.click(screen.getByTestId('login-to-register-btn'));
await fireEvent.input(screen.getByTestId('login-register-username-input'), {
target: { value: 'u' }
});
await fireEvent.input(screen.getByTestId('login-register-password-input'), {
target: { value: 'TestPassword1!' }
});
await fireEvent.input(screen.getByTestId('login-register-confirm-input'), {
target: { value: 'Different1!' }
});
await fireEvent.click(screen.getByTestId('login-register-submit-btn'));
expect(auth.register).not.toHaveBeenCalled();
});
it('creates the first administrator in setup mode', async () => {
m(auth.getAuthStatus).mockResolvedValue({ initialized: false });
m(auth.setupAdmin).mockResolvedValue(undefined);
render(LoginPage);
await screen.findByTestId('login-setup-form');
await fireEvent.input(screen.getByTestId('login-setup-email-input'), {
target: { value: 'admin@x.test' }
});
await fireEvent.input(screen.getByTestId('login-setup-password-input'), {
target: { value: 'TestPassword1!' }
});
await fireEvent.input(screen.getByTestId('login-setup-confirm-input'), {
target: { value: 'TestPassword1!' }
});
await fireEvent.click(screen.getByTestId('login-setup-submit-btn'));
await waitFor(() =>
expect(auth.setupAdmin).toHaveBeenCalledWith('admin@x.test', 'TestPassword1!')
);
});
it('renders an SSO sign-in link when an OIDC provider is configured', async () => {
m(auth.getOidcProviders).mockResolvedValue({
enabled: true,
authorize_endpoint: 'https://idp.test/auth',
provider_name: 'Acme SSO',
password_login_enabled: true
});
render(LoginPage);
const sso = await screen.findByTestId('login-oidc-btn');
expect(sso.getAttribute('href')).toBe('https://idp.test/auth');
});