Skip to content

Commit f47801f

Browse files
committed
implemented accept account request and added unit testing, changed existing account request methods to support jwt
1 parent 671391c commit f47801f

8 files changed

Lines changed: 443 additions & 14 deletions

File tree

‎packages/api-gateway/src/models/registration.dto.ts‎

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,67 @@ export class NewAccountRequestsResponse {
8787
}
8888
}
8989

90+
export class AcceptAccountRequestResponseUser {
91+
@ApiProperty({ description: 'User ID' })
92+
id: number;
93+
94+
@ApiProperty({ description: 'Email' })
95+
email: string;
96+
97+
@ApiProperty({ description: 'Name' })
98+
name: string;
99+
100+
@ApiProperty({
101+
description: 'User type',
102+
enum: ['SUPERADMIN', 'ADMIN', 'USER'],
103+
})
104+
type: string;
105+
106+
@ApiPropertyOptional({
107+
description: 'Project IDs the user is linked to',
108+
type: [Number],
109+
})
110+
projectIds?: number[];
111+
}
112+
113+
export class AcceptAccountRequestResponseProject {
114+
@ApiProperty({ description: 'Project ID' })
115+
id: number;
116+
117+
@ApiProperty({ description: 'Project name' })
118+
name: string;
119+
}
120+
121+
export class AcceptAccountRequestResponseModel {
122+
@ApiProperty({
123+
description: 'The created user',
124+
type: AcceptAccountRequestResponseUser,
125+
})
126+
user: AcceptAccountRequestResponseUser;
127+
128+
@ApiPropertyOptional({
129+
description: 'The created or linked project (if applicable)',
130+
type: AcceptAccountRequestResponseProject,
131+
})
132+
project?: AcceptAccountRequestResponseProject;
133+
134+
constructor(res: UserProto.AcceptAccountRequestResponse) {
135+
this.user = {
136+
id: Number(res.user.id),
137+
email: res.user.email,
138+
name: res.user.name,
139+
type: fromUserType(res.user.type),
140+
projectIds: res.user.projectIds?.map(Number),
141+
};
142+
if (res.project) {
143+
this.project = {
144+
id: Number(res.project.id),
145+
name: res.project.name,
146+
};
147+
}
148+
}
149+
}
150+
90151
export function userTypeStringToProto(value: string): CommonProto.UserType {
91152
switch (value) {
92153
case 'SUPERADMIN':

‎packages/api-gateway/src/modules/auth/auth.controller.ts‎

Lines changed: 60 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ import {
4141
RequestNewAccountModel,
4242
NewAccountRequestResponse,
4343
NewAccountRequestsResponse,
44+
AcceptAccountRequestResponseModel,
4445
userTypeStringToProto,
4546
} from 'src/models/registration.dto';
4647
import { userLinkedToProject } from 'src/user_project_validator';
@@ -326,13 +327,13 @@ export class AuthController implements OnModuleInit {
326327
@ApiHeader({
327328
name: 'X-User-Email',
328329
description: 'Email of an admin or superadmin user',
329-
required: true,
330+
required: false,
330331
schema: { type: 'string' },
331332
})
332333
@ApiHeader({
333334
name: 'X-User-Password',
334335
description: 'Password of the admin or superadmin user',
335-
required: true,
336+
required: false,
336337
schema: { type: 'string' },
337338
})
338339
@ApiBearerAuth('API_Key')
@@ -376,13 +377,13 @@ export class AuthController implements OnModuleInit {
376377
@ApiHeader({
377378
name: 'X-User-Email',
378379
description: 'Email of an admin or superadmin user',
379-
required: true,
380+
required: false,
380381
schema: { type: 'string' },
381382
})
382383
@ApiHeader({
383384
name: 'X-User-Password',
384385
description: 'Password of the admin or superadmin user',
385-
required: true,
386+
required: false,
386387
schema: { type: 'string' },
387388
})
388389
@ApiBearerAuth('API_Key')
@@ -402,4 +403,59 @@ export class AuthController implements OnModuleInit {
402403
);
403404
return new NewAccountRequestResponse(removed);
404405
}
406+
407+
@Post('/account-request/:id/accept')
408+
@ApiOperation({
409+
summary: 'Accept an account request',
410+
description:
411+
'Accepts a pending account request: creates the user, optionally creates/links a project (for ADMIN requests with a projectName), and deletes the pending request. Requires admin or superadmin credentials.',
412+
})
413+
@ApiParam({
414+
name: 'id',
415+
required: true,
416+
description: 'ID of the account request to accept',
417+
type: String,
418+
})
419+
@ApiResponse({
420+
status: HttpStatus.CREATED,
421+
description: 'Account request accepted and user created',
422+
type: AcceptAccountRequestResponseModel,
423+
})
424+
@ApiResponse({
425+
status: HttpStatus.NOT_FOUND,
426+
description: 'Account request not found',
427+
})
428+
@ApiResponse({
429+
status: HttpStatus.UNAUTHORIZED,
430+
description: 'Unauthorized',
431+
})
432+
@ApiHeader({
433+
name: 'X-User-Email',
434+
description: 'Email of an admin or superadmin user',
435+
required: false,
436+
schema: { type: 'string' },
437+
})
438+
@ApiHeader({
439+
name: 'X-User-Password',
440+
description: 'Password of the admin or superadmin user',
441+
required: false,
442+
schema: { type: 'string' },
443+
})
444+
@ApiBearerAuth('API_Key')
445+
async acceptAccountRequest(
446+
@User() user: CommonProto.User,
447+
@Param('id') idStr: string,
448+
): Promise<AcceptAccountRequestResponseModel> {
449+
if (!user || user.type === CommonProto.UserType.USER) {
450+
throw new HttpException('Unauthorized', HttpStatus.UNAUTHORIZED);
451+
}
452+
const id = parseInt(idStr);
453+
if (Number.isNaN(id)) {
454+
throw new HttpException('id must be a number', HttpStatus.BAD_REQUEST);
455+
}
456+
const result = await lastValueFrom(
457+
this.accountRequestService.acceptAccountRequest({ id }),
458+
);
459+
return new AcceptAccountRequestResponseModel(result);
460+
}
405461
}

‎packages/api-gateway/src/modules/auth/auth.module.ts‎

Lines changed: 11 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -86,14 +86,16 @@ const { PROJECT_SERVICE_NAME, JUNO_PROJECT_PACKAGE_NAME } = ProjectProto;
8686
})
8787
export class AuthModule implements NestModule {
8888
configure(consumer: MiddlewareConsumer) {
89-
consumer
90-
.apply(CredentialsMiddleware)
91-
.forRoutes(
92-
{ path: 'auth/key', method: RequestMethod.POST },
93-
{ path: 'auth/user/jwt', method: RequestMethod.POST },
94-
{ path: 'auth/test-auth', method: RequestMethod.GET },
95-
{ path: 'auth/account-request', method: RequestMethod.GET },
96-
{ path: 'auth/account-request/:id', method: RequestMethod.DELETE },
97-
);
89+
consumer.apply(CredentialsMiddleware).forRoutes(
90+
{ path: 'auth/key', method: RequestMethod.POST },
91+
{ path: 'auth/user/jwt', method: RequestMethod.POST },
92+
{ path: 'auth/test-auth', method: RequestMethod.GET },
93+
{ path: 'auth/account-request', method: RequestMethod.GET },
94+
{ path: 'auth/account-request/:id', method: RequestMethod.DELETE },
95+
{
96+
path: 'auth/account-request/:id/accept',
97+
method: RequestMethod.POST,
98+
},
99+
);
98100
}
99101
}

