feat(17-01): move TenderEmailConfig ownership from tenant to user

Alert-Postfach gehoert jetzt dem einzelnen Nutzer (userId @unique) statt
dem Mandanten (D-01) — ein zweiter Kollege desselben Mandanten kann sein
eigenes Postfach anbinden. tenantId bleibt denormalisiert (SMTP-Aufloesung,
Herkunftsmarkierung), wird auf create UND update mitgeschrieben.

- Handgeschriebene Migration (prisma migrate dev verweigert die
  nicht-interaktive Shell): befuellt Bestandszeilen mit dem aeltesten
  aktiven Administrator ihres Mandanten, entfernt verwaiste Zeilen ohne
  Administrator, ersetzt die tenantId-Eindeutigkeit durch userId.
  Lokal getestet (0 Bestandszeilen lokal und auf alpha — Zaehlung im
  Task-1-Checkpoint), Index-Ergebnis verifiziert.
- TenderEmailConfigService.getConfigForApi/saveConfig auf userId als
  Schluessel umgestellt; saveConfig nimmt {userId, tenantId}.
- TendersController: email-config-Routen von @Roles(ADMIN,SUPER_ADMIN)
  auf @UseModule('tender-radar') umgestellt (Postfach ist jetzt
  Nutzereinstellung); Route-Reihenfolge vor @Get(':id') unveraendert.
- Neue Seite /modules/tender-radar/my-sources ("Meine Quellen") mit dem
  unveraenderten EmailAlertConfigForm; Hinweistext benennt D-05 (Tender
  bleibt plattform-global — nur wer Quellen einspeist aendert sich).
- tenders.controller.spec.ts an neue Service-Signatur angepasst (Rule 3,
  nicht im Plan gelistet, aber zum Kompilieren/Bestehen erforderlich).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-12 11:19:55 +02:00
