diff --git a/package-lock.json b/package-lock.json index 2286478..0698b0f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,6 +14,8 @@ "@nestjs/config": "^4.0.3", "@nestjs/core": "^11.1.15", "@nestjs/event-emitter": "^3.0.1", + "@nestjs/jwt": "^11.0.2", + "@nestjs/passport": "^11.0.5", "@nestjs/platform-express": "^11.1.15", "@nestjs/sequelize": "^11.0.1", "@nestjs/swagger": "^11.2.6", @@ -23,6 +25,8 @@ "helmet": "^8.1.0", "nanoid": "^5.1.5", "nestjs-pino": "^4.6.0", + "passport": "^0.7.0", + "passport-jwt": "^4.0.1", "pg": "^8.20.0", "pino-http": "^11.0.0", "pino-pretty": "^13.1.3", @@ -44,6 +48,7 @@ "@types/chance": "^1.1.7", "@types/express": "^5.0.6", "@types/node": "^22.15.0", + "@types/passport-jwt": "^4.0.1", "@vitest/coverage-v8": "^3.2.4", "chance": "^1.1.13", "eslint": "^9.18.0", @@ -1622,6 +1627,19 @@ "@nestjs/core": "^10.0.0 || ^11.0.0" } }, + "node_modules/@nestjs/jwt": { + "version": "11.0.2", + "resolved": "https://registry.npmjs.org/@nestjs/jwt/-/jwt-11.0.2.tgz", + "integrity": "sha512-rK8aE/3/Ma45gAWfCksAXUNbOoSOUudU0Kn3rT39htPF7wsYXtKfjALKeKKJbFrIWbLjsbqfXX5bIJNvgBugGA==", + "license": "MIT", + "dependencies": { + "@types/jsonwebtoken": "9.0.10", + "jsonwebtoken": "9.0.3" + }, + "peerDependencies": { + "@nestjs/common": "^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0" + } + }, "node_modules/@nestjs/mapped-types": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/@nestjs/mapped-types/-/mapped-types-2.1.0.tgz", @@ -1642,6 +1660,16 @@ } } }, + "node_modules/@nestjs/passport": { + "version": "11.0.5", + "resolved": "https://registry.npmjs.org/@nestjs/passport/-/passport-11.0.5.tgz", + "integrity": "sha512-ulQX6mbjlws92PIM15Naes4F4p2JoxGnIJuUsdXQPT+Oo2sqQmENEZXM7eYuimocfHnKlcfZOuyzbA33LwUlOQ==", + "license": "MIT", + "peerDependencies": { + "@nestjs/common": "^10.0.0 || ^11.0.0", + "passport": "^0.5.0 || ^0.6.0 || ^0.7.0" + } + }, "node_modules/@nestjs/platform-express": { "version": "11.1.17", "resolved": "https://registry.npmjs.org/@nestjs/platform-express/-/platform-express-11.1.17.tgz", @@ -2639,6 +2667,16 @@ "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", "license": "MIT" }, + "node_modules/@types/jsonwebtoken": { + "version": "9.0.10", + "resolved": "https://registry.npmjs.org/@types/jsonwebtoken/-/jsonwebtoken-9.0.10.tgz", + "integrity": "sha512-asx5hIG9Qmf/1oStypjanR7iKTv0gXQ1Ov/jfrX6kS/EO0OFni8orbmGCn0672NHR3kXHwpAwR+B368ZGN/2rA==", + "license": "MIT", + "dependencies": { + "@types/ms": "*", + "@types/node": "*" + } + }, "node_modules/@types/ms": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", @@ -2655,6 +2693,38 @@ "undici-types": "~6.21.0" } }, + "node_modules/@types/passport": { + "version": "1.0.17", + "resolved": "https://registry.npmjs.org/@types/passport/-/passport-1.0.17.tgz", + "integrity": "sha512-aciLyx+wDwT2t2/kJGJR2AEeBz0nJU4WuRX04Wu9Dqc5lSUtwu0WERPHYsLhF9PtseiAMPBGNUOtFjxZ56prsg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/express": "*" + } + }, + "node_modules/@types/passport-jwt": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@types/passport-jwt/-/passport-jwt-4.0.1.tgz", + "integrity": "sha512-Y0Ykz6nWP4jpxgEUYq8NoVZeCQPo1ZndJLfapI249g1jHChvRfZRO/LS3tqu26YgAS/laI1qx98sYGz0IalRXQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/jsonwebtoken": "*", + "@types/passport-strategy": "*" + } + }, + "node_modules/@types/passport-strategy": { + "version": "0.2.38", + "resolved": "https://registry.npmjs.org/@types/passport-strategy/-/passport-strategy-0.2.38.tgz", + "integrity": "sha512-GC6eMqqojOooq993Tmnmp7AUTbbQSgilyvpCYQjT+H6JfG/g6RGc7nXEniZlp0zyKJ0WUdOiZWLBZft9Yug1uA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/express": "*", + "@types/passport": "*" + } + }, "node_modules/@types/qs": { "version": "6.15.0", "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.15.0.tgz", @@ -3670,6 +3740,12 @@ "ieee754": "^1.1.13" } }, + "node_modules/buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", + "license": "BSD-3-Clause" + }, "node_modules/buffer-from": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", @@ -4404,6 +4480,15 @@ "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", "license": "MIT" }, + "node_modules/ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, "node_modules/editorconfig": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/editorconfig/-/editorconfig-1.0.7.tgz", @@ -6074,6 +6159,49 @@ "graceful-fs": "^4.1.6" } }, + "node_modules/jsonwebtoken": { + "version": "9.0.3", + "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz", + "integrity": "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==", + "license": "MIT", + "dependencies": { + "jws": "^4.0.1", + "lodash.includes": "^4.3.0", + "lodash.isboolean": "^3.0.3", + "lodash.isinteger": "^4.0.4", + "lodash.isnumber": "^3.0.3", + "lodash.isplainobject": "^4.0.6", + "lodash.isstring": "^4.0.1", + "lodash.once": "^4.0.0", + "ms": "^2.1.1", + "semver": "^7.5.4" + }, + "engines": { + "node": ">=12", + "npm": ">=6" + } + }, + "node_modules/jwa": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", + "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", + "license": "MIT", + "dependencies": { + "buffer-equal-constant-time": "^1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/jws": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz", + "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==", + "license": "MIT", + "dependencies": { + "jwa": "^2.0.1", + "safe-buffer": "^5.0.1" + } + }, "node_modules/keyv": { "version": "4.5.4", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", @@ -6295,6 +6423,42 @@ "integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==", "license": "MIT" }, + "node_modules/lodash.includes": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", + "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==", + "license": "MIT" + }, + "node_modules/lodash.isboolean": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz", + "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==", + "license": "MIT" + }, + "node_modules/lodash.isinteger": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz", + "integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==", + "license": "MIT" + }, + "node_modules/lodash.isnumber": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz", + "integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==", + "license": "MIT" + }, + "node_modules/lodash.isplainobject": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", + "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==", + "license": "MIT" + }, + "node_modules/lodash.isstring": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz", + "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==", + "license": "MIT" + }, "node_modules/lodash.merge": { "version": "4.6.2", "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", @@ -6302,6 +6466,12 @@ "dev": true, "license": "MIT" }, + "node_modules/lodash.once": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz", + "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==", + "license": "MIT" + }, "node_modules/log-symbols": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-4.1.0.tgz", @@ -7053,6 +7223,43 @@ "node": ">= 0.8" } }, + "node_modules/passport": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/passport/-/passport-0.7.0.tgz", + "integrity": "sha512-cPLl+qZpSc+ireUvt+IzqbED1cHHkDoVYMo30jbJIdOOjQ1MQYZBPiNvmi8UM6lJuOpTPXJGZQk0DtC4y61MYQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "passport-strategy": "1.x.x", + "pause": "0.0.1", + "utils-merge": "^1.0.1" + }, + "engines": { + "node": ">= 0.4.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/jaredhanson" + } + }, + "node_modules/passport-jwt": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/passport-jwt/-/passport-jwt-4.0.1.tgz", + "integrity": "sha512-UCKMDYhNuGOBE9/9Ycuoyh7vP6jpeTp/+sfMJl7nLff/t6dps+iaeE0hhNkKN8/HZHcJ7lCdOyDxHdDoxoSvdQ==", + "license": "MIT", + "dependencies": { + "jsonwebtoken": "^9.0.0", + "passport-strategy": "^1.0.0" + } + }, + "node_modules/passport-strategy": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/passport-strategy/-/passport-strategy-1.0.0.tgz", + "integrity": "sha512-CB97UUvDKJde2V0KDWWB3lyf6PC3FaZP7YxZ2G8OAtn9p4HI9j9JLP9qjOGZFvyl8uwNT8qM+hGnz/n16NI7oA==", + "engines": { + "node": ">= 0.4.0" + } + }, "node_modules/path-exists": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", @@ -7139,6 +7346,11 @@ "node": ">= 14.16" } }, + "node_modules/pause": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/pause/-/pause-0.0.1.tgz", + "integrity": "sha512-KG8UEiEVkR3wGEb4m5yZkVCzigAD+cVEJck2CzYZO37ZGJfctvVptVO192MwrtPhzONn6go8ylnOdMhKqi4nfg==" + }, "node_modules/pg": { "version": "8.20.0", "resolved": "https://registry.npmjs.org/pg/-/pg-8.20.0.tgz", @@ -9324,6 +9536,15 @@ "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", "license": "MIT" }, + "node_modules/utils-merge": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", + "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", + "license": "MIT", + "engines": { + "node": ">= 0.4.0" + } + }, "node_modules/uuid": { "version": "8.3.2", "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", diff --git a/package.json b/package.json index cac10ff..1139248 100644 --- a/package.json +++ b/package.json @@ -34,6 +34,8 @@ "@nestjs/config": "^4.0.3", "@nestjs/core": "^11.1.15", "@nestjs/event-emitter": "^3.0.1", + "@nestjs/jwt": "^11.0.2", + "@nestjs/passport": "^11.0.5", "@nestjs/platform-express": "^11.1.15", "@nestjs/sequelize": "^11.0.1", "@nestjs/swagger": "^11.2.6", @@ -43,6 +45,8 @@ "helmet": "^8.1.0", "nanoid": "^5.1.5", "nestjs-pino": "^4.6.0", + "passport": "^0.7.0", + "passport-jwt": "^4.0.1", "pg": "^8.20.0", "pino-http": "^11.0.0", "pino-pretty": "^13.1.3", @@ -64,6 +68,7 @@ "@types/chance": "^1.1.7", "@types/express": "^5.0.6", "@types/node": "^22.15.0", + "@types/passport-jwt": "^4.0.1", "@vitest/coverage-v8": "^3.2.4", "chance": "^1.1.13", "eslint": "^9.18.0", diff --git a/src/modules/auth/auth.guard.ts b/src/modules/auth/auth.guard.ts index 826ee4a..185d33d 100644 --- a/src/modules/auth/auth.guard.ts +++ b/src/modules/auth/auth.guard.ts @@ -1,12 +1,24 @@ -import { - type CanActivate, - type ExecutionContext, - Injectable, -} from '@nestjs/common'; +import { Injectable, type ExecutionContext } from '@nestjs/common'; +import { Reflector } from '@nestjs/core'; +import { AuthGuard } from '@nestjs/passport'; +import { IS_PUBLIC_KEY } from './decorators/public.decorator.js'; @Injectable() -export class AuthGuard implements CanActivate { - canActivate(_context: ExecutionContext): boolean { - return true; +export class JwtAuthGuard extends AuthGuard('jwt') { + constructor(private readonly reflector: Reflector) { + super(); + } + + canActivate(context: ExecutionContext) { + const isPublic = this.reflector.getAllAndOverride(IS_PUBLIC_KEY, [ + context.getHandler(), + context.getClass(), + ]); + + if (isPublic) { + return true; + } + + return super.canActivate(context); } } diff --git a/src/modules/auth/auth.module.ts b/src/modules/auth/auth.module.ts index 276a818..d65a86f 100644 --- a/src/modules/auth/auth.module.ts +++ b/src/modules/auth/auth.module.ts @@ -1,8 +1,18 @@ import { Module } from '@nestjs/common'; -import { AuthGuard } from './auth.guard'; +import { APP_GUARD } from '@nestjs/core'; +import { JwtModule } from '@nestjs/jwt'; +import { PassportModule } from '@nestjs/passport'; +import { JwtAuthGuard } from './auth.guard.js'; +import { JwtStrategy } from './jwt.strategy.js'; @Module({ - providers: [AuthGuard], - exports: [AuthGuard], + imports: [PassportModule, JwtModule.register({})], + providers: [ + JwtStrategy, + { + provide: APP_GUARD, + useClass: JwtAuthGuard, + }, + ], }) export class AuthModule {} diff --git a/src/modules/auth/decorators/public.decorator.ts b/src/modules/auth/decorators/public.decorator.ts new file mode 100644 index 0000000..b3845e1 --- /dev/null +++ b/src/modules/auth/decorators/public.decorator.ts @@ -0,0 +1,4 @@ +import { SetMetadata } from '@nestjs/common'; + +export const IS_PUBLIC_KEY = 'isPublic'; +export const Public = () => SetMetadata(IS_PUBLIC_KEY, true); diff --git a/src/modules/auth/decorators/user.decorator.ts b/src/modules/auth/decorators/user.decorator.ts new file mode 100644 index 0000000..91999cf --- /dev/null +++ b/src/modules/auth/decorators/user.decorator.ts @@ -0,0 +1,15 @@ +import { createParamDecorator, type ExecutionContext } from '@nestjs/common'; +import type { Request } from 'express'; +import type { UserPayload } from '../jwt-payload.dto.js'; + +interface AuthenticatedRequest extends Request { + user: UserPayload; +} + +export const User = createParamDecorator( + (field: keyof UserPayload | undefined, ctx: ExecutionContext) => { + const request = ctx.switchToHttp().getRequest(); + const user = request.user; + return field ? user[field] : user; + }, +); diff --git a/src/modules/auth/jwt-payload.dto.ts b/src/modules/auth/jwt-payload.dto.ts new file mode 100644 index 0000000..f63137f --- /dev/null +++ b/src/modules/auth/jwt-payload.dto.ts @@ -0,0 +1,18 @@ +export interface JwtTokenPayload { + payload: { + uuid: string; + email: string; + name: string; + lastname: string; + username: string; + sharedWorkspace: boolean; + networkCredentials: { + user: string; + }; + workspaces: { owners: string[] }; + }; + iat: number; + exp: number; +} + +export type UserPayload = JwtTokenPayload['payload']; diff --git a/src/modules/auth/jwt.strategy.spec.ts b/src/modules/auth/jwt.strategy.spec.ts new file mode 100644 index 0000000..6af5c1c --- /dev/null +++ b/src/modules/auth/jwt.strategy.spec.ts @@ -0,0 +1,41 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { Test, type TestingModule } from '@nestjs/testing'; +import { ConfigService } from '@nestjs/config'; +import { JwtStrategy } from './jwt.strategy.js'; +import type { JwtTokenPayload } from './jwt-payload.dto.js'; +import { newUserPayload } from '../../../test/fixtures.js'; + +describe('JwtStrategy', () => { + let strategy: JwtStrategy; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + JwtStrategy, + { + provide: ConfigService, + useValue: { + getOrThrow: () => 'test-jwt-secret', + }, + }, + ], + }).compile(); + + strategy = module.get(JwtStrategy); + }); + + describe('validate', () => { + it('When a valid token payload is provided, then it returns the inner payload', () => { + const userPayload = newUserPayload(); + const token: JwtTokenPayload = { + payload: userPayload, + iat: Math.floor(Date.now() / 1000), + exp: Math.floor(Date.now() / 1000) + 3600, + }; + + const result = strategy.validate(token); + + expect(result).toEqual(userPayload); + }); + }); +}); diff --git a/src/modules/auth/jwt.strategy.ts b/src/modules/auth/jwt.strategy.ts new file mode 100644 index 0000000..599ae49 --- /dev/null +++ b/src/modules/auth/jwt.strategy.ts @@ -0,0 +1,20 @@ +import { Injectable } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { PassportStrategy } from '@nestjs/passport'; +import { ExtractJwt, Strategy } from 'passport-jwt'; +import type { JwtTokenPayload, UserPayload } from './jwt-payload.dto.js'; + +@Injectable() +export class JwtStrategy extends PassportStrategy(Strategy) { + constructor(configService: ConfigService) { + super({ + jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), + ignoreExpiration: false, + secretOrKey: configService.getOrThrow('secrets.jwt'), + }); + } + + validate(token: JwtTokenPayload): UserPayload { + return token.payload; + } +} diff --git a/src/modules/email/email.controller.spec.ts b/src/modules/email/email.controller.spec.ts index 362bf12..bb0d2d8 100644 --- a/src/modules/email/email.controller.spec.ts +++ b/src/modules/email/email.controller.spec.ts @@ -1,13 +1,18 @@ import { describe, it, expect, beforeEach } from 'vitest'; import { Test, type TestingModule } from '@nestjs/testing'; import { createMock, type DeepMocked } from '@golevelup/ts-vitest'; -import { EmailController, STUB_USER } from './email.controller.js'; +import { EmailController } from './email.controller.js'; import { EmailService } from './email.service.js'; -import { newMailbox, newEmailSummary } from '../../../test/fixtures.js'; +import { + newMailbox, + newEmailSummary, + newUserPayload, +} from '../../../test/fixtures.js'; describe('EmailController', () => { let controller: EmailController; let emailService: DeepMocked; + const userEmail = newUserPayload().email; beforeEach(async () => { const module: TestingModule = await Test.createTestingModule({ @@ -25,8 +30,9 @@ describe('EmailController', () => { const mailboxes = [newMailbox(), newMailbox()]; emailService.getMailboxes.mockResolvedValue(mailboxes); - const result = await controller.getMailboxes(); + const result = await controller.getMailboxes(userEmail); + expect(emailService.getMailboxes).toHaveBeenCalledWith(userEmail); expect(result).toBe(mailboxes); }); }); @@ -36,41 +42,41 @@ describe('EmailController', () => { const response = { emails: [newEmailSummary()], total: 1 }; emailService.listEmails.mockResolvedValue(response); - const result = await controller.list('inbox'); + const result = await controller.list(userEmail, 'inbox'); - expect(emailService.listEmails.mock.calls[0]).toEqual([ - STUB_USER, + expect(emailService.listEmails).toHaveBeenCalledWith( + userEmail, 'inbox', 20, 0, - ]); + ); expect(result).toBe(response); }); it('When list is called with limit and position, then it parses them', async () => { emailService.listEmails.mockResolvedValue({ emails: [], total: 0 }); - await controller.list('sent', '10', '5'); + await controller.list(userEmail, 'sent', '10', '5'); - expect(emailService.listEmails.mock.calls[0]).toEqual([ - STUB_USER, + expect(emailService.listEmails).toHaveBeenCalledWith( + userEmail, 'sent', 10, 5, - ]); + ); }); it('When list is called with non-numeric strings, then it falls back to defaults', async () => { emailService.listEmails.mockResolvedValue({ emails: [], total: 0 }); - await controller.list('inbox', 'abc', 'xyz'); + await controller.list(userEmail, 'inbox', 'abc', 'xyz'); - expect(emailService.listEmails.mock.calls[0]).toEqual([ - STUB_USER, + expect(emailService.listEmails).toHaveBeenCalledWith( + userEmail, 'inbox', 20, 0, - ]); + ); }); }); }); diff --git a/src/modules/email/email.controller.ts b/src/modules/email/email.controller.ts index 3de426a..e2405f9 100644 --- a/src/modules/email/email.controller.ts +++ b/src/modules/email/email.controller.ts @@ -21,6 +21,7 @@ import { ApiQuery, ApiTags, } from '@nestjs/swagger'; +import { User } from '../auth/decorators/user.decorator.js'; import { EmailService } from './email.service.js'; import { DraftEmailRequestDto, @@ -33,9 +34,6 @@ import { } from './email.dto.js'; import type { MailboxType } from './email.types.js'; -// TODO: Replace with actual authenticated user from AuthGuard -export const STUB_USER = 'jose@codekishi.com'; - @ApiBearerAuth() @ApiTags('Email') @Controller('email') @@ -49,8 +47,8 @@ export class EmailController { 'Returns every mailbox for the authenticated user, including folder counts.', }) @ApiOkResponse({ type: [MailboxResponseDto] }) - getMailboxes() { - return this.emailService.getMailboxes(STUB_USER); + getMailboxes(@User('email') email: string) { + return this.emailService.getMailboxes(email); } @Get() @@ -81,12 +79,13 @@ export class EmailController { }) @ApiOkResponse({ type: EmailListResponseDto }) list( + @User('email') email: string, @Query('mailbox') mailbox: MailboxType = 'inbox', @Query('limit') limit?: string, @Query('position') position?: string, ) { return this.emailService.listEmails( - STUB_USER, + email, mailbox, limit ? Number(limit) || 20 : 20, position ? Number(position) || 0 : 0, @@ -102,8 +101,8 @@ export class EmailController { @ApiParam({ name: 'id', description: 'Email ID' }) @ApiOkResponse({ type: EmailResponseDto }) @ApiNotFoundResponse({ description: 'Email not found' }) - get(@Param('id') id: string) { - return this.emailService.getEmail(STUB_USER, id); + get(@User('email') email: string, @Param('id') id: string) { + return this.emailService.getEmail(email, id); } @Post('send') @@ -118,8 +117,8 @@ export class EmailController { type: EmailCreatedResponseDto, description: 'Email sent successfully', }) - send(@Body() dto: SendEmailRequestDto) { - return this.emailService.sendEmail(STUB_USER, dto); + send(@User('email') email: string, @Body() dto: SendEmailRequestDto) { + return this.emailService.sendEmail(email, dto); } @Post('drafts') @@ -133,8 +132,8 @@ export class EmailController { type: EmailCreatedResponseDto, description: 'Draft saved successfully', }) - saveDraft(@Body() dto: DraftEmailRequestDto) { - return this.emailService.saveDraft(STUB_USER, dto); + saveDraft(@User('email') email: string, @Body() dto: DraftEmailRequestDto) { + return this.emailService.saveDraft(email, dto); } @Patch(':id') @@ -149,16 +148,20 @@ export class EmailController { @ApiBody({ type: UpdateEmailRequestDto }) @ApiNoContentResponse({ description: 'Email updated successfully' }) @ApiNotFoundResponse({ description: 'Email not found' }) - async update(@Param('id') id: string, @Body() body: UpdateEmailRequestDto) { + async update( + @User('email') email: string, + @Param('id') id: string, + @Body() body: UpdateEmailRequestDto, + ) { const ops: Promise[] = []; if (body.mailbox !== undefined) { - ops.push(this.emailService.moveEmail(STUB_USER, id, body.mailbox)); + ops.push(this.emailService.moveEmail(email, id, body.mailbox)); } if (body.isRead !== undefined) { - ops.push(this.emailService.markAsRead(STUB_USER, id, body.isRead)); + ops.push(this.emailService.markAsRead(email, id, body.isRead)); } if (body.isFlagged !== undefined) { - ops.push(this.emailService.markAsFlagged(STUB_USER, id, body.isFlagged)); + ops.push(this.emailService.markAsFlagged(email, id, body.isFlagged)); } await Promise.all(ops); } @@ -172,7 +175,7 @@ export class EmailController { @ApiParam({ name: 'id', description: 'Email ID' }) @ApiNoContentResponse({ description: 'Email deleted successfully' }) @ApiNotFoundResponse({ description: 'Email not found' }) - delete(@Param('id') id: string) { - return this.emailService.deleteEmail(STUB_USER, id); + delete(@User('email') email: string, @Param('id') id: string) { + return this.emailService.deleteEmail(email, id); } } diff --git a/src/modules/health/health.controller.ts b/src/modules/health/health.controller.ts index 9f85795..3fb73b3 100644 --- a/src/modules/health/health.controller.ts +++ b/src/modules/health/health.controller.ts @@ -1,9 +1,11 @@ import { Controller, Get } from '@nestjs/common'; import { ApiTags } from '@nestjs/swagger'; +import { Public } from '../auth/decorators/public.decorator.js'; @ApiTags('Health') @Controller('health') export class HealthController { + @Public() @Get() check() { return { status: 'ok' }; diff --git a/test/fixtures.ts b/test/fixtures.ts index 1b6e7dd..adbdbad 100644 --- a/test/fixtures.ts +++ b/test/fixtures.ts @@ -8,6 +8,7 @@ import type { DraftEmailDto, MailboxType, } from '../src/modules/email/email.types.js'; +import type { UserPayload } from '../src/modules/auth/jwt-payload.dto.js'; import type { Mailbox as JmapMailbox, Email as JmapEmail, @@ -43,6 +44,20 @@ function randomISODate(): string { return random.date({ year: 2025 }).toString(); } +export function newUserPayload(attrs?: Partial): UserPayload { + return { + uuid: random.guid(), + email: random.email(), + name: random.first(), + lastname: random.last(), + username: random.email(), + sharedWorkspace: false, + networkCredentials: { user: random.hash({ length: 24 }) }, + workspaces: { owners: [] }, + ...attrs, + }; +} + // ── Domain Fixtures ──────────────────────────────────────────────── export function newEmailAddress(attrs?: Partial): EmailAddress {