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:
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user