Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
127 changes: 127 additions & 0 deletions src/__tests__/box/auth/BoxIdFilterInterceptor.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
import { BoxIdFilterInterceptor } from '../../../../src/box/auth/BoxIdFilter.interceptor';
import { CallHandler } from '@nestjs/common';
import { lastValueFrom, of } from 'rxjs';
import RequestBuilder from '../../test_utils/data/RequestBuilder';
import ExecutionContextBuilder from '../../test_utils/data/ExecutionContextBuilder';

const mockJwtService = {
verifyAsync: jest.fn(),
};
const mockReflector = {
getAllAndOverride: jest.fn(),
};

describe('BoxIdFilterInterceptor class test suite', () => {
let interceptor: BoxIdFilterInterceptor;
let callHandler: CallHandler;

beforeEach(() => {
interceptor = new BoxIdFilterInterceptor(
mockReflector as any,
mockJwtService as any,
);
callHandler = { handle: jest.fn() } as any;
jest.clearAllMocks();
});

it('should filter array data by box_id', async () => {
const boxId = 'box123';
const data = [
{ box_id: 'box123', value: 1 },
{ box_id: 'box999', value: 2 },
];
const request = new RequestBuilder().build();
(request as any).user = { box_id: boxId };
const context = new ExecutionContextBuilder()
.setHttpRequest(request)
.build();
(callHandler.handle as jest.Mock).mockReturnValue(of(data));

const results$ = await interceptor.intercept(context, callHandler);
const result = await lastValueFrom(results$);
expect(result).toEqual([{ box_id: 'box123', value: 1 }]);
});

it('should filter nested data and update metaData', async () => {
const boxId = 'box123';
const data = {
data: {
Clan: [
{ box_id: 'box123', name: 'A' },
{ box_id: 'box999', name: 'B' },
],
},
metaData: { dataKey: 'Clan', dataCount: 2 },
paginationData: { itemCount: 2 },
};
const request = new RequestBuilder().build();
(request as any).user = { box_id: boxId };
const context = new ExecutionContextBuilder()
.setHttpRequest(request)
.build();
(callHandler.handle as jest.Mock).mockReturnValue(of(data));

const results$ = await interceptor.intercept(context, callHandler);
const result = await lastValueFrom(results$);
expect(result.data.Clan).toEqual([{ box_id: 'box123', name: 'A' }]);
expect(result.metaData.dataCount).toBe(1);
expect(result.paginationData.itemCount).toBe(1);
});

it('should skip filtering if NO_BOX_ID_FILTER is set', async () => {
mockReflector.getAllAndOverride.mockReturnValueOnce(true);
const data = [
{ box_id: 'box123', value: 1 },
{ box_id: 'box999', value: 2 },
];
const request = new RequestBuilder().build();
(request as any).user = { box_id: 'box123' };
const context = new ExecutionContextBuilder()
.setHttpRequest(request)
.build();
(callHandler.handle as jest.Mock).mockReturnValue(of(data));

const results$ = await interceptor.intercept(context, callHandler);
const result = await lastValueFrom(results$);
expect(result).toEqual(data);
});

it('should extract box_id from JWT if user is missing', async () => {
const boxId = 'jwtBoxId';
mockJwtService.verifyAsync.mockResolvedValueOnce({ box_id: boxId });
const data = [
{ box_id: 'jwtBoxId', value: 1 },
{ box_id: 'other', value: 2 },
];
const request = new RequestBuilder()
.setHeaders({ authorization: 'Bearer token' })
.build();
const context = new ExecutionContextBuilder()
.setHttpRequest(request)
.build();
(callHandler.handle as jest.Mock).mockReturnValue(of(data));

const results$ = await interceptor.intercept(context, callHandler);
const result = await lastValueFrom(results$);
expect(result).toEqual([{ box_id: 'jwtBoxId', value: 1 }]);
expect(mockJwtService.verifyAsync).toHaveBeenCalledWith(
'token',
expect.any(Object),
);
});

it('should throw APIError if JWT is invalid', async () => {
mockJwtService.verifyAsync.mockRejectedValueOnce(new Error('bad token'));
const request = new RequestBuilder()
.setHeaders({ authorization: 'Bearer valid-test-token' })
.build();
const context = new ExecutionContextBuilder()
.setHttpRequest(request)
.build();
(callHandler.handle as jest.Mock).mockReturnValue(of([]));

await expect(
interceptor.intercept(context, callHandler).then(lastValueFrom),
).rejects.toThrow();
});
});
6 changes: 5 additions & 1 deletion src/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import { RequestHelperModule } from './requestHelper/requestHelper.module';
import { ProfileModule } from './profile/profile.module';
import { AuthModule } from './auth/auth.module';
import { AuthGuard } from './auth/auth.guard';
import { APP_GUARD } from '@nestjs/core';
import { APP_GUARD, APP_INTERCEPTOR } from '@nestjs/core';
import { AuthorizationModule } from './authorization/authorization.module';
import { ChatModule } from './chat/chat.module';
import { GameDataModule } from './gameData/gameData.module';
Expand All @@ -35,6 +35,7 @@ import { FeedbackModule } from './feedback/feedback.module';
import { MetadataModule } from './metadata/metadata.module';
import mongoose from 'mongoose';
import { addBoxIdToSchemaPlugin } from './common/plugin/addBoxIdToSchema.plugin';
import { BoxIdFilterInterceptor } from './box/auth/BoxIdFilter.interceptor';

