Pablan, as it stands
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
This commit is contained in:
co-authored by
Claude Opus 5
parent
68d3a43191
commit
784b76baf7
@@ -0,0 +1,107 @@
|
||||
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');
|
||||
});
|
||||
Reference in New Issue
Block a user