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
68 lines
2.7 KiB
TypeScript
68 lines
2.7 KiB
TypeScript
import { expect, test, type Page } from '@playwright/test';
|
|
import { login as loginAs, logout } from './helpers';
|
|
|
|
// Permission boundary in the browser, against the restricted corpus
|
|
// document "Wissenssicherung: Werner Krause" (visibility: restricted,
|
|
// granted to Engineering only). Pablo (Engineering) sees it, Max (Sales)
|
|
// must not — in the list, in the detail view, and in chat sources.
|
|
|
|
test.setTimeout(120_000);
|
|
|
|
async function login(page: Page, email: string) {
|
|
await loginAs(page, email);
|
|
}
|
|
|
|
test('restricted corpus document stays invisible across list, detail and chat', async ({
|
|
page
|
|
}) => {
|
|
// Pablo (Engineering, has the grant) can see it — and we grab the id.
|
|
await login(page, 'pablo@pablan.dev');
|
|
const fromApi = (await (await page.request.get('/api/documents?search=Wissenssicherung')).json())
|
|
.items;
|
|
expect(fromApi.length).toBeGreaterThan(0);
|
|
const restrictedId = fromApi[0].id;
|
|
|
|
await page.goto('/documents');
|
|
await page.locator('body[data-hydrated]').waitFor();
|
|
await page.getByTestId('document-search').fill('Wissenssicherung');
|
|
await expect(page.getByTestId('document-list')).toContainText('Wissenssicherung: Werner Krause', {
|
|
timeout: 10_000
|
|
});
|
|
await logout(page);
|
|
|
|
// Max (Sales): list is empty, detail 404s, chat cites nothing restricted.
|
|
await login(page, 'max@pablan.dev');
|
|
await page.goto('/documents');
|
|
await page.locator('body[data-hydrated]').waitFor();
|
|
await page.getByTestId('document-search').fill('Wissenssicherung Werner Krause');
|
|
// Search ranks by meaning, so it returns related documents rather than
|
|
// nothing — what matters is that the restricted one is never among them.
|
|
await expect(
|
|
page.getByTestId('document-list').or(page.getByText('No documents match'))
|
|
).toBeVisible({
|
|
timeout: 15_000
|
|
});
|
|
await expect(page.locator('body')).not.toContainText('Wissenssicherung: Werner Krause');
|
|
|
|
await page.goto(`/documents/${restrictedId}`);
|
|
await expect(page.getByText('Document not found')).toBeVisible();
|
|
|
|
await page.getByTestId('sidebar-new-conversation').click();
|
|
await page
|
|
.getByTestId('chat-input')
|
|
.fill('Welcher Servotec-Techniker kennt die F-350 am besten?');
|
|
await page.getByTestId('send-button').click();
|
|
await expect(page.getByTestId('send-button')).toBeVisible({ timeout: 60_000 });
|
|
const sources = page.getByTestId('sources');
|
|
if ((await sources.count()) > 0) {
|
|
await expect(sources.last()).not.toContainText('Wissenssicherung');
|
|
}
|
|
|
|
// Cleanup: ben's chat question created a conversation; drop it + session.
|
|
const conversations = await (await page.request.get('/api/conversations')).json();
|
|
if (conversations.length > 0) {
|
|
await page.request.delete(`/api/conversations/${conversations[0].id}`);
|
|
}
|
|
await page.request.post('/api/auth/logout');
|
|
});
|