Files
BetterDesk/web-nodejs/tests/auth.routes.test.js
T
2026-06-17 20:18:30 +02:00

240 lines
9.0 KiB
JavaScript

/**
* BetterDesk Console - Auth Routes Tests
*/
const request = require('supertest');
const { createTestApp } = require('./helpers');
// Mock dependencies before requiring routes
jest.mock('../services/database', () => ({
logAction: jest.fn().mockResolvedValue(undefined),
getUser: jest.fn().mockResolvedValue(null),
getUserById: jest.fn().mockResolvedValue({ id: 1, username: 'admin', role: 'admin', password_hash: 'hash', totp_secret: 'SECRET' }),
enableTotp: jest.fn().mockResolvedValue(undefined),
disableTotp: jest.fn().mockResolvedValue(undefined)
}));
jest.mock('../services/authService', () => ({
authenticate: jest.fn().mockResolvedValue(null),
isAuthFailure: jest.fn((result) => !!(result && result.__authFailure)),
changePassword: jest.fn().mockResolvedValue({ success: true }),
hashPassword: jest.fn().mockResolvedValue('hashed'),
verifyPassword: jest.fn().mockResolvedValue(true),
verifyAndEnableTotp: jest.fn().mockResolvedValue({ success: true, recoveryCodes: ['CODE1', 'CODE2'] }),
disableTotp: jest.fn().mockResolvedValue({ success: true }),
isTotpEnabled: jest.fn().mockResolvedValue(false),
generateTotpSetup: jest.fn().mockResolvedValue({ success: true, qrCode: 'qr', secret: 'SECRET', otpauthUrl: 'otpauth://totp/test' }),
generateRecoveryCodes: jest.fn().mockReturnValue(['CODE1', 'CODE2']),
recordAttempt: jest.fn().mockResolvedValue(undefined)
}));
jest.mock('../services/userSync', () => ({
mirrorUpdate: jest.fn().mockResolvedValue(undefined),
mirrorTotpEnable: jest.fn().mockResolvedValue(undefined),
mirrorTotpDisable: jest.fn().mockResolvedValue(undefined),
}));
jest.mock('../middleware/rateLimiter', () => ({
loginLimiter: (_req, _res, next) => next(),
passwordChangeLimiter: (_req, _res, next) => next(),
apiLimiter: (_req, _res, next) => next()
}));
const authService = require('../services/authService');
const db = require('../services/database');
const userSync = require('../services/userSync');
const authRoutes = require('../routes/auth.routes');
describe('Auth Routes', () => {
let app;
beforeEach(() => {
app = createTestApp();
app.use('/', authRoutes);
jest.clearAllMocks();
});
function authenticatedApp(user = { id: 1, username: 'admin', role: 'admin' }) {
const authed = createTestApp();
authed.use((req, _res, next) => {
req.session.userId = user.id;
req.session.user = user;
next();
});
authed.use('/', authRoutes);
return authed;
}
describe('POST /api/auth/login', () => {
it('should return 400 when username is missing', async () => {
const res = await request(app)
.post('/api/auth/login')
.send({ password: 'test123' });
expect(res.status).toBe(400);
expect(res.body.success).toBe(false);
});
it('should return 400 when password is missing', async () => {
const res = await request(app)
.post('/api/auth/login')
.send({ username: 'admin' });
expect(res.status).toBe(400);
expect(res.body.success).toBe(false);
});
it('should return 400 when username exceeds 128 chars', async () => {
const res = await request(app)
.post('/api/auth/login')
.send({ username: 'a'.repeat(129), password: 'test123' });
expect(res.status).toBe(400);
expect(res.body.success).toBe(false);
});
it('should return 401 when credentials are invalid', async () => {
authService.authenticate.mockResolvedValue(null);
const res = await request(app)
.post('/api/auth/login')
.send({ username: 'admin', password: 'wrong' });
expect(res.status).toBe(401);
expect(res.body.success).toBe(false);
expect(db.logAction).toHaveBeenCalledWith(
null, 'login_failed', expect.stringContaining('admin'), expect.anything()
);
});
it('should return 409 when local/SSO username collision is detected', async () => {
authService.authenticate.mockResolvedValue({ __authFailure: 'username_collision' });
const res = await request(app)
.post('/api/auth/login')
.send({ username: 'domainuser', password: 'ad-pass' });
expect(res.status).toBe(409);
expect(res.body.success).toBe(false);
expect(res.body.code).toBe('username_collision');
expect(db.logAction).toHaveBeenCalledWith(
null, 'login_failed', expect.stringContaining('collision'), expect.anything()
);
});
it('should return 200 with user on valid login', async () => {
authService.authenticate.mockResolvedValue({
id: 1,
username: 'admin',
role: 'admin'
});
const res = await request(app)
.post('/api/auth/login')
.send({ username: 'admin', password: 'correct' });
expect(res.status).toBe(200);
expect(res.body.success).toBe(true);
expect(res.body.user).toBeDefined();
expect(res.body.user.username).toBe('admin');
});
it('should return totpRequired when 2FA is enabled', async () => {
authService.authenticate.mockResolvedValue({
id: 1,
username: 'admin',
role: 'admin',
totpRequired: true
});
const res = await request(app)
.post('/api/auth/login')
.send({ username: 'admin', password: 'correct' });
expect(res.status).toBe(200);
expect(res.body.totpRequired).toBe(true);
});
});
describe('POST /api/auth/logout', () => {
it('should destroy session on logout', async () => {
// Set up auth
app.use((req, _res, next) => {
req.session.userId = 1;
req.session.user = { id: 1, username: 'admin', role: 'admin' };
next();
});
// Re-mount routes after auth middleware
const logoutApp = createTestApp();
logoutApp.use((req, _res, next) => {
req.session.userId = 1;
req.session.user = { id: 1, username: 'admin', role: 'admin' };
next();
});
logoutApp.use('/', authRoutes);
const res = await request(logoutApp)
.post('/api/auth/logout');
expect(res.status).toBe(200);
expect(res.body.success).toBe(true);
});
});
describe('POST /api/auth/password', () => {
it('mirrors successful self-service password changes to Go', async () => {
const res = await request(authenticatedApp())
.post('/api/auth/password')
.send({
currentPassword: 'old-password',
newPassword: 'new-password-123',
confirmPassword: 'new-password-123'
});
expect(res.status).toBe(200);
expect(res.body.success).toBe(true);
expect(userSync.mirrorUpdate).toHaveBeenCalledWith('admin', { password: 'new-password-123' });
});
it('does not fail password changes when Go mirror is unavailable', async () => {
userSync.mirrorUpdate.mockRejectedValueOnce(new Error('go offline'));
const res = await request(authenticatedApp())
.post('/api/auth/password')
.send({
currentPassword: 'old-password',
newPassword: 'new-password-123',
confirmPassword: 'new-password-123'
});
expect(res.status).toBe(200);
expect(res.body.success).toBe(true);
});
});
describe('POST /api/auth/totp/enable', () => {
it('mirrors enabled TOTP state to Go without issuing tokens', async () => {
const res = await request(authenticatedApp())
.post('/api/auth/totp/enable')
.send({ code: '123456' });
expect(res.status).toBe(200);
expect(res.body.success).toBe(true);
expect(userSync.mirrorTotpEnable).toHaveBeenCalledWith('admin', { secret: 'SECRET' });
});
});
describe('POST /api/auth/totp/disable', () => {
it('mirrors disabled TOTP state to Go after password verification', async () => {
const res = await request(authenticatedApp())
.post('/api/auth/totp/disable')
.send({ password: 'current-password' });
expect(res.status).toBe(200);
expect(res.body.success).toBe(true);
expect(authService.verifyPassword).toHaveBeenCalledWith('current-password', expect.anything());
expect(userSync.mirrorTotpDisable).toHaveBeenCalledWith('admin');
});
});
});