diff --git a/src/admin/admin.controller.spec.ts b/src/admin/admin.controller.spec.ts new file mode 100644 index 0000000..9dc4c32 --- /dev/null +++ b/src/admin/admin.controller.spec.ts @@ -0,0 +1,29 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { AdminController } from './admin.controller'; +import { CouchdbService } from '../couchdb/couchdb.service'; +import { authGuardMockProviders } from '../auth/auth-guard-mock.providers'; +import { AdminService } from './admin.service'; + +describe('AdminController', () => { + let controller: AdminController; + let mockAdminService: CouchdbService; + + beforeEach(async () => { + mockAdminService = { + clearLocal: () => Promise.resolve(), + } as any; + const module: TestingModule = await Test.createTestingModule({ + controllers: [AdminController], + providers: [ + ...authGuardMockProviders, + { provide: AdminService, useValue: mockAdminService }, + ], + }).compile(); + + controller = module.get(AdminController); + }); + + it('should be defined', () => { + expect(controller).toBeDefined(); + }); +}); diff --git a/src/admin/admin.controller.ts b/src/admin/admin.controller.ts new file mode 100644 index 0000000..c71f709 --- /dev/null +++ b/src/admin/admin.controller.ts @@ -0,0 +1,20 @@ +import { Controller, Param, Post, UseGuards } from '@nestjs/common'; +import { CombinedAuthGuard } from '../auth/guards/combined-auth/combined-auth.guard'; +import { OnlyAuthenticated } from '../auth/only-authenticated.decorator'; +import { AdminService } from './admin.service'; + +/** + * This controller provides some general administrative endpoints. + */ +@OnlyAuthenticated() +@UseGuards(CombinedAuthGuard) +@Controller('admin') +export class AdminController { + constructor(private adminService: AdminService) {} + + @Post('/clear_local/:db') + async clearLocal(@Param('db') db: string): Promise { + await this.adminService.clearLocal(db); + return true; + } +} diff --git a/src/admin/admin.module.ts b/src/admin/admin.module.ts index b7c2dfb..550de6d 100644 --- a/src/admin/admin.module.ts +++ b/src/admin/admin.module.ts @@ -1,11 +1,13 @@ import { Module } from '@nestjs/common'; -import { AdminController } from './admin/admin.controller'; +import { AdminController } from './admin.controller'; import { PermissionModule } from '../permissions/permission.module'; import { CouchdbModule } from '../couchdb/couchdb.module'; import { AuthModule } from '../auth/auth.module'; +import { AdminService } from './admin.service'; @Module({ controllers: [AdminController], imports: [PermissionModule, CouchdbModule, AuthModule], + providers: [AdminService], }) export class AdminModule {} diff --git a/src/admin/admin/admin.controller.spec.ts b/src/admin/admin.service.spec.ts similarity index 68% rename from src/admin/admin/admin.controller.spec.ts rename to src/admin/admin.service.spec.ts index da60afd..fac3da5 100644 --- a/src/admin/admin/admin.controller.spec.ts +++ b/src/admin/admin.service.spec.ts @@ -1,11 +1,10 @@ import { Test, TestingModule } from '@nestjs/testing'; -import { AdminController } from './admin.controller'; +import { AdminService } from './admin.service'; +import { CouchdbService } from '../couchdb/couchdb.service'; import { of } from 'rxjs'; -import { CouchdbService } from '../../couchdb/couchdb.service'; -import { authGuardMockProviders } from '../../auth/auth-guard-mock.providers'; -describe('AdminController', () => { - let controller: AdminController; +describe('AdminService', () => { + let service: AdminService; let mockCouchDBService: CouchdbService; beforeEach(async () => { @@ -14,18 +13,17 @@ describe('AdminController', () => { delete: () => of({}), } as any; const module: TestingModule = await Test.createTestingModule({ - controllers: [AdminController], providers: [ - ...authGuardMockProviders, + AdminService, { provide: CouchdbService, useValue: mockCouchDBService }, ], }).compile(); - controller = module.get(AdminController); + service = module.get(AdminService); }); it('should be defined', () => { - expect(controller).toBeDefined(); + expect(service).toBeDefined(); }); it('should delete all docs in the _local db', async () => { @@ -42,12 +40,11 @@ describe('AdminController', () => { jest.spyOn(mockCouchDBService, 'delete').mockReturnValue(of(undefined)); const dbName = 'app'; - const result = await controller.clearLocal(dbName); + await service.clearLocal(dbName); expect(mockCouchDBService.get).toHaveBeenCalledWith(dbName, '_local_docs'); mockAllDocsResponse.rows.forEach((row) => { expect(mockCouchDBService.delete).toHaveBeenCalledWith(dbName, row.id); }); - expect(result).toBe(true); }); }); diff --git a/src/admin/admin/admin.controller.ts b/src/admin/admin.service.ts similarity index 62% rename from src/admin/admin/admin.controller.ts rename to src/admin/admin.service.ts index 8e92a17..b92e93d 100644 --- a/src/admin/admin/admin.controller.ts +++ b/src/admin/admin.service.ts @@ -1,17 +1,13 @@ -import { Controller, Param, Post, UseGuards } from '@nestjs/common'; +import { Injectable } from '@nestjs/common'; import { firstValueFrom } from 'rxjs'; -import { AllDocsResponse } from '../../restricted-endpoints/replication/bulk-document/couchdb-dtos/all-docs.dto'; -import { CouchdbService } from '../../couchdb/couchdb.service'; -import { CombinedAuthGuard } from '../../auth/guards/combined-auth/combined-auth.guard'; -import { OnlyAuthenticated } from '../../auth/only-authenticated.decorator'; +import { AllDocsResponse } from '../restricted-endpoints/replication/bulk-document/couchdb-dtos/all-docs.dto'; +import { CouchdbService } from '../couchdb/couchdb.service'; /** - * This service provides some general administrativ endpoints. + * Service providing some general functionalities that are required in the context of administering the db and permissions. */ -@OnlyAuthenticated() -@UseGuards(CombinedAuthGuard) -@Controller('admin') -export class AdminController { +@Injectable() +export class AdminService { constructor(private couchdbService: CouchdbService) {} /** @@ -24,8 +20,7 @@ export class AdminController { * * This function should be called whenever the permissions change to re-trigger sync */ - @Post('/clear_local/:db') - async clearLocal(@Param('db') db: string): Promise { + async clearLocal(db: string) { const localDocsResponse = await firstValueFrom( this.couchdbService.get(db, '_local_docs'), ); @@ -41,6 +36,5 @@ export class AdminController { ); await Promise.all(deletePromises); - return true; } } diff --git a/src/permissions/rules/rules.service.spec.ts b/src/permissions/rules/rules.service.spec.ts index 9d97eb2..e1924cf 100644 --- a/src/permissions/rules/rules.service.spec.ts +++ b/src/permissions/rules/rules.service.spec.ts @@ -6,14 +6,18 @@ import { Permission } from './permission'; import { ConfigService } from '@nestjs/config'; import { CouchdbService } from '../../couchdb/couchdb.service'; import { ChangesResponse } from '../../restricted-endpoints/replication/bulk-document/couchdb-dtos/changes.dto'; +import { AdminService } from '../../admin/admin.service'; describe('RulesService', () => { let service: RulesService; let adminRules: DocumentRule[]; let userRules: DocumentRule[]; let mockCouchDBService: CouchdbService; + let mockAdminService: AdminService; + let testPermission: Permission; let changesResponse: ChangesResponse; + const normalUser = new UserInfo('normalUser', ['user_app']); const adminUser = new UserInfo('superUser', ['user_app', 'admin_app']); const DATABASE_NAME = 'app'; @@ -28,6 +32,7 @@ describe('RulesService', () => { }); userRules = testPermission.data[normalUser.roles[0]]; adminRules = testPermission.data[adminUser.roles[1]]; + changesResponse = { last_seq: 'initial_seq', results: [ @@ -43,6 +48,10 @@ describe('RulesService', () => { .mockReturnValueOnce(of(changesResponse)) .mockReturnValueOnce(NEVER); + mockAdminService = { + clearLocal: jest.fn().mockResolvedValue(undefined), + } as any; + const module = await Test.createTestingModule({ providers: [ RulesService, @@ -53,6 +62,7 @@ describe('RulesService', () => { }), }, { provide: CouchdbService, useValue: mockCouchDBService }, + { provide: AdminService, useValue: mockAdminService }, ], }).compile(); @@ -178,4 +188,40 @@ describe('RulesService', () => { expect(result).toEqual([publicRule]); expect(result).not.toContain(testPermission.data.default); }); + + it('should update rules and call clear_local when permission doc changed', () => { + jest.useFakeTimers(); + + const updatedPermission = new Permission({ + user_app: [{ action: 'manage', subject: 'all' }], + }); + const updatedPermissionChange = { + last_seq: '1', + results: [ + { + doc: updatedPermission, + seq: '1', + changes: [], + id: updatedPermission._id, + }, + ], + pending: 0, + }; + + jest + .spyOn(mockCouchDBService, 'get') + .mockReturnValueOnce(of(changesResponse)) + .mockReturnValueOnce(of(updatedPermissionChange)) + .mockReturnValue(NEVER); + + service.loadRulesContinuously('app'); + jest.advanceTimersByTime(1500); + + expect(service.getRulesForUser(normalUser)).toEqual([ + { action: 'manage', subject: 'all' }, + ]); + expect(mockAdminService.clearLocal).toHaveBeenCalled(); + + jest.useRealTimers(); + }); }); diff --git a/src/permissions/rules/rules.service.ts b/src/permissions/rules/rules.service.ts index bf201ee..ba22d9a 100644 --- a/src/permissions/rules/rules.service.ts +++ b/src/permissions/rules/rules.service.ts @@ -8,6 +8,7 @@ import { CouchdbService } from '../../couchdb/couchdb.service'; import { ConfigService } from '@nestjs/config'; import { ChangesResponse } from '../../restricted-endpoints/replication/bulk-document/couchdb-dtos/changes.dto'; import { get } from 'lodash'; +import { AdminService } from '../../admin/admin.service'; export type DocumentRule = RawRuleOf; @@ -24,6 +25,7 @@ export class RulesService { constructor( private couchdbService: CouchdbService, private configService: ConfigService, + private adminService: AdminService, ) { const permissionDbName = this.configService.get( RulesService.ENV_PERMISSION_DB, @@ -60,8 +62,19 @@ export class RulesService { ) .subscribe((changes) => { this.lastSeq = changes.last_seq; - if (changes.results.length > 0) { - this.permission = changes.results[0].doc.data; + if (changes.results?.length > 0) { + const prevPermissions = this.permission; + const newPermissions = changes.results[0].doc.data; + + this.permission = newPermissions; + + if ( + prevPermissions !== undefined && // do not clear upon restart of the API + JSON.stringify(prevPermissions) !== JSON.stringify(newPermissions) + ) { + this.adminService.clearLocal(db); + console.log('Permissions changed - triggered clearLocal:'); + } } }); }