From 567cc581a7de1cbec9e2c56efd1bdff2ee61d8ca Mon Sep 17 00:00:00 2001 From: Teddy Roncin Date: Mon, 30 Sep 2024 01:25:15 +0200 Subject: [PATCH 1/9] feat: written documentation for permissions --- docs/doc_developers/api/permissions.md | 29 ++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) create mode 100644 docs/doc_developers/api/permissions.md diff --git a/docs/doc_developers/api/permissions.md b/docs/doc_developers/api/permissions.md new file mode 100644 index 0000000..d26cd17 --- /dev/null +++ b/docs/doc_developers/api/permissions.md @@ -0,0 +1,29 @@ +# Permissions + +Cette page traite à la fois des permissions des utilisateurs, et de celles des applications utilisant l'API. + +## Introduction + +Tout d'abord, faisons un tour d'horizon des tables : +- `ApiKey` : c'est la table de base pour les permissions. Cette table contient des données de base sur les droits de chaque utilisateur. L'utilisateur ne sera pas authentifié directement, mais avec une `ApiKey`. Chaque utilisateur peut avoir plusieurs `ApiKey`, que vous pouvez voir comme des subdivisions d'un utilisateur. Prenons l'exemple de l'intégration : ils auront une `ApiKey` pour le front de EtuUTT, une `ApiKey` pour leur site web, et une troisième pour leur application. Chaque `ApiKey` a des permissions différentes. +- `User` : l'utilisateur, les `ApiKey` pointent vers cette table. +- `GrantedPermissions` : Cette table contient les permissions données par un certain utilisateur à une certaine clé API. +- `ApiPermissions` et `UserPermissions` : Ces _enum_ listent l'entièreté des permissions prises en charge par l'API. Les valeurs de `ApiPermissions` (resp. `UserPermissions`) commencent par `API_` (resp. `USER_`). Les `UserPermissions` peuvent être demandées par les différentes applications et acceptées par les utilisateurs au cas par cas. Plus d'information dans la partie du fonctionnement des (grants)[#grants]. + +## Authentification des requêtes + +On va traiter l'authentification des requêtes avant la connexion, le _flow_ me paraît plus logique dans ce sens là :) + +Pour authentifier les requêtes, on utilise un token JWT, passé dans le _header_ `Authorization`, sous le format `Bearer {token}`. Une fois décodé, le token renvoit un objet de la forme contenant un champ `token`. Ce champ permet de trouver l'`ApiKey` unique. À partir de cette `ApiKey`, il est ainsi possible d'obtenir l'utilisateur authentifié, et les routes ou informations auxquelles l'utilisateur a le droit d'accéder. + +## Connexion + +La connexion pour un utilisateur ou une application diffère : +- Pour un utilisateur : on passe par le CAS de l'UTT, avec la route `POST /auth/signin`, puis l'API nous renvoit un token pour authentifier nos requêtes, voir la partie (Authentification des requêtes)[#authentification-des-requetes] +- Pour une application : on génère un token pour l'`ApiKey` demandée, puis on retourne le token JWT. Il faut aussi bien sauvegarder la date de dernière mise à jour (`tokenUpdatedAt`), et utiliser cette date pour toujours retourner la même version du token (champ `iat` dans l'objet à encoder avec JWT). L'utilisateur peut renouveler les token de ses `ApiKey`, le token sera alors modifié, pour empêcher l'accès avec l'ancien token. + +## Grants + +Ce système de _grant_ permet aux utilisateurs de maîtriser quelles données sont partagées avec quelle application externe. + +Par exemple, Guillaume a décidé de développer une application web avec un backend Rust permettant de gérer nos comptes EtuUTT. Il aimerait donc les permissions `USER_SEE_DETAILS` et `USER_UPDATE` sur tous les utilisateurs, ce que nous ne pouvons évidemment pas lui donner, pour des raisons de sécurité et de confidentialité. Il peut cependant marquer ces permissions comme étant demandées (champ `grantablePermissions` de la table `ApiKey`). Ainsi, il pourra rediriger les utilisateur vers une page de EtuUTT (TODO : toujours à déterminer, API ou front ? API serait plus simple), qui leur permettront de se connecter et d'accepter ou non que l'application de Guillaume accède à leurs données. Ils peuvent aussi choisir, par exemple, de n'autoriser l'application qu'à voir leurs données, mais pas de les mettre à jour. From cacae38bfa4169c50b805fdfcd9b608d811f11dd Mon Sep 17 00:00:00 2001 From: Teddy Roncin Date: Mon, 30 Sep 2024 23:28:21 +0200 Subject: [PATCH 2/9] feat: updated db schema --- prisma/schema.prisma | 135 ++++++++++++++++++++++++------------------- 1 file changed, 76 insertions(+), 59 deletions(-) diff --git a/prisma/schema.prisma b/prisma/schema.prisma index ddc9f4b..5254bc6 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -7,6 +7,40 @@ datasource db { url = env("DATABASE_URL") } +model ApiKey { + id String @id @default(uuid()) + name String + token String + tokenUpdatedAt DateTime + userId String + + user User @relation(fields: [userId], references: [id]) + permissions ApiKeyPermission[] +} + +model ApiKeyPermission { + id String @id @default(uuid()) + permission Permission + apiKeyId String + soft Boolean + + apiKey ApiKey @relation(fields: [apiKeyId], references: [id]) + grants ApiKeyGrantedPermission[] + + @@unique([apiKeyId, permission]) +} + +model ApiKeyGrantedPermission { + apiKeyPermissionId String + userId String + createdAt DateTime @default(now()) + + apiKeyPermission ApiKeyPermission @relation(fields: [apiKeyPermissionId], references: [id]) + user User @relation(fields: [userId], references: [id]) + + @@id([apiKeyPermissionId, userId]) +} + model Asso { id String @id @default(uuid()) login String @unique @db.VarChar(50) @@ -21,12 +55,12 @@ model Asso { descriptionShortTranslationId String @unique descriptionTranslationId String @unique - descriptionShortTranslation Translation @relation(name: "descriptionShortTranslation", fields: [descriptionShortTranslationId], references: [id], onDelete: Cascade) - descriptionTranslation Translation @relation(name: "descriptionTranslation", fields: [descriptionTranslationId], references: [id], onDelete: Cascade) + descriptionShortTranslation Translation @relation(name: "descriptionShortTranslation", fields: [descriptionShortTranslationId], references: [id], onDelete: Cascade) + descriptionTranslation Translation @relation(name: "descriptionTranslation", fields: [descriptionTranslationId], references: [id], onDelete: Cascade) assoMemberships AssoMembership[] assoMessages AssoMessage[] events Event[] - assoMembershipRoles AssoMembershipRole[] + assoMembershipRoles AssoMembershipRole[] } model AssoMembership { @@ -55,10 +89,10 @@ model AssoMembershipRole { name String position Int isPresident Boolean - assoId String + assoId String assoMembership AssoMembership[] - asso Asso @relation(fields: [assoId], references: [id]) + asso Asso @relation(fields: [assoId], references: [id]) } model AssoMessage { @@ -196,8 +230,6 @@ model Translation { assoMessageTitleBody AssoMessage? @relation("bodyTranslation") eventDescription Event? @relation("descriptionTranslation") eventTitle Event? @relation("titleTranslation") - userPermissionName UserPermission? @relation("userPermissionName") - userPermissionDescription UserPermission? @relation("userPermissionDescription") annalReportReasonDescriptions UeAnnalReportReason? commentReportReasonDescriptions UeCommentReportReason? starCriterionDescriptions UeStarCriterion? @@ -490,28 +522,6 @@ model UeWorkTime { ue Ue @relation(fields: [ueId], references: [id], onDelete: Cascade) } -model UserPermission { - id String @id - nameTranslationId String @unique - descriptionTranslationId String? @unique - - name Translation @relation(name: "userPermissionName", fields: [nameTranslationId], references: [id], onDelete: Cascade) - description Translation? @relation(name: "userPermissionDescription", fields: [descriptionTranslationId], references: [id], onDelete: SetNull) - users UserPermissionAssignement[] -} - -model UserPermissionAssignement { - id String @id @default(uuid()) - userPermissionId String - userId String - assignedAt DateTime @default(now()) - assignedById String? - - userPermission UserPermission @relation(fields: [userPermissionId], references: [id], onDelete: Cascade) - user User @relation(fields: [userId], references: [id], onDelete: Cascade) - assignedBy User? @relation(fields: [assignedById], references: [id], name: "permissions_assigned", onDelete: SetNull) -} - model User { id String @id @default(uuid()) login String @unique @db.VarChar(50) @@ -525,21 +535,21 @@ model User { infosId String @unique mailsPhonesId String @unique socialNetworkId String @unique - privacyId String @unique + privacyId String @unique userType UserType timestamps UserTimestamps? - socialNetwork UserSocialNetwork @relation(fields: [socialNetworkId], references: [id]) + socialNetwork UserSocialNetwork @relation(fields: [socialNetworkId], references: [id]) bans UserBan[] - rgpd UserRGPD @relation(fields: [rgpdId], references: [id]) + rgpd UserRGPD @relation(fields: [rgpdId], references: [id]) bdeContributions UserBDEContribution[] assoMembership AssoMembership[] branchSubscriptions UserBranchSubscription[] formation UserFormation? - preference UserPreference @relation(fields: [preferenceId], references: [id]) - infos UserInfos @relation(fields: [infosId], references: [id]) + preference UserPreference @relation(fields: [preferenceId], references: [id]) + infos UserInfos @relation(fields: [infosId], references: [id]) addresses UserAddress[] - mailsPhones UserMailsPhones @relation(fields: [mailsPhonesId], references: [id]) + mailsPhones UserMailsPhones @relation(fields: [mailsPhonesId], references: [id]) otherAttributes UserOtherAttributValue[] UesSubscriptions UserUeSubscription[] UeStarVotes UeStarVote[] @@ -553,16 +563,14 @@ model User { repliesReported UeCommentReplyReport[] gitHubIssues GitHubIssue[] etuUTTTeam UserEtuUTTTeam[] - // Permissions assigned to the user - permissions UserPermissionAssignement[] - // Permission assigned by the user to other users. Used for access control history - permissionsAssigned UserPermissionAssignement[] @relation(name: "permissions_assigned") courseExchanges UeCourseExchange[] courseExchangeReplies UeCourseExchangeReply[] commentUpvotes UeCommentUpvote[] timetableGroups UserTimetableGroup[] homepageWidgets UserHomepageWidget[] - privacy UserPrivacy @relation(fields: [privacyId], references: [id]) + privacy UserPrivacy @relation(fields: [privacyId], references: [id]) + apiKeys ApiKey[] + apiKeyGrantedPermissions ApiKeyGrantedPermission[] } model UserAddress { @@ -599,12 +607,12 @@ model UserBDEContribution { } model UserBranchSubscription { - id String @id @default(uuid()) - userId String - semesterNumber Int @db.SmallInt - createdAt DateTime @default(now()) - branchOptionId String - semesterCode String + id String @id @default(uuid()) + userId String + semesterNumber Int @db.SmallInt + createdAt DateTime @default(now()) + branchOptionId String + semesterCode String user User @relation(fields: [userId], references: [id]) branchOption UTTBranchOption @relation(fields: [branchOptionId], references: [id]) @@ -701,11 +709,11 @@ model UserHomepageWidget { } model UserPreference { - id String @id @default(uuid()) - language Language @default(fr) - wantDaymail Boolean @default(false) - wantDayNotif Boolean @default(false) - wantDiscordUtt Boolean @default(false) + id String @id @default(uuid()) + language Language @default(fr) + wantDaymail Boolean @default(false) + wantDayNotif Boolean @default(false) + wantDiscordUtt Boolean @default(false) user User? } @@ -719,14 +727,14 @@ model UserRGPD { } model UserSocialNetwork { - id String @id @default(uuid()) - facebook String? @db.VarChar(255) - twitter String? @db.VarChar(255) - instagram String? @db.VarChar(255) - linkedin String? @db.VarChar(255) - twitch String? @db.VarChar(255) - spotify String? @db.VarChar(255) - discord String? @db.VarChar(255) + id String @id @default(uuid()) + facebook String? @db.VarChar(255) + twitter String? @db.VarChar(255) + instagram String? @db.VarChar(255) + linkedin String? @db.VarChar(255) + twitch String? @db.VarChar(255) + spotify String? @db.VarChar(255) + discord String? @db.VarChar(255) user User? } @@ -868,3 +876,12 @@ enum AddressPrivacy { ADDRESS_PRIVATE ALL_PUBLIC } + +enum Permission { + API_SEE_OPINIONS_UE + API_UPLOAD_ANNAL + API_MODERATE_COMMENTS + + USER_SEE_DETAILS + USER_UPDATE_DETAILS +} From 2d18cbc4cef82cb10f31073e9cb472238f88a65f Mon Sep 17 00:00:00 2001 From: Teddy Roncin Date: Sat, 12 Oct 2024 14:49:55 +0200 Subject: [PATCH 3/9] feat: implemented better permission manager --- prisma/schema.prisma | 46 ++++-- prisma/seed/utils.ts | 8 +- src/auth/auth.controller.ts | 42 +++-- src/auth/auth.service.ts | 145 ++++++++++++++---- .../decorator/get-application.decorator.ts | 30 ++++ .../decorator/get-permissions.decorator.ts | 30 ++++ src/auth/decorator/get-user.decorator.ts | 3 +- .../decorator/require-permission.decorator.ts | 7 +- src/auth/dto/req/auth-cas-sign-in-req.dto.ts | 11 +- src/auth/dto/req/auth-cas-sign-up-req.dto.ts | 8 +- src/auth/dto/req/auth-sign-in-req.dto.ts | 9 +- src/auth/dto/req/auth-sign-up-req.dto.ts | 4 + src/auth/dto/req/create-api-key-req.dto.ts | 17 ++ src/auth/dto/res/cas-login-res.dto.ts | 2 +- src/auth/guard/jwt.guard.ts | 29 ++-- src/auth/guard/permission.guard.ts | 14 +- src/auth/guard/role.guard.ts | 4 +- .../interfaces/request-auth-data.interface.ts | 12 ++ src/auth/strategy/jwt.strategy.ts | 40 ++++- src/exceptions.ts | 10 ++ src/main.ts | 2 +- src/prisma/types.ts | 1 + src/{array.ts => std.type.ts} | 22 ++- src/ue/annals/annals.controller.ts | 66 ++++++-- src/ue/comments/comments.controller.ts | 47 ++++-- src/users/dto/res/user-overview-res.dto.ts | 5 +- src/users/interfaces/user.interface.ts | 46 ++++-- src/users/users.controller.ts | 7 +- src/validation.ts | 2 +- test/declarations.d.ts | 2 + test/declarations.ts | 13 +- test/e2e/app.e2e-spec.ts | 2 +- test/e2e/auth/cas-sign-in.e2e-spec.ts | 5 +- test/e2e/auth/cas-sign-up.e2e-spec.ts | 15 +- test/e2e/auth/signin-e2e-spec.ts | 1 + test/e2e/auth/signup-e2e-spec.ts | 25 ++- test/e2e/auth/verify-e2e-spec.ts | 8 +- .../ue/annals/get-annal-metadata.e2e-spec.ts | 3 +- test/e2e/ue/annals/get-annals.e2e-spec.ts | 2 +- test/e2e/ue/comments/get-comment.e2e-spec.ts | 2 +- test/e2e/users/search-e2e-spec.ts | 4 +- test/external_services/cas.ts | 4 +- test/unit/app.spec.ts | 2 +- test/utils/fakedb.ts | 95 ++++++------ test/utils/test_utils.ts | 28 +++- 45 files changed, 673 insertions(+), 207 deletions(-) create mode 100644 src/auth/decorator/get-application.decorator.ts create mode 100644 src/auth/decorator/get-permissions.decorator.ts create mode 100644 src/auth/dto/req/create-api-key-req.dto.ts create mode 100644 src/auth/interfaces/request-auth-data.interface.ts rename src/{array.ts => std.type.ts} (65%) diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 5254bc6..e5f5c34 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -7,38 +7,48 @@ datasource db { url = env("DATABASE_URL") } +model ApiApplication { + id String @id @default(uuid()) + name String + userId String + + user User @relation(fields: [userId], references: [id]) + ApiKey ApiKey[] +} + model ApiKey { id String @id @default(uuid()) - name String - token String + token String @unique tokenUpdatedAt DateTime userId String + applicationId String - user User @relation(fields: [userId], references: [id]) - permissions ApiKeyPermission[] + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + application ApiApplication @relation(fields: [applicationId], references: [id], onDelete: Cascade) + apiKeyPermissions ApiKeyPermission[] } model ApiKeyPermission { - id String @id @default(uuid()) + id String @id @default(uuid()) permission Permission - apiKeyId String - soft Boolean + apiKeyId String + soft Boolean - apiKey ApiKey @relation(fields: [apiKeyId], references: [id]) - grants ApiKeyGrantedPermission[] + apiKey ApiKey @relation(fields: [apiKeyId], references: [id], onDelete: Cascade) + grants ApiGrantedPermission[] @@unique([apiKeyId, permission]) } -model ApiKeyGrantedPermission { +model ApiGrantedPermission { apiKeyPermissionId String - userId String - createdAt DateTime @default(now()) + userId String + createdAt DateTime @default(now()) - apiKeyPermission ApiKeyPermission @relation(fields: [apiKeyPermissionId], references: [id]) - user User @relation(fields: [userId], references: [id]) + apiKeyPermission ApiKeyPermission @relation(fields: [apiKeyPermissionId], references: [id], onDelete: Cascade) + user User @relation(fields: [userId], references: [id], onDelete: Cascade) - @@id([apiKeyPermissionId, userId]) + @@id([userId, apiKeyPermissionId]) } model Asso { @@ -487,7 +497,7 @@ model UeInfo { model UeStarCriterion { id String @id @default(uuid()) - name String @db.VarChar(255) + name String @db.VarChar(255) @unique descriptionTranslationId String @unique descriptionTranslation Translation @relation(fields: [descriptionTranslationId], references: [id], onDelete: Cascade) @@ -569,8 +579,9 @@ model User { timetableGroups UserTimetableGroup[] homepageWidgets UserHomepageWidget[] privacy UserPrivacy @relation(fields: [privacyId], references: [id]) + apiApplications ApiApplication[] apiKeys ApiKey[] - apiKeyGrantedPermissions ApiKeyGrantedPermission[] + apiGrants ApiGrantedPermission[] } model UserAddress { @@ -880,6 +891,7 @@ enum AddressPrivacy { enum Permission { API_SEE_OPINIONS_UE API_UPLOAD_ANNAL + API_MODERATE_ANNAL API_MODERATE_COMMENTS USER_SEE_DETAILS diff --git a/prisma/seed/utils.ts b/prisma/seed/utils.ts index 5cdc744..125efca 100644 --- a/prisma/seed/utils.ts +++ b/prisma/seed/utils.ts @@ -93,7 +93,7 @@ function fakeSafeUniqueData value } } } } + * {@code { db: { [tableName]: { [columnName]: () => value } } } } */ declare module '@faker-js/faker' { export interface Faker { @@ -120,6 +120,9 @@ declare module '@faker-js/faker' { assoMembershipRole: { position: () => number; }; + ueStarCriterion: { + name: () => string; + } }; } } @@ -178,6 +181,9 @@ Faker.prototype.db = { () => Math.max(...(registeredUniqueValues.assoMembershipRole?.position ?? [0])) + 1, ), }, + ueStarCriterion: { + name: () => fakeSafeUniqueData('ueStarCriterion', 'name', faker.word.adjective), + }, }; export function generateTranslation(rng: () => string = faker.random.words) { diff --git a/src/auth/auth.controller.ts b/src/auth/auth.controller.ts index ff644c3..d8224f7 100644 --- a/src/auth/auth.controller.ts +++ b/src/auth/auth.controller.ts @@ -12,6 +12,8 @@ import AccessTokenResponse from './dto/res/auth-access-token-res.dto'; import TokenValidityResDto from './dto/res/token-validity-res.dto'; import CasLoginResDto from './dto/res/cas-login-res.dto'; import { ApiAppErrorResponse } from '../app.dto'; +import { GetApplication } from './decorator/get-application.decorator'; +import CreateApiKeyReqDto from './dto/req/create-api-key-req.dto'; @Controller('auth') @ApiTags('Authentication') @@ -31,8 +33,8 @@ export class AuthController { ERROR_CODE.CREDENTIALS_ALREADY_TAKEN, 'Login, email address or any field that should be unique is already taken', ) - async signup(@Body() dto: AuthSignUpReqDto): Promise { - const token = await this.authService.signup(dto); + async signup(@Body() dto: AuthSignUpReqDto, @GetApplication() applicationId: string): Promise { + const token = await this.authService.signup(dto, applicationId, false, dto.tokenExpiresIn); return { access_token: token }; } @@ -47,8 +49,8 @@ export class AuthController { type: AccessTokenResponse, }) @ApiAppErrorResponse(ERROR_CODE.INVALID_CREDENTIALS, 'Either the login or the password is incorrect') - async signin(@Body() dto: AuthSignInReqDto) { - const token = await this.authService.signin(dto); + async signin(@Body() dto: AuthSignInReqDto, @GetApplication() application: string) { + const token = await this.authService.signin(dto, application, dto.tokenExpiresIn); if (!token) throw new AppException(ERROR_CODE.INVALID_CREDENTIALS); return { access_token: token }; } @@ -97,12 +99,12 @@ export class AuthController { 'The CAS ticket was successfully validated. If signedIn is true, the user is authenticated and can use the access_token to authenticate his requests. If signedIn is false, the user should use the access_token to sign up with "POST /auth/signup/cas".', type: AccessTokenResponse, }) - async casSignIn(@Body() dto: AuthCasSignInReqDto): Promise { - const res = await this.authService.casSignIn(dto.service, dto.ticket); + async casSignIn(@Body() dto: AuthCasSignInReqDto, @GetApplication() application: string): Promise { + const res = await this.authService.casSignIn(dto.service, dto.ticket, application, dto.tokenExpiresIn); if (res.status === 'invalid') { throw new AppException(ERROR_CODE.INVALID_CAS_TICKET); } - return { signedIn: res.status === 'ok', access_token: res.token }; + return { status: res.status, access_token: res.token }; } @IsPublic() @@ -124,12 +126,32 @@ export class AuthController { ERROR_CODE.CREDENTIALS_ALREADY_TAKEN, 'Login, email, or any other field that should be unique about a user is already bound to another user', ) - async casSignUp(@Body() dto: AuthCasSignUpReqDto) { - const data = this.authService.decodeRegisterToken(dto.registerToken); + async casSignUp(@Body() dto: AuthCasSignUpReqDto, @GetApplication() application: string) { + const data = this.authService.decodeRegisterUserToken(dto.registerToken); if (!data) throw new AppException(ERROR_CODE.INVALID_TOKEN_FORMAT); if (await this.usersService.doesUserExist({ login: data.login })) throw new AppException(ERROR_CODE.CREDENTIALS_ALREADY_TAKEN); - const token = await this.authService.signup(data, true); + const token = await this.authService.signup(data, application, true, dto.tokenExpiresIn); + return { access_token: token }; + } + + @IsPublic() + @Post('/api-key') + @HttpCode(HttpStatus.CREATED) + @ApiOperation({ + description: + 'Creates an API Key to allow user to connect to an application through EtuUTT. Returns a token that can be used to authenticate requests', + }) + @ApiCreatedResponse({ + description: + 'Create an API access for user to the application that made the request. Returns an authentication token. A route to sign-in should be called before, to get the required token in body.', + }) + async createApiKey(@Body() dto: CreateApiKeyReqDto, @GetApplication() application: string) { + const data = this.authService.decodeRegisterApiKeyToken(dto.token); + if (!data) throw new AppException(ERROR_CODE.INVALID_TOKEN_FORMAT); + if (!(await this.usersService.doesUserExist({ id: data.userId }))) + throw new AppException(ERROR_CODE.NO_SUCH_USER, data.userId); // Can only happen if user has deleted his account + const token = await this.authService.createApiKey(data.userId, application, dto.tokenExpiresIn); return { access_token: token }; } } diff --git a/src/auth/auth.service.ts b/src/auth/auth.service.ts index 3cf7e62..5300aed 100644 --- a/src/auth/auth.service.ts +++ b/src/auth/auth.service.ts @@ -16,8 +16,8 @@ import { SemesterService } from '../semester/semester.service'; import AuthSignUpReqDto from './dto/req/auth-sign-up-req.dto'; import AuthSignInReqDto from './dto/req/auth-sign-in-req.dto'; -export type RegisterData = { login: string; mail: string; lastName: string; firstName: string }; -export type ExtendedRegisterData = RegisterData & { studentId: string; type: UserType }; +export type RegisterUserData = { login: string; mail: string; lastName: string; firstName: string }; +export type RegisterApiKeyData = { userId: string; applicationId: string }; @Injectable() export class AuthService { @@ -35,8 +35,16 @@ export class AuthService { * Creates a new user from the data that is provided to this function. * It returns an access token that the user can then use to authenticate their requests. * @param dto Data about the user to create. + * @param applicationId The id of the application we are connecting with. + * @param fetchLdap Whether user information should be imported from the UTT LDAP. + * @param tokenExpiresIn The time the return token will be valid, in seconds. If not given, token will not expire. */ - async signup(dto: SetPartial, fetchLdap = false): Promise { + async signup( + dto: SetPartial, + applicationId: string, + fetchLdap = false, + tokenExpiresIn?: number, + ): Promise { let phoneNumber: string = undefined; let formation: string = undefined; const branch: string[] = []; @@ -73,6 +81,13 @@ export class AuthService { infos: { create: { sex: dto.sex, birthday: dto.birthday }, }, + apiKeys: { + create: { + token: this.generateToken(), + tokenUpdatedAt: new Date(), + application: { connect: { id: applicationId } }, + }, + }, ...(branch.length && branchOption.length && currentSemester ? { branchSubscriptions: { @@ -172,9 +187,12 @@ export class AuthService { userType: type, privacy: { create: {} }, }, + include: { + apiKeys: true, + }, }); - return this.signToken(user.id, user.login); + return this.signAuthenticationToken(user.apiKeys[0].token, tokenExpiresIn); } catch (error) { if (error instanceof Prisma.PrismaClientKnownRequestError) { if (error.code === 'P2002') { @@ -189,13 +207,28 @@ export class AuthService { * Verifies the credentials are right. * It then returns an access_token the user can use to authenticate their requests. * @param dto Data needed to sign in the user (login & password). + * @param applicationId The id of the application to which the user should be signed in. + * @param tokenExpiresIn The time the return token will be valid, in seconds. If not given, token will not expire. */ - async signin(dto: AuthSignInReqDto): Promise { + async signin( + dto: AuthSignInReqDto, + applicationId: string, + tokenExpiresIn?: number, + ): Promise<{ signedIn: boolean; token: string } | null> { // find the user by login, if it does not exist, throw exception const user = await this.prisma.withDefaultBehaviour.user.findUnique({ where: { login: dto.login, }, + include: { + apiKeys: { + where: { + application: { + id: applicationId, + }, + }, + }, + }, }); if (!user) { return null; @@ -208,7 +241,10 @@ export class AuthService { return null; } - return this.signToken(user.id, user.login); + if (!user.apiKeys.length) { + return { signedIn: false, token: await this.signRegisterToken({ userId: user.id } as RegisterApiKeyData) }; + } + return { signedIn: true, token: await this.signAuthenticationToken(user.apiKeys[0].token, tokenExpiresIn) }; } /** @@ -233,11 +269,15 @@ export class AuthService { * - { status: 'ok', token: '' } : the user was successfully authenticated, the token is a normal access token that allows requests to be authenticated. * @param service The service parameter for the CAS API. * @param ticket The ticket that was assigned for this particular connection by the CAS API. + * @param applicationId The application the user is trying to log with. + * @param tokenExpiresIn The time the return token will be valid, in seconds. If not given, token will not expire. */ async casSignIn( service: string, ticket: string, - ): Promise<{ status: 'invalid' | 'no_account' | 'ok'; token: string }> { + applicationId: string, + tokenExpiresIn?: number, + ): Promise<{ status: 'invalid' | 'no_account' | 'no_api_key' | 'ok'; token: string }> { const res = await lastValueFrom( this.httpService.get(`${this.config.CAS_URL}/serviceValidate`, { params: { service, ticket } }), ); @@ -258,58 +298,72 @@ export class AuthService { if ('cas:authenticationFailure' in resData['cas:serviceResponse']) { return { status: 'invalid', token: '' }; } - const data: RegisterData = { + const data: RegisterUserData = { login: resData['cas:serviceResponse']['cas:authenticationSuccess']['cas:attributes']['cas:uid'], mail: resData['cas:serviceResponse']['cas:authenticationSuccess']['cas:attributes']['cas:mail'], lastName: resData['cas:serviceResponse']['cas:authenticationSuccess']['cas:attributes']['cas:sn'], firstName: resData['cas:serviceResponse']['cas:authenticationSuccess']['cas:attributes']['cas:givenName'], }; - const user = await this.prisma.user.findUnique({ where: { login: data.login } }); + const user = await this.prisma.withDefaultBehaviour.user.findUnique({ + where: { login: data.login }, + include: { apiKeys: { where: { application: { id: applicationId } } } }, + }); if (!user) { - const token = this.signRegisterToken(data); - return { status: 'no_account', token }; + return { status: 'no_account', token: await this.signRegisterToken(data) }; + } + if (!user.apiKeys.length) { + return { status: 'no_api_key', token: await this.signRegisterToken({ userId: user.id, applicationId }) }; } - return { status: 'ok', token: await this.signToken(user.id, data.login) }; + return { status: 'ok', token: await this.signAuthenticationToken(user.apiKeys[0].token, tokenExpiresIn) }; } /** * Decodes a register token to access the data it contains. - * @param registerToken {@link RegisterData} that permits creating a user account, or null if the token format is invalid in any way. + * @param registerToken {@link RegisterUserData} that permits creating a user account, or null if the token format is invalid in any way. */ - decodeRegisterToken(registerToken: string): RegisterData | null { + decodeRegisterUserToken(registerToken: string): RegisterUserData | null { const data = this.jwt.decode(registerToken); if (!data || !('login' in data) || !('mail' in data) || !('firstName' in data) || !('lastName' in data)) { return null; } - return omit(data, 'iat', 'exp') as RegisterData; + return omit(data, 'iat', 'exp') as RegisterUserData; + } + + /** + * Decodes a register api key token to access the data it contains. + * @param registerToken {@link RegisterApiKeyData} that permits creating the Api Key linking the user and the given application. + */ + decodeRegisterApiKeyToken(registerToken: string): RegisterApiKeyData | null { + const data = this.jwt.decode(registerToken); + if (!data || !('userId' in data)) { + return null; + } + return omit(data, 'iat', 'exp') as RegisterApiKeyData; } /** * Creates a token for user with the provided user id and login. * It returns the generated token. - * @param userId The id of the user for who we are creating the token. - * @param login The login of the user for who we are creating the token. + * @param token The token to sign. + * @param expiresIn The number of seconds in which the token will expire. If not given, token will never expire. */ - signToken(userId: string, login: string): Promise { - const payload = { - sub: userId, - login, - }; + signAuthenticationToken(token: string, expiresIn?: number): Promise { + const payload = { token }; const secret = this.config.JWT_SECRET; return this.jwt.signAsync(payload, { - expiresIn: this.config.JWT_EXPIRES_IN, - secret: secret, + secret, + ...(expiresIn !== undefined ? { expiresIn } : {}), }); } /** * Creates a register token for the provided data. Returns that token. * When decoded, the returned token contains all the necessary information to register a new user. - * @param data {@link RegisterData} that should be contained in the token. + * @param data {@link RegisterUserData} that should be contained in the token. */ - signRegisterToken(data: RegisterData): string { - return this.jwt.sign(data, { expiresIn: 60, secret: this.config.JWT_SECRET }); + signRegisterToken(data: RegisterUserData | RegisterApiKeyData): Promise { + return this.jwt.signAsync(data, { expiresIn: 60, secret: this.config.JWT_SECRET }); } /** @@ -320,4 +374,41 @@ export class AuthService { const saltRounds = this.config.SALT_ROUNDS; return bcrypt.hash(password, saltRounds); } + + /** + * Creates an API Key, and returns the signed token. + */ + async createApiKey(userId: string, applicationId: string, tokenExpiresIn?: number): Promise { + const apiKey = await this.prisma.withDefaultBehaviour.apiKey.create({ + data: { + user: { + connect: { + id: userId, + }, + }, + application: { + connect: { + id: applicationId, + }, + }, + token: this.generateToken(), + tokenUpdatedAt: new Date(), + }, + }); + return this.signAuthenticationToken(apiKey.token, tokenExpiresIn); + } + + /** + * Generates a completely random string composed of + * @private + */ + private generateToken(): string { + const random = () => Math.random().toString(36).substring(2); + const tokenLength = 128; + let token = ''; + while (token.length < tokenLength) { + token += random(); + } + return token.slice(0, tokenLength); + } } diff --git a/src/auth/decorator/get-application.decorator.ts b/src/auth/decorator/get-application.decorator.ts new file mode 100644 index 0000000..2e9ecc3 --- /dev/null +++ b/src/auth/decorator/get-application.decorator.ts @@ -0,0 +1,30 @@ +import { createParamDecorator, ExecutionContext } from '@nestjs/common'; +import { RequestAuthData } from '../interfaces/request-auth-data.interface'; + +/** + * Get the id of the application that made the request. + * @returns The user property or the whole user. + * + * @example + * ``` + * @Get('/:ueCode/comments') + * async getUEComments( + * @Param('ueCode') ueCode: string, + * @GetUser() user: User, + * @Query() dto: GetUECommentsDto, + * ) { + * if (!(await this.ueService.doesUEExist(ueCode))) + * throw new AppException(ERROR_CODE.NO_SUCH_UE, ueCode); + * return this.ueService.getComments( + * ueCode, + * user, + * dto, + * user.permissions.includes('commentModerator'), + * ); + * } + * ``` + */ +export const GetApplication = createParamDecorator((data: never, ctx: ExecutionContext) => { + const request = ctx.switchToHttp().getRequest(); + return (request.user as RequestAuthData)?.applicationId; +}); diff --git a/src/auth/decorator/get-permissions.decorator.ts b/src/auth/decorator/get-permissions.decorator.ts new file mode 100644 index 0000000..13c8e3b --- /dev/null +++ b/src/auth/decorator/get-permissions.decorator.ts @@ -0,0 +1,30 @@ +import { createParamDecorator, ExecutionContext } from '@nestjs/common'; +import {RequestAuthData} from "../interfaces/request-auth-data.interface"; + +/** + * Get the permissions of a user. + * @returns The user property or the whole user. + * + * @example + * ``` + * @Get('/:ueCode/comments') + * async getUEComments( + * @Param('ueCode') ueCode: string, + * @GetUser() user: User, + * @Query() dto: GetUECommentsDto, + * ) { + * if (!(await this.ueService.doesUEExist(ueCode))) + * throw new AppException(ERROR_CODE.NO_SUCH_UE, ueCode); + * return this.ueService.getComments( + * ueCode, + * user, + * dto, + * user.permissions.includes('commentModerator'), + * ); + * } + * ``` + */ +export const GetPermissions = createParamDecorator((data: never, ctx: ExecutionContext) => { + const request = ctx.switchToHttp().getRequest(); + return (request.user as RequestAuthData).permissions; +}); diff --git a/src/auth/decorator/get-user.decorator.ts b/src/auth/decorator/get-user.decorator.ts index 0add8a6..0b48028 100644 --- a/src/auth/decorator/get-user.decorator.ts +++ b/src/auth/decorator/get-user.decorator.ts @@ -1,5 +1,6 @@ import { createParamDecorator, ExecutionContext } from '@nestjs/common'; import { User } from 'src/users/interfaces/user.interface'; +import {RequestAuthData} from "../interfaces/request-auth-data.interface"; /** * Get the user from the request. @@ -27,5 +28,5 @@ import { User } from 'src/users/interfaces/user.interface'; */ export const GetUser = createParamDecorator((data: keyof User, ctx: ExecutionContext) => { const request = ctx.switchToHttp().getRequest(); - return data ? request.user[data] : request.user; + return data ? (request.user as RequestAuthData).user[data] : (request.user as RequestAuthData).user; }); diff --git a/src/auth/decorator/require-permission.decorator.ts b/src/auth/decorator/require-permission.decorator.ts index 6611193..5278afc 100644 --- a/src/auth/decorator/require-permission.decorator.ts +++ b/src/auth/decorator/require-permission.decorator.ts @@ -1,11 +1,12 @@ import { SetMetadata, ExecutionContext } from '@nestjs/common'; import { Reflector } from '@nestjs/core'; +import {Permission} from "@prisma/client"; export const REQUIRED_PERMISSIONS_KEY = 'requiredPermissions'; /** - * Use this decorator for any non-public route that requires a specific or one of - * the specific permission listed in the decorator + * Use this decorator for any non-public route that requires all the permissions passed in arguments. + * Requires user to have hard-permissions, for soft-permissions, checks should be done directly in the controller. */ -export const RequirePermission = (...permissions: string[]) => SetMetadata(REQUIRED_PERMISSIONS_KEY, permissions); +export const RequirePermission = (...permissions: Permission[]) => SetMetadata(REQUIRED_PERMISSIONS_KEY, permissions); export const findRequiredPermissions = (reflector: Reflector, context: ExecutionContext) => reflector.get>(REQUIRED_PERMISSIONS_KEY, context.getHandler()); diff --git a/src/auth/dto/req/auth-cas-sign-in-req.dto.ts b/src/auth/dto/req/auth-cas-sign-in-req.dto.ts index ff363d8..79978ea 100644 --- a/src/auth/dto/req/auth-cas-sign-in-req.dto.ts +++ b/src/auth/dto/req/auth-cas-sign-in-req.dto.ts @@ -1,9 +1,18 @@ -import { IsString } from 'class-validator'; +import {IsInt, IsNotEmpty, IsString} from 'class-validator'; +import {Type} from "class-transformer"; +import {ApiProperty} from "@nestjs/swagger"; export default class AuthCasSignInReqDto { @IsString() + @IsNotEmpty() ticket: string; @IsString() + @IsNotEmpty() service: string; + + @IsInt() + @Type(() => Number) + @ApiProperty({ description: 'How much time the generated token should be valid' }) + tokenExpiresIn?: number; } diff --git a/src/auth/dto/req/auth-cas-sign-up-req.dto.ts b/src/auth/dto/req/auth-cas-sign-up-req.dto.ts index 283b9e2..6ba76f7 100644 --- a/src/auth/dto/req/auth-cas-sign-up-req.dto.ts +++ b/src/auth/dto/req/auth-cas-sign-up-req.dto.ts @@ -1,9 +1,15 @@ -import { IsNotEmpty, IsString } from 'class-validator'; +import {IsInt, IsNotEmpty, IsString} from 'class-validator'; import { ApiProperty } from '@nestjs/swagger'; +import {Type} from "class-transformer"; export default class AuthCasSignUpReqDto { @IsString() @IsNotEmpty() @ApiProperty({ description: 'Token that has been generated by route "POST /auth/cas/sign-in"' }) registerToken: string; + + @IsInt() + @Type(() => Number) + @ApiProperty({ description: 'How much time the generated token should be valid' }) + tokenExpiresIn?: number; } diff --git a/src/auth/dto/req/auth-sign-in-req.dto.ts b/src/auth/dto/req/auth-sign-in-req.dto.ts index 7b583eb..d14f8f2 100644 --- a/src/auth/dto/req/auth-sign-in-req.dto.ts +++ b/src/auth/dto/req/auth-sign-in-req.dto.ts @@ -1,4 +1,6 @@ -import { IsAlphanumeric, IsNotEmpty, IsString } from 'class-validator'; +import {IsAlphanumeric, IsInt, IsNotEmpty, IsString} from 'class-validator'; +import {Type} from "class-transformer"; +import {ApiProperty} from "@nestjs/swagger"; export default class AuthSignInReqDto { @IsNotEmpty() @@ -8,4 +10,9 @@ export default class AuthSignInReqDto { @IsString() @IsNotEmpty() password: string; + + @IsInt() + @Type(() => Number) + @ApiProperty({ description: 'How much time the generated token should be valid' }) + tokenExpiresIn?: number; } diff --git a/src/auth/dto/req/auth-sign-up-req.dto.ts b/src/auth/dto/req/auth-sign-up-req.dto.ts index cb42355..4792088 100644 --- a/src/auth/dto/req/auth-sign-up-req.dto.ts +++ b/src/auth/dto/req/auth-sign-up-req.dto.ts @@ -42,4 +42,8 @@ export default class AuthSignUpReqDto { @Type(() => Date) @IsOptional() birthday?: Date; + + @IsPositive() + @Type(() => Number) + tokenExpiresIn?: number; } diff --git a/src/auth/dto/req/create-api-key-req.dto.ts b/src/auth/dto/req/create-api-key-req.dto.ts new file mode 100644 index 0000000..5d1027e --- /dev/null +++ b/src/auth/dto/req/create-api-key-req.dto.ts @@ -0,0 +1,17 @@ +import { IsInt, IsNotEmpty, IsString } from 'class-validator'; +import { Type } from 'class-transformer'; +import { ApiProperty } from '@nestjs/swagger'; + +export default class CreateApiKeyReqDto { + @IsString() + @IsNotEmpty() + @ApiProperty({ + description: 'Token that has been generated by route "POST /auth/cas/sign-in" or "POST /auth/sign-in"', + }) + token: string; + + @IsInt() + @Type(() => Number) + @ApiProperty({ description: 'How much time the generated token should be valid' }) + tokenExpiresIn: number; +} diff --git a/src/auth/dto/res/cas-login-res.dto.ts b/src/auth/dto/res/cas-login-res.dto.ts index 3d6cf04..ca8229c 100644 --- a/src/auth/dto/res/cas-login-res.dto.ts +++ b/src/auth/dto/res/cas-login-res.dto.ts @@ -1,4 +1,4 @@ export default class CasLoginResDto { - signedIn: boolean; + status: 'no_account' | 'no_api_key' | 'ok'; access_token: string; } diff --git a/src/auth/guard/jwt.guard.ts b/src/auth/guard/jwt.guard.ts index 66f402f..318f586 100644 --- a/src/auth/guard/jwt.guard.ts +++ b/src/auth/guard/jwt.guard.ts @@ -1,9 +1,10 @@ -import { ExecutionContext, Injectable } from '@nestjs/common'; +import {ExecutionContext, Injectable, UnauthorizedException} from '@nestjs/common'; import { Reflector } from '@nestjs/core'; import { AuthGuard } from '@nestjs/passport'; import { IsPublic } from '../decorator/public.decorator'; import { AppException, ERROR_CODE } from '../../../src/exceptions'; import { Observable, firstValueFrom } from 'rxjs'; +import {RequestAuthData} from "../interfaces/request-auth-data.interface"; @Injectable() export class JwtGuard extends AuthGuard('jwt') { @@ -12,17 +13,27 @@ export class JwtGuard extends AuthGuard('jwt') { } async canActivate(context: ExecutionContext) { + const request = context.switchToHttp().getRequest(); + const applicationId = context.switchToHttp().getRequest().headers['x-application']; + if (!applicationId) throw new AppException(ERROR_CODE.APPLICATION_HEADER_MISSING); + // Check whether the user is logged in + let loggedIn = true; try { - // Check whether the user is logged in - const result = await super.canActivate(context); - if (!result || (result instanceof Observable && !(await firstValueFrom(result)))) throw new Error(); - // The user is logged in, we can serve the request - return true; + await super.canActivate(context); } catch { - // If the route is public, serve the request even if no user is connected - if (this.reflector.get(IsPublic, context.getHandler())) return true; - // The user is not logged in, throw an logging error + loggedIn = false; + } + if (!loggedIn && !this.reflector.get(IsPublic, context.getHandler())) { throw new AppException(ERROR_CODE.NOT_LOGGED_IN); } + // If the user is logged in, we verify that the application used is consistent with the given application in header + if (loggedIn && request.user.applicationId !== applicationId) { + throw new AppException(ERROR_CODE.INCONSISTENT_APPLICATION); + } + if (!loggedIn) { + request.user = { applicationId, permissions: {} } satisfies RequestAuthData; + } + // We can serve the request + return true; } } diff --git a/src/auth/guard/permission.guard.ts b/src/auth/guard/permission.guard.ts index bd5cd17..aa91e37 100644 --- a/src/auth/guard/permission.guard.ts +++ b/src/auth/guard/permission.guard.ts @@ -1,8 +1,8 @@ import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common'; import { Reflector } from '@nestjs/core'; -import { User } from '../../users/interfaces/user.interface'; import { findRequiredPermissions } from '../decorator'; import { AppException, ERROR_CODE } from '../../exceptions'; +import { RequestAuthData } from '../interfaces/request-auth-data.interface'; @Injectable() export class PermissionGuard implements CanActivate { @@ -12,13 +12,13 @@ export class PermissionGuard implements CanActivate { const requiredPermissions = findRequiredPermissions(this.reflector, context); // If there is no required permission, serve the route if (!requiredPermissions || !requiredPermissions.length) return true; - const user = context.switchToHttp().getRequest().user as User; + const permissions = (context.switchToHttp().getRequest().user as RequestAuthData)?.permissions; // Check whether the user is logged in - if (!user) throw new AppException(ERROR_CODE.NOT_LOGGED_IN); - // If the user has one of the needed permissions, serve the request + if (!permissions) throw new AppException(ERROR_CODE.NOT_LOGGED_IN); + // If the user doesn't have one of the needed permissions, throw an error ; else, serve the request for (const requiredPermission of requiredPermissions) - if (user.permissions.includes(requiredPermission)) return true; - // The user has none of the required permissions, throw an error - throw new AppException(ERROR_CODE.FORBIDDEN_NOT_ENOUGH_PERMISSIONS, requiredPermissions[0]); + if (permissions[requiredPermission] !== '*') + throw new AppException(ERROR_CODE.FORBIDDEN_NOT_ENOUGH_PERMISSIONS, requiredPermissions[0]); + return true; } } diff --git a/src/auth/guard/role.guard.ts b/src/auth/guard/role.guard.ts index 87499e5..584f5e1 100644 --- a/src/auth/guard/role.guard.ts +++ b/src/auth/guard/role.guard.ts @@ -1,8 +1,8 @@ import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common'; import { Reflector } from '@nestjs/core'; -import { User } from '../../users/interfaces/user.interface'; import { findRequiredUserTypes } from '../decorator'; import { AppException, ERROR_CODE } from '../../exceptions'; +import { RequestAuthData } from '../interfaces/request-auth-data.interface'; @Injectable() export class RoleGuard implements CanActivate { @@ -12,7 +12,7 @@ export class RoleGuard implements CanActivate { const requiredTypes = findRequiredUserTypes(this.reflector, context); // If there is no required userType, serve the route if (!requiredTypes || !requiredTypes.length) return true; - const user = context.switchToHttp().getRequest().user as User; + const user = (context.switchToHttp().getRequest().user as RequestAuthData).user; // Check whether the user is logged in if (!user) throw new AppException(ERROR_CODE.NOT_LOGGED_IN); // If the user has one of the needed permissions, serve the request diff --git a/src/auth/interfaces/request-auth-data.interface.ts b/src/auth/interfaces/request-auth-data.interface.ts new file mode 100644 index 0000000..c298abf --- /dev/null +++ b/src/auth/interfaces/request-auth-data.interface.ts @@ -0,0 +1,12 @@ +import { User } from '../../users/interfaces/user.interface'; +import { Permission } from '@prisma/client'; + +export interface RequestAuthData { + applicationId: string; + user?: User; + permissions: RequestPermissions; +} + +export type RequestPermissions = { + [k in Permission]?: '*' | string[]; +}; diff --git a/src/auth/strategy/jwt.strategy.ts b/src/auth/strategy/jwt.strategy.ts index 14ec5c8..3c403be 100644 --- a/src/auth/strategy/jwt.strategy.ts +++ b/src/auth/strategy/jwt.strategy.ts @@ -2,8 +2,8 @@ import { Injectable } from '@nestjs/common'; import { PassportStrategy } from '@nestjs/passport'; import { ExtractJwt, Strategy } from 'passport-jwt'; import { PrismaService } from '../../prisma/prisma.service'; -import { User } from '../../users/interfaces/user.interface'; import { ConfigModule } from '../../config/config.module'; +import { RequestAuthData } from '../interfaces/request-auth-data.interface'; @Injectable() export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') { @@ -14,11 +14,43 @@ export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') { }); } - async validate(payload: { sub: string; login: string }): Promise { - return await this.prisma.user.findUnique({ + async validate(payload: { token: string }): Promise { + const apiKey = await this.prisma.apiKey.findUnique({ where: { - id: payload.sub, + token: payload.token, + }, + include: { + apiKeyPermissions: { + include: { + grants: true, + }, + }, + }, + }); + if (!apiKey) return null; + const user = await this.prisma.user.findUnique({ + where: { + id: apiKey.userId, }, }); + const permissions: RequestAuthData['permissions'] = {}; + for (const permission of apiKey.apiKeyPermissions) { + if (permissions[permission.permission] === '*') { + continue; + } + if (!permission.soft) { + permissions[permission.permission] = '*'; + } else { + if (!permissions[permission.permission]) { + permissions[permission.permission] = []; + } + (permissions[permission.permission] as string[]).push(...permission.grants.map((grant) => grant.userId)); + } + } + return { + applicationId: apiKey.applicationId, + user, + permissions, + }; } } diff --git a/src/exceptions.ts b/src/exceptions.ts index d833183..fd44356 100644 --- a/src/exceptions.ts +++ b/src/exceptions.ts @@ -14,6 +14,8 @@ import { HttpException, HttpStatus } from '@nestjs/common'; */ export const enum ERROR_CODE { NOT_LOGGED_IN = 1001, + APPLICATION_HEADER_MISSING = 1002, + INCONSISTENT_APPLICATION = 1003, PARAM_DOES_NOT_EXIST = 2001, PARAM_MALFORMED = 2002, PARAM_MISSING = 2003, @@ -80,6 +82,14 @@ export const ErrorData = Object.freeze({ message: 'You must be logged in to access this resource', httpCode: HttpStatus.UNAUTHORIZED, }, + [ERROR_CODE.APPLICATION_HEADER_MISSING]: { + message: 'You should specify your application ID in the X-Application header', + httpCode: HttpStatus.BAD_REQUEST, + }, + [ERROR_CODE.INCONSISTENT_APPLICATION]: { + message: 'The application used to log in is different from the application given in the X-Application header', + httpCode: HttpStatus.CONFLICT, + }, [ERROR_CODE.PARAM_DOES_NOT_EXIST]: { message: 'The parameter % does not exist', httpCode: HttpStatus.BAD_REQUEST, diff --git a/src/main.ts b/src/main.ts index 40b551f..221df04 100644 --- a/src/main.ts +++ b/src/main.ts @@ -3,7 +3,7 @@ import { VersioningType } from '@nestjs/common'; import { DocumentBuilder, OpenAPIObject, SwaggerModule } from '@nestjs/swagger'; import { AppModule } from './app.module'; import { AppValidationPipe } from './app.pipe'; -import './array'; +import './std.type'; async function bootstrap() { const app = await NestFactory.create(AppModule); diff --git a/src/prisma/types.ts b/src/prisma/types.ts index 6021916..ae1e954 100644 --- a/src/prisma/types.ts +++ b/src/prisma/types.ts @@ -32,6 +32,7 @@ export { AssoMembership as RawAssoMembership, UserHomepageWidget as RawHomepageWidget, UserPrivacy as RawUserPrivacy, + ApiApplication as RawApiApplication } from '@prisma/client'; export { RawTranslation }; diff --git a/src/array.ts b/src/std.type.ts similarity index 65% rename from src/array.ts rename to src/std.type.ts index a6948ed..2b010fe 100644 --- a/src/array.ts +++ b/src/std.type.ts @@ -1,7 +1,7 @@ declare global { interface Array { /** - * Sorts the current array and returns it. + * Sorts the current array (in place) and returns it. * Array is sorted based on a mapper function, that returns in order the values by which to sort the array. * @example * const array = [ @@ -20,6 +20,22 @@ declare global { * The length of the array should be fixed, not dependent on the value to map. */ mappedSort(mapper: (e: T) => any[] | any): this; + + /** + * Creates a new array containing the same values as the original array, but removing duplicates. + * The order is not changed. A duplicate gets the position where it was found first. + * The original array is not modified. + * @example + * const array = [1, 2, 3, 3, 2, 5, 2, 6]; + * array.unique(); + * // Result : + * // [1, 2, 3, 5, 6] + */ + unique(): Array; + } + + interface ObjectConstructor { + keys(o: O): (keyof O)[]; } } @@ -41,4 +57,8 @@ Array.prototype.mappedSort = function (this: Array, mapper: (e: T) => any[ }); }; +Array.prototype.unique = function (this: Array) { + return this.reduce((acc, curr) => (acc.includes(curr) ? acc : [...acc, curr]), [] as T[]); +}; + export {}; diff --git a/src/ue/annals/annals.controller.ts b/src/ue/annals/annals.controller.ts index 76e96ad..e588924 100644 --- a/src/ue/annals/annals.controller.ts +++ b/src/ue/annals/annals.controller.ts @@ -16,6 +16,9 @@ import { ApiBody, ApiConsumes, ApiOkResponse, ApiOperation, ApiTags } from '@nes import { ApiAppErrorResponse } from '../../app.dto'; import UeAnnalResDto from './dto/res/ue-annal-res.dto'; import UeAnnalMetadataResDto from './dto/res/ue-annal-metadata-res.dto'; +import { GetPermissions } from '../../auth/decorator/get-permissions.decorator'; +import { RequestPermissions } from '../../auth/interfaces/request-auth-data.interface'; +import { Permission } from '@prisma/client'; @Controller('ue/annals') @ApiTags('Annal') @@ -27,9 +30,13 @@ export class AnnalsController { @ApiOperation({ description: 'Get the list of annals of a UE.' }) @ApiOkResponse({ type: UeAnnalResDto, isArray: true }) @ApiAppErrorResponse(ERROR_CODE.NO_SUCH_UE, 'Thrown when there is no UE with code `ueCode`.') - async getUeAnnalList(@Query() { ueCode }: GetFromUeReqDto, @GetUser() user: User): Promise { + async getUeAnnalList( + @Query() { ueCode }: GetFromUeReqDto, + @GetUser() user: User, + @GetPermissions() permissions: RequestPermissions, + ): Promise { if (!(await this.ueService.doesUeExist(ueCode))) throw new AppException(ERROR_CODE.NO_SUCH_UE, ueCode); - return this.annalsService.getUEAnnalsList(user, ueCode, user.permissions.includes('annalModerator')); + return this.annalsService.getUEAnnalsList(user, ueCode, permissions[Permission.API_MODERATE_ANNAL] === '*'); } @Post() @@ -51,12 +58,13 @@ export class AnnalsController { async createUeAnnal( @Body() { ueCode, semester, typeId }: CreateAnnalReqDto, @GetUser() user: User, + @GetPermissions() permissions: RequestPermissions, ): Promise { if (!(await this.ueService.doesUeExist(ueCode))) throw new AppException(ERROR_CODE.NO_SUCH_UE, ueCode); if (!(await this.annalsService.doesAnnalTypeExist(typeId))) throw new AppException(ERROR_CODE.NO_SUCH_ANNAL_TYPE); if ( !(await this.ueService.hasDoneThisUeInSemester(user.id, ueCode, semester)) && - !user.permissions.includes('annalUploader') + permissions[Permission.API_UPLOAD_ANNAL] !== '*' ) throw new AppException(ERROR_CODE.NOT_DONE_UE_IN_SEMESTER, ueCode, semester); return this.annalsService.createAnnalFile(user, { ueCode, semester, typeId }); @@ -78,11 +86,12 @@ export class AnnalsController { async getUeAnnalMetadata( @Query() { ueCode }: GetFromUeReqDto, @GetUser() user: User, + @GetPermissions() permissions: RequestPermissions, ): Promise { if (!(await this.ueService.doesUeExist(ueCode))) throw new AppException(ERROR_CODE.NO_SUCH_UE, ueCode); - if (!(await this.ueService.hasAlreadyDoneThisUe(user.id, ueCode)) && !user.permissions.includes('annalUploader')) + if (!(await this.ueService.hasAlreadyDoneThisUe(user.id, ueCode)) && permissions[Permission.API_UPLOAD_ANNAL] !== '*') throw new AppException(ERROR_CODE.NOT_ALREADY_DONE_UE); - return this.annalsService.getUEAnnalMetadata(user, ueCode, user.permissions.includes('annalUploader')); + return this.annalsService.getUEAnnalMetadata(user, ueCode, permissions[Permission.API_UPLOAD_ANNAL] === '*'); } @Put(':annalId') @@ -108,11 +117,12 @@ export class AnnalsController { @Param('annalId') annalId: string, @Query() { rotate }: UploadAnnalReqDto, @GetUser() user: User, + @GetPermissions() permissions: RequestPermissions, ) { if (!(await this.annalsService.isUeAnnalSender(user.id, annalId))) throw new AppException(ERROR_CODE.NOT_ANNAL_SENDER); if ( - (await this.annalsService.getUEAnnal(annalId, user.id, user.permissions.includes('annalModerator'))).status !== + (await this.annalsService.getUEAnnal(annalId, user.id, permissions[Permission.API_MODERATE_ANNAL] === '*')).status !== CommentStatus.PROCESSING ) throw new AppException(ERROR_CODE.ANNAL_ALREADY_UPLOADED); @@ -131,13 +141,20 @@ export class AnnalsController { @UUIDParam('annalId') annalId: string, @GetUser() user: User, @Response() response: ExpressResponse, + @GetPermissions() permissions: RequestPermissions, ) { - if (!(await this.annalsService.isAnnalAccessible(user.id, annalId, user.permissions.includes('annalModerator')))) + if ( + !(await this.annalsService.isAnnalAccessible( + user.id, + annalId, + permissions[Permission.API_MODERATE_ANNAL] === '*', + )) + ) throw new AppException(ERROR_CODE.NO_SUCH_ANNAL, annalId); const annalFile = await this.annalsService.getUEAnnalFile( annalId, user.id, - user.permissions.includes('annalModerator'), + permissions[Permission.API_MODERATE_ANNAL] === '*', ); if (!annalFile) throw new AppException(ERROR_CODE.NO_SUCH_ANNAL, annalId); response.setHeader('Content-Type', 'application/pdf'); @@ -161,10 +178,20 @@ export class AnnalsController { @UUIDParam('annalId') annalId: string, @Body() body: UpdateAnnalReqDto, @GetUser() user: User, + @GetPermissions() permissions: RequestPermissions, ): Promise { - if (!(await this.annalsService.isAnnalAccessible(user.id, annalId, user.permissions.includes('annalModerator')))) + if ( + !(await this.annalsService.isAnnalAccessible( + user.id, + annalId, + permissions[Permission.API_MODERATE_ANNAL] === '*', + )) + ) throw new AppException(ERROR_CODE.NO_SUCH_ANNAL, annalId); - if (!(await this.annalsService.isUeAnnalSender(user.id, annalId)) && !user.permissions.includes('annalModerator')) + if ( + !(await this.annalsService.isUeAnnalSender(user.id, annalId)) && + permissions[Permission.API_MODERATE_ANNAL] !== '*' + ) throw new AppException(ERROR_CODE.NOT_ANNAL_SENDER); return this.annalsService.updateAnnalMetadata(annalId, body); } @@ -178,10 +205,23 @@ export class AnnalsController { @ApiOkResponse({ type: UeAnnalResDto }) @ApiAppErrorResponse(ERROR_CODE.NO_SUCH_ANNAL) @ApiAppErrorResponse(ERROR_CODE.NOT_ANNAL_SENDER) - async deleteUeAnnal(@UUIDParam('annalId') annalId: string, @GetUser() user: User) { - if (!(await this.annalsService.isAnnalAccessible(user.id, annalId, user.permissions.includes('annalModerator')))) + async deleteUeAnnal( + @UUIDParam('annalId') annalId: string, + @GetUser() user: User, + @GetPermissions() permissions: RequestPermissions, + ) { + if ( + !(await this.annalsService.isAnnalAccessible( + user.id, + annalId, + permissions[Permission.API_MODERATE_ANNAL] === '*', + )) + ) throw new AppException(ERROR_CODE.NO_SUCH_ANNAL, annalId); - if (!(await this.annalsService.isUeAnnalSender(user.id, annalId)) && !user.permissions.includes('annalModerator')) + if ( + !(await this.annalsService.isUeAnnalSender(user.id, annalId)) && + permissions[Permission.API_MODERATE_ANNAL] !== '*' + ) throw new AppException(ERROR_CODE.NOT_ANNAL_SENDER); return this.annalsService.deleteAnnal(annalId); } diff --git a/src/ue/comments/comments.controller.ts b/src/ue/comments/comments.controller.ts index 88725d3..0854fed 100644 --- a/src/ue/comments/comments.controller.ts +++ b/src/ue/comments/comments.controller.ts @@ -14,6 +14,9 @@ import { ApiOkResponse, ApiOperation, ApiTags } from '@nestjs/swagger'; import { ApiAppErrorResponse, paginatedResponseDto } from '../../app.dto'; import { UeCommentUpvoteResDto$False, UeCommentUpvoteResDto$True } from './dto/res/ue-comment-upvote-res.dto'; import UeCommentReplyResDto from './dto/res/ue-comment-reply-res.dto'; +import { Permission } from '@prisma/client'; +import { GetPermissions } from '../../auth/decorator/get-permissions.decorator'; +import { RequestPermissions } from '../../auth/interfaces/request-auth-data.interface'; @Controller('ue/comments') @ApiTags('UE Comment') @@ -28,9 +31,13 @@ export class CommentsController { ERROR_CODE.NO_SUCH_UE, 'This error is sent back when there is no UE associated with the code provided.', ) - async getUEComments(@GetUser() user: User, @Query() dto: GetUeCommentsReqDto): Promise> { + async getUEComments( + @GetUser() user: User, + @Query() dto: GetUeCommentsReqDto, + @GetPermissions() permissions: RequestPermissions, + ): Promise> { if (!(await this.ueService.doesUeExist(dto.ueCode))) throw new AppException(ERROR_CODE.NO_SUCH_UE, dto.ueCode); - return this.commentsService.getComments(user.id, dto, user.permissions.includes('commentModerator')); + return this.commentsService.getComments(user.id, dto, permissions[Permission.API_MODERATE_COMMENTS] === '*'); } @Post() @@ -64,11 +71,15 @@ export class CommentsController { @ApiOperation({ description: 'Fetch a specific comment.' }) @ApiOkResponse({ type: UeCommentResDto }) @ApiAppErrorResponse(ERROR_CODE.NO_SUCH_COMMENT, 'No comment is associated with the given commentId') - async getUECommentFromId(@UUIDParam('commentId') commentId: string, @GetUser() user: User): Promise { + async getUECommentFromId( + @UUIDParam('commentId') commentId: string, + @GetUser() user: User, + @GetPermissions() permissions: RequestPermissions, + ): Promise { const comment = await this.commentsService.getCommentFromId( commentId, user.id, - user.permissions.includes('commentModerator'), + permissions[Permission.API_MODERATE_COMMENTS] === '*', ); if (!comment) throw new AppException(ERROR_CODE.NO_SUCH_COMMENT); return comment; @@ -87,8 +98,9 @@ export class CommentsController { @UUIDParam('commentId') commentId: string, @GetUser() user: User, @Body() body: UeCommentUpdateReqDto, + @GetPermissions() permissions: RequestPermissions, ): Promise { - const isCommentModerator = user.permissions.includes('commentModerator'); + const isCommentModerator = permissions[Permission.API_MODERATE_COMMENTS] === '*'; if (!(await this.commentsService.doesCommentExist(commentId, user.id, isCommentModerator, isCommentModerator))) throw new AppException(ERROR_CODE.NO_SUCH_COMMENT); if (isCommentModerator || (await this.commentsService.isUserCommentAuthor(user.id, commentId))) @@ -107,8 +119,12 @@ export class CommentsController { ERROR_CODE.NOT_COMMENT_AUTHOR, 'The user is not the author of the comment and does not have the `commentModerator` permission.', ) - async DiscardUEComment(@UUIDParam('commentId') commentId: string, @GetUser() user: User): Promise { - const isCommentModerator = user.permissions.includes('commentModerator'); + async DiscardUEComment( + @UUIDParam('commentId') commentId: string, + @GetUser() user: User, + @GetPermissions() permissions: RequestPermissions, + ): Promise { + const isCommentModerator = permissions[Permission.API_MODERATE_COMMENTS] === '*'; if (!(await this.commentsService.doesCommentExist(commentId, user.id, isCommentModerator))) throw new AppException(ERROR_CODE.NO_SUCH_COMMENT); if ((await this.commentsService.isUserCommentAuthor(user.id, commentId)) || isCommentModerator) @@ -132,8 +148,9 @@ export class CommentsController { async UpvoteUEComment( @UUIDParam('commentId') commentId: string, @GetUser() user: User, + @GetPermissions() permissions: RequestPermissions, ): Promise { - const commentModerator = user.permissions.includes('commentModerator'); + const commentModerator = permissions[Permission.API_MODERATE_COMMENTS] === '*'; if (!(await this.commentsService.doesCommentExist(commentId, user.id, commentModerator, commentModerator))) throw new AppException(ERROR_CODE.NO_SUCH_COMMENT); if (await this.commentsService.isUserCommentAuthor(user.id, commentId)) @@ -157,11 +174,12 @@ export class CommentsController { async UnUpvoteUEComment( @UUIDParam('commentId') commentId: string, @GetUser() user: User, + @GetPermissions() permissions: RequestPermissions, ): Promise { - const commentModerator = user.permissions.includes('commentModerator'); + const commentModerator = permissions[Permission.API_MODERATE_COMMENTS] === '*'; if (!(await this.commentsService.doesCommentExist(commentId, user.id, commentModerator, commentModerator))) throw new AppException(ERROR_CODE.NO_SUCH_COMMENT); - // TODO : on est d'accord qu'on peut virer cette condition ? Puisque de toutes manières l'utilisateur ne peut pas mettre un comment. + // TODO : on est d'accord qu'on peut virer cette condition ? Puisque de toutes manières l'utilisateur ne peut pas mettre un upvote. if (await this.commentsService.isUserCommentAuthor(user.id, commentId)) throw new AppException(ERROR_CODE.IS_COMMENT_AUTHOR); if (!(await this.commentsService.hasAlreadyUpvoted(user.id, commentId))) @@ -179,8 +197,9 @@ export class CommentsController { @GetUser() user: User, @UUIDParam('commentId') commentId: string, @Body() body: CommentReplyReqDto, + @GetPermissions() permissions: RequestPermissions, ): Promise { - const isCommentModerator = user.permissions.includes('commentModerator'); + const isCommentModerator = permissions[Permission.API_MODERATE_COMMENTS] === '*'; if (!(await this.commentsService.doesCommentExist(commentId, user.id, isCommentModerator, isCommentModerator))) throw new AppException(ERROR_CODE.NO_SUCH_COMMENT); return this.commentsService.replyComment(user.id, commentId, body); @@ -199,11 +218,12 @@ export class CommentsController { @GetUser() user: User, @UUIDParam('replyId') replyId: string, @Body() body: CommentReplyReqDto, + @GetPermissions() permissions: RequestPermissions, ): Promise { if (!(await this.commentsService.doesReplyExist(replyId))) throw new AppException(ERROR_CODE.NO_SUCH_REPLY); if ( (await this.commentsService.isUserCommentReplyAuthor(user.id, replyId)) || - user.permissions.includes('commentModerator') + permissions[Permission.API_MODERATE_COMMENTS] === '*' ) return this.commentsService.editReply(replyId, body); throw new AppException(ERROR_CODE.NOT_REPLY_AUTHOR); @@ -221,11 +241,12 @@ export class CommentsController { async DeleteReplyComment( @GetUser() user: User, @UUIDParam('replyId') replyId: string, + @GetPermissions() permissions: RequestPermissions, ): Promise { if (!(await this.commentsService.doesReplyExist(replyId))) throw new AppException(ERROR_CODE.NO_SUCH_REPLY); if ( (await this.commentsService.isUserCommentReplyAuthor(user.id, replyId)) || - user.permissions.includes('commentModerator') + permissions[Permission.API_MODERATE_COMMENTS] === '*' ) return this.commentsService.deleteReply(replyId); throw new AppException(ERROR_CODE.NOT_REPLY_AUTHOR); diff --git a/src/users/dto/res/user-overview-res.dto.ts b/src/users/dto/res/user-overview-res.dto.ts index 25a6472..7e240dd 100644 --- a/src/users/dto/res/user-overview-res.dto.ts +++ b/src/users/dto/res/user-overview-res.dto.ts @@ -1,5 +1,5 @@ import { ApiProperty } from '@nestjs/swagger'; -import { Sex, UserType } from '@prisma/client'; +import { Permission, Sex, UserType } from '@prisma/client'; export default class UserOverviewResDto { id: string; @@ -7,7 +7,8 @@ export default class UserOverviewResDto { lastName: string; login: string; studentId: number; - permissions: string[]; + @ApiProperty({ enum: Permission }) + permissions: Permission[]; @ApiProperty({ enum: UserType }) userType: UserType; infos: UserOverviewResDto_Infos; diff --git a/src/users/interfaces/user.interface.ts b/src/users/interfaces/user.interface.ts index 9403848..3ec9e16 100644 --- a/src/users/interfaces/user.interface.ts +++ b/src/users/interfaces/user.interface.ts @@ -1,6 +1,7 @@ -import { Prisma, PrismaClient } from '@prisma/client'; +import { Permission, Prisma, PrismaClient } from '@prisma/client'; import { generateCustomModel, RequestType } from '../../prisma/prisma.service'; import { Translation } from '../../prisma/types'; +import { omit } from '../../utils'; const USER_SELECT_FILTER = { select: { @@ -21,11 +22,6 @@ const USER_SELECT_FILTER = { nationality: true, }, }, - permissions: { - select: { - userPermissionId: true, - }, - }, branchSubscriptions: { select: { semesterNumber: true, @@ -97,23 +93,47 @@ const USER_SELECT_FILTER = { timetable: true, }, }, + apiKeys: { + select: { + id: true, + apiKeyPermissions: { + select: { + permission: true, + soft: true, + grants: { + select: { + userId: true, + }, + }, + }, + }, + }, + }, }, orderBy: [{ lastName: 'asc' }, { firstName: 'asc' }], } satisfies Partial>; type UnformattedUser = Prisma.UserGetPayload; -export type User = Omit & { - permissions: string[]; +export type User = Omit & { + permissions: { [k: string]: { [p in Permission]?: '*' | string[] } }; }; export const generateCustomUserModel = (prisma: PrismaClient) => generateCustomModel(prisma, 'user', USER_SELECT_FILTER, formatUser); -export function formatUser(_: PrismaClient, user: UnformattedUser): User { - return { - ...user, - permissions: user.permissions.map((permission) => permission.userPermissionId), - }; +function formatUser(_, user: UnformattedUser) { + const permissions: User['permissions'] = {}; + for (const apiKey of user.apiKeys) { + permissions[apiKey.id] = {}; + for (const permission of apiKey.apiKeyPermissions) { + if (!permission.soft) { + permissions[apiKey.id][permission.permission] = '*'; + } else { + permissions[apiKey.id][permission.permission] = permission.grants.map((p) => p.userId); + } + } + } + return { ...omit(user, 'apiKeys'), permissions }; } export type UserAssoMembership = { diff --git a/src/users/users.controller.ts b/src/users/users.controller.ts index 1e66609..ed0bb82 100644 --- a/src/users/users.controller.ts +++ b/src/users/users.controller.ts @@ -110,7 +110,6 @@ export default class UsersController { lastName: user.lastName, login: user.login, studentId: user.studentId, - permissions: user.permissions, userType: user.userType, infos: { ...pick(user.infos, 'nickname', 'avatar', 'nationality', 'passions', 'website'), @@ -141,6 +140,12 @@ export default class UsersController { country: address.country, })) : [], + permissions: Object.values(user.permissions) + .map((apiKeyPermissions) => + Object.keys(apiKeyPermissions).filter((permission) => apiKeyPermissions[permission] === '*'), + ) + .flat() + .unique(), }; } diff --git a/src/validation.ts b/src/validation.ts index b4a89a6..949cfef 100644 --- a/src/validation.ts +++ b/src/validation.ts @@ -47,7 +47,7 @@ export const validationExceptionFactory = (errors: ValidationError[]) => { for (const [constraint, error] of Object.entries(mappedErrors)) { if (constraint in errorsByType) return new AppException(error, errorsByType[constraint].sort().join(', ')); } - console.log(errors); // TODO : send to sentry. soon™ + console.error(errors); // TODO : send to sentry. soon™ // If errors are not registered in the mappedErrors object, throw a generic error return new AppException( ERROR_CODE.PARAM_MALFORMED, diff --git a/test/declarations.d.ts b/test/declarations.d.ts index 3ef3a8a..37a60f9 100644 --- a/test/declarations.d.ts +++ b/test/declarations.d.ts @@ -81,5 +81,7 @@ declare module './declarations' { expectCreditCategories(categories: JsonLikeVariant): this; withLanguage(language: Language): this; language: Language; + withApplication(application: string): this; + application: string; } } diff --git a/test/declarations.ts b/test/declarations.ts index cfc09f6..a1b9d87 100644 --- a/test/declarations.ts +++ b/test/declarations.ts @@ -9,7 +9,7 @@ import { UeRating } from 'src/ue/interfaces/rate.interface'; import { FakeUeAnnalType, FakeUser, FakeUe, FakeHomepageWidget, FakeAsso, FakeUeCreditCategory } from './utils/fakedb'; import { UeAnnalFile } from 'src/ue/annals/interfaces/annal.interface'; import { ConfigModule } from '../src/config/config.module'; -import { AppProvider } from './utils/test_utils'; +import {AppProvider, DEFAULT_APPLICATION} from './utils/test_utils'; import { getTranslation, omit, pick } from '../src/utils'; import { isArray } from 'class-validator'; import { Language } from '@prisma/client'; @@ -55,11 +55,22 @@ function ueOverviewExpectation(ue: FakeUe, spec: Spec) { }; } +const baseToss = Spec.prototype.toss; + Spec.prototype.language = 'fr'; Spec.prototype.withLanguage = function (language: Language) { this.language = language; return this; }; +Spec.prototype.application = DEFAULT_APPLICATION; +Spec.prototype.withApplication = function (application: string) { + this.application = application; + return this; +}; +Spec.prototype.toss = function () { + (this).withHeaders('X-Language', (this).language).withHeaders('X-Application', (this).application); + return baseToss.call(this); +}; Spec.prototype.expectAppError = function ( errorCode: ErrorCode, ...args: ExtrasTypeBuilder<(typeof ErrorData)[ErrorCode]['message']> diff --git a/test/e2e/app.e2e-spec.ts b/test/e2e/app.e2e-spec.ts index 1267265..af567fc 100644 --- a/test/e2e/app.e2e-spec.ts +++ b/test/e2e/app.e2e-spec.ts @@ -1,5 +1,5 @@ import '../declarations'; -import '../../src/array'; +import '../../src/std.type'; import * as testUtils from '../utils/test_utils'; import { INestApplication, VersioningType } from '@nestjs/common'; import { Test } from '@nestjs/testing'; diff --git a/test/e2e/auth/cas-sign-in.e2e-spec.ts b/test/e2e/auth/cas-sign-in.e2e-spec.ts index e0e69a2..234f5ce 100644 --- a/test/e2e/auth/cas-sign-in.e2e-spec.ts +++ b/test/e2e/auth/cas-sign-in.e2e-spec.ts @@ -6,9 +6,10 @@ import { ERROR_CODE } from '../../../src/exceptions'; import { string } from 'pactum-matchers'; import { JwtService } from '@nestjs/jwt'; import { PrismaService } from '../../../src/prisma/prisma.service'; +import AuthCasSignInReqDto from '../../../src/auth/dto/req/auth-cas-sign-in-req.dto'; -const CasSignInE2ESpec = e2eSuite('/auth/signin/cas', (app) => { - const body = { service: cas.validService, ticket: cas.validTicket }; +const CasSignInE2ESpec = e2eSuite('POST /auth/signin/cas', (app) => { + const body: AuthCasSignInReqDto = { service: cas.validService, ticket: cas.validTicket, tokenExpiresIn: 1000 }; it('should fail as provided service is not valid', async () => { await pactum diff --git a/test/e2e/auth/cas-sign-up.e2e-spec.ts b/test/e2e/auth/cas-sign-up.e2e-spec.ts index 3e39925..25831c0 100644 --- a/test/e2e/auth/cas-sign-up.e2e-spec.ts +++ b/test/e2e/auth/cas-sign-up.e2e-spec.ts @@ -3,7 +3,7 @@ import * as pactum from 'pactum'; import { faker } from '@faker-js/faker'; import { JwtService } from '@nestjs/jwt'; import * as fakedb from '../../utils/fakedb'; -import { AuthService, RegisterData } from '../../../src/auth/auth.service'; +import { AuthService, RegisterUserData } from '../../../src/auth/auth.service'; import { pick } from '../../../src/utils'; import { PrismaService } from '../../../src/prisma/prisma.service'; import { FakeUser } from '../../utils/fakedb'; @@ -44,7 +44,7 @@ const CasSignUpE2ESpec = e2eSuite('POST /auth/signup/cas', (app) => { pactum .spec() .post('/auth/signup/cas') - .withJson({ registerToken: faker.random.alpha() }) + .withJson({ registerToken: faker.random.alpha(), tokenExpiresIn: 1000 }) .expectAppError(ERROR_CODE.INVALID_TOKEN_FORMAT)); it('should fail as the provided token does not contains an object in the right form', async () => { @@ -54,7 +54,7 @@ const CasSignUpE2ESpec = e2eSuite('POST /auth/signup/cas', (app) => { pactum .spec() .post('/auth/signup/cas') - .withJson({ registerToken: token }) + .withJson({ registerToken: token, tokenExpiresIn: 1000 }) .expectAppError(ERROR_CODE.INVALID_TOKEN_FORMAT); }); @@ -64,12 +64,13 @@ const CasSignUpE2ESpec = e2eSuite('POST /auth/signup/cas', (app) => { .spec() .post('/auth/signup/cas') .withJson({ - registerToken: app() + registerToken: await app() .get(AuthService) .signRegisterToken({ ...pick(user as Required, 'login', 'firstName', 'lastName'), mail: faker.internet.email(), }), + tokenExpiresIn: 1000, }) .expectAppError(ERROR_CODE.CREDENTIALS_ALREADY_TAKEN); await app() @@ -77,10 +78,10 @@ const CasSignUpE2ESpec = e2eSuite('POST /auth/signup/cas', (app) => { .user.delete({ where: { id: user.id } }); }); - const executeValidSignupRequest = (type: string) => { + const executeValidSignupRequest = async (type: string) => { const firstName = faker.name.firstName(); const lastName = faker.name.lastName(); - const userData: RegisterData = { + const userData: RegisterUserData = { login: `${lastName.toLowerCase().slice(0, 7)}${firstName.toLowerCase()}`.slice(0, 8), mail: `${firstName.toLowerCase()}.${lastName.toLowerCase()}@utt.fr`, firstName, @@ -111,7 +112,7 @@ const CasSignUpE2ESpec = e2eSuite('POST /auth/signup/cas', (app) => { return pactum .spec() .post('/auth/signup/cas') - .withJson({ registerToken: app().get(AuthService).signRegisterToken(userData) }) + .withJson({ registerToken: await app().get(AuthService).signRegisterToken(userData), tokenExpiresIn: 1000 }) .expectStatus(HttpStatus.CREATED) .expectJsonMatch({ access_token: string() }); // TODO : test that the user has been created, along with all its data diff --git a/test/e2e/auth/signin-e2e-spec.ts b/test/e2e/auth/signin-e2e-spec.ts index 414647b..0a7af9f 100644 --- a/test/e2e/auth/signin-e2e-spec.ts +++ b/test/e2e/auth/signin-e2e-spec.ts @@ -8,6 +8,7 @@ const SignInE2ESpec = e2eSuite('POST /auth/signin', (app) => { const dto = { login: 'testLogin', password: 'testPassword', + tokenExpiresIn: 1000, } as AuthSignInDto; fakedb.createUser(app, dto); diff --git a/test/e2e/auth/signup-e2e-spec.ts b/test/e2e/auth/signup-e2e-spec.ts index 8dc27d0..eeeb886 100644 --- a/test/e2e/auth/signup-e2e-spec.ts +++ b/test/e2e/auth/signup-e2e-spec.ts @@ -4,6 +4,9 @@ import { PrismaService } from '../../../src/prisma/prisma.service'; import { e2eSuite } from '../../utils/test_utils'; import { ERROR_CODE } from '../../../src/exceptions'; import { UserType } from '@prisma/client'; +import { createUser } from '../../utils/fakedb'; +import { AuthService } from '../../../src/auth/auth.service'; +import { JwtService } from '@nestjs/jwt'; const SignupE2ESpec = e2eSuite('POST /auth/signup', (app) => { const dto = { @@ -14,6 +17,7 @@ const SignupE2ESpec = e2eSuite('POST /auth/signup', (app) => { studentId: 44250, sex: 'OTHER', birthday: new Date('1999-01-01'), + tokenExpiresIn: 1000, } as AuthSignUpReqDto; it('should return a 400 if login is missing', async () => { @@ -90,7 +94,19 @@ const SignupE2ESpec = e2eSuite('POST /auth/signup', (app) => { .expectAppError(ERROR_CODE.PARAM_MISSING, 'firstName, lastName, login, password'); }); it('should create a new user', async () => { - await pactum.spec().post('/auth/signup').withBody(dto).expectBodyContains('access_token').expectStatus(201); + await pactum + .spec() + .post('/auth/signup') + .withBody(dto) + .expectStatus(201) + .expect(async (ctx) => { + expect(ctx.res.json['access_token']).toBeDefined(); + const token = app().get(JwtService).decode(ctx.res.json['access_token']).token; + const apiKey = await app() + .get(PrismaService) + .apiKey.findFirst({ where: { user: { login: dto.login } } }); + expect(token).toEqual(apiKey.token); + }); const user = await app() .get(PrismaService) .user.findUnique({ where: { login: dto.login } }); @@ -103,10 +119,17 @@ const SignupE2ESpec = e2eSuite('POST /auth/signup', (app) => { expect(user.infos.birthday).toEqual(dto.birthday); expect(user.userType).toEqual(UserType.OTHER); expect(user.id).toMatch(/[a-z0-9-]{36}/); + await app() + .get(PrismaService) + .user.delete({ where: { id: user.id } }); }); it('should fail as the credentials are already used', async () => { + const user = await createUser(app, { login: dto.login }, true); await pactum.spec().post('/auth/signup').withBody(dto).expectAppError(ERROR_CODE.CREDENTIALS_ALREADY_TAKEN); + await app() + .get(PrismaService) + .user.delete({ where: { id: user.id } }); }); }); diff --git a/test/e2e/auth/verify-e2e-spec.ts b/test/e2e/auth/verify-e2e-spec.ts index fd72adb..99e68ae 100644 --- a/test/e2e/auth/verify-e2e-spec.ts +++ b/test/e2e/auth/verify-e2e-spec.ts @@ -24,16 +24,12 @@ const VerifyE2ESpec = e2eSuite('GET /token/signin', (app) => { .expectBody({ valid: false })); it('should fail as the token has expired', async () => { - const config = app().get(ConfigModule); - const old_JWT_EXPIRES_IN_value = config.JWT_EXPIRES_IN; - Reflect.set(config, 'JWT_EXPIRES_IN', 0); // field is readonly, we need to use reflection - const token = await app().get(AuthService).signToken('abcdef', "it's me, mario"); + const token = await app().get(AuthService).signAuthenticationToken('abcdef', 0); await pactum.spec().get('/auth/signin').withBearerToken(token).expectStatus(200).expectBody({ valid: false }); - Reflect.set(config, 'JWT_EXPIRES_IN', old_JWT_EXPIRES_IN_value); }); it('should return that the token is valid', async () => { - const token = await app().get(AuthService).signToken('abcdef', "it's me, mario"); + const token = await app().get(AuthService).signAuthenticationToken('abcdef'); return pactum.spec().get('/auth/signin').withBearerToken(token).expectStatus(200).expectBody({ valid: true }); }); }); diff --git a/test/e2e/ue/annals/get-annal-metadata.e2e-spec.ts b/test/e2e/ue/annals/get-annal-metadata.e2e-spec.ts index 558d10e..5e50ef9 100644 --- a/test/e2e/ue/annals/get-annal-metadata.e2e-spec.ts +++ b/test/e2e/ue/annals/get-annal-metadata.e2e-spec.ts @@ -10,11 +10,12 @@ import { } from '../../../utils/fakedb'; import { e2eSuite } from '../../../utils/test_utils'; import { ERROR_CODE } from '../../../../src/exceptions'; +import {Permission} from "@prisma/client"; const GetAnnalMetadata = e2eSuite('GET /ue/annals/metadata', (app) => { const ueUser = createUser(app); const nonUeUser = createUser(app, { login: 'user2', studentId: 3 }); - const uploader = createUser(app, { login: 'user3', studentId: 4, permissions: ['annalUploader'] }); + const uploader = createUser(app, { login: 'user3', studentId: 4, permissions: [Permission.API_UPLOAD_ANNAL] }); const annalType = createAnnalType(app); const semester = createSemester(app); const branch = createBranch(app); diff --git a/test/e2e/ue/annals/get-annals.e2e-spec.ts b/test/e2e/ue/annals/get-annals.e2e-spec.ts index ded199e..cb33ca4 100644 --- a/test/e2e/ue/annals/get-annals.e2e-spec.ts +++ b/test/e2e/ue/annals/get-annals.e2e-spec.ts @@ -19,7 +19,7 @@ import { CommentStatus } from '../../../../src/ue/comments/interfaces/comment.in const GetAnnal = e2eSuite('GET /ue/annals', (app) => { const senderUser = createUser(app); const nonUeUser = createUser(app, { login: 'user2', studentId: 2 }); - const moderator = createUser(app, { login: 'user3', studentId: 3, permissions: ['annalModerator'] }); + const moderator = createUser(app, { login: 'user3', studentId: 3, permissions: ['API_MODERATE_ANNAL'] }); const nonStudentUser = createUser(app, { login: 'nonStudent', studentId: 4, userType: 'TEACHER' }); const annalType = createAnnalType(app); const semester = createSemester(app); diff --git a/test/e2e/ue/comments/get-comment.e2e-spec.ts b/test/e2e/ue/comments/get-comment.e2e-spec.ts index d11d766..0a13a62 100644 --- a/test/e2e/ue/comments/get-comment.e2e-spec.ts +++ b/test/e2e/ue/comments/get-comment.e2e-spec.ts @@ -18,7 +18,7 @@ import { PrismaService } from '../../../../src/prisma/prisma.service'; const GetCommentsE2ESpec = e2eSuite('GET /ue/comments', (app) => { const user = createUser(app); const user2 = createUser(app, { login: 'user2', studentId: 3 }); - const moderator = createUser(app, { login: 'user3', studentId: 3, permissions: ['commentModerator'] }); + const moderator = createUser(app, { login: 'user3', studentId: 3, permissions: ['API_MODERATE_COMMENTS'] }); const semester = createSemester(app); const branch = createBranch(app); const branchOption = createBranchOption(app, { branch }); diff --git a/test/e2e/users/search-e2e-spec.ts b/test/e2e/users/search-e2e-spec.ts index 018d140..c127b5e 100644 --- a/test/e2e/users/search-e2e-spec.ts +++ b/test/e2e/users/search-e2e-spec.ts @@ -7,7 +7,7 @@ import { ConfigModule } from '../../../src/config/config.module'; const SearchE2ESpec = e2eSuite('GET /users', (app) => { const user = fakedb.createUser(app, { lastName: 'zis is sad, i am last in the alphabet :(', - firstName: 'thi missing l3ttr', + firstName: 'thi mizing lettr', }); const randomUsers = []; @@ -38,7 +38,7 @@ const SearchE2ESpec = e2eSuite('GET /users', (app) => { it('should return both users by searching by their firstName', async () => { return pactum .spec() - .get('/users?firstName=e') + .get('/users?firstName=s') .withBearerToken(user.token) .expectUsers(app, randomUsers.slice(0, app().get(ConfigModule).PAGINATION_PAGE_SIZE), randomUsers.length); }); diff --git a/test/external_services/cas.ts b/test/external_services/cas.ts index 5d3089b..d65ce6b 100644 --- a/test/external_services/cas.ts +++ b/test/external_services/cas.ts @@ -2,11 +2,11 @@ import axios from 'axios'; import nock from 'nock'; import { HttpStatus } from '@nestjs/common'; import { faker } from '@faker-js/faker'; -import { RegisterData } from '../../src/auth/auth.service'; +import { RegisterUserData } from '../../src/auth/auth.service'; export const validService = faker.internet.url(); export const validTicket = faker.datatype.uuid(); -export const user: RegisterData = { +export const user: RegisterUserData = { login: faker.datatype.uuid(), mail: faker.internet.email(), lastName: faker.name.lastName(), diff --git a/test/unit/app.spec.ts b/test/unit/app.spec.ts index ea8eff9..4b6766f 100644 --- a/test/unit/app.spec.ts +++ b/test/unit/app.spec.ts @@ -1,7 +1,7 @@ //import TimetableServiceUnitSpec from './timetable/timetable.service.spec'; import { Test, TestingModule } from '@nestjs/testing'; import { AppModule } from '../../src/app.module'; -import '../../src/array'; +import '../../src/std.type'; describe('EtuUTT API unit testing', () => { // eslint-disable-next-line @typescript-eslint/no-unused-vars diff --git a/test/utils/fakedb.ts b/test/utils/fakedb.ts index fe4bc17..43e2c2c 100644 --- a/test/utils/fakedb.ts +++ b/test/utils/fakedb.ts @@ -34,8 +34,8 @@ import { import { faker } from '@faker-js/faker'; import { AuthService } from '../../src/auth/auth.service'; import { PrismaService } from '../../src/prisma/prisma.service'; -import { AppProvider } from './test_utils'; -import { Sex, TimetableEntryType, UserType } from '@prisma/client'; +import {AppProvider, DEFAULT_APPLICATION} from './test_utils'; +import { Permission, Sex, TimetableEntryType, UserType } from '@prisma/client'; import { CommentStatus } from '../../src/ue/comments/interfaces/comment.interface'; import { UeAnnalFile } from '../../src/ue/annals/interfaces/annal.interface'; import { omit, pick, translationSelect } from '../../src/utils'; @@ -46,8 +46,7 @@ import { omit, pick, translationSelect } from '../../src/utils'; */ export type FakeUser = Partial & { infos?: Partial; - permissions?: string[]; - token?: string; + permissions?: Permission[]; mailsPhones?: Partial; addresses?: Array>; socialNetwork?: Partial; @@ -59,7 +58,8 @@ export type FakeUser = Partial & { semester?: Partial; } >; - privacy: Partial; + privacy?: Partial; + token?: string; }; export type FakeTimetableGroup = Partial; export type FakeTimetableEntry = Partial; @@ -161,7 +161,7 @@ export interface FakeEntityMap { params: CreateUserSubscriptionParameters; deps: { user: FakeUser; ue: FakeUe; semester: FakeSemester }; }; - ueCriterion: { + ueStarCriterion: { entity: FakeUeStarCriterion; params: CreateCriterionParameters; }; @@ -250,35 +250,6 @@ export const createUser = entityFaker( privacy: {}, }, async (app, params) => { - const permissions = await app() - .get(PrismaService) - .userPermission.findMany({ - where: { - id: { - in: params.permissions, - }, - }, - }); - permissions.push( - ...(await Promise.all( - (params.permissions ?? []) - .filter((perm) => !permissions.some((p) => p.id === perm)) - .map((perm) => - app() - .get(PrismaService) - .userPermission.create({ - data: { - id: perm, - name: { - create: { - fr: 'TODO : implement this value', - }, - }, - }, - }), - ), - )), - ); const user = await app() .get(PrismaService) .withDefaultBehaviour.user.create({ @@ -293,11 +264,6 @@ export const createUser = entityFaker( : null, // Add the 1h timezone offset (in you are in France ^^) to make the time the same as the one expected if you don't look at the timezone offset }, }, - permissions: { - createMany: { - data: permissions.map((perm) => ({ userPermissionId: perm.id })), - }, - }, rgpd: { create: {} }, preference: { create: {} }, mailsPhones: { create: {} }, @@ -326,11 +292,6 @@ export const createUser = entityFaker( }, include: { infos: true, - permissions: { - select: { - userPermissionId: true, - }, - }, mailsPhones: true, addresses: { select: { @@ -352,10 +313,46 @@ export const createUser = entityFaker( privacy: true, }, }); + const apiKey = await app() + .get(PrismaService) + .apiKey.create({ + data: { + token: faker.random.alpha({ count: 30 }), + tokenUpdatedAt: new Date(), + user: { connect: { id: user.id } }, + application: { connect: { id: DEFAULT_APPLICATION } }, + apiKeyPermissions: { + create: [ + { + permission: 'USER_SEE_DETAILS', + soft: true, + grants: { + create: { + user: { connect: { id: user.id } }, + }, + }, + }, + { + permission: 'USER_UPDATE_DETAILS', + soft: true, + grants: { + create: { + user: { connect: { id: user.id } }, + }, + }, + }, + ...params.permissions.map((permission) => ({ + permission, + soft: false, + })), + ], + }, + }, + }); return { ...user, - permissions: user.permissions.map((perm) => perm.userPermissionId), - token: await app().get(AuthService).signToken(user.id, user.login), + permissions: [], + token: await app().get(AuthService).signAuthenticationToken(apiKey.token), }; }, ); @@ -820,9 +817,9 @@ export const createUeSubscription = entityFaker('userUeSubscription', {}, async export type CreateCriterionParameters = FakeUeStarCriterion; export const createCriterion = entityFaker( - 'ueCriterion', + 'ueStarCriterion', { - name: faker.word.adjective, + name: faker.db.ueStarCriterion.name, }, async (app, params) => app() diff --git a/test/utils/test_utils.ts b/test/utils/test_utils.ts index e7345e0..c34b9ee 100644 --- a/test/utils/test_utils.ts +++ b/test/utils/test_utils.ts @@ -5,7 +5,7 @@ import { faker } from '@faker-js/faker'; import { ConfigModule } from '../../src/config/config.module'; import { DMMF } from '@prisma/client/runtime/library'; import { clearUniqueValues } from '../../prisma/seed/utils'; -import { PrismaClient } from '@prisma/client'; +import { PrismaClient, UserType } from '@prisma/client'; /** * Initializes this file. @@ -45,6 +45,28 @@ function suite(name: string, func: (app: T) => void) { describe(name, () => { beforeAll(async () => { await cleanDb(app().get(PrismaService)); + await app() + .get(PrismaService) + .user.create({ + data: { + login: "etuutt", + firstName: "Etu", + lastName: "UTT", + userType: UserType.STUDENT, + apiApplications: { + create: { + id: DEFAULT_APPLICATION, + name: faker.company.name(), + }, + }, + socialNetwork: { create: {} }, + rgpd: { create: {} }, + preference: { create: {} }, + infos: { create: {} }, + mailsPhones: { create: {} }, + privacy: { create: {} }, + }, + }); clearUniqueValues(); }); func(app); @@ -82,7 +104,7 @@ export async function cleanDb(prisma: PrismaService | PrismaClient) { // We can't delete each table one by one, because of foreign key constraints const tablesCleared = [] as string[]; // _runtimeDataModel.models basically contains a JS-ified version of the schema.prisma - for (const modelName of Object.keys((prisma as any)._runtimeDataModel.models)) { + for (const modelName of Object.keys((prisma as any)._runtimeDataModel.models) as string[]) { // Check the table hasn't been already cleaned if (tablesCleared.includes(modelName)) continue; await clearTableWithCascade(prisma, modelName, tablesCleared); @@ -122,3 +144,5 @@ async function clearTableWithCascade(prisma: PrismaService | PrismaClient, model await prisma[modelName].deleteMany(); tablesCleared.push(modelName); } + +export const DEFAULT_APPLICATION = '52ce644d-183f-49e9-bd21-d2d4f37e2196'; From e2686e3d5e5445b35c17068ad30f4a25e360676f Mon Sep 17 00:00:00 2001 From: Teddy Roncin Date: Wed, 30 Oct 2024 01:23:45 +0100 Subject: [PATCH 4/9] feat: updated docs permissions.md and tried factorizing ldap server mocking in tests --- docs/doc_developers/api/permissions.md | 107 ++++++++++++++++++++++--- prisma/schema.prisma | 2 +- src/ldap/ldap.module.ts | 1 + test/e2e/auth/cas-sign-up.e2e-spec.ts | 10 ++- test/external_services/ldap.ts | 29 +++++++ 5 files changed, 131 insertions(+), 18 deletions(-) create mode 100644 test/external_services/ldap.ts diff --git a/docs/doc_developers/api/permissions.md b/docs/doc_developers/api/permissions.md index d26cd17..5af408e 100644 --- a/docs/doc_developers/api/permissions.md +++ b/docs/doc_developers/api/permissions.md @@ -2,28 +2,109 @@ Cette page traite à la fois des permissions des utilisateurs, et de celles des applications utilisant l'API. -## Introduction +Tous les termes spécifiques aux permissions seront en _italique_, et leur définition peut être retrouvée dans la partie [terminologie](#terminologie). -Tout d'abord, faisons un tour d'horizon des tables : -- `ApiKey` : c'est la table de base pour les permissions. Cette table contient des données de base sur les droits de chaque utilisateur. L'utilisateur ne sera pas authentifié directement, mais avec une `ApiKey`. Chaque utilisateur peut avoir plusieurs `ApiKey`, que vous pouvez voir comme des subdivisions d'un utilisateur. Prenons l'exemple de l'intégration : ils auront une `ApiKey` pour le front de EtuUTT, une `ApiKey` pour leur site web, et une troisième pour leur application. Chaque `ApiKey` a des permissions différentes. -- `User` : l'utilisateur, les `ApiKey` pointent vers cette table. +## Terminologie + +### Permission + +Une _permission_ est une autorisation de réaliser une action ou d'accéder à des données. Dès que quelque chose ne devrait pas être accessible / faisable avec n'importe quelle _clé API_, une _permission_ pour faire cette dite chose doit exister. + +Les _permissions_ sont divisées en 2 catégories : les _user permissions_ et les _API permissions_. + +**Exemples :** La permission permettant de voir les commentaires des UEs, la permission permettant de modifier les permissions des autres, ... + +### User permission + +Une _user permission_ est un type de _permission_. Ces _permissions_ sont les _permissions_ liées à un utilisateur. + +**Exemples :** accéder aux données privées des utilisateurs, modifier les données d'un utilisateur, ... + +### API permission +Une _API permission_ est un type de _permission_. Ces _permissions_ sont les _permissions_ générales, qui portent sur toute l'API. + +**Exemples :** modérer les commentaires, modérer les annales, etc... + +### Application + +Une application est un logiciel ayant besoin d'un accès à l'API de EtuUTT. Chaque application est reliée à un utilisateur, qui est l'administrateur de celle-ci. + +**Exemples :** le front de EtuUTT, l'application EtuUTT, le site de l'intégration, ... + +### Clé API (ou Api Key) + +Une _clé API_ (ou _Api Key_) est une relation entre un utilisateur et une _application_. Un utilisateur ne peut avoir qu'une _clé API_ par _application_. + +```{note} +Une _clé API_ **n'est pas** un token, c'est plutôt un objet qui servira à générer un token et authentifier les requêtes. + +Un utilisateur n'a pas nécessairement les mêmes droits sur les différentes _applications_. Il est tout de même important de noter que rien ne l'empêchera d'utiliser une _clé API_ sur une _application_ qui n'est pas liée à cette _clé API_. Il est donc important **d'avoir confiance** en l'utilisateur, et pas uniquement en l'application. +``` + +**Exemple :** prenons l'exemple de l'intégration : ils auront : +* Une _clé API_ pour le pour se connecter au front de EtuUTT avec le compte `integration@utt.fr` (reliée à l'_application_ `EtuUTT-front`) +* Une _clé API_ pour le back de leur site web (reliée à `Integration-website`) +* Une _clé API_ par utilisateur de leur application (qui n'utiliserait pas le backend de leur site web), avec uniquement les droits de base, pour leur application (reliées à `Integration-app`). Chaque _clé API_ a des permissions différentes, ce qui signifie qu'on peut donner des droits à un utilisateur en particulier sur l'_application_ de l'intégration. + +### Bearer token + +Le _bearer token_ est une chaîne de caractère encodant une certaine _clé API_, en utilisant le standard JWT. + +### Soft grant + +Un _soft grant_ ne peut se faire que sur des _user permissions_ (ça n'aurait pas de sens sur des _api permissions_). + +Les _soft grant_ ne donne pas la permission à la _clé API_ sur tous les utilisateurs. Chaque utilisateur doit explicitement donner son consentement pour que la _clé API_ puisse exercer sa _permission_ sur son compte. + +Une _clé API_ peut se soft grant n'importe quelle _user permission_. Tant qu'elle n'aura reçu le consentement de personne, elle n'aura aucun droit supplémentaire. + +**Exemple :** Guillaume, grand rageux qu'il est, décide de développer une application, qui permet d'avoir une interface bien plus agréable que celle de EtuUTT. Il a aussi une API (en Rust, on se respecte), qui s'occupe de faire l'interface entre l'API EtuUTT et son application. Guillaume pourra donner la _permission_ à sa clé API de voir le détail des utilisateurs. Cependant, ce sera un _soft grant_, ce qui signifie qu'il n'aura au début accès aux détails d'aucun utilisateur. Teddy va alors être curieux du projet, et se connecter à son application. Pendant l'authentification avec EtuUTT, il devra donner son consentement pour que Guillaume puisse récupérer ses informations personnelles. À partir de ce moment là, Guillaume pourra utiliser sa permission sur Teddy, mais **uniquement** sur Teddy, jusqu'à ce qu'un autre utilisateur lui donne son consentement. (Ah, au fait, Teddy a pas aimé l'application et a revoke son consentement 😔) + +### Hard grant + +Un _hard grant_ peut se faire sur n'importe quel type de _permissions_ (_user permissions_ et _API permissions_). + +Un _hard grant_ ne nécessite le consentement de personne, et s'applique sur tous les utilisateurs. Une _clé API_ ne peut évidemment pas se _hard grant_ des _permissions_. + +Une _API permissions_ est nécessairement _hard granted_ (aucun sens de les _soft grant_). + +**Exemple :** Guillaume rêve de pouvoir. Et finalement, il a amélioré son application (Teddy est revenu sur son choix). Son code est devenu propriété de l'UNG (merci Guillaume). Nous pouvons donc donner la _permission_ pour voir les informations personnelles des utilisateurs à l'application. Un administrateur va alors _hard grant_ la permission à Guillaume. Les utilisateurs n'ont pas besoin de donner leur consentement, Guillaume aspire tout 😈. + +```{warning} +Attenation cependant à bien respecter le RGPD en faisant un _hard grant_ d'une _user permission_ ! \ +À ce jour, nous ne pensons qu'à 2 _applications_ qui devraient en avoir besoin : le site de EtuUTT, et son application. +``` + +## Tables + +Faisons un tour d'horizon des tables : +- `Application` : représente une _application_. +- `ApiKey` : représente une _clé API_. L'`ApiKey` contient un token, qui sera signé pour créer le Bearer token. +- `User` : représente un utilisateur (rien de particulier à signaler ici, la table ressemble à ce dont vous pouvez vous attendre d'une table utilisateur) +- `ApiKeyPermission` : Une _permission_ spécifique donnée à une certaine _clé API_. Cette _permission_ peut soit être _soft granted_ soit être _hard granted_. - `GrantedPermissions` : Cette table contient les permissions données par un certain utilisateur à une certaine clé API. -- `ApiPermissions` et `UserPermissions` : Ces _enum_ listent l'entièreté des permissions prises en charge par l'API. Les valeurs de `ApiPermissions` (resp. `UserPermissions`) commencent par `API_` (resp. `USER_`). Les `UserPermissions` peuvent être demandées par les différentes applications et acceptées par les utilisateurs au cas par cas. Plus d'information dans la partie du fonctionnement des (grants)[#grants]. +- `Permission` : une _enum_ listant l'entièreté des _permissions_ prises en charge par l'API. Les _API permissions_ commencent par `API_`, et les _user permissions_ commencent par `USER_`. ## Authentification des requêtes -On va traiter l'authentification des requêtes avant la connexion, le _flow_ me paraît plus logique dans ce sens là :) +On va traiter l'authentification des requêtes avant la connexion, le _flow_ me paraît plus logique dans ce sens là 🙂 -Pour authentifier les requêtes, on utilise un token JWT, passé dans le _header_ `Authorization`, sous le format `Bearer {token}`. Une fois décodé, le token renvoit un objet de la forme contenant un champ `token`. Ce champ permet de trouver l'`ApiKey` unique. À partir de cette `ApiKey`, il est ainsi possible d'obtenir l'utilisateur authentifié, et les routes ou informations auxquelles l'utilisateur a le droit d'accéder. +Pour authentifier les requêtes, on utilise un _bearer token_ (token JWT), passé dans le _header_ `Authorization`, sous le format `Bearer {token}`. Une fois décodé, le token renvoit un objet contenant un champ `token`. Ce champ permet de trouver une `ApiKey` unique. À partir de cette `ApiKey`, il est ainsi possible d'obtenir l'utilisateur authentifié, et les routes ou informations auxquelles l'utilisateur a le droit d'accéder. ## Connexion -La connexion pour un utilisateur ou une application diffère : -- Pour un utilisateur : on passe par le CAS de l'UTT, avec la route `POST /auth/signin`, puis l'API nous renvoit un token pour authentifier nos requêtes, voir la partie (Authentification des requêtes)[#authentification-des-requetes] -- Pour une application : on génère un token pour l'`ApiKey` demandée, puis on retourne le token JWT. Il faut aussi bien sauvegarder la date de dernière mise à jour (`tokenUpdatedAt`), et utiliser cette date pour toujours retourner la même version du token (champ `iat` dans l'objet à encoder avec JWT). L'utilisateur peut renouveler les token de ses `ApiKey`, le token sera alors modifié, pour empêcher l'accès avec l'ancien token. +On fera la différence entre un utilisateur et une _application_. Mais comme vous avez dû le comprendre, un utilisateur n'est rien d'autre que l'_application_ du site web de EtuUTT essayant de se connecter en tant que cet utilisateur. + +La méthode de connexion "utilisateur" permettra donc de générer un _bearer token_ temporaire, avec une connexion standard (décentralisée, nom d'utilisateur / mot de passe). + +La méthode de connexion "application" permettra de générer un _bearer token_ avec une durée de vie possiblement infinie (en fonction de ce que veut l'utilisateur). On passe ici par une autre application (le site EtuUTT) pour générer un _bearer token_. + +### Pour un utilisateur + +Pour un utilisateur, on passe par le CAS de l'UTT, avec la route `POST /auth/signin`, puis l'API nous renvoit un token pour authentifier nos requêtes, voir la partie (Authentification des requêtes)[#authentification-des-requetes] -## Grants +### Pour une application -Ce système de _grant_ permet aux utilisateurs de maîtriser quelles données sont partagées avec quelle application externe. +Pour une application, on génère un token pour la _clé API_ demandée, puis on retourne le _bearer token_ associé. Il faut aussi bien sauvegarder la date de dernière mise à jour (`tokenUpdatedAt`), et utiliser cette date pour toujours retourner la même version du token (champ `iat` dans l'objet à encoder avec JWT). -Par exemple, Guillaume a décidé de développer une application web avec un backend Rust permettant de gérer nos comptes EtuUTT. Il aimerait donc les permissions `USER_SEE_DETAILS` et `USER_UPDATE` sur tous les utilisateurs, ce que nous ne pouvons évidemment pas lui donner, pour des raisons de sécurité et de confidentialité. Il peut cependant marquer ces permissions comme étant demandées (champ `grantablePermissions` de la table `ApiKey`). Ainsi, il pourra rediriger les utilisateur vers une page de EtuUTT (TODO : toujours à déterminer, API ou front ? API serait plus simple), qui leur permettront de se connecter et d'accepter ou non que l'application de Guillaume accède à leurs données. Ils peuvent aussi choisir, par exemple, de n'autoriser l'application qu'à voir leurs données, mais pas de les mettre à jour. +L'utilisateur peut renouveler les token de ses `ApiKey`. Le token sera alors modifié, pour empêcher l'accès avec l'ancien token. diff --git a/prisma/schema.prisma b/prisma/schema.prisma index e5f5c34..48ec76d 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -13,7 +13,7 @@ model ApiApplication { userId String user User @relation(fields: [userId], references: [id]) - ApiKey ApiKey[] + apiKeys ApiKey[] } model ApiKey { diff --git a/src/ldap/ldap.module.ts b/src/ldap/ldap.module.ts index a27b2c7..d50a8dd 100644 --- a/src/ldap/ldap.module.ts +++ b/src/ldap/ldap.module.ts @@ -23,6 +23,7 @@ export class LdapModule { if (this.config.LDAP_USER && this.config.LDAP_PWD) await ldapClient.bind(this.config.LDAP_USER, this.config.LDAP_PWD); // Search User in LDAP + console.log("making request") const { searchEntries: [ldapUser], } = await ldapClient.search('ou=people,dc=utt,dc=fr', { diff --git a/test/e2e/auth/cas-sign-up.e2e-spec.ts b/test/e2e/auth/cas-sign-up.e2e-spec.ts index 25831c0..c6b8f55 100644 --- a/test/e2e/auth/cas-sign-up.e2e-spec.ts +++ b/test/e2e/auth/cas-sign-up.e2e-spec.ts @@ -12,10 +12,11 @@ import { ERROR_CODE } from '../../../src/exceptions'; import { ConfigModule } from '../../../src/config/config.module'; import { LdapServerMock, LdapUser } from 'ldap-server-mock'; import { HttpStatus } from '@nestjs/common'; +import {mockLdapServer} from "../../external_services/ldap"; const CasSignUpE2ESpec = e2eSuite('POST /auth/signup/cas', (app) => { const list: LdapUser[] = []; - const ldapServer = new LdapServerMock( + /*const ldapServer = new LdapServerMock( list, { searchBase: 'ou=people,dc=utt,dc=fr', @@ -27,7 +28,7 @@ const CasSignUpE2ESpec = e2eSuite('POST /auth/signup/cas', (app) => { // Disable default logging info: () => undefined, }, - ); + );*/ const branch = fakedb.createBranch(app); const branchOption = fakedb.createBranchOption(app, { branch }); fakedb.createSemester(app, { @@ -37,8 +38,9 @@ const CasSignUpE2ESpec = e2eSuite('POST /auth/signup/cas', (app) => { }); const ue = fakedb.createUe(app); - beforeAll(() => ldapServer.start()); - afterAll(() => ldapServer.stop()); + //beforeAll(() => ldapServer.start()); + //afterAll(() => ldapServer.stop()); + mockLdapServer(list); it('should fail as the provided token is not jwt-generated', () => pactum diff --git a/test/external_services/ldap.ts b/test/external_services/ldap.ts new file mode 100644 index 0000000..a860b1e --- /dev/null +++ b/test/external_services/ldap.ts @@ -0,0 +1,29 @@ +import { LdapServerMock, LdapUser } from "ldap-server-mock"; + +export function mockLdapServer(list: LdapUser[]) { + //console.log("loading server ", process.env.LDAP_URL) + const ldapServer = new LdapServerMock( + list, + { + searchBase: 'ou=people,dc=utt,dc=fr', + port: Number(process.env.LDAP_URL.split(':')[2]), + }, + null, + null, + { + // Disable default logging + info: () => undefined, + }, + ); + + beforeAll(async () => { + console.log("starting"); + await ldapServer.start(); + console.log("started"); + }); + afterAll(async () => { + console.log("stopping"); + await ldapServer.stop(); + console.log("stopped"); + }); +} From cfe4d8b104a0b5b10b8390a4b8c1c442826281bc Mon Sep 17 00:00:00 2001 From: Teddy Roncin Date: Fri, 1 Nov 2024 01:11:18 +0100 Subject: [PATCH 5/9] fix: improved docs --- docs/doc_developers/api/permissions.md | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/docs/doc_developers/api/permissions.md b/docs/doc_developers/api/permissions.md index 5af408e..efaa574 100644 --- a/docs/doc_developers/api/permissions.md +++ b/docs/doc_developers/api/permissions.md @@ -108,3 +108,13 @@ Pour un utilisateur, on passe par le CAS de l'UTT, avec la route `POST /auth/sig Pour une application, on génère un token pour la _clé API_ demandée, puis on retourne le _bearer token_ associé. Il faut aussi bien sauvegarder la date de dernière mise à jour (`tokenUpdatedAt`), et utiliser cette date pour toujours retourner la même version du token (champ `iat` dans l'objet à encoder avec JWT). L'utilisateur peut renouveler les token de ses `ApiKey`. Le token sera alors modifié, pour empêcher l'accès avec l'ancien token. + +## Grant + +### Soft grant + +N'importe quelle application peut se _soft grant_ une _permission_. + +Pour permettre à un utilisateur d'accepter cette _soft grant_, l'_application_ doit rediriger l'utilisateur vers une route sur le front{sup}`route à déterminer`, en lui passant en paramètre l'id de l'_application_, l'URL de redirection, et les IDs des `ApiKeyPermission` pour nécessaires à _l'application_{sup}`noms des arguments à déterminer`. + +L'utilisateur sera alors invité à se connecter, à accepter ou refuser les différentes _permissions_, et sera redirigé vers l'URL, avec en paramètre les _permissions_ acceptées{sup}`format à déterminer`. From 113b41736318715f4927af2d2fc56ddbf6c91b29 Mon Sep 17 00:00:00 2001 From: Teddy Roncin Date: Sat, 2 Nov 2024 11:24:55 +0100 Subject: [PATCH 6/9] fix: tests are now passing --- src/ldap/ldap.module.ts | 1 - src/prisma/types.ts | 3 ++- test/e2e/auth/cas-sign-in.e2e-spec.ts | 30 ++++++++++++++++++++++----- test/external_services/ldap.ts | 4 ---- test/utils/fakedb.ts | 5 ++++- 5 files changed, 31 insertions(+), 12 deletions(-) diff --git a/src/ldap/ldap.module.ts b/src/ldap/ldap.module.ts index d50a8dd..a27b2c7 100644 --- a/src/ldap/ldap.module.ts +++ b/src/ldap/ldap.module.ts @@ -23,7 +23,6 @@ export class LdapModule { if (this.config.LDAP_USER && this.config.LDAP_PWD) await ldapClient.bind(this.config.LDAP_USER, this.config.LDAP_PWD); // Search User in LDAP - console.log("making request") const { searchEntries: [ldapUser], } = await ldapClient.search('ou=people,dc=utt,dc=fr', { diff --git a/src/prisma/types.ts b/src/prisma/types.ts index ae1e954..139b8ea 100644 --- a/src/prisma/types.ts +++ b/src/prisma/types.ts @@ -32,7 +32,8 @@ export { AssoMembership as RawAssoMembership, UserHomepageWidget as RawHomepageWidget, UserPrivacy as RawUserPrivacy, - ApiApplication as RawApiApplication + ApiApplication as RawApiApplication, + ApiKey as RawApiKey, } from '@prisma/client'; export { RawTranslation }; diff --git a/test/e2e/auth/cas-sign-in.e2e-spec.ts b/test/e2e/auth/cas-sign-in.e2e-spec.ts index 234f5ce..20429fa 100644 --- a/test/e2e/auth/cas-sign-in.e2e-spec.ts +++ b/test/e2e/auth/cas-sign-in.e2e-spec.ts @@ -1,4 +1,4 @@ -import { e2eSuite } from '../../utils/test_utils'; +import { DEFAULT_APPLICATION, e2eSuite } from '../../utils/test_utils'; import * as cas from '../../external_services/cas'; import * as fakedb from '../../utils/fakedb'; import * as pactum from 'pactum'; @@ -24,29 +24,49 @@ const CasSignInE2ESpec = e2eSuite('POST /auth/signin/cas', (app) => { .expectAppError(ERROR_CODE.INVALID_CAS_TICKET); }); - it('should successfully return a register code', () => + it('should successfully return a user-register code', () => pactum .spec() .post('/auth/signin/cas') .withBody(body) - .expectJsonMatch({ signedIn: false, access_token: string() }) + .expectJsonMatch({ status: 'no_account', access_token: string() }) .expect((res) => { const jwt = app().get(JwtService); const data = jwt.decode((res.res.json as { access_token: string }).access_token); expect(data).toMatchObject(cas.user); })); + it('should successfully return an apikey-register code', async () => { + const user = await fakedb.createUser(app, { login: cas.user.login }, true); + await app() + .get(PrismaService) + .apiKey.delete({ where: { id: user.apiKey.id } }); + await pactum + .spec() + .post('/auth/signin/cas') + .withBody(body) + .expectJsonMatch({ status: 'no_api_key', access_token: string() }) + .expect((res) => { + const jwt = app().get(JwtService); + const data = jwt.decode((res.res.json as { access_token: string }).access_token); + expect(data).toMatchObject({ userId: user.id, applicationId: DEFAULT_APPLICATION }); + }); + await app() + .get(PrismaService) + .user.delete({ where: { id: user.id } }); + }); + it('should successfully sign in the user', async () => { const user = await fakedb.createUser(app, { login: cas.user.login }, true); await pactum .spec() .post('/auth/signin/cas') .withBody(body) - .expectJsonMatch({ signedIn: true, access_token: string() }) + .expectJsonMatch({ status: 'ok', access_token: string() }) .expect((res) => { const jwt = app().get(JwtService); const data = jwt.decode((res.res.json as { access_token: string }).access_token); - expect(data).toMatchObject({ sub: user.id, login: cas.user.login }); + expect(data).toMatchObject({ token: user.apiKey.token }); }); await app() .get(PrismaService) diff --git a/test/external_services/ldap.ts b/test/external_services/ldap.ts index a860b1e..fe76d1f 100644 --- a/test/external_services/ldap.ts +++ b/test/external_services/ldap.ts @@ -17,13 +17,9 @@ export function mockLdapServer(list: LdapUser[]) { ); beforeAll(async () => { - console.log("starting"); await ldapServer.start(); - console.log("started"); }); afterAll(async () => { - console.log("stopping"); await ldapServer.stop(); - console.log("stopped"); }); } diff --git a/test/utils/fakedb.ts b/test/utils/fakedb.ts index 43e2c2c..251920d 100644 --- a/test/utils/fakedb.ts +++ b/test/utils/fakedb.ts @@ -30,11 +30,12 @@ import { RawUserUeSubscription, Translation, RawUserPrivacy, + RawApiKey, } from '../../src/prisma/types'; import { faker } from '@faker-js/faker'; import { AuthService } from '../../src/auth/auth.service'; import { PrismaService } from '../../src/prisma/prisma.service'; -import {AppProvider, DEFAULT_APPLICATION} from './test_utils'; +import { AppProvider, DEFAULT_APPLICATION } from './test_utils'; import { Permission, Sex, TimetableEntryType, UserType } from '@prisma/client'; import { CommentStatus } from '../../src/ue/comments/interfaces/comment.interface'; import { UeAnnalFile } from '../../src/ue/annals/interfaces/annal.interface'; @@ -60,6 +61,7 @@ export type FakeUser = Partial & { >; privacy?: Partial; token?: string; + apiKey?: Partial; }; export type FakeTimetableGroup = Partial; export type FakeTimetableEntry = Partial; @@ -353,6 +355,7 @@ export const createUser = entityFaker( ...user, permissions: [], token: await app().get(AuthService).signAuthenticationToken(apiKey.token), + apiKey, }; }, ); From ef9e6241b0a89faa0fd9954eaf74a97903a7d353 Mon Sep 17 00:00:00 2001 From: Teddy Roncin Date: Sat, 2 Nov 2024 11:27:51 +0100 Subject: [PATCH 7/9] fix: lint --- prisma/seed/utils.ts | 2 +- src/auth/decorator/get-permissions.decorator.ts | 2 +- src/auth/decorator/get-user.decorator.ts | 2 +- src/auth/decorator/require-permission.decorator.ts | 2 +- src/auth/dto/req/auth-cas-sign-in-req.dto.ts | 6 +++--- src/auth/dto/req/auth-cas-sign-up-req.dto.ts | 4 ++-- src/auth/dto/req/auth-sign-in-req.dto.ts | 6 +++--- src/auth/guard/jwt.guard.ts | 9 ++++----- src/ue/annals/annals.controller.ts | 9 ++++++--- test/declarations.ts | 2 +- test/e2e/auth/cas-sign-up.e2e-spec.ts | 4 ++-- test/e2e/auth/signup-e2e-spec.ts | 1 - test/e2e/auth/verify-e2e-spec.ts | 1 - test/e2e/ue/annals/get-annal-metadata.e2e-spec.ts | 2 +- test/external_services/ldap.ts | 2 +- test/utils/test_utils.ts | 6 +++--- 16 files changed, 30 insertions(+), 30 deletions(-) diff --git a/prisma/seed/utils.ts b/prisma/seed/utils.ts index 125efca..d0e946c 100644 --- a/prisma/seed/utils.ts +++ b/prisma/seed/utils.ts @@ -122,7 +122,7 @@ declare module '@faker-js/faker' { }; ueStarCriterion: { name: () => string; - } + }; }; } } diff --git a/src/auth/decorator/get-permissions.decorator.ts b/src/auth/decorator/get-permissions.decorator.ts index 13c8e3b..ca84fdd 100644 --- a/src/auth/decorator/get-permissions.decorator.ts +++ b/src/auth/decorator/get-permissions.decorator.ts @@ -1,5 +1,5 @@ import { createParamDecorator, ExecutionContext } from '@nestjs/common'; -import {RequestAuthData} from "../interfaces/request-auth-data.interface"; +import { RequestAuthData } from '../interfaces/request-auth-data.interface'; /** * Get the permissions of a user. diff --git a/src/auth/decorator/get-user.decorator.ts b/src/auth/decorator/get-user.decorator.ts index 0b48028..321a61d 100644 --- a/src/auth/decorator/get-user.decorator.ts +++ b/src/auth/decorator/get-user.decorator.ts @@ -1,6 +1,6 @@ import { createParamDecorator, ExecutionContext } from '@nestjs/common'; import { User } from 'src/users/interfaces/user.interface'; -import {RequestAuthData} from "../interfaces/request-auth-data.interface"; +import { RequestAuthData } from '../interfaces/request-auth-data.interface'; /** * Get the user from the request. diff --git a/src/auth/decorator/require-permission.decorator.ts b/src/auth/decorator/require-permission.decorator.ts index 5278afc..60ef293 100644 --- a/src/auth/decorator/require-permission.decorator.ts +++ b/src/auth/decorator/require-permission.decorator.ts @@ -1,6 +1,6 @@ import { SetMetadata, ExecutionContext } from '@nestjs/common'; import { Reflector } from '@nestjs/core'; -import {Permission} from "@prisma/client"; +import { Permission } from '@prisma/client'; export const REQUIRED_PERMISSIONS_KEY = 'requiredPermissions'; /** diff --git a/src/auth/dto/req/auth-cas-sign-in-req.dto.ts b/src/auth/dto/req/auth-cas-sign-in-req.dto.ts index 79978ea..6a65ecb 100644 --- a/src/auth/dto/req/auth-cas-sign-in-req.dto.ts +++ b/src/auth/dto/req/auth-cas-sign-in-req.dto.ts @@ -1,6 +1,6 @@ -import {IsInt, IsNotEmpty, IsString} from 'class-validator'; -import {Type} from "class-transformer"; -import {ApiProperty} from "@nestjs/swagger"; +import { IsInt, IsNotEmpty, IsString } from 'class-validator'; +import { Type } from 'class-transformer'; +import { ApiProperty } from '@nestjs/swagger'; export default class AuthCasSignInReqDto { @IsString() diff --git a/src/auth/dto/req/auth-cas-sign-up-req.dto.ts b/src/auth/dto/req/auth-cas-sign-up-req.dto.ts index 6ba76f7..8de4ef4 100644 --- a/src/auth/dto/req/auth-cas-sign-up-req.dto.ts +++ b/src/auth/dto/req/auth-cas-sign-up-req.dto.ts @@ -1,6 +1,6 @@ -import {IsInt, IsNotEmpty, IsString} from 'class-validator'; +import { IsInt, IsNotEmpty, IsString } from 'class-validator'; import { ApiProperty } from '@nestjs/swagger'; -import {Type} from "class-transformer"; +import { Type } from 'class-transformer'; export default class AuthCasSignUpReqDto { @IsString() diff --git a/src/auth/dto/req/auth-sign-in-req.dto.ts b/src/auth/dto/req/auth-sign-in-req.dto.ts index d14f8f2..5d19f06 100644 --- a/src/auth/dto/req/auth-sign-in-req.dto.ts +++ b/src/auth/dto/req/auth-sign-in-req.dto.ts @@ -1,6 +1,6 @@ -import {IsAlphanumeric, IsInt, IsNotEmpty, IsString} from 'class-validator'; -import {Type} from "class-transformer"; -import {ApiProperty} from "@nestjs/swagger"; +import { IsAlphanumeric, IsInt, IsNotEmpty, IsString } from 'class-validator'; +import { Type } from 'class-transformer'; +import { ApiProperty } from '@nestjs/swagger'; export default class AuthSignInReqDto { @IsNotEmpty() diff --git a/src/auth/guard/jwt.guard.ts b/src/auth/guard/jwt.guard.ts index 318f586..327f633 100644 --- a/src/auth/guard/jwt.guard.ts +++ b/src/auth/guard/jwt.guard.ts @@ -1,10 +1,9 @@ -import {ExecutionContext, Injectable, UnauthorizedException} from '@nestjs/common'; +import { ExecutionContext, Injectable } from '@nestjs/common'; import { Reflector } from '@nestjs/core'; import { AuthGuard } from '@nestjs/passport'; -import { IsPublic } from '../decorator/public.decorator'; -import { AppException, ERROR_CODE } from '../../../src/exceptions'; -import { Observable, firstValueFrom } from 'rxjs'; -import {RequestAuthData} from "../interfaces/request-auth-data.interface"; +import { IsPublic } from '../decorator'; +import { AppException, ERROR_CODE } from '../../exceptions'; +import { RequestAuthData } from '../interfaces/request-auth-data.interface'; @Injectable() export class JwtGuard extends AuthGuard('jwt') { diff --git a/src/ue/annals/annals.controller.ts b/src/ue/annals/annals.controller.ts index e588924..305adaf 100644 --- a/src/ue/annals/annals.controller.ts +++ b/src/ue/annals/annals.controller.ts @@ -89,7 +89,10 @@ export class AnnalsController { @GetPermissions() permissions: RequestPermissions, ): Promise { if (!(await this.ueService.doesUeExist(ueCode))) throw new AppException(ERROR_CODE.NO_SUCH_UE, ueCode); - if (!(await this.ueService.hasAlreadyDoneThisUe(user.id, ueCode)) && permissions[Permission.API_UPLOAD_ANNAL] !== '*') + if ( + !(await this.ueService.hasAlreadyDoneThisUe(user.id, ueCode)) && + permissions[Permission.API_UPLOAD_ANNAL] !== '*' + ) throw new AppException(ERROR_CODE.NOT_ALREADY_DONE_UE); return this.annalsService.getUEAnnalMetadata(user, ueCode, permissions[Permission.API_UPLOAD_ANNAL] === '*'); } @@ -122,8 +125,8 @@ export class AnnalsController { if (!(await this.annalsService.isUeAnnalSender(user.id, annalId))) throw new AppException(ERROR_CODE.NOT_ANNAL_SENDER); if ( - (await this.annalsService.getUEAnnal(annalId, user.id, permissions[Permission.API_MODERATE_ANNAL] === '*')).status !== - CommentStatus.PROCESSING + (await this.annalsService.getUEAnnal(annalId, user.id, permissions[Permission.API_MODERATE_ANNAL] === '*')) + .status !== CommentStatus.PROCESSING ) throw new AppException(ERROR_CODE.ANNAL_ALREADY_UPLOADED); return this.annalsService.uploadAnnalFile(await file, annalId, rotate); diff --git a/test/declarations.ts b/test/declarations.ts index a1b9d87..c1da9de 100644 --- a/test/declarations.ts +++ b/test/declarations.ts @@ -9,7 +9,7 @@ import { UeRating } from 'src/ue/interfaces/rate.interface'; import { FakeUeAnnalType, FakeUser, FakeUe, FakeHomepageWidget, FakeAsso, FakeUeCreditCategory } from './utils/fakedb'; import { UeAnnalFile } from 'src/ue/annals/interfaces/annal.interface'; import { ConfigModule } from '../src/config/config.module'; -import {AppProvider, DEFAULT_APPLICATION} from './utils/test_utils'; +import { AppProvider, DEFAULT_APPLICATION } from './utils/test_utils'; import { getTranslation, omit, pick } from '../src/utils'; import { isArray } from 'class-validator'; import { Language } from '@prisma/client'; diff --git a/test/e2e/auth/cas-sign-up.e2e-spec.ts b/test/e2e/auth/cas-sign-up.e2e-spec.ts index c6b8f55..1c36386 100644 --- a/test/e2e/auth/cas-sign-up.e2e-spec.ts +++ b/test/e2e/auth/cas-sign-up.e2e-spec.ts @@ -10,9 +10,9 @@ import { FakeUser } from '../../utils/fakedb'; import { string } from 'pactum-matchers'; import { ERROR_CODE } from '../../../src/exceptions'; import { ConfigModule } from '../../../src/config/config.module'; -import { LdapServerMock, LdapUser } from 'ldap-server-mock'; +import { LdapUser } from 'ldap-server-mock'; import { HttpStatus } from '@nestjs/common'; -import {mockLdapServer} from "../../external_services/ldap"; +import { mockLdapServer } from '../../external_services/ldap'; const CasSignUpE2ESpec = e2eSuite('POST /auth/signup/cas', (app) => { const list: LdapUser[] = []; diff --git a/test/e2e/auth/signup-e2e-spec.ts b/test/e2e/auth/signup-e2e-spec.ts index eeeb886..b9a17fa 100644 --- a/test/e2e/auth/signup-e2e-spec.ts +++ b/test/e2e/auth/signup-e2e-spec.ts @@ -5,7 +5,6 @@ import { e2eSuite } from '../../utils/test_utils'; import { ERROR_CODE } from '../../../src/exceptions'; import { UserType } from '@prisma/client'; import { createUser } from '../../utils/fakedb'; -import { AuthService } from '../../../src/auth/auth.service'; import { JwtService } from '@nestjs/jwt'; const SignupE2ESpec = e2eSuite('POST /auth/signup', (app) => { diff --git a/test/e2e/auth/verify-e2e-spec.ts b/test/e2e/auth/verify-e2e-spec.ts index 99e68ae..dc0ac09 100644 --- a/test/e2e/auth/verify-e2e-spec.ts +++ b/test/e2e/auth/verify-e2e-spec.ts @@ -1,7 +1,6 @@ import * as pactum from 'pactum'; import { e2eSuite } from '../../utils/test_utils'; import { AuthService } from '../../../src/auth/auth.service'; -import { ConfigModule } from '../../../src/config/config.module'; import { ERROR_CODE } from '../../../src/exceptions'; const VerifyE2ESpec = e2eSuite('GET /token/signin', (app) => { diff --git a/test/e2e/ue/annals/get-annal-metadata.e2e-spec.ts b/test/e2e/ue/annals/get-annal-metadata.e2e-spec.ts index 5e50ef9..c7e9c5f 100644 --- a/test/e2e/ue/annals/get-annal-metadata.e2e-spec.ts +++ b/test/e2e/ue/annals/get-annal-metadata.e2e-spec.ts @@ -10,7 +10,7 @@ import { } from '../../../utils/fakedb'; import { e2eSuite } from '../../../utils/test_utils'; import { ERROR_CODE } from '../../../../src/exceptions'; -import {Permission} from "@prisma/client"; +import { Permission } from '@prisma/client'; const GetAnnalMetadata = e2eSuite('GET /ue/annals/metadata', (app) => { const ueUser = createUser(app); diff --git a/test/external_services/ldap.ts b/test/external_services/ldap.ts index fe76d1f..0fbd063 100644 --- a/test/external_services/ldap.ts +++ b/test/external_services/ldap.ts @@ -1,4 +1,4 @@ -import { LdapServerMock, LdapUser } from "ldap-server-mock"; +import { LdapServerMock, LdapUser } from 'ldap-server-mock'; export function mockLdapServer(list: LdapUser[]) { //console.log("loading server ", process.env.LDAP_URL) diff --git a/test/utils/test_utils.ts b/test/utils/test_utils.ts index c34b9ee..d39d864 100644 --- a/test/utils/test_utils.ts +++ b/test/utils/test_utils.ts @@ -49,9 +49,9 @@ function suite(name: string, func: (app: T) => void) { .get(PrismaService) .user.create({ data: { - login: "etuutt", - firstName: "Etu", - lastName: "UTT", + login: 'etuutt', + firstName: 'Etu', + lastName: 'UTT', userType: UserType.STUDENT, apiApplications: { create: { From 11d517472dc7da0f12dd70a49f1dd3d921f6691c Mon Sep 17 00:00:00 2001 From: Teddy Roncin Date: Tue, 5 Nov 2024 20:59:47 +0100 Subject: [PATCH 8/9] fix: modified user seeding to add api keys --- prisma/seed/modules/user.seed.ts | 13 +++++++++++ prisma/seed/seed.ts | 2 ++ prisma/seed/utils.ts | 30 +++++++++++++++++++++++- src/assos/assos.controller.ts | 1 + src/assos/assos.service.ts | 1 + src/auth/auth.service.ts | 7 +++--- src/{types.d.ts => types.ts} | 10 ++++---- src/ue/comments/comments.controller.ts | 1 + src/ue/comments/comments.service.ts | 1 + src/ue/ue.controller.ts | 1 + src/ue/ue.service.ts | 1 + src/users/users.controller.ts | 1 + test/declarations.ts | 4 +++- test/e2e/auth/cas-sign-in.e2e-spec.ts | 3 ++- test/utils/fakedb.ts | 3 ++- test/utils/test_utils.ts | 32 ++++---------------------- 16 files changed, 72 insertions(+), 39 deletions(-) rename src/{types.d.ts => types.ts} (56%) diff --git a/prisma/seed/modules/user.seed.ts b/prisma/seed/modules/user.seed.ts index 2b3645a..f26a0a2 100644 --- a/prisma/seed/modules/user.seed.ts +++ b/prisma/seed/modules/user.seed.ts @@ -2,6 +2,8 @@ import { PrismaClient, Sex } from '@prisma/client'; import { RawUser } from '../../../src/prisma/types'; import { faker } from '@faker-js/faker'; import * as bcrypt from 'bcryptjs'; +import { DEFAULT_APPLICATION } from '../utils'; +import { AuthService } from '../../../src/auth/auth.service'; const FAKER_ROUNDS = 100; @@ -22,6 +24,17 @@ export async function userSeed(prisma: PrismaClient): Promise { login: i === 0 ? 'student' : faker.internet.userName(), userType: 'STUDENT', hash, + apiKeys: { + create: { + token: AuthService.generateToken(), + tokenUpdatedAt: new Date(), + application: { + connect: { + id: DEFAULT_APPLICATION, + }, + }, + }, + }, rgpd: { create: {} }, infos: { create: { diff --git a/prisma/seed/seed.ts b/prisma/seed/seed.ts index 130d02d..079ac87 100644 --- a/prisma/seed/seed.ts +++ b/prisma/seed/seed.ts @@ -14,11 +14,13 @@ import ueSubscriptionSeed from './modules/ueSubscription.seed'; import assoSeed from './modules/asso.seed'; import assoMembershipRoleSeed from './modules/assoMembershipRole.seed'; import assoMembershipSeed from './modules/assoMembership.seed'; +import { generateDefaultApplication } from './utils'; const prisma = new PrismaClient(); async function main() { console.log('Flushing database...'); await cleanDb(prisma); + await generateDefaultApplication(prisma); //Set custom seed faker.seed(parseInt(process.env.FAKER_SEED)); diff --git a/prisma/seed/utils.ts b/prisma/seed/utils.ts index d0e946c..89fc5e6 100644 --- a/prisma/seed/utils.ts +++ b/prisma/seed/utils.ts @@ -1,6 +1,8 @@ import { Faker, faker } from '@faker-js/faker'; import { Entity, FakeEntityMap } from '../../test/utils/fakedb'; import { Translation } from 'src/prisma/types'; +import { PrismaClient, UserType } from '@prisma/client'; +import { PrismaService } from '../../src/prisma/prisma.service'; // While waiting to be able to recover the real data export const branchesCode = ['ISI', 'GM', 'RT', 'MTE', 'GI', 'SN', 'A2I', 'MM']; @@ -186,6 +188,8 @@ Faker.prototype.db = { }, }; +export { Faker }; + export function generateTranslation(rng: () => string = faker.random.words) { return { create: { @@ -196,4 +200,28 @@ export function generateTranslation(rng: () => string = faker.random.words) { }; } -export { Faker }; +export async function generateDefaultApplication(prisma: PrismaService | PrismaClient): Promise { + // Ok typing is broken there + await (prisma.user.create as any)({ + data: { + login: 'etuutt', + firstName: 'Etu', + lastName: 'UTT', + userType: UserType.STUDENT, + apiApplications: { + create: { + id: DEFAULT_APPLICATION, + name: faker.company.name(), + }, + }, + socialNetwork: { create: {} }, + rgpd: { create: {} }, + preference: { create: {} }, + infos: { create: {} }, + mailsPhones: { create: {} }, + privacy: { create: {} }, + }, + }); +} + +export const DEFAULT_APPLICATION = '52ce644d-183f-49e9-bd21-d2d4f37e2196'; diff --git a/src/assos/assos.controller.ts b/src/assos/assos.controller.ts index 8a5d5c2..794ef91 100644 --- a/src/assos/assos.controller.ts +++ b/src/assos/assos.controller.ts @@ -9,6 +9,7 @@ import AssoOverviewResDto from './dto/res/asso-overview-res.dto'; import AssoDetailResDto from './dto/res/asso-detail-res.dto'; import { ApiOkResponse, ApiOperation, ApiTags } from '@nestjs/swagger'; import { ApiAppErrorResponse, paginatedResponseDto } from '../app.dto'; +import { Pagination } from '../types'; @Controller('assos') @ApiTags('Assos') diff --git a/src/assos/assos.service.ts b/src/assos/assos.service.ts index 7b427aa..53add09 100644 --- a/src/assos/assos.service.ts +++ b/src/assos/assos.service.ts @@ -3,6 +3,7 @@ import { ConfigModule } from '../config/config.module'; import { PrismaService } from '../prisma/prisma.service'; import AssosSearchReqDto from './dto/req/assos-search-req.dto'; import { Asso } from './interfaces/asso.interface'; +import { Pagination } from '../types'; @Injectable() export class AssosService { diff --git a/src/auth/auth.service.ts b/src/auth/auth.service.ts index 5300aed..1694f5f 100644 --- a/src/auth/auth.service.ts +++ b/src/auth/auth.service.ts @@ -15,6 +15,7 @@ import { UeService } from '../ue/ue.service'; import { SemesterService } from '../semester/semester.service'; import AuthSignUpReqDto from './dto/req/auth-sign-up-req.dto'; import AuthSignInReqDto from './dto/req/auth-sign-in-req.dto'; +import { SetPartial } from '../types'; export type RegisterUserData = { login: string; mail: string; lastName: string; firstName: string }; export type RegisterApiKeyData = { userId: string; applicationId: string }; @@ -83,7 +84,7 @@ export class AuthService { }, apiKeys: { create: { - token: this.generateToken(), + token: AuthService.generateToken(), tokenUpdatedAt: new Date(), application: { connect: { id: applicationId } }, }, @@ -391,7 +392,7 @@ export class AuthService { id: applicationId, }, }, - token: this.generateToken(), + token: AuthService.generateToken(), tokenUpdatedAt: new Date(), }, }); @@ -402,7 +403,7 @@ export class AuthService { * Generates a completely random string composed of * @private */ - private generateToken(): string { + static generateToken(): string { const random = () => Math.random().toString(36).substring(2); const tokenLength = 128; let token = ''; diff --git a/src/types.d.ts b/src/types.ts similarity index 56% rename from src/types.d.ts rename to src/types.ts index aaa6df3..1e9b8f6 100644 --- a/src/types.d.ts +++ b/src/types.ts @@ -5,17 +5,17 @@ * @property itemsPerPage The number of items per page. * @property itemCount The total number of items. */ -declare interface Pagination { +export interface Pagination { items: T[]; itemsPerPage: number; itemCount: number; } -declare type UnpartialFields = { [P in K]-?: T[P] } & { +export type UnpartialFields = { [P in K]-?: T[P] } & { [P in keyof T]: P extends T ? never : T[P]; }; -declare type SetPartial = Omit & Partial>; -declare type RecursivelySetPartial = K extends `${infer K1}.${infer K2}` +export type SetPartial = Omit & Partial>; +export type RecursivelySetPartial = K extends `${infer K1}.${infer K2}` ? Omit & RecursivelySetPartial - : SetPartial; + : SetPartial; diff --git a/src/ue/comments/comments.controller.ts b/src/ue/comments/comments.controller.ts index 0854fed..0b1b2cc 100644 --- a/src/ue/comments/comments.controller.ts +++ b/src/ue/comments/comments.controller.ts @@ -17,6 +17,7 @@ import UeCommentReplyResDto from './dto/res/ue-comment-reply-res.dto'; import { Permission } from '@prisma/client'; import { GetPermissions } from '../../auth/decorator/get-permissions.decorator'; import { RequestPermissions } from '../../auth/interfaces/request-auth-data.interface'; +import { Pagination } from '../../types'; @Controller('ue/comments') @ApiTags('UE Comment') diff --git a/src/ue/comments/comments.service.ts b/src/ue/comments/comments.service.ts index 53093fc..3bf8085 100644 --- a/src/ue/comments/comments.service.ts +++ b/src/ue/comments/comments.service.ts @@ -8,6 +8,7 @@ import GetUeCommentsReqDto from './dto/req/ue-get-comments-req.dto'; import { UeCommentReply } from './interfaces/comment-reply.interface'; import { CommentStatus, UeComment } from './interfaces/comment.interface'; import { ConfigModule } from '../../config/config.module'; +import { Pagination } from '../../types'; @Injectable() export class CommentsService { diff --git a/src/ue/ue.controller.ts b/src/ue/ue.controller.ts index cc41def..45aa8ce 100644 --- a/src/ue/ue.controller.ts +++ b/src/ue/ue.controller.ts @@ -14,6 +14,7 @@ import UeOverviewResDto from './dto/res/ue-overview-res.dto'; import UeRateCriterionResDto from './dto/res/ue-rate-criterion-res.dto'; import UeRateResDto from './dto/res/ue-rate-res.dto'; import { Language, UserType } from '@prisma/client'; +import { Pagination } from '../types'; @Controller('ue') @ApiTags('UE') diff --git a/src/ue/ue.service.ts b/src/ue/ue.service.ts index 2899884..2440b78 100644 --- a/src/ue/ue.service.ts +++ b/src/ue/ue.service.ts @@ -9,6 +9,7 @@ import { RawUserUeSubscription } from '../prisma/types'; import { ConfigModule } from '../config/config.module'; import { Language, Prisma } from '@prisma/client'; import { SemesterService } from '../semester/semester.service'; +import { Pagination } from '../types'; @Injectable() export class UeService { diff --git a/src/users/users.controller.ts b/src/users/users.controller.ts index ed0bb82..bf68639 100644 --- a/src/users/users.controller.ts +++ b/src/users/users.controller.ts @@ -12,6 +12,7 @@ import { ApiAppErrorResponse, paginatedResponseDto } from '../app.dto'; import UserDetailResDto from './dto/res/user-detail-res.dto'; import UserBirthdayResDto from './dto/res/user-birthday-res.dto'; import UserAssoMembershipResDto from './dto/res/user-asso-membership-res.dto'; +import { Pagination } from '../types'; @Controller('users') @ApiTags('User') diff --git a/test/declarations.ts b/test/declarations.ts index c1da9de..6214dcd 100644 --- a/test/declarations.ts +++ b/test/declarations.ts @@ -9,10 +9,12 @@ import { UeRating } from 'src/ue/interfaces/rate.interface'; import { FakeUeAnnalType, FakeUser, FakeUe, FakeHomepageWidget, FakeAsso, FakeUeCreditCategory } from './utils/fakedb'; import { UeAnnalFile } from 'src/ue/annals/interfaces/annal.interface'; import { ConfigModule } from '../src/config/config.module'; -import { AppProvider, DEFAULT_APPLICATION } from './utils/test_utils'; +import { AppProvider } from './utils/test_utils'; import { getTranslation, omit, pick } from '../src/utils'; import { isArray } from 'class-validator'; import { Language } from '@prisma/client'; +import { DEFAULT_APPLICATION } from '../prisma/seed/utils'; +import { Pagination, SetPartial } from '../src/types'; /** Shortcut function for `this.expectStatus(200).expectJsonLike` */ function expect(obj: JsonLikeVariant) { diff --git a/test/e2e/auth/cas-sign-in.e2e-spec.ts b/test/e2e/auth/cas-sign-in.e2e-spec.ts index 20429fa..9b05cfa 100644 --- a/test/e2e/auth/cas-sign-in.e2e-spec.ts +++ b/test/e2e/auth/cas-sign-in.e2e-spec.ts @@ -1,4 +1,4 @@ -import { DEFAULT_APPLICATION, e2eSuite } from '../../utils/test_utils'; +import { e2eSuite } from '../../utils/test_utils'; import * as cas from '../../external_services/cas'; import * as fakedb from '../../utils/fakedb'; import * as pactum from 'pactum'; @@ -7,6 +7,7 @@ import { string } from 'pactum-matchers'; import { JwtService } from '@nestjs/jwt'; import { PrismaService } from '../../../src/prisma/prisma.service'; import AuthCasSignInReqDto from '../../../src/auth/dto/req/auth-cas-sign-in-req.dto'; +import { DEFAULT_APPLICATION } from '../../../prisma/seed/utils'; const CasSignInE2ESpec = e2eSuite('POST /auth/signin/cas', (app) => { const body: AuthCasSignInReqDto = { service: cas.validService, ticket: cas.validTicket, tokenExpiresIn: 1000 }; diff --git a/test/utils/fakedb.ts b/test/utils/fakedb.ts index 251920d..d5a996e 100644 --- a/test/utils/fakedb.ts +++ b/test/utils/fakedb.ts @@ -35,11 +35,12 @@ import { import { faker } from '@faker-js/faker'; import { AuthService } from '../../src/auth/auth.service'; import { PrismaService } from '../../src/prisma/prisma.service'; -import { AppProvider, DEFAULT_APPLICATION } from './test_utils'; +import { AppProvider } from './test_utils'; import { Permission, Sex, TimetableEntryType, UserType } from '@prisma/client'; import { CommentStatus } from '../../src/ue/comments/interfaces/comment.interface'; import { UeAnnalFile } from '../../src/ue/annals/interfaces/annal.interface'; import { omit, pick, translationSelect } from '../../src/utils'; +import { DEFAULT_APPLICATION } from '../../prisma/seed/utils'; /** * The fake entities can be used like normal entities in the it(string, () => void) functions. diff --git a/test/utils/test_utils.ts b/test/utils/test_utils.ts index d39d864..7a8eb25 100644 --- a/test/utils/test_utils.ts +++ b/test/utils/test_utils.ts @@ -4,8 +4,8 @@ import { TestingModule } from '@nestjs/testing'; import { faker } from '@faker-js/faker'; import { ConfigModule } from '../../src/config/config.module'; import { DMMF } from '@prisma/client/runtime/library'; -import { clearUniqueValues } from '../../prisma/seed/utils'; -import { PrismaClient, UserType } from '@prisma/client'; +import { clearUniqueValues, generateDefaultApplication } from '../../prisma/seed/utils'; +import { PrismaClient } from '@prisma/client'; /** * Initializes this file. @@ -44,30 +44,10 @@ function suite(name: string, func: (app: T) => void) { return (app: T) => describe(name, () => { beforeAll(async () => { - await cleanDb(app().get(PrismaService)); - await app() - .get(PrismaService) - .user.create({ - data: { - login: 'etuutt', - firstName: 'Etu', - lastName: 'UTT', - userType: UserType.STUDENT, - apiApplications: { - create: { - id: DEFAULT_APPLICATION, - name: faker.company.name(), - }, - }, - socialNetwork: { create: {} }, - rgpd: { create: {} }, - preference: { create: {} }, - infos: { create: {} }, - mailsPhones: { create: {} }, - privacy: { create: {} }, - }, - }); + const prisma = app().get(PrismaService); + await cleanDb(prisma); clearUniqueValues(); + await generateDefaultApplication(prisma); }); func(app); }); @@ -144,5 +124,3 @@ async function clearTableWithCascade(prisma: PrismaService | PrismaClient, model await prisma[modelName].deleteMany(); tablesCleared.push(modelName); } - -export const DEFAULT_APPLICATION = '52ce644d-183f-49e9-bd21-d2d4f37e2196'; From 8ebdacac626329c0e08e129a5c3c1fe6896cfa96 Mon Sep 17 00:00:00 2001 From: Teddy Roncin Date: Wed, 6 Nov 2024 11:18:26 +0100 Subject: [PATCH 9/9] fix: removed unit tests as they were failing because they are empty --- test/jest.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/jest.json b/test/jest.json index 3923f8c..784c5f6 100644 --- a/test/jest.json +++ b/test/jest.json @@ -3,7 +3,7 @@ "roots": [".", "../test"], "rootDir": "../src", "testEnvironment": "node", - "testRegex": ["e2e/app\\.e2e-spec\\.ts$", "unit/app\\.spec\\.ts$"], + "testRegex": ["e2e/app\\.e2e-spec\\.ts$"], //, "unit/app\\.spec\\.ts$"], "transform": { "^.+\\.(t|j)s$": "ts-jest" },