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:
@@ -132,6 +132,14 @@ export class TenderDigestScheduler implements OnModuleInit {
|
||||
// Je-Treffer-Haelfte, gebunden an den Mandanten DIESER
|
||||
// Kandidatenzeile (260909-laa, Aufgabe 3) — ein einziger gebundener
|
||||
// Client fuer alle Zugriffe dieses Schleifendurchlaufs.
|
||||
//
|
||||
// Bewusst OHNE Benutzer (260911-nke, Etappe 3b): dieser Scheduler ist
|
||||
// ein Hintergrunddienst, kein Nutzer-CRUD-Aufrufer — er liest UND
|
||||
// schreibt fuer den Nutzer, nicht ALS ihn eingeloggt. Die `IS NULL
|
||||
// OR`-Form der Regeln macht das zur bewussten Eigenschaft: ohne
|
||||
// `userId` sieht dieser Zugriff den ganzen Mandanten, exakt wie vor
|
||||
// der Migration. Ein Systemkontext fuer Hintergrunddienste ist
|
||||
// Etappe 3c, nicht Teil dieser Aenderung.
|
||||
const tenantPrisma = forTenant(this.prisma, tenantId) as any;
|
||||
|
||||
const pref = await tenantPrisma.tenderNotificationPref.findUnique({
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { TenderEmailConfigService } from './tender-email-config.service';
|
||||
import { forTenant } from '../prisma/prisma-tenant.extension';
|
||||
|
||||
/**
|
||||
* TenderEmailConfigService.spec — Phase 14, Plan 03 (CONFIG-02, D-06/D-07).
|
||||
@@ -375,6 +376,8 @@ describe('TenderEmailConfigService', () => {
|
||||
(c: any) => c.tenantId === 't1' && c.model === 'tenderEmailConfig' && c.method === 'findUnique',
|
||||
);
|
||||
expect(findUniqueCalls.length).toBe(2);
|
||||
// Benutzerdimension (260911-nke): forTenant() bekommt userId als drittes Argument.
|
||||
expect(forTenant).toHaveBeenCalledWith(prisma, 't1', 'user-k');
|
||||
});
|
||||
|
||||
it('saveConfig() bindet den credChanged-Lesezugriff UND das upsert an den uebergebenen Mandanten', async () => {
|
||||
|
||||
@@ -54,6 +54,12 @@ const EMAIL_CONFIG_SAFE_SELECT = {
|
||||
* uniqueness constraint on `userId` and surfaces as a translated
|
||||
* ConflictException, not a raw 500 (T-LAA-07, Befund F, Aufgabe 1).
|
||||
*
|
||||
* Benutzerdimension seit 20260911120000 (Etappe 3b, 260911-nke): every
|
||||
* `forTenant()` call above also passes `userId` as the third argument, so
|
||||
* the `tenant_isolation_policy` on TenderEmailConfig ALSO enforces
|
||||
* `userId = current_user_id()` — a second net, not a replacement for the
|
||||
* `userId @unique` ownership model above.
|
||||
*
|
||||
* Security:
|
||||
* - T-07-12: encryptedInboxCreds is excluded from every read-path select;
|
||||
* getConfigForApi returns `hasPassword: boolean` instead of the password.
|
||||
@@ -96,7 +102,7 @@ export class TenderEmailConfigService {
|
||||
* by userId (T-17-01) — a user only ever reads their own mailbox.
|
||||
*/
|
||||
async getConfigForApi(userId: string, tenantId: string) {
|
||||
const tenantPrisma = forTenant(this.prisma, tenantId) as any;
|
||||
const tenantPrisma = forTenant(this.prisma, tenantId, userId) as any;
|
||||
const safe = await tenantPrisma.tenderEmailConfig.findUnique({
|
||||
where: { userId },
|
||||
select: EMAIL_CONFIG_SAFE_SELECT,
|
||||
@@ -146,7 +152,7 @@ export class TenderEmailConfigService {
|
||||
*/
|
||||
async saveConfig(ctx: { userId: string; tenantId: string }, dto: TenderEmailConfigDto) {
|
||||
const { userId, tenantId } = ctx;
|
||||
const tenantPrisma = forTenant(this.prisma, tenantId) as any;
|
||||
const tenantPrisma = forTenant(this.prisma, tenantId, userId) as any;
|
||||
let encryptedInboxCreds: string | undefined;
|
||||
|
||||
const credChanged =
|
||||
@@ -234,7 +240,7 @@ export class TenderEmailConfigService {
|
||||
|
||||
if (!username || !password) {
|
||||
try {
|
||||
const tenantPrisma = forTenant(this.prisma, tenantId) as any;
|
||||
const tenantPrisma = forTenant(this.prisma, tenantId, userId) as any;
|
||||
const existing = await tenantPrisma.tenderEmailConfig.findUnique({ where: { userId } });
|
||||
if (existing?.encryptedInboxCreds) {
|
||||
const stored = JSON.parse(this.crypto.decrypt(existing.encryptedInboxCreds)) as {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
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
|
||||
@@ -144,6 +145,8 @@ describe('TenderNotificationPrefService', () => {
|
||||
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 () => {
|
||||
|
||||
@@ -26,6 +26,12 @@ import { forTenant } from '../prisma/prisma-tenant.extension';
|
||||
* the failure is a P2002 unique-constraint violation, not an RLS
|
||||
* rejection. Translated below into a German message, same pattern as
|
||||
* `tender-saved-search.service.ts`, instead of surfacing as a raw 500.
|
||||
*
|
||||
* Nachtrag (260911-nke, Etappe 3b): seit Migration 20260911120000 traegt
|
||||
* die Regel auf TenderNotificationPref die Benutzerdimension
|
||||
* (`current_user_id() IS NULL OR "userId" = current_user_id()`) — beide
|
||||
* `forTenant()`-Aufrufe unten reichen `userId` als drittes Argument durch.
|
||||
* Die anwendungsseitige userId-Filterung bleibt zweites Netz, kein Ersatz.
|
||||
*/
|
||||
@Injectable()
|
||||
export class TenderNotificationPrefService {
|
||||
@@ -39,7 +45,7 @@ export class TenderNotificationPrefService {
|
||||
* autowrite needed to represent "using the default".
|
||||
*/
|
||||
async getForUser(userId: string, tenantId: string): Promise<{ digestInterval: string }> {
|
||||
const tenantPrisma = forTenant(this.prisma, tenantId) as any;
|
||||
const tenantPrisma = forTenant(this.prisma, tenantId, userId) as any;
|
||||
const existing = await tenantPrisma.tenderNotificationPref.findUnique({
|
||||
where: { userId },
|
||||
});
|
||||
@@ -57,7 +63,7 @@ export class TenderNotificationPrefService {
|
||||
* than creating a new one.
|
||||
*/
|
||||
async setForUser(userId: string, tenantId: string, digestInterval: string) {
|
||||
const tenantPrisma = forTenant(this.prisma, tenantId) as any;
|
||||
const tenantPrisma = forTenant(this.prisma, tenantId, userId) as any;
|
||||
try {
|
||||
return await tenantPrisma.tenderNotificationPref.upsert({
|
||||
where: { userId },
|
||||
|
||||
@@ -509,6 +509,7 @@ describe('TenderRssFeedSourceService', () => {
|
||||
|
||||
expectBoundCall(prisma, 'tenant-a', 'count');
|
||||
expectBoundCall(prisma, 'tenant-a', 'create');
|
||||
expect(forTenant).toHaveBeenCalledWith(prisma, 'tenant-a', 'user-a');
|
||||
});
|
||||
|
||||
// Umkehr von 'listForUser() bindet NICHT' (260910-jab, Aufgabe 2): seit
|
||||
@@ -530,7 +531,7 @@ describe('TenderRssFeedSourceService', () => {
|
||||
|
||||
await service.listForUser('u-anyone', 'tenant-a');
|
||||
|
||||
expect(forTenant).toHaveBeenCalledWith(prisma, 'tenant-a');
|
||||
expect(forTenant).toHaveBeenCalledWith(prisma, 'tenant-a', 'u-anyone');
|
||||
expectBoundCall(prisma, 'tenant-a', 'findMany');
|
||||
});
|
||||
|
||||
|
||||
@@ -69,7 +69,7 @@ export class TenderRssFeedSourceService {
|
||||
* Bindung nicht überflüssig, sondern das zweite Netz.
|
||||
*/
|
||||
async listForUser(userId: string, tenantId: string) {
|
||||
const tenantPrisma = forTenant(this.prisma, tenantId) as any;
|
||||
const tenantPrisma = forTenant(this.prisma, tenantId, userId) as any;
|
||||
return tenantPrisma.tenderRssFeedSource.findMany({
|
||||
where: { OR: [{ userId: null }, { userId }] },
|
||||
orderBy: { createdAt: 'asc' },
|
||||
@@ -93,7 +93,7 @@ export class TenderRssFeedSourceService {
|
||||
) {
|
||||
this.assertUrlAllowed(dto.url);
|
||||
|
||||
const tenantPrisma = forTenant(this.prisma, ctx.tenantId) as any;
|
||||
const tenantPrisma = forTenant(this.prisma, ctx.tenantId, ctx.userId) as any;
|
||||
const existingCount = await tenantPrisma.tenderRssFeedSource.count({
|
||||
where: { userId: ctx.userId },
|
||||
});
|
||||
@@ -130,7 +130,10 @@ export class TenderRssFeedSourceService {
|
||||
* Zeile laesst sich unter der Anwendungsrolle grundsaetzlich nicht
|
||||
* anlegen, weil jede Schreibregel einen Mandanten verlangt. Kein
|
||||
* Verwaltungsweg dafuer existiert heute; WINDOWS #24 haelt das als eigenen
|
||||
* offenen Punkt fest, der NICHT mit #19 verschwindet.
|
||||
* offenen Punkt fest, der NICHT mit #19 verschwindet. Nachtrag (260911-nke,
|
||||
* Etappe 3b): dieselbe Begruendung gilt fuer die neue Benutzerdimension
|
||||
* (20260911120000) — `createPlatform` bleibt bewusst ungebunden, WINDOWS #24
|
||||
* unveraendert offen.
|
||||
*/
|
||||
async createPlatform(dto: TenderRssFeedDto) {
|
||||
this.assertUrlAllowed(dto.url);
|
||||
@@ -173,7 +176,10 @@ export class TenderRssFeedSourceService {
|
||||
* Anweisungen zu zerlegen, um nur die persoenliche Haelfte zu binden,
|
||||
* wuerde ausserdem das Pruef-/Nutzungsfenster wieder oeffnen, das dieser
|
||||
* Kommentar oben (T-17-07) vermeidet — deshalb bleibt die gesamte Methode
|
||||
* ungebunden, nicht nur ihre plattformweite Haelfte.
|
||||
* ungebunden, nicht nur ihre plattformweite Haelfte. Nachtrag (260911-nke,
|
||||
* Etappe 3b): dieselbe Begruendung gilt fuer die neue Benutzerdimension
|
||||
* (20260911120000) — `remove` bleibt bewusst ungebunden, WINDOWS #24
|
||||
* unveraendert offen.
|
||||
*/
|
||||
async remove(id: string, ctx: { userId: string; isAdmin: boolean }) {
|
||||
const { userId, isAdmin } = ctx;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
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/
|
||||
@@ -188,6 +189,8 @@ describe('TenderTriageService', () => {
|
||||
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 () => {
|
||||
|
||||
@@ -25,6 +25,12 @@ export interface SetTriageInput {
|
||||
* TenderSavedSearch policy in Aufgabe 1; all five policies of this area
|
||||
* share the identical `"tenantId" = current_tenant_id()` text).
|
||||
*
|
||||
* Nachtrag (260911-nke, Etappe 3b): seit Migration 20260911120000 traegt
|
||||
* die Regel auf TenderTriage die Benutzerdimension (`current_user_id() IS
|
||||
* NULL OR "userId" = current_user_id()`) — alle drei `forTenant()`-Aufrufe
|
||||
* unten reichen `userId` als drittes Argument durch. Die anwendungsseitige
|
||||
* userId-Filterung bleibt zweites Netz, kein Ersatz.
|
||||
*
|
||||
* Cascade (Pitfall 6): the schema's `Tender @relation(..., onDelete:
|
||||
* Cascade)` removes a tender's triage rows automatically when Phase 10's
|
||||
* retention job deletes the tender — no manual cleanup needed here.
|
||||
@@ -69,7 +75,7 @@ export class TenderTriageService {
|
||||
update.favoritedAt = dto.isFavorite ? now : null;
|
||||
}
|
||||
|
||||
const tenantPrisma = forTenant(this.prisma, tenantId) as any;
|
||||
const tenantPrisma = forTenant(this.prisma, tenantId, userId) as any;
|
||||
try {
|
||||
return await tenantPrisma.tenderTriage.upsert({
|
||||
where: { userId_tenderId: { userId, tenderId } },
|
||||
@@ -105,7 +111,7 @@ export class TenderTriageService {
|
||||
*/
|
||||
async listForUser(userId: string, tenantId: string, tenderIds: string[]) {
|
||||
if (!tenderIds.length) return [];
|
||||
const tenantPrisma = forTenant(this.prisma, tenantId) as any;
|
||||
const tenantPrisma = forTenant(this.prisma, tenantId, userId) as any;
|
||||
return tenantPrisma.tenderTriage.findMany({
|
||||
where: { userId, tenderId: { in: tenderIds } },
|
||||
});
|
||||
@@ -117,7 +123,7 @@ export class TenderTriageService {
|
||||
* tender-query.builder.ts's buildTenderWhere.
|
||||
*/
|
||||
async favoriteIds(userId: string, tenantId: string): Promise<string[]> {
|
||||
const tenantPrisma = forTenant(this.prisma, tenantId) as any;
|
||||
const tenantPrisma = forTenant(this.prisma, tenantId, userId) as any;
|
||||
const rows = await tenantPrisma.tenderTriage.findMany({
|
||||
where: { userId, isFavorite: true },
|
||||
select: { tenderId: true },
|
||||
|
||||
Reference in New Issue
Block a user