From 1407a7156295a1ab677f44e7d377d49b71305814 Mon Sep 17 00:00:00 2001 From: y4nder Date: Thu, 26 Feb 2026 07:26:31 +0800 Subject: [PATCH] FAC-34 feat: implement login strategy pattern for auth service MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Refactor AuthService.Login() to use a strategy pattern that separates local password authentication from Moodle SSO authentication. - Add LoginStrategy interface with priority-based ordering - Create LocalLoginStrategy (priority 10) for local password auth - Create MoodleLoginStrategy (priority 100) for Moodle SSO - Strategies auto-sorted by priority in AuthService constructor - Add eslint rule to allow underscore-prefixed unused params - Preserve transactional integrity and MoodleConnectivityError handling 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- eslint.config.mjs | 4 + src/modules/auth/auth.module.ts | 21 +- src/modules/auth/auth.service.spec.ts | 338 +++++++----------- src/modules/auth/auth.service.ts | 105 ++---- src/modules/auth/strategies/index.ts | 3 + .../strategies/local-login.strategy.spec.ts | 103 ++++++ .../auth/strategies/local-login.strategy.ts | 36 ++ .../strategies/login-strategy.interface.ts | 56 +++ .../strategies/moodle-login.strategy.spec.ts | 196 ++++++++++ .../auth/strategies/moodle-login.strategy.ts | 70 ++++ .../ingestion/adapters/csv.adapter.spec.ts | 1 - .../ingestion/adapters/excel.adapter.spec.ts | 1 - .../ingestion/adapters/excel.adapter.ts | 2 +- .../validators/answers-validator.ts | 2 - 14 files changed, 649 insertions(+), 289 deletions(-) create mode 100644 src/modules/auth/strategies/index.ts create mode 100644 src/modules/auth/strategies/local-login.strategy.spec.ts create mode 100644 src/modules/auth/strategies/local-login.strategy.ts create mode 100644 src/modules/auth/strategies/login-strategy.interface.ts create mode 100644 src/modules/auth/strategies/moodle-login.strategy.spec.ts create mode 100644 src/modules/auth/strategies/moodle-login.strategy.ts diff --git a/eslint.config.mjs b/eslint.config.mjs index 0e2d8b9..f58fc35 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -29,6 +29,10 @@ export default tseslint.config( '@typescript-eslint/no-explicit-any': 'off', '@typescript-eslint/no-floating-promises': 'warn', '@typescript-eslint/no-unsafe-argument': 'warn', + '@typescript-eslint/no-unused-vars': [ + 'error', + { argsIgnorePattern: '^_', varsIgnorePattern: '^_' }, + ], "prettier/prettier": ["error", { endOfLine: "auto" }], // @ts-ignore "prettier/prettier": "off", diff --git a/src/modules/auth/auth.module.ts b/src/modules/auth/auth.module.ts index 2a3aafe..d4c8e0a 100644 --- a/src/modules/auth/auth.module.ts +++ b/src/modules/auth/auth.module.ts @@ -9,6 +9,11 @@ import MoodleModule from '../moodle/moodle.module'; import DataLoaderModule from '../common/data-loaders/index.module'; import { JwtStrategy } from 'src/security/passport-strategys/jwt.strategy'; import { JwtRefreshStrategy } from 'src/security/passport-strategys/refresh-jwt.strategy'; +import { + LOGIN_STRATEGIES, + LocalLoginStrategy, + MoodleLoginStrategy, +} from './strategies'; @Module({ imports: [ @@ -18,7 +23,21 @@ import { JwtRefreshStrategy } from 'src/security/passport-strategys/refresh-jwt. MoodleModule, ], controllers: [AuthController], - providers: [AuthService, JwtStrategy, JwtRefreshStrategy], + providers: [ + AuthService, + JwtStrategy, + JwtRefreshStrategy, + LocalLoginStrategy, + MoodleLoginStrategy, + { + provide: LOGIN_STRATEGIES, + useFactory: ( + localStrategy: LocalLoginStrategy, + moodleStrategy: MoodleLoginStrategy, + ) => [localStrategy, moodleStrategy], + inject: [LocalLoginStrategy, MoodleLoginStrategy], + }, + ], exports: [AuthService], }) export default class AuthModule {} diff --git a/src/modules/auth/auth.service.spec.ts b/src/modules/auth/auth.service.spec.ts index 3e85061..799396b 100644 --- a/src/modules/auth/auth.service.spec.ts +++ b/src/modules/auth/auth.service.spec.ts @@ -1,48 +1,39 @@ import { Test, TestingModule } from '@nestjs/testing'; import { AuthService } from './auth.service'; -import { MoodleService } from '../moodle/moodle.service'; -import { MoodleSyncService } from '../moodle/services/moodle-sync.service'; -import { MoodleUserHydrationService } from '../moodle/services/moodle-user-hydration.service'; import { CustomJwtService } from '../common/custom-jwt-service'; import UnitOfWork from '../common/unit-of-work'; import { User } from '../../entities/user.entity'; import * as bcrypt from 'bcrypt'; import { UnauthorizedException } from '@nestjs/common'; -import { MoodleConnectivityError } from '../moodle/lib/moodle.client'; +import { LOGIN_STRATEGIES, LoginStrategy } from './strategies'; +import { EntityManager } from '@mikro-orm/postgresql'; describe('AuthService', () => { let service: AuthService; - - let moodleService: MoodleService; - - let moodleSyncService: MoodleSyncService; - let moodleUserHydrationService: MoodleUserHydrationService; - let jwtService: CustomJwtService; - let unitOfWork: UnitOfWork; + let mockLocalStrategy: jest.Mocked; + let mockMoodleStrategy: jest.Mocked; beforeEach(async () => { + mockLocalStrategy = { + priority: 10, + CanHandle: jest.fn(), + Execute: jest.fn(), + }; + + mockMoodleStrategy = { + priority: 100, + CanHandle: jest.fn(), + Execute: jest.fn(), + }; + const module: TestingModule = await Test.createTestingModule({ providers: [ AuthService, { - provide: MoodleService, - useValue: { - Login: jest.fn(), - }, - }, - { - provide: MoodleSyncService, - useValue: { - SyncUserContext: jest.fn(), - }, - }, - { - provide: MoodleUserHydrationService, - useValue: { - hydrateUserCourses: jest.fn(), - }, + provide: LOGIN_STRATEGIES, + useValue: [mockLocalStrategy, mockMoodleStrategy], }, { provide: CustomJwtService, @@ -55,8 +46,7 @@ describe('AuthService', () => { useValue: { runInTransaction: jest .fn() - .mockImplementation((cb: (em: any) => any) => - // eslint-disable-next-line @typescript-eslint/no-unsafe-return + .mockImplementation((cb: (em: EntityManager) => unknown) => cb({ getRepository: jest.fn().mockReturnValue({ UpsertFromMoodle: jest.fn(), @@ -64,7 +54,7 @@ describe('AuthService', () => { }), findOne: jest.fn(), findOneOrFail: jest.fn(), - }), + } as unknown as EntityManager), ), }, }, @@ -72,11 +62,6 @@ describe('AuthService', () => { }).compile(); service = module.get(AuthService); - moodleService = module.get(MoodleService); - moodleSyncService = module.get(MoodleSyncService); - moodleUserHydrationService = module.get( - MoodleUserHydrationService, - ); jwtService = module.get(CustomJwtService); unitOfWork = module.get(UnitOfWork); }); @@ -85,8 +70,69 @@ describe('AuthService', () => { expect(service).toBeDefined(); }); + it('should sort strategies by priority (lower priority first)', async () => { + // Create strategies with reversed priority order in the array + const highPriorityStrategy: jest.Mocked = { + priority: 5, + CanHandle: jest.fn().mockReturnValue(true), + Execute: jest.fn().mockResolvedValue({ user: new User() }), + }; + + const lowPriorityStrategy: jest.Mocked = { + priority: 200, + CanHandle: jest.fn().mockReturnValue(true), + Execute: jest.fn().mockResolvedValue({ user: new User() }), + }; + + // Inject in wrong order (low priority first) + const moduleWithReversedOrder: TestingModule = + await Test.createTestingModule({ + providers: [ + AuthService, + { + provide: LOGIN_STRATEGIES, + useValue: [lowPriorityStrategy, highPriorityStrategy], + }, + { + provide: CustomJwtService, + useValue: { CreateSignedTokens: jest.fn().mockResolvedValue({}) }, + }, + { + provide: UnitOfWork, + useValue: { + runInTransaction: jest + .fn() + .mockImplementation((cb: (em: EntityManager) => unknown) => + cb({ findOne: jest.fn() } as unknown as EntityManager), + ), + }, + }, + ], + }).compile(); + + const serviceWithReversedOrder = + moduleWithReversedOrder.get(AuthService); + + await serviceWithReversedOrder.Login( + { username: 'test', password: 'test' }, + { browserName: 'test', os: 'test', ipAddress: '127.0.0.1' }, + ); + + // High priority (5) should be checked first and executed + // eslint-disable-next-line @typescript-eslint/unbound-method + expect(highPriorityStrategy.Execute).toHaveBeenCalled(); + // eslint-disable-next-line @typescript-eslint/unbound-method + expect(lowPriorityStrategy.Execute).not.toHaveBeenCalled(); + }); + describe('Login', () => { - it('should login locally if user has a password', async () => { + const mockMetadata = { + browserName: 'test', + os: 'test', + ipAddress: '127.0.0.1', + }; + + it('should use local strategy when user has a password', async () => { const password = 'password123'; const hashedPassword = await bcrypt.hash(password, 10); const mockUser = new User(); @@ -100,32 +146,37 @@ describe('AuthService', () => { }; (unitOfWork.runInTransaction as jest.Mock).mockImplementation( - // eslint-disable-next-line @typescript-eslint/no-unsafe-return - (cb: (em: any) => any) => cb(mockEm), + (cb: (em: EntityManager) => unknown) => + cb(mockEm as unknown as EntityManager), ); + mockLocalStrategy.CanHandle.mockReturnValue(true); + mockMoodleStrategy.CanHandle.mockReturnValue(false); + mockLocalStrategy.Execute.mockResolvedValue({ user: mockUser }); + (jwtService.CreateSignedTokens as jest.Mock).mockResolvedValue({ token: 'access', refreshToken: 'refresh', }); - const mockMetadata = { - browserName: 'test', - os: 'test', - ipAddress: '127.0.0.1', - }; - const result = await service.Login( { username: 'admin', password: 'password123' }, mockMetadata, ); expect(mockEm.findOne).toHaveBeenCalledWith(User, { userName: 'admin' }); + // eslint-disable-next-line @typescript-eslint/unbound-method + expect(mockLocalStrategy.CanHandle).toHaveBeenCalledWith(mockUser, { + username: 'admin', + password: 'password123', + }); + // eslint-disable-next-line @typescript-eslint/unbound-method + expect(mockLocalStrategy.Execute).toHaveBeenCalled(); expect(result).toBeDefined(); expect(result.token).toBe('access'); }); - it('should fall back to Moodle login if no local user exists', async () => { + it('should use moodle strategy when no local user exists', async () => { const mockEm = { findOne: jest.fn().mockResolvedValue(null), getRepository: jest.fn().mockReturnValue({ @@ -134,224 +185,83 @@ describe('AuthService', () => { }; (unitOfWork.runInTransaction as jest.Mock).mockImplementation( - // eslint-disable-next-line @typescript-eslint/no-unsafe-return - (cb: (em: any) => any) => cb(mockEm), + (cb: (em: EntityManager) => unknown) => + cb(mockEm as unknown as EntityManager), ); - (moodleService.Login as jest.Mock).mockResolvedValue({ - token: 'moodle-token', - }); - const mockUser = new User(); mockUser.id = 'moodle-user-id'; mockUser.moodleUserId = 123; - (moodleSyncService.SyncUserContext as jest.Mock).mockResolvedValue( - mockUser, - ); + + mockLocalStrategy.CanHandle.mockReturnValue(false); + mockMoodleStrategy.CanHandle.mockReturnValue(true); + mockMoodleStrategy.Execute.mockResolvedValue({ + user: mockUser, + moodleToken: 'moodle-token', + }); (jwtService.CreateSignedTokens as jest.Mock).mockResolvedValue({ token: 'access', refreshToken: 'refresh', }); - const mockMetadata = { - browserName: 'test', - os: 'test', - ipAddress: '127.0.0.1', - }; - await service.Login( { username: 'moodleuser', password: 'moodlepassword' }, mockMetadata, ); // eslint-disable-next-line @typescript-eslint/unbound-method - expect(moodleService.Login).toHaveBeenCalledTimes(1); + expect(mockMoodleStrategy.CanHandle).toHaveBeenCalled(); // eslint-disable-next-line @typescript-eslint/unbound-method - expect(moodleSyncService.SyncUserContext).toHaveBeenCalledWith( - 'moodle-token', - ); + expect(mockMoodleStrategy.Execute).toHaveBeenCalled(); }); - it('should throw UnauthorizedException if local password is invalid', async () => { - const mockUser = new User(); - mockUser.userName = 'admin'; - mockUser.password = await bcrypt.hash('correct-password', 10); - - const mockEm = { - findOne: jest.fn().mockResolvedValue(mockUser), - }; - - (unitOfWork.runInTransaction as jest.Mock).mockImplementation( - // eslint-disable-next-line @typescript-eslint/no-unsafe-return - (cb: (em: any) => any) => cb(mockEm), - ); - - const mockMetadata = { - browserName: 'test', - os: 'test', - ipAddress: '127.0.0.1', - }; - - await expect( - service.Login( - { username: 'admin', password: 'wrong-password' }, - mockMetadata, - ), - ).rejects.toThrow(UnauthorizedException); - }); - - it('should throw UnauthorizedException with descriptive message when Moodle service is unreachable', async () => { + it('should throw UnauthorizedException when no strategy can handle', async () => { const mockEm = { findOne: jest.fn().mockResolvedValue(null), - getRepository: jest.fn().mockReturnValue({ - UpsertFromMoodle: jest.fn(), - }), }; (unitOfWork.runInTransaction as jest.Mock).mockImplementation( - // eslint-disable-next-line @typescript-eslint/no-unsafe-return - (cb: (em: any) => any) => cb(mockEm), + (cb: (em: EntityManager) => unknown) => + cb(mockEm as unknown as EntityManager), ); - (moodleService.Login as jest.Mock).mockRejectedValue( - new MoodleConnectivityError('Failed to connect to Moodle service'), - ); - - const mockMetadata = { - browserName: 'test', - os: 'test', - ipAddress: '127.0.0.1', - }; + mockLocalStrategy.CanHandle.mockReturnValue(false); + mockMoodleStrategy.CanHandle.mockReturnValue(false); await expect( service.Login( - { username: 'moodleuser', password: 'moodlepassword' }, + { username: 'unknown', password: 'password' }, mockMetadata, ), - ).rejects.toThrow( - new UnauthorizedException( - 'Moodle service is currently unreachable. Please try again later.', - ), - ); - }); - - it('should throw UnauthorizedException when Moodle request times out', async () => { - const mockEm = { - findOne: jest.fn().mockResolvedValue(null), - getRepository: jest.fn().mockReturnValue({ - UpsertFromMoodle: jest.fn(), - }), - }; - - (unitOfWork.runInTransaction as jest.Mock).mockImplementation( - // eslint-disable-next-line @typescript-eslint/no-unsafe-return - (cb: (em: any) => any) => cb(mockEm), - ); - - const timeoutError = new Error('Timeout'); - timeoutError.name = 'TimeoutError'; - (moodleService.Login as jest.Mock).mockRejectedValue( - new MoodleConnectivityError('Moodle request timed out', timeoutError), - ); - - const mockMetadata = { - browserName: 'test', - os: 'test', - ipAddress: '127.0.0.1', - }; - - await expect( - service.Login( - { username: 'moodleuser', password: 'moodlepassword' }, - mockMetadata, - ), - ).rejects.toThrow( - new UnauthorizedException( - 'Moodle service is currently unreachable. Please try again later.', - ), - ); + ).rejects.toThrow(UnauthorizedException); }); - it('should throw UnauthorizedException when Moodle connectivity fails during hydration', async () => { - const mockEm = { - findOne: jest.fn().mockResolvedValue(null), - getRepository: jest.fn().mockReturnValue({ - UpsertFromMoodle: jest.fn(), - }), - }; - - (unitOfWork.runInTransaction as jest.Mock).mockImplementation( - // eslint-disable-next-line @typescript-eslint/no-unsafe-return - (cb: (em: any) => any) => cb(mockEm), - ); - - (moodleService.Login as jest.Mock).mockResolvedValue({ - token: 'moodle-token', - }); - + it('should throw UnauthorizedException when strategy execution fails', async () => { const mockUser = new User(); - mockUser.id = 'moodle-user-id'; - mockUser.moodleUserId = 123; - (moodleSyncService.SyncUserContext as jest.Mock).mockResolvedValue( - mockUser, - ); - - ( - moodleUserHydrationService.hydrateUserCourses as jest.Mock - ).mockRejectedValue( - new MoodleConnectivityError( - 'Failed to connect to Moodle during hydration', - ), - ); - - const mockMetadata = { - browserName: 'test', - os: 'test', - ipAddress: '127.0.0.1', - }; - - await expect( - service.Login( - { username: 'moodleuser', password: 'moodlepassword' }, - mockMetadata, - ), - ).rejects.toThrow( - new UnauthorizedException( - 'Moodle service is currently unreachable. Please try again later.', - ), - ); - }); + mockUser.userName = 'admin'; + mockUser.password = await bcrypt.hash('correct-password', 10); - it('should rethrow non-connectivity errors as-is', async () => { const mockEm = { - findOne: jest.fn().mockResolvedValue(null), - getRepository: jest.fn().mockReturnValue({ - UpsertFromMoodle: jest.fn(), - }), + findOne: jest.fn().mockResolvedValue(mockUser), }; (unitOfWork.runInTransaction as jest.Mock).mockImplementation( - // eslint-disable-next-line @typescript-eslint/no-unsafe-return - (cb: (em: any) => any) => cb(mockEm), + (cb: (em: EntityManager) => unknown) => + cb(mockEm as unknown as EntityManager), ); - (moodleService.Login as jest.Mock).mockRejectedValue( + mockLocalStrategy.CanHandle.mockReturnValue(true); + mockLocalStrategy.Execute.mockRejectedValue( new UnauthorizedException('Invalid credentials'), ); - const mockMetadata = { - browserName: 'test', - os: 'test', - ipAddress: '127.0.0.1', - }; - await expect( service.Login( - { username: 'moodleuser', password: 'moodlepassword' }, + { username: 'admin', password: 'wrong-password' }, mockMetadata, ), - ).rejects.toThrow(new UnauthorizedException('Invalid credentials')); + ).rejects.toThrow(UnauthorizedException); }); }); }); diff --git a/src/modules/auth/auth.service.ts b/src/modules/auth/auth.service.ts index b789827..81153f3 100644 --- a/src/modules/auth/auth.service.ts +++ b/src/modules/auth/auth.service.ts @@ -1,9 +1,11 @@ -import { Injectable, Logger, NotFoundException } from '@nestjs/common'; -import { MoodleService } from '../moodle/moodle.service'; +import { + Inject, + Injectable, + Logger, + NotFoundException, + UnauthorizedException, +} from '@nestjs/common'; import { LoginRequest } from './dto/requests/login.request.dto'; -import { MoodleSyncService } from '../moodle/services/moodle-sync.service'; -import { MoodleUserHydrationService } from '../moodle/services/moodle-user-hydration.service'; -import { MoodleTokenRepository } from '../../repositories/moodle-token.repository'; import UnitOfWork from '../common/unit-of-work'; import { JwtPayload } from '../common/custom-jwt-service/jwt-payload.dto'; import { CustomJwtService } from '../common/custom-jwt-service'; @@ -13,92 +15,57 @@ import { MeResponse } from './dto/responses/me.response.dto'; import { RequestMetadata } from '../common/interceptors/http/enriched-request'; import { RefreshJwtPayload } from '../common/custom-jwt-service/refresh-jwt-payload.dto'; import { v4 } from 'uuid'; -import { MoodleToken } from 'src/entities/moodle-token.entity'; import { RefreshToken } from 'src/entities/refresh-token.entity'; -import { UnauthorizedException } from '@nestjs/common'; import * as bcrypt from 'bcrypt'; import { RefreshTokenRepository } from 'src/repositories/refresh-token.repository'; -import { MoodleConnectivityError } from '../moodle/lib/moodle.client'; +import { LOGIN_STRATEGIES, LoginStrategy } from './strategies'; @Injectable() export class AuthService { private readonly logger = new Logger(AuthService.name); + private readonly sortedStrategies: LoginStrategy[]; + constructor( - private readonly moodleService: MoodleService, - private readonly moodleSyncService: MoodleSyncService, - private readonly moodleUserHydrationService: MoodleUserHydrationService, + @Inject(LOGIN_STRATEGIES) + loginStrategies: LoginStrategy[], private readonly jwtService: CustomJwtService, private readonly unitOfWork: UnitOfWork, - ) {} + ) { + this.sortedStrategies = [...loginStrategies].sort( + (a, b) => a.priority - b.priority, + ); + } async Login(body: LoginRequest, metaData: RequestMetadata) { return await this.unitOfWork.runInTransaction(async (em) => { - let user: User | null = null; - let moodleToken: string | undefined; - const localUser = await em.findOne(User, { userName: body.username }); - if (localUser && localUser.password) { - const isPasswordValid = await bcrypt.compare( - body.password, - localUser.password, + const strategy = this.sortedStrategies.find((s) => + s.CanHandle(localUser, body), + ); + + if (!strategy) { + this.logger.warn( + 'Login attempt failed: no matching authentication strategy', ); - if (!isPasswordValid) { - throw new UnauthorizedException('Invalid credentials'); - } - user = localUser; - } else { - // login via moodle create token - try { - const moodleTokenResponse = await this.moodleService.Login({ - username: body.username, - password: body.password, - }); - - moodleToken = moodleTokenResponse.token; - - // handle post login - user = await this.moodleSyncService.SyncUserContext( - moodleTokenResponse.token, - ); - - const moodleTokenRepository: MoodleTokenRepository = - em.getRepository(MoodleToken); - - await moodleTokenRepository.UpsertFromMoodle( - user, - moodleTokenResponse, - ); - - // Hydrate user courses and enrollments immediately (Moodle users only) - if (user.moodleUserId && moodleToken) { - await this.moodleUserHydrationService.hydrateUserCourses( - user.moodleUserId, - moodleToken, - ); - } - } catch (error) { - if (error instanceof MoodleConnectivityError) { - this.logger.error( - `Moodle connectivity failure during login for user "${body.username}": ${error.message}`, - error.cause?.stack, - ); - throw new UnauthorizedException( - 'Moodle service is currently unreachable. Please try again later.', - ); - } - throw error; - } + throw new UnauthorizedException('Invalid credentials'); } - // create jwt tokens - const jwtPayload = JwtPayload.Create(user.id, user.moodleUserId); - const refreshTokenPayload = RefreshJwtPayload.Create(user.id, v4()); + const result = await strategy.Execute(em, localUser, body); + + const jwtPayload = JwtPayload.Create( + result.user.id, + result.user.moodleUserId, + ); + const refreshTokenPayload = RefreshJwtPayload.Create( + result.user.id, + v4(), + ); const signedTokens = await this.jwtService.CreateSignedTokens({ jwt: jwtPayload, refreshJwt: refreshTokenPayload, - userId: user.id, + userId: result.user.id, metaData, }); diff --git a/src/modules/auth/strategies/index.ts b/src/modules/auth/strategies/index.ts new file mode 100644 index 0000000..9db53c6 --- /dev/null +++ b/src/modules/auth/strategies/index.ts @@ -0,0 +1,3 @@ +export * from './login-strategy.interface'; +export * from './local-login.strategy'; +export * from './moodle-login.strategy'; diff --git a/src/modules/auth/strategies/local-login.strategy.spec.ts b/src/modules/auth/strategies/local-login.strategy.spec.ts new file mode 100644 index 0000000..e5a2d6b --- /dev/null +++ b/src/modules/auth/strategies/local-login.strategy.spec.ts @@ -0,0 +1,103 @@ +import { UnauthorizedException } from '@nestjs/common'; +import { EntityManager } from '@mikro-orm/postgresql'; +import * as bcrypt from 'bcrypt'; +import { LocalLoginStrategy } from './local-login.strategy'; +import { User } from 'src/entities/user.entity'; + +describe('LocalLoginStrategy', () => { + let strategy: LocalLoginStrategy; + + beforeEach(() => { + strategy = new LocalLoginStrategy(); + }); + + it('should have priority 10 (core authentication)', () => { + expect(strategy.priority).toBe(10); + }); + + describe('CanHandle', () => { + it('should return true when user exists and has a password', () => { + const user = new User(); + user.password = 'hashed-password'; + + const result = strategy.CanHandle(user, { + username: 'test', + password: 'pass', + }); + + expect(result).toBe(true); + }); + + it('should return false when user is null', () => { + const result = strategy.CanHandle(null, { + username: 'test', + password: 'pass', + }); + + expect(result).toBe(false); + }); + + it('should return false when user has no password', () => { + const user = new User(); + user.password = null; + + const result = strategy.CanHandle(user, { + username: 'test', + password: 'pass', + }); + + expect(result).toBe(false); + }); + }); + + describe('Execute', () => { + it('should return user when password is valid', async () => { + const password = 'password123'; + const hashedPassword = await bcrypt.hash(password, 10); + const user = new User(); + user.id = 'user-id'; + user.password = hashedPassword; + + const result = await strategy.Execute({} as EntityManager, user, { + username: 'test', + password, + }); + + expect(result.user).toBe(user); + expect(result.moodleToken).toBeUndefined(); + }); + + it('should throw UnauthorizedException when password is invalid', async () => { + const user = new User(); + user.password = await bcrypt.hash('correct-password', 10); + + await expect( + strategy.Execute({} as EntityManager, user, { + username: 'test', + password: 'wrong-password', + }), + ).rejects.toThrow(UnauthorizedException); + }); + + it('should throw UnauthorizedException when user is null', async () => { + await expect( + strategy.Execute({} as EntityManager, null, { + username: 'test', + password: 'password', + }), + ).rejects.toThrow(UnauthorizedException); + }); + + it('should throw UnauthorizedException when user has no password', async () => { + const user = new User(); + user.password = null; + + await expect( + strategy.Execute({} as EntityManager, user, { + username: 'test', + password: 'password', + }), + ).rejects.toThrow(UnauthorizedException); + }); + }); +}); diff --git a/src/modules/auth/strategies/local-login.strategy.ts b/src/modules/auth/strategies/local-login.strategy.ts new file mode 100644 index 0000000..8d03ff3 --- /dev/null +++ b/src/modules/auth/strategies/local-login.strategy.ts @@ -0,0 +1,36 @@ +import { Injectable, UnauthorizedException } from '@nestjs/common'; +import { EntityManager } from '@mikro-orm/postgresql'; +import * as bcrypt from 'bcrypt'; +import { LoginRequest } from '../dto/requests/login.request.dto'; +import { User } from 'src/entities/user.entity'; +import { LoginStrategy, LoginStrategyResult } from './login-strategy.interface'; + +@Injectable() +export class LocalLoginStrategy implements LoginStrategy { + readonly priority = 10; + + CanHandle(localUser: User | null, _body: LoginRequest): boolean { + return localUser !== null && localUser.password !== null; + } + + async Execute( + _em: EntityManager, + localUser: User | null, + body: LoginRequest, + ): Promise { + if (!localUser || !localUser.password) { + throw new UnauthorizedException('Invalid credentials'); + } + + const isPasswordValid = await bcrypt.compare( + body.password, + localUser.password, + ); + + if (!isPasswordValid) { + throw new UnauthorizedException('Invalid credentials'); + } + + return { user: localUser }; + } +} diff --git a/src/modules/auth/strategies/login-strategy.interface.ts b/src/modules/auth/strategies/login-strategy.interface.ts new file mode 100644 index 0000000..61ba8fb --- /dev/null +++ b/src/modules/auth/strategies/login-strategy.interface.ts @@ -0,0 +1,56 @@ +import { EntityManager } from '@mikro-orm/postgresql'; +import { LoginRequest } from '../dto/requests/login.request.dto'; +import { User } from 'src/entities/user.entity'; + +/** + * Result from a login strategy execution. + */ +export interface LoginStrategyResult { + /** The authenticated user entity */ + user: User; + /** + * Moodle session token (only set by MoodleLoginStrategy). + * Available for audit logging or future features requiring Moodle API calls. + */ + moodleToken?: string; +} + +/** + * Interface for login strategies. + * Each strategy handles a specific authentication method (local, moodle, etc.) + * Strategies are evaluated in priority order (lower number = higher priority). + */ +export interface LoginStrategy { + /** + * Priority determines evaluation order. Lower values are checked first. + * Recommended ranges: + * - 0-99: Core authentication (local passwords) + * - 100-199: External providers (Moodle, LDAP, OAuth) + * - 200+: Fallback strategies + */ + readonly priority: number; + + /** + * Determines if this strategy can handle the login for the given user. + * @param localUser - The user found by username (null if not found) + * @param body - The login request containing credentials (for future extensibility) + * @returns true if this strategy should handle the login + */ + CanHandle(localUser: User | null, body: LoginRequest): boolean; + + /** + * Executes the login strategy within the provided transaction. + * @param em - EntityManager for database operations + * @param localUser - The user found by username (null if not found) + * @param body - The login request containing credentials + * @returns The authenticated user and optional moodle token + * @throws UnauthorizedException if credentials are invalid + */ + Execute( + em: EntityManager, + localUser: User | null, + body: LoginRequest, + ): Promise; +} + +export const LOGIN_STRATEGIES = Symbol('LOGIN_STRATEGIES'); diff --git a/src/modules/auth/strategies/moodle-login.strategy.spec.ts b/src/modules/auth/strategies/moodle-login.strategy.spec.ts new file mode 100644 index 0000000..8532b5d --- /dev/null +++ b/src/modules/auth/strategies/moodle-login.strategy.spec.ts @@ -0,0 +1,196 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { UnauthorizedException } from '@nestjs/common'; +import { EntityManager } from '@mikro-orm/postgresql'; +import { MoodleLoginStrategy } from './moodle-login.strategy'; +import { MoodleService } from 'src/modules/moodle/moodle.service'; +import { MoodleSyncService } from 'src/modules/moodle/services/moodle-sync.service'; +import { MoodleUserHydrationService } from 'src/modules/moodle/services/moodle-user-hydration.service'; +import { MoodleConnectivityError } from 'src/modules/moodle/lib/moodle.client'; +import { User } from 'src/entities/user.entity'; + +describe('MoodleLoginStrategy', () => { + let strategy: MoodleLoginStrategy; + let moodleService: jest.Mocked; + let moodleSyncService: jest.Mocked; + let moodleUserHydrationService: jest.Mocked; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + MoodleLoginStrategy, + { + provide: MoodleService, + useValue: { + Login: jest.fn(), + }, + }, + { + provide: MoodleSyncService, + useValue: { + SyncUserContext: jest.fn(), + }, + }, + { + provide: MoodleUserHydrationService, + useValue: { + hydrateUserCourses: jest.fn(), + }, + }, + ], + }).compile(); + + strategy = module.get(MoodleLoginStrategy); + moodleService = module.get(MoodleService); + moodleSyncService = module.get(MoodleSyncService); + moodleUserHydrationService = module.get(MoodleUserHydrationService); + }); + + it('should have priority 100 (external provider)', () => { + expect(strategy.priority).toBe(100); + }); + + describe('CanHandle', () => { + it('should return true when user is null', () => { + const result = strategy.CanHandle(null, { + username: 'test', + password: 'pass', + }); + + expect(result).toBe(true); + }); + + it('should return true when user has no password', () => { + const user = new User(); + user.password = null; + + const result = strategy.CanHandle(user, { + username: 'test', + password: 'pass', + }); + + expect(result).toBe(true); + }); + + it('should return false when user has a password', () => { + const user = new User(); + user.password = 'hashed-password'; + + const result = strategy.CanHandle(user, { + username: 'test', + password: 'pass', + }); + + expect(result).toBe(false); + }); + }); + + describe('Execute', () => { + const mockEm = { + getRepository: jest.fn().mockReturnValue({ + UpsertFromMoodle: jest.fn(), + }), + } as unknown as EntityManager; + + it('should return user and moodle token on successful login', async () => { + const mockUser = new User(); + mockUser.id = 'user-id'; + mockUser.moodleUserId = 123; + + moodleService.Login.mockResolvedValue({ token: 'moodle-token' }); + moodleSyncService.SyncUserContext.mockResolvedValue(mockUser); + + const result = await strategy.Execute(mockEm, null, { + username: 'moodleuser', + password: 'moodlepassword', + }); + + expect(result.user).toBe(mockUser); + expect(result.moodleToken).toBe('moodle-token'); + // eslint-disable-next-line @typescript-eslint/unbound-method + expect(moodleService.Login).toHaveBeenCalledWith({ + username: 'moodleuser', + password: 'moodlepassword', + }); + // eslint-disable-next-line @typescript-eslint/unbound-method + expect(moodleSyncService.SyncUserContext).toHaveBeenCalledWith( + 'moodle-token', + ); + // eslint-disable-next-line @typescript-eslint/unbound-method + expect( + moodleUserHydrationService.hydrateUserCourses, + ).toHaveBeenCalledWith(123, 'moodle-token'); + }); + + it('should throw UnauthorizedException when Moodle connectivity fails', async () => { + moodleService.Login.mockRejectedValue( + new MoodleConnectivityError('Failed to connect'), + ); + + await expect( + strategy.Execute(mockEm, null, { + username: 'moodleuser', + password: 'moodlepassword', + }), + ).rejects.toThrow( + new UnauthorizedException( + 'Moodle service is currently unreachable. Please try again later.', + ), + ); + }); + + it('should throw UnauthorizedException when Moodle connectivity fails during hydration', async () => { + const mockUser = new User(); + mockUser.id = 'user-id'; + mockUser.moodleUserId = 123; + + moodleService.Login.mockResolvedValue({ token: 'moodle-token' }); + moodleSyncService.SyncUserContext.mockResolvedValue(mockUser); + moodleUserHydrationService.hydrateUserCourses.mockRejectedValue( + new MoodleConnectivityError('Failed during hydration'), + ); + + await expect( + strategy.Execute(mockEm, null, { + username: 'moodleuser', + password: 'moodlepassword', + }), + ).rejects.toThrow( + new UnauthorizedException( + 'Moodle service is currently unreachable. Please try again later.', + ), + ); + }); + + it('should rethrow non-connectivity errors', async () => { + moodleService.Login.mockRejectedValue( + new UnauthorizedException('Invalid credentials'), + ); + + await expect( + strategy.Execute(mockEm, null, { + username: 'moodleuser', + password: 'moodlepassword', + }), + ).rejects.toThrow(new UnauthorizedException('Invalid credentials')); + }); + + it('should skip hydration when user has no moodleUserId', async () => { + const mockUser = new User(); + mockUser.id = 'user-id'; + mockUser.moodleUserId = null; + + moodleService.Login.mockResolvedValue({ token: 'moodle-token' }); + moodleSyncService.SyncUserContext.mockResolvedValue(mockUser); + + await strategy.Execute(mockEm, null, { + username: 'moodleuser', + password: 'moodlepassword', + }); + + // eslint-disable-next-line @typescript-eslint/unbound-method + expect( + moodleUserHydrationService.hydrateUserCourses, + ).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/src/modules/auth/strategies/moodle-login.strategy.ts b/src/modules/auth/strategies/moodle-login.strategy.ts new file mode 100644 index 0000000..db6beee --- /dev/null +++ b/src/modules/auth/strategies/moodle-login.strategy.ts @@ -0,0 +1,70 @@ +import { Injectable, Logger, UnauthorizedException } from '@nestjs/common'; +import { EntityManager } from '@mikro-orm/postgresql'; +import { LoginRequest } from '../dto/requests/login.request.dto'; +import { User } from 'src/entities/user.entity'; +import { MoodleToken } from 'src/entities/moodle-token.entity'; +import { MoodleTokenRepository } from 'src/repositories/moodle-token.repository'; +import { MoodleService } from 'src/modules/moodle/moodle.service'; +import { MoodleSyncService } from 'src/modules/moodle/services/moodle-sync.service'; +import { MoodleUserHydrationService } from 'src/modules/moodle/services/moodle-user-hydration.service'; +import { MoodleConnectivityError } from 'src/modules/moodle/lib/moodle.client'; +import { LoginStrategy, LoginStrategyResult } from './login-strategy.interface'; + +@Injectable() +export class MoodleLoginStrategy implements LoginStrategy { + readonly priority = 100; + + private readonly logger = new Logger(MoodleLoginStrategy.name); + + constructor( + private readonly moodleService: MoodleService, + private readonly moodleSyncService: MoodleSyncService, + private readonly moodleUserHydrationService: MoodleUserHydrationService, + ) {} + + CanHandle(localUser: User | null, _body: LoginRequest): boolean { + return localUser === null || localUser.password === null; + } + + async Execute( + em: EntityManager, + _localUser: User | null, + body: LoginRequest, + ): Promise { + try { + const moodleTokenResponse = await this.moodleService.Login({ + username: body.username, + password: body.password, + }); + + const moodleToken = moodleTokenResponse.token; + + const user = await this.moodleSyncService.SyncUserContext(moodleToken); + + const moodleTokenRepository: MoodleTokenRepository = + em.getRepository(MoodleToken); + + await moodleTokenRepository.UpsertFromMoodle(user, moodleTokenResponse); + + if (user.moodleUserId && moodleToken) { + await this.moodleUserHydrationService.hydrateUserCourses( + user.moodleUserId, + moodleToken, + ); + } + + return { user, moodleToken }; + } catch (error) { + if (error instanceof MoodleConnectivityError) { + this.logger.error( + `Moodle connectivity failure during login for user "${body.username}": ${error.message}`, + error.cause?.stack, + ); + throw new UnauthorizedException( + 'Moodle service is currently unreachable. Please try again later.', + ); + } + throw error; + } + } +} diff --git a/src/modules/questionnaires/ingestion/adapters/csv.adapter.spec.ts b/src/modules/questionnaires/ingestion/adapters/csv.adapter.spec.ts index 7a83174..f72a7a8 100644 --- a/src/modules/questionnaires/ingestion/adapters/csv.adapter.spec.ts +++ b/src/modules/questionnaires/ingestion/adapters/csv.adapter.spec.ts @@ -93,7 +93,6 @@ Jane,456`; const destroySpy = jest.spyOn(stream, 'destroy'); const config = { dryRun: false }; - // eslint-disable-next-line @typescript-eslint/no-unused-vars for await (const _ of adapter.extract(stream, config)) { break; // Abort early } diff --git a/src/modules/questionnaires/ingestion/adapters/excel.adapter.spec.ts b/src/modules/questionnaires/ingestion/adapters/excel.adapter.spec.ts index 0d9328a..f34cc69 100644 --- a/src/modules/questionnaires/ingestion/adapters/excel.adapter.spec.ts +++ b/src/modules/questionnaires/ingestion/adapters/excel.adapter.spec.ts @@ -108,7 +108,6 @@ describe('ExcelAdapter', () => { const config = { dryRun: false }; - // eslint-disable-next-line @typescript-eslint/no-unused-vars for await (const _ of adapter.extract(stream, config)) { break; } diff --git a/src/modules/questionnaires/ingestion/adapters/excel.adapter.ts b/src/modules/questionnaires/ingestion/adapters/excel.adapter.ts index 584747b..f2e865d 100644 --- a/src/modules/questionnaires/ingestion/adapters/excel.adapter.ts +++ b/src/modules/questionnaires/ingestion/adapters/excel.adapter.ts @@ -39,7 +39,7 @@ export class ExcelAdapter extends BaseStreamAdapter { if (!isTarget) { // We must consume the worksheet reader even if we don't use it // to move the workbook reader forward. - // eslint-disable-next-line @typescript-eslint/no-unused-vars + for await (const _ of worksheetReader) { // Skip } diff --git a/src/modules/questionnaires/validators/answers-validator.ts b/src/modules/questionnaires/validators/answers-validator.ts index 883ff2b..a5b4421 100644 --- a/src/modules/questionnaires/validators/answers-validator.ts +++ b/src/modules/questionnaires/validators/answers-validator.ts @@ -11,7 +11,6 @@ export class IsValidAnswersConstraint implements ValidatorConstraintInterface { private static readonly MAX_ANSWERS_COUNT = 1000; private static readonly MAX_JSON_SIZE_BYTES = 100 * 1024; // 100KB - // eslint-disable-next-line @typescript-eslint/no-unused-vars validate(answers: unknown, _args: ValidationArguments): boolean { // Must be an object if ( @@ -58,7 +57,6 @@ export class IsValidAnswersConstraint implements ValidatorConstraintInterface { return true; } - // eslint-disable-next-line @typescript-eslint/no-unused-vars defaultMessage(_args: ValidationArguments): string { return 'Answers must be a non-empty object with string keys and numeric values, containing at most 1000 entries and 100KB total size'; }