/** * Tests for User Management, RBAC permissions, token versioning (session invalidation), * scoped role assignments, password management, 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'; import { assertStackExistsOnNode } from '../helpers/assertStackExistsOnNode'; vi.mock('../helpers/assertStackExistsOnNode', () => ({ assertStackExistsOnNode: vi.fn(async () => ({ ok: true as const })), })); let tmpDir: string; let app: import('express').Express; let DatabaseService: typeof import('../services/DatabaseService').DatabaseService; function defaultNodeId(): number { const node = DatabaseService.getInstance().getDefaultNode(); if (!node) throw new Error('test default node missing'); return node.id; } /** 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 the paid tier for RBAC tests const { LicenseService } = await import('../services/LicenseService'); vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValue('paid'); ({ 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('PERMISSION_DENIED'); }); 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'); }); it('creates an advanced-role user on the paid tier (201)', async () => { const res = await request(app) .post('/api/users') .set('Authorization', `Bearer ${adminToken()}`) .send({ username: 'paid-deployer', password: 'password123', role: 'deployer' }); expect(res.status).toBe(201); expect(res.body.role).toBe('deployer'); DatabaseService.getInstance().deleteUser(res.body.id); }); it('blocks an advanced-role user on the Community tier (403 PAID_REQUIRED)', async () => { const { LicenseService } = await import('../services/LicenseService'); const svc = LicenseService.getInstance(); vi.spyOn(svc, 'getTier').mockReturnValue('community'); try { const res = await request(app) .post('/api/users') .set('Authorization', `Bearer ${adminToken()}`) .send({ username: 'community-deployer', password: 'password123', role: 'deployer' }); expect(res.status).toBe(403); expect(res.body.code).toBe('PAID_REQUIRED'); } finally { vi.spyOn(svc, 'getTier').mockReturnValue('paid'); } }); }); 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 nodeId = defaultNodeId(); const res = await request(app) .post(`/api/users/${targetUserId}/roles`) .set('Authorization', `Bearer ${adminToken()}`) .send({ role: 'deployer', resource_type: 'stack', resource_id: 'test-stack', node_id: nodeId }); expect(res.status).toBe(201); expect(res.body.role).toBe('deployer'); expect(res.body.resource_type).toBe('stack'); expect(res.body.node_id).toBe(nodeId); }); it('POST /api/users/:id/roles rejects stack assignment without node_id (400)', async () => { const res = await request(app) .post(`/api/users/${targetUserId}/roles`) .set('Authorization', `Bearer ${adminToken()}`) .send({ role: 'deployer', resource_type: 'stack', resource_id: 'no-node-stack' }); expect(res.status).toBe(400); expect(res.body.error).toMatch(/node_id/i); }); it('POST /api/users/:id/roles rejects when stack does not exist on node (400)', async () => { vi.mocked(assertStackExistsOnNode).mockResolvedValueOnce({ ok: false, error: 'Stack not found on node', }); const res = await request(app) .post(`/api/users/${targetUserId}/roles`) .set('Authorization', `Bearer ${adminToken()}`) .send({ role: 'deployer', resource_type: 'stack', resource_id: 'missing-stack', node_id: defaultNodeId(), }); expect(res.status).toBe(400); expect(res.body.error).toMatch(/not found/i); }); it('POST /api/users/:id/roles rejects node_id qualifier on node assignments (400)', async () => { const res = await request(app) .post(`/api/users/${targetUserId}/roles`) .set('Authorization', `Bearer ${adminToken()}`) .send({ role: 'deployer', resource_type: 'node', resource_id: String(defaultNodeId()), node_id: defaultNodeId(), }); expect(res.status).toBe(400); expect(res.body.error).toMatch(/must not be set/i); }); it('POST /api/users/:id/roles rejects nonexistent numeric node resource_id (400)', async () => { const res = await request(app) .post(`/api/users/${targetUserId}/roles`) .set('Authorization', `Bearer ${adminToken()}`) .send({ role: 'deployer', resource_type: 'node', resource_id: '999999', }); expect(res.status).toBe(400); expect(res.body.error).toMatch(/Node not found/i); }); it('POST /api/users/:id/roles creates node assignment without node_id (201)', async () => { const res = await request(app) .post(`/api/users/${targetUserId}/roles`) .set('Authorization', `Bearer ${adminToken()}`) .send({ role: 'deployer', resource_type: 'node', resource_id: String(defaultNodeId()), }); expect(res.status).toBe(201); expect(res.body.resource_type).toBe('node'); expect(res.body.node_id).toBeNull(); }); it('POST /api/users/:id/roles rejects a noncanonical node resource_id (400)', async () => { const res = await request(app) .post(`/api/users/${targetUserId}/roles`) .set('Authorization', `Bearer ${adminToken()}`) .send({ role: 'deployer', resource_type: 'node', resource_id: `0${defaultNodeId()}`, }); expect(res.status).toBe(400); expect(res.body.error).toMatch(/canonical/i); }); 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', node_id: defaultNodeId() }); 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); }); it('POST /api/users/:id/roles is blocked on the Community tier (PAID_REQUIRED)', async () => { const { LicenseService } = await import('../services/LicenseService'); const svc = LicenseService.getInstance(); vi.spyOn(svc, 'getTier').mockReturnValue('community'); try { const res = await request(app) .post(`/api/users/${targetUserId}/roles`) .set('Authorization', `Bearer ${adminToken()}`) .send({ role: 'deployer', resource_type: 'stack', resource_id: 'community-stack', node_id: defaultNodeId() }); expect(res.status).toBe(403); expect(res.body.code).toBe('PAID_REQUIRED'); } finally { vi.spyOn(svc, 'getTier').mockReturnValue('paid'); } }); }); // ---- 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'); }); 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' }); const nodeId = defaultNodeId(); db.addRoleAssignment({ user_id: id, role: 'deployer', resource_type: 'stack', resource_id: 'my-stack', node_id: nodeId }); 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:${nodeId}:my-stack`]).toBeDefined(); // Cleanup db.deleteRoleAssignmentsByUser(id); db.deleteUser(id); }); it('omits scoped permissions on the Community tier even when assignments exist', async () => { const { LicenseService } = await import('../services/LicenseService'); const db = DatabaseService.getInstance(); const svc = LicenseService.getInstance(); const hash = await bcrypt.hash('password123', 1); const id = db.addUser({ username: 'permcheck-community', password_hash: hash, role: 'viewer' }); db.addRoleAssignment({ user_id: id, role: 'deployer', resource_type: 'stack', resource_id: 'my-stack', node_id: defaultNodeId(), }); const user = db.getUserById(id)!; const token = authToken('permcheck-community', 'viewer', user.token_version); // Scoped grants only take effect on paid; a downgraded instance must not // advertise per-resource permissions the API will then 403. vi.spyOn(svc, 'getTier').mockReturnValue('community'); const res = await request(app) .get('/api/permissions/me') .set('Authorization', `Bearer ${token}`); expect(res.status).toBe(200); expect(res.body.scopedPermissions).toEqual({}); // Cleanup vi.spyOn(svc, 'getTier').mockReturnValue('paid'); 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'); }); }); // ---- User creation is uncapped on every tier ---- describe('User creation seat caps', () => { it('creates additional admins and viewers without a seat cap', async () => { const adminRes = await request(app) .post('/api/users') .set('Authorization', `Bearer ${adminToken()}`) .send({ username: 'extraadmin', password: 'password123', role: 'admin' }); expect(adminRes.status).toBe(201); const viewerRes = await request(app) .post('/api/users') .set('Authorization', `Bearer ${adminToken()}`) .send({ username: 'extraviewer', password: 'password123', role: 'viewer' }); expect(viewerRes.status).toBe(201); const db = DatabaseService.getInstance(); db.deleteUser(db.getUserByUsername('extraadmin')!.id); db.deleteUser(db.getUserByUsername('extraviewer')!.id); }); it('creates additional users on the Community tier (no seat cap)', async () => { const { LicenseService } = await import('../services/LicenseService'); const svc = LicenseService.getInstance(); vi.spyOn(svc, 'getTier').mockReturnValue('community'); try { const res = await request(app) .post('/api/users') .set('Authorization', `Bearer ${adminToken()}`) .send({ username: 'communityviewer', password: 'password123', role: 'viewer' }); expect(res.status).toBe(201); DatabaseService.getInstance().deleteUser(DatabaseService.getInstance().getUserByUsername('communityviewer')!.id); } finally { vi.spyOn(svc, 'getTier').mockReturnValue('paid'); } }); }); // ---- 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); }); }); // ---- Role Promotion (uncapped) ---- describe('Role promotion', () => { it('promotes a viewer to admin without a seat cap', async () => { const db = DatabaseService.getInstance(); const hash = await bcrypt.hash('password123', 1); const viewerId = db.addUser({ username: 'promoteok', password_hash: hash, role: 'viewer' }); const res = await request(app) .put(`/api/users/${viewerId}`) .set('Authorization', `Bearer ${adminToken()}`) .send({ role: 'admin' }); expect(res.status).toBe(200); expect(db.getUser(viewerId)!.role).toBe('admin'); db.deleteUser(viewerId); }); }); // ---- Atomic Last-Admin Guard (TOCTOU protection) ---- describe('Atomic last-admin guard', () => { // These lock the guard contract: the admin-count re-check and the mutation run // in one transaction, so a refusal writes nothing (no partial state) and the // count is unchanged. That re-check inside the transaction is what closes the // TOCTOU window a route-level pre-check left open. it('updateUserIfNotLastAdmin refuses to demote the sole admin and applies otherwise', async () => { const db = DatabaseService.getInstance(); expect(db.getAdminCount()).toBe(1); const sole = db.getUserByUsername(TEST_USERNAME)!; expect(db.updateUserIfNotLastAdmin(sole.id, { role: 'viewer' })).toBe(false); // Refusal is side-effect free: role intact and count unchanged. expect(db.getUser(sole.id)!.role).toBe('admin'); expect(db.getAdminCount()).toBe(1); const hash = await bcrypt.hash('password123', 1); const extra = db.addUser({ username: 'raceadmin', password_hash: hash, role: 'admin' }); expect(db.updateUserIfNotLastAdmin(extra, { role: 'viewer' })).toBe(true); expect(db.getUser(extra)!.role).toBe('viewer'); expect(db.getAdminCount()).toBe(1); db.deleteUser(extra); }); it('deleteUserIfNotLastAdmin refuses to delete the sole admin and applies otherwise', async () => { const db = DatabaseService.getInstance(); expect(db.getAdminCount()).toBe(1); const sole = db.getUserByUsername(TEST_USERNAME)!; expect(db.deleteUserIfNotLastAdmin(sole.id)).toBe(false); // Refusal is side-effect free: row intact and count unchanged. expect(db.getUser(sole.id)).toBeTruthy(); expect(db.getAdminCount()).toBe(1); const hash = await bcrypt.hash('password123', 1); const extra = db.addUser({ username: 'raceadmin2', password_hash: hash, role: 'admin' }); expect(db.deleteUserIfNotLastAdmin(extra)).toBe(true); expect(db.getAdminCount()).toBe(1); }); }); // ---- Orphaned Role Assignment Cleanup ---- // Proxied remote stack DELETE clears hub grants only on 2xx in // remoteNodeProxy (deleteRoleAssignmentsByStack). Non-2xx preserves rows. // Orchestrated proxyRes coverage lives in proxy-scoped-stack-evidence.test.ts; // these cases lock the DB helper isolation that the proxy calls. describe('Orphaned role assignment cleanup', () => { it('deleting a node removes its node and stack role assignments', async () => { const db = DatabaseService.getInstance(); const nodeId = db.addNode({ name: 'test-cleanup-node', type: 'remote', api_url: 'http://test:1852', api_token: '', compose_dir: '/tmp', is_default: false, }); 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), }); db.addRoleAssignment({ user_id: userId, role: 'deployer', resource_type: 'stack', resource_id: 'on-doomed', node_id: nodeId, }); expect(db.getAllRoleAssignments(userId)).toHaveLength(2); db.deleteNode(nodeId); expect(db.getAllRoleAssignments(userId)).toHaveLength(0); db.deleteUser(userId); }); it('deleteRoleAssignmentsByStack clears only that node+stack tuple', async () => { const db = DatabaseService.getInstance(); const hash = await bcrypt.hash('password123', 1); const userId = db.addUser({ username: 'tupleorphan', password_hash: hash, role: 'viewer' }); const nodeA = db.addNode({ name: 'tuple-cleanup-node-a', type: 'remote', api_url: 'http://test-a:1852', api_token: '', compose_dir: '/tmp', is_default: false, }); const nodeB = db.addNode({ name: 'tuple-cleanup-node-b', type: 'remote', api_url: 'http://test-b:1852', api_token: '', compose_dir: '/tmp', is_default: false, }); db.addRoleAssignment({ user_id: userId, role: 'deployer', resource_type: 'stack', resource_id: 'shared-name', node_id: nodeA }); db.addRoleAssignment({ user_id: userId, role: 'deployer', resource_type: 'stack', resource_id: 'shared-name', node_id: nodeB }); db.addRoleAssignment({ user_id: userId, role: 'deployer', resource_type: 'stack', resource_id: 'keep-stack', node_id: nodeA }); db.addRoleAssignment({ user_id: userId, role: 'deployer', resource_type: 'node', resource_id: String(nodeA) }); db.deleteRoleAssignmentsByStack(nodeA, 'shared-name'); const after = db.getAllRoleAssignments(userId); expect(after.some((a) => a.resource_type === 'stack' && a.resource_id === 'shared-name' && a.node_id === nodeA)).toBe(false); expect(after.some((a) => a.resource_type === 'stack' && a.resource_id === 'shared-name' && a.node_id === nodeB)).toBe(true); expect(after.some((a) => a.resource_type === 'stack' && a.resource_id === 'keep-stack' && a.node_id === nodeA)).toBe(true); expect(after.some((a) => a.resource_type === 'node' && a.resource_id === String(nodeA))).toBe(true); db.deleteUser(userId); db.deleteNode(nodeA); db.deleteNode(nodeB); }); }); // ---- 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('PERMISSION_DENIED'); }); });