feat(15-05): getWidgets filters via ModuleAccessService (D-22, PERM-07)
- DashboardModule imports ModuleRegistryModule to inject ModuleAccessService - getWidgets(userId, tenantId, role) runs the existing findMany unchanged first, then calls getAccessibleModuleIds exactly once — only if a loaded widget's type is in WIDGET_MODULE_MAP (currently always empty, so no lookup runs today); unresolved module slugs fail closed - DashboardController.getWidgets forwards tenantId + role from the JWT - dashboard.service.spec.ts (8 tests, TDD-GREEN): covers every <behavior> case incl. D-03 ADMIN bypass, adjacency/empty/ordering/idempotency, and fail-closed on an unresolved Module slug - pnpm --filter @tessera/api test: 457/457 green; type-check clean - manual e2e against local API + DB container: empty WIDGET_MODULE_MAP leaves an existing user's widget count unchanged (2/2 clock+search survived the filter), throwaway verification user/rows removed after
This commit is contained in:
@@ -68,8 +68,9 @@ export class DashboardController {
|
||||
|
||||
@Get('widgets')
|
||||
async getWidgets(@Req() req: Request) {
|
||||
const { userId } = this.extractContext(req);
|
||||
return this.dashboardService.getWidgets(userId);
|
||||
const { userId, tenantId } = this.extractContext(req);
|
||||
const role = (req as any).user?.role;
|
||||
return this.dashboardService.getWidgets(userId, tenantId, role);
|
||||
}
|
||||
|
||||
@Post('widgets')
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { ModuleRegistryModule } from '../module-registry/module-registry.module';
|
||||
import { DashboardController } from './dashboard.controller';
|
||||
import { DashboardService } from './dashboard.service';
|
||||
|
||||
@@ -9,9 +10,13 @@ import { DashboardService } from './dashboard.service';
|
||||
* - DashboardService: CRUD for per-user dashboard layouts and widget instances
|
||||
* - DashboardController: REST API for layout and widget operations
|
||||
*
|
||||
* Imports ModuleRegistryModule so DashboardService can inject
|
||||
* ModuleAccessService for the D-22 widget-module filter (Plan 15-05).
|
||||
*
|
||||
* Exports DashboardService so downstream modules can access layout/widget data.
|
||||
*/
|
||||
@Module({
|
||||
imports: [ModuleRegistryModule],
|
||||
controllers: [DashboardController],
|
||||
providers: [DashboardService],
|
||||
exports: [DashboardService],
|
||||
|
||||
@@ -5,9 +5,12 @@ import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
* Objektreferenz (`mockMap`) gesteuert: `getModuleSlugForWidgetType`
|
||||
* liest `mockMap` erst zur Aufrufzeit, deshalb genügt eine Mutation vor
|
||||
* jedem Test statt eines dynamischen Imports mit `vi.resetModules()` je
|
||||
* Testfall (vi.mock wird von Vitest ohnehin an den Dateianfang gehoben).
|
||||
* Testfall. `vi.mock` selbst wird von Vitest an den Dateianfang gehoben —
|
||||
* `mockMap` muss deshalb über `vi.hoisted` deklariert werden, sonst
|
||||
* schlägt der Zugriff im Factory-Callback mit einem
|
||||
* Initialisierungsfehler fehl.
|
||||
*/
|
||||
const mockMap: Record<string, string> = {};
|
||||
const mockMap = vi.hoisted(() => ({}) as Record<string, string>);
|
||||
|
||||
vi.mock('./widget-module-map', () => ({
|
||||
WIDGET_MODULE_MAP: mockMap,
|
||||
@@ -125,7 +128,7 @@ describe('DashboardService.getWidgets — Modulfilter (D-22, PERM-07)', () => {
|
||||
|
||||
it('D-03: ein ADMIN sieht ein modulgebundenes Widget eines aktiven Moduls auch ohne eigenen Grant', async () => {
|
||||
mockMap['tender-radar'] = 'tender-radar';
|
||||
const widget = makeWidget({ id: 'w1', widgetType: 'tender-radar' });
|
||||
const widget = makeWidget({ id: 'w1', userId: 'admin-1', widgetType: 'tender-radar' });
|
||||
const prisma = makeFakePrisma({
|
||||
widgets: [widget],
|
||||
modules: [{ id: 'mod-1', slug: 'tender-radar' }],
|
||||
|
||||
@@ -2,12 +2,14 @@ import {
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { Prisma, Role } from '@prisma/client';
|
||||
import { ModuleAccessService } from '../module-registry/module-access.service';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { CreateSearchProviderDto } from './dto/create-search-provider.dto';
|
||||
import { CreateWidgetDto } from './dto/create-widget.dto';
|
||||
import { SaveLayoutDto } from './dto/save-layout.dto';
|
||||
import { UpdateWidgetConfigDto } from './dto/update-widget-config.dto';
|
||||
import { getModuleSlugForWidgetType } from './widget-module-map';
|
||||
|
||||
/**
|
||||
* Default search providers (D-15).
|
||||
@@ -54,7 +56,10 @@ const DEFAULT_SEARCH_PROVIDERS = [
|
||||
*/
|
||||
@Injectable()
|
||||
export class DashboardService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly moduleAccessService: ModuleAccessService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Returns the user's saved layout, or a default empty layout
|
||||
@@ -89,13 +94,60 @@ export class DashboardService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns all widget instances for a given user.
|
||||
* Returns all widget instances for a given user, gefiltert um Widgets
|
||||
* eines für den Benutzer gesperrten Moduls (D-22, PERM-07).
|
||||
*
|
||||
* Die bestehende Query bleibt unverändert die erste Aktion. Steht unter
|
||||
* den geladenen Widgets kein einziger Typ in `WIDGET_MODULE_MAP` — der
|
||||
* Zustand am Ende dieser Phase, weil die Tabelle leer ist — wird die
|
||||
* Liste unverändert zurückgegeben, ohne einen Zugriffs-Lookup. Nur bei
|
||||
* mindestens einem modulgebundenen Widget wird die Zugriffsauflösung
|
||||
* aus 15-01 einmal aufgerufen (D-01: dieselbe Auflösung wie Guard und
|
||||
* Sidebar, keine zweite Implementierung). Lässt sich ein eingetragener
|
||||
* Modul-Slug nicht auf einen `Module`-Datensatz auflösen, wird das
|
||||
* betroffene Widget entfernt (Fail-Closed).
|
||||
*/
|
||||
async getWidgets(userId: string) {
|
||||
return this.prisma.widgetInstance.findMany({
|
||||
async getWidgets(userId: string, tenantId: string, role: Role) {
|
||||
const widgets = await this.prisma.widgetInstance.findMany({
|
||||
where: { userId },
|
||||
orderBy: { createdAt: 'asc' },
|
||||
});
|
||||
|
||||
const boundSlugs = [
|
||||
...new Set(
|
||||
widgets
|
||||
.map((w) => getModuleSlugForWidgetType(w.widgetType))
|
||||
.filter((slug): slug is string => slug !== undefined),
|
||||
),
|
||||
];
|
||||
|
||||
if (boundSlugs.length === 0) {
|
||||
return widgets;
|
||||
}
|
||||
|
||||
const accessibleModuleIds = await this.moduleAccessService.getAccessibleModuleIds(
|
||||
tenantId,
|
||||
userId,
|
||||
role,
|
||||
);
|
||||
|
||||
const modules = await this.prisma.module.findMany({
|
||||
where: { slug: { in: boundSlugs } },
|
||||
select: { id: true, slug: true },
|
||||
});
|
||||
const slugToModuleId = new Map(modules.map((m) => [m.slug, m.id]));
|
||||
|
||||
return widgets.filter((w) => {
|
||||
const slug = getModuleSlugForWidgetType(w.widgetType);
|
||||
if (slug === undefined) {
|
||||
return true;
|
||||
}
|
||||
const moduleId = slugToModuleId.get(slug);
|
||||
if (moduleId === undefined) {
|
||||
return false;
|
||||
}
|
||||
return accessibleModuleIds.has(moduleId);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user