9e7ba5353d
- TenderNotificationPrefService: per-user digestInterval CRUD (default
'daily', upsert on @@unique userId, D-01/D-03)
- UpdateNotificationPrefDto: @IsIn(['daily','weekly','off']) validation (V5)
- GET/PUT /modules/tender-radar/notification-pref, declared before
@Get(':id') (route-order pitfall)
- instantAlert passthrough in Create/UpdateSavedSearchDto and
TenderSavedSearchService.create/update (NOTIFY-02, D-04)
- All pref/profile routes scoped strictly via extractTriageContext(req),
never from body/query (T-12-14, IDOR)
- Updated tenders.controller.spec.ts fakes for the new constructor param
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
229 lines
8.3 KiB
TypeScript
229 lines
8.3 KiB
TypeScript
import { ConflictException, NotFoundException } from '@nestjs/common';
|
|
import { describe, expect, it } from 'vitest';
|
|
import { TenderSavedSearchService } from './tender-saved-search.service';
|
|
|
|
/**
|
|
* TenderSavedSearchService.spec — RED-first (TDD) proof for FILTER-06
|
|
* (D-08/D-11) and the V4/IDOR access-control invariant (T-11-14):
|
|
*
|
|
* - create() scopes to (userId, tenantId); a second profile with the same
|
|
* name for the SAME user is rejected (@@unique([userId,name])) — the
|
|
* same name IS allowed across different users (scoped per-user, not
|
|
* globally unique).
|
|
* - list() is scoped strictly by userId — a foreign userId sees nothing
|
|
* (IDOR).
|
|
* - update()/remove() verify userId ownership before mutating, throwing
|
|
* NotFoundException for both a missing row AND a foreign-owned row
|
|
* (FavoritesService pattern — never distinguishes the two, to avoid
|
|
* leaking existence of another user's profile).
|
|
*
|
|
* Uses the same hand-rolled prisma-shaped fake convention as
|
|
* tender-triage.service.spec.ts / tenders.controller.spec.ts (in-memory
|
|
* Map, no live DB connection). The fake simulates Prisma's P2002 unique-
|
|
* constraint violation the same way a live Postgres unique index would.
|
|
*/
|
|
|
|
function makeFakePrisma() {
|
|
const rows = new Map<string, any>();
|
|
let counter = 0;
|
|
|
|
function findByUserAndName(userId: string, name: string, excludeId?: string) {
|
|
return Array.from(rows.values()).find(
|
|
(r) => r.userId === userId && r.name === name && r.id !== excludeId,
|
|
);
|
|
}
|
|
|
|
function throwUniqueViolation(): never {
|
|
const err: any = new Error('Unique constraint failed on the fields: (`userId`,`name`)');
|
|
err.code = 'P2002';
|
|
throw err;
|
|
}
|
|
|
|
return {
|
|
tenderSavedSearch: {
|
|
create: async ({ data }: any) => {
|
|
if (findByUserAndName(data.userId, data.name)) throwUniqueViolation();
|
|
counter += 1;
|
|
const record = {
|
|
id: `ss-${counter}`,
|
|
...data,
|
|
createdAt: new Date(),
|
|
updatedAt: new Date(),
|
|
};
|
|
rows.set(record.id, record);
|
|
return record;
|
|
},
|
|
findMany: async ({ where }: any) =>
|
|
Array.from(rows.values()).filter((r) => r.userId === where.userId),
|
|
findUnique: async ({ where }: any) => rows.get(where.id) ?? null,
|
|
update: async ({ where, data }: any) => {
|
|
const existing = rows.get(where.id);
|
|
const nextName = data.name ?? existing.name;
|
|
if (findByUserAndName(existing.userId, nextName, existing.id)) {
|
|
throwUniqueViolation();
|
|
}
|
|
const record = { ...existing, ...data, updatedAt: new Date() };
|
|
rows.set(where.id, record);
|
|
return record;
|
|
},
|
|
delete: async ({ where }: any) => {
|
|
rows.delete(where.id);
|
|
},
|
|
},
|
|
};
|
|
}
|
|
|
|
describe('TenderSavedSearchService', () => {
|
|
it('create() creates a row scoped to userId + tenantId', async () => {
|
|
const prisma = makeFakePrisma();
|
|
const service = new TenderSavedSearchService(prisma as any);
|
|
|
|
const result = await service.create('u1', 'tenant1', {
|
|
name: 'Bau NRW',
|
|
filters: { q: 'Bau', bundesland: 'Nordrhein-Westfalen' },
|
|
});
|
|
|
|
expect(result.userId).toBe('u1');
|
|
expect(result.tenantId).toBe('tenant1');
|
|
expect(result.name).toBe('Bau NRW');
|
|
expect(result.filters).toEqual({ q: 'Bau', bundesland: 'Nordrhein-Westfalen' });
|
|
});
|
|
|
|
it('create() rejects a second profile with the same name for the same user (@@unique([userId,name]))', async () => {
|
|
const prisma = makeFakePrisma();
|
|
const service = new TenderSavedSearchService(prisma as any);
|
|
|
|
await service.create('u1', 'tenant1', { name: 'Bau NRW', filters: {} });
|
|
|
|
await expect(
|
|
service.create('u1', 'tenant1', { name: 'Bau NRW', filters: {} }),
|
|
).rejects.toBeInstanceOf(ConflictException);
|
|
});
|
|
|
|
it('create() allows the same name for two different users (unique is scoped per-user, not global)', async () => {
|
|
const prisma = makeFakePrisma();
|
|
const service = new TenderSavedSearchService(prisma as any);
|
|
|
|
await service.create('u1', 'tenant1', { name: 'Bau NRW', filters: {} });
|
|
const result = await service.create('u2', 'tenant1', { name: 'Bau NRW', filters: {} });
|
|
|
|
expect(result.userId).toBe('u2');
|
|
});
|
|
|
|
it('list() scopes strictly by userId — a foreign user sees nothing (V4 / IDOR)', async () => {
|
|
const prisma = makeFakePrisma();
|
|
const service = new TenderSavedSearchService(prisma as any);
|
|
|
|
await service.create('u1', 'tenant1', { name: 'Bau NRW', filters: {} });
|
|
|
|
expect(await service.list('u2')).toEqual([]);
|
|
expect(await service.list('u1')).toHaveLength(1);
|
|
});
|
|
|
|
it('update() applies a rename + filters change when owned by the caller', async () => {
|
|
const prisma = makeFakePrisma();
|
|
const service = new TenderSavedSearchService(prisma as any);
|
|
|
|
const created = await service.create('u1', 'tenant1', { name: 'Alt', filters: { q: 'x' } });
|
|
const updated = await service.update(created.id, 'u1', {
|
|
name: 'Neu',
|
|
filters: { q: 'y' },
|
|
});
|
|
|
|
expect(updated.name).toBe('Neu');
|
|
expect(updated.filters).toEqual({ q: 'y' });
|
|
});
|
|
|
|
it('update() throws NotFoundException when the row is owned by a different user (ownership check, IDOR)', async () => {
|
|
const prisma = makeFakePrisma();
|
|
const service = new TenderSavedSearchService(prisma as any);
|
|
|
|
const created = await service.create('u1', 'tenant1', { name: 'Alt', filters: {} });
|
|
|
|
await expect(
|
|
service.update(created.id, 'u2', { name: 'Uebernahme' }),
|
|
).rejects.toBeInstanceOf(NotFoundException);
|
|
});
|
|
|
|
it('update() throws NotFoundException for a nonexistent id', async () => {
|
|
const prisma = makeFakePrisma();
|
|
const service = new TenderSavedSearchService(prisma as any);
|
|
|
|
await expect(
|
|
service.update('missing', 'u1', { name: 'x' }),
|
|
).rejects.toBeInstanceOf(NotFoundException);
|
|
});
|
|
|
|
it('update() rejects a rename that collides with another of this user\'s existing profiles', async () => {
|
|
const prisma = makeFakePrisma();
|
|
const service = new TenderSavedSearchService(prisma as any);
|
|
|
|
await service.create('u1', 'tenant1', { name: 'Erstes Profil', filters: {} });
|
|
const second = await service.create('u1', 'tenant1', { name: 'Zweites Profil', filters: {} });
|
|
|
|
await expect(
|
|
service.update(second.id, 'u1', { name: 'Erstes Profil' }),
|
|
).rejects.toBeInstanceOf(ConflictException);
|
|
});
|
|
|
|
it('remove() throws NotFoundException when the row is owned by a different user (ownership check, IDOR)', async () => {
|
|
const prisma = makeFakePrisma();
|
|
const service = new TenderSavedSearchService(prisma as any);
|
|
|
|
const created = await service.create('u1', 'tenant1', { name: 'Alt', filters: {} });
|
|
|
|
await expect(service.remove(created.id, 'u2')).rejects.toBeInstanceOf(NotFoundException);
|
|
});
|
|
|
|
it('remove() deletes the row when owned by the caller', async () => {
|
|
const prisma = makeFakePrisma();
|
|
const service = new TenderSavedSearchService(prisma as any);
|
|
|
|
const created = await service.create('u1', 'tenant1', { name: 'Alt', filters: {} });
|
|
await service.remove(created.id, 'u1');
|
|
|
|
expect(await service.list('u1')).toEqual([]);
|
|
});
|
|
|
|
// --- instantAlert passthrough (NOTIFY-02, D-04) ---------------------------
|
|
|
|
it('create() persists instantAlert=true when supplied', async () => {
|
|
const prisma = makeFakePrisma();
|
|
const service = new TenderSavedSearchService(prisma as any);
|
|
|
|
const result = await service.create('u1', 'tenant1', {
|
|
name: 'Bau NRW',
|
|
filters: {},
|
|
instantAlert: true,
|
|
});
|
|
|
|
expect(result.instantAlert).toBe(true);
|
|
});
|
|
|
|
it('create() leaves instantAlert unset (falls back to the Prisma column default) when omitted', async () => {
|
|
const prisma = makeFakePrisma();
|
|
const service = new TenderSavedSearchService(prisma as any);
|
|
|
|
const result = await service.create('u1', 'tenant1', {
|
|
name: 'Bau NRW',
|
|
filters: {},
|
|
});
|
|
|
|
expect(result.instantAlert).toBeUndefined();
|
|
});
|
|
|
|
it('update() persists an instantAlert toggle for an owned profile', async () => {
|
|
const prisma = makeFakePrisma();
|
|
const service = new TenderSavedSearchService(prisma as any);
|
|
|
|
const created = await service.create('u1', 'tenant1', {
|
|
name: 'Alt',
|
|
filters: {},
|
|
instantAlert: false,
|
|
});
|
|
const updated = await service.update(created.id, 'u1', { instantAlert: true });
|
|
|
|
expect(updated.instantAlert).toBe(true);
|
|
});
|
|
});
|