07fc653f52
- 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
260 lines
10 KiB
TypeScript
260 lines
10 KiB
TypeScript
import { ConflictException } from '@nestjs/common';
|
|
import { describe, expect, it, vi } from 'vitest';
|
|
import { TenderTriageService } from './tender-triage.service';
|
|
import { forTenant } from '../prisma/prisma-tenant.extension';
|
|
|
|
/**
|
|
* TenderTriageService.spec — RED-first (TDD) proof for UI-03/04 (D-09/D-10/
|
|
* D-11) and the V4/IDOR access-control invariant (T-11-10):
|
|
*
|
|
* - setTriage() is idempotent: repeated calls with the same params upsert
|
|
* onto exactly one row (@@unique([userId,tenderId]) target).
|
|
* - listForUser()/favoriteIds() are scoped strictly by userId — a foreign
|
|
* userId must see nothing, even for the same tenderId (IDOR).
|
|
* - Cascade: once a tender's triage rows are removed (simulating the DB's
|
|
* `onDelete: Cascade` FK — real enforcement is verified separately
|
|
* against the applied migration on the live dev DB), the service's read
|
|
* path must not leak orphaned rows (Pitfall 6).
|
|
*
|
|
* Bindung an forTenant() (260909-laa, Befund C/H): `__makeBoundClient()`
|
|
* wraps the SAME in-memory Map with a per-model, per-call logging layer —
|
|
* a pure identity mock (`(p) => p`, the ldap-era mistake) would leave a
|
|
* forgotten `forTenant()` call invisible to every test. Muster aus
|
|
* `groups.service.spec.ts` (260909-jts).
|
|
*/
|
|
|
|
vi.mock('../prisma/prisma-tenant.extension', () => ({
|
|
forTenant: vi.fn((prisma: any, tenantId: string) => prisma.__makeBoundClient(tenantId)),
|
|
}));
|
|
|
|
function makeFakePrisma() {
|
|
const rows = new Map<string, any>();
|
|
const key = (userId: string, tenderId: string) => `${userId}:${tenderId}`;
|
|
const boundCallLog: { tenantId: string; model: string; method: string }[] = [];
|
|
|
|
const tenderTriage = {
|
|
upsert: vi.fn(async ({ where, update, create }: any) => {
|
|
const k = key(where.userId_tenderId.userId, where.userId_tenderId.tenderId);
|
|
const existing = rows.get(k);
|
|
const record = existing
|
|
? { ...existing, ...update, updatedAt: new Date() }
|
|
: { id: `tt-${rows.size + 1}`, ...create, createdAt: new Date(), updatedAt: new Date() };
|
|
rows.set(k, record);
|
|
return record;
|
|
}),
|
|
findMany: vi.fn(async ({ where }: any) => {
|
|
return Array.from(rows.values()).filter((r) => {
|
|
if (where.userId !== undefined && r.userId !== where.userId) return false;
|
|
if (where.isFavorite !== undefined && r.isFavorite !== where.isFavorite) return false;
|
|
if (where.tenderId?.in && !where.tenderId.in.includes(r.tenderId)) return false;
|
|
return true;
|
|
});
|
|
}),
|
|
// Simulates the schema-level `onDelete: Cascade` FK constraint
|
|
// (Pitfall 6). Real enforcement is verified separately by applying
|
|
// the 20260721160000_add_tender_triage migration and checking
|
|
// `SELECT * FROM "TenderTriage"` after a live delete — this fake
|
|
// proves the service's read path (listForUser/favoriteIds) is
|
|
// consistent with that cascade once rows are gone.
|
|
_simulateTenderCascadeDelete: (tenderId: string) => {
|
|
for (const [k, r] of rows) {
|
|
if (r.tenderId === tenderId) rows.delete(k);
|
|
}
|
|
},
|
|
};
|
|
|
|
const fake: any = {
|
|
tenderTriage,
|
|
__boundCallLog: boundCallLog,
|
|
__makeBoundClient(tenantId: string) {
|
|
const wrapped: any = {};
|
|
for (const method of ['upsert', 'findMany']) {
|
|
wrapped[method] = async (...args: any[]) => {
|
|
boundCallLog.push({ tenantId, model: 'tenderTriage', method });
|
|
return (tenderTriage as any)[method](...args);
|
|
};
|
|
}
|
|
return { tenderTriage: wrapped };
|
|
},
|
|
};
|
|
|
|
return fake;
|
|
}
|
|
|
|
function expectBoundCall(prisma: any, tenantId: string, method: string) {
|
|
const found = prisma.__boundCallLog.some(
|
|
(c: any) => c.tenantId === tenantId && c.model === 'tenderTriage' && c.method === method,
|
|
);
|
|
expect(
|
|
found,
|
|
`erwarteter gebundener Aufruf tenderTriage.${method}(tenant=${tenantId}) fehlt im Protokoll: ${JSON.stringify(prisma.__boundCallLog)}`,
|
|
).toBe(true);
|
|
}
|
|
|
|
describe('TenderTriageService', () => {
|
|
it('setTriage is idempotent: calling twice with identical params results in exactly one row', async () => {
|
|
const prisma = makeFakePrisma();
|
|
const service = new TenderTriageService(prisma as any);
|
|
|
|
await service.setTriage('u1', 'tenant1', 't1', { isRead: true });
|
|
await service.setTriage('u1', 'tenant1', 't1', { isRead: true });
|
|
|
|
const rows = await service.listForUser('u1', 'tenant1', ['t1']);
|
|
expect(rows).toHaveLength(1);
|
|
expect(rows[0].isRead).toBe(true);
|
|
});
|
|
|
|
it('setTriage upserts isFavorite=true and stamps favoritedAt', async () => {
|
|
const prisma = makeFakePrisma();
|
|
const service = new TenderTriageService(prisma as any);
|
|
|
|
const result = await service.setTriage('u1', 'tenant1', 't1', { isFavorite: true });
|
|
|
|
expect(result.isFavorite).toBe(true);
|
|
expect(result.favoritedAt).toBeInstanceOf(Date);
|
|
});
|
|
|
|
it('setTriage toggling isRead back to false clears readAt', async () => {
|
|
const prisma = makeFakePrisma();
|
|
const service = new TenderTriageService(prisma as any);
|
|
|
|
await service.setTriage('u1', 'tenant1', 't1', { isRead: true });
|
|
const result = await service.setTriage('u1', 'tenant1', 't1', { isRead: false });
|
|
|
|
expect(result.isRead).toBe(false);
|
|
expect(result.readAt).toBeNull();
|
|
});
|
|
|
|
it('a partial update (isRead only) does not clobber a previously-set isFavorite', async () => {
|
|
const prisma = makeFakePrisma();
|
|
const service = new TenderTriageService(prisma as any);
|
|
|
|
await service.setTriage('u1', 'tenant1', 't1', { isFavorite: true });
|
|
const result = await service.setTriage('u1', 'tenant1', 't1', { isRead: true });
|
|
|
|
expect(result.isRead).toBe(true);
|
|
expect(result.isFavorite).toBe(true);
|
|
});
|
|
|
|
it('listForUser scopes strictly by userId — a foreign user sees nothing for the same tenderId (V4 / IDOR)', async () => {
|
|
const prisma = makeFakePrisma();
|
|
const service = new TenderTriageService(prisma as any);
|
|
|
|
await service.setTriage('u1', 'tenant1', 't1', { isRead: true, isFavorite: true });
|
|
|
|
const foreignRows = await service.listForUser('u2', 'tenant1', ['t1']);
|
|
expect(foreignRows).toHaveLength(0);
|
|
});
|
|
|
|
it('listForUser with an empty tenderIds array short-circuits to an empty result without querying prisma', async () => {
|
|
const prisma = makeFakePrisma();
|
|
const service = new TenderTriageService(prisma as any);
|
|
|
|
const rows = await service.listForUser('u1', 'tenant1', []);
|
|
|
|
expect(rows).toEqual([]);
|
|
expect(prisma.tenderTriage.findMany).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('favoriteIds returns only tenderIds favorited by that exact user, never another user\'s (V4 / IDOR)', async () => {
|
|
const prisma = makeFakePrisma();
|
|
const service = new TenderTriageService(prisma as any);
|
|
|
|
await service.setTriage('u1', 'tenant1', 't1', { isFavorite: true });
|
|
await service.setTriage('u1', 'tenant1', 't2', { isFavorite: false });
|
|
await service.setTriage('u2', 'tenant1', 't3', { isFavorite: true });
|
|
|
|
expect(await service.favoriteIds('u1', 'tenant1')).toEqual(['t1']);
|
|
expect(await service.favoriteIds('u2', 'tenant1')).toEqual(['t3']);
|
|
});
|
|
|
|
it('cascade: after a tender\'s triage rows are removed (DB onDelete: Cascade), it no longer appears for any user', async () => {
|
|
const prisma = makeFakePrisma();
|
|
const service = new TenderTriageService(prisma as any);
|
|
|
|
await service.setTriage('u1', 'tenant1', 't1', { isFavorite: true });
|
|
prisma.tenderTriage._simulateTenderCascadeDelete('t1');
|
|
|
|
expect(await service.listForUser('u1', 'tenant1', ['t1'])).toHaveLength(0);
|
|
expect(await service.favoriteIds('u1', 'tenant1')).toEqual([]);
|
|
});
|
|
|
|
// --- Bindung an forTenant() (260909-laa, Aufgabe 2) -----------------------
|
|
|
|
describe('Bindung an forTenant() (260909-laa)', () => {
|
|
it('setTriage() bindet tenderTriage.upsert an den uebergebenen Mandanten', async () => {
|
|
const prisma = makeFakePrisma();
|
|
const service = new TenderTriageService(prisma as any);
|
|
|
|
await service.setTriage('u1', 't1', 'tender-x', { isRead: true });
|
|
|
|
expectBoundCall(prisma, 't1', 'upsert');
|
|
// Benutzerdimension (260911-nke): forTenant() bekommt userId als drittes Argument.
|
|
expect(forTenant).toHaveBeenCalledWith(prisma, 't1', 'u1');
|
|
});
|
|
|
|
it('listForUser() bindet tenderTriage.findMany an den uebergebenen Mandanten', async () => {
|
|
const prisma = makeFakePrisma();
|
|
const service = new TenderTriageService(prisma as any);
|
|
|
|
await service.listForUser('u1', 't1', ['tender-x']);
|
|
|
|
expectBoundCall(prisma, 't1', 'findMany');
|
|
});
|
|
|
|
it('favoriteIds() bindet tenderTriage.findMany an den uebergebenen Mandanten', async () => {
|
|
const prisma = makeFakePrisma();
|
|
const service = new TenderTriageService(prisma as any);
|
|
|
|
await service.favoriteIds('u1', 't1');
|
|
|
|
expectBoundCall(prisma, 't1', 'findMany');
|
|
});
|
|
});
|
|
|
|
/**
|
|
* Gegenrichtung der Bindung (Befund F, 260909-laa). Der Eindeutigkeits-
|
|
* schluessel `@@unique([userId, tenderId])` traegt keine Mandanten-
|
|
* dimension. Ist die vorhandene Zeile unter dem gebundenen Kontext
|
|
* unsichtbar, findet das `upsert` sie nicht, versucht anzulegen und
|
|
* laeuft in die Eindeutigkeitsverletzung — aus stillem Ueberschreiben
|
|
* wird ein harter Fehler.
|
|
*
|
|
* Diese Pruefung fehlte in der ersten Lieferung von 260909-laa: die
|
|
* Zusammenfassung behauptete die Uebersetzung fuer ALLE DREI
|
|
* mandantenlosen Eindeutigkeitsschluessel, gebaut war sie nur fuer zwei.
|
|
* Vom Verifizierer gefunden, hier nachgereicht.
|
|
*/
|
|
describe('P2002 auf mandantenlosem Eindeutigkeitsschluessel (Befund F)', () => {
|
|
it('setTriage() uebersetzt die Eindeutigkeitsverletzung in eine ConflictException statt in einen 500', async () => {
|
|
const prisma = makeFakePrisma();
|
|
const verletzung: any = new Error(
|
|
'Unique constraint failed on the fields: (`userId`,`tenderId`)',
|
|
);
|
|
verletzung.code = 'P2002';
|
|
prisma.tenderTriage.upsert = vi.fn(async () => {
|
|
throw verletzung;
|
|
});
|
|
const service = new TenderTriageService(prisma as any);
|
|
|
|
await expect(
|
|
service.setTriage('u1', 't1', 'tender-x', { isFavorite: true }),
|
|
).rejects.toBeInstanceOf(ConflictException);
|
|
});
|
|
|
|
it('setTriage() reicht jeden anderen Datenbankfehler unveraendert durch', async () => {
|
|
const prisma = makeFakePrisma();
|
|
const anderer: any = new Error('Verbindung verloren');
|
|
anderer.code = 'P1001';
|
|
prisma.tenderTriage.upsert = vi.fn(async () => {
|
|
throw anderer;
|
|
});
|
|
const service = new TenderTriageService(prisma as any);
|
|
|
|
await expect(
|
|
service.setTriage('u1', 't1', 'tender-x', { isFavorite: true }),
|
|
).rejects.toBe(anderer);
|
|
});
|
|
});
|
|
});
|