import type { Logger } from '@n8n/backend-common';
import type { GlobalConfig } from '@n8n/config';
import { Time } from '@n8n/constants';
import type {
	AuthenticatedRequest,
	User,
	InvalidAuthTokenRepository,
	UserRepository,
} from '@n8n/db';
import { GLOBAL_MEMBER_ROLE, GLOBAL_OWNER_ROLE } from '@n8n/db';
import type { NextFunction, Response } from 'express';
import { mock } from 'jest-mock-extended';
import jwt from 'jsonwebtoken';

import { AuthService } from '@/auth/auth.service';
import { AUTH_COOKIE_NAME } from '@/constants';
import type { MfaService } from '@/mfa/mfa.service';
import { JwtService } from '@/services/jwt.service';
import type { UrlService } from '@/services/url.service';
import type { License } from '@/license';

describe('AuthService', () => {
	const browserId = 'test-browser-id';
	const userData = {
		id: '123',
		email: 'test@example.com',
		password: 'passwordHash',
		disabled: false,
		mfaEnabled: false,
		role: GLOBAL_OWNER_ROLE,
	};
	const user = mock<User>(userData);
	const globalConfig = mock<GlobalConfig>({
		auth: { cookie: { secure: true, samesite: 'lax' } },
		userManagement: { jwtSecret: 'random-secret' },
	});
	const jwtService = new JwtService(mock(), globalConfig);
	const urlService = mock<UrlService>();
	const userRepository = mock<UserRepository>();
	const invalidAuthTokenRepository = mock<InvalidAuthTokenRepository>();
	const mfaService = mock<MfaService>();
	const license = mock<License>();
	const logger = mock<Logger>();
	const authService = new AuthService(
		globalConfig,
		logger,
		license,
		jwtService,
		urlService,
		userRepository,
		invalidAuthTokenRepository,
		mfaService,
	);

	const now = new Date('2024-02-01T01:23:45.678Z');
	jest.useFakeTimers({ now });

	const validToken =
		'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjEyMyIsImhhc2giOiJtSkFZeDRXYjdrIiwiYnJvd3NlcklkIjoiOFpDVXE1YU1uSFhnMFZvcURLcm9hMHNaZ0NwdWlPQ1AzLzB2UmZKUXU0MD0iLCJ1c2VkTWZhIjpmYWxzZSwiaWF0IjoxNzA2NzUwNjI1LCJleHAiOjE3MDczNTU0MjV9.N7JgwETmO41o4FUDVb4pA1HM3Clj4jyjDK-lE8Fa1Zw'; // Generated using `authService.issueJWT(user, false, browserId)`

	const validTokenWithMfa =
		'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjEyMyIsImhhc2giOiJtSkFZeDRXYjdrIiwiYnJvd3NlcklkIjoiOFpDVXE1YU1uSFhnMFZvcURLcm9hMHNaZ0NwdWlPQ1AzLzB2UmZKUXU0MD0iLCJ1c2VkTWZhIjp0cnVlLCJpYXQiOjE3MDY3NTA2MjUsImV4cCI6MTcwNzM1NTQyNX0.9kTTue-ZdBQ0CblH0IrqW9K-k0WWfxfsWTglyPB10ko'; // Generated using `authService.issueJWT(user, true, browserId)`

	beforeEach(() => {
		jest.resetAllMocks();
		jest.setSystemTime(now);
		globalConfig.userManagement.jwtSessionDurationHours = 168;
		globalConfig.userManagement.jwtRefreshTimeoutHours = 0;
		globalConfig.auth.cookie = { secure: true, samesite: 'lax' };
		license.isWithinUsersLimit.mockReturnValue(true);
	});

	describe('createJWTHash', () => {
		it('should generate unique hashes', () => {
			expect(authService.createJWTHash(user)).toEqual('mJAYx4Wb7k');
			expect(
				authService.createJWTHash(
					mock<User>({ email: user.email, password: 'newPasswordHash', mfaEnabled: false }),
				),
			).toEqual('FVALtU7AE0');
			expect(
				authService.createJWTHash(
					mock<User>({ email: 'test1@example.com', password: user.password, mfaEnabled: false }),
				),
			).toEqual('y8ha6X01jd');
			expect(
				authService.createJWTHash(
					mock<User>({
						email: user.email,
						password: user.password,
						mfaEnabled: true,
						mfaSecret: 'secret',
					}),
				),
			).toEqual('WUXEVFet9W');
			expect(
				authService.createJWTHash(
					mock<User>({
						email: user.email,
						password: 'newPasswordHash',
						mfaEnabled: true,
						mfaSecret: 'secret',
					}),
				),
			).toEqual('toYQYKufH6');
		});
	});

	describe('createAuthMiddleware', () => {
		const mockReq = () =>
			mock<AuthenticatedRequest>({
				cookies: {},
				user: undefined,
				browserId,
			});
		const res = mock<Response>();
		const next = jest.fn() as NextFunction;

		beforeEach(() => {
			res.status.mockReturnThis();
		});

		it('should 401 if no cookie is set', async () => {
			const req = mockReq();
			req.cookies[AUTH_COOKIE_NAME] = undefined;

			const middleware = authService.createAuthMiddleware({ allowSkipMFA: true });

			await middleware(req, res, next);

			expect(invalidAuthTokenRepository.existsBy).not.toHaveBeenCalled();
			expect(next).not.toHaveBeenCalled();
			expect(res.status).toHaveBeenCalledWith(401);
		});

		it('should 401 and clear the cookie if the JWT is expired', async () => {
			const req = mockReq();
			req.cookies[AUTH_COOKIE_NAME] = validToken;
			invalidAuthTokenRepository.existsBy.mockResolvedValue(false);
			jest.advanceTimersByTime(365 * Time.days.toMilliseconds);

			const middleware = authService.createAuthMiddleware({ allowSkipMFA: true });

			await middleware(req, res, next);

			expect(invalidAuthTokenRepository.existsBy).toHaveBeenCalled();
			expect(userRepository.findOne).not.toHaveBeenCalled();
			expect(next).not.toHaveBeenCalled();
			expect(res.status).toHaveBeenCalledWith(401);
			expect(res.clearCookie).toHaveBeenCalledWith(AUTH_COOKIE_NAME);
		});

		it('should 401 and clear the cookie if the JWT has been invalidated', async () => {
			const req = mockReq();
			req.cookies[AUTH_COOKIE_NAME] = validToken;
			invalidAuthTokenRepository.existsBy.mockResolvedValue(true);

			const middleware = authService.createAuthMiddleware({ allowSkipMFA: true });

			await middleware(req, res, next);

			expect(invalidAuthTokenRepository.existsBy).toHaveBeenCalled();
			expect(userRepository.findOne).not.toHaveBeenCalled();
			expect(next).not.toHaveBeenCalled();
			expect(res.status).toHaveBeenCalledWith(401);
			expect(res.clearCookie).toHaveBeenCalledWith(AUTH_COOKIE_NAME);
		});

		it('should 401 but not clear the cookie if 2FA is enforced and not configured for the user', async () => {
			const req = mockReq();
			req.cookies[AUTH_COOKIE_NAME] = validToken;
			userRepository.findOne.mockResolvedValue(user);
			invalidAuthTokenRepository.existsBy.mockResolvedValue(false);
			mfaService.isMFAEnforced.mockResolvedValue(true);

			const middleware = authService.createAuthMiddleware({ allowSkipMFA: false });

			await middleware(req, res, next);

			expect(invalidAuthTokenRepository.existsBy).toHaveBeenCalled();
			expect(userRepository.findOne).toHaveBeenCalled();
			expect(next).not.toHaveBeenCalled();
			expect(res.status).toHaveBeenCalledWith(401);
			expect(res.clearCookie).not.toHaveBeenCalledWith();
		});

		it('should refresh the cookie before it expires', async () => {
			const req = mockReq();
			req.cookies[AUTH_COOKIE_NAME] = validToken;
			jest.advanceTimersByTime(6 * Time.days.toMilliseconds);
			invalidAuthTokenRepository.existsBy.mockResolvedValue(false);
			userRepository.findOne.mockResolvedValue(user);

			const middleware = authService.createAuthMiddleware({ allowSkipMFA: true });

			await middleware(req, res, next);

			expect(next).toHaveBeenCalled();
			expect(res.cookie).toHaveBeenCalledWith('n8n-auth', expect.any(String), {
				httpOnly: true,
				maxAge: 604800000,
				sameSite: 'lax',
				secure: true,
			});
		});

		describe('allowSkipPreviewAuth', () => {
			let originalPreviewMode: string | undefined;

			beforeEach(() => {
				// Store original value
				originalPreviewMode = process.env.N8N_PREVIEW_MODE;
				// Reset mocks
				jest.resetAllMocks();
				res.status.mockReturnThis();
			});

			afterEach(() => {
				// Restore original value
				if (originalPreviewMode === undefined) {
					delete process.env.N8N_PREVIEW_MODE;
				} else {
					process.env.N8N_PREVIEW_MODE = originalPreviewMode;
				}
			});

			it('should skip authentication when allowSkipPreviewAuth is true and preview mode is enabled', async () => {
				process.env.N8N_PREVIEW_MODE = 'true';
				const req = mockReq();
				req.cookies[AUTH_COOKIE_NAME] = undefined;

				const middleware = authService.createAuthMiddleware({
					allowSkipMFA: true,
					allowSkipPreviewAuth: true,
				});

				await middleware(req, res, next);

				expect(invalidAuthTokenRepository.existsBy).not.toHaveBeenCalled();
				expect(userRepository.findOne).not.toHaveBeenCalled();
				expect(next).toHaveBeenCalled();
				expect(res.status).not.toHaveBeenCalled();
			});

			it('should NOT skip authentication when allowSkipPreviewAuth is false even in preview mode', async () => {
				process.env.N8N_PREVIEW_MODE = 'true';
				const req = mockReq();
				req.cookies[AUTH_COOKIE_NAME] = undefined;

				const middleware = authService.createAuthMiddleware({
					allowSkipMFA: true,
					allowSkipPreviewAuth: false,
				});

				await middleware(req, res, next);

				expect(invalidAuthTokenRepository.existsBy).not.toHaveBeenCalled();
				expect(userRepository.findOne).not.toHaveBeenCalled();
				expect(next).not.toHaveBeenCalled();
				expect(res.status).toHaveBeenCalledWith(401);
			});

			it('should NOT skip authentication when allowSkipPreviewAuth is true but preview mode is disabled', async () => {
				process.env.N8N_PREVIEW_MODE = 'false';
				const req = mockReq();
				req.cookies[AUTH_COOKIE_NAME] = undefined;

				const middleware = authService.createAuthMiddleware({
					allowSkipMFA: true,
					allowSkipPreviewAuth: true,
				});

				await middleware(req, res, next);

				expect(invalidAuthTokenRepository.existsBy).not.toHaveBeenCalled();
				expect(userRepository.findOne).not.toHaveBeenCalled();
				expect(next).not.toHaveBeenCalled();
				expect(res.status).toHaveBeenCalledWith(401);
			});

			it('should NOT skip authentication when allowSkipPreviewAuth is true but preview mode is undefined', async () => {
				delete process.env.N8N_PREVIEW_MODE;
				const req = mockReq();
				req.cookies[AUTH_COOKIE_NAME] = undefined;

				const middleware = authService.createAuthMiddleware({
					allowSkipMFA: true,
					allowSkipPreviewAuth: true,
				});

				await middleware(req, res, next);

				expect(invalidAuthTokenRepository.existsBy).not.toHaveBeenCalled();
				expect(userRepository.findOne).not.toHaveBeenCalled();
				expect(next).not.toHaveBeenCalled();
				expect(res.status).toHaveBeenCalledWith(401);
			});

			it('should still process valid authentication normally in preview mode with allowSkipPreviewAuth true', async () => {
				process.env.N8N_PREVIEW_MODE = 'true';
				const req = mockReq();
				req.cookies[AUTH_COOKIE_NAME] = validToken;
				invalidAuthTokenRepository.existsBy.mockResolvedValue(false);
				userRepository.findOne.mockResolvedValue(user);

				const middleware = authService.createAuthMiddleware({
					allowSkipMFA: true,
					allowSkipPreviewAuth: true,
				});

				await middleware(req, res, next);

				expect(invalidAuthTokenRepository.existsBy).toHaveBeenCalled();
				expect(userRepository.findOne).toHaveBeenCalled();
				expect(req.user).toBe(user);
				expect(next).toHaveBeenCalled();
				expect(res.status).not.toHaveBeenCalled();
			});

			it('should handle authentication errors normally in preview mode with allowSkipPreviewAuth true', async () => {
				process.env.N8N_PREVIEW_MODE = 'true';
				const req = mockReq();
				req.cookies[AUTH_COOKIE_NAME] = 'invalid-token';
				invalidAuthTokenRepository.existsBy.mockResolvedValue(false);

				const middleware = authService.createAuthMiddleware({
					allowSkipMFA: true,
					allowSkipPreviewAuth: true,
				});

				await middleware(req, res, next);

				expect(invalidAuthTokenRepository.existsBy).toHaveBeenCalled();
				expect(res.clearCookie).toHaveBeenCalledWith(AUTH_COOKIE_NAME);
				expect(next).toHaveBeenCalled(); // Should still call next() due to preview mode skip
				expect(res.status).not.toHaveBeenCalled();
			});
		});

		describe('allowUnauthenticated', () => {
			it('should populate the user info if the token is valid', async () => {
				const req = mockReq();
				req.cookies[AUTH_COOKIE_NAME] = validToken;
				invalidAuthTokenRepository.existsBy.mockResolvedValue(false);
				userRepository.findOne.mockResolvedValue(user);

				const middleware = authService.createAuthMiddleware({
					allowSkipMFA: false,
					allowUnauthenticated: true,
				});

				await middleware(req, res, next);

				expect(invalidAuthTokenRepository.existsBy).toHaveBeenCalled();
				expect(userRepository.findOne).toHaveBeenCalled();
				expect(req.user).toBe(user);
				expect(next).toHaveBeenCalled();
				expect(res.clearCookie).not.toHaveBeenCalled();
			});

			it('should clear the cookie if the token is expired', async () => {
				const req = mockReq();
				req.cookies[AUTH_COOKIE_NAME] = validToken;
				invalidAuthTokenRepository.existsBy.mockResolvedValue(false);
				jest.advanceTimersByTime(365 * Time.days.toMilliseconds);

				const middleware = authService.createAuthMiddleware({
					allowSkipMFA: false,
					allowUnauthenticated: true,
				});

				await middleware(req, res, next);

				expect(invalidAuthTokenRepository.existsBy).toHaveBeenCalled();
				expect(userRepository.findOne).not.toHaveBeenCalled();
				expect(req.user).toBeUndefined();
				expect(next).toHaveBeenCalled();
				expect(res.clearCookie).toHaveBeenCalledWith(AUTH_COOKIE_NAME);
			});

			it('should clear the cookie if the token has been invalidated', async () => {
				const req = mockReq();
				req.cookies[AUTH_COOKIE_NAME] = validToken;
				invalidAuthTokenRepository.existsBy.mockResolvedValue(true);

				const middleware = authService.createAuthMiddleware({
					allowSkipMFA: false,
					allowUnauthenticated: true,
				});

				await middleware(req, res, next);

				expect(invalidAuthTokenRepository.existsBy).toHaveBeenCalled();
				expect(userRepository.findOne).not.toHaveBeenCalled();
				expect(req.user).toBeUndefined();
				expect(next).toHaveBeenCalled();
				expect(res.clearCookie).toHaveBeenCalledWith(AUTH_COOKIE_NAME);
			});

			it('should not populate the user info if the token is invalid', async () => {
				const req = mockReq();
				req.cookies[AUTH_COOKIE_NAME] = 'invalid-token';
				invalidAuthTokenRepository.existsBy.mockResolvedValue(false);

				const middleware = authService.createAuthMiddleware({
					allowSkipMFA: false,
					allowUnauthenticated: true,
				});

				await middleware(req, res, next);

				expect(invalidAuthTokenRepository.existsBy).toHaveBeenCalled();
				expect(userRepository.findOne).not.toHaveBeenCalled();
				expect(req.user).toBeUndefined();
				expect(next).toHaveBeenCalled();
				expect(res.clearCookie).toHaveBeenCalledWith(AUTH_COOKIE_NAME);
			});

			it('should not populate the user info if the token is not set', async () => {
				const req = mockReq();
				req.cookies[AUTH_COOKIE_NAME] = undefined;

				const middleware = authService.createAuthMiddleware({
					allowSkipMFA: false,
					allowUnauthenticated: true,
				});

				await middleware(req, res, next);

				expect(invalidAuthTokenRepository.existsBy).not.toHaveBeenCalled();
				expect(userRepository.findOne).not.toHaveBeenCalled();
				expect(req.user).toBeUndefined();
				expect(next).toHaveBeenCalled();
				expect(res.clearCookie).not.toHaveBeenCalled();
			});

			it('should clear cookie if MFA required and not used', async () => {
				const userWithMfa = mock<User>({ ...userData, mfaEnabled: true, mfaSecret: 'secret' });

				const req = mockReq();
				req.cookies[AUTH_COOKIE_NAME] = validToken; // validToken has usedMfa: false

				invalidAuthTokenRepository.existsBy.mockResolvedValue(false);
				userRepository.findOne.mockResolvedValue(userWithMfa);
				mfaService.isMFAEnforced.mockResolvedValue(true);

				const middleware = authService.createAuthMiddleware({
					allowSkipMFA: false,
					allowUnauthenticated: true,
				});

				await middleware(req, res, next);

				expect(invalidAuthTokenRepository.existsBy).toHaveBeenCalled();
				expect(userRepository.findOne).toHaveBeenCalled();
				expect(req.user).toBeUndefined();
				expect(next).toHaveBeenCalled();
				expect(res.clearCookie).toHaveBeenCalledWith(AUTH_COOKIE_NAME);
			});

			it('should skip user when MFA enforced and user has no MFA', async () => {
				const req = mockReq();
				req.cookies[AUTH_COOKIE_NAME] = validToken; // validToken has usedMfa: false

				invalidAuthTokenRepository.existsBy.mockResolvedValue(false);
				userRepository.findOne.mockResolvedValue(user); // user has mfaEnabled: false
				mfaService.isMFAEnforced.mockResolvedValue(true);

				const middleware = authService.createAuthMiddleware({
					allowSkipMFA: false,
					allowUnauthenticated: true,
				});

				await middleware(req, res, next);

				expect(invalidAuthTokenRepository.existsBy).toHaveBeenCalled();
				expect(userRepository.findOne).toHaveBeenCalled();
				expect(req.user).toBeUndefined();
				expect(next).toHaveBeenCalled();
				expect(res.status).not.toHaveBeenCalled();
				expect(res.clearCookie).not.toHaveBeenCalled();
			});

			it('should work correctly when both allowUnauthenticated and allowSkipPreviewAuth are true in preview mode', async () => {
				const originalPreviewMode = process.env.N8N_PREVIEW_MODE;
				process.env.N8N_PREVIEW_MODE = 'true';

				const req = mockReq();
				req.cookies[AUTH_COOKIE_NAME] = undefined;

				const middleware = authService.createAuthMiddleware({
					allowSkipMFA: false,
					allowUnauthenticated: true,
					allowSkipPreviewAuth: true,
				});

				await middleware(req, res, next);

				expect(invalidAuthTokenRepository.existsBy).not.toHaveBeenCalled();
				expect(userRepository.findOne).not.toHaveBeenCalled();
				expect(req.user).toBeUndefined();
				expect(next).toHaveBeenCalled();
				expect(res.status).not.toHaveBeenCalled();

				// Restore original value
				if (originalPreviewMode === undefined) {
					delete process.env.N8N_PREVIEW_MODE;
				} else {
					process.env.N8N_PREVIEW_MODE = originalPreviewMode;
				}
			});
		});
	});

	describe('issueCookie', () => {
		const res = mock<Response>();
		it('should issue a cookie with the correct options', () => {
			authService.issueCookie(res, user, false, browserId);

			expect(res.cookie).toHaveBeenCalledWith('n8n-auth', validToken, {
				httpOnly: true,
				maxAge: 604800000,
				sameSite: 'lax',
				secure: true,
			});
		});

		describe('when user limit is reached', () => {
			it('should block issuance if the user is not the global owner', async () => {
				user.role = GLOBAL_MEMBER_ROLE;
				license.isWithinUsersLimit.mockReturnValue(false);
				expect(() => {
					authService.issueCookie(res, user, false, browserId);
				}).toThrowError('Maximum number of users reached');
			});

			it('should allow issuance if the user is the global owner', async () => {
				license.isWithinUsersLimit.mockReturnValue(false);
				user.role = GLOBAL_OWNER_ROLE;
				expect(() => {
					authService.issueCookie(res, user, false, browserId);
				}).not.toThrowError('Maximum number of users reached');
				expect(res.cookie).toHaveBeenCalledWith('n8n-auth', validToken, {
					httpOnly: true,
					maxAge: 604800000,
					sameSite: 'lax',
					secure: true,
				});
			});
		});

		it('should issue a cookie with the correct options, when 2FA was used', () => {
			authService.issueCookie(res, user, true, browserId);

			expect(res.cookie).toHaveBeenCalledWith('n8n-auth', validTokenWithMfa, {
				httpOnly: true,
				maxAge: 604800000,
				sameSite: 'lax',
				secure: true,
			});
		});

		it('should allow changing cookie options', () => {
			globalConfig.auth.cookie = { secure: false, samesite: 'none' };

			authService.issueCookie(res, user, false, browserId);

			expect(res.cookie).toHaveBeenCalledWith('n8n-auth', validToken, {
				httpOnly: true,
				maxAge: 604800000,
				sameSite: 'none',
				secure: false,
			});
		});
	});

	describe('issueJWT', () => {
		describe('when not setting userManagement.jwtSessionDuration', () => {
			it('should default to expire in 7 days', () => {
				const defaultInSeconds = 7 * Time.days.toSeconds;
				const token = authService.issueJWT(user, false, browserId);

				expect(authService.jwtExpiration).toBe(defaultInSeconds);
				const decodedToken = jwtService.verify(token);
				if (decodedToken.exp === undefined || decodedToken.iat === undefined) {
					fail('Expected exp and iat to be defined');
				}

				expect(decodedToken.exp - decodedToken.iat).toBe(defaultInSeconds);
			});
		});

		describe('when setting userManagement.jwtSessionDuration', () => {
			const testDurationHours = 1;
			const testDurationSeconds = testDurationHours * Time.hours.toSeconds;

			it('should apply it to tokens', () => {
				globalConfig.userManagement.jwtSessionDurationHours = testDurationHours;
				const token = authService.issueJWT(user, false, browserId);

				const decodedToken = jwtService.verify(token);
				if (decodedToken.exp === undefined || decodedToken.iat === undefined) {
					fail('Expected exp and iat to be defined on decodedToken');
				}
				expect(decodedToken.exp - decodedToken.iat).toBe(testDurationSeconds);
			});
		});
	});

	describe('resolveJwt', () => {
		const req = mock<AuthenticatedRequest>({
			cookies: {},
			user: undefined,
			browserId,
		});
		const res = mock<Response>();
		let originalSkipBrowserIdCheckEndpoints: string[];

		beforeEach(() => {
			// Save the original skipBrowserIdCheckEndpoints value
			originalSkipBrowserIdCheckEndpoints = (authService as any).skipBrowserIdCheckEndpoints;
		});

		afterEach(() => {
			// Restore the original skipBrowserIdCheckEndpoints value
			(authService as any).skipBrowserIdCheckEndpoints = originalSkipBrowserIdCheckEndpoints;
		});

		it('should throw on invalid tokens', async () => {
			await expect(authService.resolveJwt('random-string', req, res)).rejects.toThrow(
				'jwt malformed',
			);
			expect(res.cookie).not.toHaveBeenCalled();
		});

		it('should throw on expired tokens', async () => {
			jest.advanceTimersByTime(365 * Time.days.toMilliseconds);

			await expect(authService.resolveJwt(validToken, req, res)).rejects.toThrow('jwt expired');
			expect(res.cookie).not.toHaveBeenCalled();
		});

		it('should throw on tampered tokens', async () => {
			const [header, payload, signature] = validToken.split('.');
			const tamperedToken = [header, payload, signature + '123'].join('.');
			await expect(authService.resolveJwt(tamperedToken, req, res)).rejects.toThrow(
				'invalid signature',
			);
			expect(res.cookie).not.toHaveBeenCalled();
		});

		it('should throw on hijacked tokens', async () => {
			userRepository.findOne.mockResolvedValue(user);
			const req = mock<AuthenticatedRequest>({
				browserId: 'another-browser',
				method: 'POST',
				baseUrl: '/api',
				route: { path: '/some-endpoint' },
			});
			await expect(authService.resolveJwt(validToken, req, res)).rejects.toThrow('Unauthorized');
			expect(res.cookie).not.toHaveBeenCalled();
		});

		it('should skip browserId check for GET requests on skip endpoints', async () => {
			userRepository.findOne.mockResolvedValue(user);
			const req = mock<AuthenticatedRequest>({
				browserId: 'another-browser',
				method: 'GET',
				baseUrl: '/api',
				route: { path: '/chat/sessions' },
			});

			// Mock skipBrowserIdCheckEndpoints to include this endpoint
			(authService as any).skipBrowserIdCheckEndpoints = ['/api/chat/sessions'];

			// Should not throw even though browserId is different
			const result = await authService.resolveJwt(validToken, req, res);
			expect(result).toEqual([user, { usedMfa: false }]);
			expect(res.cookie).not.toHaveBeenCalled();
		});

		it('should not skip browserId check for POST requests on skip endpoints', async () => {
			userRepository.findOne.mockResolvedValue(user);
			const req = mock<AuthenticatedRequest>({
				browserId: 'another-browser',
				method: 'POST',
				baseUrl: '/api',
				route: { path: '/chat/sessions' },
			});

			// Mock skipBrowserIdCheckEndpoints to include this endpoint
			(authService as any).skipBrowserIdCheckEndpoints = ['/api/chat/sessions'];

			// Should still throw for POST even on skip endpoint
			await expect(authService.resolveJwt(validToken, req, res)).rejects.toThrow('Unauthorized');
			expect(res.cookie).not.toHaveBeenCalled();
		});

		test.each([
			['no user is found', null],
			['the user is disabled', { ...userData, disabled: true }],
			[
				'user password does not match the one on the token',
				{ ...userData, password: 'something else' },
			],
			[
				'user email does not match the one on the token',
				{ ...userData, email: 'someone@example.com' },
			],
			[
				'user mfa secret does not match the one on the token',
				{ ...userData, mfaEnabled: true, mfaSecret: '123' },
			],
		])('should throw if %s', async (_, data) => {
			userRepository.findOne.mockResolvedValueOnce(data && mock<User>(data));
			await expect(authService.resolveJwt(validToken, req, res)).rejects.toThrow('Unauthorized');
			expect(res.cookie).not.toHaveBeenCalled();
		});

		it('should refresh the cookie before it expires', async () => {
			userRepository.findOne.mockResolvedValue(user);
			expect(await authService.resolveJwt(validToken, req, res)).toEqual([
				user,
				{ usedMfa: false },
			]);
			expect(res.cookie).not.toHaveBeenCalled();

			jest.advanceTimersByTime(6 * Time.days.toMilliseconds); // 6 Days
			expect(await authService.resolveJwt(validToken, req, res)).toEqual([
				user,
				{ usedMfa: false },
			]);
			expect(res.cookie).toHaveBeenCalledWith('n8n-auth', expect.any(String), {
				httpOnly: true,
				maxAge: 604800000,
				sameSite: 'lax',
				secure: true,
			});

			const newToken = res.cookie.mock.calls[0].at(1);
			expect(newToken).not.toBe(validToken);
			expect(await authService.resolveJwt(newToken, req, res)).toEqual([user, { usedMfa: false }]);
			expect((jwt.decode(newToken) as jwt.JwtPayload).browserId).toEqual(
				(jwt.decode(validToken) as jwt.JwtPayload).browserId,
			);
		});

		it('should refresh the cookie only if less than 1/4th of time is left', async () => {
			userRepository.findOne.mockResolvedValue(user);
			expect(await authService.resolveJwt(validToken, req, res)).toEqual([
				user,
				{ usedMfa: false },
			]);
			expect(res.cookie).not.toHaveBeenCalled();

			jest.advanceTimersByTime(5 * Time.days.toMilliseconds);
			expect(await authService.resolveJwt(validToken, req, res)).toEqual([
				user,
				{ usedMfa: false },
			]);
			expect(res.cookie).not.toHaveBeenCalled();

			jest.advanceTimersByTime(1 * Time.days.toMilliseconds);
			expect(await authService.resolveJwt(validToken, req, res)).toEqual([
				user,
				{ usedMfa: false },
			]);
			expect(res.cookie).toHaveBeenCalled();
		});

		it('should preserve embed cookie attributes when refreshing an embed session', async () => {
			userRepository.findOne.mockResolvedValue(user);
			const embedToken = authService.issueJWT(user, false, browserId, true);

			jest.advanceTimersByTime(6 * Time.days.toMilliseconds);
			await authService.resolveJwt(embedToken, req, res);

			expect(res.cookie).toHaveBeenCalledWith('n8n-auth', expect.any(String), {
				httpOnly: true,
				maxAge: 604800000,
				sameSite: 'none',
				secure: true,
			});

			const refreshedToken = res.cookie.mock.calls[0].at(1);
			const decoded = jwt.decode(refreshedToken) as jwt.JwtPayload;
			expect(decoded.isEmbed).toBe(true);
		});

		it('should not refresh the cookie if jwtRefreshTimeoutHours is set to -1', async () => {
			globalConfig.userManagement.jwtRefreshTimeoutHours = -1;

			userRepository.findOne.mockResolvedValue(user);
			expect(await authService.resolveJwt(validToken, req, res)).toEqual([
				user,
				{ usedMfa: false },
			]);
			expect(res.cookie).not.toHaveBeenCalled();

			jest.advanceTimersByTime(6 * Time.days.toMilliseconds); // 6 Days
			expect(await authService.resolveJwt(validToken, req, res)).toEqual([
				user,
				{ usedMfa: false },
			]);
			expect(res.cookie).not.toHaveBeenCalled();
		});
	});

	describe('generatePasswordResetUrl', () => {
		it('should generate a valid url', () => {
			urlService.getInstanceBaseUrl.mockReturnValue('https://n8n.instance');
			const url = authService.generatePasswordResetUrl(user);
			expect(url).toEqual(
				'https://n8n.instance/change-password?token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjMiLCJoYXNoIjoibUpBWXg0V2I3ayIsImlhdCI6MTcwNjc1MDYyNSwiZXhwIjoxNzA2NzUxODI1fQ.rg90I7MKjc_KC77mov59XYAeRc-CoW9ka4mt1dCfrnk&mfaEnabled=false',
			);
		});
	});

	describe('generatePasswordResetToken', () => {
		it('should generate valid password-reset tokens', () => {
			const token = authService.generatePasswordResetToken(user);

			const decoded = jwt.decode(token) as jwt.JwtPayload;

			if (!decoded.exp) fail('Token does not contain expiry');
			if (!decoded.iat) fail('Token does not contain issued-at');

			expect(decoded.sub).toEqual(user.id);
			expect(decoded.exp - decoded.iat).toEqual(1200); // Expires in 20 minutes
			expect(decoded.hash).toEqual('mJAYx4Wb7k');
		});
	});

	describe('resolvePasswordResetToken', () => {
		const expectTokenNotLogged = (token: string) => {
			const allLogCalls = [
				...logger.debug.mock.calls,
				...logger.info.mock.calls,
				...logger.warn.mock.calls,
				...logger.error.mock.calls,
			];
			for (const call of allLogCalls) {
				const serialized = JSON.stringify(call);
				expect(serialized).not.toContain(token);
			}
		};

		it('should not return a user if the token in invalid', async () => {
			const resolvedUser = await authService.resolvePasswordResetToken('invalid-token');
			expect(resolvedUser).toBeUndefined();
		});

		it('should not return a user if the token in expired', async () => {
			const token = authService.generatePasswordResetToken(user, '-1h');

			const resolvedUser = await authService.resolvePasswordResetToken(token);
			expect(resolvedUser).toBeUndefined();
		});

		it('should not return a user if the user does not exist in the DB', async () => {
			userRepository.findOne.mockResolvedValueOnce(null);
			const token = authService.generatePasswordResetToken(user);

			const resolvedUser = await authService.resolvePasswordResetToken(token);
			expect(resolvedUser).toBeUndefined();
		});

		it('should not return a user if the password sha does not match', async () => {
			const token = authService.generatePasswordResetToken(user);
			const updatedUser = Object.create(user);
			updatedUser.password = 'something-else';
			userRepository.findOne.mockResolvedValueOnce(updatedUser);

			const resolvedUser = await authService.resolvePasswordResetToken(token);
			expect(resolvedUser).toBeUndefined();
		});

		it('should not return the user if all checks pass', async () => {
			const token = authService.generatePasswordResetToken(user);
			userRepository.findOne.mockResolvedValueOnce(user);

			const resolvedUser = await authService.resolvePasswordResetToken(token);
			expect(resolvedUser).toEqual(user);
		});

		it('should not include the raw token in any log entry when the token is malformed', async () => {
			const malformedToken = 'this-is-not-a-valid-jwt';

			await authService.resolvePasswordResetToken(malformedToken);

			expectTokenNotLogged(malformedToken);
		});

		it('should not include the raw token in any log entry when the token is expired', async () => {
			const expiredToken = authService.generatePasswordResetToken(user, '-1h');

			await authService.resolvePasswordResetToken(expiredToken);

			expectTokenNotLogged(expiredToken);
		});

		it('should not include the raw token in any log entry when the referenced user is missing', async () => {
			userRepository.findOne.mockResolvedValueOnce(null);
			const validToken = authService.generatePasswordResetToken(user);

			await authService.resolvePasswordResetToken(validToken);

			expectTokenNotLogged(validToken);
		});
	});

	describe('invalidateToken', () => {
		const req = mock<AuthenticatedRequest>({
			cookies: {
				[AUTH_COOKIE_NAME]: validToken,
			},
		});

		it('should invalidate the token', async () => {
			await authService.invalidateToken(req);

			expect(invalidAuthTokenRepository.insert).toHaveBeenCalledWith({
				token: validToken,
				expiresAt: new Date('2024-02-08T01:23:45.000Z'),
			});
		});
	});

	describe('getCookieToken', () => {
		it('should return token from cookies', () => {
			const req = mock<AuthenticatedRequest>({
				cookies: { [AUTH_COOKIE_NAME]: 'test-token-123' },
			});

			const token = authService.getCookieToken(req);

			expect(token).toBe('test-token-123');
		});

		it('should return undefined when cookie not present', () => {
			const req = {
				cookies: {},
			} as AuthenticatedRequest;

			const token = authService.getCookieToken(req);

			expect(token).toBeUndefined();
		});
	});

	describe('getBrowserId', () => {
		it('should return browserId from request', () => {
			const req = mock<AuthenticatedRequest>({
				browserId: 'browser-123',
			});

			const browserId = authService.getBrowserId(req);

			expect(browserId).toBe('browser-123');
		});
	});

	describe('getMethod', () => {
		it('should return HTTP method from request', () => {
			const req = mock<AuthenticatedRequest>({
				method: 'POST',
			});

			const method = authService.getMethod(req);

			expect(method).toBe('POST');
		});
	});

	describe('getEndpoint', () => {
		it('should return full endpoint path when route is present', () => {
			const req = mock<AuthenticatedRequest>({
				baseUrl: '/api',
				route: { path: '/chat/message' },
			});

			const endpoint = authService.getEndpoint(req);

			expect(endpoint).toBe('/api/chat/message');
		});

		it('should return baseUrl when route is not present', () => {
			const req = mock<AuthenticatedRequest>({
				baseUrl: '/api',
				route: undefined,
			});

			const endpoint = authService.getEndpoint(req);

			expect(endpoint).toBe('/api');
		});
	});

	describe('validateCookieToken', () => {
		beforeEach(() => {
			jest.resetAllMocks();
		});

		it('should resolve with the user for a valid token', async () => {
			const token = authService.issueJWT(user, false, browserId);
			invalidAuthTokenRepository.existsBy.mockResolvedValue(false);
			userRepository.findOne.mockResolvedValue(user);

			await expect(authService.validateCookieToken(token)).resolves.toBe(user);
			expect(invalidAuthTokenRepository.existsBy).toHaveBeenCalledWith({ token });
			expect(userRepository.findOne).toHaveBeenCalled();
		});

		it('should throw when token has been revoked', async () => {
			const token = authService.issueJWT(user, false, browserId);
			invalidAuthTokenRepository.existsBy.mockResolvedValue(true);

			await expect(authService.validateCookieToken(token)).rejects.toThrow('Unauthorized');
			expect(userRepository.findOne).not.toHaveBeenCalled();
		});

		it('should throw when token is malformed', async () => {
			invalidAuthTokenRepository.existsBy.mockResolvedValue(false);

			await expect(authService.validateCookieToken('fake-value')).rejects.toThrow('jwt malformed');
		});

		it('should throw when token is expired', async () => {
			const token = authService.issueJWT(user, false, browserId);
			invalidAuthTokenRepository.existsBy.mockResolvedValue(false);
			jest.advanceTimersByTime(365 * Time.days.toMilliseconds);

			await expect(authService.validateCookieToken(token)).rejects.toThrow('jwt expired');
		});

		it('should throw when user is disabled', async () => {
			const token = authService.issueJWT(user, false, browserId);
			invalidAuthTokenRepository.existsBy.mockResolvedValue(false);
			userRepository.findOne.mockResolvedValue(mock<User>({ ...userData, disabled: true }));

			await expect(authService.validateCookieToken(token)).rejects.toThrow('Unauthorized');
		});

		it('should throw when user no longer exists', async () => {
			const token = authService.issueJWT(user, false, browserId);
			invalidAuthTokenRepository.existsBy.mockResolvedValue(false);
			userRepository.findOne.mockResolvedValue(null);

			await expect(authService.validateCookieToken(token)).rejects.toThrow('Unauthorized');
		});
	});

	describe('authenticateUserBasedOnToken', () => {
		const method = 'POST';
		const endpoint = '/api/users';

		beforeEach(() => {
			jest.resetAllMocks();
		});

		describe('successful authentication', () => {
			it('should authenticate successfully when MFA was used', async () => {
				// Generate a fresh token with MFA
				const tokenWithMfa = authService.issueJWT(user, true, browserId);

				invalidAuthTokenRepository.existsBy.mockResolvedValue(false);
				userRepository.findOne.mockResolvedValue(user);

				const result = await authService.authenticateUserBasedOnToken(
					tokenWithMfa,
					method,
					endpoint,
					browserId,
				);

				expect(result).toEqual(user);
				expect(invalidAuthTokenRepository.existsBy).toHaveBeenCalledWith({ token: tokenWithMfa });
				expect(userRepository.findOne).toHaveBeenCalled();
			});

			it('should authenticate successfully when MFA not enforced and user has no MFA', async () => {
				// Generate a fresh token without MFA
				const tokenWithoutMfa = authService.issueJWT(user, false, browserId);

				invalidAuthTokenRepository.existsBy.mockResolvedValue(false);
				userRepository.findOne.mockResolvedValue(user);
				mfaService.isMFAEnforced.mockResolvedValue(false);

				const result = await authService.authenticateUserBasedOnToken(
					tokenWithoutMfa,
					method,
					endpoint,
					browserId,
				);

				expect(result).toEqual(user);
				expect(invalidAuthTokenRepository.existsBy).toHaveBeenCalledWith({
					token: tokenWithoutMfa,
				});
				expect(userRepository.findOne).toHaveBeenCalled();
				expect(mfaService.isMFAEnforced).toHaveBeenCalled();
			});

			it('should authenticate successfully without browserId on skip endpoints with GET', async () => {
				// Generate token without browserId
				const tokenNoBrowser = authService.issueJWT(user, false, undefined);

				invalidAuthTokenRepository.existsBy.mockResolvedValue(false);
				userRepository.findOne.mockResolvedValue(user);
				mfaService.isMFAEnforced.mockResolvedValue(false);

				const skipEndpoint = '/rest/push';
				const result = await authService.authenticateUserBasedOnToken(
					tokenNoBrowser,
					'GET',
					skipEndpoint,
					undefined,
				);

				expect(result).toEqual(user);
			});
		});

		describe('token validation failures', () => {
			it('should throw when token is in invalid token repository', async () => {
				const token = authService.issueJWT(user, false, browserId);
				invalidAuthTokenRepository.existsBy.mockResolvedValue(true);

				await expect(
					authService.authenticateUserBasedOnToken(token, method, endpoint, browserId),
				).rejects.toThrow('Unauthorized');

				expect(invalidAuthTokenRepository.existsBy).toHaveBeenCalledWith({ token });
				expect(userRepository.findOne).not.toHaveBeenCalled();
			});

			it('should throw when JWT is expired', async () => {
				const token = authService.issueJWT(user, false, browserId);
				invalidAuthTokenRepository.existsBy.mockResolvedValue(false);
				jest.advanceTimersByTime(365 * Time.days.toMilliseconds);

				await expect(
					authService.authenticateUserBasedOnToken(token, method, endpoint, browserId),
				).rejects.toThrow('jwt expired');

				expect(invalidAuthTokenRepository.existsBy).toHaveBeenCalled();
			});

			it('should throw when JWT is malformed', async () => {
				invalidAuthTokenRepository.existsBy.mockResolvedValue(false);

				await expect(
					authService.authenticateUserBasedOnToken('invalid-token', method, endpoint, browserId),
				).rejects.toThrow('jwt malformed');

				expect(invalidAuthTokenRepository.existsBy).toHaveBeenCalled();
			});

			it('should throw when user is not found', async () => {
				const token = authService.issueJWT(user, false, browserId);
				invalidAuthTokenRepository.existsBy.mockResolvedValue(false);
				userRepository.findOne.mockResolvedValue(null);

				await expect(
					authService.authenticateUserBasedOnToken(token, method, endpoint, browserId),
				).rejects.toThrow('Unauthorized');

				expect(userRepository.findOne).toHaveBeenCalled();
			});

			it('should throw when user is disabled', async () => {
				const token = authService.issueJWT(user, false, browserId);
				invalidAuthTokenRepository.existsBy.mockResolvedValue(false);
				userRepository.findOne.mockResolvedValue(mock<User>({ ...userData, disabled: true }));

				await expect(
					authService.authenticateUserBasedOnToken(token, method, endpoint, browserId),
				).rejects.toThrow('Unauthorized');
			});

			it('should throw when user password has changed', async () => {
				const token = authService.issueJWT(user, false, browserId);
				invalidAuthTokenRepository.existsBy.mockResolvedValue(false);
				userRepository.findOne.mockResolvedValue(
					mock<User>({ ...userData, password: 'newPasswordHash' }),
				);

				await expect(
					authService.authenticateUserBasedOnToken(token, method, endpoint, browserId),
				).rejects.toThrow('Unauthorized');
			});
		});

		describe('browserId validation', () => {
			it('should throw when browserId does not match', async () => {
				const token = authService.issueJWT(user, false, browserId);
				invalidAuthTokenRepository.existsBy.mockResolvedValue(false);
				userRepository.findOne.mockResolvedValue(user);

				await expect(
					authService.authenticateUserBasedOnToken(token, method, endpoint, 'different-browser-id'),
				).rejects.toThrow('Unauthorized');
			});

			it('should throw when browserId is missing but required', async () => {
				const token = authService.issueJWT(user, false, browserId);
				invalidAuthTokenRepository.existsBy.mockResolvedValue(false);
				userRepository.findOne.mockResolvedValue(user);

				await expect(
					authService.authenticateUserBasedOnToken(token, method, endpoint, undefined),
				).rejects.toThrow('Unauthorized');
			});

			it('should not throw when browserId check is skipped for GET on skip endpoints', async () => {
				const token = authService.issueJWT(user, false, browserId);
				invalidAuthTokenRepository.existsBy.mockResolvedValue(false);
				userRepository.findOne.mockResolvedValue(user);
				mfaService.isMFAEnforced.mockResolvedValue(false);

				// Use an endpoint that's definitely in the skip list
				const skipEndpoint = '/types/nodes.json';
				const result = await authService.authenticateUserBasedOnToken(
					token,
					'GET',
					skipEndpoint,
					'different-browser-id',
				);

				expect(result).toEqual(user);
			});

			it('should throw when browserId check is not skipped for POST on skip endpoints', async () => {
				const token = authService.issueJWT(user, false, browserId);
				invalidAuthTokenRepository.existsBy.mockResolvedValue(false);
				userRepository.findOne.mockResolvedValue(user);

				const skipEndpoint = '/rest/push';
				await expect(
					authService.authenticateUserBasedOnToken(
						token,
						'POST',
						skipEndpoint,
						'different-browser-id',
					),
				).rejects.toThrow('Unauthorized');
			});
		});

		describe('MFA validation', () => {
			it('should throw when MFA is enforced and user has MFA enabled but did not use it', async () => {
				const userWithMfa = mock<User>({
					...userData,
					mfaEnabled: true,
					mfaSecret: 'secret',
				});
				// Need to generate a token for this user to make the hash match
				const tokenForMfaUser = authService.issueJWT(userWithMfa, false, browserId);

				invalidAuthTokenRepository.existsBy.mockResolvedValue(false);
				userRepository.findOne.mockResolvedValue(userWithMfa);
				mfaService.isMFAEnforced.mockResolvedValue(true);

				await expect(
					authService.authenticateUserBasedOnToken(tokenForMfaUser, method, endpoint, browserId),
				).rejects.toThrow('Unauthorized');

				expect(mfaService.isMFAEnforced).toHaveBeenCalled();
			});

			it('should throw when MFA is enforced, user has no MFA, and token did not use MFA', async () => {
				// Generate token without MFA
				const tokenWithoutMfa = authService.issueJWT(user, false, browserId);

				invalidAuthTokenRepository.existsBy.mockResolvedValue(false);
				userRepository.findOne.mockResolvedValue(user); // user has mfaEnabled: false
				mfaService.isMFAEnforced.mockResolvedValue(true);

				await expect(
					authService.authenticateUserBasedOnToken(tokenWithoutMfa, method, endpoint, browserId),
				).rejects.toThrow('Unauthorized');

				expect(mfaService.isMFAEnforced).toHaveBeenCalled();
			});

			it('should throw when MFA is not enforced but user has MFA enabled and did not use it', async () => {
				const userWithMfa = mock<User>({
					...userData,
					mfaEnabled: true,
					mfaSecret: 'secret',
				});
				// Need to generate a token for this user to make the hash match
				const tokenForMfaUser = authService.issueJWT(userWithMfa, false, browserId);

				invalidAuthTokenRepository.existsBy.mockResolvedValue(false);
				userRepository.findOne.mockResolvedValue(userWithMfa);
				mfaService.isMFAEnforced.mockResolvedValue(false);

				await expect(
					authService.authenticateUserBasedOnToken(tokenForMfaUser, method, endpoint, browserId),
				).rejects.toThrow('Unauthorized');

				expect(mfaService.isMFAEnforced).toHaveBeenCalled();
			});

			it('should succeed when MFA is enforced, user has MFA enabled, and used it', async () => {
				const userWithMfa = mock<User>({
					...userData,
					mfaEnabled: true,
					mfaSecret: 'secret',
				});
				// Need to generate a token with MFA for this user
				const tokenForMfaUser = authService.issueJWT(userWithMfa, true, browserId);

				invalidAuthTokenRepository.existsBy.mockResolvedValue(false);
				userRepository.findOne.mockResolvedValue(userWithMfa);
				mfaService.isMFAEnforced.mockResolvedValue(true);

				const result = await authService.authenticateUserBasedOnToken(
					tokenForMfaUser,
					method,
					endpoint,
					browserId,
				);

				expect(result).toEqual(userWithMfa);
				// MFA enforcement check is skipped when usedMfa is true
				expect(mfaService.isMFAEnforced).not.toHaveBeenCalled();
			});
		});

		describe('edge cases', () => {
			it('should handle token with missing usedMfa field as false', async () => {
				// Generate fresh token - usedMfa field defaults to false when not explicitly set
				const tokenWithoutMfa = authService.issueJWT(user, false, browserId);

				invalidAuthTokenRepository.existsBy.mockResolvedValue(false);
				userRepository.findOne.mockResolvedValue(user);
				mfaService.isMFAEnforced.mockResolvedValue(false);

				const result = await authService.authenticateUserBasedOnToken(
					tokenWithoutMfa,
					method,
					endpoint,
					browserId,
				);

				expect(result).toEqual(user);
				expect(mfaService.isMFAEnforced).toHaveBeenCalled();
			});

			it('should handle token without browserId successfully', async () => {
				// Token without browserId field
				const tokenWithoutBrowserId = authService.issueJWT(user, false, undefined);
				invalidAuthTokenRepository.existsBy.mockResolvedValue(false);
				userRepository.findOne.mockResolvedValue(user);
				mfaService.isMFAEnforced.mockResolvedValue(false);

				const result = await authService.authenticateUserBasedOnToken(
					tokenWithoutBrowserId,
					method,
					endpoint,
					undefined,
				);

				expect(result).toEqual(user);
			});
		});
	});

	describe('authenticateUserByCookie', () => {
		beforeEach(() => {
			jest.resetAllMocks();
		});

		describe('token validation failures', () => {
			it('should throw when token is blocklisted', async () => {
				const token = authService.issueJWT(user, true, browserId);
				invalidAuthTokenRepository.existsBy.mockResolvedValue(true);

				await expect(authService.authenticateUserByCookie(token)).rejects.toThrow('Unauthorized');
				expect(userRepository.findOne).not.toHaveBeenCalled();
			});

			it('should throw when JWT is expired', async () => {
				const token = authService.issueJWT(user, true, browserId);
				invalidAuthTokenRepository.existsBy.mockResolvedValue(false);
				jest.advanceTimersByTime(365 * Time.days.toMilliseconds);

				await expect(authService.authenticateUserByCookie(token)).rejects.toThrow('jwt expired');
			});

			it('should throw when JWT is malformed', async () => {
				invalidAuthTokenRepository.existsBy.mockResolvedValue(false);

				await expect(authService.authenticateUserByCookie('not-a-jwt')).rejects.toThrow(
					'jwt malformed',
				);
			});

			it('should throw when user is not found', async () => {
				const token = authService.issueJWT(user, true, browserId);
				invalidAuthTokenRepository.existsBy.mockResolvedValue(false);
				userRepository.findOne.mockResolvedValue(null);

				await expect(authService.authenticateUserByCookie(token)).rejects.toThrow('Unauthorized');
			});

			it('should throw when user is disabled', async () => {
				const token = authService.issueJWT(user, true, browserId);
				invalidAuthTokenRepository.existsBy.mockResolvedValue(false);
				userRepository.findOne.mockResolvedValue(mock<User>({ ...userData, disabled: true }));

				await expect(authService.authenticateUserByCookie(token)).rejects.toThrow('Unauthorized');
			});

			it('should throw when user password hash changed', async () => {
				const token = authService.issueJWT(user, true, browserId);
				invalidAuthTokenRepository.existsBy.mockResolvedValue(false);
				userRepository.findOne.mockResolvedValue(
					mock<User>({ ...userData, password: 'newPasswordHash' }),
				);

				await expect(authService.authenticateUserByCookie(token)).rejects.toThrow('Unauthorized');
			});
		});

		describe('MFA gate', () => {
			it('should throw when MFA is enforced and token was issued without MFA', async () => {
				const tokenWithoutMfa = authService.issueJWT(user, false, browserId);
				invalidAuthTokenRepository.existsBy.mockResolvedValue(false);
				userRepository.findOne.mockResolvedValue(user);
				mfaService.isMFAEnforced.mockResolvedValue(true);

				await expect(authService.authenticateUserByCookie(tokenWithoutMfa)).rejects.toThrow(
					'Unauthorized',
				);
			});

			it('should throw when user has MFA enabled and token was issued without MFA', async () => {
				const userWithMfa = mock<User>({
					...userData,
					mfaEnabled: true,
					mfaSecret: 'secret',
				});
				const tokenForMfaUser = authService.issueJWT(userWithMfa, false, browserId);
				invalidAuthTokenRepository.existsBy.mockResolvedValue(false);
				userRepository.findOne.mockResolvedValue(userWithMfa);
				mfaService.isMFAEnforced.mockResolvedValue(false);

				await expect(authService.authenticateUserByCookie(tokenForMfaUser)).rejects.toThrow(
					'Unauthorized',
				);
			});
		});
	});
});
