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
162 lines
6.3 KiB
TypeScript
162 lines
6.3 KiB
TypeScript
import { ConflictException } from '@nestjs/common';
|
|
import { describe, expect, it, vi } from 'vitest';
|
|
import { TenderNotificationPrefService } from './tender-notification-pref.service';
|
|
import { forTenant } from '../prisma/prisma-tenant.extension';
|
|
|
|
/**
|
|
* TenderNotificationPrefService.spec — RED-first (TDD) proof for NOTIFY-01
|
|
* (D-01/D-03) and the V4/IDOR access-control invariant (T-12-14):
|
|
*
|
|
* - getForUser() without an existing row returns a default
|
|
* { digestInterval: 'daily' } (D-01) — no error, no implicit autowrite.
|
|
* - setForUser() upserts on the @@unique userId (D-03); a second call with
|
|
* a different value updates the SAME row rather than creating a new one.
|
|
* - setForUser() translates a P2002 (unique-constraint violation on a
|
|
* stale-tenant upsert, T-LAA-07/Befund F) into a German ConflictException
|
|
* instead of a raw error.
|
|
*
|
|
* Bindung an forTenant() (260909-laa, Befund C/H) — Muster aus
|
|
* `groups.service.spec.ts` (260909-jts): `__makeBoundClient()` wraps the
|
|
* SAME in-memory Map with a per-call logging layer, so a forgotten
|
|
* `forTenant()` call is visible as a missing log entry, not just a passing
|
|
* test either way.
|
|
*/
|
|
|
|
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 boundCallLog: { tenantId: string; model: string; method: string }[] = [];
|
|
|
|
function throwUniqueViolation(): never {
|
|
const err: any = new Error('Unique constraint failed on the fields: (`userId`)');
|
|
err.code = 'P2002';
|
|
throw err;
|
|
}
|
|
|
|
const tenderNotificationPref = {
|
|
findUnique: async ({ where }: any) => rows.get(where.userId) ?? null,
|
|
upsert: async ({ where, create, update }: any) => {
|
|
const existing = rows.get(where.userId);
|
|
const record = existing
|
|
? { ...existing, ...update, updatedAt: new Date() }
|
|
: { id: `pref-${rows.size + 1}`, ...create, createdAt: new Date(), updatedAt: new Date() };
|
|
rows.set(where.userId, record);
|
|
return record;
|
|
},
|
|
__throwUniqueViolationOnNextUpsert: false,
|
|
};
|
|
|
|
const fake: any = {
|
|
tenderNotificationPref,
|
|
__boundCallLog: boundCallLog,
|
|
__makeBoundClient(tenantId: string) {
|
|
const wrapped: any = {};
|
|
for (const method of ['findUnique', 'upsert']) {
|
|
wrapped[method] = async (...args: any[]) => {
|
|
boundCallLog.push({ tenantId, model: 'tenderNotificationPref', method });
|
|
return (tenderNotificationPref as any)[method](...args);
|
|
};
|
|
}
|
|
return { tenderNotificationPref: wrapped };
|
|
},
|
|
__throwUniqueViolation: throwUniqueViolation,
|
|
};
|
|
|
|
return fake;
|
|
}
|
|
|
|
function expectBoundCall(prisma: any, tenantId: string, method: string) {
|
|
const found = prisma.__boundCallLog.some(
|
|
(c: any) =>
|
|
c.tenantId === tenantId && c.model === 'tenderNotificationPref' && c.method === method,
|
|
);
|
|
expect(
|
|
found,
|
|
`erwarteter gebundener Aufruf tenderNotificationPref.${method}(tenant=${tenantId}) fehlt im Protokoll: ${JSON.stringify(prisma.__boundCallLog)}`,
|
|
).toBe(true);
|
|
}
|
|
|
|
describe('TenderNotificationPrefService', () => {
|
|
it('getForUser() returns a default digestInterval="daily" when no row exists (D-01)', async () => {
|
|
const prisma = makeFakePrisma();
|
|
const service = new TenderNotificationPrefService(prisma as any);
|
|
|
|
const result = await service.getForUser('u1', 'tenant1');
|
|
|
|
expect(result.digestInterval).toBe('daily');
|
|
});
|
|
|
|
it('setForUser() upserts on userId, creating a row scoped to (userId, tenantId) (D-03)', async () => {
|
|
const prisma = makeFakePrisma();
|
|
const service = new TenderNotificationPrefService(prisma as any);
|
|
|
|
const result = await service.setForUser('u1', 'tenant1', 'weekly');
|
|
|
|
expect(result.userId).toBe('u1');
|
|
expect(result.tenantId).toBe('tenant1');
|
|
expect(result.digestInterval).toBe('weekly');
|
|
});
|
|
|
|
it('setForUser() called a second time updates the SAME row (@@unique userId), not a new one', async () => {
|
|
const prisma = makeFakePrisma();
|
|
const service = new TenderNotificationPrefService(prisma as any);
|
|
|
|
await service.setForUser('u1', 'tenant1', 'weekly');
|
|
const second = await service.setForUser('u1', 'tenant1', 'off');
|
|
|
|
expect(second.digestInterval).toBe('off');
|
|
expect(await service.getForUser('u1', 'tenant1')).toMatchObject({ digestInterval: 'off' });
|
|
});
|
|
|
|
it('getForUser() is scoped strictly by userId — a foreign userId never sees another user\'s pref (V4 / IDOR)', async () => {
|
|
const prisma = makeFakePrisma();
|
|
const service = new TenderNotificationPrefService(prisma as any);
|
|
|
|
await service.setForUser('u1', 'tenant1', 'weekly');
|
|
|
|
const foreign = await service.getForUser('u2', 'tenant1');
|
|
expect(foreign.digestInterval).toBe('daily'); // default, not u1's 'weekly'
|
|
});
|
|
|
|
// --- Fehlerbehandlung (260909-laa, Befund F / T-LAA-07) -------------------
|
|
|
|
it('setForUser() translates a P2002 unique-constraint violation into a German ConflictException, never a raw error', async () => {
|
|
const prisma = makeFakePrisma();
|
|
prisma.tenderNotificationPref.upsert = vi.fn(async () => {
|
|
prisma.__throwUniqueViolation();
|
|
});
|
|
const service = new TenderNotificationPrefService(prisma as any);
|
|
|
|
await expect(service.setForUser('u1', 'tenant1', 'weekly')).rejects.toBeInstanceOf(
|
|
ConflictException,
|
|
);
|
|
});
|
|
|
|
// --- Bindung an forTenant() (260909-laa, Aufgabe 2) -----------------------
|
|
|
|
describe('Bindung an forTenant() (260909-laa)', () => {
|
|
it('getForUser() bindet tenderNotificationPref.findUnique an den uebergebenen Mandanten', async () => {
|
|
const prisma = makeFakePrisma();
|
|
const service = new TenderNotificationPrefService(prisma as any);
|
|
|
|
await service.getForUser('u1', 't1');
|
|
|
|
expectBoundCall(prisma, 't1', 'findUnique');
|
|
// Benutzerdimension (260911-nke): forTenant() bekommt userId als drittes Argument.
|
|
expect(forTenant).toHaveBeenCalledWith(prisma, 't1', 'u1');
|
|
});
|
|
|
|
it('setForUser() bindet tenderNotificationPref.upsert an den uebergebenen Mandanten', async () => {
|
|
const prisma = makeFakePrisma();
|
|
const service = new TenderNotificationPrefService(prisma as any);
|
|
|
|
await service.setForUser('u1', 't1', 'weekly');
|
|
|
|
expectBoundCall(prisma, 't1', 'upsert');
|
|
});
|
|
});
|
|
});
|