Self-hosted knowledge management for SMEs: a split-screen Markdown editor whose sections an LLM refines while you write, and RAG question answering over the documents that result. FastAPI + Postgres/pgvector on the back, SvelteKit on the front, everything OpenAI-compatible and self-hostable. Squashed into a single commit; the development history stays local. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CA43ZJda8Rbp2hKXNy8f6b
108 lines
4.6 KiB
TypeScript
108 lines
4.6 KiB
TypeScript
import { expect, test, type Page } from '@playwright/test';
|
|
import { login, logout } from './helpers';
|
|
|
|
// Uses the seeded dev users (make seed): pablo@pablan.dev / pablan-dev.
|
|
|
|
async function gotoHydrated(page: Page, path: string) {
|
|
await page.goto(path);
|
|
await page.locator('body[data-hydrated]').waitFor();
|
|
}
|
|
|
|
async function signInOnCurrentPage(page: Page, email: string) {
|
|
// Fills the login form already on screen — NO page.goto. A full
|
|
// navigation would reset the module singletons and hide the very leak
|
|
// this exercises. Wait for hydration first, so the client submit handler
|
|
// (which does the full-reload navigation) is wired up. Selectors are by
|
|
// attribute, not label: the logged-out login page is rendered in
|
|
// whatever language the previous user left, which is part of the point.
|
|
await page.locator('body[data-hydrated]').waitFor();
|
|
await page.locator('input[name="email"]').fill(email);
|
|
await page.locator('input[name="password"]').fill('pablan-dev');
|
|
await page.locator('input[name="password"]').press('Enter');
|
|
await expect(page).toHaveURL('/');
|
|
await page.locator('body[data-hydrated]').waitFor();
|
|
}
|
|
|
|
test('redirects anonymous visitors to the login page', async ({ page }) => {
|
|
await page.goto('/');
|
|
await expect(page).toHaveURL(/\/login$/);
|
|
await expect(page.getByRole('button', { name: 'Sign in' })).toBeVisible();
|
|
});
|
|
|
|
test('shows an error for wrong credentials', async ({ page }) => {
|
|
await gotoHydrated(page, '/login');
|
|
await page.getByLabel('Email').fill('pablo@pablan.dev');
|
|
await page.getByLabel('Password').fill('definitely-wrong');
|
|
await page.getByRole('button', { name: 'Sign in' }).click();
|
|
await expect(page.getByRole('alert')).toHaveText('Email or password is incorrect.');
|
|
await expect(page).toHaveURL(/\/login$/);
|
|
});
|
|
|
|
test('login and logout round-trip', async ({ page }) => {
|
|
await gotoHydrated(page, '/login');
|
|
await page.getByLabel('Email').fill('pablo@pablan.dev');
|
|
await page.getByLabel('Password').fill('pablan-dev');
|
|
await page.getByRole('button', { name: 'Sign in' }).click();
|
|
|
|
await expect(page).toHaveURL('/');
|
|
await expect(page.getByTestId('sidebar').getByText('Pablo')).toBeVisible();
|
|
// The landing greets by first name and invites capture.
|
|
await expect(page.getByRole('heading', { name: /Pablo/ })).toBeVisible();
|
|
|
|
await logout(page);
|
|
|
|
await page.goto('/');
|
|
await expect(page).toHaveURL(/\/login$/);
|
|
});
|
|
|
|
// Regression for the P0 session-state leak: switching users in one browser
|
|
// context must not carry the previous user's conversation titles (an
|
|
// information disclosure) or their interface language across the boundary.
|
|
// Deliberately drives the real in-app flow — logout button, then the login
|
|
// form on the page it lands on — with no page.goto between users, which is
|
|
// what masked this in the other specs.
|
|
const MARKER = 'Zebrafrage-Session-P0-Test';
|
|
let leakConversationId: string | null = null;
|
|
|
|
test.afterEach(async ({ page }) => {
|
|
// End whatever session is current (max's UI login is never logged out by
|
|
// the test itself), then re-auth as pablo to undo his residue.
|
|
await page.request.post('/api/auth/logout');
|
|
await page.request.post('/api/auth/login', {
|
|
data: { email: 'pablo@pablan.dev', password: 'pablan-dev' }
|
|
});
|
|
await page.request.put('/api/account/locale', { data: { locale: null } });
|
|
if (leakConversationId) {
|
|
await page.request.delete(`/api/conversations/${leakConversationId}`);
|
|
leakConversationId = null;
|
|
}
|
|
await page.request.post('/api/auth/logout');
|
|
});
|
|
|
|
test('a different user does not inherit the previous session state', async ({ page }) => {
|
|
// Pablo: German interface and one conversation with a recognizable title.
|
|
await login(page, 'pablo@pablan.dev');
|
|
await page.request.put('/api/account/locale', { data: { locale: 'de' } });
|
|
const created = await page.request.post('/api/conversations', { data: { mode: 'query' } });
|
|
leakConversationId = (await created.json()).id;
|
|
await page.request.post(`/api/conversations/${leakConversationId}/messages`, {
|
|
data: { content: MARKER }
|
|
});
|
|
|
|
// Reload (pablo → pablo) so the German setting and the new conversation
|
|
// are on screen before the switch.
|
|
await page.goto('/');
|
|
await page.locator('body[data-hydrated]').waitFor();
|
|
await expect(page.getByTestId('sidebar')).toContainText(MARKER);
|
|
await expect(page.locator('html')).toHaveAttribute('lang', 'de');
|
|
|
|
// Switch to Max through the UI, no page.goto.
|
|
await logout(page);
|
|
await page.locator('body[data-hydrated]').waitFor();
|
|
await signInOnCurrentPage(page, 'max@pablan.dev');
|
|
|
|
// Max sees only his own world.
|
|
await expect(page.getByTestId('sidebar')).not.toContainText(MARKER);
|
|
await expect(page.locator('html')).toHaveAttribute('lang', 'en');
|
|
});
|