docs(quick-260911-gwh): Fehlerrichtung fuer Bereiche favorites und settings messen und aufschreiben

- runFavoritesAreaChecks (8 Pruefungen) und runSettingsAreaChecks (9 Pruefungen)
  erweitern rls-scratch-check.mjs auf 137 bestandene Pruefungen
- FavoriteLink-Wegwerftabelle traegt den Fremdschluessel auf WidgetInstance
  (Befund C, WINDOWS #27), SmtpConfig-Wegwerftabelle den Eindeutigkeitsindex
- Ergebnis Pruefung 7: der Fremdschluessel prueft am Zeilenschutz vorbei
  (steuert den Besitzriegel in Aufgabe 2); Pruefung 8: ungebundenes upsert
  wirft PrismaClientUnknownRequestError, dieselbe Klasse wie 260910-krx
- docs/mandantentrennung-etappe2-fehlerrichtung.md: Abschnitte
  ## Bereich favorites (f1-f5), ## Bereich settings (s1-s5) und
  ## Etappe 2 -- Abschluss; Nachtraege unter Befund K in (t4) und im
  Uebergaben-Absatz von (d4) -- die Reihenfolgebedingung ist erfuellt

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 13:47:02 +02:00
parent 2a27d96bca
commit 88896d3b43
2 changed files with 1019 additions and 0 deletions
+577
View File
@@ -3776,6 +3776,581 @@ async function runAuthAreaChecks(adminUrl, scratchRoleUrl, results) {
}
}
/**
* Aufgabe 1 (260911-gwh) — misst die acht im Plan genannten Verhaltensweisen
* des Bereichs `favorites` unter der Rolle ohne BYPASSRLS, an der Regel
* WORTGLEICH aus der ausgelieferten Migration
* `20260909140000_rls_remaining_tenant_tables` geschnitten — NICHT dem
* Werkzeug nachgetippt (vgl. runCalendarAreaChecks/runDashboardAreaChecks).
*
* Legt die Wegwerf-Tabelle "FavoriteLink" mit SAEMTLICHEN skalaren Spalten
* des Modells an (readSchemaModelScalarFieldNames('FavoriteLink') filtert
* das Relationsfeld `widgetInstance` heraus, sonst misst Pruefung 2 die
* falsche Menge) und traegt DEN Fremdschluessel auf die von
* runDashboardAreaChecks() bereits angelegte Tabelle "WidgetInstance"
* (Befund C, WINDOWS #27: eine Relation ist fuer die Bestandsaufnahme
* unsichtbar — hier deshalb ausdruecklich mitgebaut und gemessen, nicht nur
* behauptet). Muss deshalb NACH runDashboardAreaChecks() laufen. Setzt auf
* keiner Tabelle eines spaeteren Abschnitts auf: er ist ein Blatt in der
* Aufrufkette, muss NACH runAuthAreaChecks() und VOR
* runTransactionShapeMeasurement() laufen (siehe Aufrufkette in main()).
*/
async function runFavoritesAreaChecks(adminUrl, scratchRoleUrl, results) {
const widenMigrationSql = readRlsWidenMigrationSql();
const widenHasOwnFavoriteLinkPolicy =
widenMigrationSql && Boolean(extractPolicySql(widenMigrationSql, 'FavoriteLink'));
report(
results,
'favoritelink-regelstand-eindeutig',
!widenHasOwnFavoriteLinkPolicy,
widenHasOwnFavoriteLinkPolicy
? 'die *_rls_widen_membership_grant_and_platform_read-Migration (260910-jab) enthaelt eine EIGENE Regel fuer "FavoriteLink" — 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 "FavoriteLink" — der Stand aus 20260909140000_rls_remaining_tenant_tables ist weiterhin der ausgelieferte, aktuelle Regelstand',
);
if (widenHasOwnFavoriteLinkPolicy) {
return;
}
const remainingMigrationSql = readRemainingTenantTablesMigrationSql();
const favoriteLinkPolicy = remainingMigrationSql
? extractPolicySql(remainingMigrationSql, 'FavoriteLink')
: null;
if (!favoriteLinkPolicy) {
report(
results,
'favoritelink-policy-aus-migration-gefunden',
false,
'CREATE POLICY fuer "FavoriteLink" nicht in der ausgelieferten *_rls_remaining_tenant_tables-Migration gefunden',
);
return;
}
// Befund C: vorher ueber die Wartungsrolle MESSEN, welche "WidgetInstance"-
// Zeilen stehen — nicht annehmen. runDashboardAreaChecks() legt diese
// Tabelle bereits mit drei Zeilen an (widget-a1/user-a1/TENANT-A,
// widget-a2/user-a2/TENANT-A, widget-b1/user-b1/TENANT-B).
const widgetInstanceRows = await withAdminPrisma(
urlForDatabase(adminUrl, SCRATCH_DB_NAME).toString(),
async (db) => {
const rows = await db.$queryRaw`SELECT id, "userId", "tenantId" FROM "WidgetInstance" ORDER BY id`;
return rows;
},
);
await withAdminPrisma(urlForDatabase(adminUrl, SCRATCH_DB_NAME).toString(), async (db) => {
// updatedAt bekommt DEFAULT CURRENT_TIMESTAMP als vermerkte Abweichung
// (Prisma setzt den Wert clientseitig, das schmale INSERT unten braucht
// trotzdem einen Wert) — Form von 260911-e2s/fh9.
await db.$executeRawUnsafe(`
CREATE TABLE "FavoriteLink" (
id text PRIMARY KEY,
"userId" text NOT NULL,
"tenantId" text NOT NULL,
"widgetId" text NOT NULL,
title text NOT NULL,
url text NOT NULL,
"iconUrl" text,
position integer NOT NULL DEFAULT 0,
"createdAt" timestamp(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" timestamp(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "FavoriteLink_widgetId_fkey" FOREIGN KEY ("widgetId") REFERENCES "WidgetInstance"("id") ON DELETE CASCADE
);
`);
await db.$executeRawUnsafe(`ALTER TABLE "FavoriteLink" ENABLE ROW LEVEL SECURITY;`);
await db.$executeRawUnsafe(`ALTER TABLE "FavoriteLink" FORCE ROW LEVEL SECURITY;`);
await db.$executeRawUnsafe(favoriteLinkPolicy);
await db.$executeRawUnsafe(
`GRANT SELECT, INSERT, UPDATE, DELETE ON "FavoriteLink" TO ${SCRATCH_ROLE_NAME}`,
);
// fav-a1-1/fav-a1-2 gehoeren user-a1 (TENANT-A, widget-a1); fav-a2-1
// gehoert dem KOLLEGEN user-a2 (TENANT-A, widget-a2, Befund G-Form);
// fav-b1-1 liegt unter TENANT-B (widget-b1). fav-a1-1 traegt eine
// iconUrl, fav-a1-2 nicht (Pitfall 3 des Bereichs).
await db.$executeRawUnsafe(`
INSERT INTO "FavoriteLink" (id, "userId", "tenantId", "widgetId", title, url, "iconUrl", position) VALUES
('fav-a1-1', 'user-a1', 'TENANT-A', 'widget-a1', 'Favorit A1-1', 'https://example.invalid/a1-1', 'https://icons.invalid/a1-1.png', 0),
('fav-a1-2', 'user-a1', 'TENANT-A', 'widget-a1', 'Favorit A1-2', 'https://example.invalid/a1-2', NULL, 1),
('fav-a2-1', 'user-a2', 'TENANT-A', 'widget-a2', 'Favorit A2-1', 'https://example.invalid/a2-1', NULL, 0),
('fav-b1-1', 'user-b1', 'TENANT-B', 'widget-b1', 'Favorit B1-1', 'https://example.invalid/b1-1', NULL, 0);
`);
});
// Pruefung 2 zuerst — faellt sie durch, sind die Client-Messungen (3-8)
// wertlos, deshalb steht sie vor ihnen und die Funktion bricht ab, wenn
// sie fehlschlaegt (Lehre aus Pruefung 8 im Bereich `calendar`).
const schemaFields = readSchemaModelScalarFieldNames('FavoriteLink');
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 = 'FavoriteLink'
`;
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,
'favoritelink-wegwerftabelle-deckt-alle-spalten-des-generierten-clients',
columnsMatch,
`Schema-Felder aus schema.prisma (model FavoriteLink, skalare Felder ohne Relation, ${schemaFieldsSorted.length}): ${JSON.stringify(schemaFieldsSorted)}; Spalten der Wegwerf-Tabelle (${tableColumns.length}): ${JSON.stringify(tableColumns)}; gemessene "WidgetInstance"-Zeilen (Befund C, von runDashboardAreaChecks angelegt): ${JSON.stringify(widgetInstanceRows)}`,
);
if (!columnsMatch) {
return;
}
const prisma = new PrismaClient({ datasourceUrl: scratchRoleUrl });
try {
// 3: favoritelink-liste-generierter-client-ungebunden-liefert-leere-liste
// — die tragende Belegzeile dieses Abschnitts.
const unboundList = await prisma.favoriteLink.findMany({
where: { userId: 'user-a1', widgetId: 'widget-a1' },
orderBy: [{ position: 'asc' }, { title: 'asc' }],
});
report(
results,
'favoritelink-liste-generierter-client-ungebunden-liefert-leere-liste',
unboundList.length === 0,
`ungebundenes prisma.favoriteLink.findMany({ where: { userId: 'user-a1', widgetId: 'widget-a1' }, orderBy: [{ position: 'asc' }, { title: 'asc' }] }) (die Form von list) liefert ${unboundList.length} Zeile(n), obwohl 2 tatsaechlich vorhanden sind — das ist der Wert, aus dem favorites-widget.tsx "Noch keine Favoriten." macht`,
);
// 4: favoritelink-liste-generierter-client-gebunden-eigener-mandant-liefert-eigene-zeilen
// — zwei Aussagen in einer Messung: die eigenen Zeilen kommen, UND die
// Regel kennt keine Benutzerdimension (die Zeile des Kollegen ist ueber
// ein gebundenes findMany auf DESSEN widgetId ebenfalls sichtbar).
const bound = buildInlineExtendedClient(prisma, 'TENANT-A');
const boundOwnList = await bound.favoriteLink.findMany({
where: { userId: 'user-a1', widgetId: 'widget-a1' },
orderBy: [{ position: 'asc' }, { title: 'asc' }],
});
const ownListOk =
boundOwnList.length === 2 &&
boundOwnList.every((r) => r.userId === 'user-a1' && r.widgetId === 'widget-a1');
const boundColleagueList = await bound.favoriteLink.findMany({ where: { widgetId: 'widget-a2' } });
const colleagueVisible =
boundColleagueList.length === 1 && boundColleagueList[0].userId === 'user-a2';
report(
results,
'favoritelink-liste-generierter-client-gebunden-eigener-mandant-liefert-eigene-zeilen',
ownListOk && colleagueVisible,
`bound.favoriteLink.findMany unter TENANT-A liefert fuer (userId='user-a1', widgetId='widget-a1') ${boundOwnList.length} Zeile(n): ${JSON.stringify(boundOwnList.map((r) => r.id))} — die Zeile von user-a2 fehlt (anwendungsseitige Benutzerfilterung); ein gebundenes findMany({ where: { widgetId: 'widget-a2' } }) unter DEMSELBEN Mandanten liefert dagegen ${boundColleagueList.length} Zeile(n) des Kollegen user-a2 (${JSON.stringify(boundColleagueList.map((r) => r.id))}) — die Regel auf "FavoriteLink" kennt keine Benutzerdimension (dieselbe Lehre wie calendarsource-fremder-nutzer-desselben-mandanten-gebunden-sichtbar), die anwendungsseitige userId-Filterung bleibt deshalb der einzige Schutz gegen Quer-Lesen zwischen Nutzern DESSELBEN Mandanten (Etappe-3-Entscheidung (2))`,
);
// 5: favoritelink-besitzpruefung-generierter-client-gebunden-fremder-mandant-liefert-null
// — die Datenbankseite der Vorpruefung in update/remove/getIconBytes (T-GWH-02).
const boundB = buildInlineExtendedClient(prisma, 'TENANT-B');
const foreignFind = await boundB.favoriteLink.findUnique({ where: { id: 'fav-a1-1' } });
report(
results,
'favoritelink-besitzpruefung-generierter-client-gebunden-fremder-mandant-liefert-null',
foreignFind === null,
`bound.favoriteLink.findUnique({ where: { id: 'fav-a1-1' } }) unter TENANT-B (die Zeile gehoert TENANT-A) liefert ${JSON.stringify(foreignFind)} — das ist die Datenbankseite der Vorpruefung in update/remove/getIconBytes (T-GWH-02): fremde Zeile -> findUnique liefert null -> NotFoundException`,
);
// 6: favoritelink-gebundenes-loeschen-ueber-kennung-allein-fremder-mandant-scheitert-laut
// — der generierte Client meldet null getroffene Zeilen bei delete anders
// als Roh-SQL (dkv Befund G in der Client-Form): Konstruktorname/code
// woertlich, das Ergebnis wird nicht vorweggenommen.
let foreignDeleteThrew = false;
let foreignDeleteDetail = '';
try {
await boundB.favoriteLink.delete({ where: { id: 'fav-a1-1' } });
foreignDeleteDetail =
'bound.favoriteLink.delete unter TENANT-B auf die unter TENANT-A liegende Zeile fav-a1-1 ist NICHT fehlgeschlagen';
} catch (err) {
foreignDeleteThrew = true;
const ctor = err?.constructor?.name ?? 'unbekannt';
foreignDeleteDetail = `bound.favoriteLink.delete unter TENANT-B auf die unter TENANT-A liegende, fuer TENANT-B unsichtbare Zeile fav-a1-1 wirft ${ctor}${err?.code ? ` (code ${err.code})` : ''}: ${(err.message ?? '').toString().trim()}`;
}
const stillThereAfterForeignDelete = await withAdminPrisma(
urlForDatabase(adminUrl, SCRATCH_DB_NAME).toString(),
async (db) => {
const rows = await db.$queryRaw`SELECT id FROM "FavoriteLink" WHERE id = 'fav-a1-1'`;
return rows.length === 1;
},
);
report(
results,
'favoritelink-gebundenes-loeschen-ueber-kennung-allein-fremder-mandant-scheitert-laut',
foreignDeleteThrew && stillThereAfterForeignDelete,
`${foreignDeleteDetail} — die Wartungsrolle liest die Zeile danach noch: ${stillThereAfterForeignDelete}`,
);
// 7: favoritelink-fremdschluessel-prueft-am-zeilenschutz-vorbei — MISST,
// ob der Fremdschluessel auf "WidgetInstance" die Zeilenschutz-Regel
// dieser Tabelle umgeht (dokumentiertes PostgreSQL-Verhalten:
// referentielle Integritaet prueft AN der Regel vorbei). Das Ergebnis
// steuert Aufgabe 2 (Befund F), es wird NICHT vorweggenommen. Zweite
// Haelfte derselben Pruefung: ein gebundenes create mit einer
// WIRKLICH fehlenden widgetId MUSS an der FK-Verletzung scheitern — der
// Unterschied zwischen beiden Antworten ist das Existenzorakel (T-GWH-05).
const widgetB1UnderA = await bound.widgetInstance.findUnique({
where: { id: 'widget-b1' },
select: { userId: true },
});
let foreignWidgetCreateSucceeded = false;
let foreignWidgetCreateDetail = '';
try {
const created = await bound.favoriteLink.create({
data: {
id: 'fav-a1-fremdes-widget',
userId: 'user-a1',
tenantId: 'TENANT-A',
widgetId: 'widget-b1',
title: 'Fremdes Widget',
url: 'https://example.invalid/fremd',
position: 0,
},
});
foreignWidgetCreateSucceeded = Boolean(created);
foreignWidgetCreateDetail = `bound.favoriteLink.create unter TENANT-A mit widgetId='widget-b1' (gehoert TENANT-B, unter TENANT-A per gebundenem widgetInstance.findUnique unsichtbar: ${JSON.stringify(widgetB1UnderA)}) GELINGT (id=${created?.id}) — der Fremdschluessel prueft am Zeilenschutz VORBEI (dokumentiertes PostgreSQL-Verhalten)`;
// Die Zeile ist ein Messartefakt, nicht Teil des Bestands fuer die
// folgenden Pruefungen — ueber die Wartungsrolle wieder entfernen.
await withAdminPrisma(urlForDatabase(adminUrl, SCRATCH_DB_NAME).toString(), (db) =>
db.$executeRawUnsafe(`DELETE FROM "FavoriteLink" WHERE id = 'fav-a1-fremdes-widget'`),
);
} catch (err) {
const ctor = err?.constructor?.name ?? 'unbekannt';
foreignWidgetCreateDetail = `bound.favoriteLink.create unter TENANT-A mit widgetId='widget-b1' (unter TENANT-A per gebundenem widgetInstance.findUnique unsichtbar: ${JSON.stringify(widgetB1UnderA)}) scheitert mit ${ctor}${err?.code ? ` (code ${err.code})` : ''}: ${(err.message ?? '').toString().trim()}`;
}
let missingWidgetCreateRejected = false;
let missingWidgetCreateDetail = '';
try {
await bound.favoriteLink.create({
data: {
id: 'fav-a1-widget-fehlt',
userId: 'user-a1',
tenantId: 'TENANT-A',
widgetId: 'widget-gibt-es-nicht',
title: 'Widget fehlt',
url: 'https://example.invalid/fehlt',
position: 0,
},
});
missingWidgetCreateDetail =
'bound.favoriteLink.create unter TENANT-A mit widgetId="widget-gibt-es-nicht" ist NICHT fehlgeschlagen';
} catch (err) {
missingWidgetCreateRejected = true;
const ctor = err?.constructor?.name ?? 'unbekannt';
missingWidgetCreateDetail = `bound.favoriteLink.create unter TENANT-A mit widgetId="widget-gibt-es-nicht" scheitert mit ${ctor}${err?.code ? ` (code ${err.code})` : ''}: ${(err.message ?? '').toString().trim()} — die FK-Verletzung, das Gegenstueck zum Gelingen oben`;
}
report(
results,
'favoritelink-fremdschluessel-prueft-am-zeilenschutz-vorbei',
missingWidgetCreateRejected,
`${foreignWidgetCreateDetail}; ${missingWidgetCreateDetail} — der Unterschied zwischen beiden Antworten ("gibt es nicht" scheitert, "liegt bei fremdem Mandanten" gelingt${foreignWidgetCreateSucceeded ? '' : ' NICHT, gemessen statt angenommen'}) ist das Existenzorakel (T-GWH-05); das Ergebnis der ersten Haelfte (Gelingen: ${foreignWidgetCreateSucceeded}) steuert, ob Aufgabe 2 einen Besitzriegel in create() baut`,
);
// 8: favoritelink-gebundenes-anlegen-eigener-mandant-gelingt
let ownCreateSucceeded = false;
let ownCreateDetail = '';
try {
const created = await bound.favoriteLink.create({
data: {
id: 'fav-a1-neu',
userId: 'user-a1',
tenantId: 'TENANT-A',
widgetId: 'widget-a1',
title: 'Neu angelegter Favorit',
url: 'https://example.invalid/neu',
position: 2,
},
});
const readBack = await withAdminPrisma(
urlForDatabase(adminUrl, SCRATCH_DB_NAME).toString(),
async (db) =>
db.$queryRaw`SELECT "tenantId", "createdAt", "updatedAt" FROM "FavoriteLink" WHERE id = 'fav-a1-neu'`,
);
ownCreateSucceeded =
readBack.length === 1 &&
readBack[0].tenantId === 'TENANT-A' &&
readBack[0].createdAt != null &&
readBack[0].updatedAt != null;
ownCreateDetail = `bound.favoriteLink.create unter TENANT-A mit widgetId='widget-a1' gelingt (id=${created.id}); die Wartungsrolle liest danach tenantId=${JSON.stringify(readBack[0]?.tenantId)}, createdAt=${JSON.stringify(readBack[0]?.createdAt)}, updatedAt=${JSON.stringify(readBack[0]?.updatedAt)} — bestaetigt nebenbei, dass die Wegwerf-Tabelle die clientseitig erzeugten Werte annimmt`;
} catch (err) {
ownCreateDetail = `bound.favoriteLink.create unter TENANT-A mit widgetId='widget-a1' ist fehlgeschlagen: ${err.message}`;
}
report(
results,
'favoritelink-gebundenes-anlegen-eigener-mandant-gelingt',
ownCreateSucceeded,
ownCreateDetail,
);
} finally {
await prisma.$disconnect();
}
}
/**
* Aufgabe 1 (260911-gwh) — misst die neun im Plan genannten Verhaltensweisen
* des Bereichs `settings` unter der Rolle ohne BYPASSRLS, an der Regel
* WORTGLEICH aus der ausgelieferten Migration
* `20260909140000_rls_remaining_tenant_tables` geschnitten. Legt die
* Wegwerf-Tabelle "SmtpConfig" mit SAEMTLICHEN skalaren Spalten des Modells
* an UND mit dem Eindeutigkeitsindex `SmtpConfig_tenantId_key` WORTGLEICH
* aus `20260629130000_add_missing_tables` — ohne diesen Index misst
* Pruefung 8 nichts (der Konfliktweg braucht den physischen Index, nicht
* nur die Regel). Ein Blatt wie `runFavoritesAreaChecks`: muss NACH
* runAuthAreaChecks() und VOR runTransactionShapeMeasurement() laufen.
*/
async function runSettingsAreaChecks(adminUrl, scratchRoleUrl, results) {
const widenMigrationSql = readRlsWidenMigrationSql();
const widenHasOwnSmtpConfigPolicy =
widenMigrationSql && Boolean(extractPolicySql(widenMigrationSql, 'SmtpConfig'));
report(
results,
'smtpconfig-regelstand-eindeutig',
!widenHasOwnSmtpConfigPolicy,
widenHasOwnSmtpConfigPolicy
? 'die *_rls_widen_membership_grant_and_platform_read-Migration (260910-jab) enthaelt eine EIGENE Regel fuer "SmtpConfig" — 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 "SmtpConfig" — der Stand aus 20260909140000_rls_remaining_tenant_tables ist weiterhin der ausgelieferte, aktuelle Regelstand',
);
if (widenHasOwnSmtpConfigPolicy) {
return;
}
const remainingMigrationSql = readRemainingTenantTablesMigrationSql();
const smtpConfigPolicy = remainingMigrationSql
? extractPolicySql(remainingMigrationSql, 'SmtpConfig')
: null;
if (!smtpConfigPolicy) {
report(
results,
'smtpconfig-policy-aus-migration-gefunden',
false,
'CREATE POLICY fuer "SmtpConfig" 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 "SmtpConfig" (
id text PRIMARY KEY,
"tenantId" text NOT NULL,
host text NOT NULL,
port integer NOT NULL DEFAULT 587,
encryption text NOT NULL DEFAULT 'starttls',
username text,
"encryptedPassword" text,
"fromAddress" text NOT NULL,
"createdAt" timestamp(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" timestamp(3) NOT NULL DEFAULT CURRENT_TIMESTAMP
);
`);
// WORTGLEICH aus 20260629130000_add_missing_tables — ohne diesen Index
// misst Pruefung 8 nichts.
await db.$executeRawUnsafe(
`CREATE UNIQUE INDEX "SmtpConfig_tenantId_key" ON "SmtpConfig"("tenantId");`,
);
await db.$executeRawUnsafe(`ALTER TABLE "SmtpConfig" ENABLE ROW LEVEL SECURITY;`);
await db.$executeRawUnsafe(`ALTER TABLE "SmtpConfig" FORCE ROW LEVEL SECURITY;`);
await db.$executeRawUnsafe(smtpConfigPolicy);
await db.$executeRawUnsafe(
`GRANT SELECT, INSERT, UPDATE, DELETE ON "SmtpConfig" TO ${SCRATCH_ROLE_NAME}`,
);
await db.$executeRawUnsafe(`
INSERT INTO "SmtpConfig" (id, "tenantId", host, "encryptedPassword", "fromAddress") VALUES
('smtp-a', 'TENANT-A', 'smtp-a.example.invalid', 'enc(a-passwort-platzhalter)', 'a@example.invalid'),
('smtp-b', 'TENANT-B', 'smtp-b.example.invalid', 'enc(b-passwort-platzhalter)', 'b@example.invalid');
`);
});
// Pruefung 2 zuerst — dieselbe Reihenfolgeregel wie bei `favorites`.
const schemaFields = readSchemaModelScalarFieldNames('SmtpConfig');
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 = 'SmtpConfig'
`;
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]);
const indexRows = await withAdminPrisma(
urlForDatabase(adminUrl, SCRATCH_DB_NAME).toString(),
async (db) => {
const rows = await db.$queryRaw`
SELECT indexdef FROM pg_indexes WHERE tablename = 'SmtpConfig' AND indexname = 'SmtpConfig_tenantId_key'
`;
return rows;
},
);
const indexOk = indexRows.length === 1;
report(
results,
'smtpconfig-wegwerftabelle-deckt-alle-spalten-des-generierten-clients',
columnsMatch && indexOk,
`Schema-Felder aus schema.prisma (model SmtpConfig, skalare Felder ohne Relation, ${schemaFieldsSorted.length}): ${JSON.stringify(schemaFieldsSorted)}; Spalten der Wegwerf-Tabelle (${tableColumns.length}): ${JSON.stringify(tableColumns)}; Eindeutigkeitsindex "SmtpConfig_tenantId_key" ueber pg_indexes: ${JSON.stringify(indexRows.map((r) => r.indexdef))}`,
);
if (!columnsMatch || !indexOk) {
return;
}
const prisma = new PrismaClient({ datasourceUrl: scratchRoleUrl });
try {
// 3: smtpconfig-startpfad-generierter-client-ungebunden-liefert-null —
// die Form von loadAnySmtpConfigForStartupTransport(), ohne jede Bedingung.
const unboundStartup = await prisma.smtpConfig.findFirst();
report(
results,
'smtpconfig-startpfad-generierter-client-ungebunden-liefert-null',
unboundStartup === null,
`ungebundenes prisma.smtpConfig.findFirst() (die Form von loadAnySmtpConfigForStartupTransport) liefert ${JSON.stringify(unboundStartup)}, obwohl 2 Zeilen existieren — das ist der Wert, mit dem mail.module.ts nach dem Scharfschalten auf Umgebungsvariablen und zuletzt localhost:1025 zurueckfaellt, ein falscher Transport statt einer Meldung`,
);
// 4: smtpconfig-startpfad-ueber-wartungsrolle-zieht-beliebige-zeile —
// die HEUTIGE Lage: dieselbe Abfrage ueber die Wartungsrolle liefert
// eine beliebige, aber vorhandene Zeile.
const adminStartupRow = await withAdminPrisma(
urlForDatabase(adminUrl, SCRATCH_DB_NAME).toString(),
async (db) => {
const rows = await db.$queryRaw`SELECT "tenantId" FROM "SmtpConfig" LIMIT 1`;
return rows[0];
},
);
report(
results,
'smtpconfig-startpfad-ueber-wartungsrolle-zieht-beliebige-zeile',
Boolean(adminStartupRow),
`dieselbe Abfrage (SELECT ... LIMIT 1 ohne jede Bedingung) ueber die Wartungsrolle liefert genau EINE Zeile, tenantId=${JSON.stringify(adminStartupRow?.tenantId)} — nichts in der Abfrage bestimmt, WELCHER Mandant gezogen wird, und dessen Server und Absender tragen ab Start alle Kennwort-Zuruecksetzungs-Mails ALLER Mandanten (T-GWH-03)`,
);
// 5: smtpconfig-versandpfad-generierter-client-ungebunden-liefert-null —
// Befund K, der Versandpfad.
const unboundSendPath = await prisma.smtpConfig.findUnique({ where: { tenantId: 'TENANT-A' } });
report(
results,
'smtpconfig-versandpfad-generierter-client-ungebunden-liefert-null',
unboundSendPath === null,
`ungebundenes prisma.smtpConfig.findUnique({ where: { tenantId: 'TENANT-A' } }) (die Form von getDecryptedSmtpConfig) liefert ${JSON.stringify(unboundSendPath)}, waehrend die Wartungsrolle die Zeile liest — Befund K: tender-mail.service.ts protokolliert "No SMTP configuration" und ueberspringt, dkv-mail.service.ts wirft; kein Versand fuer niemanden`,
);
// 6: smtpconfig-versandpfad-generierter-client-gebunden-eigener-mandant-liefert-zugangsdaten
const boundA = buildInlineExtendedClient(prisma, 'TENANT-A');
const boundOwn = await boundA.smtpConfig.findUnique({ where: { tenantId: 'TENANT-A' } });
report(
results,
'smtpconfig-versandpfad-generierter-client-gebunden-eigener-mandant-liefert-zugangsdaten',
Boolean(boundOwn) &&
boundOwn.encryptedPassword === 'enc(a-passwort-platzhalter)' &&
boundOwn.host === 'smtp-a.example.invalid' &&
boundOwn.fromAddress === 'a@example.invalid',
`gebunden unter TENANT-A liefert findUnique({ where: { tenantId: 'TENANT-A' } }): host=${JSON.stringify(boundOwn?.host)}, fromAddress=${JSON.stringify(boundOwn?.fromAddress)}, encryptedPassword=${JSON.stringify(boundOwn?.encryptedPassword)}`,
);
// 7: smtpconfig-versandpfad-generierter-client-gebunden-fremder-mandant-liefert-null
// — T-GWH-01.
const boundB = buildInlineExtendedClient(prisma, 'TENANT-B');
const foreignSend = await boundB.smtpConfig.findUnique({ where: { tenantId: 'TENANT-A' } });
report(
results,
'smtpconfig-versandpfad-generierter-client-gebunden-fremder-mandant-liefert-null',
foreignSend === null,
`gebunden unter TENANT-B liefert findUnique({ where: { tenantId: 'TENANT-A' } }) (gehoert TENANT-A): ${JSON.stringify(foreignSend)} — die verschluesselten Zugangsdaten von A sind fuer B unsichtbar (T-GWH-01)`,
);
// 8: smtpconfig-ungebundenes-upsert-auf-unsichtbare-zeile-scheitert-laut
// — die Form von saveSmtpConfig, UNGEBUNDEN. Konstruktorname und code
// woertlich, das Ergebnis wird NICHT vorweggenommen; die Belegausgabe
// haelt daneben, was 260910-krx fuer DashboardLayout gemessen hat.
let conflictThrew = false;
let conflictCtor = 'unbekannt';
let conflictCode;
let conflictMessage = '';
try {
await prisma.smtpConfig.upsert({
where: { tenantId: 'TENANT-A' },
create: {
id: 'smtp-a-neu',
tenantId: 'TENANT-A',
host: 'smtp-a-neu.example.invalid',
fromAddress: 'a@example.invalid',
},
update: { host: 'smtp-a-neu.example.invalid' },
});
} catch (err) {
conflictThrew = true;
conflictCtor = err?.constructor?.name ?? 'unbekannt';
conflictCode = err?.code;
conflictMessage = (err.message ?? '').toString().trim();
}
const hostAfterConflict = await withAdminPrisma(
urlForDatabase(adminUrl, SCRATCH_DB_NAME).toString(),
async (db) => {
const rows = await db.$queryRaw`SELECT host FROM "SmtpConfig" WHERE id = 'smtp-a'`;
return rows[0]?.host;
},
);
report(
results,
'smtpconfig-ungebundenes-upsert-auf-unsichtbare-zeile-scheitert-laut',
conflictThrew && hostAfterConflict === 'smtp-a.example.invalid',
`ungebundenes prisma.smtpConfig.upsert({ where: { tenantId: 'TENANT-A' }, ... }) (die Form von saveSmtpConfig) wirft ${conflictCtor}${conflictCode ? ` (code ${conflictCode})` : ''}: ${conflictMessage} — zum Vergleich: 260910-krx mass fuer DashboardLayout unter dieser Form PrismaClientUnknownRequestError; die Wartungsrolle liest danach weiterhin host=${JSON.stringify(hostAfterConflict)}`,
);
// 9: smtpconfig-gebundenes-upsert-eigener-mandant-aktualisiert
let boundUpsertSucceeded = false;
let boundUpsertDetail = '';
try {
await boundA.smtpConfig.upsert({
where: { tenantId: 'TENANT-A' },
create: {
id: 'smtp-a-neu-2',
tenantId: 'TENANT-A',
host: 'smtp-a-gebunden-neu.example.invalid',
fromAddress: 'a@example.invalid',
},
update: { host: 'smtp-a-gebunden-neu.example.invalid' },
});
const afterBoundUpsert = await withAdminPrisma(
urlForDatabase(adminUrl, SCRATCH_DB_NAME).toString(),
async (db) => {
const rows = await db.$queryRaw`SELECT id, host, "updatedAt" FROM "SmtpConfig" WHERE "tenantId" = 'TENANT-A'`;
return rows[0];
},
);
const bUnchanged = await withAdminPrisma(
urlForDatabase(adminUrl, SCRATCH_DB_NAME).toString(),
async (db) => {
const rows = await db.$queryRaw`SELECT host FROM "SmtpConfig" WHERE id = 'smtp-b'`;
return rows[0]?.host;
},
);
boundUpsertSucceeded =
afterBoundUpsert?.id === 'smtp-a' &&
afterBoundUpsert?.host === 'smtp-a-gebunden-neu.example.invalid' &&
afterBoundUpsert?.updatedAt != null &&
bUnchanged === 'smtp-b.example.invalid';
boundUpsertDetail = `gebundenes upsert unter TENANT-A trifft die eigene Zeile (id=${afterBoundUpsert?.id}), die Wartungsrolle liest danach host=${JSON.stringify(afterBoundUpsert?.host)}, updatedAt=${JSON.stringify(afterBoundUpsert?.updatedAt)}; smtp-b bleibt unveraendert: ${JSON.stringify(bUnchanged)}`;
} catch (err) {
boundUpsertDetail = `gebundenes upsert unter TENANT-A ist fehlgeschlagen: ${err.message}`;
}
report(
results,
'smtpconfig-gebundenes-upsert-eigener-mandant-aktualisiert',
boundUpsertSucceeded,
boundUpsertDetail,
);
} finally {
await prisma.$disconnect();
}
}
/**
* Aufgabe 1 (260909-jts), TEIL 2 — misst, welche der drei Transaktionsformen
* den Mandantenkontext auf DERSELBEN Verbindung ueber alle Teilschritte
@@ -4015,6 +4590,8 @@ async function main() {
await runCalendarAreaChecks(adminUrl, scratchRoleUrlString, results);
await runTenantAreaChecks(adminUrl, scratchRoleUrlString, results);
await runAuthAreaChecks(adminUrl, scratchRoleUrlString, results);
await runFavoritesAreaChecks(adminUrl, scratchRoleUrlString, results);
await runSettingsAreaChecks(adminUrl, scratchRoleUrlString, results);
await runTransactionShapeMeasurement(scratchRoleUrlString, results);
await runConcurrencyProbe(scratchRoleUrlString, results);
} finally {