feat(quick-260911-cwh): Fehlerrichtung Bereich calendar messen (Aufgabe 1)
- rls-scratch-check.mjs: elfter Abschnitt runCalendarAreaChecks mit 13 namentlich benannten Pruefungen gegen die aus 20260909140000_rls_remaining_tenant_tables geschnittene Regel, davon 4 ueber den generierten Client an einer schemagleichen Wegwerf-Tabelle (17 Spalten, gegen schema.prisma laufzeitgeprueft); Laufzeitpruefung, dass 20260910120000 keine eigene CalendarSource-Regel traegt (Messfalle 260910-jab) - Alle 101 Pruefungen bestehen (88 bisherige + 13 neue) - docs/mandantentrennung-etappe2-fehlerrichtung.md: Abschnitt "## Bereich calendar" mit (k1)-(k5) — Messung, Signaltabelle je Pfad, Leere-als-Abwesenheit in Backend UND Frontend samt Fehlerverschluckung, bewusst nicht geloest (Cache-Schluessel-Urteil, Zugangsdaten-Erhaltung, fehlende Benutzerdimension, 403/404), bewusst nicht angefasst - Wettlauf-Fehlerklasse gemessen: PrismaClientKnownRequestError (P2025), NICHT PrismaClientUnknownRequestError wie im Bereich dashboard — Aufgabe 2 braucht deshalb keine neue Fehleruebersetzung fuer die Besitzpruefungen Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AMASaSxv5QMY7RncqZriRR
This commit is contained in:
@@ -42,6 +42,7 @@ const SCRATCH_ROLE_NAME = 'tessera_rls_scratch_role';
|
|||||||
const SCRATCH_ROLE_PASSWORD = 'scratch_only_local_never_reused';
|
const SCRATCH_ROLE_PASSWORD = 'scratch_only_local_never_reused';
|
||||||
const MIGRATIONS_DIR = join(__dirname, '../prisma/migrations');
|
const MIGRATIONS_DIR = join(__dirname, '../prisma/migrations');
|
||||||
const PRISMA_BIN = join(__dirname, '../node_modules/.bin/prisma');
|
const PRISMA_BIN = join(__dirname, '../node_modules/.bin/prisma');
|
||||||
|
const SCHEMA_PRISMA_PATH = join(__dirname, '../prisma/schema.prisma');
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fuehrt ein mehrteiliges SQL-Skript (mehrere Anweisungen, DO $$ ... $$
|
* Fuehrt ein mehrteiliges SQL-Skript (mehrere Anweisungen, DO $$ ... $$
|
||||||
@@ -2765,6 +2766,336 @@ async function runDashboardAreaChecks(adminUrl, scratchRoleUrl, results) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Liest die Feldnamen eines Prisma-Modellblocks direkt aus
|
||||||
|
* `apps/api/prisma/schema.prisma`, statt sie im Werkzeug zu wiederholen
|
||||||
|
* (Pruefung 8 in `runCalendarAreaChecks`, Lehre aus Pruefung 5b im Bereich
|
||||||
|
* `dashboard`: der generierte Client waehlt standardmaessig JEDE Spalte des
|
||||||
|
* Modells aus und scheitert mit P2022 an jeder fehlenden — eine
|
||||||
|
* Wegwerf-Tabelle mit unvollstaendigem Spaltensatz wuerde das nie zeigen,
|
||||||
|
* Roh-SQL merkt es ohnehin nie). Feldname = erstes Wort jeder nicht-leeren
|
||||||
|
* Zeile im Modellblock, die nicht mit `@@` (Modell-Attribute wie
|
||||||
|
* `@@index`) und nicht mit `//` (Kommentarzeile) beginnt.
|
||||||
|
*/
|
||||||
|
function readSchemaModelFieldNames(modelName) {
|
||||||
|
const schemaSource = readFileSync(SCHEMA_PRISMA_PATH, 'utf-8');
|
||||||
|
const re = new RegExp(`model ${modelName} \\{([\\s\\S]*?)\\n\\}`);
|
||||||
|
const match = schemaSource.match(re);
|
||||||
|
if (!match) return [];
|
||||||
|
const fields = [];
|
||||||
|
for (const rawLine of match[1].split('\n')) {
|
||||||
|
const line = rawLine.trim();
|
||||||
|
if (!line) continue;
|
||||||
|
if (line.startsWith('@@')) continue;
|
||||||
|
if (line.startsWith('//')) continue;
|
||||||
|
const firstWord = line.split(/\s+/)[0];
|
||||||
|
if (firstWord) fields.push(firstWord);
|
||||||
|
}
|
||||||
|
return fields;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Aufgabe 1 (260911-cwh) — misst die zwoelf im Plan genannten
|
||||||
|
* Verhaltensweisen des Bereichs `calendar` unter der Rolle ohne BYPASSRLS,
|
||||||
|
* an der Regel WORTGLEICH aus der ausgelieferten Migration
|
||||||
|
* `20260909140000_rls_remaining_tenant_tables` geschnitten — NICHT dem
|
||||||
|
* Werkzeug nachgetippt (vgl. runDkvAreaChecks/runDashboardAreaChecks).
|
||||||
|
*
|
||||||
|
* Prueft zur Laufzeit zusaetzlich die Messfalle aus 260910-jab: die
|
||||||
|
* `*_rls_widen_membership_grant_and_platform_read`-Migration (260910-jab)
|
||||||
|
* darf KEINE eigene Regel fuer "CalendarSource" enthalten (Befund G) —
|
||||||
|
* faende sich dort eine, waere der Regelstand nicht mehr eindeutig auf
|
||||||
|
* 20260909140000 zurueckzufuehren und dieser Abschnitt braeche ab, statt
|
||||||
|
* die abgeloeste Regel weiterzumessen.
|
||||||
|
*
|
||||||
|
* Legt die Wegwerf-Tabelle "CalendarSource" selbst neu an, mit SAEMTLICHEN
|
||||||
|
* Spalten des Modells (nicht nur denen, die Roh-SQL braucht — Pruefung 8,
|
||||||
|
* die dashboard-Lehre aus Pruefung 5b) und setzt auf keiner Tabelle eines
|
||||||
|
* anderen Abschnitts auf: er ist ein Blatt in der Aufrufkette, muss NACH
|
||||||
|
* runDashboardAreaChecks() und VOR runTransactionShapeMeasurement() laufen
|
||||||
|
* (siehe Aufrufkette in main()) — Letztere setzt weiterhin auf der von
|
||||||
|
* runGroupsAreaChecks() angelegten Tabelle "Group" auf, dieser Abschnitt
|
||||||
|
* aendert daran nichts, und keine spaetere Pruefung setzt auf der hier
|
||||||
|
* angelegten Tabelle auf.
|
||||||
|
*/
|
||||||
|
async function runCalendarAreaChecks(adminUrl, scratchRoleUrl, results) {
|
||||||
|
const widenMigrationSql = readRlsWidenMigrationSql();
|
||||||
|
const widenHasOwnCalendarSourcePolicy =
|
||||||
|
widenMigrationSql && Boolean(extractPolicySql(widenMigrationSql, 'CalendarSource'));
|
||||||
|
report(
|
||||||
|
results,
|
||||||
|
'calendarsource-regelstand-eindeutig',
|
||||||
|
!widenHasOwnCalendarSourcePolicy,
|
||||||
|
widenHasOwnCalendarSourcePolicy
|
||||||
|
? 'die *_rls_widen_membership_grant_and_platform_read-Migration (260910-jab) enthaelt eine EIGENE Regel fuer "CalendarSource" — der Regelstand ist nicht mehr eindeutig auf 20260909140000_rls_remaining_tenant_tables zurueckzufuehren, Messung abgebrochen statt die abgeloeste Regel weiterzumessen'
|
||||||
|
: 'die *_rls_widen_membership_grant_and_platform_read-Migration (260910-jab) enthaelt KEINE eigene Regel fuer "CalendarSource" (Befund G) — der Stand aus 20260909140000_rls_remaining_tenant_tables ist weiterhin der ausgelieferte, aktuelle Regelstand',
|
||||||
|
);
|
||||||
|
if (widenHasOwnCalendarSourcePolicy) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const remainingMigrationSql = readRemainingTenantTablesMigrationSql();
|
||||||
|
const calendarSourcePolicy = remainingMigrationSql
|
||||||
|
? extractPolicySql(remainingMigrationSql, 'CalendarSource')
|
||||||
|
: null;
|
||||||
|
|
||||||
|
if (!calendarSourcePolicy) {
|
||||||
|
report(
|
||||||
|
results,
|
||||||
|
'calendarsource-policy-aus-migration-gefunden',
|
||||||
|
false,
|
||||||
|
'CREATE POLICY fuer "CalendarSource" nicht in der ausgelieferten *_rls_remaining_tenant_tables-Migration gefunden',
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await withAdminPrisma(urlForDatabase(adminUrl, SCRATCH_DB_NAME).toString(), async (db) => {
|
||||||
|
await db.$executeRawUnsafe(`
|
||||||
|
CREATE TABLE "CalendarSource" (
|
||||||
|
id text PRIMARY KEY,
|
||||||
|
"userId" text NOT NULL,
|
||||||
|
"tenantId" text NOT NULL,
|
||||||
|
name text NOT NULL,
|
||||||
|
type text NOT NULL,
|
||||||
|
"exchangeMode" text,
|
||||||
|
domain text,
|
||||||
|
url text NOT NULL,
|
||||||
|
username text,
|
||||||
|
"encryptedPassword" text,
|
||||||
|
color text DEFAULT '#3B82F6',
|
||||||
|
"isVisible" boolean NOT NULL DEFAULT true,
|
||||||
|
"syncIntervalMin" integer NOT NULL DEFAULT 15,
|
||||||
|
"lastSyncAt" timestamp(3),
|
||||||
|
"lastSyncError" text,
|
||||||
|
"createdAt" timestamp(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" timestamp(3) NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
|
||||||
|
await db.$executeRawUnsafe(`ALTER TABLE "CalendarSource" ENABLE ROW LEVEL SECURITY;`);
|
||||||
|
await db.$executeRawUnsafe(`ALTER TABLE "CalendarSource" FORCE ROW LEVEL SECURITY;`);
|
||||||
|
await db.$executeRawUnsafe(calendarSourcePolicy);
|
||||||
|
await db.$executeRawUnsafe(
|
||||||
|
`GRANT SELECT, INSERT, UPDATE, DELETE ON "CalendarSource" TO ${SCRATCH_ROLE_NAME}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Zwei Zeilen unter TENANT-A mit VERSCHIEDENEN Benutzerkennungen
|
||||||
|
// (Befund G — die Regel kennt keine Benutzerdimension), je mit
|
||||||
|
// gesetztem encryptedPassword-Platzhalter, damit Pruefung 3 zeigen
|
||||||
|
// kann, dass ein Kollege desselben Mandanten die verschluesselten
|
||||||
|
// Zugangsdaten sieht; eine Zeile unter TENANT-B fuer die
|
||||||
|
// Mandantengrenze.
|
||||||
|
await db.$executeRawUnsafe(`
|
||||||
|
INSERT INTO "CalendarSource" (id, "userId", "tenantId", name, type, url, "encryptedPassword", "isVisible") VALUES
|
||||||
|
('source-a1', 'user-a1', 'TENANT-A', 'Quelle A1', 'ics', 'https://example.invalid/a1.ics', 'enc(a1-passwort-platzhalter)', true),
|
||||||
|
('source-a2', 'user-a2', 'TENANT-A', 'Quelle A2', 'ics', 'https://example.invalid/a2.ics', 'enc(a2-passwort-platzhalter)', true),
|
||||||
|
('source-b1', 'user-b1', 'TENANT-B', 'Quelle B1', 'ics', 'https://example.invalid/b1.ics', 'enc(b1-passwort-platzhalter)', true);
|
||||||
|
`);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Pruefung 8 zuerst — faellt sie durch, sind die Client-Messungen (9-12)
|
||||||
|
// wertlos, deshalb steht sie vor ihnen und die Funktion bricht ab, wenn
|
||||||
|
// sie fehlschlaegt.
|
||||||
|
const schemaFields = readSchemaModelFieldNames('CalendarSource');
|
||||||
|
const tableColumns = await withAdminPrisma(
|
||||||
|
urlForDatabase(adminUrl, SCRATCH_DB_NAME).toString(),
|
||||||
|
async (db) => {
|
||||||
|
const rows = await db.$queryRaw`
|
||||||
|
SELECT column_name FROM information_schema.columns
|
||||||
|
WHERE table_schema = 'public' AND table_name = 'CalendarSource'
|
||||||
|
`;
|
||||||
|
return rows.map((r) => r.column_name).sort();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
const schemaFieldsSorted = [...schemaFields].sort();
|
||||||
|
const columnsMatch =
|
||||||
|
schemaFieldsSorted.length > 0 &&
|
||||||
|
schemaFieldsSorted.length === tableColumns.length &&
|
||||||
|
schemaFieldsSorted.every((f, i) => f === tableColumns[i]);
|
||||||
|
report(
|
||||||
|
results,
|
||||||
|
'calendarsource-wegwerftabelle-deckt-alle-spalten-des-generierten-clients',
|
||||||
|
columnsMatch,
|
||||||
|
`Schema-Felder aus schema.prisma (model CalendarSource, ${schemaFieldsSorted.length}): ${JSON.stringify(schemaFieldsSorted)}; Spalten der Wegwerf-Tabelle (${tableColumns.length}): ${JSON.stringify(tableColumns)}`,
|
||||||
|
);
|
||||||
|
if (!columnsMatch) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const prisma = new PrismaClient({ datasourceUrl: scratchRoleUrl });
|
||||||
|
try {
|
||||||
|
// 1 + 3: calendarsource-gebunden-nur-eigener-mandant UND
|
||||||
|
// calendarsource-fremder-nutzer-desselben-mandanten-gebunden-sichtbar —
|
||||||
|
// eine Abfrage, zwei Aussagen. Pruefung 3 zu bestehen IST das erwartete
|
||||||
|
// Ergebnis: die Regel kennt keine Benutzerdimension.
|
||||||
|
const rowsForA = await forTenantQuery(prisma, 'TENANT-A', (tx) =>
|
||||||
|
tx.$queryRaw`SELECT id, "userId", "tenantId", "encryptedPassword" FROM "CalendarSource" ORDER BY id`,
|
||||||
|
);
|
||||||
|
report(
|
||||||
|
results,
|
||||||
|
'calendarsource-gebunden-nur-eigener-mandant',
|
||||||
|
rowsForA.length === 2 && rowsForA.every((r) => r.tenantId === 'TENANT-A'),
|
||||||
|
`forTenant(TENANT-A) liefert ${rowsForA.length} Zeile(n): ${JSON.stringify(rowsForA.map((r) => r.id))}`,
|
||||||
|
);
|
||||||
|
const a2Row = rowsForA.find((r) => r.userId === 'user-a2');
|
||||||
|
const a2CredentialsVisible = Boolean(a2Row && a2Row.encryptedPassword);
|
||||||
|
report(
|
||||||
|
results,
|
||||||
|
'calendarsource-fremder-nutzer-desselben-mandanten-gebunden-sichtbar',
|
||||||
|
a2CredentialsVisible,
|
||||||
|
`forTenant(TENANT-A) liefert die Quelle von 'user-a2' (anderer Benutzer, gleicher Mandant) mit encryptedPassword=${JSON.stringify(a2Row?.encryptedPassword)} — die Regel auf "CalendarSource" kennt keine Benutzerdimension, die verschluesselten Zugangsdaten eines Kollegen DESSELBEN Mandanten sind auf Datenbankebene lesbar; die anwendungsseitige Filterung ueber die Benutzerkennung bleibt deshalb der einzige Schutz, bis die Etappe-3-Entscheidung (2) die Benutzerdimension nachzieht`,
|
||||||
|
);
|
||||||
|
|
||||||
|
// 2: calendarsource-ungebunden-null-zeilen — die tragende Belegzeile.
|
||||||
|
const unboundRows = await prisma.$queryRaw`SELECT "tenantId" FROM "CalendarSource"`;
|
||||||
|
report(
|
||||||
|
results,
|
||||||
|
'calendarsource-ungebunden-null-zeilen',
|
||||||
|
unboundRows.length === 0,
|
||||||
|
`ungebundener SELECT auf "CalendarSource" liefert ${unboundRows.length} Zeile(n), tatsaechlich vorhanden sind 3`,
|
||||||
|
);
|
||||||
|
|
||||||
|
// 4: calendarsource-ungebundene-einzelabfrage-ueber-kennung-liefert-keine-zeile
|
||||||
|
// — die Datenbankseite der drei Besitzpruefungen (Befund I).
|
||||||
|
const unboundSingleRow = await prisma.$queryRaw`SELECT id FROM "CalendarSource" WHERE id = 'source-b1'`;
|
||||||
|
report(
|
||||||
|
results,
|
||||||
|
'calendarsource-ungebundene-einzelabfrage-ueber-kennung-liefert-keine-zeile',
|
||||||
|
unboundSingleRow.length === 0,
|
||||||
|
`ungebundenes SELECT ueber die Kennung 'source-b1' (vorhanden) liefert ${unboundSingleRow.length} Zeile(n) — die Datenbankseite der drei Besitzpruefungen: ein ungebundenes Nachschlagen ueber eine vorhandene Kennung liefert null Zeilen, das ist der Weg in NotFoundException`,
|
||||||
|
);
|
||||||
|
|
||||||
|
// 5: calendarsource-gebundenes-einfuegen-fremder-mandant-abgelehnt
|
||||||
|
let foreignInsertRejected = false;
|
||||||
|
let foreignInsertDetail = '';
|
||||||
|
try {
|
||||||
|
await forTenantQuery(
|
||||||
|
prisma,
|
||||||
|
'TENANT-A',
|
||||||
|
(tx) =>
|
||||||
|
tx.$executeRaw`INSERT INTO "CalendarSource" (id, "userId", "tenantId", name, type, url) VALUES ('source-rejected', 'user-a1', 'TENANT-B', 'Sollte abgewiesen werden', 'ics', 'https://example.invalid/rejected.ics')`,
|
||||||
|
);
|
||||||
|
foreignInsertDetail = 'gebundenes INSERT unter TENANT-A mit tenantId=TENANT-B ist NICHT fehlgeschlagen';
|
||||||
|
} catch (err) {
|
||||||
|
const sqlState = sqlStateOf(err);
|
||||||
|
foreignInsertRejected = sqlState === '42501';
|
||||||
|
foreignInsertDetail = `gebundenes INSERT unter TENANT-A mit tenantId=TENANT-B abgewiesen mit SQLSTATE ${sqlState ?? 'unbekannt'} (${err.message.trim()})`;
|
||||||
|
}
|
||||||
|
report(
|
||||||
|
results,
|
||||||
|
'calendarsource-gebundenes-einfuegen-fremder-mandant-abgelehnt',
|
||||||
|
foreignInsertRejected,
|
||||||
|
foreignInsertDetail,
|
||||||
|
);
|
||||||
|
|
||||||
|
// 6: calendarsource-gebundenes-loeschen-fremder-zeile-trifft-keine-zeile
|
||||||
|
const foreignDeleteAffected = await forTenantQuery(
|
||||||
|
prisma,
|
||||||
|
'TENANT-A',
|
||||||
|
(tx) => tx.$executeRaw`DELETE FROM "CalendarSource" WHERE id = 'source-b1'`,
|
||||||
|
);
|
||||||
|
const stillThereAfterDelete = await withAdminPrisma(
|
||||||
|
urlForDatabase(adminUrl, SCRATCH_DB_NAME).toString(),
|
||||||
|
async (db) => {
|
||||||
|
const rows = await db.$queryRaw`SELECT id FROM "CalendarSource" WHERE id = 'source-b1'`;
|
||||||
|
return rows.length === 1;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
report(
|
||||||
|
results,
|
||||||
|
'calendarsource-gebundenes-loeschen-fremder-zeile-trifft-keine-zeile',
|
||||||
|
foreignDeleteAffected === 0 && stillThereAfterDelete,
|
||||||
|
`gebundenes DELETE unter TENANT-A ueber die Kennung 'source-b1' (gehoert TENANT-B) trifft ${foreignDeleteAffected} Zeile(n); ueber die Wartungsrolle ist die Zeile danach noch vorhanden: ${stillThereAfterDelete}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
// 7: calendarsource-gebundenes-update-ueber-kennung-allein-trifft-null-zeilen
|
||||||
|
const foreignUpdateAffected = await forTenantQuery(
|
||||||
|
prisma,
|
||||||
|
'TENANT-A',
|
||||||
|
(tx) => tx.$executeRaw`UPDATE "CalendarSource" SET "lastSyncError" = 'x' WHERE id = 'source-b1'`,
|
||||||
|
);
|
||||||
|
report(
|
||||||
|
results,
|
||||||
|
'calendarsource-gebundenes-update-ueber-kennung-allein-trifft-null-zeilen',
|
||||||
|
foreignUpdateAffected === 0,
|
||||||
|
`gebundenes UPDATE ... WHERE id = 'source-b1' (gehoert TENANT-B) unter TENANT-A trifft ${foreignUpdateAffected} Zeile(n), lastSyncError bleibt unveraendert`,
|
||||||
|
);
|
||||||
|
|
||||||
|
// 9: calendarsource-generierter-client-gebundene-quellenliste-nur-eigener-mandant
|
||||||
|
const bound = buildInlineExtendedClient(prisma, 'TENANT-A');
|
||||||
|
const clientBoundSources = await bound.calendarSource.findMany({
|
||||||
|
where: { userId: 'user-a1', isVisible: true },
|
||||||
|
});
|
||||||
|
report(
|
||||||
|
results,
|
||||||
|
'calendarsource-generierter-client-gebundene-quellenliste-nur-eigener-mandant',
|
||||||
|
clientBoundSources.length === 1 && clientBoundSources[0].id === 'source-a1',
|
||||||
|
`bound.calendarSource.findMany({ where: { userId: 'user-a1', isVisible: true } }) unter TENANT-A liefert ${clientBoundSources.length} Zeile(n): ${JSON.stringify(clientBoundSources.map((s) => s.id))} — die Abfrage, die fetchAndCacheEvents stellt`,
|
||||||
|
);
|
||||||
|
|
||||||
|
// 10: calendarsource-generierter-client-ungebundene-quellenliste-null-zeilen
|
||||||
|
const clientUnboundSources = await prisma.calendarSource.findMany({
|
||||||
|
where: { userId: 'user-a1', isVisible: true },
|
||||||
|
});
|
||||||
|
report(
|
||||||
|
results,
|
||||||
|
'calendarsource-generierter-client-ungebundene-quellenliste-null-zeilen',
|
||||||
|
clientUnboundSources.length === 0,
|
||||||
|
`dieselbe Abfrage auf dem UNGEBUNDENEN generierten Client liefert ${clientUnboundSources.length} Zeile(n) ohne Fehler — exakt der Wert, den getSources als "keine Quelle" und fetchAndCacheEvents als "keine Termine" weiterreicht`,
|
||||||
|
);
|
||||||
|
|
||||||
|
// 11: calendarsource-generierter-client-gebundenes-update-auf-unsichtbare-zeile-scheitert-laut
|
||||||
|
let updateOnInvisibleThrew = false;
|
||||||
|
let updateOnInvisibleDetail = '';
|
||||||
|
try {
|
||||||
|
await bound.calendarSource.update({
|
||||||
|
where: { id: 'source-b1' },
|
||||||
|
data: { lastSyncError: 'x' },
|
||||||
|
});
|
||||||
|
updateOnInvisibleDetail =
|
||||||
|
'bound.calendarSource.update unter TENANT-A auf die unter TENANT-B unsichtbare Zeile (source-b1) ist NICHT fehlgeschlagen';
|
||||||
|
} catch (err) {
|
||||||
|
updateOnInvisibleThrew = true;
|
||||||
|
const ctor = err?.constructor?.name ?? 'unbekannt';
|
||||||
|
updateOnInvisibleDetail = `bound.calendarSource.update unter TENANT-A auf die unter TENANT-B unsichtbare Zeile (source-b1) wirft ${ctor}${err?.code ? ` (code ${err.code})` : ''}: ${(err.message ?? '').toString().trim()}`;
|
||||||
|
}
|
||||||
|
report(
|
||||||
|
results,
|
||||||
|
'calendarsource-generierter-client-gebundenes-update-auf-unsichtbare-zeile-scheitert-laut',
|
||||||
|
updateOnInvisibleThrew,
|
||||||
|
updateOnInvisibleDetail,
|
||||||
|
);
|
||||||
|
|
||||||
|
// 12: calendarsource-generierter-client-gebundenes-anlegen-eigener-mandant-gelingt
|
||||||
|
let createSucceeded = false;
|
||||||
|
let createDetail = '';
|
||||||
|
try {
|
||||||
|
const created = await bound.calendarSource.create({
|
||||||
|
data: {
|
||||||
|
userId: 'user-a1',
|
||||||
|
tenantId: 'TENANT-A',
|
||||||
|
name: 'Neu angelegte Quelle',
|
||||||
|
type: 'ics',
|
||||||
|
url: 'https://example.invalid/neu.ics',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const readBack = await bound.calendarSource.findMany({ where: { id: created.id } });
|
||||||
|
createSucceeded = readBack.length === 1;
|
||||||
|
createDetail = `bound.calendarSource.create unter TENANT-A gelingt (id=${created.id}, createdAt=${JSON.stringify(created.createdAt)}), gebunden lesbar: ${createSucceeded} — bestaetigt nebenbei, dass die Wegwerf-Tabelle die clientseitig erzeugten Werte (id, createdAt, updatedAt) annimmt`;
|
||||||
|
} catch (err) {
|
||||||
|
createDetail = `bound.calendarSource.create unter TENANT-A ist fehlgeschlagen: ${err.message}`;
|
||||||
|
}
|
||||||
|
report(
|
||||||
|
results,
|
||||||
|
'calendarsource-generierter-client-gebundenes-anlegen-eigener-mandant-gelingt',
|
||||||
|
createSucceeded,
|
||||||
|
createDetail,
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
await prisma.$disconnect();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Aufgabe 1 (260909-jts), TEIL 2 — misst, welche der drei Transaktionsformen
|
* Aufgabe 1 (260909-jts), TEIL 2 — misst, welche der drei Transaktionsformen
|
||||||
* den Mandantenkontext auf DERSELBEN Verbindung ueber alle Teilschritte
|
* den Mandantenkontext auf DERSELBEN Verbindung ueber alle Teilschritte
|
||||||
@@ -3001,6 +3332,7 @@ async function main() {
|
|||||||
await runUserAreaChecks(adminUrl, scratchRoleUrlString, results);
|
await runUserAreaChecks(adminUrl, scratchRoleUrlString, results);
|
||||||
await runModuleRegistryAreaChecks(adminUrl, scratchRoleUrlString, results);
|
await runModuleRegistryAreaChecks(adminUrl, scratchRoleUrlString, results);
|
||||||
await runDashboardAreaChecks(adminUrl, scratchRoleUrlString, results);
|
await runDashboardAreaChecks(adminUrl, scratchRoleUrlString, results);
|
||||||
|
await runCalendarAreaChecks(adminUrl, scratchRoleUrlString, results);
|
||||||
await runTransactionShapeMeasurement(scratchRoleUrlString, results);
|
await runTransactionShapeMeasurement(scratchRoleUrlString, results);
|
||||||
await runConcurrencyProbe(scratchRoleUrlString, results);
|
await runConcurrencyProbe(scratchRoleUrlString, results);
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -2000,6 +2000,265 @@ Abweichung von seiner eigenen Auswahl liest, bleibt das unbemerkt.
|
|||||||
- Schema und Migrationen — geprüft und bewusst gelassen, keine
|
- Schema und Migrationen — geprüft und bewusst gelassen, keine
|
||||||
Schemaänderung in dieser Etappe.
|
Schemaänderung in dieser Etappe.
|
||||||
|
|
||||||
|
## Bereich calendar
|
||||||
|
|
||||||
|
Dieser Abschnitt erweitert die Kritikschrift um den Bereich `calendar`
|
||||||
|
(Quick-Task 260911-cwh), den neunten Bereich der Etappe und den einzigen
|
||||||
|
Dienst, der nicht bloß Daten hält, sondern ZUGANGSDATEN zu fremden Servern —
|
||||||
|
die verschlüsselten Exchange-, CalDAV- und ICS-Anmeldungen eines Nutzers.
|
||||||
|
Ein Quer-Lesen ist hier nicht Offenlegung eines Termins, sondern Offenlegung
|
||||||
|
der Anmeldung einer anderen Firma bei ihrem Mailserver. Die umgekehrte
|
||||||
|
Fehlerrichtung sieht hier nicht wie ein Fehler aus, sondern wie ein leerer
|
||||||
|
Kalender — und das Frontend verstärkt das, siehe (k3).
|
||||||
|
|
||||||
|
### (k1) Die Messung
|
||||||
|
|
||||||
|
Aufgabe 1 hat `apps/api/scripts/rls-scratch-check.mjs` um einen elften
|
||||||
|
Abschnitt (`runCalendarAreaChecks`) erweitert, unmittelbar nach
|
||||||
|
`runDashboardAreaChecks` und vor `runTransactionShapeMeasurement` aufgerufen.
|
||||||
|
Er legt die Wegwerf-Tabelle `CalendarSource` selbst neu an, mit SÄMTLICHEN
|
||||||
|
17 Spalten des Modells (nicht nur den für Roh-SQL nötigen — die Lehre aus
|
||||||
|
Prüfung 5b im Bereich `dashboard`: der generierte Client wählt standardmäßig
|
||||||
|
JEDE Spalte des Modells aus und scheitert mit P2022 an jeder fehlenden), mit
|
||||||
|
der Regel `extractPolicySql()` WORTGLEICH aus der ausgelieferten Migration
|
||||||
|
`20260909140000_rls_remaining_tenant_tables` geschnitten, **gemessen am
|
||||||
|
Regelstand NACH der Migration
|
||||||
|
`20260910120000_rls_widen_membership_grant_and_platform_read`**. Diese
|
||||||
|
zweite Migration wird zur LAUFZEIT geprüft (Prüfung
|
||||||
|
`calendarsource-regelstand-eindeutig`), nicht nur zur Planungszeit behauptet:
|
||||||
|
`extractPolicySql(readRlsWidenMigrationSql(), 'CalendarSource')` liefert
|
||||||
|
`null` — jene Migration trägt KEINE eigene Regel für `CalendarSource`, der
|
||||||
|
Stand aus `20260909140000` ist weiterhin der ausgelieferte, aktuelle
|
||||||
|
Regelstand. Fände sich dort doch eine Regel, bräche der Abschnitt mit einer
|
||||||
|
FEHLGESCHLAGENEN Prüfung ab, statt die abgelöste Regel weiterzumessen — die
|
||||||
|
Messfalle aus 260910-jab.
|
||||||
|
|
||||||
|
Vier der dreizehn neuen Prüfungen laufen über den GENERIERTEN Prisma-Client
|
||||||
|
(nicht nur an rohem SQL), weil `calendar.service.ts` in Wahrheit
|
||||||
|
`this.prisma.calendarSource.findMany/update/create()` aufruft, nicht
|
||||||
|
`$executeRaw` — dieselbe dashboard-Lehre: Prüfung 8
|
||||||
|
(`calendarsource-wegwerftabelle-deckt-alle-spalten-des-generierten-clients`)
|
||||||
|
vergleicht die zur Laufzeit aus `schema.prisma` gelesenen 17 Feldnamen des
|
||||||
|
Modells `CalendarSource` mit den tatsächlichen Spalten der Wegwerf-Tabelle
|
||||||
|
über `information_schema.columns` — sie steht VOR den Client-Prüfungen 9-12
|
||||||
|
und bricht den Abschnitt ab, wenn sie durchfällt, weil alle folgenden
|
||||||
|
Client-Messungen sonst wertlos wären.
|
||||||
|
|
||||||
|
Tatsächlich beobachtete Ausgabe dieses Laufs (2026-09-11, gegen
|
||||||
|
`tessera-ctl-db-1`, Adresse `172.19.0.2`, nur die dreizehn neuen Zeilen
|
||||||
|
dieses Abschnitts sowie die abschließende Summenzeile):
|
||||||
|
|
||||||
|
```
|
||||||
|
calendarsource-regelstand-eindeutig: bestanden — 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
|
||||||
|
calendarsource-wegwerftabelle-deckt-alle-spalten-des-generierten-clients: bestanden — Schema-Felder aus schema.prisma (model CalendarSource, 17): ["color","createdAt","domain","encryptedPassword","exchangeMode","id","isVisible","lastSyncAt","lastSyncError","name","syncIntervalMin","tenantId","type","updatedAt","url","userId","username"]; Spalten der Wegwerf-Tabelle (17): ["color","createdAt","domain","encryptedPassword","exchangeMode","id","isVisible","lastSyncAt","lastSyncError","name","syncIntervalMin","tenantId","type","updatedAt","url","userId","username"]
|
||||||
|
calendarsource-gebunden-nur-eigener-mandant: bestanden — forTenant(TENANT-A) liefert 2 Zeile(n): ["source-a1","source-a2"]
|
||||||
|
calendarsource-fremder-nutzer-desselben-mandanten-gebunden-sichtbar: bestanden — forTenant(TENANT-A) liefert die Quelle von 'user-a2' (anderer Benutzer, gleicher Mandant) mit encryptedPassword="enc(a2-passwort-platzhalter)" — 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
|
||||||
|
calendarsource-ungebunden-null-zeilen: bestanden — ungebundener SELECT auf "CalendarSource" liefert 0 Zeile(n), tatsaechlich vorhanden sind 3
|
||||||
|
calendarsource-ungebundene-einzelabfrage-ueber-kennung-liefert-keine-zeile: bestanden — ungebundenes SELECT ueber die Kennung 'source-b1' (vorhanden) liefert 0 Zeile(n) — die Datenbankseite der drei Besitzpruefungen: ein ungebundenes Nachschlagen ueber eine vorhandene Kennung liefert null Zeilen, das ist der Weg in NotFoundException
|
||||||
|
calendarsource-gebundenes-einfuegen-fremder-mandant-abgelehnt: bestanden — gebundenes INSERT unter TENANT-A mit tenantId=TENANT-B abgewiesen mit SQLSTATE 42501 (Invalid `prisma.$executeRaw()` invocation: Raw query failed. Code: `42501`. Message: `ERROR: new row violates row-level security policy for table "CalendarSource"`)
|
||||||
|
calendarsource-gebundenes-loeschen-fremder-zeile-trifft-keine-zeile: bestanden — gebundenes DELETE unter TENANT-A ueber die Kennung 'source-b1' (gehoert TENANT-B) trifft 0 Zeile(n); ueber die Wartungsrolle ist die Zeile danach noch vorhanden: true
|
||||||
|
calendarsource-gebundenes-update-ueber-kennung-allein-trifft-null-zeilen: bestanden — gebundenes UPDATE ... WHERE id = 'source-b1' (gehoert TENANT-B) unter TENANT-A trifft 0 Zeile(n), lastSyncError bleibt unveraendert
|
||||||
|
calendarsource-generierter-client-gebundene-quellenliste-nur-eigener-mandant: bestanden — bound.calendarSource.findMany({ where: { userId: 'user-a1', isVisible: true } }) unter TENANT-A liefert 1 Zeile(n): ["source-a1"] — die Abfrage, die fetchAndCacheEvents stellt
|
||||||
|
calendarsource-generierter-client-ungebundene-quellenliste-null-zeilen: bestanden — dieselbe Abfrage auf dem UNGEBUNDENEN generierten Client liefert 0 Zeile(n) ohne Fehler — exakt der Wert, den getSources als "keine Quelle" und fetchAndCacheEvents als "keine Termine" weiterreicht
|
||||||
|
calendarsource-generierter-client-gebundenes-update-auf-unsichtbare-zeile-scheitert-laut: bestanden — bound.calendarSource.update unter TENANT-A auf die unter TENANT-B unsichtbare Zeile (source-b1) wirft PrismaClientKnownRequestError (code P2025): Invalid `prisma.calendarSource.update()` invocation: An operation failed because it depends on one or more records that were required but not found. No record was found for an update.
|
||||||
|
calendarsource-generierter-client-gebundenes-anlegen-eigener-mandant-gelingt: bestanden — bound.calendarSource.create unter TENANT-A gelingt (id=62b383e2-4768-48f2-997a-cc5251174f8b, createdAt="2026-09-11T07:44:53.869Z"), gebunden lesbar: true — bestaetigt nebenbei, dass die Wegwerf-Tabelle die clientseitig erzeugten Werte (id, createdAt, updatedAt) annimmt
|
||||||
|
Alle 101 Pruefungen bestanden.
|
||||||
|
```
|
||||||
|
|
||||||
|
Dreizehn neue Prüfungen (101 = 88 + 13), nicht zwölf wie in der Aufzählung
|
||||||
|
des Plans namentlich vorgezeichnet — die dreizehnte
|
||||||
|
(`calendarsource-regelstand-eindeutig`) wurde ergänzt, weil sie die
|
||||||
|
Messfalle aus 260910-jab zur LAUFZEIT prüft statt sie nur als Planungsprosa
|
||||||
|
festzuhalten, derselbe Grund, aus dem der Bereich `dashboard` eine
|
||||||
|
dreizehnte Prüfung ergänzt hatte.
|
||||||
|
|
||||||
|
**Die tragende Belegzeile ist `calendarsource-ungebunden-null-zeilen`:** der
|
||||||
|
IDENTISCHE `SELECT "tenantId" FROM "CalendarSource"` ohne vorheriges
|
||||||
|
`set_config` liefert **0 Zeilen**, nicht die 3 tatsächlich vorhandenen — an
|
||||||
|
der echten, ausgelieferten Policy gemessen.
|
||||||
|
|
||||||
|
**Das Wettlauf-Ergebnis aus Befund H/K ist gemessen, nicht vorweggenommen —
|
||||||
|
und es ist eine ANDERE Fehlerklasse als im Bereich `dashboard`.** Ein
|
||||||
|
gebundenes `bound.calendarSource.update({ where: { id: 'source-b1' }, ... })`
|
||||||
|
unter TENANT-A auf die unter TENANT-B unsichtbare Zeile wirft eine
|
||||||
|
**`PrismaClientKnownRequestError` mit `.code === 'P2025'`** ("Record to
|
||||||
|
update not found") — NICHT die `PrismaClientUnknownRequestError`, die
|
||||||
|
`dashboardLayout.upsert()` im Bereich `dashboard` bei genau diesem
|
||||||
|
Wettlauf-Fall geworfen hat. Der Unterschied erklärt sich aus der Form des
|
||||||
|
Zugriffs: `dashboardLayout.upsert()` löst ein `INSERT ... ON CONFLICT`
|
||||||
|
aus, das die RLS-`USING`-Klausel beim `INSERT`-Zweig verletzt (SQLSTATE
|
||||||
|
`42501`, von Prisma als unbekannter Fehler durchgereicht); ein einfaches
|
||||||
|
`calendarSource.update({ where: { id } })` ohne Konfliktbehandlung sieht
|
||||||
|
unter der gebundenen Regel schlicht KEINE passende Zeile — dasselbe
|
||||||
|
Verhalten wie ein `UPDATE` über eine nicht existierende Kennung, das Prisma
|
||||||
|
grundsätzlich als P2025 meldet. **Das bedeutet für Aufgabe 2: keine der drei
|
||||||
|
Besitzprüfungsschreibpfade (`updateSource`/`deleteSource`/`testConnection`)
|
||||||
|
braucht eine neue Fehlerübersetzung** — P2025 wäre ohnehin nicht der Pfad,
|
||||||
|
über den ein Nutzer diese Zeile erreicht (die vorgeschaltete
|
||||||
|
`findUnique`-Besitzprüfung fängt den Fall vorher über `NotFoundException`
|
||||||
|
ab), sondern ausschließlich der Wettlauf-Fall der Aggregationsschleife
|
||||||
|
(Befund K, siehe (k2)).
|
||||||
|
|
||||||
|
### (k2) Signaltabelle je umgestelltem Pfad
|
||||||
|
|
||||||
|
| Pfad | Verhalten bei zu wenig Ergebnis | Konkretes Signal, Ort | Frontend lässt Signal durch? |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `CalendarService.getSources` | Der gebundene Lesezugriff liefert eine leere Liste statt der vorhandenen Quellen | `GET /calendar/sources` liefert `[]`, Status 200, kein Fehler | Nein — `calendar-widget.tsx` zeigt `emptyNoSources` ("keine Quelle eingerichtet"), `calendar-settings-panel.tsx` zeigt `sourceEmpty`; beide ununterscheidbar vom echten Erstbenutzer-Zustand |
|
||||||
|
| `CalendarService.addSource` | Kein Leere-Fall in diese Richtung — die Mandantenkennung ist Pflichtparameter | Schlägt ohne Mandant im Controller bereits mit `ForbiddenException` fehl | — |
|
||||||
|
| `CalendarService.updateSource`, Besitzprüfung | Die gebundene `findUnique`-Abfrage liefert `null` statt der eigenen Zeile — ununterscheidbar vom echten "gehört jemand anderem" | `NotFoundException('Calendar source not found')` — dieselbe Meldung wie beim echten Besitzverstoß, siehe (k4)(d) für 403/404 | `calendar-settings-panel.tsx` fängt Schreibfehler bei `handleVisibilityToggle` (`catch { revert }`) bzw. beim Formular (`saveError`/`editSaveError`) — sichtbar als Fehlermeldung, NICHT als leerer Zustand |
|
||||||
|
| `CalendarService.deleteSource`, Besitzprüfung | Wie bei `updateSource`: `null` statt der eigenen Zeile | `NotFoundException` — dieselbe Meldung wie beim echten Besitzverstoß | Wie oben — Löschfehler werden sichtbar gemeldet, nicht verschluckt |
|
||||||
|
| `CalendarService.testConnection`, Besitzprüfung | Wie oben; zusätzlich der Wettlauf-Fall der beiden Rückschreibungen bei Erfolg/Fehler (Befund K) | `NotFoundException` bei fehlender/fremder Zeile; ein Wettlauf zwischen Nachschlagen und Rückschreiben würfe gemessen `PrismaClientKnownRequestError` (P2025, siehe (k1)) — heute strukturell ausgeschlossen, weil derselbe `tenantPrisma`-Klient beide Schritte trägt | `testResults`-State zeigt `'error'` — sichtbar, nicht verschluckt |
|
||||||
|
| `CalendarService.fetchAndCacheEvents` | Der gebundene Lesezugriff auf die Quellenliste liefert eine leere Liste statt der sichtbaren Quellen — `if (sources.length === 0) return [];` kehrt VOR dem Cache-Eintrag zurück, ein leeres Ergebnis wird also NIE zwischengespeichert, jeder Aufruf misst neu leer. Der Wettlauf-Fall der beiden Synchronstatus-Rückschreibungen (Befund K) ist gemessen: `PrismaClientKnownRequestError` (P2025) — strukturell ausgeschlossen durch EINEN `tenantPrisma`-Klient je Aufruf | `GET /calendar/events` liefert `[]`, Status 200, kein Fehler, keine Protokollzeile | Nein — `calendar-widget.tsx` zeigt bei leerem `events` (aber `hasSources === true`) `emptyNoEvents` ("keine Termine"); ein LAUTER Fehler von `fetchEvents()` ODER `fetchSources()` landet im selben `catch` und setzt ebenfalls `events = []` — die Unterscheidung zwischen "leer" und "Fehler" existiert im State nicht |
|
||||||
|
| `CalendarService.refreshCacheInBackground` | Ruft `fetchAndCacheEvents` mit der Mandantenkennung der urspünglichen Anfrage auf (kein eigener Kontext, siehe Befund B) — dasselbe Leere-Verhalten wie oben, zusätzlich verschluckt durch `.catch((error) => this.logger.warn(...))`: selbst ein LAUTER Fehler der Hintergrundauffrischung erzeugt nur eine Protokollzeile, kein Signal an den Aufrufer, der die Antwort bereits erhalten hat | Kein HTTP-Signal — die auslösende Anfrage ist bereits beantwortet, bevor die Hintergrundauffrischung beginnt | Kann das Frontend strukturell nicht erreichen — die Anfrage, die es ausgelöst hat, ist längst beantwortet |
|
||||||
|
|
||||||
|
### (k3) Welcher Code Leere als Abwesenheit deutet
|
||||||
|
|
||||||
|
**Backend, zwei Stellen (Befund I):** `CalendarService.getSources` liefert
|
||||||
|
bei null Treffern eine leere Liste, Status 200 — dieselbe Deutung wie überall
|
||||||
|
in dieser Etappe. `CalendarService.fetchAndCacheEvents`,
|
||||||
|
`if (sources.length === 0) return [];`: kehrt VOR dem Cache-Eintrag zurück,
|
||||||
|
ein leeres Quellenergebnis wird also nicht einmal für die TTL festgehalten,
|
||||||
|
sondern bei JEDEM Aufruf neu leer gemessen — anders als ein echter
|
||||||
|
Cache-Treffer, der fünf Minuten stehen bleibt. Die drei
|
||||||
|
Besitzprüfungspfade (`updateSource`, `deleteSource`, `testConnection`) sind
|
||||||
|
dagegen LAUT: ein zu kleines Nachschlagen wirft `NotFoundException`.
|
||||||
|
|
||||||
|
**Frontend, drei Dateien, zur Ausführungszeit erneut nachgeprüft (Befund
|
||||||
|
J), nicht aus dem Plan abgeschrieben — dieser Plan ändert an KEINER der drei
|
||||||
|
Dateien etwas:**
|
||||||
|
|
||||||
|
1. `apps/web/src/components/dashboard/widgets/calendar-widget.tsx`, Zeile
|
||||||
|
37: `if (sources.length === 0)` setzt `hasSources = false`, gerendert als
|
||||||
|
`emptyNoSources` ("keine Quelle eingerichtet"). Zeile 50:
|
||||||
|
`catch { // Silent fail — show empty state }` fängt jeden Fehler von
|
||||||
|
`fetchSources()` ODER `fetchEvents()` — bei einem Fehler bleibt
|
||||||
|
`hasSources` jedoch beim vorherigen Wert (nicht `false`) und `events`
|
||||||
|
wird auf `[]` gesetzt, wodurch die Render-Logik in den Zweig
|
||||||
|
`events.length === 0` fällt und `emptyNoEvents` ("keine Termine")
|
||||||
|
zeigt — NICHT `emptyNoSources`. Ein LAUTER Fehler (403, 500,
|
||||||
|
Netzwerkfehler) auf `GET /calendar/sources` oder `GET /calendar/events`
|
||||||
|
ist damit für den Nutzer vom echten "Quellen vorhanden, aber gerade
|
||||||
|
keine Termine" nicht zu unterscheiden — beide zeigen `emptyNoEvents`.
|
||||||
|
2. `apps/web/src/components/settings/calendar-settings-panel.tsx`, Zeile
|
||||||
|
49: `.catch(() => { // Silent fail — show empty state })` — hier bleibt
|
||||||
|
`sources` beim initialen `[]`, unabhängig davon, ob `fetchSources()` eine
|
||||||
|
echte leere Antwort ODER einen LAUTEN Fehler liefert; Zeile 127:
|
||||||
|
`sources.length === 0` zeigt `sourceEmpty`. Auf dieser Seite sind "keine
|
||||||
|
Quelle" und "Fehler beim Laden" damit VOLLSTÄNDIG ununterscheidbar — eine
|
||||||
|
schärfere Form derselben Verschluckung als im Widget.
|
||||||
|
3. `apps/web/src/components/settings/calendar-source-form.tsx`, Zeile 149:
|
||||||
|
`if (password) payload.password = password;` — nicht Leere, sondern
|
||||||
|
Weglassen; gehört hierhin, weil es die Erhaltungsfrage beantwortet (siehe
|
||||||
|
(k4)(b)): ein leer gelassenes Passwortfeld wird aus dem Sendeobjekt
|
||||||
|
WEGGELASSEN, nicht als leere Zeichenkette übertragen.
|
||||||
|
|
||||||
|
**Die Folgekette, abgegrenzt gegen `dashboard` (Befund J):** leerer Kalender
|
||||||
|
oder leere Quellenliste → der Nutzer liest das als "die Synchronisation ist
|
||||||
|
kaputt" oder "ich habe keine Quelle eingerichtet" → er legt seine Quelle
|
||||||
|
NEU an (`addSource`, gebunden, gelingt) → er tippt sein Exchange- oder
|
||||||
|
CalDAV-Passwort ein ZWEITES MAL in ein System, das gerade aussieht, als wäre
|
||||||
|
es defekt → die ursprüngliche Zeile bleibt unsichtbar liegen. Anders als bei
|
||||||
|
`dashboard` wird dabei NICHTS überschrieben (`CalendarSource` hat keinen
|
||||||
|
automatischen Rückschreibpfad wie `setEditMode` im Dashboard) — die
|
||||||
|
Zerstörung ist hier keine verlorene Aufzeichnung, sondern eine preisgegebene
|
||||||
|
Anmeldung: der Nutzer gibt Zugangsdaten zu einem fremden Mailserver in ein
|
||||||
|
System ein, das er für kaputt hält, und sobald die Ursache behoben ist
|
||||||
|
(Etappe 4), liegen ZWEI Quellen mit ZWEI Sätzen von Zugangsdaten für
|
||||||
|
denselben Server vor — Dubletten bei Quellen UND bei Terminen.
|
||||||
|
|
||||||
|
**Die Fehlerverschluckung als eigener Punkt:** selbst ein LAUTER
|
||||||
|
Backend-Fehler auf `GET /calendar/sources` oder `GET /calendar/events` (403,
|
||||||
|
500, Netzwerkfehler) ist für den Nutzer vom leeren Kalender nicht zu
|
||||||
|
unterscheiden — das Signal existiert ausschließlich im Netzwerkprotokoll des
|
||||||
|
Browsers und im API-Log, an keiner Stelle in der Benutzeroberfläche.
|
||||||
|
|
||||||
|
### (k4) Was dieser Durchlauf bewusst nicht löst
|
||||||
|
|
||||||
|
- **(a) Das Urteil zum Cache-Schlüssel**, vierteilig belegt, jedes Glied
|
||||||
|
einzeln nachgesehen: `eventCache` (Zeile 109 alt) wird mit
|
||||||
|
`${userId}:${from}:${to}` beschlüsselt. `userId` kommt aus
|
||||||
|
`calendar.controller.ts` `extractContext` (`req.user?.id`); das ist laut
|
||||||
|
`apps/api/src/auth/strategies/jwt.strategy.ts` `validate` (Zeile 27-34)
|
||||||
|
wörtlich `id: payload.sub`; `payload.sub` ist laut
|
||||||
|
`apps/api/src/auth/auth.service.ts` (Zeilen 143 und 332, beide
|
||||||
|
`sub: user.id`) die Datenbankkennung `User.id`; die trägt laut
|
||||||
|
`apps/api/prisma/schema.prisma` `model User` `@id @default(uuid())`.
|
||||||
|
**Urteil:** der Schlüssel trägt eine plattformweit eindeutige UUID, kein
|
||||||
|
Anmeldename — die Etappe-3-Entscheidung (1) des Users (Anmeldenamen
|
||||||
|
eindeutig PRO MANDANT statt plattformweit) betrifft `User.username` und
|
||||||
|
`User.email`, nicht `User.id`, und berührt den Schlüssel deshalb NICHT.
|
||||||
|
Der Schlüssel bleibt unverändert; das Urteil steht zusätzlich als
|
||||||
|
Kommentar unmittelbar über der `eventCache`-Zuweisung in
|
||||||
|
`calendar.service.ts` (Aufgabe 2).
|
||||||
|
- **(b) Der Befund zur Zugangsdaten-Erhaltung (Befund E)**, an vier Stellen
|
||||||
|
zur Ausführungszeit nachgelesen: `calendar.service.ts` `updateSource`
|
||||||
|
besitzt KEINEN Lesezugriff, der ein gespeichertes Passwort lädt, um es neu
|
||||||
|
zu verschlüsseln — die `dkv`-Form (lesen, entschlüsseln, neu
|
||||||
|
verschlüsseln) existiert hier nicht. Stattdessen: `if (dto.password !== undefined)`
|
||||||
|
entscheidet, ob überhaupt geschrieben wird — Feld FEHLT im Rumpf →
|
||||||
|
`encryptedPassword` bleibt im `update`-Aufruf gänzlich unerwähnt und damit
|
||||||
|
in der Datenbank unverändert; Feld LEER (`''`) → wird explizit auf `null`
|
||||||
|
gesetzt (Löschen); Feld GESETZT → wird verschlüsselt. Auf der Web-Seite
|
||||||
|
(`calendar-source-form.tsx`, Zeile 149) wird ein leer gelassenes
|
||||||
|
Passwortfeld WEGGELASSEN, nicht als leere Zeichenkette gesendet — die
|
||||||
|
Erhaltung läuft also über das Weglassen des Felds im Rumpf, nicht über
|
||||||
|
einen Lesezugriff, der nach dem Scharfschalten leerlaufen könnte. Die
|
||||||
|
beiden lesenden Stellen (`testConnection`, `fetchAndCacheEvents`)
|
||||||
|
entschlüsseln nur, um den Provider aufzurufen, und schreiben nichts
|
||||||
|
Entschlüsseltes zurück. Als drei Testfälle in Aufgabe 2 festgenagelt.
|
||||||
|
- **(c) Die fehlende Benutzerdimension der Regel (Befund G)**, gemessen in
|
||||||
|
(k1) (`calendarsource-fremder-nutzer-desselben-mandanten-gebunden-sichtbar`):
|
||||||
|
die verschlüsselten Exchange-/CalDAV-Zugangsdaten eines Kollegen
|
||||||
|
DESSELBEN Mandanten sind auf Datenbankebene lesbar, bis die
|
||||||
|
Etappe-3-Entscheidung (2) des Users die Benutzerdimension in die Regel
|
||||||
|
aufnimmt (`CalendarSource` steht dort ausdrücklich in der Liste). Bis
|
||||||
|
dahin bleiben der `userId`-Filter in `getSources`/`fetchAndCacheEvents`
|
||||||
|
und die drei Besitzprüfungen der EINZIGE Schutz.
|
||||||
|
- **(d) 403 statt 404 als Existenzpreisgabe zwischen Kollegen (Befund D):**
|
||||||
|
`updateSource`, `deleteSource` und `testConnection` werfen bei fremdem
|
||||||
|
Besitz `ForbiddenException('Not your calendar source')` (403), bei
|
||||||
|
unbekannter Kennung `NotFoundException('Calendar source not found')`
|
||||||
|
(404) — ein Kollege DESSELBEN Mandanten erfährt über 403 die Existenz
|
||||||
|
einer fremden Quellenkennung, ein Nutzer eines FREMDEN Mandanten bekommt
|
||||||
|
nach der Bindung durchgängig 404 (die Zeile ist für ihn unsichtbar).
|
||||||
|
Kennungen sind UUIDs, nicht erratbar. Die Antwortsemantik wird von diesem
|
||||||
|
Plan NICHT geändert (wäre eine API-Änderung außerhalb des Auftrags).
|
||||||
|
- **(e) Die fehlende Unterscheidbarkeit von "keine Quelle" und "Quelle
|
||||||
|
nicht sichtbar".** Beide liefern identisch eine leere Liste, Status 200,
|
||||||
|
keinen Protokolleintrag — siehe (k3). Die konkrete Vorabprüfung für
|
||||||
|
Etappe 4 (`rls-preflight.mjs`): physisch vorhandene
|
||||||
|
`CalendarSource`-Zeilen je Mandant über die Wartungsrolle zählen und mit
|
||||||
|
der gebundenen Zählung je Mandant vergleichen — jede Abweichung ist ein
|
||||||
|
Trennungsfehler, kein Erstbenutzer. Eine Laufzeitwarnung an
|
||||||
|
`getSources`/`fetchAndCacheEvents` wurde erwogen und VERWORFEN, mit
|
||||||
|
derselben Begründung wie bei `getAllActiveConfigs` im Bereich `ldap` und
|
||||||
|
bei `getLayout`/`getWidgets`/`getSearchProviders` im Bereich `dashboard`:
|
||||||
|
keine Quelle ist auf einer frischen Installation oder für einen neuen
|
||||||
|
Nutzer der NORMALZUSTAND — eine Warnung an dieser Stelle wäre Dauerlärm
|
||||||
|
und verlöre ihr Signal, bevor sie gebraucht wird.
|
||||||
|
|
||||||
|
### (k5) Was dieser Durchlauf bewusst nicht anfasst
|
||||||
|
|
||||||
|
- **Das Frontend** — geprüft (Befund I/J, (k3) oben) und bewusst gelassen,
|
||||||
|
keine Datei dieses Plans. `calendar-widget.tsx`,
|
||||||
|
`calendar-settings-panel.tsx` und `calendar-source-form.tsx` werden NICHT
|
||||||
|
geändert.
|
||||||
|
- **Die drei Provider** (`ics.provider.ts`, `caldav.provider.ts`,
|
||||||
|
`exchange.provider.ts`) — reden mit echten Servern, werden in Aufgabe 2
|
||||||
|
NICHT ausgeübt, nur als Attrappen (`fetchEvents`/`testConnection` als
|
||||||
|
`vi.fn`) verwendet.
|
||||||
|
- **`testConnectionFromConfig`** — kein Datenbankzugriff, kein Mandant
|
||||||
|
(prüft eine Konfiguration, bevor sie gespeichert wird), unverändert.
|
||||||
|
- **Der Bereich `favorites`** — eigener Bereich mit eigener Umstellung,
|
||||||
|
nicht Teil dieses Plans.
|
||||||
|
- **Die Antwortsemantik 403/404** — siehe (k4)(d), bewusst nicht geändert.
|
||||||
|
- **Der Fremdkommentar in `apps/api/src/ldap/ldap-config.service.ts`**
|
||||||
|
(Zeile 24, nennt `CalendarSource` als Vorbild der Verschlüsselung) —
|
||||||
|
zutreffend (`CalendarCryptoService`, siehe Dezision `07-01` in
|
||||||
|
STATE.md), nicht zu ändern.
|
||||||
|
- Schema und Migrationen — geprüft und bewusst gelassen, keine
|
||||||
|
Schemaänderung in dieser Etappe.
|
||||||
|
|
||||||
## Verweis
|
## Verweis
|
||||||
|
|
||||||
Die Bestandsaufnahme, welche Fundstelle den hier beschriebenen Übergang
|
Die Bestandsaufnahme, welche Fundstelle den hier beschriebenen Übergang
|
||||||
|
|||||||
Reference in New Issue
Block a user