/** * Development/Test fixture seeding. Idempotent: running it twice must not * duplicate anything. Never run against production data. * * Fixture matrix (documented in apps/web/e2e/README.md): * fixture-admin active, Site Admin * fixture-user active, regular account * fixture-editor active, regular account (a second non-admin for the * collab permission packs: reader/editor of another's pond) * fixture-viewer active, regular account, never a member (for the * `authenticated`/`public` access-rule cases, issue #55) * fixture-pending registered but e-mail not verified * * All fixture accounts share the password below — they exist only on * dev machines and disposable CI/Test databases. On shared stages * (test/int), set FIXTURE_ADMIN_PASSWORD / FIXTURE_USER_PASSWORD to give * those two accounts non-public passwords. * * Content fixtures (issue #32): a shared pond owned by fixture-user with * two pages — "Every Element" (every editor schema node/mark, #24, loaded * from the checked-in `fixtures/content-page.yjs`; regenerate via * `pnpm --filter @dorfteich/api fixtures:regenerate` after editing * `fixtures/content-page.md`) and "Fixture Image" (one real, servable * uploaded image) — for the content regression pack and manual QA. */ import { readFileSync } from 'node:fs'; import { mkdir, writeFile } from 'node:fs/promises'; import { join } from 'node:path'; import { docToHtml, docToMarkdown, docToPlainText, editorSchema, extractOutline, slugify, } from '@dorfteich/shared'; import { Prisma, PrismaClient, UserStatus } from '@prisma/client'; import { generateKeyBetween } from 'fractional-indexing'; import { prosemirrorJSONToYXmlFragment, yXmlFragmentToProseMirrorRootNode } from 'y-prosemirror'; import * as Y from 'yjs'; import { Node as ProseMirrorNode } from 'prosemirror-model'; import { hashPassword } from '../src/users/password'; export const FIXTURE_PASSWORD = 'fixture passwort 123'; const PASSWORD_OVERRIDES: Record = { 'fixture-admin': process.env.FIXTURE_ADMIN_PASSWORD, 'fixture-user': process.env.FIXTURE_USER_PASSWORD, }; const prisma = new PrismaClient(); interface FixtureUser { username: string; displayName: string; status: UserStatus; isSiteAdmin: boolean; } const FIXTURES: FixtureUser[] = [ { username: 'fixture-admin', displayName: 'Fixture Admin', status: 'ACTIVE', isSiteAdmin: true }, { username: 'fixture-user', displayName: 'Fixture User', status: 'ACTIVE', isSiteAdmin: false }, { username: 'fixture-editor', displayName: 'Fixture Editor', status: 'ACTIVE', isSiteAdmin: false, }, { // A signed-in, non-admin, non-member account: for `authenticated`/`public` // access-rule cases (issue #55) where the viewer must be no one's member. username: 'fixture-viewer', displayName: 'Fixture Viewer', status: 'ACTIVE', isSiteAdmin: false, }, { // The "foreign user" of the permission matrix (issue #60): signed in, but // a member of nothing — should see 404 everywhere. username: 'fixture-outsider', displayName: 'Fixture Outsider', status: 'ACTIVE', isSiteAdmin: false, }, { username: 'fixture-pending', displayName: 'Fixture Pending', status: 'PENDING_VERIFICATION', isSiteAdmin: false, }, ]; /** * Every pond needs its owner's Pond Admin grant — access is decided solely * by role_grants from M5 on (issue #52). Idempotent, matching the * owner_admin_grants migration backfill. */ async function ensureOwnerAdminGrant(pondId: string, ownerId: string): Promise { const existing = await prisma.roleGrant.findFirst({ where: { pondId, subjectType: 'USER', subjectId: ownerId, role: 'POND_ADMIN', scopeType: 'POND', }, select: { id: true }, }); if (existing) return; await prisma.roleGrant.create({ data: { pondId, subjectType: 'USER', subjectId: ownerId, role: 'POND_ADMIN', scopeType: 'POND', scopeId: null, effect: 'ALLOW', createdBy: ownerId, }, }); } async function upsertFixtureUser(fixture: FixtureUser): Promise { const email = `${fixture.username}@dorfteich.test`; const user = await prisma.user.upsert({ where: { username: fixture.username }, create: { username: fixture.username, email, displayName: fixture.displayName, locale: 'de', status: fixture.status, isSiteAdmin: fixture.isSiteAdmin, emailVerifiedAt: fixture.status === 'ACTIVE' ? new Date() : null, }, update: { status: fixture.status, isSiteAdmin: fixture.isSiteAdmin, }, }); // Re-hash on every run so a changed override takes effect on re-seed. const credential = await hashPassword(PASSWORD_OVERRIDES[fixture.username] ?? FIXTURE_PASSWORD); await prisma.userIdentity.upsert({ where: { provider_subject: { provider: 'password', subject: user.id } }, create: { userId: user.id, provider: 'password', subject: user.id, credential, }, update: { credential }, }); // Active accounts get their personal pond, mirroring what e-mail // verification does for real signups (issue #21). if (fixture.status === 'ACTIVE') { const existing = await prisma.pond.findFirst({ where: { ownerId: user.id, type: 'PERSONAL' }, select: { id: true }, }); const pondId = existing?.id ?? ( await prisma.pond.create({ data: { slug: slugify(fixture.displayName) || fixture.username, name: fixture.displayName, type: 'PERSONAL', ownerId: user.id, }, }) ).id; // Also for a pre-existing pond: a dev database shared with test suites can // lose the owner grant to a cleanup — re-seeding must heal it (the seed's // documented contract is "idempotent", not "first run only"). await ensureOwnerAdminGrant(pondId, user.id); // The instance default for additional_ponds is 0 (ADR 0011) — give // the fixtures headroom so pond flows are exercisable in dev/e2e. await prisma.quotaOverride.upsert({ where: { subjectType_subjectId_quotaKey: { subjectType: 'USER', subjectId: user.id, quotaKey: 'additional_ponds', }, }, create: { subjectType: 'USER', subjectId: user.id, quotaKey: 'additional_ponds', value: 100, }, update: { value: 100 }, }); } return user.id; } interface DerivedContent { plainText: string; markdown: string; html: string; outline: Prisma.InputJsonValue; } function deriveContentOf(doc: ProseMirrorNode): DerivedContent { return { plainText: docToPlainText(doc), markdown: docToMarkdown(doc), html: docToHtml(doc), outline: extractOutline(doc) as unknown as Prisma.InputJsonValue, }; } function docFromYjsState(state: Uint8Array): ProseMirrorNode { const ydoc = new Y.Doc(); Y.applyUpdate(ydoc, state); const doc = yXmlFragmentToProseMirrorRootNode(ydoc.getXmlFragment('default'), editorSchema); ydoc.destroy(); return doc; } async function upsertFixturePage( pondId: string, slug: string, title: string, ownerId: string, state: Uint8Array, content: DerivedContent, ): Promise { const existing = await prisma.page.findFirst({ where: { pondId, slug }, select: { id: true } }); if (existing) { await prisma.page.update({ where: { id: existing.id }, data: { ydocState: state, contentCache: { upsert: { create: content, update: content } }, }, }); return existing.id; } const last = await prisma.page.findFirst({ where: { pondId }, orderBy: { sortKey: 'desc' }, select: { sortKey: true }, }); const page = await prisma.page.create({ data: { pondId, title, slug, sortKey: generateKeyBetween(last?.sortKey ?? null, null), ydocState: state, createdBy: ownerId, contentCache: { create: content }, }, }); return page.id; } const FIXTURE_POND_SLUG = 'content-fixtures'; // 1x1 transparent PNG — only the magic bytes matter for a real, servable // (if visually trivial) fixture image. const FIXTURE_IMAGE_PNG_BASE64 = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII='; const FIXTURE_IMAGE_ID = '00000000-0000-4000-8000-000000000001'; /** Shared content pond + fixture pages (issue #32). */ async function seedContentFixtures(ownerId: string): Promise { let pond = await prisma.pond.findUnique({ where: { slug: FIXTURE_POND_SLUG } }); if (!pond) { pond = await prisma.pond.create({ data: { slug: FIXTURE_POND_SLUG, name: 'Content Fixtures', type: 'SHARED', ownerId, }, }); } await ensureOwnerAdminGrant(pond.id, ownerId); await prisma.quotaOverride.upsert({ where: { subjectType_subjectId_quotaKey: { subjectType: 'POND', subjectId: pond.id, quotaKey: 'storage_bytes', }, }, create: { subjectType: 'POND', subjectId: pond.id, quotaKey: 'storage_bytes', value: 100 * 1024 * 1024, }, update: { value: 100 * 1024 * 1024 }, }); // "Every Element": the checked-in Yjs snapshot regenerated from // fixtures/content-page.md — covers every editor schema node/mark (#24). const everyElementState = readFileSync(join(__dirname, 'fixtures/content-page.yjs')); const everyElementDoc = docFromYjsState(everyElementState); await upsertFixturePage( pond.id, 'every-element', 'Every Element', ownerId, new Uint8Array(everyElementState), deriveContentOf(everyElementDoc), ); // "Classified Note" (issue #206, ADR 0022): a VS-NfD-marked page so e2e // (a11y pack) can assert the marking banner in both themes. Kept simple — // the marking, not the content, is what the fixture exists for. const classifiedDoc = editorSchema.node('doc', null, [ editorSchema.node('heading', { level: 1 }, [editorSchema.text('Classified Note')]), editorSchema.node('paragraph', null, [ editorSchema.text('This fixture page carries the VS-NfD marking.'), ]), ]); const classifiedYdoc = new Y.Doc(); prosemirrorJSONToYXmlFragment( editorSchema, classifiedDoc.toJSON(), classifiedYdoc.getXmlFragment('default'), ); const classifiedState = new Uint8Array(Y.encodeStateAsUpdate(classifiedYdoc)); classifiedYdoc.destroy(); const classifiedPageId = await upsertFixturePage( pond.id, 'classified-note', 'Classified Note', ownerId, classifiedState, deriveContentOf(classifiedDoc), ); await prisma.page.update({ where: { id: classifiedPageId }, data: { classification: 'VS_NFD' }, }); // "Fixture Image": one real, servable uploaded image (the Markdown // fixture above only carries a placeholder fileId for round-trip // testing — this is the one that actually resolves via /media/:fileId). const uploadsDir = process.env.UPLOADS_DIR ?? './data/uploads'; const imageBytes = Buffer.from(FIXTURE_IMAGE_PNG_BASE64, 'base64'); await mkdir(join(uploadsDir, pond.id), { recursive: true }); await writeFile(join(uploadsDir, pond.id, FIXTURE_IMAGE_ID), imageBytes); await prisma.attachment.upsert({ where: { id: FIXTURE_IMAGE_ID }, create: { id: FIXTURE_IMAGE_ID, pondId: pond.id, fileName: 'fixture.png', mimeType: 'image/png', sizeBytes: imageBytes.length, storagePath: `${pond.id}/${FIXTURE_IMAGE_ID}`, uploadedBy: ownerId, }, update: {}, }); await prisma.pondUsage.upsert({ where: { pondId: pond.id }, create: { pondId: pond.id, storageBytesUsed: imageBytes.length }, update: {}, }); const imageDoc = editorSchema.node('doc', null, [ editorSchema.node('heading', { level: 1 }, [editorSchema.text('Fixture Image')]), editorSchema.node('paragraph', null, [ editorSchema.node('image', { fileId: FIXTURE_IMAGE_ID, alt: 'A tiny fixture image', width: null, }), ]), ]); const imageYdoc = new Y.Doc(); prosemirrorJSONToYXmlFragment( editorSchema, imageDoc.toJSON(), imageYdoc.getXmlFragment('default'), ); const imageState = new Uint8Array(Y.encodeStateAsUpdate(imageYdoc)); imageYdoc.destroy(); const imagePageId = await upsertFixturePage( pond.id, 'fixture-image', 'Fixture Image', ownerId, imageState, deriveContentOf(imageDoc), ); await prisma.attachment.update({ where: { id: FIXTURE_IMAGE_ID }, data: { pageId: imagePageId }, }); } async function main(): Promise { // Fresh rate-limit budget for e2e runs — seed targets are always // disposable dev/CI databases, never production. await prisma.rateLimit.deleteMany({}); let contentOwnerId: string | undefined; for (const fixture of FIXTURES) { const userId = await upsertFixtureUser(fixture); if (fixture.username === 'fixture-user') contentOwnerId = userId; } if (contentOwnerId) await seedContentFixtures(contentOwnerId); // Seeded environments are configured by definition: mark the first-run // setup wizard (issue #80) as completed so e2e stacks and stages never // hit the setup gate. Never overwrite an existing (real) completion. const setupMarker = await prisma.instanceSetting.findUnique({ where: { key: 'setup.completedAt' }, }); if (!setupMarker) { await prisma.instanceSetting.create({ data: { key: 'setup.completedAt', value: new Date().toISOString() }, }); } await prisma.instanceSetting.upsert({ where: { key: 'seed.marker' }, create: { key: 'seed.marker', value: { seededAt: new Date().toISOString() } }, update: { value: { seededAt: new Date().toISOString() } }, }); console.log(`seed: done (${FIXTURES.length} fixture users)`); } main() .catch((error) => { console.error(error); process.exitCode = 1; }) .finally(() => prisma.$disconnect());