feat(quick-260911-nke): Benutzer an 34 Aufrufstellen gesetzt, zehn Tabellen gemessen, sechs Pruefungen umgedreht

- 30 verbleibende forTenant()-Aufrufstellen in sieben Diensten (calendar 6,
  dashboard 9, favorites 5, tender-email-config 3, tender-notification-pref 2,
  tender-rss-feed 2, tender-triage 3) reichen userId als drittes Argument
  durch. tender-digest.scheduler.ts bleibt zweistellig (Hintergrunddienst,
  Etappe 3c), mit Begruendung im Kommentar. Keine Methodensignatur, kein
  Controller angefasst, keine anwendungsseitige userId-Filterung entfernt.
- rls-scratch-check.mjs: zwoelf Extraktionsstellen auf die neue Migration
  umgeleitet (TenderEmailConfig/TenderNotificationPref/TenderSavedSearch/
  TenderTriage/TenderRssFeedSource in runTendersAreaChecks, SearchProvider in
  runSearchProviderAreaChecks/runDashboardAreaChecks, DashboardLayout/
  WidgetInstance, CalendarSource/FavoriteLink samt regelstand-eindeutig-Gates).
  SearchProvider/TenderRssFeedSource jetzt mit extractAllPolicySql (4 Regeln).
  runUserDimensionChecks() um die uebrigen neun Tabellen erweitert (neue
  Routine runCommandSeparatedPersonalTableCheck fuer die zwei NULL-faehigen
  Tabellen inkl. gemeinsame-Zeile-Pruefungen).
- Sechs Loch-Pruefungen umgedreht (dashboardlayout, widgetinstance,
  searchprovider, calendarsource, favoritelink-Doppelaussage getrennt) —
  alte Messung ohne Benutzer bleibt unter neuem Namen, Umkehrung MIT
  Benutzer erwartet das Gegenteil; kein alter Name mehr als Kennung.
