test(rbac): add five-role parity coverage across proxy, WebSocket, and mixed-version surfaces

Adds a reusable five-role persona fixture and extends RBAC test matrix to cover proxy, WebSocket, Pilot-agent, and mixed-version transport surfaces.
This commit is contained in:
Anso
2026-07-31 23:06:57 -04:00
committed by GitHub
parent 8ac4876ad5
commit 5cc4566eb1
8 changed files with 499 additions and 67 deletions
+27 -21
View File
@@ -321,29 +321,35 @@ describe('WebSocket upgrade - exec auth enforcement', () => {
expect(code).toBe(401);
});
it('rejects WebSocket upgrade with non-admin token (403)', async () => {
// Add a non-admin user
const { DatabaseService } = await import('../services/DatabaseService');
const bcrypt = await import('bcrypt');
const hash = await bcrypt.hash('viewerpass', 1);
try {
DatabaseService.getInstance().addUser({ username: 'viewer', password_hash: hash, role: 'viewer' });
} catch {
// User may already exist
}
// All five built-in roles: container exec requires admin. Every non-admin
// role must be rejected at upgrade time by generic.ts's role === 'admin' gate.
const NON_ADMIN_EXEC_ROLES = ['viewer', 'deployer', 'node-admin', 'auditor'] as const;
const token = jwt.sign(
{ username: 'viewer', role: 'viewer' },
TEST_JWT_SECRET,
{ expiresIn: '1m' },
);
const ws = new WebSocket(getWsUrl(), { headers: { Cookie: `sencho_token=${token}` } });
const code = await new Promise<number>((resolve) => {
ws.on('unexpected-response', (_req, res) => resolve(res.statusCode ?? 0));
ws.on('error', () => resolve(0));
for (const role of NON_ADMIN_EXEC_ROLES) {
it(`rejects /ws upgrade with ${role} token (403)`, async () => {
const { DatabaseService } = await import('../services/DatabaseService');
const bcrypt = await import('bcrypt');
const username = `exec_${role.replace('-', '_')}`;
const hash = bcrypt.hashSync('password123', 1);
try {
DatabaseService.getInstance().addUser({ username, password_hash: hash, role });
} catch {
// User may already exist from a prior run in the same worker
}
const token = jwt.sign(
{ username, role },
TEST_JWT_SECRET,
{ expiresIn: '1m' },
);
const ws = new WebSocket(getWsUrl(), { headers: { Cookie: `sencho_token=${token}` } });
const code = await new Promise<number>((resolve) => {
ws.on('unexpected-response', (_req, res) => resolve(res.statusCode ?? 0));
ws.on('error', () => resolve(0));
});
expect(code).toBe(403);
});
expect(code).toBe(403);
});
}
it('rejects WebSocket upgrade with node_proxy token (403)', async () => {
const token = jwt.sign({ scope: 'node_proxy' }, TEST_JWT_SECRET, { expiresIn: '1m' });
@@ -0,0 +1,53 @@
/**
* Reusable five-role persona fixtures for RBAC test suites.
*
* Usage (one-time setup per test file):
* const personas = seedPersonas(DatabaseService.getInstance());
* const viewerReq = request(app).get('/api/stacks').set('Authorization', personas.viewer.bearer);
*
* Each persona carries its own smoke test so a token_version mismatch
* (seeding vs signing) surfaces as a 401 before any permission assertion.
*/
import bcrypt from 'bcrypt';
import jwt from 'jsonwebtoken';
import { TEST_JWT_SECRET } from '../helpers/setupTestDb';
import type { UserRole } from '../../services/DatabaseService';
export const FIVE_ROLES: readonly UserRole[] = ['admin', 'viewer', 'deployer', 'node-admin', 'auditor'] as const;
export interface Persona {
username: string;
role: UserRole;
tokenVersion: number;
bearer: string;
}
export type PersonaMap = Record<UserRole, Persona>;
/** Minimal DB interface needed by seedPersonas — avoids InstanceType<T> issues with private constructors. */
interface PersonaDb {
addUser(u: { username: string; password_hash: string; role: string }): number;
getUserByUsername(username: string): { username: string; role: UserRole; token_version: number } | undefined;
}
/** Seed one user per built-in global role and return signed JWTs for all five. */
export function seedPersonas(db: PersonaDb): PersonaMap {
const personas: Partial<PersonaMap> = {};
for (const role of FIVE_ROLES) {
const username = `persona-${role}`;
// Use a simple shared password since tests auth via Bearer, not login.
const passwordHash = bcrypt.hashSync('password123', 1);
db.addUser({ username, password_hash: passwordHash, role });
const user = db.getUserByUsername(username)!;
const tv = user.token_version;
const token = jwt.sign(
{ username, role, tv },
TEST_JWT_SECRET,
{ expiresIn: '1m' },
);
personas[role] = { username, role, tokenVersion: tv, bearer: `Bearer ${token}` };
}
return personas as PersonaMap;
}
+19 -12
View File
@@ -65,18 +65,25 @@ describe('WebSocket upgrade - host console auth enforcement', () => {
expect(await expectRejected(ws)).toBe(403);
});
it('rejects a non-admin user without system:console (403)', async () => {
const { DatabaseService } = await import('../services/DatabaseService');
const hash = await bcrypt.hash('viewerpass', 1);
try {
DatabaseService.getInstance().addUser({ username: 'hc_viewer', password_hash: hash, role: 'viewer' });
} catch {
// already exists from a prior run in the same worker
}
const token = jwt.sign({ username: 'hc_viewer', role: 'viewer' }, TEST_JWT_SECRET, { expiresIn: '1m' });
const ws = new WebSocket(wsUrl(), { headers: { Cookie: `sencho_token=${token}` } });
expect(await expectRejected(ws)).toBe(403);
});
// All five built-in roles: only admin (system:console) is accepted on host
// console. Every other role must be rejected at upgrade time.
const NON_ADMIN_HOST_CONSOLE_ROLES = ['viewer', 'deployer', 'node-admin', 'auditor'] as const;
for (const role of NON_ADMIN_HOST_CONSOLE_ROLES) {
it(`rejects ${role} user without system:console (403)`, async () => {
const { DatabaseService } = await import('../services/DatabaseService');
const username = `hc_${role.replace('-', '_')}`;
const hash = await bcrypt.hash('password123', 1);
try {
DatabaseService.getInstance().addUser({ username, password_hash: hash, role });
} catch {
// already exists from a prior run in the same worker
}
const token = jwt.sign({ username, role }, TEST_JWT_SECRET, { expiresIn: '1m' });
const ws = new WebSocket(wsUrl(), { headers: { Cookie: `sencho_token=${token}` } });
expect(await expectRejected(ws)).toBe(403);
});
}
it('accepts a Community-tier admin', async () => {
getTierSpy.mockReturnValueOnce('community');
@@ -3,34 +3,31 @@ import { ROLE_PERMISSIONS, type PermissionAction } from '../middleware/permissio
import { classifyStackApiPath } from '../helpers/stackRouteAuth';
describe('operational role matrix', () => {
const expectations: Record<string, { allow: PermissionAction[]; deny: PermissionAction[] }> = {
admin: {
allow: ['stack:read', 'stack:edit', 'stack:deploy', 'stack:create', 'stack:delete', 'node:read', 'node:manage', 'system:settings'],
deny: [],
},
'node-admin': {
allow: ['stack:read', 'stack:edit', 'stack:deploy', 'stack:create', 'stack:delete', 'node:read', 'node:manage'],
deny: ['system:settings', 'system:users', 'system:license', 'system:registries'],
},
deployer: {
allow: ['stack:read', 'stack:deploy'],
deny: ['stack:edit', 'stack:create', 'stack:delete', 'node:read', 'node:manage', 'system:settings'],
},
viewer: {
allow: ['stack:read', 'node:read'],
deny: ['stack:edit', 'stack:deploy', 'stack:create', 'stack:delete', 'node:manage', 'system:audit'],
},
auditor: {
allow: ['stack:read', 'node:read', 'system:audit'],
deny: ['stack:edit', 'stack:deploy', 'stack:create', 'stack:delete', 'node:manage', 'system:settings'],
},
/**
* Lockstep guard: every role's full permission set must match exactly.
* Any addition or removal to ROLE_PERMISSIONS must update this table;
* the full-set equality catches drift that a subset check would miss.
*/
const expectedRoleActions: Record<string, PermissionAction[]> = {
admin: [
'stack:read', 'stack:edit', 'stack:deploy', 'stack:create', 'stack:delete',
'node:read', 'node:manage',
'system:settings', 'system:users', 'system:license', 'system:webhooks',
'system:tokens', 'system:console', 'system:audit', 'system:registries',
],
'node-admin': [
'stack:read', 'stack:edit', 'stack:deploy', 'stack:create', 'stack:delete',
'node:read', 'node:manage',
],
deployer: ['stack:read', 'stack:deploy'],
viewer: ['stack:read', 'node:read'],
auditor: ['stack:read', 'node:read', 'system:audit'],
};
for (const [role, expected] of Object.entries(expectations)) {
it(`${role} exposes only its intended operational actions`, () => {
const actions = ROLE_PERMISSIONS[role as keyof typeof ROLE_PERMISSIONS];
for (const action of expected.allow) expect(actions).toContain(action);
for (const action of expected.deny) expect(actions).not.toContain(action);
for (const [role, expected] of Object.entries(expectedRoleActions)) {
it(`${role} has exactly the expected permission set`, () => {
const actual = [...(ROLE_PERMISSIONS[role as keyof typeof ROLE_PERMISSIONS] ?? [])].sort();
expect(actual).toEqual([...expected].sort());
});
}
});
@@ -0,0 +1,56 @@
/**
* Persona fixture smoke tests: every persona must authenticate and carry
* the correct permission set before any downstream test relies on it.
*/
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import request from 'supertest';
import {
setupTestDb,
cleanupTestDb,
} from './helpers/setupTestDb';
import { seedPersonas, FIVE_ROLES, type PersonaMap } from './fixtures/personas';
import { ROLE_PERMISSIONS, checkPermission } from '../middleware/permissions';
import { DatabaseService } from '../services/DatabaseService';
describe('persona fixture integrity', () => {
let tmpDir: string;
let app: import('express').Express;
let personas: PersonaMap;
beforeAll(async () => {
tmpDir = await setupTestDb();
({ app } = await import('../index'));
personas = seedPersonas(DatabaseService.getInstance());
});
afterAll(() => {
cleanupTestDb(tmpDir);
});
for (const role of FIVE_ROLES) {
describe(`${role} persona`, () => {
it('authenticates on the auth-status endpoint', async () => {
const res = await request(app)
.get('/api/auth/status')
.set('Authorization', personas[role].bearer);
expect(res.status).toBe(200);
// Smoke: a 200 on /api/auth/status proves the JWT was accepted.
});
it('has the expected global permissions from ROLE_PERMISSIONS', () => {
const actions = ROLE_PERMISSIONS[role];
expect(actions).toBeDefined();
expect(actions).toContain('stack:read');
});
it('checkPermission matches global role grants for each owned action', () => {
const p = personas[role];
const actions = ROLE_PERMISSIONS[role];
for (const action of actions) {
const req = { user: { username: p.username, role: p.role, userId: 1 } } as any;
expect(checkPermission(req, action)).toBe(true);
}
});
});
}
});
@@ -0,0 +1,105 @@
/**
* Pilot-agent-mode header parity: the createRemoteProxyMiddleware code path
* for pilot_agent remotes is identical to proxy mode (the only difference is
* an empty apiToken from NodeRegistry.getProxyTarget). Stub the singleton so
* a pilot_agent node routes through a loopback capture server and assert the
* same PROXY_ROLE_HEADER is forwarded.
*/
import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest';
import http from 'http';
import request from 'supertest';
import { setupTestDb, cleanupTestDb } from './helpers/setupTestDb';
import { PROXY_ROLE_HEADER } from '../services/license-headers';
import { seedPersonas } from './fixtures/personas';
import { DatabaseService } from '../services/DatabaseService';
describe('pilot-agent-mode proxy role header parity', () => {
let tmpDir: string;
let app: import('express').Express;
let server: http.Server;
let captured: http.IncomingHttpHeaders | null = null;
let pilotNodeId: number;
let personas: ReturnType<typeof seedPersonas>;
beforeAll(async () => {
tmpDir = await setupTestDb();
({ app } = await import('../index'));
personas = seedPersonas(DatabaseService.getInstance());
// Loopback capture server that advertises cross-node-rbac.
server = http.createServer((req, res) => {
if (req.url?.startsWith('/api/meta')) {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ version: '0.96.0', capabilities: ['cross-node-rbac'] }));
return;
}
captured = req.headers;
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end('[]');
});
await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve));
const port = (server.address() as import('net').AddressInfo).port;
pilotNodeId = DatabaseService.getInstance().addNode({
name: 'pilot-header-test',
type: 'remote',
mode: 'pilot_agent',
compose_dir: '/tmp',
is_default: false,
api_url: '',
api_token: '',
});
// getProxyTarget returns null for pilot_agent without a live bridge.
// Stub it to return the capture-server URL with an empty apiToken — the
// exact shape a real PilotTunnelBridge produces at runtime.
const { NodeRegistry } = await import('../services/NodeRegistry');
const registry = NodeRegistry.getInstance();
const orig = registry.getProxyTarget.bind(registry);
vi.spyOn(registry, 'getProxyTarget').mockImplementation((nid: number) => {
if (nid === pilotNodeId) return { apiUrl: `http://127.0.0.1:${port}`, apiToken: '' };
return orig(nid);
});
});
afterAll(async () => {
vi.restoreAllMocks();
await new Promise<void>((resolve) => server.close(() => resolve()));
cleanupTestDb(tmpDir);
});
it('forwards the deployer session role through the pilot-agent proxy path', async () => {
captured = null;
const res = await request(app)
.get('/api/stacks')
.set('Authorization', personas.deployer.bearer)
.set('x-node-id', String(pilotNodeId));
expect(res.status).toBe(200);
// The capture server must have received the request.
expect(captured).not.toBeNull();
expect(captured?.[PROXY_ROLE_HEADER]).toBe('deployer');
});
it('overwrites a smuggled admin header with the real deployer role on pilot path', async () => {
captured = null;
const res = await request(app)
.get('/api/stacks')
.set('Authorization', personas.deployer.bearer)
.set('x-node-id', String(pilotNodeId))
.set(PROXY_ROLE_HEADER, 'admin'); // smuggled
expect(res.status).toBe(200);
expect(captured).not.toBeNull();
expect(captured?.[PROXY_ROLE_HEADER]).toBe('deployer');
});
it('forwards the admin session role through the pilot-agent proxy path', async () => {
captured = null;
const res = await request(app)
.get('/api/stacks')
.set('Authorization', personas.admin.bearer)
.set('x-node-id', String(pilotNodeId));
expect(res.status).toBe(200);
expect(captured).not.toBeNull();
expect(captured?.[PROXY_ROLE_HEADER]).toBe('admin');
});
});
@@ -24,22 +24,38 @@ import { PROXY_ROLE_HEADER, PROXY_DEPLOY_SOURCE_HEADER, PROXY_DEPLOY_ACTOR_HEADE
let tmpDir: string;
let app: import('express').Express;
let viewerBearer: string;
let nodeAdminBearer: string;
let auditorBearer: string;
let deployerBearer: string;
const VIEWER_USER = 'proxy-role-viewer';
const NODE_ADMIN_USER = 'proxy-role-node-admin';
const AUDITOR_USER = 'proxy-role-auditor';
const DEPLOYER_USER = 'proxy-role-deployer';
beforeAll(async () => {
tmpDir = await setupTestDb();
({ app } = await import('../index'));
const { DatabaseService } = await import('../services/DatabaseService');
const db = DatabaseService.getInstance();
const hash = await bcrypt.hash('password123', 1);
db.addUser({ username: VIEWER_USER, password_hash: hash, role: 'viewer' });
const viewer = db.getUserByUsername(VIEWER_USER)!;
viewerBearer = jwt.sign(
{ username: VIEWER_USER, role: 'viewer', tv: viewer.token_version },
TEST_JWT_SECRET,
{ expiresIn: '1m' },
);
// Seed bearer tokens for each non-admin role, matching the original
// viewer pattern exactly to avoid drift.
const seed = (username: string, role: string): string => {
const hash = bcrypt.hashSync('password123', 1);
db.addUser({ username, password_hash: hash, role: role as any });
const user = db.getUserByUsername(username)!;
return jwt.sign(
{ username, role, tv: user.token_version },
TEST_JWT_SECRET,
{ expiresIn: '1m' },
);
};
viewerBearer = seed(VIEWER_USER, 'viewer');
deployerBearer = seed(DEPLOYER_USER, 'deployer');
nodeAdminBearer = seed(NODE_ADMIN_USER, 'node-admin');
auditorBearer = seed(AUDITOR_USER, 'auditor');
});
afterAll(() => {
@@ -127,9 +143,65 @@ describe('authMiddleware - forwarded actor role (node_proxy)', () => {
.set(PROXY_ROLE_HEADER, 'superadmin');
expect(res.status).toBe(200);
});
// All five built-in roles: denied on admin-only, allowed on read-only.
const NON_ADMIN_ROLES = [
{ role: 'viewer', desc: 'viewer' },
{ role: 'deployer', desc: 'deployer' },
{ role: 'node-admin', desc: 'node-admin' },
{ role: 'auditor', desc: 'auditor' },
];
for (const { role, desc } of NON_ADMIN_ROLES) {
it(`denies admin-only route for forwarded ${desc}`, async () => {
const token = signToken({ scope: 'node_proxy' });
const res = await request(app)
.get(ADMIN_ONLY)
.set('Authorization', `Bearer ${token}`)
.set(PROXY_ROLE_HEADER, role);
expect(res.status).toBe(403);
});
it(`grants read access for forwarded ${desc}`, async () => {
const token = signToken({ scope: 'node_proxy' });
const res = await request(app)
.get(READ_ONLY)
.set('Authorization', `Bearer ${token}`)
.set(PROXY_ROLE_HEADER, role);
expect(res.status).toBe(200);
});
it(`applies same trust to pilot_tunnel for forwarded ${desc}`, async () => {
const token = signToken({ scope: 'pilot_tunnel' });
const res = await request(app)
.get(ADMIN_ONLY)
.set('Authorization', `Bearer ${token}`)
.set(PROXY_ROLE_HEADER, role);
expect(res.status).toBe(403);
});
}
});
describe('Security - actor-role header cannot be smuggled by a user session', () => {
// Verify each non-admin bearer token authenticates locally before testing
// proxy paths. Catches token-version drift between seeding and signing.
// Bearer variables are set in beforeAll; resolve lazily inside each test.
const BEARER_REFS: Array<{ name: string; get: () => string }> = [
{ name: 'viewer', get: () => viewerBearer },
{ name: 'deployer', get: () => deployerBearer },
{ name: 'node-admin', get: () => nodeAdminBearer },
{ name: 'auditor', get: () => auditorBearer },
];
for (const { name, get } of BEARER_REFS) {
it(`${name} token authenticates on /api/auth/status`, async () => {
const res = await request(app)
.get('/api/auth/status')
.set('Authorization', `Bearer ${get()}`);
expect(res.status).toBe(200);
});
}
it('ignores the role header on a cookie session (uses the DB role)', async () => {
const cookie = await loginAsTestAdmin(app);
// A browser session that tries to downgrade itself (or, by the same path,
@@ -235,4 +307,28 @@ describe('remote proxy gateway - actor role header forwarding', () => {
expect(captured?.[PROXY_DEPLOY_SOURCE_HEADER]).toBe('manual');
expect(captured?.[PROXY_DEPLOY_ACTOR_HEADER]).toBe(VIEWER_USER);
});
// Anti-smuggling extended to the remaining non-admin roles: each persona's
// real role must be forwarded even when an attacker sets the admin header.
// The bearer variables are set in beforeAll; resolve them lazily inside each
// test so we never capture undefined at describe-registration time.
const SMUGGLE_PERSONAS: Array<{ name: string; bearerRef: () => string; role: string }> = [
{ name: 'deployer', bearerRef: () => deployerBearer, role: 'deployer' },
{ name: 'node-admin', bearerRef: () => nodeAdminBearer, role: 'node-admin' },
{ name: 'auditor', bearerRef: () => auditorBearer, role: 'auditor' },
];
for (const { name, bearerRef, role } of SMUGGLE_PERSONAS) {
it(`overwrites smuggled admin header with ${name} session role`, async () => {
captured = null;
const res = await request(app)
.get(READ_ONLY)
.set('Authorization', `Bearer ${bearerRef()}`)
.set('x-node-id', String(remoteNodeId))
.set(PROXY_ROLE_HEADER, 'admin');
expect(res.status).toBe(200);
expect(captured?.[PROXY_ROLE_HEADER]).toBe(role);
});
}
});
@@ -0,0 +1,112 @@
/**
* RBAC regression coverage: MFA reset gating, SSO user permission parity,
* and scoped-evidence fail-closed on remote.
*
* Tests for last-admin protection, immediate role-change effect, and
* API-token scope isolation are already covered by users-rbac.test.ts,
* api-tokens.test.ts, and api-token-ws-scope.test.ts respectively.
*
* Single describe + single setupTestDb()/cleanupTestDb() pair because the
* DatabaseService singleton is lazy-constructed on first getInstance() and
* never re-initializes; a second setupTestDb() after a first cleanupTestDb()
* would reuse a stale handle pointing at a deleted file, tripping
* SQLITE_READONLY_DBMOVED on Linux.
*/
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import request from 'supertest';
import jwt from 'jsonwebtoken';
import {
setupTestDb,
cleanupTestDb,
TEST_JWT_SECRET,
} from './helpers/setupTestDb';
import { checkPermission } from '../middleware/permissions';
import { DatabaseService } from '../services/DatabaseService';
describe('RBAC regression coverage', () => {
let tmpDir: string;
let app: import('express').Express;
beforeAll(async () => {
tmpDir = await setupTestDb();
({ app } = await import('../index'));
});
afterAll(() => {
cleanupTestDb(tmpDir);
});
// ── MFA reset gating ────────────────────────────────────────────
// POST /:id/mfa/reset requires system:users (admin only).
const TARGET_ID = 1; // Baseline admin is id 1, seeded by globalSetup.
it('rejects unauthenticated MFA reset (401)', async () => {
const res = await request(app).post(`/api/users/${TARGET_ID}/mfa/reset`);
expect(res.status).toBe(401);
});
it('rejects non-admin from resetting MFA (403)', async () => {
const db = DatabaseService.getInstance();
const bcrypt = await import('bcrypt');
const hash = await bcrypt.default.hash('password123', 1);
db.addUser({ username: 'mfa-viewer', password_hash: hash, role: 'viewer' });
const user = db.getUserByUsername('mfa-viewer')!;
const token = jwt.sign(
{ username: 'mfa-viewer', role: 'viewer', tv: user.token_version },
TEST_JWT_SECRET,
{ expiresIn: '1m' },
);
const res = await request(app)
.post(`/api/users/${TARGET_ID}/mfa/reset`)
.set('Authorization', `Bearer ${token}`);
expect(res.status).toBe(403);
});
// ── SSO user permission parity ──────────────────────────────────
it('grants same permissions as a local user of the same role', () => {
const db = DatabaseService.getInstance();
db.addUser({
username: 'sso-node-admin',
password_hash: '$sso$fake',
role: 'node-admin',
auth_provider: 'oidc_google',
provider_id: 'google-456',
email: 'sso-na@test.com',
});
const ssoUser = db.getUserByUsername('sso-node-admin')!;
// permissions.ts never branches on auth_provider; only role matters.
const req = {
user: { username: ssoUser.username, role: ssoUser.role, userId: ssoUser.id },
} as any;
expect(checkPermission(req, 'stack:read')).toBe(true);
expect(checkPermission(req, 'stack:edit')).toBe(true);
expect(checkPermission(req, 'stack:deploy')).toBe(true);
expect(checkPermission(req, 'node:manage')).toBe(true);
expect(checkPermission(req, 'system:settings')).toBe(false);
expect(checkPermission(req, 'system:users')).toBe(false);
});
// ── Scoped evidence fail-closed ─────────────────────────────────
it('denies a scoped-only user when scopedStackEvidence is absent', () => {
const remoteReq = {
user: { username: 'node-proxy', role: 'viewer', userId: 0 },
scopedStackEvidence: undefined,
} as any;
expect(checkPermission(remoteReq, 'stack:edit', 'stack', 'web')).toBe(false);
});
it('allows the same action when evidence is present and matches', () => {
const remoteReq = {
user: { username: 'node-proxy', role: 'viewer', userId: 0 },
scopedStackEvidence: {
stackName: 'web',
actions: new Set(['stack:edit', 'stack:deploy']),
},
} as any;
expect(checkPermission(remoteReq, 'stack:edit', 'stack', 'web')).toBe(true);
});
});