Skip to content

Commit d5e37fe

Browse files
committed
Allow registered users to see submissions
1 parent 4f56528 commit d5e37fe

File tree

2 files changed

+166
-8
lines changed

2 files changed

+166
-8
lines changed
Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
import 'reflect-metadata';
2+
3+
import { ForbiddenException, type ExecutionContext } from '@nestjs/common';
4+
import { Reflector } from '@nestjs/core';
5+
6+
import { TokenRolesGuard, ROLES_KEY } from './tokenRoles.guard';
7+
import { SCOPES_KEY } from '../decorators/scopes.decorator';
8+
import { UserRole } from '../enums/userRole.enum';
9+
10+
describe('TokenRolesGuard', () => {
11+
const reflector = new Reflector();
12+
const guard = new TokenRolesGuard(reflector, {} as any);
13+
14+
function listSubmissions() {
15+
return undefined;
16+
}
17+
18+
const handler = listSubmissions;
19+
20+
beforeAll(() => {
21+
Reflect.defineMetadata(
22+
ROLES_KEY,
23+
[UserRole.Copilot, UserRole.Admin, UserRole.Reviewer, UserRole.User],
24+
handler,
25+
);
26+
Reflect.defineMetadata(SCOPES_KEY, ['read:submission'], handler);
27+
});
28+
29+
type TestRequest = Record<string, unknown> & {
30+
method: string;
31+
query: Record<string, unknown>;
32+
user: {
33+
userId: string;
34+
isMachine: boolean;
35+
roles?: unknown[];
36+
scopes?: unknown[];
37+
};
38+
};
39+
40+
const createExecutionContext = (request: TestRequest): ExecutionContext => {
41+
class SubmissionController {}
42+
43+
return {
44+
switchToHttp: () => ({
45+
getRequest: () => request,
46+
}),
47+
getHandler: () => handler,
48+
getClass: () => SubmissionController,
49+
getType: () => 'http',
50+
getArgs: () => [],
51+
getArgByIndex: () => undefined,
52+
switchToRpc: () => ({
53+
getData: () => undefined,
54+
getContext: () => undefined,
55+
}),
56+
switchToWs: () => ({
57+
getClient: () => undefined,
58+
getData: () => undefined,
59+
getPattern: () => undefined,
60+
}),
61+
} as unknown as ExecutionContext;
62+
};
63+
64+
it('allows authenticated users without explicit roles when requesting submissions by challengeId', () => {
65+
const request = {
66+
method: 'GET',
67+
query: { challengeId: '12345' },
68+
user: {
69+
userId: '1001',
70+
isMachine: false,
71+
roles: [],
72+
},
73+
};
74+
75+
const context = createExecutionContext(request);
76+
77+
expect(guard.canActivate(context)).toBe(true);
78+
});
79+
80+
it('denies access when challengeId is missing', () => {
81+
const request = {
82+
method: 'GET',
83+
query: {},
84+
user: {
85+
userId: '1001',
86+
isMachine: false,
87+
roles: [],
88+
},
89+
};
90+
91+
const context = createExecutionContext(request);
92+
93+
expect(() => guard.canActivate(context)).toThrow(ForbiddenException);
94+
});
95+
});

src/shared/guards/tokenRoles.guard.ts

Lines changed: 71 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -43,22 +43,35 @@ export class TokenRolesGuard implements CanActivate {
4343
throw new UnauthorizedException('Missing or invalid token!');
4444
}
4545

46-
// Check role-based access for regular users
47-
if (Array.isArray(user.roles) && requiredRoles.length > 0) {
48-
const normalizedUserRoles = user.roles
49-
.map((role) => String(role).trim().toLowerCase())
50-
.filter((role) => role.length > 0);
46+
const normalizedRequiredRoles = requiredRoles.map((role) =>
47+
String(role).trim().toLowerCase(),
48+
);
5149

52-
const normalizedRequiredRoles = requiredRoles.map((role) =>
53-
String(role).trim().toLowerCase(),
54-
);
50+
// Check role-based access for regular users
51+
if (normalizedRequiredRoles.length > 0) {
52+
const normalizedUserRoles = Array.isArray(user.roles)
53+
? user.roles
54+
.map((role) => String(role).trim().toLowerCase())
55+
.filter((role) => role.length > 0)
56+
: [];
5557

5658
const hasRole = normalizedRequiredRoles.some((role) =>
5759
normalizedUserRoles.includes(role),
5860
);
5961
if (hasRole) {
6062
return true;
6163
}
64+
65+
if (
66+
this.allowSubmissionListByChallenge(
67+
context,
68+
request,
69+
normalizedRequiredRoles,
70+
user,
71+
)
72+
) {
73+
return true;
74+
}
6275
}
6376

6477
// Check scope-based access for M2M tokens
@@ -95,4 +108,54 @@ export class TokenRolesGuard implements CanActivate {
95108
throw new UnauthorizedException('Invalid token');
96109
}
97110
}
111+
112+
private allowSubmissionListByChallenge(
113+
context: ExecutionContext,
114+
request: any,
115+
normalizedRequiredRoles: string[],
116+
user: any,
117+
): boolean {
118+
const generalUserRole = String(UserRole.User).trim().toLowerCase();
119+
120+
if (user?.isMachine || !user?.userId) {
121+
return false;
122+
}
123+
124+
if (!normalizedRequiredRoles.includes(generalUserRole)) {
125+
return false;
126+
}
127+
128+
const handler = context.getHandler?.();
129+
const controllerClass = context.getClass?.();
130+
131+
const isSubmissionListHandler =
132+
controllerClass?.name === 'SubmissionController' &&
133+
handler?.name === 'listSubmissions';
134+
135+
if (!isSubmissionListHandler) {
136+
return false;
137+
}
138+
139+
const method = (request?.method || '').toUpperCase();
140+
if (method !== 'GET') {
141+
return false;
142+
}
143+
144+
const challengeId = request?.query?.challengeId;
145+
if (!this.hasNonEmptyQueryParam(challengeId)) {
146+
return false;
147+
}
148+
149+
return true;
150+
}
151+
152+
private hasNonEmptyQueryParam(value: unknown): boolean {
153+
if (typeof value === 'string') {
154+
return value.trim().length > 0;
155+
}
156+
if (Array.isArray(value)) {
157+
return value.some((entry) => this.hasNonEmptyQueryParam(entry));
158+
}
159+
return false;
160+
}
98161
}

0 commit comments

Comments
 (0)