diff --git a/backend/src/database/seeds/database-seeder.service.spec.ts b/backend/src/database/seeds/database-seeder.service.spec.ts index d56285e..e31dc2c 100644 --- a/backend/src/database/seeds/database-seeder.service.spec.ts +++ b/backend/src/database/seeds/database-seeder.service.spec.ts @@ -1,6 +1,7 @@ import { Test, TestingModule } from '@nestjs/testing'; import { getRepositoryToken } from '@nestjs/typeorm'; import { getLoggerToken } from 'nestjs-pino'; +import { CACHE_MANAGER } from '@nestjs/cache-manager'; import { Repository } from 'typeorm'; import { DatabaseSeederService } from './database-seeder.service'; import { Role } from '../../modules/roles/role.entity'; @@ -8,6 +9,8 @@ import { Organization } from '../../modules/organizations/organization.entity'; import { User } from '../../modules/users/user.entity'; import { UserOrganizationRole } from '../../modules/user-organization-roles/user-organization-role.entity'; import { Game } from '../../modules/games/game.entity'; +import { OrgPermission } from '../../modules/permissions/permissions.constants'; +import { defaultRoles } from './roles.seed'; // Mock bcrypt module jest.mock('bcrypt', () => ({ @@ -27,6 +30,12 @@ describe('DatabaseSeederService', () => { error: jest.fn(), debug: jest.fn(), }; + const mockCacheManager = { + clear: jest.fn().mockResolvedValue(undefined), + // cache-manager v7: stores is an array of Keyv instances. Tests use an + // empty array (no Redis) so the seeder logs a warning instead of clearing. + stores: [] as unknown[], + }; let loggerErrorSpy: jest.Mock; const mockGame = { @@ -72,6 +81,8 @@ describe('DatabaseSeederService', () => { mockLogger.warn.mockClear(); mockLogger.error.mockClear(); mockLogger.debug.mockClear(); + mockCacheManager.clear.mockClear(); + (mockCacheManager as { stores: unknown[] }).stores = []; loggerErrorSpy = mockLogger.error; const module: TestingModule = await Test.createTestingModule({ @@ -81,6 +92,10 @@ describe('DatabaseSeederService', () => { provide: getLoggerToken(DatabaseSeederService.name), useValue: mockLogger, }, + { + provide: CACHE_MANAGER, + useValue: mockCacheManager, + }, { provide: getRepositoryToken(Role), useValue: { @@ -192,13 +207,29 @@ describe('DatabaseSeederService', () => { await expect(service.seedAll()).resolves.toBeUndefined(); }); - it('should handle existing data gracefully', async () => { + it('should update role permissions and warn when no Redis store is available', async () => { + const ownerSeedData = defaultRoles.find((r) => r.name === 'Owner')!; + jest .spyOn(gamesRepository, 'findOne') .mockResolvedValue(mockGame as unknown as Game); - jest - .spyOn(rolesRepository, 'findOne') - .mockResolvedValue(mockRole as unknown as Role); + + // Return a role with legacy camelCase keys for every findOne call so all + // existing-role paths exercise the strip-and-merge logic. + jest.spyOn(rolesRepository, 'findOne').mockImplementation( + async () => + ({ + ...mockRole, + permissions: { + canViewOrganization: true, + canInviteUsers: true, + }, + }) as unknown as Role, + ); + + const saveSpy = jest + .spyOn(rolesRepository, 'save') + .mockImplementation(async (role) => role as Role); jest .spyOn(organizationsRepository, 'findOne') .mockResolvedValue(mockOrganization as unknown as Organization); @@ -212,10 +243,454 @@ describe('DatabaseSeederService', () => { await expect(service.seedAll()).resolves.toBeUndefined(); expect(gamesRepository.save).not.toHaveBeenCalled(); - expect(rolesRepository.save).not.toHaveBeenCalled(); + expect(rolesRepository.save).toHaveBeenCalled(); expect(organizationsRepository.save).not.toHaveBeenCalled(); expect(usersRepository.save).not.toHaveBeenCalled(); expect(userOrgRolesRepository.save).not.toHaveBeenCalled(); + // No Redis store — must warn, not call clear() (which only clears the + // seeder's own throwaway cache, not the running backend's). + expect(mockCacheManager.clear).not.toHaveBeenCalled(); + expect(mockLogger.warn).toHaveBeenCalledWith( + expect.stringContaining('No Redis store found'), + ); + + // The first saved role is Owner — assert legacy keys were stripped and seed + // permissions were applied correctly. + const savedOwner = saveSpy.mock.calls[0][0] as Partial; + expect(savedOwner.permissions).not.toHaveProperty('canViewOrganization'); + expect(savedOwner.permissions).not.toHaveProperty('canInviteUsers'); + expect(savedOwner.permissions).toEqual( + expect.objectContaining(ownerSeedData.permissions), + ); + }); + + it('should update description without clearing cache when only description differs', async () => { + const ownerSeedData = defaultRoles.find((r) => r.name === 'Owner')!; + + jest + .spyOn(gamesRepository, 'findOne') + .mockResolvedValue(mockGame as unknown as Game); + + // Return roles with correct permissions but a known legacy description + // that the seeder is allowed to replace. + jest + .spyOn(rolesRepository, 'findOne') + .mockImplementation(async (opts) => { + const name = (opts as { where: { name: string } }).where.name; + const seedRole = defaultRoles.find((r) => r.name === name); + return { + ...mockRole, + name, + description: + 'Full access to organization. Can delete organization and manage all settings.', + permissions: { ...(seedRole?.permissions ?? {}) }, + } as unknown as Role; + }); + + const saveSpy = jest + .spyOn(rolesRepository, 'save') + .mockImplementation(async (role) => role as Role); + jest + .spyOn(organizationsRepository, 'findOne') + .mockResolvedValue(mockOrganization as unknown as Organization); + jest + .spyOn(usersRepository, 'findOne') + .mockResolvedValue(mockUser as unknown as User); + jest + .spyOn(userOrgRolesRepository, 'findOne') + .mockResolvedValue(mockUserOrgRole as unknown as UserOrganizationRole); + + await service.seedAll(); + + // Save must have been called to patch the description. + expect(saveSpy).toHaveBeenCalled(); + const savedOwner = saveSpy.mock.calls[0][0] as Partial; + expect(savedOwner.description).toBe(ownerSeedData.description); + // Permissions did not change so cache must NOT be cleared. + expect(mockCacheManager.clear).not.toHaveBeenCalled(); + }); + + it('should not save or clear cache when permissions are already up to date', async () => { + jest + .spyOn(gamesRepository, 'findOne') + .mockResolvedValue(mockGame as unknown as Game); + + // Return the exact seed permissions AND description for each role so no + // change is detected and the idempotency path skips save entirely. + jest + .spyOn(rolesRepository, 'findOne') + .mockImplementation(async (opts) => { + const name = (opts as { where: { name: string } }).where.name; + const seedRole = defaultRoles.find((r) => r.name === name); + return { + ...mockRole, + name, + description: seedRole?.description ?? mockRole.description, + permissions: { ...(seedRole?.permissions ?? {}) }, + } as unknown as Role; + }); + jest + .spyOn(rolesRepository, 'save') + .mockImplementation(async (role) => role as Role); + jest + .spyOn(organizationsRepository, 'findOne') + .mockResolvedValue(mockOrganization as unknown as Organization); + jest + .spyOn(usersRepository, 'findOne') + .mockResolvedValue(mockUser as unknown as User); + jest + .spyOn(userOrgRolesRepository, 'findOne') + .mockResolvedValue(mockUserOrgRole as unknown as UserOrganizationRole); + + await expect(service.seedAll()).resolves.toBeUndefined(); + + expect(rolesRepository.save).not.toHaveBeenCalled(); + expect(mockCacheManager.clear).not.toHaveBeenCalled(); + }); + + it('should preserve custom (non-legacy) permission keys when merging', async () => { + const ownerSeedData = defaultRoles.find((r) => r.name === 'Owner')!; + // Use a key that is not in the seed matrix for Owner so it cannot be + // overwritten by the merge and truly exercises the custom-key path. + const customKey = 'custom:guild:officer' as unknown as OrgPermission; + + jest + .spyOn(gamesRepository, 'findOne') + .mockResolvedValue(mockGame as unknown as Game); + + jest.spyOn(rolesRepository, 'findOne').mockImplementation( + async () => + ({ + ...mockRole, + permissions: { + // Legacy key — should be stripped. + canViewOrganization: true, + // Unknown custom key — should be preserved. + [customKey]: true, + }, + }) as unknown as Role, + ); + + const saveSpy = jest + .spyOn(rolesRepository, 'save') + .mockImplementation(async (role) => role as Role); + jest + .spyOn(organizationsRepository, 'findOne') + .mockResolvedValue(mockOrganization as unknown as Organization); + jest + .spyOn(usersRepository, 'findOne') + .mockResolvedValue(mockUser as unknown as User); + jest + .spyOn(userOrgRolesRepository, 'findOne') + .mockResolvedValue(mockUserOrgRole as unknown as UserOrganizationRole); + + await service.seedAll(); + + const savedOwner = saveSpy.mock.calls[0][0] as Partial; + // Legacy key must be gone. + expect(savedOwner.permissions).not.toHaveProperty('canViewOrganization'); + // Unknown custom key must survive. + expect(savedOwner.permissions).toHaveProperty(customKey, true); + // Seed permissions are applied on top. + expect(savedOwner.permissions).toEqual( + expect.objectContaining(ownerSeedData.permissions), + ); + }); + + it('should use targeted Redis SCAN/DEL in batches when a Redis store is present', async () => { + // Simulate 150 matched keys so two DEL batches are issued (100 + 50). + const matchedKeys = Array.from( + { length: 150 }, + (_, i) => `permissions:user:${i}:org:1`, + ); + const mockRedisClient = { + scanIterator: jest + .fn() + .mockImplementation(() => matchedKeys[Symbol.iterator]()), + del: jest.fn().mockResolvedValue(undefined), + }; + const mockRedisStore = { store: { client: mockRedisClient } }; + + (mockCacheManager as { stores: unknown[] }).stores = [mockRedisStore]; + + jest + .spyOn(gamesRepository, 'findOne') + .mockResolvedValue(mockGame as unknown as Game); + jest.spyOn(rolesRepository, 'findOne').mockImplementation( + async () => + ({ + ...mockRole, + permissions: { canViewOrganization: true }, + }) as unknown as Role, + ); + jest + .spyOn(rolesRepository, 'save') + .mockImplementation(async (role) => role as Role); + jest + .spyOn(organizationsRepository, 'findOne') + .mockResolvedValue(mockOrganization as unknown as Organization); + jest + .spyOn(usersRepository, 'findOne') + .mockResolvedValue(mockUser as unknown as User); + jest + .spyOn(userOrgRolesRepository, 'findOne') + .mockResolvedValue(mockUserOrgRole as unknown as UserOrganizationRole); + + await service.seedAll(); + + expect(mockRedisClient.scanIterator).toHaveBeenCalledWith({ + MATCH: 'permissions:user:*', + COUNT: 100, + }); + // Two batches: first 100 keys, then the remaining 50. + expect(mockRedisClient.del).toHaveBeenCalledTimes(2); + expect(mockRedisClient.del).toHaveBeenNthCalledWith( + 1, + matchedKeys.slice(0, 100), + ); + expect(mockRedisClient.del).toHaveBeenNthCalledWith( + 2, + matchedKeys.slice(100), + ); + expect(mockCacheManager.clear).not.toHaveBeenCalled(); + }); + + it('should seed roles with correct OrgPermission keys and per-role permission values', async () => { + const savedRoles: Partial[] = []; + + jest.spyOn(rolesRepository, 'findOne').mockResolvedValue(null); + jest + .spyOn(rolesRepository, 'create') + .mockImplementation((data) => data as Role); + jest.spyOn(rolesRepository, 'save').mockImplementation(async (role) => { + savedRoles.push(role as Partial); + return role as Role; + }); + + jest + .spyOn(gamesRepository, 'findOne') + .mockResolvedValue(mockGame as unknown as Game); + jest + .spyOn(gamesRepository, 'create') + .mockReturnValue(mockGame as unknown as Game); + jest + .spyOn(gamesRepository, 'save') + .mockResolvedValue(mockGame as unknown as Game); + jest.spyOn(organizationsRepository, 'findOne').mockResolvedValue(null); + jest + .spyOn(organizationsRepository, 'create') + .mockReturnValue(mockOrganization as unknown as Organization); + jest + .spyOn(organizationsRepository, 'save') + .mockResolvedValue(mockOrganization as unknown as Organization); + jest.spyOn(usersRepository, 'findOne').mockResolvedValue(null); + jest + .spyOn(usersRepository, 'create') + .mockReturnValue(mockUser as unknown as User); + jest + .spyOn(usersRepository, 'save') + .mockResolvedValue(mockUser as unknown as User); + jest.spyOn(userOrgRolesRepository, 'findOne').mockResolvedValue(null); + jest + .spyOn(userOrgRolesRepository, 'create') + .mockReturnValue(mockUserOrgRole as unknown as UserOrganizationRole); + jest + .spyOn(userOrgRolesRepository, 'save') + .mockResolvedValue(mockUserOrgRole as unknown as UserOrganizationRole); + + await service.seedAll(); + + const validPermissionKeys = new Set(Object.values(OrgPermission)); + const seededRoleNames = savedRoles.map((r) => r.name); + + expect(seededRoleNames).toEqual( + expect.arrayContaining(defaultRoles.map((r) => r.name)), + ); + + // All permission keys must be valid OrgPermission enum values. + for (const role of savedRoles) { + const keys = Object.keys(role.permissions ?? {}); + for (const key of keys) { + expect(validPermissionKeys).toContain(key); + } + } + + // Independent per-role assertions — hardcoded so a bug in the seed matrix + // itself (e.g. Owner losing view access, Viewer gaining edit) is caught. + const byName = Object.fromEntries( + savedRoles.map((r) => [r.name, r.permissions]), + ); + + // Full-access roles: Owner, Admin, Director, Inventory Manager + for (const roleName of [ + 'Owner', + 'Admin', + 'Director', + 'Inventory Manager', + ]) { + expect(byName[roleName]?.[OrgPermission.CAN_VIEW_ORG_INVENTORY]).toBe( + true, + ); + expect(byName[roleName]?.[OrgPermission.CAN_EDIT_ORG_INVENTORY]).toBe( + true, + ); + expect(byName[roleName]?.[OrgPermission.CAN_ADMIN_ORG_INVENTORY]).toBe( + true, + ); + expect( + byName[roleName]?.[OrgPermission.CAN_VIEW_MEMBER_SHARED_ITEMS], + ).toBe(true); + } + + // Member: can view and view shared, but not edit or admin + expect(byName['Member']?.[OrgPermission.CAN_VIEW_ORG_INVENTORY]).toBe( + true, + ); + expect(byName['Member']?.[OrgPermission.CAN_EDIT_ORG_INVENTORY]).toBe( + false, + ); + expect(byName['Member']?.[OrgPermission.CAN_ADMIN_ORG_INVENTORY]).toBe( + false, + ); + expect( + byName['Member']?.[OrgPermission.CAN_VIEW_MEMBER_SHARED_ITEMS], + ).toBe(true); + + // Viewer: can only view inventory, nothing else + expect(byName['Viewer']?.[OrgPermission.CAN_VIEW_ORG_INVENTORY]).toBe( + true, + ); + expect(byName['Viewer']?.[OrgPermission.CAN_EDIT_ORG_INVENTORY]).toBe( + false, + ); + expect(byName['Viewer']?.[OrgPermission.CAN_ADMIN_ORG_INVENTORY]).toBe( + false, + ); + expect( + byName['Viewer']?.[OrgPermission.CAN_VIEW_MEMBER_SHARED_ITEMS], + ).toBe(false); + }); + + it('should not overwrite a user-customized role description when permissions are current', async () => { + const customDescription = 'Custom org-specific description set by admin'; + + jest + .spyOn(gamesRepository, 'findOne') + .mockResolvedValue(mockGame as unknown as Game); + + jest + .spyOn(rolesRepository, 'findOne') + .mockImplementation(async (opts) => { + const name = (opts as { where: { name: string } }).where.name; + const seedRole = defaultRoles.find((r) => r.name === name); + return { + ...mockRole, + name, + description: customDescription, + permissions: { ...(seedRole?.permissions ?? {}) }, + } as unknown as Role; + }); + + const saveSpy = jest + .spyOn(rolesRepository, 'save') + .mockImplementation(async (role) => role as Role); + jest + .spyOn(organizationsRepository, 'findOne') + .mockResolvedValue(mockOrganization as unknown as Organization); + jest + .spyOn(usersRepository, 'findOne') + .mockResolvedValue(mockUser as unknown as User); + jest + .spyOn(userOrgRolesRepository, 'findOne') + .mockResolvedValue(mockUserOrgRole as unknown as UserOrganizationRole); + + await service.seedAll(); + + expect(saveSpy).not.toHaveBeenCalled(); + expect(mockCacheManager.clear).not.toHaveBeenCalled(); + }); + + it('should update stale permissions but preserve a custom description', async () => { + const customDescription = 'Custom org-specific description set by admin'; + const ownerSeedData = defaultRoles.find((r) => r.name === 'Owner')!; + + jest + .spyOn(gamesRepository, 'findOne') + .mockResolvedValue(mockGame as unknown as Game); + + jest.spyOn(rolesRepository, 'findOne').mockImplementation( + async () => + ({ + ...mockRole, + // Stale permissions — will trigger a save. + permissions: { canViewOrganization: true }, + // Custom description — must survive the save untouched. + description: customDescription, + }) as unknown as Role, + ); + + const saveSpy = jest + .spyOn(rolesRepository, 'save') + .mockImplementation(async (role) => role as Role); + jest + .spyOn(organizationsRepository, 'findOne') + .mockResolvedValue(mockOrganization as unknown as Organization); + jest + .spyOn(usersRepository, 'findOne') + .mockResolvedValue(mockUser as unknown as User); + jest + .spyOn(userOrgRolesRepository, 'findOne') + .mockResolvedValue(mockUserOrgRole as unknown as UserOrganizationRole); + + await service.seedAll(); + + // Role must have been saved (permissions were stale). + expect(saveSpy).toHaveBeenCalled(); + const savedOwner = saveSpy.mock.calls[0][0] as Partial; + // Permissions must be updated to current seed values. + expect(savedOwner.permissions).toEqual( + expect.objectContaining(ownerSeedData.permissions), + ); + // Custom description must be preserved — not overwritten by seed text. + expect(savedOwner.description).toBe(customDescription); + }); + + it('should skip demo data seeding in production', async () => { + const originalEnv = process.env.NODE_ENV; + process.env.NODE_ENV = 'production'; + + try { + jest + .spyOn(gamesRepository, 'findOne') + .mockResolvedValue(mockGame as unknown as Game); + jest + .spyOn(rolesRepository, 'findOne') + .mockImplementation(async (opts) => { + const name = (opts as { where: { name: string } }).where.name; + const seedRole = defaultRoles.find((r) => r.name === name); + return { + ...mockRole, + name, + description: seedRole?.description ?? mockRole.description, + permissions: { ...(seedRole?.permissions ?? {}) }, + } as unknown as Role; + }); + jest + .spyOn(rolesRepository, 'save') + .mockImplementation(async (role) => role as Role); + + await service.seedAll(); + + // Demo-data repositories must never be touched in production. + expect(organizationsRepository.findOne).not.toHaveBeenCalled(); + expect(usersRepository.findOne).not.toHaveBeenCalled(); + expect(userOrgRolesRepository.findOne).not.toHaveBeenCalled(); + expect(organizationsRepository.save).not.toHaveBeenCalled(); + expect(usersRepository.save).not.toHaveBeenCalled(); + expect(userOrgRolesRepository.save).not.toHaveBeenCalled(); + } finally { + process.env.NODE_ENV = originalEnv; + } }); it('should throw error on failure', async () => { diff --git a/backend/src/database/seeds/database-seeder.service.ts b/backend/src/database/seeds/database-seeder.service.ts index 1446a99..7acf2f4 100644 --- a/backend/src/database/seeds/database-seeder.service.ts +++ b/backend/src/database/seeds/database-seeder.service.ts @@ -1,7 +1,9 @@ -import { Injectable } from '@nestjs/common'; +import { Injectable, Inject } from '@nestjs/common'; import { InjectPinoLogger, PinoLogger } from 'nestjs-pino'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; +import { CACHE_MANAGER } from '@nestjs/cache-manager'; +import { Cache } from 'cache-manager'; import { Role } from '../../modules/roles/role.entity'; import { Organization } from '../../modules/organizations/organization.entity'; import { User } from '../../modules/users/user.entity'; @@ -10,11 +12,48 @@ import { Game } from '../../modules/games/game.entity'; import { defaultRoles } from './roles.seed'; import * as bcrypt from 'bcrypt'; +// Known legacy descriptions seeded before the inventory-focused rewrite. Only +// these values are replaced on reseed — any other description is treated as +// a user customization and left untouched. +const LEGACY_ROLE_DESCRIPTIONS = new Set([ + 'Full access to organization. Can delete organization and manage all settings.', + 'Administrative access. Can manage users and settings.', + 'Standard member access. Can view and participate.', + 'Read-only access. Can only view information.', + // Seeded by migration 1764961461064-SeedInventoryManagerRole + 'Manages organization inventory with full permissions for viewing, editing, and administering items', +]); + +// Known legacy camelCase keys introduced before the OrgPermission enum. Only +// these are stripped on merge — unknown custom keys are preserved. +const LEGACY_PERMISSION_KEYS = new Set([ + 'canDeleteOrganization', + 'canEditOrganization', + 'canViewOrganization', + 'canInviteUsers', + 'canRemoveUsers', + 'canEditUserRoles', + 'canViewUsers', + 'canCreateRoles', + 'canEditRoles', + 'canDeleteRoles', + 'canViewRoles', + 'canManageSettings', + 'canViewSettings', +]); +// Bare key pattern — no Keyv namespace prefix is configured in app.module.ts, +// so permission keys are stored as-is in Redis. TTL is 15 min (900000ms) per +// PermissionsService. If a namespace is ever added, this pattern must be +// updated to match (e.g. 'namespace:permissions:user:*'). +const PERMISSION_CACHE_PATTERN = 'permissions:user:*'; + @Injectable() export class DatabaseSeederService { constructor( @InjectPinoLogger(DatabaseSeederService.name) private readonly logger: PinoLogger, + @Inject(CACHE_MANAGER) + private readonly cacheManager: Cache, @InjectRepository(Role) private rolesRepository: Repository, @InjectRepository(Organization) @@ -31,12 +70,20 @@ export class DatabaseSeederService { this.logger.info('🌱 Starting database seeding...'); try { - // Seed in order of dependencies + // Always safe to run in any environment await this.seedGames(); await this.seedRoles(); - await this.seedTestOrganization(); - await this.seedTestUser(); - await this.seedUserOrganizationRoles(); + + // Demo credentials must never be created in production + if (process.env.NODE_ENV !== 'production') { + await this.seedTestOrganization(); + await this.seedTestUser(); + await this.seedUserOrganizationRoles(); + } else { + this.logger.info( + '⊙ Skipping demo data seeding in production environment', + ); + } this.logger.info('✅ Database seeding completed successfully!'); } catch (error) { @@ -83,6 +130,8 @@ export class DatabaseSeederService { private async seedRoles(): Promise { this.logger.info('Seeding roles...'); + let permissionsUpdated = false; + for (const roleData of defaultRoles) { const existingRole = await this.rolesRepository.findOne({ where: { name: roleData.name }, @@ -93,9 +142,112 @@ export class DatabaseSeederService { await this.rolesRepository.save(role); this.logger.info(` ✓ Created role: ${role.name}`); } else { - this.logger.info(` ⊙ Role already exists: ${roleData.name}`); + // Strip only known legacy camelCase keys from existing permissions; + // unknown custom keys are preserved and carried forward. + const sanitizedExisting = Object.fromEntries( + Object.entries(existingRole.permissions ?? {}).filter( + ([k]) => !LEGACY_PERMISSION_KEYS.has(k), + ), + ); + const merged = { ...sanitizedExisting, ...roleData.permissions }; + + // Use key-sorted stringify because JSONB does not preserve key order, + // so a naive stringify can differ even for semantically equal objects. + const sortedKeys = (obj: Record) => + JSON.stringify(obj, Object.keys(obj).sort()); + const permissionsChanged = + sortedKeys(existingRole.permissions ?? {}) !== sortedKeys(merged); + // Only update the description if it is a known legacy seeded value or + // still matches the current seed text (i.e. was never customized). + // Any other description is treated as a user customization and preserved. + const isReplaceableDescription = + existingRole.description !== undefined && + (LEGACY_ROLE_DESCRIPTIONS.has(existingRole.description) || + existingRole.description === roleData.description); + const descriptionChanged = + roleData.description !== undefined && + existingRole.description !== roleData.description && + isReplaceableDescription; + + if (permissionsChanged || descriptionChanged) { + if (permissionsChanged) { + existingRole.permissions = merged; + } + if (descriptionChanged) { + existingRole.description = roleData.description!; + } + await this.rolesRepository.save(existingRole); + this.logger.info(` ✓ Updated role: ${roleData.name}`); + if (permissionsChanged) { + permissionsUpdated = true; + } + } else { + this.logger.info(` ⊙ Role unchanged: ${roleData.name}`); + } } } + + if (permissionsUpdated) { + const cacheCleared = await this.invalidatePermissionCache(); + if (cacheCleared) { + this.logger.info(' ✓ Cleared permission cache'); + } + } + } + + private async invalidatePermissionCache(): Promise { + // cache-manager v7 exposes backing stores via `cacheManager.stores` (Keyv[]). + // Each Keyv wraps a store adapter; for cache-manager-redis-yet the adapter + // exposes `.client` (the node-redis 4.x client). + // Use SCAN (non-blocking cursor iteration) instead of KEYS to avoid stalling + // Redis on large keyspaces. node-redis 4.x del() takes an array, not variadic args. + type RedisClient = { + scanIterator?: (opts: { + MATCH: string; + COUNT: number; + }) => AsyncIterable; + del?: (keys: string[]) => Promise; + }; + type KeyvLike = { store?: { client?: RedisClient } }; + + const stores: KeyvLike[] = + (this.cacheManager as unknown as { stores?: KeyvLike[] }).stores ?? []; + + const BATCH_SIZE = 100; + let invalidated = false; + for (const keyv of stores) { + const client = keyv.store?.client; + if (client?.scanIterator && client?.del) { + let batch: string[] = []; + for await (const key of client.scanIterator({ + MATCH: PERMISSION_CACHE_PATTERN, + COUNT: BATCH_SIZE, + })) { + batch.push(key); + if (batch.length >= BATCH_SIZE) { + await client.del(batch); + batch = []; + } + } + if (batch.length > 0) { + await client.del(batch); + } + invalidated = true; + } + } + + if (!invalidated) { + // No Redis-backed store was found — the running backend may be using an + // in-memory cache (USE_REDIS_CACHE=false or Redis unavailable). In-memory + // caches are per-process: this seeder process cannot reach the backend's + // cache instance. Stale permission entries will expire naturally at TTL + // (15 min) or be cleared on backend restart. + this.logger.warn( + ' ⚠️ No Redis store found — backend in-memory permission cache cannot be invalidated from this process. Restart the backend or wait for TTL expiry.', + ); + } + + return invalidated; } private async seedTestOrganization(): Promise { diff --git a/backend/src/database/seeds/roles.seed.ts b/backend/src/database/seeds/roles.seed.ts index cee4f53..55f1c96 100644 --- a/backend/src/database/seeds/roles.seed.ts +++ b/backend/src/database/seeds/roles.seed.ts @@ -1,84 +1,32 @@ import { Role } from '../../modules/roles/role.entity'; - -export const defaultRoles: Partial[] = [ - { - name: 'Owner', - description: - 'Full access to organization. Can delete organization and manage all settings.', - permissions: { - // Organization management - canDeleteOrganization: true, - canEditOrganization: true, - canViewOrganization: true, - - // User management - canInviteUsers: true, - canRemoveUsers: true, - canEditUserRoles: true, - canViewUsers: true, - - // Role management - canCreateRoles: true, - canEditRoles: true, - canDeleteRoles: true, - canViewRoles: true, - - // Settings - canManageSettings: true, - canViewSettings: true, - }, - }, - { - name: 'Admin', - description: 'Administrative access. Can manage users and settings.', - permissions: { - // Organization management - canEditOrganization: true, - canViewOrganization: true, - - // User management - canInviteUsers: true, - canRemoveUsers: true, - canEditUserRoles: true, - canViewUsers: true, - - // Role management - canViewRoles: true, - - // Settings - canManageSettings: true, - canViewSettings: true, - }, - }, - { - name: 'Member', - description: 'Standard member access. Can view and participate.', - permissions: { - // Organization management - canViewOrganization: true, - - // User management - canViewUsers: true, - - // Role management - canViewRoles: true, - - // Settings - canViewSettings: true, - }, - }, - { - name: 'Viewer', - description: 'Read-only access. Can only view information.', - permissions: { - // Organization management - canViewOrganization: true, - - // User management - canViewUsers: true, - - // Settings - canViewSettings: true, - }, - }, -]; +import { DEFAULT_ROLE_PERMISSIONS } from '../../modules/permissions/permissions.constants'; + +// `keyof typeof DEFAULT_ROLE_PERMISSIONS` is now a literal union of role names +// (not `string`) because the constant uses `satisfies` without a wide type +// annotation. TypeScript will fail to compile if a new role is added to +// DEFAULT_ROLE_PERMISSIONS without a matching entry here. +type DefaultRoleName = keyof typeof DEFAULT_ROLE_PERMISSIONS; +const ROLE_DESCRIPTIONS: Record = { + Owner: + 'Full inventory access. Can view, edit, and administer organization inventory, and view member shared items.', + Admin: + 'Full inventory access. Can view, edit, and administer organization inventory, and view member shared items.', + Director: + 'Full inventory access. Can view, edit, and administer organization inventory, and view member shared items.', + 'Inventory Manager': + 'Full inventory access. Can view, edit, and administer organization inventory, and view member shared items.', + Member: + 'Standard member access. Can view organization inventory and member shared items.', + Viewer: 'Read-only access. Can only view organization inventory.', +}; + +export const defaultRoles: Partial[] = ( + Object.entries(DEFAULT_ROLE_PERMISSIONS) as [ + DefaultRoleName, + (typeof DEFAULT_ROLE_PERMISSIONS)[DefaultRoleName], + ][] +).map(([name, permissions]) => ({ + name, + description: ROLE_DESCRIPTIONS[name], + permissions, +})); diff --git a/backend/src/modules/permissions/permissions.constants.ts b/backend/src/modules/permissions/permissions.constants.ts index 522bb60..593112f 100644 --- a/backend/src/modules/permissions/permissions.constants.ts +++ b/backend/src/modules/permissions/permissions.constants.ts @@ -20,18 +20,20 @@ export enum OrgPermission { /** * Default role permission mappings + * + * Declared without an explicit wide type so `keyof typeof DEFAULT_ROLE_PERMISSIONS` + * resolves to the literal union of role name strings rather than `string`. + * The `satisfies` clause still enforces that every value is a complete + * `Record`, giving both narrowing and type-safety. */ -export const DEFAULT_ROLE_PERMISSIONS: Record< - string, - Record -> = { - Member: { +export const DEFAULT_ROLE_PERMISSIONS = { + Owner: { [OrgPermission.CAN_VIEW_ORG_INVENTORY]: true, - [OrgPermission.CAN_EDIT_ORG_INVENTORY]: false, - [OrgPermission.CAN_ADMIN_ORG_INVENTORY]: false, + [OrgPermission.CAN_EDIT_ORG_INVENTORY]: true, + [OrgPermission.CAN_ADMIN_ORG_INVENTORY]: true, [OrgPermission.CAN_VIEW_MEMBER_SHARED_ITEMS]: true, }, - 'Inventory Manager': { + Admin: { [OrgPermission.CAN_VIEW_ORG_INVENTORY]: true, [OrgPermission.CAN_EDIT_ORG_INVENTORY]: true, [OrgPermission.CAN_ADMIN_ORG_INVENTORY]: true, @@ -43,20 +45,32 @@ export const DEFAULT_ROLE_PERMISSIONS: Record< [OrgPermission.CAN_ADMIN_ORG_INVENTORY]: true, [OrgPermission.CAN_VIEW_MEMBER_SHARED_ITEMS]: true, }, - Admin: { + 'Inventory Manager': { [OrgPermission.CAN_VIEW_ORG_INVENTORY]: true, [OrgPermission.CAN_EDIT_ORG_INVENTORY]: true, [OrgPermission.CAN_ADMIN_ORG_INVENTORY]: true, [OrgPermission.CAN_VIEW_MEMBER_SHARED_ITEMS]: true, }, -}; + Member: { + [OrgPermission.CAN_VIEW_ORG_INVENTORY]: true, + [OrgPermission.CAN_EDIT_ORG_INVENTORY]: false, + [OrgPermission.CAN_ADMIN_ORG_INVENTORY]: false, + [OrgPermission.CAN_VIEW_MEMBER_SHARED_ITEMS]: true, + }, + Viewer: { + [OrgPermission.CAN_VIEW_ORG_INVENTORY]: true, + [OrgPermission.CAN_EDIT_ORG_INVENTORY]: false, + [OrgPermission.CAN_ADMIN_ORG_INVENTORY]: false, + [OrgPermission.CAN_VIEW_MEMBER_SHARED_ITEMS]: false, + }, +} satisfies Record>; /** * Permission descriptions for documentation and UI */ export const PERMISSION_DESCRIPTIONS: Record = { [OrgPermission.CAN_VIEW_ORG_INVENTORY]: - 'View organization-owned inventory and member-shared items', + 'View organization-owned inventory items', [OrgPermission.CAN_EDIT_ORG_INVENTORY]: 'Create, update, and delete organization inventory items', [OrgPermission.CAN_ADMIN_ORG_INVENTORY]: