dorfteich/apps/api/prisma/seed.ts
Claude Fable 5 adceca7358
Some checks failed
CI / Lint, typecheck, test (pull_request) Successful in 6m24s
CI / Build container images (pull_request) Successful in 4m24s
CI / Auth e2e pack (pull_request) Successful in 8m44s
CI / Import/export fidelity gate (pull_request) Successful in 59s
CD / Build and push images (push) Successful in 26s
CD / Deploy to Test (push) Successful in 13s
CD / Smoke tests against Test (push) Successful in 1m30s
CD / Promote to Int (push) Successful in 11s
CI / Lint, typecheck, test (push) Successful in 6m10s
CI / Build container images (push) Has been skipped
CI / Auth e2e pack (push) Successful in 8m55s
CI / Import/export fidelity gate (push) Failing after 50s
#206: show the VS-NfD marking in web view header and footer
ClassificationBanner renders the fixed ADR-0022 wording above and below
the content in reading view, editor and public page view; unclassified
pages show nothing. Announced to assistive tech via a localized hidden
prefix (de+en); styled from the plain text token only, so contrast holds
in both themes and under every accent with no new color pair. Public
content endpoint now carries the classification. New seed fixture
classified-note; a11y pack asserts banner top+bottom and axe-clean in
light and dark.

Co-Authored-By: Claude Fable 5 (1M context) <noreply@anthropic.com>
2026-07-31 06:18:47 +02:00

438 lines
14 KiB
TypeScript

/**
* 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<string, string | undefined> = {
'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<void> {
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<string> {
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<string> {
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<void> {
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<void> {
// 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());