mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-11 11:16:55 +00:00
9922d8e765
* feat(rbac): make stack-scoped grants node-specific Qualify stack role assignments as (nodeId, stackName), migrate legacy rows to the default node, and forward bound multi-action evidence on Proxy/Pilot hops so scoped users keep least-privilege remote access without shipping the full grant table. * fix: mirror scoped-stack-auth-evidence capability to frontend, sanitize node id in role assignment log Backend added the scoped-stack-auth-evidence capability without the matching frontend entry, failing the capability parity test. The role assignment log also interpolated the node id without sanitizeForLog, unlike the rest of the line. * fix(rbac): honor node-wide scopes and fix proxied DELETE cleanup Node-scoped grants now authorize that role's stack actions on the same node in the backend resolver, frontend can(), and remote evidence. Proxied DELETE cleanup uses the gate-stashed route because pathRewrite mutates req.path before proxyRes. Add proxy integration coverage and drop the stale scoped-permissions screenshot. * fix(rbac): preserve node-qualified grants during repair
896 lines
34 KiB
TypeScript
896 lines
34 KiB
TypeScript
/**
|
|
* 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<string, unknown> = { 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('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');
|
|
});
|
|
|
|
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('ADMIN_REQUIRED');
|
|
});
|
|
});
|