All checks were successful
CD / Build and push images (push) Successful in 2m54s
CI / Lint, typecheck, test (push) Successful in 2m25s
CI / Auth e2e pack (push) Successful in 2m58s
CI / Build container images (push) Has been skipped
CD / Deploy to Test (push) Successful in 9s
CD / Smoke tests against Test (push) Successful in 1m12s
CD / Promote to Int (push) Successful in 12s
Every route now declares its access rule explicitly and is enforced through the shared resolution algorithm (permissions.md): - PermissionGuard + decorators (@RequiresPondRole, @RequiresPagePermission, @RequiresAttachmentPermission, @AuthenticatedOnly) applied to every route; a route-enumeration test proves full coverage alongside @Public()/Site-Admin-guarded routes. - 404/403 policy (documented in README conventions): denied reads answer 404 (existence hiding), denied writes on readable things answer 403; trash views need write capability (ADR 0013). - PermissionService resolves page/pond questions via the shared resolver, with an in-process pond-context cache (grants + label parents) that is invalidated on every grant/label-tree change and TTL-bounded as a multi-process safety net. Grant changes also fire pond_access_changed for collab revalidation (#39/#53). - shared: pond-scope resolution (hasPondRole, canSeePond) next to the page resolver; grant wire schemas + GrantView. - Owner Pond-Admin grants: migration backfill for all existing ponds, created transactionally with every new pond (shared + personal + seed). - Grant CRUD under /ponds/:id/grants (pond_admin-gated) with structural and referential validation, last-admin protection, audit logs. - InterimAccessService deleted; page lists, search, backlinks, phantom links, and trash listings are filtered per page through the resolver; collab tokens are now truly ro for readers. - Fixture-matrix e2e (reader/editor/pond admin/foreign, label-deny, authenticated-subject, revoke-then-immediate-deny cache test). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PGdhRiwU1WRL4XxJfZYipY
366 lines
11 KiB
TypeScript
366 lines
11 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-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-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());
|