Skip to content

Commit

Permalink
fix: automatically trigger clear_local when permissions changed
Browse files Browse the repository at this point in the history
see #7
  • Loading branch information
sleidig committed Mar 19, 2024
1 parent e1f3b16 commit cb2357f
Show file tree
Hide file tree
Showing 8 changed files with 155 additions and 60 deletions.
29 changes: 29 additions & 0 deletions src/admin/admin.controller.spec.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
30 changes: 30 additions & 0 deletions src/admin/admin.controller.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
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 service provides some general administrativ endpoints.
*/
@OnlyAuthenticated()
@UseGuards(CombinedAuthGuard)
@Controller('admin')
export class AdminController {
constructor(private adminService: AdminService) {}

/**
* Deletes all local documents of the remote database.
* These document hold meta-information about the replication process.
* Deleting them forces clients to re-run sync and check which documents are different.
* See {@link https://docs.couchdb.org/en/stable/replication/protocol.html#retrieve-replication-logs-from-source-and-target}
*
* @param db name of the database where the local documents should be deleted from
*
* This function should be called whenever the permissions change to re-trigger sync
*/
@Post('/clear_local/:db')
async clearLocal(@Param('db') db: string): Promise<any> {
await this.adminService.clearLocal(db);
return true;
}
}
4 changes: 3 additions & 1 deletion src/admin/admin.module.ts
Original file line number Diff line number Diff line change
@@ -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 {}
Original file line number Diff line number Diff line change
@@ -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 () => {
Expand All @@ -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>(AdminService);
});

it('should be defined', () => {
expect(controller).toBeDefined();
expect(service).toBeDefined();
});

it('should delete all docs in the _local db', async () => {
Expand All @@ -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);
});
});
27 changes: 27 additions & 0 deletions src/admin/admin.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
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';

@Injectable()
export class AdminService {
constructor(private couchdbService: CouchdbService) {}

async clearLocal(db: string) {
const localDocsResponse = await firstValueFrom(
this.couchdbService.get<AllDocsResponse>(db, '_local_docs'),
);

// Get IDs of the replication checkpoints
const ids = localDocsResponse.rows
.map((doc) => doc.id)
.filter(
(id) => !id.includes('purge-mrview') && !id.includes('shard-sync'),
);
const deletePromises = ids.map((id) =>
firstValueFrom(this.couchdbService.delete(db, id)),
);

await Promise.all(deletePromises);
}
}
46 changes: 0 additions & 46 deletions src/admin/admin/admin.controller.ts

This file was deleted.

46 changes: 46 additions & 0 deletions src/permissions/rules/rules.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -28,6 +32,7 @@ describe('RulesService', () => {
});
userRules = testPermission.data[normalUser.roles[0]];
adminRules = testPermission.data[adminUser.roles[1]];

changesResponse = {
last_seq: 'initial_seq',
results: [
Expand All @@ -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,
Expand All @@ -53,6 +62,7 @@ describe('RulesService', () => {
}),
},
{ provide: CouchdbService, useValue: mockCouchDBService },
{ provide: AdminService, useValue: mockAdminService },
],
}).compile();

Expand Down Expand Up @@ -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();
});
});
14 changes: 12 additions & 2 deletions src/permissions/rules/rules.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<DocumentAbility>;

Expand All @@ -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,
Expand Down Expand Up @@ -60,8 +62,16 @@ 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 newPermissions = changes.results[0].doc.data;
this.permission = newPermissions;

if (
this.permission !== undefined && // do not clear upon restart of the API
JSON.stringify(this.permission) !== JSON.stringify(newPermissions)
) {
this.adminService.clearLocal(db);
}
}
});
}
Expand Down

0 comments on commit cb2357f

Please sign in to comment.