parent 42a2c7703f
commit 05b1d293d8
9 changed files with 363 additions and 115 deletions
@@ -0,0 +1,53 @@
-- Phase 17 (D-01): das Alert-Postfach ("TenderEmailConfig") gehoerte bisher
-- dem MANDANTEN (tenantId eindeutig) -- ein zweiter Kollege mit eigenem
-- Portal-Konto konnte sein Postfach nicht anbinden, weil es bereits eines
-- fuer den ganzen Mandanten gab. Ab dieser Migration gehoert das Postfach
-- dem NUTZER (userId eindeutig); tenantId bleibt als gewoehnliches,
-- denormalisiertes Feld erhalten (SMTP-Aufloesung, Herkunftsmarkierung der
-- eingelesenen Ausschreibungen -- gleiche Rolle wie in TenderMatch).
--
-- Eine bereits vorhandene Postfach-Zeile bekommt dabei den AELTESTEN
-- AKTIVEN Administrator (ADMIN oder SUPER_ADMIN) ihres Mandanten als
-- Besitzer zugeordnet, nicht geloescht: die Zeile enthaelt verschluesselte
-- Zugangsdaten und wurde seinerzeit von genau dieser Rolle angelegt: ein
-- Loeschen wuerde den laufenden Abruf ohne Vorwarnung stilllegen. Nur wenn
-- ein Mandant ueberhaupt keinen aktiven Administrator hat, wird die Zeile
-- entfernt -- sonst waere sie fuer niemanden mehr bearbeitbar, wuerde aber
-- im Hintergrund weiter abgefragt.
-- 1. Neue Spalte zunaechst ohne Pflicht, damit Bestandszeilen befuellt
-- werden koennen, bevor NOT NULL erzwungen wird.
ALTER TABLE "TenderEmailConfig" ADD COLUMN "userId" TEXT;
-- 2. Bestandszeilen: aeltester aktiver Administrator desselben Mandanten
-- (sortiert nach createdAt, bei Gleichstand nach id, genau einer).
UPDATE "TenderEmailConfig" AS tec
SET "userId" = (
SELECT u."id"
FROM "User" u
WHERE u."tenantId" = tec."tenantId"
AND u."isActive" = true
AND u."role" IN ('ADMIN', 'SUPER_ADMIN')
ORDER BY u."createdAt" ASC, u."id" ASC
LIMIT 1
)
WHERE tec."userId" IS NULL;
-- 3. Zeilen ohne gefundenen Administrator entfernen (kein Mandanten-Admin
-- vorhanden -- siehe Begruendung oben).
DELETE FROM "TenderEmailConfig" WHERE "userId" IS NULL;
-- 4. Alte Eindeutigkeitsregel "ein Postfach pro Mandant" aufheben. Der
-- gewoehnliche Index auf tenantId (TenderEmailConfig_tenantId_idx)
-- bleibt bestehen -- tenantId wird weiterhin gefiltert (SMTP-Aufloesung).
DROP INDEX "TenderEmailConfig_tenantId_key";
-- 5. Neue Eindeutigkeitsregel "ein Postfach pro Nutzer" -- erst NOT NULL
-- setzen, nachdem jede Zeile einen Besitzer hat (Schritte 2/3).
ALTER TABLE "TenderEmailConfig" ALTER COLUMN "userId" SET NOT NULL;
-- CreateIndex
CREATE UNIQUE INDEX "TenderEmailConfig_userId_key" ON "TenderEmailConfig"("userId");
-- CreateIndex
CREATE INDEX "TenderEmailConfig_userId_idx" ON "TenderEmailConfig"("userId");
+8 -1
View File
@@ -275,9 +275,15 @@ model DkvModuleConfig {
// DKV invoice inbox). Credentials are encrypted via CalendarCryptoService
// (same AES-256-GCM iv:authTag:ciphertext format as DkvModuleConfig/
// SmtpConfig) and excluded from every API response (Safe-Select, T-07-12).
// Phase 17 (D-01): ownership lives at the USER, not the tenant — each user
// connects their own alert mailbox, so two colleagues at the same tenant can
// each run their own inbox in parallel. `tenantId` stays as a denormalized
// field (SMTP resolution, ownerTenantId tagging on ingested Tender rows) —
// same role as `tenantId` on TenderMatch, not the ownership key anymore.
model TenderEmailConfig {
id String @id @default(uuid())
tenantId String @unique
userId String @unique
tenantId String
protocol String @default("imap") // 'imap' | 'exchange'
host String?
port Int?
@@ -291,6 +297,7 @@ model TenderEmailConfig {
updatedAt DateTime @updatedAt
@@index([tenantId])
@@index([userId])
}
model DkvVehicleMaster {
@@ -7,6 +7,12 @@ import { TenderEmailConfigService } from './tender-email-config.service';
* (deterministic reversible encode, NOT real AES) — same convention as
* tender-dedup.service.spec.ts: no live DB/crypto dependency, just proving
* this service's own encrypt-preserve-empty / safe-select contract.
*
* Phase 17, Plan 01 (D-01): ownership moved from tenantId to userId — the
* fake prisma below is now keyed by userId (matches the real
* `where: { userId }` upsert target), and two new cases prove the actual
* new capability: two users of the SAME tenant get two independent rows,
* and tenantId is written on create (denormalized, D-01).
*/
function makeFakeCrypto() {
@@ -24,7 +30,7 @@ function makeFakePrisma() {
return {
tenderEmailConfig: {
findUnique: vi.fn(async ({ where, select }: any) => {
const row = configs.get(where.tenantId);
const row = configs.get(where.userId);
if (!row) return null;
if (!select) return row;
const out: any = {};
@@ -32,9 +38,9 @@ function makeFakePrisma() {
return out;
}),
upsert: vi.fn(async ({ where, update, create, select }: any) => {
const existing = configs.get(where.tenantId);
const row = existing ? { ...existing, ...update } : { id: 'cfg-1', ...create };
configs.set(where.tenantId, row);
const existing = configs.get(where.userId);
const row = existing ? { ...existing, ...update } : { id: `cfg-${configs.size + 1}`, ...create };
configs.set(where.userId, row);
if (!select) return row;
const out: any = {};
for (const k of Object.keys(select)) out[k] = row[k];
@@ -46,12 +52,12 @@ function makeFakePrisma() {
}
describe('TenderEmailConfigService', () => {
it('getConfigForApi returns null when no config exists for the tenant', async () => {
it('getConfigForApi returns null when no config exists for the user', async () => {
const prisma = makeFakePrisma();
const crypto = makeFakeCrypto();
const service = new TenderEmailConfigService(prisma as any, crypto as any);
const result = await service.getConfigForApi('tenant-missing');
const result = await service.getConfigForApi('user-missing');
expect(result).toBeNull();
});
@@ -61,18 +67,21 @@ describe('TenderEmailConfigService', () => {
const crypto = makeFakeCrypto();
const service = new TenderEmailConfigService(prisma as any, crypto as any);
await service.saveConfig('tenant-a', {
protocol: 'imap',
encryption: 'ssl-tls',
host: 'imap.example.test',
port: 993,
folder: 'INBOX',
username: 'alerts@example.test',
password: 'super-secret',
isActive: true,
} as any);
await service.saveConfig(
{ userId: 'user-a', tenantId: 'tenant-a' },
{
protocol: 'imap',
encryption: 'ssl-tls',
host: 'imap.example.test',
port: 993,
folder: 'INBOX',
username: 'alerts@example.test',
password: 'super-secret',
isActive: true,
} as any,
);
const apiResult = await service.getConfigForApi('tenant-a');
const apiResult = await service.getConfigForApi('user-a');
expect(apiResult).not.toBeNull();
expect(apiResult).not.toHaveProperty('password');
@@ -86,16 +95,19 @@ describe('TenderEmailConfigService', () => {
const crypto = makeFakeCrypto();
const service = new TenderEmailConfigService(prisma as any, crypto as any);
await service.saveConfig('tenant-b', {
protocol: 'imap',
encryption: 'ssl-tls',
host: 'imap.example.test',
port: 993,
folder: 'INBOX',
isActive: false,
} as any);
await service.saveConfig(
{ userId: 'user-b', tenantId: 'tenant-b' },
{
protocol: 'imap',
encryption: 'ssl-tls',
host: 'imap.example.test',
port: 993,
folder: 'INBOX',
isActive: false,
} as any,
);
const apiResult = await service.getConfigForApi('tenant-b');
const apiResult = await service.getConfigForApi('user-b');
expect(apiResult!.hasPassword).toBe(false);
expect(apiResult!.username).toBeNull();
@@ -107,21 +119,27 @@ describe('TenderEmailConfigService', () => {
const crypto = makeFakeCrypto();
const service = new TenderEmailConfigService(prisma as any, crypto as any);
await service.saveConfig('tenant-c', {
protocol: 'imap',
encryption: 'ssl-tls',
username: 'old@example.test',
password: 'original-secret',
} as any);
await service.saveConfig(
{ userId: 'user-c', tenantId: 'tenant-c' },
{
protocol: 'imap',
encryption: 'ssl-tls',
username: 'old@example.test',
password: 'original-secret',
} as any,
);
// Re-save with a new username, password left blank (T-07-12 UI convention)
await service.saveConfig('tenant-c', {
protocol: 'imap',
encryption: 'ssl-tls',
username: 'new@example.test',
} as any);
await service.saveConfig(
{ userId: 'user-c', tenantId: 'tenant-c' },
{
protocol: 'imap',
encryption: 'ssl-tls',
username: 'new@example.test',
} as any,
);
const raw = prisma.__store.get('tenant-c');
const raw = prisma.__store.get('user-c');
const decrypted = JSON.parse(crypto.decrypt(raw.encryptedInboxCreds));
expect(decrypted.username).toBe('new@example.test');
expect(decrypted.password).toBe('original-secret');
@@ -132,20 +150,26 @@ describe('TenderEmailConfigService', () => {
const crypto = makeFakeCrypto();
const service = new TenderEmailConfigService(prisma as any, crypto as any);
await service.saveConfig('tenant-d', {
protocol: 'imap',
encryption: 'ssl-tls',
username: 'stable@example.test',
password: 'first-secret',
} as any);
await service.saveConfig(
{ userId: 'user-d', tenantId: 'tenant-d' },
{
protocol: 'imap',
encryption: 'ssl-tls',
username: 'stable@example.test',
password: 'first-secret',
} as any,
);
await service.saveConfig('tenant-d', {
protocol: 'imap',
encryption: 'ssl-tls',
password: 'rotated-secret',
} as any);
await service.saveConfig(
{ userId: 'user-d', tenantId: 'tenant-d' },
{
protocol: 'imap',
encryption: 'ssl-tls',
password: 'rotated-secret',
} as any,
);
const raw = prisma.__store.get('tenant-d');
const raw = prisma.__store.get('user-d');
const decrypted = JSON.parse(crypto.decrypt(raw.encryptedInboxCreds));
expect(decrypted.username).toBe('stable@example.test');
expect(decrypted.password).toBe('rotated-secret');
@@ -156,14 +180,54 @@ describe('TenderEmailConfigService', () => {
const crypto = makeFakeCrypto();
const service = new TenderEmailConfigService(prisma as any, crypto as any);
const result = await service.saveConfig('tenant-e', {
protocol: 'imap',
encryption: 'ssl-tls',
username: 'x@example.test',
password: 'y',
} as any);
const result = await service.saveConfig(
{ userId: 'user-e', tenantId: 'tenant-e' },
{
protocol: 'imap',
encryption: 'ssl-tls',
username: 'x@example.test',
password: 'y',
} as any,
);
expect(result).not.toHaveProperty('encryptedInboxCreds');
expect(result).not.toHaveProperty('password');
});
it('saveConfig writes BOTH userId and tenantId on create (Phase 17, D-01: tenantId stays denormalized)', async () => {
const prisma = makeFakePrisma();
const crypto = makeFakeCrypto();
const service = new TenderEmailConfigService(prisma as any, crypto as any);
await service.saveConfig(
{ userId: 'user-f', tenantId: 'tenant-f' },
{ protocol: 'imap', encryption: 'ssl-tls' } as any,
);
const raw = prisma.__store.get('user-f');
expect(raw.userId).toBe('user-f');
expect(raw.tenantId).toBe('tenant-f');
});
it('two users of the SAME tenant each get their own row — the second save never overwrites the first (Phase 17, D-01)', async () => {
const prisma = makeFakePrisma();
const crypto = makeFakeCrypto();
const service = new TenderEmailConfigService(prisma as any, crypto as any);
await service.saveConfig(
{ userId: 'user-g1', tenantId: 'tenant-shared' },
{ protocol: 'imap', encryption: 'ssl-tls', host: 'imap.user-g1.test' } as any,
);
await service.saveConfig(
{ userId: 'user-g2', tenantId: 'tenant-shared' },
{ protocol: 'imap', encryption: 'ssl-tls', host: 'imap.user-g2.test' } as any,
);
const configG1 = await service.getConfigForApi('user-g1');
const configG2 = await service.getConfigForApi('user-g2');
expect(configG1!.host).toBe('imap.user-g1.test');
expect(configG2!.host).toBe('imap.user-g2.test');
expect(prisma.__store.size).toBe(2);
});
});
@@ -10,6 +10,7 @@ import type { TenderEmailConfigDto } from './dto/tender-email-config.dto';
*/
const EMAIL_CONFIG_SAFE_SELECT = {
id: true,
userId: true,
tenantId: true,
protocol: true,
host: true,
@@ -25,25 +26,36 @@ const EMAIL_CONFIG_SAFE_SELECT = {
} as const;
/**
* TenderEmailConfigService — per-tenant admin CRUD for the portal-alert
* mailbox config (Phase 14, Plan 03, INGEST-05/CONFIG-02, D-06/D-07).
* Structural clone of DkvService's config half (safe-select + encrypt-
* preserve-empty semantics), mirroring the exact same pattern already
* proven for DKV's own (separate, D-03) mailbox config.
* TenderEmailConfigService — per-USER CRUD for the portal-alert mailbox
* config (Phase 14, Plan 03, INGEST-05/CONFIG-02, D-06/D-07; ownership
* moved from tenant to user in Phase 17, Plan 01, D-01). Structural clone
* of DkvService's config half (safe-select + encrypt-preserve-empty
* semantics), mirroring the exact same pattern already proven for DKV's
* own (separate, D-03) mailbox config.
*
* Ownership (Phase 17, D-01): a mailbox belongs to exactly one user
* (`userId @unique`) — two users at the same tenant each connect their own
* inbox independently, neither can read or overwrite the other's config.
* `tenantId` stays denormalized on every row (SMTP resolution, same role as
* `tenantId` on TenderMatch) and is written on both create and update,
* since a user's tenant can in principle change.
*
* Security:
* - T-07-12: encryptedInboxCreds is excluded from every read-path select;
* getConfigForApi returns `hasPassword: boolean` instead of the password.
* - T-05-13: decrypted credentials only ever exist within a method's local
* scope — never logged.
* - T-17-01: userId/tenantId are supplied by the caller from the auth
* context (TendersController.extractTriageContext) — this service never
* derives ownership from anything the DTO carries.
*
* This service is used ONLY by the admin GET/PUT /email-config routes
* (TendersController). EmailAlertAdapter's own per-tenant poll-time fan-out
* decrypts credentials independently via a direct CryptoService
* injection (RESEARCH.md Pattern 1) — it does NOT go through this service,
* since the adapter's cross-tenant `findMany({where:{isActive:true}})` read
* is a deliberate platform-scheduler exception (see EmailAlertAdapter's
* docstring), structurally different from this service's tenant-scoped CRUD.
* This service is used ONLY by the GET/PUT /email-config routes
* (TendersController). EmailAlertAdapter's own poll-time fan-out decrypts
* credentials independently via a direct CryptoService injection
* (RESEARCH.md Pattern 1) — it does NOT go through this service, since the
* adapter's cross-tenant `findMany({where:{isActive:true}})` read is a
* deliberate platform-scheduler exception (see EmailAlertAdapter's
* docstring), structurally different from this service's per-user CRUD.
*/
@Injectable()
export class TenderEmailConfigService {
@@ -54,11 +66,12 @@ export class TenderEmailConfigService {
/**
* Load config for API response: safe fields + decrypted username +
* hasPassword flag. T-07-12: password is NEVER returned.
* hasPassword flag. T-07-12: password is NEVER returned. Scoped strictly
* by userId (T-17-01) — a user only ever reads their own mailbox.
*/
async getConfigForApi(tenantId: string) {
async getConfigForApi(userId: string) {
const safe = await this.prisma.tenderEmailConfig.findUnique({
where: { tenantId },
where: { userId },
select: EMAIL_CONFIG_SAFE_SELECT,
});
if (!safe) return null;
@@ -66,7 +79,7 @@ export class TenderEmailConfigService {
let username: string | null = null;
let hasPassword = false;
try {
const raw = await this.prisma.tenderEmailConfig.findUnique({ where: { tenantId } });
const raw = await this.prisma.tenderEmailConfig.findUnique({ where: { userId } });
if (raw?.encryptedInboxCreds) {
const creds = JSON.parse(this.crypto.decrypt(raw.encryptedInboxCreds)) as {
username?: string;
@@ -83,7 +96,7 @@ export class TenderEmailConfigService {
}
/**
* Upsert TenderEmailConfig for a tenant.
* Upsert TenderEmailConfig for a user.
*
* Credential handling (identical semantics to DkvService.saveConfig):
* - dto.password non-empty: re-encrypt {username, password} together.
@@ -91,10 +104,15 @@ export class TenderEmailConfigService {
* password, re-encrypt with the new username.
* - both empty/undefined: preserve existing encryptedInboxCreds entirely.
*
* tenantId is written on both create AND update (Phase 17, D-01): it is
* denormalized, so if the resolved tenant for this user ever changes the
* stored value must follow.
*
* T-07-12: Returns safe select (no encryptedInboxCreds).
* T-05-13: Never logs decrypted credentials.
*/
async saveConfig(tenantId: string, dto: TenderEmailConfigDto) {
async saveConfig(ctx: { userId: string; tenantId: string }, dto: TenderEmailConfigDto) {
const { userId, tenantId } = ctx;
let encryptedInboxCreds: string | undefined;
const credChanged =
@@ -107,7 +125,7 @@ export class TenderEmailConfigService {
if (!dto.password || !dto.username) {
try {
const existing = await this.prisma.tenderEmailConfig.findUnique({ where: { tenantId } });
const existing = await this.prisma.tenderEmailConfig.findUnique({ where: { userId } });
if (existing?.encryptedInboxCreds) {
const stored = JSON.parse(this.crypto.decrypt(existing.encryptedInboxCreds)) as {
username?: string;
@@ -136,10 +154,13 @@ export class TenderEmailConfigService {
...(encryptedInboxCreds !== undefined && { encryptedInboxCreds }),
};
// tenantId is written on BOTH create and update — it is denormalized
// (Phase 17, D-01), so a user's resolved tenant must always overwrite
// whatever was stored previously, not just be set once on create.
return this.prisma.tenderEmailConfig.upsert({
where: { tenantId },
create: { tenantId, ...data },
update: data,
where: { userId },
create: { userId, tenantId, ...data },
update: { tenantId, ...data },
select: EMAIL_CONFIG_SAFE_SELECT,
});
}
+44 -10
View File
@@ -104,13 +104,17 @@ function makeFakeRssFeedService() {
/**
* Fake TenderEmailConfigService for controller-level wiring tests (Plan
* 14-03, D-06/D-07/CONFIG-02). Default stubs echo/return null; individual
* tests override via `.mockResolvedValueOnce`/reassigning the mock.
* 14-03, D-06/D-07/CONFIG-02; per-user ownership since Phase 17, Plan 01,
* D-01). Default stubs echo/return null; individual tests override via
* `.mockResolvedValueOnce`/reassigning the mock.
*/
function makeFakeEmailConfigService() {
return {
getConfigForApi: vi.fn(async (_tenantId: string) => null as any),
saveConfig: vi.fn(async (_tenantId: string, dto: any) => ({ id: 'ec-1', ...dto })),
getConfigForApi: vi.fn(async (_userId: string) => null as any),
saveConfig: vi.fn(async (_ctx: { userId: string; tenantId: string }, dto: any) => ({
id: 'ec-1',
...dto,
})),
};
}
@@ -959,12 +963,13 @@ describe('TendersController — RSS-feeds admin CRUD (Plan 14-02, D-14/D-08)', (
});
});
describe('TendersController — email-config (Plan 14-03, per-tenant, D-06/D-07/T-14-03-05)', () => {
it('GET /email-config resolves tenantId from the auth context and delegates to tenderEmailConfig.getConfigForApi(tenantId)', async () => {
describe('TendersController — email-config (Plan 14-03, per-user since Phase 17 Plan 01 D-01, T-14-03-05/T-17-01)', () => {
it('GET /email-config resolves userId from the auth context and delegates to tenderEmailConfig.getConfigForApi(userId)', async () => {
const prisma = makeFakePrisma();
const scheduler = { setInterval: vi.fn(), stopJob: vi.fn() } as any;
const emailConfigService = makeFakeEmailConfigService();
emailConfigService.getConfigForApi.mockResolvedValueOnce({
userId: 'u1',
tenantId: 'tenant1',
protocol: 'imap',
hasPassword: true,
@@ -981,11 +986,16 @@ describe('TendersController — email-config (Plan 14-03, per-tenant, D-06/D-07/
const result = await controller.getEmailConfig(makeFakeRequest('u1', 'tenant1'));
expect(emailConfigService.getConfigForApi).toHaveBeenCalledWith('tenant1');
expect(result).toEqual({ tenantId: 'tenant1', protocol: 'imap', hasPassword: true });
expect(emailConfigService.getConfigForApi).toHaveBeenCalledWith('u1');
expect(result).toEqual({
userId: 'u1',
tenantId: 'tenant1',
protocol: 'imap',
hasPassword: true,
});
});
it('PUT /email-config delegates to tenderEmailConfig.saveConfig with tenantId from the auth context, never the body', async () => {
it('PUT /email-config delegates to tenderEmailConfig.saveConfig with {userId, tenantId} from the auth context, never the body', async () => {
const prisma = makeFakePrisma();
const scheduler = { setInterval: vi.fn(), stopJob: vi.fn() } as any;
const emailConfigService = makeFakeEmailConfigService();
@@ -1002,7 +1012,31 @@ describe('TendersController — email-config (Plan 14-03, per-tenant, D-06/D-07/
const dto = { protocol: 'imap', encryption: 'ssl-tls', host: 'imap.example.test' } as any;
await controller.saveEmailConfig(dto, makeFakeRequest('u1', 'tenant1'));
expect(emailConfigService.saveConfig).toHaveBeenCalledWith('tenant1', dto);
expect(emailConfigService.saveConfig).toHaveBeenCalledWith(
{ userId: 'u1', tenantId: 'tenant1' },
dto,
);
});
it('two different users of the same tenant each resolve their own userId — never the other user\'s (T-17-01, IDOR)', async () => {
const prisma = makeFakePrisma();
const scheduler = { setInterval: vi.fn(), stopJob: vi.fn() } as any;
const emailConfigService = makeFakeEmailConfigService();
const controller = new TendersController(
prisma as any,
scheduler,
makeFakeTriageService() as any,
makeFakeSavedSearchService() as any,
makeFakeNotificationPrefService() as any,
makeFakeRssFeedService() as any,
emailConfigService as any,
);
await controller.getEmailConfig(makeFakeRequest('user-a', 'tenant1'));
await controller.getEmailConfig(makeFakeRequest('user-b', 'tenant1'));
expect(emailConfigService.getConfigForApi).toHaveBeenNthCalledWith(1, 'user-a');
expect(emailConfigService.getConfigForApi).toHaveBeenNthCalledWith(2, 'user-b');
});
});
+31 -26
View File
@@ -59,15 +59,17 @@ const DOE_SOURCE_TYPE = 'doe-opendata';
* shared platform-wide poll schedule is a platform-admin action, not a
* per-tenant module feature.
*
* Phase 14, Plan 03 (INGEST-05, D-13): `GET`/`PUT /email-config` are, like
* `source-config`/`rss-feeds`, per-handler `@Roles(ADMIN, SUPER_ADMIN)`-
* guarded — but UNLIKE those (platform-wide singletons/lists), the email
* mailbox config is per-TENANT: tenantId is resolved from the auth context,
* never the body (T-14-03-05/IDOR). This is also the plan that breaks the
* "GET/GET :id are never row-scoped" invariant above, narrowly: `listTenders`/
* `getTender` now resolve the requesting tenant to apply the D-13 OR[global,
* mine] visibility filter for PRIVATE (email-alert) tenders only — public
* tenders (D-03) remain visible to every tenant exactly as before.
* Phase 14, Plan 03 (INGEST-05, D-13) originally made `GET`/`PUT
* /email-config` per-handler `@Roles(ADMIN, SUPER_ADMIN)`-guarded and
* per-TENANT. Phase 17, Plan 01 (D-01) changed this: the mailbox is now
* per-USER — every user with module access connects their own inbox, so
* these two routes are `@UseModule('tender-radar')`-gated like the other
* per-user routes below, and `userId` (not `tenantId`) is the ownership
* key, resolved from the auth context, never the body (T-14-03-05/T-17-01/
* IDOR). `listTenders`/`getTender` still resolve the requesting tenant to
* apply the D-13 OR[global, mine] visibility filter for PRIVATE
* (email-alert) tenders — public tenders (D-03) remain visible to every
* tenant exactly as before; that part of D-13 is unaffected by D-01.
*/
@Controller('modules/tender-radar')
export class TendersController {
@@ -268,37 +270,40 @@ export class TendersController {
return this.tenderRssFeedSource.remove(feedId);
}
// ─── E-Mail-Alerts config (Roles-guarded, PER-TENANT, D-06/D-07/D-13) ──────
// ─── E-Mail-Alerts config (ModuleGuard-gated, PER-USER, Phase 17 D-01) ─────
/**
* GET /modules/tender-radar/email-config — this tenant's portal-alert
* mailbox config (safe-select — never the password, T-07-12). Unlike
* `source-config`/`rss-feeds` (platform-wide), this is PER-TENANT:
* tenantId is resolved from the auth context, never a query/body field
* (T-14-03-05 / V4 — IDOR).
* GET /modules/tender-radar/email-config — the requesting USER's own
* portal-alert mailbox config (safe-select — never the password,
* T-07-12). Phase 17 (D-01): ownership moved from tenant to user — every
* user with module access manages their own mailbox, so the Roles guard
* (previously ADMIN/SUPER_ADMIN only) is replaced with `@UseModule`, the
* same access gate as every other per-user route below. `userId` comes
* exclusively from the auth context, never a query/body field
* (T-14-03-05 / T-17-01 / V4 — IDOR).
*
* MUST be declared before `@Get(':id')` below — same route-order pitfall
* as `source-config`/`coverage`/`triage`/`rss-feeds`/... above (Pitfall 5).
*/
@Get('email-config')
@Roles(Role.ADMIN, Role.SUPER_ADMIN)
@UseModule('tender-radar')
async getEmailConfig(@Req() req: Request) {
const { tenantId } = this.extractTriageContext(req);
return this.tenderEmailConfig.getConfigForApi(tenantId);
const { userId } = this.extractTriageContext(req);
return this.tenderEmailConfig.getConfigForApi(userId);
}
/**
* PUT /modules/tender-radar/email-config — upsert this tenant's mailbox
* config. tenantId comes exclusively from the auth context — `dto` never
* carries a tenantId field (T-14-03-05 / V4 — IDOR). Credential
* encrypt-preserve-empty semantics live in TenderEmailConfigService
* (mirrors DkvService.saveConfig / T-07-12).
* PUT /modules/tender-radar/email-config — upsert the requesting USER's
* own mailbox config. userId/tenantId come exclusively from the auth
* context — `dto` never carries either field (T-14-03-05 / T-17-01 / V4
* — IDOR). Credential encrypt-preserve-empty semantics live in
* TenderEmailConfigService (mirrors DkvService.saveConfig / T-07-12).
*/
@Put('email-config')
@Roles(Role.ADMIN, Role.SUPER_ADMIN)
@UseModule('tender-radar')
async saveEmailConfig(@Body() dto: TenderEmailConfigDto, @Req() req: Request) {
const { tenantId } = this.extractTriageContext(req);
return this.tenderEmailConfig.saveConfig(tenantId, dto);
const { userId, tenantId } = this.extractTriageContext(req);
return this.tenderEmailConfig.saveConfig({ userId, tenantId }, dto);
}
/**