feat(quick-260911-cwh): Fehlerrichtung Bereich calendar messen (Aufgabe 1)

- rls-scratch-check.mjs: elfter Abschnitt runCalendarAreaChecks mit 13
  namentlich benannten Pruefungen gegen die aus 20260909140000_rls_remaining_tenant_tables
  geschnittene Regel, davon 4 ueber den generierten Client an einer
  schemagleichen Wegwerf-Tabelle (17 Spalten, gegen schema.prisma
  laufzeitgeprueft); Laufzeitpruefung, dass 20260910120000 keine eigene
  CalendarSource-Regel traegt (Messfalle 260910-jab)
- Alle 101 Pruefungen bestehen (88 bisherige + 13 neue)
- docs/mandantentrennung-etappe2-fehlerrichtung.md: Abschnitt
  "## Bereich calendar" mit (k1)-(k5) — Messung, Signaltabelle je Pfad,
  Leere-als-Abwesenheit in Backend UND Frontend samt Fehlerverschluckung,
  bewusst nicht geloest (Cache-Schluessel-Urteil, Zugangsdaten-Erhaltung,
  fehlende Benutzerdimension, 403/404), bewusst nicht angefasst
- Wettlauf-Fehlerklasse gemessen: PrismaClientKnownRequestError (P2025),
  NICHT PrismaClientUnknownRequestError wie im Bereich dashboard — Aufgabe
  2 braucht deshalb keine neue Fehleruebersetzung fuer die Besitzpruefungen

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AMASaSxv5QMY7RncqZriRR
This commit is contained in:
2026-09-11 09:48:44 +02:00
parent 508d9e4301
commit bf5fc4d4c7
2 changed files with 591 additions and 0 deletions
+332
View File
@@ -42,6 +42,7 @@ const SCRATCH_ROLE_NAME = 'tessera_rls_scratch_role';
const SCRATCH_ROLE_PASSWORD = 'scratch_only_local_never_reused';
const MIGRATIONS_DIR = join(__dirname, '../prisma/migrations');
const PRISMA_BIN = join(__dirname, '../node_modules/.bin/prisma');
const SCHEMA_PRISMA_PATH = join(__dirname, '../prisma/schema.prisma');
/**
* Fuehrt ein mehrteiliges SQL-Skript (mehrere Anweisungen, DO $$ ... $$
@@ -2765,6 +2766,336 @@ async function runDashboardAreaChecks(adminUrl, scratchRoleUrl, results) {
}
}
/**
* Liest die Feldnamen eines Prisma-Modellblocks direkt aus
* `apps/api/prisma/schema.prisma`, statt sie im Werkzeug zu wiederholen
* (Pruefung 8 in `runCalendarAreaChecks`, Lehre aus Pruefung 5b im Bereich
* `dashboard`: der generierte Client waehlt standardmaessig JEDE Spalte des
* Modells aus und scheitert mit P2022 an jeder fehlenden — eine
* Wegwerf-Tabelle mit unvollstaendigem Spaltensatz wuerde das nie zeigen,
* Roh-SQL merkt es ohnehin nie). Feldname = erstes Wort jeder nicht-leeren
* Zeile im Modellblock, die nicht mit `@@` (Modell-Attribute wie
* `@@index`) und nicht mit `//` (Kommentarzeile) beginnt.
*/
function readSchemaModelFieldNames(modelName) {
const schemaSource = readFileSync(SCHEMA_PRISMA_PATH, 'utf-8');
const re = new RegExp(`model ${modelName} \\{([\\s\\S]*?)\\n\\}`);
const match = schemaSource.match(re);
if (!match) return [];
const fields = [];
for (const rawLine of match[1].split('\n')) {
const line = rawLine.trim();
if (!line) continue;
if (line.startsWith('@@')) continue;
if (line.startsWith('//')) continue;
const firstWord = line.split(/\s+/)[0];
if (firstWord) fields.push(firstWord);
}
return fields;
}
/**
* Aufgabe 1 (260911-cwh) — misst die zwoelf im Plan genannten
* Verhaltensweisen des Bereichs `calendar` unter der Rolle ohne BYPASSRLS,
* an der Regel WORTGLEICH aus der ausgelieferten Migration
* `20260909140000_rls_remaining_tenant_tables` geschnitten — NICHT dem
* Werkzeug nachgetippt (vgl. runDkvAreaChecks/runDashboardAreaChecks).
*
* Prueft zur Laufzeit zusaetzlich die Messfalle aus 260910-jab: die
* `*_rls_widen_membership_grant_and_platform_read`-Migration (260910-jab)
* darf KEINE eigene Regel fuer "CalendarSource" enthalten (Befund G) —
* faende sich dort eine, waere der Regelstand nicht mehr eindeutig auf
* 20260909140000 zurueckzufuehren und dieser Abschnitt braeche ab, statt
* die abgeloeste Regel weiterzumessen.
*
* Legt die Wegwerf-Tabelle "CalendarSource" selbst neu an, mit SAEMTLICHEN
* Spalten des Modells (nicht nur denen, die Roh-SQL braucht — Pruefung 8,
* die dashboard-Lehre aus Pruefung 5b) und setzt auf keiner Tabelle eines
* anderen Abschnitts auf: er ist ein Blatt in der Aufrufkette, muss NACH
* runDashboardAreaChecks() und VOR runTransactionShapeMeasurement() laufen
* (siehe Aufrufkette in main()) — Letztere setzt weiterhin auf der von
* runGroupsAreaChecks() angelegten Tabelle "Group" auf, dieser Abschnitt
* aendert daran nichts, und keine spaetere Pruefung setzt auf der hier
* angelegten Tabelle auf.
*/
async function runCalendarAreaChecks(adminUrl, scratchRoleUrl, results) {
const widenMigrationSql = readRlsWidenMigrationSql();
const widenHasOwnCalendarSourcePolicy =
widenMigrationSql && Boolean(extractPolicySql(widenMigrationSql, 'CalendarSource'));
report(
results,
'calendarsource-regelstand-eindeutig',
!widenHasOwnCalendarSourcePolicy,
widenHasOwnCalendarSourcePolicy
? 'die *_rls_widen_membership_grant_and_platform_read-Migration (260910-jab) enthaelt eine EIGENE Regel fuer "CalendarSource" — der Regelstand ist nicht mehr eindeutig auf 20260909140000_rls_remaining_tenant_tables zurueckzufuehren, Messung abgebrochen statt die abgeloeste Regel weiterzumessen'
: 'die *_rls_widen_membership_grant_and_platform_read-Migration (260910-jab) enthaelt KEINE eigene Regel fuer "CalendarSource" (Befund G) — der Stand aus 20260909140000_rls_remaining_tenant_tables ist weiterhin der ausgelieferte, aktuelle Regelstand',
);
if (widenHasOwnCalendarSourcePolicy) {
return;
}
const remainingMigrationSql = readRemainingTenantTablesMigrationSql();
const calendarSourcePolicy = remainingMigrationSql
? extractPolicySql(remainingMigrationSql, 'CalendarSource')
: null;
if (!calendarSourcePolicy) {
report(
results,
'calendarsource-policy-aus-migration-gefunden',
false,
'CREATE POLICY fuer "CalendarSource" nicht in der ausgelieferten *_rls_remaining_tenant_tables-Migration gefunden',
);
return;
}
await withAdminPrisma(urlForDatabase(adminUrl, SCRATCH_DB_NAME).toString(), async (db) => {
await db.$executeRawUnsafe(`
CREATE TABLE "CalendarSource" (
id text PRIMARY KEY,
"userId" text NOT NULL,
"tenantId" text NOT NULL,
name text NOT NULL,
type text NOT NULL,
"exchangeMode" text,
domain text,
url text NOT NULL,
username text,
"encryptedPassword" text,
color text DEFAULT '#3B82F6',
"isVisible" boolean NOT NULL DEFAULT true,
"syncIntervalMin" integer NOT NULL DEFAULT 15,
"lastSyncAt" timestamp(3),
"lastSyncError" text,
"createdAt" timestamp(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" timestamp(3) NOT NULL DEFAULT CURRENT_TIMESTAMP
);
`);
await db.$executeRawUnsafe(`ALTER TABLE "CalendarSource" ENABLE ROW LEVEL SECURITY;`);
await db.$executeRawUnsafe(`ALTER TABLE "CalendarSource" FORCE ROW LEVEL SECURITY;`);
await db.$executeRawUnsafe(calendarSourcePolicy);
await db.$executeRawUnsafe(
`GRANT SELECT, INSERT, UPDATE, DELETE ON "CalendarSource" TO ${SCRATCH_ROLE_NAME}`,
);
// Zwei Zeilen unter TENANT-A mit VERSCHIEDENEN Benutzerkennungen
// (Befund G — die Regel kennt keine Benutzerdimension), je mit
// gesetztem encryptedPassword-Platzhalter, damit Pruefung 3 zeigen
// kann, dass ein Kollege desselben Mandanten die verschluesselten
// Zugangsdaten sieht; eine Zeile unter TENANT-B fuer die
// Mandantengrenze.
await db.$executeRawUnsafe(`
INSERT INTO "CalendarSource" (id, "userId", "tenantId", name, type, url, "encryptedPassword", "isVisible") VALUES
('source-a1', 'user-a1', 'TENANT-A', 'Quelle A1', 'ics', 'https://example.invalid/a1.ics', 'enc(a1-passwort-platzhalter)', true),
('source-a2', 'user-a2', 'TENANT-A', 'Quelle A2', 'ics', 'https://example.invalid/a2.ics', 'enc(a2-passwort-platzhalter)', true),
('source-b1', 'user-b1', 'TENANT-B', 'Quelle B1', 'ics', 'https://example.invalid/b1.ics', 'enc(b1-passwort-platzhalter)', true);
`);
});
// Pruefung 8 zuerst — faellt sie durch, sind die Client-Messungen (9-12)
// wertlos, deshalb steht sie vor ihnen und die Funktion bricht ab, wenn
// sie fehlschlaegt.
const schemaFields = readSchemaModelFieldNames('CalendarSource');
const tableColumns = await withAdminPrisma(
urlForDatabase(adminUrl, SCRATCH_DB_NAME).toString(),
async (db) => {
const rows = await db.$queryRaw`
SELECT column_name FROM information_schema.columns
WHERE table_schema = 'public' AND table_name = 'CalendarSource'
`;
return rows.map((r) => r.column_name).sort();
},
);
const schemaFieldsSorted = [...schemaFields].sort();
const columnsMatch =
schemaFieldsSorted.length > 0 &&
schemaFieldsSorted.length === tableColumns.length &&
schemaFieldsSorted.every((f, i) => f === tableColumns[i]);
report(
results,
'calendarsource-wegwerftabelle-deckt-alle-spalten-des-generierten-clients',
columnsMatch,
`Schema-Felder aus schema.prisma (model CalendarSource, ${schemaFieldsSorted.length}): ${JSON.stringify(schemaFieldsSorted)}; Spalten der Wegwerf-Tabelle (${tableColumns.length}): ${JSON.stringify(tableColumns)}`,
);
if (!columnsMatch) {
return;
}
const prisma = new PrismaClient({ datasourceUrl: scratchRoleUrl });
try {
// 1 + 3: calendarsource-gebunden-nur-eigener-mandant UND
// calendarsource-fremder-nutzer-desselben-mandanten-gebunden-sichtbar —
// eine Abfrage, zwei Aussagen. Pruefung 3 zu bestehen IST das erwartete
// Ergebnis: die Regel kennt keine Benutzerdimension.
const rowsForA = await forTenantQuery(prisma, 'TENANT-A', (tx) =>
tx.$queryRaw`SELECT id, "userId", "tenantId", "encryptedPassword" FROM "CalendarSource" ORDER BY id`,
);
report(
results,
'calendarsource-gebunden-nur-eigener-mandant',
rowsForA.length === 2 && rowsForA.every((r) => r.tenantId === 'TENANT-A'),
`forTenant(TENANT-A) liefert ${rowsForA.length} Zeile(n): ${JSON.stringify(rowsForA.map((r) => r.id))}`,
);
const a2Row = rowsForA.find((r) => r.userId === 'user-a2');
const a2CredentialsVisible = Boolean(a2Row && a2Row.encryptedPassword);
report(
results,
'calendarsource-fremder-nutzer-desselben-mandanten-gebunden-sichtbar',
a2CredentialsVisible,
`forTenant(TENANT-A) liefert die Quelle von 'user-a2' (anderer Benutzer, gleicher Mandant) mit encryptedPassword=${JSON.stringify(a2Row?.encryptedPassword)} — die Regel auf "CalendarSource" kennt keine Benutzerdimension, die verschluesselten Zugangsdaten eines Kollegen DESSELBEN Mandanten sind auf Datenbankebene lesbar; die anwendungsseitige Filterung ueber die Benutzerkennung bleibt deshalb der einzige Schutz, bis die Etappe-3-Entscheidung (2) die Benutzerdimension nachzieht`,
);
// 2: calendarsource-ungebunden-null-zeilen — die tragende Belegzeile.
const unboundRows = await prisma.$queryRaw`SELECT "tenantId" FROM "CalendarSource"`;
report(
results,
'calendarsource-ungebunden-null-zeilen',
unboundRows.length === 0,
`ungebundener SELECT auf "CalendarSource" liefert ${unboundRows.length} Zeile(n), tatsaechlich vorhanden sind 3`,
);
// 4: calendarsource-ungebundene-einzelabfrage-ueber-kennung-liefert-keine-zeile
// — die Datenbankseite der drei Besitzpruefungen (Befund I).
const unboundSingleRow = await prisma.$queryRaw`SELECT id FROM "CalendarSource" WHERE id = 'source-b1'`;
report(
results,
'calendarsource-ungebundene-einzelabfrage-ueber-kennung-liefert-keine-zeile',
unboundSingleRow.length === 0,
`ungebundenes SELECT ueber die Kennung 'source-b1' (vorhanden) liefert ${unboundSingleRow.length} Zeile(n) — die Datenbankseite der drei Besitzpruefungen: ein ungebundenes Nachschlagen ueber eine vorhandene Kennung liefert null Zeilen, das ist der Weg in NotFoundException`,
);
// 5: calendarsource-gebundenes-einfuegen-fremder-mandant-abgelehnt
let foreignInsertRejected = false;
let foreignInsertDetail = '';
try {
await forTenantQuery(
prisma,
'TENANT-A',
(tx) =>
tx.$executeRaw`INSERT INTO "CalendarSource" (id, "userId", "tenantId", name, type, url) VALUES ('source-rejected', 'user-a1', 'TENANT-B', 'Sollte abgewiesen werden', 'ics', 'https://example.invalid/rejected.ics')`,
);
foreignInsertDetail = 'gebundenes INSERT unter TENANT-A mit tenantId=TENANT-B ist NICHT fehlgeschlagen';
} catch (err) {
const sqlState = sqlStateOf(err);
foreignInsertRejected = sqlState === '42501';
foreignInsertDetail = `gebundenes INSERT unter TENANT-A mit tenantId=TENANT-B abgewiesen mit SQLSTATE ${sqlState ?? 'unbekannt'} (${err.message.trim()})`;
}
report(
results,
'calendarsource-gebundenes-einfuegen-fremder-mandant-abgelehnt',
foreignInsertRejected,
foreignInsertDetail,
);
// 6: calendarsource-gebundenes-loeschen-fremder-zeile-trifft-keine-zeile
const foreignDeleteAffected = await forTenantQuery(
prisma,
'TENANT-A',
(tx) => tx.$executeRaw`DELETE FROM "CalendarSource" WHERE id = 'source-b1'`,
);
const stillThereAfterDelete = await withAdminPrisma(
urlForDatabase(adminUrl, SCRATCH_DB_NAME).toString(),
async (db) => {
const rows = await db.$queryRaw`SELECT id FROM "CalendarSource" WHERE id = 'source-b1'`;
return rows.length === 1;
},
);
report(
results,
'calendarsource-gebundenes-loeschen-fremder-zeile-trifft-keine-zeile',
foreignDeleteAffected === 0 && stillThereAfterDelete,
`gebundenes DELETE unter TENANT-A ueber die Kennung 'source-b1' (gehoert TENANT-B) trifft ${foreignDeleteAffected} Zeile(n); ueber die Wartungsrolle ist die Zeile danach noch vorhanden: ${stillThereAfterDelete}`,
);
// 7: calendarsource-gebundenes-update-ueber-kennung-allein-trifft-null-zeilen
const foreignUpdateAffected = await forTenantQuery(
prisma,
'TENANT-A',
(tx) => tx.$executeRaw`UPDATE "CalendarSource" SET "lastSyncError" = 'x' WHERE id = 'source-b1'`,
);
report(
results,
'calendarsource-gebundenes-update-ueber-kennung-allein-trifft-null-zeilen',
foreignUpdateAffected === 0,
`gebundenes UPDATE ... WHERE id = 'source-b1' (gehoert TENANT-B) unter TENANT-A trifft ${foreignUpdateAffected} Zeile(n), lastSyncError bleibt unveraendert`,
);
// 9: calendarsource-generierter-client-gebundene-quellenliste-nur-eigener-mandant
const bound = buildInlineExtendedClient(prisma, 'TENANT-A');
const clientBoundSources = await bound.calendarSource.findMany({
where: { userId: 'user-a1', isVisible: true },
});
report(
results,
'calendarsource-generierter-client-gebundene-quellenliste-nur-eigener-mandant',
clientBoundSources.length === 1 && clientBoundSources[0].id === 'source-a1',
`bound.calendarSource.findMany({ where: { userId: 'user-a1', isVisible: true } }) unter TENANT-A liefert ${clientBoundSources.length} Zeile(n): ${JSON.stringify(clientBoundSources.map((s) => s.id))} — die Abfrage, die fetchAndCacheEvents stellt`,
);
// 10: calendarsource-generierter-client-ungebundene-quellenliste-null-zeilen
const clientUnboundSources = await prisma.calendarSource.findMany({
where: { userId: 'user-a1', isVisible: true },
});
report(
results,
'calendarsource-generierter-client-ungebundene-quellenliste-null-zeilen',
clientUnboundSources.length === 0,
`dieselbe Abfrage auf dem UNGEBUNDENEN generierten Client liefert ${clientUnboundSources.length} Zeile(n) ohne Fehler — exakt der Wert, den getSources als "keine Quelle" und fetchAndCacheEvents als "keine Termine" weiterreicht`,
);
// 11: calendarsource-generierter-client-gebundenes-update-auf-unsichtbare-zeile-scheitert-laut
let updateOnInvisibleThrew = false;
let updateOnInvisibleDetail = '';
try {
await bound.calendarSource.update({
where: { id: 'source-b1' },
data: { lastSyncError: 'x' },
});
updateOnInvisibleDetail =
'bound.calendarSource.update unter TENANT-A auf die unter TENANT-B unsichtbare Zeile (source-b1) ist NICHT fehlgeschlagen';
} catch (err) {
updateOnInvisibleThrew = true;
const ctor = err?.constructor?.name ?? 'unbekannt';
updateOnInvisibleDetail = `bound.calendarSource.update unter TENANT-A auf die unter TENANT-B unsichtbare Zeile (source-b1) wirft ${ctor}${err?.code ? ` (code ${err.code})` : ''}: ${(err.message ?? '').toString().trim()}`;
}
report(
results,
'calendarsource-generierter-client-gebundenes-update-auf-unsichtbare-zeile-scheitert-laut',
updateOnInvisibleThrew,
updateOnInvisibleDetail,
);
// 12: calendarsource-generierter-client-gebundenes-anlegen-eigener-mandant-gelingt
let createSucceeded = false;
let createDetail = '';
try {
const created = await bound.calendarSource.create({
data: {
userId: 'user-a1',
tenantId: 'TENANT-A',
name: 'Neu angelegte Quelle',
type: 'ics',
url: 'https://example.invalid/neu.ics',
},
});
const readBack = await bound.calendarSource.findMany({ where: { id: created.id } });
createSucceeded = readBack.length === 1;
createDetail = `bound.calendarSource.create unter TENANT-A gelingt (id=${created.id}, createdAt=${JSON.stringify(created.createdAt)}), gebunden lesbar: ${createSucceeded} — bestaetigt nebenbei, dass die Wegwerf-Tabelle die clientseitig erzeugten Werte (id, createdAt, updatedAt) annimmt`;
} catch (err) {
createDetail = `bound.calendarSource.create unter TENANT-A ist fehlgeschlagen: ${err.message}`;
}
report(
results,
'calendarsource-generierter-client-gebundenes-anlegen-eigener-mandant-gelingt',
createSucceeded,
createDetail,
);
} finally {
await prisma.$disconnect();
}
}
/**
* Aufgabe 1 (260909-jts), TEIL 2 — misst, welche der drei Transaktionsformen
* den Mandantenkontext auf DERSELBEN Verbindung ueber alle Teilschritte
@@ -3001,6 +3332,7 @@ async function main() {
await runUserAreaChecks(adminUrl, scratchRoleUrlString, results);
await runModuleRegistryAreaChecks(adminUrl, scratchRoleUrlString, results);
await runDashboardAreaChecks(adminUrl, scratchRoleUrlString, results);
await runCalendarAreaChecks(adminUrl, scratchRoleUrlString, results);
await runTransactionShapeMeasurement(scratchRoleUrlString, results);
await runConcurrencyProbe(scratchRoleUrlString, results);
} finally {