- Baseline: 1020/62 Tests weiterhin gruen, Typpruefung sauber, Werkzeug
  203/203 bestanden (vorher 146).

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 17:39:17 +02:00
parent f0b531b712
commit 07fc653f52
16 changed files with 785 additions and 108 deletions
+666 -62
View File
@@ -1056,28 +1056,28 @@ async function runGroupsAreaChecks(adminUrl, scratchRoleUrl, results) {
* Abschnitt aendert daran nichts.
*/
async function runTendersAreaChecks(adminUrl, scratchRoleUrl, results) {
const remainingMigrationSql = readRemainingTenantTablesMigrationSql();
const widenMigrationSql = readRlsWidenMigrationSql();
const userDimensionMigrationSql = readRlsUserDimensionMigrationSql();
const emailConfigPolicy = remainingMigrationSql
? extractPolicySql(remainingMigrationSql, 'TenderEmailConfig')
// Extraktionsstellen 975/978/981/987 (260911-nke, Aufgabe 2), 984 (Aufgabe
// 1): alle fuenf Tabellen dieses Bereichs werden ab der Benutzerdimension-
// Migration gelesen, nicht mehr aus *_rls_remaining_tenant_tables oder
// *_rls_widen_membership_grant_and_platform_read — sonst misst dieser
// Abschnitt die abgeloeste Regel ohne Benutzerdimension (Befund D,
// Praezedenz jab).
const emailConfigPolicy = userDimensionMigrationSql
? extractPolicySql(userDimensionMigrationSql, 'TenderEmailConfig')
: null;
const notificationPrefPolicy = remainingMigrationSql
? extractPolicySql(remainingMigrationSql, 'TenderNotificationPref')
const notificationPrefPolicy = userDimensionMigrationSql
? extractPolicySql(userDimensionMigrationSql, 'TenderNotificationPref')
: null;
const rssFeedPolicies = widenMigrationSql
? extractAllPolicySql(widenMigrationSql, 'TenderRssFeedSource')
const rssFeedPolicies = userDimensionMigrationSql
? extractAllPolicySql(userDimensionMigrationSql, 'TenderRssFeedSource')
: [];
// Extraktionsstelle 984 (260911-nke, Aufgabe 1): TenderSavedSearch wird
// ab der Benutzerdimension-Migration gelesen, nicht mehr aus
// *_rls_remaining_tenant_tables — sonst misst dieser Abschnitt die
// abgeloeste Regel ohne Benutzerdimension (Befund D, Praezedenz jab).
const savedSearchPolicy = userDimensionMigrationSql
? extractPolicySql(userDimensionMigrationSql, 'TenderSavedSearch')
: null;
const triagePolicy = remainingMigrationSql
? extractPolicySql(remainingMigrationSql, 'TenderTriage')
const triagePolicy = userDimensionMigrationSql
? extractPolicySql(userDimensionMigrationSql, 'TenderTriage')
: null;
if (
@@ -1091,7 +1091,7 @@ async function runTendersAreaChecks(adminUrl, scratchRoleUrl, results) {
results,
'tenders-policies-aus-migration-gefunden',
false,
`CREATE POLICY fuer "TenderEmailConfig", "TenderNotificationPref", "TenderSavedSearch" und/oder "TenderTriage" nicht in der ausgelieferten *_rls_remaining_tenant_tables-Migration gefunden, oder nicht genau vier Policies fuer "TenderRssFeedSource" in *_rls_widen_membership_grant_and_platform_read (gefunden: ${rssFeedPolicies.length})`,
`CREATE POLICY fuer "TenderEmailConfig", "TenderNotificationPref", "TenderSavedSearch" und/oder "TenderTriage" nicht in der Benutzerdimension-Migration (20260911120000) gefunden, oder nicht genau vier Policies fuer "TenderRssFeedSource" (gefunden: ${rssFeedPolicies.length})`,
);
return;
}
@@ -1489,17 +1489,23 @@ async function runTendersAreaChecks(adminUrl, scratchRoleUrl, results) {
* Aufrufkette ein Blatt.
*/
async function runSearchProviderAreaChecks(adminUrl, scratchRoleUrl, results) {
const remainingMigrationSql = readRemainingTenantTablesMigrationSql();
const searchProviderPolicy = remainingMigrationSql
? extractPolicySql(remainingMigrationSql, 'SearchProvider')
: null;
// Extraktionsstelle 1386 (260911-nke, Aufgabe 2): die urspruengliche
// Ein-Regel-Fassung aus *_rls_remaining_tenant_tables ist seit
// 20260911120000 abgeloest — SearchProvider traegt jetzt vier
// befehlsgetrennte Regeln (Benutzerdimension). extractAllPolicySql mit
// erwarteter Laenge 4, sonst misst dieser Abschnitt die abgeloeste Regel
// (Befund D, Praezedenz jab).
const userDimensionMigrationSql = readRlsUserDimensionMigrationSql();
const searchProviderPolicies = userDimensionMigrationSql
? extractAllPolicySql(userDimensionMigrationSql, 'SearchProvider')
: [];
if (!searchProviderPolicy) {
if (searchProviderPolicies.length !== 4) {
report(
results,
'searchprovider-policy-aus-migration-gefunden',
false,
'CREATE POLICY fuer "SearchProvider" nicht in der ausgelieferten *_rls_remaining_tenant_tables-Migration gefunden',
`nicht genau vier CREATE-POLICY-Anweisungen fuer "SearchProvider" in der Benutzerdimension-Migration (20260911120000) gefunden (gefunden: ${searchProviderPolicies.length})`,
);
return;
}
@@ -1515,7 +1521,9 @@ async function runSearchProviderAreaChecks(adminUrl, scratchRoleUrl, results) {
`);
await db.$executeRawUnsafe(`ALTER TABLE "SearchProvider" ENABLE ROW LEVEL SECURITY;`);
await db.$executeRawUnsafe(`ALTER TABLE "SearchProvider" FORCE ROW LEVEL SECURITY;`);
await db.$executeRawUnsafe(searchProviderPolicy);
for (const policySql of searchProviderPolicies) {
await db.$executeRawUnsafe(policySql);
}
await db.$executeRawUnsafe(
`GRANT SELECT, INSERT, UPDATE, DELETE ON "SearchProvider" TO ${SCRATCH_ROLE_NAME}`,
);
@@ -2542,24 +2550,29 @@ async function runModuleRegistryAreaChecks(adminUrl, scratchRoleUrl, results) {
* sie nicht stattfinden kann.
*/
async function runDashboardAreaChecks(adminUrl, scratchRoleUrl, results) {
const remainingMigrationSql = readRemainingTenantTablesMigrationSql();
// Extraktionsstellen 2430/2433 (260911-nke, Aufgabe 2): DashboardLayout
// und WidgetInstance werden ab der Benutzerdimension-Migration gelesen.
// Extraktionsstelle 2436: die SearchProvider-Praesenzpruefung hier ist nur
// ein Wortlaut-Beleg (die Tabelle selbst legt runSearchProviderAreaChecks
// an) — seit 20260911120000 vier befehlsgetrennte Regeln statt einer.
const userDimensionMigrationSql = readRlsUserDimensionMigrationSql();
const dashboardLayoutPolicy = remainingMigrationSql
? extractPolicySql(remainingMigrationSql, 'DashboardLayout')
const dashboardLayoutPolicy = userDimensionMigrationSql
? extractPolicySql(userDimensionMigrationSql, 'DashboardLayout')
: null;
const widgetInstancePolicy = remainingMigrationSql
? extractPolicySql(remainingMigrationSql, 'WidgetInstance')
: null;
const searchProviderPolicy = remainingMigrationSql
? extractPolicySql(remainingMigrationSql, 'SearchProvider')
const widgetInstancePolicy = userDimensionMigrationSql
? extractPolicySql(userDimensionMigrationSql, 'WidgetInstance')
: null;
const searchProviderPolicies = userDimensionMigrationSql
? extractAllPolicySql(userDimensionMigrationSql, 'SearchProvider')
: [];
if (!dashboardLayoutPolicy || !widgetInstancePolicy || !searchProviderPolicy) {
if (!dashboardLayoutPolicy || !widgetInstancePolicy || searchProviderPolicies.length !== 4) {
report(
results,
'dashboard-policies-aus-migration-gefunden',
false,
'CREATE POLICY fuer "DashboardLayout", "WidgetInstance" und/oder "SearchProvider" nicht in der ausgelieferten *_rls_remaining_tenant_tables-Migration gefunden',
`CREATE POLICY fuer "DashboardLayout"/"WidgetInstance" und/oder die vier Regeln fuer "SearchProvider" nicht in der Benutzerdimension-Migration (20260911120000) gefunden (SearchProvider-Regeln gefunden: ${searchProviderPolicies.length})`,
);
return;
}
@@ -2648,12 +2661,31 @@ async function runDashboardAreaChecks(adminUrl, scratchRoleUrl, results) {
layoutRowsForA.length === 2 && layoutRowsForA.every((r) => r.tenantId === 'TENANT-A'),
`forTenant(TENANT-A) liefert ${layoutRowsForA.length} Zeile(n): ${JSON.stringify(layoutRowsForA.map((r) => r.id))}`,
);
// Umgedreht (260911-nke, Aufgabe 2): die alte Pruefung
// "dashboardlayout-fremder-nutzer-desselben-mandanten-gebunden-sichtbar"
// (Befund G) mass OHNE Benutzer — nach der Migration 20260911120000 ist
// das GELINGEN weiterhin der Fall, jetzt als GEWOLLTE Eigenschaft der
// IS-NULL-Form. Die alte Messung bleibt unter neuem Namen bestehen; die
// Umkehrung misst MIT Benutzer und erwartet das GEGENTEIL.
const secondUserVisible = layoutRowsForA.some((r) => r.userId === 'user-a2');
report(
results,
'dashboardlayout-fremder-nutzer-desselben-mandanten-gebunden-sichtbar',
'dashboardlayout-ohne-benutzer-sieht-beide-nutzer-desselben-mandanten',
secondUserVisible,
`forTenant(TENANT-A) liefert die Anordnung von 'user-a2' (anderer Benutzer, gleicher Mandant) mit: ${secondUserVisible} — die Regel auf "DashboardLayout" kennt keine Benutzerdimension, die anwendungsseitige Pruefung ueber die Benutzerkennung bleibt deshalb der einzige Schutz gegen Quer-Lesen zwischen Nutzern DESSELBEN Mandanten`,
`bis 260911-nke als Befund G unter dem Namen dashboardlayout-fremder-nutzer-desselben-mandanten-gebunden-sichtbar gefuehrt — jetzt die gewollte Eigenschaft der IS-NULL-Form fuer Admin und Hintergrunddienst. forTenant(TENANT-A) OHNE Benutzer liefert die Anordnung von 'user-a2' (anderer Benutzer, gleicher Mandant) mit: ${secondUserVisible}`,
);
const layoutRowsForA1 = await forTenantQuery(
prisma,
'TENANT-A',
(tx) => tx.$queryRaw`SELECT id, "userId", "tenantId" FROM "DashboardLayout" ORDER BY id`,
'user-a1',
);
const secondUserVisibleGebunden = layoutRowsForA1.some((r) => r.userId === 'user-a2');
report(
results,
'dashboardlayout-benutzer-a-sieht-kollegen-nicht-gebunden',
!secondUserVisibleGebunden,
`forTenant(TENANT-A, user-a1) liefert die Anordnung von 'user-a2' mit: ${secondUserVisibleGebunden}`,
);
// 2: dashboardlayout-ungebunden-null-zeilen — die Belegzeile dieses
@@ -2685,12 +2717,28 @@ async function runDashboardAreaChecks(adminUrl, scratchRoleUrl, results) {
widgetRowsForA.length === 2 && widgetRowsForA.every((r) => r.tenantId === 'TENANT-A'),
`forTenant(TENANT-A) liefert ${widgetRowsForA.length} Zeile(n): ${JSON.stringify(widgetRowsForA.map((r) => r.id))}`,
);
// Umgedreht (260911-nke, Aufgabe 2): alte Pruefung
// "widgetinstance-fremder-nutzer-desselben-mandanten-gebunden-sichtbar"
// (Befund G) mass OHNE Benutzer; jetzt gewollte Eigenschaft.
const widgetSecondUserVisible = widgetRowsForA.some((r) => r.userId === 'user-a2');
report(
results,
'widgetinstance-fremder-nutzer-desselben-mandanten-gebunden-sichtbar',
'widgetinstance-ohne-benutzer-sieht-beide-nutzer-desselben-mandanten',
widgetSecondUserVisible,
`forTenant(TENANT-A) liefert das Widget von 'user-a2' (anderer Benutzer, gleicher Mandant) mit: ${widgetSecondUserVisible} — dieselbe fehlende Benutzerdimension wie bei "DashboardLayout" und "SearchProvider" (Befund G), der dritte der drei Faelle dieses Bereichs`,
`bis 260911-nke als Befund G unter dem Namen widgetinstance-fremder-nutzer-desselben-mandanten-gebunden-sichtbar gefuehrt — jetzt die gewollte Eigenschaft der IS-NULL-Form. forTenant(TENANT-A) OHNE Benutzer liefert das Widget von 'user-a2' mit: ${widgetSecondUserVisible}`,
);
const widgetRowsForA1 = await forTenantQuery(
prisma,
'TENANT-A',
(tx) => tx.$queryRaw`SELECT id, "userId", "tenantId" FROM "WidgetInstance" ORDER BY id`,
'user-a1',
);
const widgetSecondUserVisibleGebunden = widgetRowsForA1.some((r) => r.userId === 'user-a2');
report(
results,
'widgetinstance-benutzer-a-sieht-kollegen-nicht-gebunden',
!widgetSecondUserVisibleGebunden,
`forTenant(TENANT-A, user-a1) liefert das Widget von 'user-a2' mit: ${widgetSecondUserVisibleGebunden}`,
);
// 5: dashboardlayout-gebundener-konfliktschreibvorgang-auf-unsichtbare-
@@ -2842,14 +2890,33 @@ async function runDashboardAreaChecks(adminUrl, scratchRoleUrl, results) {
searchProviderRowsForA.every((r) => r.tenantId === 'TENANT-A'),
`forTenant(TENANT-A) liefert ${searchProviderRowsForA.length} Zeile(n) aus den neu hinzugefuegten: ${JSON.stringify(searchProviderRowsForA.map((r) => r.id))}`,
);
// Umgedreht (260911-nke, Aufgabe 2): alte Pruefung
// "searchprovider-fremder-nutzer-desselben-mandanten-gebunden-sichtbar"
// (Befund G) mass OHNE Benutzer; jetzt gewollte Eigenschaft.
const searchProviderSecondUserVisible = searchProviderRowsForA.some(
(r) => r.userId === 'user-a2',
);
report(
results,
'searchprovider-fremder-nutzer-desselben-mandanten-gebunden-sichtbar',
'searchprovider-ohne-benutzer-sieht-beide-nutzer-desselben-mandanten',
searchProviderSecondUserVisible,
`forTenant(TENANT-A) liefert die Suchmaschine von 'user-a2' (anderer Benutzer, gleicher Mandant) mit: ${searchProviderSecondUserVisible} — dieselbe fehlende Benutzerdimension wie bei "DashboardLayout" und "WidgetInstance" (Befund G)`,
`bis 260911-nke als Befund G unter dem Namen searchprovider-fremder-nutzer-desselben-mandanten-gebunden-sichtbar gefuehrt — jetzt die gewollte Eigenschaft der IS-NULL-Form. forTenant(TENANT-A) OHNE Benutzer liefert die Suchmaschine von 'user-a2' mit: ${searchProviderSecondUserVisible}`,
);
const searchProviderRowsForA1 = await forTenantQuery(
prisma,
'TENANT-A',
(tx) =>
tx.$queryRaw`SELECT id, "userId", "tenantId" FROM "SearchProvider" WHERE id IN ('search-a1', 'search-a2', 'search-b1') ORDER BY id`,
'user-a1',
);
const searchProviderSecondUserVisibleGebunden = searchProviderRowsForA1.some(
(r) => r.userId === 'user-a2',
);
report(
results,
'searchprovider-benutzer-a-sieht-kollegen-nicht-gebunden',
!searchProviderSecondUserVisibleGebunden,
`forTenant(TENANT-A, user-a1) liefert die Suchmaschine von 'user-a2' mit: ${searchProviderSecondUserVisibleGebunden}`,
);
// 12: searchprovider-gebundenes-einfuegen-ohne-mandant-abgelehnt — die
@@ -2975,21 +3042,29 @@ async function runCalendarAreaChecks(adminUrl, scratchRoleUrl, results) {
const widenMigrationSql = readRlsWidenMigrationSql();
const widenHasOwnCalendarSourcePolicy =
widenMigrationSql && Boolean(extractPolicySql(widenMigrationSql, 'CalendarSource'));
// 260911-nke, Aufgabe 2: die Benutzerdimension-Migration hat GENAU EINE
// eigene Regel fuer "CalendarSource" (DROP + CREATE unter demselben Namen)
// — widen hat weiterhin KEINE. Beide Bedingungen zusammen entscheiden,
// welche Migration den aktuellen Regelstand liefert.
const userDimensionMigrationSql = readRlsUserDimensionMigrationSql();
const userDimensionHasOwnCalendarSourcePolicy =
userDimensionMigrationSql && Boolean(extractPolicySql(userDimensionMigrationSql, 'CalendarSource'));
report(
results,
'calendarsource-regelstand-eindeutig',
!widenHasOwnCalendarSourcePolicy,
!widenHasOwnCalendarSourcePolicy && userDimensionHasOwnCalendarSourcePolicy,
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',
? 'die *_rls_widen_membership_grant_and_platform_read-Migration (260910-jab) enthaelt eine EIGENE Regel fuer "CalendarSource" — der Regelstand ist nicht mehr eindeutig zurueckzufuehren, Messung abgebrochen statt die abgeloeste Regel weiterzumessen'
: userDimensionHasOwnCalendarSourcePolicy
? 'die *_rls_widen_membership_grant_and_platform_read-Migration (260910-jab) enthaelt KEINE eigene Regel fuer "CalendarSource", die *_rls_user_dimension_personal_tables-Migration (20260911120000, 260911-nke) enthaelt GENAU EINE — das ist der aktuelle Regelstand, aus dieser Migration gelesen (Wechsel von 20260909140000_rls_remaining_tenant_tables)'
: 'weder die widen- noch die Benutzerdimension-Migration enthaelt eine eigene Regel fuer "CalendarSource" — Messung abgebrochen statt eine aeltere Migration zu raten',
);
if (widenHasOwnCalendarSourcePolicy) {
if (widenHasOwnCalendarSourcePolicy || !userDimensionHasOwnCalendarSourcePolicy) {
return;
}
const remainingMigrationSql = readRemainingTenantTablesMigrationSql();
const calendarSourcePolicy = remainingMigrationSql
? extractPolicySql(remainingMigrationSql, 'CalendarSource')
const calendarSourcePolicy = userDimensionMigrationSql
? extractPolicySql(userDimensionMigrationSql, 'CalendarSource')
: null;
if (!calendarSourcePolicy) {
@@ -2997,7 +3072,7 @@ async function runCalendarAreaChecks(adminUrl, scratchRoleUrl, results) {
results,
'calendarsource-policy-aus-migration-gefunden',
false,
'CREATE POLICY fuer "CalendarSource" nicht in der ausgelieferten *_rls_remaining_tenant_tables-Migration gefunden',
'CREATE POLICY fuer "CalendarSource" nicht in der Benutzerdimension-Migration (20260911120000) gefunden',
);
return;
}
@@ -3090,13 +3165,33 @@ async function runCalendarAreaChecks(adminUrl, scratchRoleUrl, results) {
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))}`,
);
// Umgedreht (260911-nke, Aufgabe 2): alte Pruefung
// "calendarsource-fremder-nutzer-desselben-mandanten-gebunden-sichtbar"
// (Befund G) mass OHNE Benutzer; jetzt gewollte Eigenschaft. Der
// Meldetext nennt weiterhin, dass encryptedPassword eines Kollegen
// gebunden MIT Benutzer jetzt NICHT mehr lesbar ist (siehe Umkehrung
// unten).
const a2Row = rowsForA.find((r) => r.userId === 'user-a2');
const a2CredentialsVisible = Boolean(a2Row && a2Row.encryptedPassword);
report(
results,
'calendarsource-fremder-nutzer-desselben-mandanten-gebunden-sichtbar',
'calendarsource-ohne-benutzer-sieht-beide-nutzer-desselben-mandanten',
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`,
`bis 260911-nke als Befund G unter dem Namen calendarsource-fremder-nutzer-desselben-mandanten-gebunden-sichtbar gefuehrt — jetzt die gewollte Eigenschaft der IS-NULL-Form. forTenant(TENANT-A) OHNE Benutzer liefert die Quelle von 'user-a2' mit encryptedPassword=${JSON.stringify(a2Row?.encryptedPassword)}; MIT Benutzer (siehe calendarsource-benutzer-a-sieht-kollegen-nicht-gebunden) ist dieselbe Zeile NICHT mehr lesbar`,
);
const rowsForA1 = await forTenantQuery(
prisma,
'TENANT-A',
(tx) =>
tx.$queryRaw`SELECT id, "userId", "tenantId", "encryptedPassword" FROM "CalendarSource" ORDER BY id`,
'user-a1',
);
const a2RowGebunden = rowsForA1.find((r) => r.userId === 'user-a2');
report(
results,
'calendarsource-benutzer-a-sieht-kollegen-nicht-gebunden',
!a2RowGebunden,
`forTenant(TENANT-A, user-a1) liefert die Quelle von 'user-a2' mit: ${JSON.stringify(a2RowGebunden)} — encryptedPassword des Kollegen ist damit auf Datenbankebene nicht mehr lesbar`,
);
// 2: calendarsource-ungebunden-null-zeilen — die tragende Belegzeile.
@@ -3917,21 +4012,27 @@ async function runFavoritesAreaChecks(adminUrl, scratchRoleUrl, results) {
const widenMigrationSql = readRlsWidenMigrationSql();
const widenHasOwnFavoriteLinkPolicy =
widenMigrationSql && Boolean(extractPolicySql(widenMigrationSql, 'FavoriteLink'));
// 260911-nke, Aufgabe 2: die Benutzerdimension-Migration hat GENAU EINE
// eigene Regel fuer "FavoriteLink" — widen hat weiterhin KEINE.
const userDimensionMigrationSql = readRlsUserDimensionMigrationSql();
const userDimensionHasOwnFavoriteLinkPolicy =
userDimensionMigrationSql && Boolean(extractPolicySql(userDimensionMigrationSql, 'FavoriteLink'));
report(
results,
'favoritelink-regelstand-eindeutig',
!widenHasOwnFavoriteLinkPolicy,
!widenHasOwnFavoriteLinkPolicy && userDimensionHasOwnFavoriteLinkPolicy,
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',
? 'die *_rls_widen_membership_grant_and_platform_read-Migration (260910-jab) enthaelt eine EIGENE Regel fuer "FavoriteLink" — der Regelstand ist nicht mehr eindeutig zurueckzufuehren, Messung abgebrochen statt die abgeloeste Regel weiterzumessen'
: userDimensionHasOwnFavoriteLinkPolicy
? 'die *_rls_widen_membership_grant_and_platform_read-Migration (260910-jab) enthaelt KEINE eigene Regel fuer "FavoriteLink", die *_rls_user_dimension_personal_tables-Migration (20260911120000, 260911-nke) enthaelt GENAU EINE — das ist der aktuelle Regelstand, aus dieser Migration gelesen (Wechsel von 20260909140000_rls_remaining_tenant_tables)'
: 'weder die widen- noch die Benutzerdimension-Migration enthaelt eine eigene Regel fuer "FavoriteLink" — Messung abgebrochen statt eine aeltere Migration zu raten',
);
if (widenHasOwnFavoriteLinkPolicy) {
if (widenHasOwnFavoriteLinkPolicy || !userDimensionHasOwnFavoriteLinkPolicy) {
return;
}
const remainingMigrationSql = readRemainingTenantTablesMigrationSql();
const favoriteLinkPolicy = remainingMigrationSql
? extractPolicySql(remainingMigrationSql, 'FavoriteLink')
const favoriteLinkPolicy = userDimensionMigrationSql
? extractPolicySql(userDimensionMigrationSql, 'FavoriteLink')
: null;
if (!favoriteLinkPolicy) {
@@ -3939,7 +4040,7 @@ async function runFavoritesAreaChecks(adminUrl, scratchRoleUrl, results) {
results,
'favoritelink-policy-aus-migration-gefunden',
false,
'CREATE POLICY fuer "FavoriteLink" nicht in der ausgelieferten *_rls_remaining_tenant_tables-Migration gefunden',
'CREATE POLICY fuer "FavoriteLink" nicht in der Benutzerdimension-Migration (20260911120000) gefunden',
);
return;
}
@@ -4040,9 +4141,11 @@ async function runFavoritesAreaChecks(adminUrl, scratchRoleUrl, results) {
);
// 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).
// — 260911-nke, Aufgabe 2: die Doppelaussage aus 260911-gwh ist
// GETRENNT. Diese Pruefung behaelt NUR die erste Haelfte (die eigenen
// Zeilen kommen). Die zweite Haelfte (Kollege sichtbar) wird zu den
// zwei neuen Pruefungen unten: die alte Messung OHNE Benutzer bleibt
// unter neuem Namen bestehen, die Umkehrung misst MIT Benutzer.
const bound = buildInlineExtendedClient(prisma, 'TENANT-A');
const boundOwnList = await bound.favoriteLink.findMany({
where: { userId: 'user-a1', widgetId: 'widget-a1' },
@@ -4051,14 +4154,32 @@ async function runFavoritesAreaChecks(adminUrl, scratchRoleUrl, results) {
const ownListOk =
boundOwnList.length === 2 &&
boundOwnList.every((r) => r.userId === 'user-a1' && r.widgetId === 'widget-a1');
report(
results,
'favoritelink-liste-generierter-client-gebunden-eigener-mandant-liefert-eigene-zeilen',
ownListOk,
`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))}`,
);
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))`,
'favoritelink-ohne-benutzer-sieht-beide-nutzer-desselben-mandanten',
colleagueVisible,
`bis 260911-nke Teil der Doppelaussage in favoritelink-liste-generierter-client-gebunden-eigener-mandant-liefert-eigene-zeilen (260911-gwh, Pruefung 4) — jetzt die gewollte Eigenschaft der IS-NULL-Form. bound(TENANT-A, ohne Benutzer).favoriteLink.findMany({ where: { widgetId: 'widget-a2' } }) liefert ${boundColleagueList.length} Zeile(n) des Kollegen user-a2: ${JSON.stringify(boundColleagueList.map((r) => r.id))}`,
);
const boundA1 = buildInlineExtendedClient(prisma, 'TENANT-A', 'user-a1');
const boundColleagueListGebunden = await boundA1.favoriteLink.findMany({
where: { widgetId: 'widget-a2' },
});
report(
results,
'favoritelink-benutzer-a-sieht-kollegen-nicht-gebunden',
boundColleagueListGebunden.length === 0,
`bound(TENANT-A, user-a1).favoriteLink.findMany({ where: { widgetId: 'widget-a2' } }) liefert ${boundColleagueListGebunden.length} Zeile(n): ${JSON.stringify(boundColleagueListGebunden.map((r) => r.id))}`,
);
// 5: favoritelink-besitzpruefung-generierter-client-gebunden-fremder-mandant-liefert-null
@@ -4605,6 +4726,174 @@ async function runSingleRulePersonalTableCheck(config) {
}
}
/**
* Wie `runSingleRulePersonalTableCheck`, aber fuer die ZWEI Tabellen mit
* NULL-faehiger `userId` und vier befehlsgetrennten Regeln (SearchProvider,
* TenderRssFeedSource, Etappe 3b, 260911-nke). Zusaetzlich zu den vier
* Standard-Wahrheiten: eine gemeinsame Zeile (`userId IS NULL`) bleibt fuer
* einen benutzergebundenen Aufruf LESBAR, aber NICHT entfernbar — weil ein
* einzelner USING-Ausdruck, der die gemeinsame Zeile zum Lesen einschliesst,
* sie auch zum Aendern/Entfernen freigaebe (jab-Praezedenz). Eine
* Gegenmessung ohne Benutzer zeigt, ob die gemeinsame Zeile dann entfernbar
* ist — bei TenderRssFeedSource NICHT (die plattformweite Zeile hat KEINEN
* Mandanten, die Schreibregel verlangt aber einen — WINDOWS #24), bei
* SearchProvider SCHON (die gemeinsame Zeile ist mandantengebunden).
*/
async function runCommandSeparatedPersonalTableCheck(config) {
const {
adminUrl,
scratchRoleUrl,
results,
slug,
tableName,
modelName,
userDimensionMigrationSql,
createTableSql,
seedSql,
ownRowId,
colleagueRowId,
sharedRowId,
sharedRowRemainsRemovableWithoutUser = true,
createAttempt,
} = config;
const policies = extractAllPolicySql(userDimensionMigrationSql, tableName);
if (policies.length !== 4) {
report(
results,
`${slug}-policy-aus-migration-gefunden`,
false,
`nicht genau vier CREATE-POLICY-Anweisungen fuer "${tableName}" in der Benutzerdimension-Migration (20260911120000) gefunden (gefunden: ${policies.length})`,
);
return;
}
await withAdminPrisma(urlForDatabase(adminUrl, SCRATCH_DB_NAME).toString(), async (db) => {
await db.$executeRawUnsafe(`DROP TABLE IF EXISTS "${tableName}" CASCADE;`);
await db.$executeRawUnsafe(createTableSql);
await db.$executeRawUnsafe(`ALTER TABLE "${tableName}" ENABLE ROW LEVEL SECURITY;`);
await db.$executeRawUnsafe(`ALTER TABLE "${tableName}" FORCE ROW LEVEL SECURITY;`);
for (const policySql of policies) {
await db.$executeRawUnsafe(policySql);
}
await db.$executeRawUnsafe(
`GRANT SELECT, INSERT, UPDATE, DELETE ON "${tableName}" TO ${SCRATCH_ROLE_NAME}`,
);
await db.$executeRawUnsafe(seedSql);
});
const schemaFields = readSchemaModelScalarFieldNames(modelName);
const tableColumns = await withAdminPrisma(
urlForDatabase(adminUrl, SCRATCH_DB_NAME).toString(),
async (db) => {
const rows = await db.$queryRawUnsafe(
`SELECT column_name FROM information_schema.columns WHERE table_schema = 'public' AND table_name = '${tableName}'`,
);
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,
`${slug}-wegwerftabelle-deckt-alle-spalten-des-generierten-clients`,
columnsMatch,
`Schema-Felder aus schema.prisma (model ${modelName}, skalare Felder ohne Relation, ${schemaFieldsSorted.length}): ${JSON.stringify(schemaFieldsSorted)}; Spalten der Wegwerf-Tabelle (${tableColumns.length}): ${JSON.stringify(tableColumns)}`,
);
if (!columnsMatch) {
return;
}
const modelAccessor = modelName.charAt(0).toLowerCase() + modelName.slice(1);
const prisma = new PrismaClient({ datasourceUrl: scratchRoleUrl });
try {
const boundA1 = buildInlineExtendedClient(prisma, 'TENANT-A', 'user-a1');
const ownRow = await boundA1[modelAccessor].findUnique({ where: { id: ownRowId } });
report(
results,
`${slug}-benutzer-a-sieht-eigene-zeile`,
Boolean(ownRow) && ownRow.id === ownRowId,
`bound(TENANT-A, user-a1).${modelAccessor}.findUnique({ id: '${ownRowId}' }) liefert ${JSON.stringify(ownRow)}`,
);
const colleagueRow = await boundA1[modelAccessor].findUnique({ where: { id: colleagueRowId } });
report(
results,
`${slug}-benutzer-a-sieht-kollegen-nicht`,
colleagueRow === null,
`bound(TENANT-A, user-a1).${modelAccessor}.findUnique({ id: '${colleagueRowId}' }) (gehoert user-a2) liefert ${JSON.stringify(colleagueRow)}`,
);
const boundNoUser = buildInlineExtendedClient(prisma, 'TENANT-A');
const bothRows = await boundNoUser[modelAccessor].findMany({
where: { id: { in: [ownRowId, colleagueRowId] } },
});
report(
results,
`${slug}-ohne-benutzer-sieht-beide`,
bothRows.length === 2,
`bound(TENANT-A, ohne Benutzer).${modelAccessor}.findMany liefert ${bothRows.length} Zeile(n): ${JSON.stringify(bothRows.map((r) => r.id))} — gewollte Eigenschaft der IS-NULL-Form fuer Admin/Hintergrunddienst`,
);
let writeRejected = false;
let writeDetail = '';
try {
await boundA1[modelAccessor].create({
data: {
id: createAttempt.id,
userId: 'user-a2',
tenantId: 'TENANT-A',
...createAttempt.extraData,
},
});
writeDetail = `bound(TENANT-A, user-a1).${modelAccessor}.create mit userId='user-a2' ist NICHT fehlgeschlagen`;
} catch (err) {
const sqlState = sqlStateOf(err);
const ctor = err?.constructor?.name ?? 'unbekannt';
writeRejected = sqlState === '42501';
writeDetail = `bound(TENANT-A, user-a1).${modelAccessor}.create mit userId='user-a2' wirft ${ctor}, SQLSTATE ${sqlState ?? 'unbekannt'}: ${(err.message ?? '').toString().trim()}`;
}
report(results, `${slug}-schreiben-als-a-mit-kennung-b-abgelehnt`, writeRejected, writeDetail);
// Gemeinsame Zeile (userId IS NULL): lesbar fuer einen benutzergebundenen
// Aufruf, aber nicht entfernbar (vier befehlsgetrennte Regeln).
const sharedRowVisible = await boundA1[modelAccessor].findUnique({ where: { id: sharedRowId } });
report(
results,
`${slug}-gemeinsame-zeile-fuer-benutzer-a-lesbar`,
Boolean(sharedRowVisible) && sharedRowVisible.id === sharedRowId,
`bound(TENANT-A, user-a1).${modelAccessor}.findUnique({ id: '${sharedRowId}' }) (gemeinsame Zeile, userId IS NULL) liefert ${JSON.stringify(sharedRowVisible)}`,
);
const deleteAsUserResult = await boundA1[modelAccessor].deleteMany({ where: { id: sharedRowId } });
report(
results,
`${slug}-gemeinsame-zeile-als-benutzer-a-nicht-entfernbar`,
deleteAsUserResult.count === 0,
`bound(TENANT-A, user-a1).${modelAccessor}.deleteMany({ id: '${sharedRowId}' }) liefert count=${deleteAsUserResult.count} — eine Regel ohne Befehlstrennung wuerde hier 1 liefern`,
);
const deleteWithoutUserResult = await boundNoUser[modelAccessor].deleteMany({
where: { id: sharedRowId },
});
const expectedCount = sharedRowRemainsRemovableWithoutUser ? 1 : 0;
report(
results,
`${slug}-gemeinsame-zeile-ohne-benutzer-weiterhin-entfernbar`,
deleteWithoutUserResult.count === expectedCount,
sharedRowRemainsRemovableWithoutUser
? `bound(TENANT-A, ohne Benutzer).${modelAccessor}.deleteMany({ id: '${sharedRowId}' }) liefert count=${deleteWithoutUserResult.count} — die gemeinsame Zeile bleibt fuer Admin/Hintergrunddienst entfernbar`
: `bound(TENANT-A, ohne Benutzer).${modelAccessor}.deleteMany({ id: '${sharedRowId}' }) liefert count=${deleteWithoutUserResult.count} — WINDOWS #24: die plattformweite Zeile hat keinen Mandanten, die Schreibregel verlangt aber einen; das gilt VOR wie NACH dieser Migration unveraendert und ist kein neu entdecktes Loch`,
);
} finally {
await prisma.$disconnect();
}
}
/**
* Etappe 3b (260911-nke) — misst die Benutzerdimension der zehn
* persoenlichen Tabellen ueber den GENERIERTEN Client. Aufgabe 1: nur
@@ -4654,6 +4943,321 @@ async function runUserDimensionChecks(adminUrl, scratchRoleUrl, results) {
colleagueRowId: 'ss-a2',
createAttempt: { id: 'ss-a-schreibversuch', extraData: { name: 'Schreibversuch', filters: {} } },
});
// Aufgabe 2 (260911-nke): die uebrigen sieben Ein-Regel-Tabellen ueber
// dieselbe innere Routine.
await runSingleRulePersonalTableCheck({
adminUrl,
scratchRoleUrl,
results,
slug: 'calendarsource',
tableName: 'CalendarSource',
modelName: 'CalendarSource',
userDimensionMigrationSql,
createTableSql: `
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
);
`,
seedSql: `
INSERT INTO "CalendarSource" (id, "userId", "tenantId", name, type, url) VALUES
('cal-a1', 'user-a1', 'TENANT-A', 'Kalender A1', 'ics', 'https://a1.example.invalid/cal.ics'),
('cal-a2', 'user-a2', 'TENANT-A', 'Kalender A2', 'ics', 'https://a2.example.invalid/cal.ics'),
('cal-b', 'user-b', 'TENANT-B', 'Kalender B', 'ics', 'https://b.example.invalid/cal.ics');
`,
ownRowId: 'cal-a1',
colleagueRowId: 'cal-a2',
createAttempt: {
id: 'cal-a-schreibversuch',
extraData: { name: 'Schreibversuch', type: 'ics', url: 'https://x.example.invalid/cal.ics' },
},
});
await runSingleRulePersonalTableCheck({
adminUrl,
scratchRoleUrl,
results,
slug: 'dashboardlayout',
tableName: 'DashboardLayout',
modelName: 'DashboardLayout',
userDimensionMigrationSql,
createTableSql: `
CREATE TABLE "DashboardLayout" (
id text PRIMARY KEY,
"userId" text NOT NULL UNIQUE,
"tenantId" text NOT NULL,
layouts jsonb NOT NULL DEFAULT '{}',
"createdAt" timestamp(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" timestamp(3) NOT NULL DEFAULT CURRENT_TIMESTAMP
);
`,
seedSql: `
INSERT INTO "DashboardLayout" (id, "userId", "tenantId", layouts) VALUES
('layout-a1', 'user-a1', 'TENANT-A', '{}'),
('layout-a2', 'user-a2', 'TENANT-A', '{}'),
('layout-b', 'user-b', 'TENANT-B', '{}');
`,
ownRowId: 'layout-a1',
colleagueRowId: 'layout-a2',
createAttempt: { id: 'layout-a-schreibversuch', extraData: { layouts: {} } },
});
await runSingleRulePersonalTableCheck({
adminUrl,
scratchRoleUrl,
results,
slug: 'widgetinstance',
tableName: 'WidgetInstance',
modelName: 'WidgetInstance',
userDimensionMigrationSql,
createTableSql: `
CREATE TABLE "WidgetInstance" (
id text PRIMARY KEY,
"userId" text NOT NULL,
"tenantId" text NOT NULL,
"widgetType" text NOT NULL,
config jsonb NOT NULL DEFAULT '{}',
"createdAt" timestamp(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" timestamp(3) NOT NULL DEFAULT CURRENT_TIMESTAMP
);
`,
seedSql: `
INSERT INTO "WidgetInstance" (id, "userId", "tenantId", "widgetType") VALUES
('widget-a1', 'user-a1', 'TENANT-A', 'clock'),
('widget-a2', 'user-a2', 'TENANT-A', 'clock'),
('widget-b', 'user-b', 'TENANT-B', 'clock');
`,
ownRowId: 'widget-a1',
colleagueRowId: 'widget-a2',
createAttempt: { id: 'widget-a-schreibversuch', extraData: { widgetType: 'clock' } },
});
await runSingleRulePersonalTableCheck({
adminUrl,
scratchRoleUrl,
results,
slug: 'favoritelink',
tableName: 'FavoriteLink',
modelName: 'FavoriteLink',
userDimensionMigrationSql,
createTableSql: `
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
);
`,
seedSql: `
INSERT INTO "FavoriteLink" (id, "userId", "tenantId", "widgetId", title, url) VALUES
('fav-a1', 'user-a1', 'TENANT-A', 'widget-a1', 'Favorit A1', 'https://a1.example.invalid'),
('fav-a2', 'user-a2', 'TENANT-A', 'widget-a2', 'Favorit A2', 'https://a2.example.invalid'),
('fav-b', 'user-b', 'TENANT-B', 'widget-b', 'Favorit B', 'https://b.example.invalid');
`,
ownRowId: 'fav-a1',
colleagueRowId: 'fav-a2',
createAttempt: {
id: 'fav-a-schreibversuch',
extraData: { widgetId: 'widget-a1', title: 'Schreibversuch', url: 'https://x.example.invalid' },
},
});
await runSingleRulePersonalTableCheck({
adminUrl,
scratchRoleUrl,
results,
slug: 'tenderemailconfig',
tableName: 'TenderEmailConfig',
modelName: 'TenderEmailConfig',
userDimensionMigrationSql,
createTableSql: `
CREATE TABLE "TenderEmailConfig" (
id text PRIMARY KEY,
"userId" text NOT NULL UNIQUE,
"tenantId" text NOT NULL,
protocol text NOT NULL DEFAULT 'imap',
host text,
port integer,
encryption text NOT NULL DEFAULT 'ssl-tls',
folder text NOT NULL DEFAULT 'INBOX',
"senderFilter" text,
domain text,
"isActive" boolean NOT NULL DEFAULT false,
"encryptedInboxCreds" text,
"createdAt" timestamp(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" timestamp(3) NOT NULL DEFAULT CURRENT_TIMESTAMP
);
`,
seedSql: `
INSERT INTO "TenderEmailConfig" (id, "userId", "tenantId") VALUES
('ec-a1', 'user-a1', 'TENANT-A'),
('ec-a2', 'user-a2', 'TENANT-A'),
('ec-b', 'user-b', 'TENANT-B');
`,
ownRowId: 'ec-a1',
colleagueRowId: 'ec-a2',
createAttempt: { id: 'ec-a-schreibversuch', extraData: {} },
});
await runSingleRulePersonalTableCheck({
adminUrl,
scratchRoleUrl,
results,
slug: 'tendernotificationpref',
tableName: 'TenderNotificationPref',
modelName: 'TenderNotificationPref',
userDimensionMigrationSql,
createTableSql: `
CREATE TABLE "TenderNotificationPref" (
id text PRIMARY KEY,
"userId" text NOT NULL UNIQUE,
"tenantId" text NOT NULL,
"digestInterval" text NOT NULL DEFAULT 'daily',
"createdAt" timestamp(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" timestamp(3) NOT NULL DEFAULT CURRENT_TIMESTAMP
);
`,
seedSql: `
INSERT INTO "TenderNotificationPref" (id, "userId", "tenantId") VALUES
('np-a1', 'user-a1', 'TENANT-A'),
('np-a2', 'user-a2', 'TENANT-A'),
('np-b', 'user-b', 'TENANT-B');
`,
ownRowId: 'np-a1',
colleagueRowId: 'np-a2',
createAttempt: { id: 'np-a-schreibversuch', extraData: {} },
});
await runSingleRulePersonalTableCheck({
adminUrl,
scratchRoleUrl,
results,
slug: 'tendertriage',
tableName: 'TenderTriage',
modelName: 'TenderTriage',
userDimensionMigrationSql,
createTableSql: `
CREATE TABLE "TenderTriage" (
id text PRIMARY KEY,
"userId" text NOT NULL,
"tenantId" text NOT NULL,
"tenderId" text NOT NULL,
"isRead" boolean NOT NULL DEFAULT false,
"isFavorite" boolean NOT NULL DEFAULT false,
"readAt" timestamp(3),
"favoritedAt" timestamp(3),
"createdAt" timestamp(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" timestamp(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE ("userId", "tenderId")
);
`,
seedSql: `
INSERT INTO "TenderTriage" (id, "userId", "tenantId", "tenderId") VALUES
('tr-a1', 'user-a1', 'TENANT-A', 'tender-a1'),
('tr-a2', 'user-a2', 'TENANT-A', 'tender-a2'),
('tr-b', 'user-b', 'TENANT-B', 'tender-b');
`,
ownRowId: 'tr-a1',
colleagueRowId: 'tr-a2',
createAttempt: { id: 'tr-a-schreibversuch', extraData: { tenderId: 'tender-a-schreibversuch' } },
});
// Aufgabe 2 (260911-nke): die zwei befehlsgetrennten Tabellen — vier
// Regeln je Tabelle, zusaetzlich die gemeinsame-Zeile-Pruefungen.
await runCommandSeparatedPersonalTableCheck({
adminUrl,
scratchRoleUrl,
results,
slug: 'searchprovider',
tableName: 'SearchProvider',
modelName: 'SearchProvider',
userDimensionMigrationSql,
createTableSql: `
CREATE TABLE "SearchProvider" (
id text PRIMARY KEY,
"userId" text,
"tenantId" text,
name text NOT NULL,
"urlTemplate" text NOT NULL,
"isDefault" boolean NOT NULL DEFAULT false,
"createdAt" timestamp(3) NOT NULL DEFAULT CURRENT_TIMESTAMP
);
`,
seedSql: `
INSERT INTO "SearchProvider" (id, "userId", "tenantId", name, "urlTemplate") VALUES
('search-a1', 'user-a1', 'TENANT-A', 'Suche A1', 'https://a1.example.invalid/?q={query}'),
('search-a2', 'user-a2', 'TENANT-A', 'Suche A2', 'https://a2.example.invalid/?q={query}'),
('search-b', 'user-b', 'TENANT-B', 'Suche B', 'https://b.example.invalid/?q={query}'),
('search-shared-a', NULL, 'TENANT-A', 'Gemeinsame Suche A', 'https://shared-a.example.invalid/?q={query}');
`,
ownRowId: 'search-a1',
colleagueRowId: 'search-a2',
sharedRowId: 'search-shared-a',
createAttempt: {
id: 'search-a-schreibversuch',
extraData: { name: 'Schreibversuch', urlTemplate: 'https://x.example.invalid/?q={query}' },
},
});
await runCommandSeparatedPersonalTableCheck({
adminUrl,
scratchRoleUrl,
results,
slug: 'tenderrssfeed',
tableName: 'TenderRssFeedSource',
modelName: 'TenderRssFeedSource',
userDimensionMigrationSql,
createTableSql: `
CREATE TABLE "TenderRssFeedSource" (
id text PRIMARY KEY,
url text NOT NULL,
label text NOT NULL,
"isActive" boolean NOT NULL DEFAULT true,
"userId" text,
"tenantId" text,
"createdAt" timestamp(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" timestamp(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE ("userId", url)
);
`,
seedSql: `
INSERT INTO "TenderRssFeedSource" (id, url, label, "userId", "tenantId") VALUES
('rss-a1', 'https://a1.example-tenders.invalid/feed', 'Feed A1', 'user-a1', 'TENANT-A'),
('rss-a2', 'https://a2.example-tenders.invalid/feed', 'Feed A2', 'user-a2', 'TENANT-A'),
('rss-b', 'https://b.example-tenders.invalid/feed', 'Feed B', 'user-b', 'TENANT-B'),
('rss-platform', 'https://platform.example-tenders.invalid/feed', 'Plattformweit', NULL, NULL);
`,
ownRowId: 'rss-a1',
colleagueRowId: 'rss-a2',
sharedRowId: 'rss-platform',
sharedRowRemainsRemovableWithoutUser: false,
createAttempt: {
id: 'rss-a-schreibversuch',
extraData: { url: 'https://x.example-tenders.invalid/feed', label: 'Schreibversuch' },
},
});
}
/**