// Set up database connection
const mongoUser = envVars.MONGO_USERNAME;
Expand Down Expand Up @@ -110,6 +111,9 @@ const authGuardClassToUse = isTestingSession() ? BoxAuthGuard : AuthGuard;
providers: [
AppService,
{ provide: APP_GUARD, useClass: authGuardClassToUse },
...(isTestingSession()
? [{ provide: APP_INTERCEPTOR, useClass: BoxIdFilterInterceptor }]
: []),
],
})
export class AppModule {}
2 changes: 2 additions & 0 deletions src/box/accountClaimer/accountClaimer.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import AccountClaimerService from './accountClaimer.service';
import ClaimedAccountDto from './dto/claimedAccount.dto';
import ClaimAccountDto from './dto/claimAccount.dto';
import { ModelName } from '../../common/enum/modelName.enum';
import { NoBoxIdFilter } from '../auth/decorator/NoBoxIdFilter.decorator';

@SwaggerTags('Box')
@Controller('/box/claim-account')
Expand All @@ -31,6 +32,7 @@ export class AccountClaimerController {
})
@NoAuth()
@Post()
@NoBoxIdFilter()
@UniformResponse(ModelName.PLAYER, ClaimedAccountDto)
async claimAccount(@Body() body: ClaimAccountDto) {
return this.accountService.claimAccount(body.sharedPassword);
Expand Down
130 changes: 130 additions & 0 deletions src/box/auth/BoxIdFilter.interceptor.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
import {
Injectable,
NestInterceptor,
ExecutionContext,
CallHandler,
} from '@nestjs/common';
import { Observable, mergeMap } from 'rxjs';
import { BoxUser } from './BoxUser';
import { Reflector } from '@nestjs/core';
import { JwtService } from '@nestjs/jwt';
import { envVars } from '../../common/service/envHandler/envVars';
import { APIError } from '../../common/controller/APIError';
import { APIErrorReason } from '../../common/controller/APIErrorReason';
import { NO_BOX_ID_FILTER } from './decorator/NoBoxIdFilter.decorator';

/**
* Interceptor used for testing sessions to prevent data leaks.
* Interceptor get's the users box_id from the request and then filters
* all outgoing data based on that box_id. So users are not able to get
* any data from other boxes.
*/
@Injectable()
export class BoxIdFilterInterceptor implements NestInterceptor {
constructor(
private readonly reflector: Reflector,
private readonly jwtService: JwtService,
) {}
async intercept(
context: ExecutionContext,
next: CallHandler,
): Promise<Observable<any>> {
const noFilter = this.reflector.getAllAndOverride<boolean>(
NO_BOX_ID_FILTER,
[context.getHandler(), context.getClass()],
);
if (noFilter) return next.handle();

const request = context.switchToHttp().getRequest();
const boxUser: BoxUser = request.user;
let boxId: string;
if (!boxUser) {
boxId = await this.getBoxIdFromRequest(request);
} else {
boxId = boxUser.box_id;
}

return next
.handle()
.pipe(mergeMap(async (data) => this.filterByBoxId(await data, boxId)));
}

/**
* Used to extract the box_id from the request.
* @param request incoming http request.
* @returns The box ID
*/
private async getBoxIdFromRequest(request: any): Promise<string | undefined> {
if (request.user && request.user.box_id) return request.user.box_id;

const [type, token] = request.headers.authorization?.split(' ') ?? [];
if (type === 'Bearer' && token) {
try {
const payload: BoxUser = await this.jwtService.verifyAsync(token, {
secret: envVars.JWT_SECRET,
});
return payload.box_id;
} catch {
throw new APIError({
reason: APIErrorReason.NOT_AUTHENTICATED,
message:
'All endpoints need to be provided an auth token in testing sessions.',
});
}
}
return undefined;
}

/**
*
*
* @param data Data to be filtered.
* @param boxId The box ID used for filtering
* @returns Data where box_id matches the boxId.
*/
private filterByBoxId(data: any, boxId: string): any {
if (
data &&
typeof data === 'object' &&
data.data &&
typeof data.data === 'object'
) {
for (const key of Object.keys(data.data)) {
const value = data.data[key];
if (Array.isArray(value)) {
const filtered = value.filter((item) => item?.box_id === boxId);
data.data[key] = filtered;
// Update metaData.dataCount if key matches metaData.dataKey
if (
data.metaData &&
data.metaData.dataKey === key &&
typeof data.metaData.dataCount === 'number'
) {
data.metaData.dataCount = filtered.length;
}
if (
data.paginationData &&
typeof data.paginationData.itemCount === 'number'
) {
data.paginationData.itemCount = filtered.length;
}
}
}
return data;
}

// If data is just an array, filter it
if (Array.isArray(data)) {
return data.filter((item) => item?.box_id === boxId);
}

// If data is an object with box_id, filter recursively
if (data && typeof data === 'object') {
if ('box_id' in data && data.box_id !== boxId) return undefined;
for (const key of Object.keys(data)) {
data[key] = this.filterByBoxId(data[key], boxId);
}
}
return data;
}
}
11 changes: 11 additions & 0 deletions src/box/auth/decorator/NoBoxIdFilter.decorator.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { SetMetadata } from '@nestjs/common';

export const NO_BOX_ID_FILTER = 'NO_BOX_ID_FILTER';

/**
* Used for skipping the BoxIdFilter interceptor.
* Used in testing session related endpoints that need to be able to return
* entities from DB that don't match the requesting users box_id
* of for endpoints before the user is authenticated.
*/
export const NoBoxIdFilter = () => SetMetadata(NO_BOX_ID_FILTER, true);
6 changes: 6 additions & 0 deletions src/box/box.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ import { InjectModel } from '@nestjs/mongoose';
import { GroupAdmin } from './groupAdmin/groupAdmin.schema';
import { Model } from 'mongoose';
import BasicService from '../common/service/basicService/BasicService';
import { NoBoxIdFilter } from './auth/decorator/NoBoxIdFilter.decorator';

@Controller('box')
@UseGuards(BoxAuthGuard)
Expand Down Expand Up @@ -69,6 +70,7 @@ export class BoxController {
})
@NoAuth()
@Post()
@NoBoxIdFilter()
@UniformResponse(ModelName.BOX, CreatedBoxDto)
async createBox(@Body() body: CreateBoxDto) {
const [createdBox, errors] = await this.boxCreator.createBox(body);
Expand Down Expand Up @@ -207,6 +209,7 @@ export class BoxController {
@SwaggerTags('Release on 27.07.2025', 'Box')
@Post('/createAdmin')
@NoAuth()
@NoBoxIdFilter()
@UniformResponse()
public async createAdmin(@Body() body: CreateGroupAdminDto) {
const [, creationErrors] = await this.adminBasicService.createOne(body);
Expand All @@ -230,6 +233,7 @@ export class BoxController {
})
@Get('/')
@NoAuth()
@NoBoxIdFilter()
@UniformResponse(ModelName.BOX)
public getAll() {
return this.service.readAll();
Expand All @@ -249,6 +253,7 @@ export class BoxController {
hasAuth: false,
})
@Get('/:_id')
@NoBoxIdFilter()
@NoAuth()
@UniformResponse(ModelName.BOX)
public getOne(
Expand All @@ -271,6 +276,7 @@ export class BoxController {
hasAuth: false,
})
@Delete('/:_id')
@NoBoxIdFilter()
@NoAuth()
@UniformResponse(ModelName.BOX)
async deleteBox(@Param() param: _idDto) {
Expand Down
Loading