fix(domains): Neuabruf ?refresh=1 nur fuer Verwalter (WR-06)
Jeder Neuabruf sind bis zu 20 Aufrufe auf dem geteilten AutoDNS-Takt; Benutzer ohne Freigabestufe Verwalten koennten damit Bestellung und Abgleich verdraengen. Der ModuleGuard legt die wirksame Stufe auf den Request, das Flag wird fuer Nicht-Verwalter ignoriert (Zwischenspeicher bleibt). Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import type { Role } from '@prisma/client';
|
||||
import type { ModuleGrantLevel, Role } from '@prisma/client';
|
||||
import type { Request } from 'express';
|
||||
|
||||
/**
|
||||
@@ -83,6 +83,11 @@ export interface AuthUser {
|
||||
export interface AuthenticatedRequest extends Request {
|
||||
user?: AuthUser;
|
||||
tenantId?: string | null;
|
||||
/**
|
||||
* Wirksame Freigabestufe des Aufrufers fuer das Modul der Route; setzt
|
||||
* `ModuleGuard` (nur auf Routen mit `@UseModule`/`@ModuleManage`).
|
||||
*/
|
||||
moduleAccessLevel?: ModuleGrantLevel;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -7,6 +7,9 @@ import { DomainsController } from './domains.controller';
|
||||
|
||||
const proto = DomainsController.prototype as any;
|
||||
const req = (tenantId?: string) => ({ tenantId }) as any;
|
||||
/** Aufrufer mit der Freigabestufe Verwalten (setzt sonst der ModuleGuard). */
|
||||
const managerReq = (tenantId?: string) => ({ tenantId, moduleAccessLevel: 'MANAGE' }) as any;
|
||||
const userReq = (tenantId?: string) => ({ tenantId, moduleAccessLevel: 'USE' }) as any;
|
||||
|
||||
const MANAGE_HANDLERS = [
|
||||
'getSettings',
|
||||
@@ -160,13 +163,13 @@ describe('DomainsController — Verhalten', () => {
|
||||
await c.createCustomer(req('t1'), { name: 'A' } as any);
|
||||
await c.updateCustomer(req('t1'), id, { name: 'B' } as any);
|
||||
await c.deleteCustomer(req('t1'), id);
|
||||
await c.listContacts(req('t1'), '1');
|
||||
await c.listContacts(req('t1'), 'true');
|
||||
await c.listContacts(req('t1'), undefined);
|
||||
await c.listContacts(req('t1'), '0');
|
||||
await c.listContacts(managerReq('t1'), '1');
|
||||
await c.listContacts(managerReq('t1'), 'true');
|
||||
await c.listContacts(managerReq('t1'), undefined);
|
||||
await c.listContacts(managerReq('t1'), '0');
|
||||
await c.createContact(req('t1'), { type: 'PERSON' } as any);
|
||||
await c.assignContacts(req('t1'), { contactIds: [1], customerId: null });
|
||||
await c.listDomains(req('t1'), 'true');
|
||||
await c.listDomains(managerReq('t1'), 'true');
|
||||
expect(directory.listCustomers).toHaveBeenCalledWith('t1');
|
||||
expect(directory.createCustomer).toHaveBeenCalledWith('t1', { name: 'A' });
|
||||
expect(directory.updateCustomer).toHaveBeenCalledWith('t1', id, { name: 'B' });
|
||||
@@ -185,6 +188,23 @@ describe('DomainsController — Verhalten', () => {
|
||||
expect(directory.listDomains).toHaveBeenCalledWith('t1', { refresh: true });
|
||||
});
|
||||
|
||||
it('refresh=1 wird ohne Freigabestufe Verwalten ignoriert (kein Neuabruf bei AutoDNS)', async () => {
|
||||
const directory = makeDirectory();
|
||||
const c = new DomainsController(makeSettings() as any, directory as any, makeOrders() as any);
|
||||
await c.listContacts(userReq('t1'), '1');
|
||||
await c.listContacts(req('t1'), 'true');
|
||||
await c.listDomains(userReq('t1'), '1');
|
||||
await c.listDomains(req('t1'), 'true');
|
||||
expect(directory.listContacts.mock.calls.map((x) => x[1])).toEqual([
|
||||
{ refresh: false },
|
||||
{ refresh: false },
|
||||
]);
|
||||
expect(directory.listDomains.mock.calls.map((x) => x[1])).toEqual([
|
||||
{ refresh: false },
|
||||
{ refresh: false },
|
||||
]);
|
||||
});
|
||||
|
||||
it('ohne Mandantenkontext 403', async () => {
|
||||
const c = new DomainsController(
|
||||
makeSettings() as any,
|
||||
@@ -222,8 +242,6 @@ describe('DomainsController — Auftraege: Verhalten', () => {
|
||||
makeDirectory() as any,
|
||||
makeOrders() as any,
|
||||
);
|
||||
await expect(c.submitOrder(req(undefined), user, id)).rejects.toBeInstanceOf(
|
||||
ForbiddenException,
|
||||
);
|
||||
await expect(c.submitOrder(req(undefined), user, id)).rejects.toBeInstanceOf(ForbiddenException);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
Query,
|
||||
Req,
|
||||
} from '@nestjs/common';
|
||||
import { ModuleGrantLevel } from '@prisma/client';
|
||||
import { CurrentUser } from '../auth/decorators/current-user.decorator';
|
||||
import type { AuthenticatedRequest, AuthUser } from '../auth/types/auth-user';
|
||||
import { ModuleManage, UseModule } from '../module-registry/module.guard';
|
||||
@@ -22,11 +23,23 @@ import { DomainsCustomerDto } from './dto/domains-customer.dto';
|
||||
import { CheckAvailabilityDto, CreateDomainsOrderDto } from './dto/domains-order.dto';
|
||||
import { SaveDomainsSettingsDto, TestDomainsConnectionDto } from './dto/domains-settings.dto';
|
||||
|
||||
/** `?refresh=1` oder `?refresh=true` umgeht den Zwischenspeicher. */
|
||||
/** `?refresh=1` oder `?refresh=true` verlangt, den Zwischenspeicher zu umgehen. */
|
||||
function isTruthyFlag(value: string | undefined): boolean {
|
||||
return value === '1' || value === 'true';
|
||||
}
|
||||
|
||||
/**
|
||||
* Nur Verwalter duerfen den Zwischenspeicher umgehen: jeder Neuabruf sind bis zu
|
||||
* 20 Aufrufe an AutoDNS auf dem prozessweit geteilten Takt von 3 pro Sekunde, mit
|
||||
* denen sich Bestellung und Abgleich aller verdraengen liessen. Fuer Benutzer ohne
|
||||
* Freigabestufe Verwalten wird das Flag ohne Fehlermeldung ignoriert; sie sehen
|
||||
* den Zwischenspeicher. Die Stufe setzt der `ModuleGuard` aus den Freigaben, nie
|
||||
* aus Body oder Query.
|
||||
*/
|
||||
function mayRefresh(req: AuthenticatedRequest, value: string | undefined): boolean {
|
||||
return isTruthyFlag(value) && req.moduleAccessLevel === ModuleGrantLevel.MANAGE;
|
||||
}
|
||||
|
||||
/**
|
||||
* `@UseModule('domains')` auf Klassenebene — Aktivierung UND Freigabe.
|
||||
* `tenantId` kommt ausschliesslich aus `req.tenantId`, nie aus Body oder Query.
|
||||
@@ -105,7 +118,7 @@ export class DomainsController {
|
||||
@Get('contacts')
|
||||
async listContacts(@Req() req: AuthenticatedRequest, @Query('refresh') refresh?: string) {
|
||||
return this.directory.listContacts(this.requireTenantId(req), {
|
||||
refresh: isTruthyFlag(refresh),
|
||||
refresh: mayRefresh(req, refresh),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -124,7 +137,7 @@ export class DomainsController {
|
||||
@Get('domains')
|
||||
async listDomains(@Req() req: AuthenticatedRequest, @Query('refresh') refresh?: string) {
|
||||
return this.directory.listDomains(this.requireTenantId(req), {
|
||||
refresh: isTruthyFlag(refresh),
|
||||
refresh: mayRefresh(req, refresh),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -119,6 +119,8 @@ describe('ModuleGuard.canActivate', () => {
|
||||
expect(result).toBe(true);
|
||||
expect((request as any).moduleAccessLevels).toBe(accessibleIds);
|
||||
expect([...(request as any).moduleAccessIds]).toEqual(['mod-1']);
|
||||
// Die wirksame Stufe fuer das Modul der Route steht auf dem Request (Neuabruf nur fuer Verwalter).
|
||||
expect((request as any).moduleAccessLevel).toBe('USE');
|
||||
});
|
||||
|
||||
/**
|
||||
|
||||
@@ -115,6 +115,7 @@ export class ModuleGuard implements CanActivate {
|
||||
}
|
||||
|
||||
request.moduleAccessLevels = levels;
|
||||
request.moduleAccessLevel = level;
|
||||
request.moduleAccessIds = new Set(levels.keys());
|
||||
|
||||
return true;
|
||||
|
||||
Reference in New Issue
Block a user