import { describe, it, expect } from 'vitest'; import { mkdtempSync, mkdirSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { parsePermissionSeed, parseNavSections, parseDbSetEntities, parseConfigurationEntities, resolveAliases, parseDomainEntity, parseListControllers, parsePermissionConstants, assemble, discoverFromProject, } from '../discover.js'; const PERM_SEED = ` var permissionsToSeed = new (string Path, string Action, string SectionCode)[] { ("todo.parametres.categories.read", "read", "categories"), ("todo.parametres.categories.create", "create", "categories"), ("todo.taches.liste.read", "read", "liste"), ("todo.taches.liste.assign", "assign", "liste"), }; `; const NAV_SEED = ` { var parentMod_taches = await context.NavigationModules.FirstOrDefaultAsync(m => m.Code == "taches", ct); if (parentMod_taches is not null) { var section = NavigationSection.Create( moduleId: parentMod_taches.Id, code: "liste", label: "Tâches", icon: "ListTodo", iconType: IconType.Lucide, route: "/todo/taches/liste", displayOrder: 1); context.NavigationSections.Add(section); } } { var section = NavigationSection.Create( moduleId: parentMod_parametres.Id, code: "categories", label: "Catégories", icon: "Tag", iconType: IconType.Lucide, route: "/todo/parametres/categories", displayOrder: 1); } `; const DBCONTEXT = ` using DomainTask = Test.Domain.Entities.Task; public class ExtensionsDbContext : SmartStackExtensionDbContext { public DbSet Categories => Set(); public DbSet Tasks => Set(); public DbSet TaskComments => Set(); } `; const DOMAIN_TASK = ` using SmartStack.Domain.Common; namespace Test.Domain.Entities; public class Task : ExtensionBaseEntity, ITenantEntity { public Guid TenantId { get; private set; } public string Title { get; private set; } = null!; } `; const TASKS_CONTROLLER = ` namespace Test.Api.Controllers.Taches; public class TasksController : ControllerBase { [HttpGet] [RequirePermission(TachesPermissions.Liste.Read)] [ProducesResponseType(typeof(List), StatusCodes.Status200OK)] public async Task GetAll(CancellationToken ct) => Ok(); [HttpGet("{id:guid}")] [RequirePermission(TachesPermissions.Liste.Read)] [ProducesResponseType(typeof(TaskDto), StatusCodes.Status200OK)] public async Task GetById(Guid id) => Ok(); } `; describe('discover / parsers', () => { it('parsePermissionSeed extracts paths and segments', () => { const perms = parsePermissionSeed(PERM_SEED); expect(perms).toHaveLength(4); const assign = perms.find((p) => p.action === 'assign')!; expect(assign).toMatchObject({ app: 'todo', module: 'taches', section: 'liste', action: 'assign' }); }); it('parseNavSections derives app/module from route + reads label/icon', () => { const secs = parseNavSections(NAV_SEED); const liste = secs.find((s) => s.code === 'liste')!; expect(liste).toMatchObject({ app: 'todo', module: 'taches', label: 'Tâches', icon: 'ListTodo', route: '/todo/taches/liste' }); expect(secs.find((s) => s.code === 'categories')!.module).toBe('parametres'); }); it('resolveAliases + parseDbSetEntities resolve the Task alias', () => { expect(resolveAliases(DBCONTEXT).get('DomainTask')).toBe('Test.Domain.Entities.Task'); const ents = parseDbSetEntities(DBCONTEXT); expect(ents.map((e) => e.simpleName).sort()).toEqual(['Category', 'Task', 'TaskComment']); expect(ents.find((e) => e.simpleName === 'Task')!.fqn).toBe('Test.Domain.Entities.Task'); }); it('parseDomainEntity detects ITenantEntity + namespace', () => { const r = parseDomainEntity(DOMAIN_TASK, 'Task')!; expect(r.tenantScoped).toBe(true); expect(r.fqn).toBe('Test.Domain.Entities.Task'); }); it('parseDomainEntity recognizes IOptionalTenantEntity as tenant-scoped (§23 — the 35-entity leak)', () => { const src = ` namespace Flotte.Domain.Flotte.Parc; public class Vehicle : ExtensionBaseEntity, IOptionalTenantEntity, ICodedEntity { public Guid? TenantId { get; private set; } public string Vin { get; private set; } = null!; } `; const r = parseDomainEntity(src, 'Vehicle')!; expect(r.tenantScoped).toBe(true); expect(r.fqn).toBe('Flotte.Domain.Flotte.Parc.Vehicle'); }); it('parseDomainEntity falls back to a declared TenantId property (tenancy inherited from a base class)', () => { const src = ` namespace Test.Domain.Entities { public class Widget : TenantScopedBase { public Guid? TenantId { get; private set; } } } `; const r = parseDomainEntity(src, 'Widget')!; expect(r.tenantScoped).toBe(true); // Block-scoped namespace resolves too. expect(r.fqn).toBe('Test.Domain.Entities.Widget'); }); it('parseDomainEntity stays false on a genuinely global entity', () => { const src = ` namespace Test.Domain.Entities; public class GlobalLookup : ExtensionBaseEntity { public string Code { get; private set; } = null!; } `; expect(parseDomainEntity(src, 'GlobalLookup')!.tenantScoped).toBe(false); }); it('parseConfigurationEntities enumerates IEntityTypeConfiguration (no DbSet needed)', () => { const src = ` namespace Test.Infrastructure.Persistence.Configurations; public class VehicleConfiguration : IEntityTypeConfiguration { public void Configure(EntityTypeBuilder builder) { } } public class RegistrationConfiguration : IEntityTypeConfiguration { public void Configure(EntityTypeBuilder builder) { } } `; const ents = parseConfigurationEntities(src); expect(ents.map((e) => e.simpleName).sort()).toEqual(['Vehicle', 'VehicleRegistration']); expect(ents.find((e) => e.simpleName === 'VehicleRegistration')!.fqn).toBe('Test.Domain.VehicleRegistration'); }); it('parseListControllers binds the LIST GetAll action only', () => { const screens = parseListControllers(TASKS_CONTROLLER); expect(screens).toHaveLength(1); // the {id} detail GET is not a list expect(screens[0]).toMatchObject({ entitySimpleName: 'Task', module: 'taches', section: 'liste' }); }); it('sanitizes the literal "undefined" icon the generator emits for icon-less sections', () => { const navNoIcon = NAV_SEED.replace('"ListTodo"', '"undefined"'); expect(parseNavSections(navNoIcon).find((s) => s.code === 'liste')!.icon).toBe('Search'); }); it('matches IReadOnlyList<…Dto> list actions and resolves the real section from the constant value', () => { const constants = ` public static class ParametresPermissions { public static class Priorities { public const string Read = "parametres.priorites.read"; } }`; const controller = ` public class PrioritiesController : ControllerBase { [HttpGet] [RequirePermission(ParametresPermissions.Priorities.Read)] [ProducesResponseType(typeof(IReadOnlyList), StatusCodes.Status200OK)] public async Task GetAll() => Ok(); }`; const constMap = parsePermissionConstants(constants); expect(constMap.get('ParametresPermissions.Priorities.Read')).toBe('parametres.priorites.read'); // With the constant map → real French section "priorites" (not the English class name). expect(parseListControllers(controller, constMap)).toEqual([ { entitySimpleName: 'Priority', module: 'parametres', section: 'priorites' }, ]); // Without it → falls back to the kebab class name "priorities". expect(parseListControllers(controller)[0].section).toBe('priorities'); }); }); describe('discover / assemble (fail-closed row scope)', () => { const base = { appCode: 'Test', projectPath: '/tmp', contextType: 'ExtensionsDbContext', permissions: parsePermissionSeed(PERM_SEED), sections: parseNavSections(NAV_SEED), entities: [ { simpleName: 'Task', fqn: 'Test.Domain.Entities.Task', tenantScoped: true }, { simpleName: 'Category', fqn: 'Test.Domain.Entities.Category', tenantScoped: true }, ], listScreens: [ { entitySimpleName: 'Task', module: 'taches', section: 'liste' }, { entitySimpleName: 'Category', module: 'parametres', section: 'categories' }, ], }; it('registers the non-suspect entity and withholds the row-scope-suspect one', () => { const { spec, review } = assemble(base); // Category (no assign on its section) → registered. expect(spec.entities.map((e) => e.entityName)).toEqual(['Test.Domain.Entities.Category']); const cat = spec.entities[0]; expect(cat.permission).toBe('todo.parametres.categories.read'); expect(cat.route).toBe('/todo/parametres/categories/{id}'); expect(cat.tenantScoped).toBe(true); // Task (liste has .assign) → NOT registered, flagged for review with a suggestion. expect(spec.entities.find((e) => e.entityName.endsWith('.Task'))).toBeUndefined(); const suspect = review.find((r) => r.kind === 'row-scope-suspect')!; expect(suspect.entity).toBe('Task'); expect(suspect.suggestion).toContain('.RestrictTo('); }); it('flags entities with no list-screen binding', () => { const { review } = assemble({ ...base, listScreens: [] }); const unbound = review.filter((r) => r.kind === 'unbound-entity').map((r) => r.entity).sort(); expect(unbound).toEqual(['Category', 'Task']); }); }); describe('discoverFromProject (ss init template shape — no DbSet, IOptionalTenantEntity)', () => { it('finds entities via Configurations and marks IOptionalTenantEntity tenant-scoped', () => { const root = mkdtempSync(join(tmpdir(), 'ssdiscover-')); const w = (rel: string, content: string) => { const abs = join(root, rel); mkdirSync(join(abs, '..'), { recursive: true }); writeFileSync(abs, content, 'utf-8'); }; w('src/Flotte.Infrastructure/Persistence/Seeding/FlotteCorePermissionsSeedDataProvider.cs', ` var permissionsToSeed = new (string Path, string Action, string SectionCode)[] { ("flotte.parametrage.cantons.read", "read", "cantons"), }; `); w('src/Flotte.Infrastructure/Persistence/Seeding/FlotteCoreNavigationSeedDataProvider.cs', ` var section = NavigationSection.Create( moduleId: parentMod_parametrage.Id, code: "cantons", label: "Cantons", icon: "Map", iconType: IconType.Lucide, route: "/flotte/parametrage/cantons", displayOrder: 1); `); // The ss init template: NO DbSet — model mounted by ApplyConfigurationsFromAssembly. w('src/Flotte.Infrastructure/Persistence/ExtensionsDbContext.cs', ` public class ExtensionsDbContext : SmartStackExtensionDbContext { // Add your DbSet properties here. Example: protected override void OnModelCreating(ModelBuilder modelBuilder) { modelBuilder.ApplyConfigurationsFromAssembly(typeof(ExtensionsDbContext).Assembly); } } `); w('src/Flotte.Infrastructure/Persistence/Configurations/CantonConfiguration.cs', ` public class CantonConfiguration : IEntityTypeConfiguration { public void Configure(EntityTypeBuilder builder) { } } `); w('src/Flotte.Domain/Flotte/Parametrage/Canton.cs', ` namespace Flotte.Domain.Flotte.Parametrage; public class Canton : ExtensionBaseEntity, IOptionalTenantEntity { public Guid? TenantId { get; private set; } public string Code { get; private set; } = null!; } `); w('src/Flotte.Api/Controllers/Parametrage/CantonsController.cs', ` public class CantonsController : ControllerBase { [HttpGet] [RequirePermission(ParametragePermissions.Cantons.Read)] [ProducesResponseType(typeof(List), StatusCodes.Status200OK)] public async Task GetAll(CancellationToken ct) => Ok(); } `); w('src/Flotte.Application/Common/Security/ParametragePermissions.cs', ` public static class ParametragePermissions { public static class Cantons { public const string Read = "flotte.parametrage.cantons.read"; } } `); const { spec, review } = discoverFromProject(root, 'Flotte'); expect(review.filter((r) => r.kind === 'row-scope-suspect')).toHaveLength(0); expect(spec.entities).toHaveLength(1); const canton = spec.entities[0]; expect(canton.entityName).toBe('Flotte.Domain.Flotte.Parametrage.Canton'); // The §23 leak: IOptionalTenantEntity used to yield tenantScoped:false — a // generated search that traversed tenants. expect(canton.tenantScoped).toBe(true); expect(canton.permission).toBe('flotte.parametrage.cantons.read'); }); });