/** * Tests for User Management, RBAC permissions, token versioning (session invalidation), * scoped role assignments, password management, seat limits, and last-admin protection. */ import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest'; import request from 'supertest'; import jwt from 'jsonwebtoken'; import bcrypt from 'bcrypt'; import crypto from 'crypto'; import { setupTestDb, cleanupTestDb, TEST_USERNAME, TEST_PASSWORD, TEST_JWT_SECRET } from './helpers/setupTestDb'; import { generateApiToken } from '../utils/apiTokenFormat'; let tmpDir: string; let app: import('express').Express; let DatabaseService: typeof import('../services/DatabaseService').DatabaseService; /** Sign a JWT for a given user with optional token_version (tv). */ function authToken(username: string, role: string = 'admin', tv?: number): string { const payload: Record = { username, role }; if (tv !== undefined) payload.tv = tv; return jwt.sign(payload, TEST_JWT_SECRET, { expiresIn: '1m' }); } /** Sign admin token using the current DB token_version (reads live state). */ function adminToken(): string { const db = DatabaseService.getInstance(); const user = db.getUserByUsername(TEST_USERNAME)!; return authToken(TEST_USERNAME, 'admin', user.token_version); } beforeAll(async () => { tmpDir = await setupTestDb(); ({ DatabaseService } = await import('../services/DatabaseService')); // Mock LicenseService to return paid/admiral for RBAC tests const { LicenseService } = await import('../services/LicenseService'); vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValue('paid'); vi.spyOn(LicenseService.getInstance(), 'getVariant').mockReturnValue('admiral'); vi.spyOn(LicenseService.getInstance(), 'getSeatLimits').mockReturnValue({ maxAdmins: null, maxViewers: null }); ({ app } = await import('../index')); }); afterAll(() => { vi.restoreAllMocks(); cleanupTestDb(tmpDir); }); // ---- User CRUD Endpoints ---- describe('POST /api/users', () => { it('creates a user with valid data (201)', async () => { const res = await request(app) .post('/api/users') .set('Authorization', `Bearer ${adminToken()}`) .send({ username: 'newuser', password: 'password123', role: 'viewer' }); expect(res.status).toBe(201); expect(res.body.username).toBe('newuser'); expect(res.body.role).toBe('viewer'); expect(res.body.id).toBeDefined(); }); it('rejects missing fields (400)', async () => { const res = await request(app) .post('/api/users') .set('Authorization', `Bearer ${adminToken()}`) .send({ username: 'incomplete' }); expect(res.status).toBe(400); }); it('rejects invalid username format (400)', async () => { const res = await request(app) .post('/api/users') .set('Authorization', `Bearer ${adminToken()}`) .send({ username: 'a b', password: 'password123', role: 'viewer' }); expect(res.status).toBe(400); }); it('rejects short password (400)', async () => { const res = await request(app) .post('/api/users') .set('Authorization', `Bearer ${adminToken()}`) .send({ username: 'shortpw', password: '123', role: 'viewer' }); expect(res.status).toBe(400); }); it('rejects invalid role (400)', async () => { const res = await request(app) .post('/api/users') .set('Authorization', `Bearer ${adminToken()}`) .send({ username: 'badrole', password: 'password123', role: 'superadmin' }); expect(res.status).toBe(400); }); it('rejects duplicate username (409)', async () => { const res = await request(app) .post('/api/users') .set('Authorization', `Bearer ${adminToken()}`) .send({ username: 'newuser', password: 'password123', role: 'viewer' }); expect(res.status).toBe(409); }); it('requires admin role (403 for viewers)', async () => { const db = DatabaseService.getInstance(); const viewer = db.getUserByUsername('newuser')!; const viewerToken = authToken('newuser', 'viewer', viewer.token_version); const res = await request(app) .post('/api/users') .set('Authorization', `Bearer ${viewerToken}`) .send({ username: 'test999', password: 'password123', role: 'viewer' }); expect(res.status).toBe(403); expect(res.body.code).toBe('ADMIN_REQUIRED'); }); it('blocks API tokens (403 SCOPE_DENIED)', async () => { const rawToken = generateApiToken(); const tokenHash = crypto.createHash('sha256').update(rawToken).digest('hex'); const db = DatabaseService.getInstance(); const user = db.getUserByUsername(TEST_USERNAME); db.addApiToken({ token_hash: tokenHash, name: `test-crud-${Date.now()}`, scope: 'full-admin', user_id: user!.id, created_at: Date.now(), expires_at: null }); const res = await request(app) .post('/api/users') .set('Authorization', `Bearer ${rawToken}`) .send({ username: 'fromtoken', password: 'password123', role: 'viewer' }); expect(res.status).toBe(403); expect(res.body.code).toBe('SCOPE_DENIED'); }); }); describe('PUT /api/users/:id', () => { let viewerId: number; beforeAll(() => { const db = DatabaseService.getInstance(); const user = db.getUserByUsername('newuser'); viewerId = user!.id; }); it('updates username', async () => { const res = await request(app) .put(`/api/users/${viewerId}`) .set('Authorization', `Bearer ${adminToken()}`) .send({ username: 'renameduser' }); expect(res.status).toBe(200); expect(res.body.success).toBe(true); // Rename back for other tests await request(app) .put(`/api/users/${viewerId}`) .set('Authorization', `Bearer ${adminToken()}`) .send({ username: 'newuser' }); }); it('updates role', async () => { const res = await request(app) .put(`/api/users/${viewerId}`) .set('Authorization', `Bearer ${adminToken()}`) .send({ role: 'deployer' }); expect(res.status).toBe(200); // Revert await request(app) .put(`/api/users/${viewerId}`) .set('Authorization', `Bearer ${adminToken()}`) .send({ role: 'viewer' }); }); it('prevents self-role-change (400)', async () => { const db = DatabaseService.getInstance(); const adminUser = db.getUserByUsername(TEST_USERNAME)!; const res = await request(app) .put(`/api/users/${adminUser.id}`) .set('Authorization', `Bearer ${adminToken()}`) .send({ role: 'viewer' }); expect(res.status).toBe(400); expect(res.body.error).toContain('Cannot change your own role'); }); it('prevents demoting last admin (400)', async () => { // testadmin is the only admin const db = DatabaseService.getInstance(); const adminUser = db.getUserByUsername(TEST_USERNAME)!; const res = await request(app) .put(`/api/users/${adminUser.id}`) .set('Authorization', `Bearer ${adminToken()}`) .send({ role: 'viewer' }); expect(res.status).toBe(400); }); it('rejects password on SSO user (400)', async () => { // Create an SSO user directly in DB const db = DatabaseService.getInstance(); const ssoId = db.addUser({ username: 'sso-user', password_hash: '$sso$fake', role: 'viewer', auth_provider: 'oidc_google', provider_id: 'google-123', email: 'sso@test.com' }); const res = await request(app) .put(`/api/users/${ssoId}`) .set('Authorization', `Bearer ${adminToken()}`) .send({ password: 'newpassword123' }); expect(res.status).toBe(400); expect(res.body.error).toContain('SSO-provisioned'); // Cleanup db.deleteUser(ssoId); }); }); describe('DELETE /api/users/:id', () => { it('deletes a user (200)', async () => { // Create a disposable user const db = DatabaseService.getInstance(); const hash = await bcrypt.hash('password123', 1); const id = db.addUser({ username: 'disposable', password_hash: hash, role: 'viewer' }); const res = await request(app) .delete(`/api/users/${id}`) .set('Authorization', `Bearer ${adminToken()}`); expect(res.status).toBe(200); expect(res.body.success).toBe(true); }); it('prevents self-deletion (400)', async () => { const db = DatabaseService.getInstance(); const admin = db.getUserByUsername(TEST_USERNAME)!; const res = await request(app) .delete(`/api/users/${admin.id}`) .set('Authorization', `Bearer ${adminToken()}`); expect(res.status).toBe(400); expect(res.body.error).toContain('Cannot delete your own account'); }); it('prevents deleting last admin (400)', async () => { // Only one admin (testadmin), can't delete const db = DatabaseService.getInstance(); const admin = db.getUserByUsername(TEST_USERNAME)!; const res = await request(app) .delete(`/api/users/${admin.id}`) .set('Authorization', `Bearer ${adminToken()}`); expect(res.status).toBe(400); }); }); // ---- Token Version (Session Invalidation) ---- describe('Token version (session invalidation)', () => { it('rejects a deleted user\'s JWT (401)', async () => { const db = DatabaseService.getInstance(); const hash = await bcrypt.hash('password123', 1); const id = db.addUser({ username: 'willdelete', password_hash: hash, role: 'viewer' }); const user = db.getUserById(id)!; const token = authToken('willdelete', 'viewer', user.token_version); // Token works before deletion const before = await request(app).get('/api/stacks').set('Authorization', `Bearer ${token}`); expect(before.status).not.toBe(401); // Delete the user db.deleteUser(id); // Token should be rejected after deletion const after = await request(app).get('/api/stacks').set('Authorization', `Bearer ${token}`); expect(after.status).toBe(401); expect(after.body.error).toContain('no longer exists'); }); it('rejects token after password change bumps tv', async () => { const db = DatabaseService.getInstance(); const hash = await bcrypt.hash('oldpass123', 1); const id = db.addUser({ username: 'pwchange', password_hash: hash, role: 'viewer' }); const user = db.getUserById(id)!; const oldToken = authToken('pwchange', 'viewer', user.token_version); // Token works before bump const before = await request(app).get('/api/stacks').set('Authorization', `Bearer ${oldToken}`); expect(before.status).not.toBe(401); // Bump token version (simulates password change) db.bumpTokenVersion(id); // Old token should be rejected const after = await request(app).get('/api/stacks').set('Authorization', `Bearer ${oldToken}`); expect(after.status).toBe(401); expect(after.body.error).toContain('Session invalidated'); // Cleanup db.deleteUser(id); }); it('admin password reset bumps token_version', async () => { const db = DatabaseService.getInstance(); const hash = await bcrypt.hash('password123', 1); const id = db.addUser({ username: 'resetme', password_hash: hash, role: 'viewer' }); const userBefore = db.getUserById(id)!; // Admin resets password via PUT /api/users/:id await request(app) .put(`/api/users/${id}`) .set('Authorization', `Bearer ${adminToken()}`) .send({ password: 'newpassword123' }); const userAfter = db.getUserById(id)!; expect(userAfter.token_version).toBe(userBefore.token_version + 1); // Cleanup db.deleteUser(id); }); it('pre-migration token (no tv claim) still works', async () => { // Sign without tv claim (simulates pre-migration token) const token = jwt.sign({ username: TEST_USERNAME, role: 'admin' }, TEST_JWT_SECRET, { expiresIn: '1m' }); const res = await request(app).get('/api/stacks').set('Authorization', `Bearer ${token}`); // Should not be 401 (backward compat) expect(res.status).not.toBe(401); }); it('uses DB role so role changes take effect immediately', async () => { const db = DatabaseService.getInstance(); const hash = await bcrypt.hash('password123', 1); const id = db.addUser({ username: 'rolecheck', password_hash: hash, role: 'admin' }); const user = db.getUserById(id)!; // Admin changes their role to viewer in DB directly (simulating a race) db.updateUser(id, { role: 'viewer' }); // Don't bump tv, so the old token still passes version check // But the middleware should use DB role (viewer), not JWT role (admin) // The token was signed with role: admin, but DB says viewer. // Auth check endpoint should reflect the DB role. const token = authToken('rolecheck', 'admin', user.token_version); const res = await request(app).get('/api/auth/check').set('Authorization', `Bearer ${token}`); expect(res.status).toBe(200); expect(res.body.user.role).toBe('viewer'); // Cleanup db.deleteUser(id); }); }); // ---- Scoped Role Assignments ---- describe('Scoped Role Assignments', () => { let targetUserId: number; beforeAll(async () => { const db = DatabaseService.getInstance(); const hash = await bcrypt.hash('password123', 1); targetUserId = db.addUser({ username: 'scopeuser', password_hash: hash, role: 'viewer' }); }); afterAll(() => { const db = DatabaseService.getInstance(); db.deleteUser(targetUserId); }); it('GET /api/users/:id/roles returns assignments', async () => { const res = await request(app) .get(`/api/users/${targetUserId}/roles`) .set('Authorization', `Bearer ${adminToken()}`); expect(res.status).toBe(200); expect(Array.isArray(res.body)).toBe(true); }); it('POST /api/users/:id/roles creates assignment (201)', async () => { const res = await request(app) .post(`/api/users/${targetUserId}/roles`) .set('Authorization', `Bearer ${adminToken()}`) .send({ role: 'deployer', resource_type: 'stack', resource_id: 'test-stack' }); expect(res.status).toBe(201); expect(res.body.role).toBe('deployer'); expect(res.body.resource_type).toBe('stack'); }); it('POST /api/users/:id/roles rejects duplicate (409)', async () => { const res = await request(app) .post(`/api/users/${targetUserId}/roles`) .set('Authorization', `Bearer ${adminToken()}`) .send({ role: 'deployer', resource_type: 'stack', resource_id: 'test-stack' }); expect(res.status).toBe(409); }); it('DELETE /api/users/:id/roles/:assignId removes assignment', async () => { const db = DatabaseService.getInstance(); const assignments = db.getAllRoleAssignments(targetUserId); const assignment = assignments[0]; const res = await request(app) .delete(`/api/users/${targetUserId}/roles/${assignment.id}`) .set('Authorization', `Bearer ${adminToken()}`); expect(res.status).toBe(200); expect(res.body.success).toBe(true); }); }); // ---- GET /api/permissions/me ---- describe('GET /api/permissions/me', () => { it('returns correct structure for admin', async () => { const res = await request(app) .get('/api/permissions/me') .set('Authorization', `Bearer ${adminToken()}`); expect(res.status).toBe(200); expect(res.body.globalRole).toBe('admin'); expect(Array.isArray(res.body.globalPermissions)).toBe(true); expect(res.body.globalPermissions).toContain('stack:read'); expect(res.body.globalPermissions).toContain('system:users'); expect(typeof res.body.isAdmiral).toBe('boolean'); }); it('returns 401 when not authenticated', async () => { const res = await request(app).get('/api/permissions/me'); expect(res.status).toBe(401); }); it('includes scoped permissions when assignments exist', async () => { const db = DatabaseService.getInstance(); const hash = await bcrypt.hash('password123', 1); const id = db.addUser({ username: 'permcheck', password_hash: hash, role: 'viewer' }); db.addRoleAssignment({ user_id: id, role: 'deployer', resource_type: 'stack', resource_id: 'my-stack' }); const user = db.getUserById(id)!; const token = authToken('permcheck', 'viewer', user.token_version); const res = await request(app) .get('/api/permissions/me') .set('Authorization', `Bearer ${token}`); expect(res.status).toBe(200); expect(res.body.globalRole).toBe('viewer'); expect(res.body.scopedPermissions['stack:my-stack']).toBeDefined(); // Cleanup db.deleteRoleAssignmentsByUser(id); db.deleteUser(id); }); }); // ---- PUT /api/auth/password ---- describe('PUT /api/auth/password', () => { it('changes password with valid old password', async () => { const res = await request(app) .put('/api/auth/password') .set('Authorization', `Bearer ${adminToken()}`) .send({ oldPassword: TEST_PASSWORD, newPassword: 'newpassword123' }); expect(res.status).toBe(200); expect(res.body.success).toBe(true); // Revert password for other tests (must use fresh token since tv was bumped) const revert = await request(app) .put('/api/auth/password') .set('Authorization', `Bearer ${adminToken()}`) .send({ oldPassword: 'newpassword123', newPassword: TEST_PASSWORD }); expect(revert.status).toBe(200); }); it('rejects wrong old password (401)', async () => { const res = await request(app) .put('/api/auth/password') .set('Authorization', `Bearer ${adminToken()}`) .send({ oldPassword: 'wrongpassword', newPassword: 'newpassword123' }); expect(res.status).toBe(401); }); it('rejects short new password (400)', async () => { const res = await request(app) .put('/api/auth/password') .set('Authorization', `Bearer ${adminToken()}`) .send({ oldPassword: TEST_PASSWORD, newPassword: '123' }); expect(res.status).toBe(400); }); it('rejects missing fields (400)', async () => { const res = await request(app) .put('/api/auth/password') .set('Authorization', `Bearer ${adminToken()}`) .send({}); expect(res.status).toBe(400); }); it('blocks API tokens (403)', async () => { const rawToken = generateApiToken(); const tokenHash = crypto.createHash('sha256').update(rawToken).digest('hex'); const db = DatabaseService.getInstance(); const user = db.getUserByUsername(TEST_USERNAME); db.addApiToken({ token_hash: tokenHash, name: `test-pwchange-${Date.now()}`, scope: 'full-admin', user_id: user!.id, created_at: Date.now(), expires_at: null }); const res = await request(app) .put('/api/auth/password') .set('Authorization', `Bearer ${rawToken}`) .send({ oldPassword: TEST_PASSWORD, newPassword: 'newpassword123' }); expect(res.status).toBe(403); expect(res.body.code).toBe('SCOPE_DENIED'); }); }); // ---- Seat Limit Enforcement ---- describe('Seat limit enforcement', () => { it('rejects new admin when seat limit reached', async () => { const { LicenseService } = await import('../services/LicenseService'); vi.spyOn(LicenseService.getInstance(), 'getSeatLimits').mockReturnValue({ maxAdmins: 1, maxViewers: null }); const res = await request(app) .post('/api/users') .set('Authorization', `Bearer ${adminToken()}`) .send({ username: 'extraadmin', password: 'password123', role: 'admin' }); expect(res.status).toBe(403); expect(res.body.error).toContain('maximum'); // Restore mock vi.spyOn(LicenseService.getInstance(), 'getSeatLimits').mockReturnValue({ maxAdmins: null, maxViewers: null }); }); it('rejects new viewer when viewer seat limit reached', async () => { const { LicenseService } = await import('../services/LicenseService'); vi.spyOn(LicenseService.getInstance(), 'getSeatLimits').mockReturnValue({ maxAdmins: null, maxViewers: 0 }); const res = await request(app) .post('/api/users') .set('Authorization', `Bearer ${adminToken()}`) .send({ username: 'extraviewer', password: 'password123', role: 'viewer' }); expect(res.status).toBe(403); expect(res.body.error).toContain('maximum'); // Restore mock vi.spyOn(LicenseService.getInstance(), 'getSeatLimits').mockReturnValue({ maxAdmins: null, maxViewers: null }); }); }); // ---- Last-Admin Protection ---- describe('Last-admin protection', () => { it('cannot demote the only admin', async () => { const db = DatabaseService.getInstance(); const admin = db.getUserByUsername(TEST_USERNAME)!; const res = await request(app) .put(`/api/users/${admin.id}`) .set('Authorization', `Bearer ${adminToken()}`) .send({ role: 'viewer' }); // Should fail with 400 (self-role-change) or last-admin check expect(res.status).toBe(400); }); it('cannot delete the only admin', async () => { const db = DatabaseService.getInstance(); const admin = db.getUserByUsername(TEST_USERNAME)!; const res = await request(app) .delete(`/api/users/${admin.id}`) .set('Authorization', `Bearer ${adminToken()}`); expect(res.status).toBe(400); }); it('can demote admin when another admin exists', async () => { const db = DatabaseService.getInstance(); const hash = await bcrypt.hash('password123', 1); const secondAdminId = db.addUser({ username: 'secondadmin', password_hash: hash, role: 'admin' }); // Now demote second admin (testadmin does the demotion) const res = await request(app) .put(`/api/users/${secondAdminId}`) .set('Authorization', `Bearer ${adminToken()}`) .send({ role: 'viewer' }); expect(res.status).toBe(200); // Cleanup db.deleteUser(secondAdminId); }); }); // ---- Orphaned Role Assignment Cleanup ---- describe('Orphaned role assignment cleanup', () => { it('deleting a node removes its role assignments', async () => { const db = DatabaseService.getInstance(); // Create a test node const nodeId = db.addNode({ name: 'test-cleanup-node', type: 'remote', api_url: 'http://test:1852', api_token: '', compose_dir: '/tmp', is_default: false }); // Create a role assignment for this node const hash = await bcrypt.hash('password123', 1); const userId = db.addUser({ username: 'nodeorphan', password_hash: hash, role: 'viewer' }); db.addRoleAssignment({ user_id: userId, role: 'deployer', resource_type: 'node', resource_id: String(nodeId) }); // Verify assignment exists const before = db.getAllRoleAssignments(userId); expect(before.length).toBe(1); // Delete the node db.deleteNode(nodeId); // Assignments should be gone const after = db.getAllRoleAssignments(userId); expect(after.length).toBe(0); // Cleanup db.deleteUser(userId); }); }); // ---- Role-Based Permission Checks (via API) ---- describe('ROLE_PERMISSIONS enforcement via API', () => { it('viewer is blocked from deploying (403)', async () => { const db = DatabaseService.getInstance(); const viewerUser = db.getUserByUsername('newuser'); if (!viewerUser) return; // Created in earlier test const token = authToken('newuser', 'viewer', viewerUser.token_version); const res = await request(app) .post('/api/stacks/test-stack/deploy') .set('Authorization', `Bearer ${token}`); expect(res.status).toBe(403); expect(res.body.code).toBe('PERMISSION_DENIED'); }); it('viewer can read stacks', async () => { const db = DatabaseService.getInstance(); const viewerUser = db.getUserByUsername('newuser'); if (!viewerUser) return; const token = authToken('newuser', 'viewer', viewerUser.token_version); const res = await request(app) .get('/api/stacks') .set('Authorization', `Bearer ${token}`); // Should not be 403 (may be 200 or 500 depending on Docker state) expect(res.status).not.toBe(403); }); it('viewer is blocked from system settings (403)', async () => { const db = DatabaseService.getInstance(); const viewerUser = db.getUserByUsername('newuser'); if (!viewerUser) return; const token = authToken('newuser', 'viewer', viewerUser.token_version); const res = await request(app) .get('/api/users') .set('Authorization', `Bearer ${token}`); expect(res.status).toBe(403); expect(res.body.code).toBe('ADMIN_REQUIRED'); }); });