‎packages/api-gateway/test/auth.e2e-spec.ts‎

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -419,3 +419,71 @@ describe('Account Request - DELETE /auth/account-request/:id', () => {
419419
.expect(404);
420420
});
421421
});
422+
423+
describe('Account Request - POST /auth/account-request/:id/accept', () => {
424+
it('should accept a USER request and return the created user', async () => {
425+
const createResp = await request(app.getHttpServer())
426+
.post('/auth/account-request')
427+
.send({
428+
email: 'accept-gw-user@example.com',
429+
name: 'Accept GW User',
430+
password: 'securepassword',
431+
userType: 'USER',
432+
})
433+
.expect(201);
434+
435+
const id = createResp.body.id;
436+
437+
const acceptResp = await request(app.getHttpServer())
438+
.post(`/auth/account-request/${id}/accept`)
439+
.set('X-User-Email', ADMIN_EMAIL)
440+
.set('X-User-Password', ADMIN_PASSWORD)
441+
.expect(201);
442+
443+
expect(acceptResp.body.user).toBeDefined();
444+
expect(acceptResp.body.user.email).toBe('accept-gw-user@example.com');
445+
expect(acceptResp.body.user.name).toBe('Accept GW User');
446+
expect(acceptResp.body.user.type).toBe('USER');
447+
});
448+
449+
it('should accept an ADMIN request with projectName and return user + project', async () => {
450+
const createResp = await request(app.getHttpServer())
451+
.post('/auth/account-request')
452+
.send({
453+
email: 'accept-gw-admin@example.com',
454+
name: 'Accept GW Admin',
455+
password: 'securepassword',
456+
userType: 'ADMIN',
457+
projectName: 'gw-accept-project',
458+
})
459+
.expect(201);
460+
461+
const id = createResp.body.id;
462+
463+
const acceptResp = await request(app.getHttpServer())
464+
.post(`/auth/account-request/${id}/accept`)
465+
.set('X-User-Email', ADMIN_EMAIL)
466+
.set('X-User-Password', ADMIN_PASSWORD)
467+
.expect(201);
468+
469+
expect(acceptResp.body.user).toBeDefined();
470+
expect(acceptResp.body.user.email).toBe('accept-gw-admin@example.com');
471+
expect(acceptResp.body.user.type).toBe('ADMIN');
472+
expect(acceptResp.body.project).toBeDefined();
473+
expect(acceptResp.body.project.name).toBe('gw-accept-project');
474+
});
475+
476+
it('should reject unauthenticated accept requests', () => {
477+
return request(app.getHttpServer())
478+
.post('/auth/account-request/1/accept')
479+
.expect(401);
480+
});
481+
482+
it('should return 400 for non-numeric id', () => {
483+
return request(app.getHttpServer())
484+
.post('/auth/account-request/abc/accept')
485+
.set('X-User-Email', ADMIN_EMAIL)
486+
.set('X-User-Password', ADMIN_PASSWORD)
487+
.expect(400);
488+
});
489+
});

