dorfteich/apps/api/prisma/seed.ts
Claude Opus 4.8 406886c56c
Some checks failed
CD / Build and push images (push) Successful in 3m5s
CI / Lint, typecheck, test (push) Successful in 2m31s
CI / Auth e2e pack (push) Failing after 2m0s
CI / Build container images (push) Has been skipped
CD / Deploy to Test (push) Successful in 9s
CD / Smoke tests against Test (push) Successful in 1m14s
CD / Promote to Int (push) Successful in 12s
Add label- and page-scope access rules UI including deny (#55)
Pond Admins configure the vision's fine-grained cases through a plain-language
surface, on top of the base roles from #54.

- shared: `AccessRuleView` (a grant enriched with subject/scope display names)
  and pure conflict helpers `scopeSpecificity`/`sameGrantSubject`/
  `isRuleShadowed` (unit-tested) for the client-side shadowed-rule hint. New
  `access` i18n namespace (de+en) with sentence templates (ADR 0012).
- api: `GET /ponds/:id/grants/access-rules` (Pond-Admin) returns the pond's
  grants enriched with each user's display name and each label/page scope's
  name, resolved in one batched query per kind.
- web `access/`: `AccessRulesManager` in Pond Settings — the pond's rules
  grouped by subject and rendered as readable de/en sentences ("Anna may not
  edit pages labeled “Confidential”"), an add form (subject = member or the
  `signed-in`/`public` pseudo-subjects; scope = label from the tree or a
  specific page; role; allow/deny) that warns when a rule would be shadowed by
  a more specific existing one (shared algorithm) and requires an explicit
  confirmation before granting anything to `public`. Semantics are the shared
  resolver's — the UI only reflects permissions.md.
- tests: shared `conflicts.test.ts`; an api db case for the enriched endpoint;
  a browser `access-rules` pack that configures BOTH vision patterns through
  the UI and verifies their effect end to end — "deny label X" (an editor
  loses a labelled page) and "only label Y" (a signed-in non-member, new
  `fixture-viewer`, reads only the labelled pages) — plus the shadow hint and
  the public confirmation, with its own CI step.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EwZ4jR4KFAPvpjWevfUGX1
2026-07-09 21:45:31 +02:00

384 lines
12 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,
},
{
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 },
});
if (!existing) {
const pond = await prisma.pond.create({
data: {
slug: slugify(fixture.displayName) || fixture.username,
name: fixture.displayName,
type: 'PERSONAL',
ownerId: user.id,
},
});
await ensureOwnerAdminGrant(pond.id, 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),
);
// "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);
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());