‎packages/db-service/src/modules/auth/auth.controller.ts‎

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,4 +96,22 @@ export class ApiKeyDbController
9696
createdAt: removed.createdAt.toISOString(),
9797
};
9898
}
99+
100+
async acceptAccountRequest(
101+
request: UserProto.AcceptAccountRequestMessage,
102+
): Promise<UserProto.AcceptAccountRequestResponse> {
103+
const result = await this.apiKeyService.acceptAccountRequest(request.id);
104+
return {
105+
user: {
106+
id: result.user.id,
107+
email: result.user.email,
108+
name: result.user.name,
109+
type: mapPrismaRoleToRPC(result.user.type),
110+
projectIds: result.user.projectIds,
111+
},
112+
project: result.project
113+
? { id: result.project.id, name: result.project.name }
114+
: undefined,
115+
};
116+
}
99117
}

‎packages/db-service/src/modules/auth/auth.service.ts‎

Lines changed: 67 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { status } from '@grpc/grpc-js';
22
import { Injectable } from '@nestjs/common';
33
import { RpcException } from '@nestjs/microservices';
4-
import { ApiKey, NewAccountRequest, Prisma } from '@prisma/client';
4+
import { ApiKey, NewAccountRequest, Prisma, Role } from '@prisma/client';
55
import { AuthCommonProto } from 'juno-proto';
66
import { PrismaService } from 'src/prisma.service';
77

@@ -137,6 +137,72 @@ export class AuthService {
137137
}
138138
return this.prisma.newAccountRequest.delete({ where: { id } });
139139
}
140+
141+
async acceptAccountRequest(id: number): Promise<{
142+
user: {
143+
id: number;
144+
email: string;
145+
name: string;
146+
type: Role;
147+
projectIds: number[];
148+
};
149+
project?: { id: number; name: string };
150+
}> {
151+
const request = await this.prisma.newAccountRequest.findUnique({
152+
where: { id },
153+
});
154+
if (!request) {
155+
throw new RpcException({
156+
code: status.NOT_FOUND,
157+
message: `Account request with id ${id} not found`,
158+
});
159+
}
160+
161+
return this.prisma.$transaction(async (tx) => {
162+
const user = await tx.user.create({
163+
data: {
164+
email: request.email,
165+
name: request.name,
166+
password: request.password,
167+
type: request.userType,
168+
},
169+
});
170+
171+
let project: { id: number; name: string } | undefined;
172+
173+
if (request.userType === Role.ADMIN && request.projectName) {
174+
let existingProject = await tx.project.findUnique({
175+
where: { name: request.projectName },
176+
});
177+
178+
if (!existingProject) {
179+
existingProject = await tx.project.create({
180+
data: { name: request.projectName },
181+
});
182+
}
183+
184+
await tx.user.update({
185+
where: { id: user.id },
186+
data: { allowedProjects: { connect: { id: existingProject.id } } },
187+
});
188+
189+
project = { id: existingProject.id, name: existingProject.name };
190+
}
191+
192+
await tx.newAccountRequest.delete({ where: { id } });
193+
194+
return {
195+
user: {
196+
id: user.id,
197+
email: user.email,
198+
name: user.name,
199+
type: user.type,
200+
projectIds: project ? [project.id] : [],
201+
},
202+
project,
203+
};
204+
});
205+
}
140206
}
141207
const convertDbApiKeyToTs = (key: ApiKey): AuthCommonProto.ApiKey => {
142208
const mappedScopes = key.scopes.map((scope) => {

0 commit comments

Comments
 (0)