mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-07-27 20:29:10 +00:00
fix(federation): gate node cordon control on node:manage to match backend (#1277)
* fix(federation): gate node cordon control on node:manage to match backend
The cordon and uncordon control on the node card rendered whenever the
instance held an Admiral license, but the backend route also requires the
node:manage permission. Non-admin users without that permission (deployer,
viewer, auditor) saw a Cordon action the API rejected with 403. Gate the
control on the same permission the route enforces, so it renders only for
users who can use it.
Add developer-mode diagnostic logging to the cordon and pin handlers, and
route-level tests covering the cordon and uncordon permission, tier,
API-token, and input-validation boundaries.
* fix(federation): reject non-numeric node ids in cordon/uncordon
The cordon and uncordon routes validated the node id with parseInt, which
accepts numeric-prefix strings (parseInt('1abc', 10) === 1), so a request to
/api/nodes/1abc/cordon would operate on node 1. Require a strict positive
integer before parsing, and add tests for both routes.
* fix(federation): sanitize user-derived values in cordon and pin diagnostics
Route the node id, blueprint id, target node id, and reason length through
the shared log sanitizer before they reach the developer-mode diagnostic
log lines, and switch the format specifiers to %s to match. The values are
already validated integers, so this is defense in depth at the log sink and
keeps the diagnostics on the same sanitize-every-interpolated-value pattern
used elsewhere. No runtime behavior change: the lines stay gated behind the
developer_mode setting, off by default.
This commit is contained in:
@@ -0,0 +1,306 @@
|
||||
/**
|
||||
* Authorization and validation tests for the Federation cordon routes
|
||||
* (POST /api/nodes/:id/cordon and /uncordon).
|
||||
*
|
||||
* These guard the same boundary the NodeCard cordon control renders against, so
|
||||
* a UI gate and a route guard cannot silently drift apart. Cordon/uncordon
|
||||
* require Admiral tier AND the node:manage permission (held by admin and
|
||||
* node-admin roles). The guard order is:
|
||||
* rejectApiTokenScope (SCOPE_DENIED) -> requirePermission (PERMISSION_DENIED)
|
||||
* -> requireAdmiral (PAID_REQUIRED / ADMIRAL_REQUIRED) -> invalid-id 400
|
||||
* -> reason 400 (cordon only) -> 404.
|
||||
*/
|
||||
import { describe, it, expect, beforeAll, afterAll, beforeEach, vi } from 'vitest';
|
||||
import request from 'supertest';
|
||||
import type { LicenseTier, LicenseVariant } from '../services/license-types';
|
||||
import type { UserRole } from '../services/DatabaseService';
|
||||
import { setupTestDb, cleanupTestDb, loginAsTestAdmin, TEST_USERNAME } from './helpers/setupTestDb';
|
||||
import { createTestApiToken } from './helpers/apiTokenTestHelper';
|
||||
|
||||
let tmpDir: string;
|
||||
let app: import('express').Express;
|
||||
let DatabaseService: typeof import('../services/DatabaseService').DatabaseService;
|
||||
let LicenseService: typeof import('../services/LicenseService').LicenseService;
|
||||
let adminCookie: string;
|
||||
let adminUserId: number;
|
||||
const roleCookie: Record<string, string> = {};
|
||||
let counter = 0;
|
||||
|
||||
function setLicense(tier: LicenseTier, variant: LicenseVariant): void {
|
||||
vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValue(tier);
|
||||
vi.spyOn(LicenseService.getInstance(), 'getVariant').mockReturnValue(variant);
|
||||
}
|
||||
|
||||
function seedNode(): { id: number; name: string } {
|
||||
counter += 1;
|
||||
const name = `cordon-authz-node-${counter}`;
|
||||
const db = DatabaseService.getInstance().getDb();
|
||||
const result = db.prepare(
|
||||
`INSERT INTO nodes (name, type, mode, compose_dir, is_default, status, created_at)
|
||||
VALUES (?, 'local', 'proxy', '/tmp/compose', 0, 'online', ?)`,
|
||||
).run(name, Date.now());
|
||||
return { id: result.lastInsertRowid as number, name };
|
||||
}
|
||||
|
||||
async function seedAndLogin(role: UserRole): Promise<string> {
|
||||
const bcrypt = (await import('bcrypt')).default;
|
||||
const supertest = (await import('supertest')).default;
|
||||
const username = `cordon-${role}`;
|
||||
const password = `cordon-${role}-pass`;
|
||||
const passwordHash = await bcrypt.hash(password, 1);
|
||||
DatabaseService.getInstance().addUser({ username, password_hash: passwordHash, role });
|
||||
const res = await supertest(app).post('/api/auth/login').send({ username, password });
|
||||
const cookies = res.headers['set-cookie'] as string | string[];
|
||||
return Array.isArray(cookies) ? cookies[0] : cookies;
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
tmpDir = await setupTestDb();
|
||||
({ DatabaseService } = await import('../services/DatabaseService'));
|
||||
({ LicenseService } = await import('../services/LicenseService'));
|
||||
|
||||
vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValue('paid');
|
||||
vi.spyOn(LicenseService.getInstance(), 'getVariant').mockReturnValue('admiral');
|
||||
vi.spyOn(LicenseService.getInstance(), 'getSeatLimits').mockReturnValue({ maxAdmins: null, maxViewers: null });
|
||||
|
||||
({ app } = await import('../index'));
|
||||
adminCookie = await loginAsTestAdmin(app);
|
||||
for (const role of ['node-admin', 'viewer', 'deployer', 'auditor'] as const) {
|
||||
roleCookie[role] = await seedAndLogin(role);
|
||||
}
|
||||
const adminRow = DatabaseService.getInstance().getDb()
|
||||
.prepare('SELECT id FROM users WHERE username = ?')
|
||||
.get(TEST_USERNAME) as { id: number } | undefined;
|
||||
if (!adminRow) throw new Error('seeded admin user not found');
|
||||
adminUserId = adminRow.id;
|
||||
});
|
||||
|
||||
afterAll(() => cleanupTestDb(tmpDir));
|
||||
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
setLicense('paid', 'admiral');
|
||||
vi.spyOn(LicenseService.getInstance(), 'getSeatLimits').mockReturnValue({ maxAdmins: null, maxViewers: null });
|
||||
DatabaseService.getInstance().getDb().prepare('DELETE FROM nodes WHERE is_default = 0').run();
|
||||
});
|
||||
|
||||
describe('POST /api/nodes/:id/cordon authorization', () => {
|
||||
it('lets an admin cordon a node and stores the trimmed reason', async () => {
|
||||
const node = seedNode();
|
||||
const res = await request(app)
|
||||
.post(`/api/nodes/${node.id}/cordon`)
|
||||
.set('Cookie', adminCookie)
|
||||
.send({ reason: ' patching kernel ' });
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.cordoned).toBe(true);
|
||||
expect(res.body.cordoned_reason).toBe('patching kernel');
|
||||
});
|
||||
|
||||
it('lets a node-admin cordon a node', async () => {
|
||||
const node = seedNode();
|
||||
const res = await request(app)
|
||||
.post(`/api/nodes/${node.id}/cordon`)
|
||||
.set('Cookie', roleCookie['node-admin'])
|
||||
.send({});
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.cordoned).toBe(true);
|
||||
});
|
||||
|
||||
it.each(['viewer', 'deployer', 'auditor'])(
|
||||
'rejects a %s without node:manage with PERMISSION_DENIED',
|
||||
async (role) => {
|
||||
const node = seedNode();
|
||||
const res = await request(app)
|
||||
.post(`/api/nodes/${node.id}/cordon`)
|
||||
.set('Cookie', roleCookie[role])
|
||||
.send({});
|
||||
expect(res.status).toBe(403);
|
||||
expect(res.body.code).toBe('PERMISSION_DENIED');
|
||||
},
|
||||
);
|
||||
|
||||
it('rejects an admin on a Skipper license with ADMIRAL_REQUIRED', async () => {
|
||||
setLicense('paid', 'skipper');
|
||||
const node = seedNode();
|
||||
const res = await request(app)
|
||||
.post(`/api/nodes/${node.id}/cordon`)
|
||||
.set('Cookie', adminCookie)
|
||||
.send({});
|
||||
expect(res.status).toBe(403);
|
||||
expect(res.body.code).toBe('ADMIRAL_REQUIRED');
|
||||
});
|
||||
|
||||
it('rejects an admin on a Community license with PAID_REQUIRED', async () => {
|
||||
setLicense('community', null);
|
||||
const node = seedNode();
|
||||
const res = await request(app)
|
||||
.post(`/api/nodes/${node.id}/cordon`)
|
||||
.set('Cookie', adminCookie)
|
||||
.send({});
|
||||
expect(res.status).toBe(403);
|
||||
expect(res.body.code).toBe('PAID_REQUIRED');
|
||||
});
|
||||
|
||||
it('rejects a full-admin API token with SCOPE_DENIED (API tokens cannot manage nodes)', async () => {
|
||||
const node = seedNode();
|
||||
const token = createTestApiToken({ db: DatabaseService, scope: 'full-admin', userId: adminUserId });
|
||||
const res = await request(app)
|
||||
.post(`/api/nodes/${node.id}/cordon`)
|
||||
.set('Authorization', `Bearer ${token}`)
|
||||
.send({ reason: 'ci' });
|
||||
expect(res.status).toBe(403);
|
||||
expect(res.body.code).toBe('SCOPE_DENIED');
|
||||
});
|
||||
|
||||
it('rejects a reason longer than 256 characters with 400', async () => {
|
||||
const node = seedNode();
|
||||
const res = await request(app)
|
||||
.post(`/api/nodes/${node.id}/cordon`)
|
||||
.set('Cookie', adminCookie)
|
||||
.send({ reason: 'a'.repeat(257) });
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it('rejects a non-string reason with 400', async () => {
|
||||
const node = seedNode();
|
||||
const res = await request(app)
|
||||
.post(`/api/nodes/${node.id}/cordon`)
|
||||
.set('Cookie', adminCookie)
|
||||
.send({ reason: 123 });
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it('rejects an invalid node id with 400', async () => {
|
||||
const res = await request(app)
|
||||
.post('/api/nodes/0/cordon')
|
||||
.set('Cookie', adminCookie)
|
||||
.send({});
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it('rejects a numeric-prefix node id with 400', async () => {
|
||||
const res = await request(app)
|
||||
.post('/api/nodes/1abc/cordon')
|
||||
.set('Cookie', adminCookie)
|
||||
.send({});
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it('returns 404 for a node that does not exist', async () => {
|
||||
const res = await request(app)
|
||||
.post('/api/nodes/999999/cordon')
|
||||
.set('Cookie', adminCookie)
|
||||
.send({});
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it('stores null when the reason is whitespace only', async () => {
|
||||
const node = seedNode();
|
||||
const res = await request(app)
|
||||
.post(`/api/nodes/${node.id}/cordon`)
|
||||
.set('Cookie', adminCookie)
|
||||
.send({ reason: ' ' });
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.cordoned).toBe(true);
|
||||
expect(res.body.cordoned_reason).toBeNull();
|
||||
});
|
||||
|
||||
it('checks node:manage before the tier gate (Skipper viewer gets PERMISSION_DENIED, not ADMIRAL_REQUIRED)', async () => {
|
||||
setLicense('paid', 'skipper');
|
||||
const node = seedNode();
|
||||
const res = await request(app)
|
||||
.post(`/api/nodes/${node.id}/cordon`)
|
||||
.set('Cookie', roleCookie['viewer'])
|
||||
.send({});
|
||||
expect(res.status).toBe(403);
|
||||
expect(res.body.code).toBe('PERMISSION_DENIED');
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /api/nodes/:id/uncordon authorization', () => {
|
||||
function seedCordonedNode(): { id: number; name: string } {
|
||||
const node = seedNode();
|
||||
DatabaseService.getInstance().setNodeCordoned(node.id, true, 'pre');
|
||||
return node;
|
||||
}
|
||||
|
||||
it('lets an admin uncordon a node', async () => {
|
||||
const node = seedCordonedNode();
|
||||
const res = await request(app)
|
||||
.post(`/api/nodes/${node.id}/uncordon`)
|
||||
.set('Cookie', adminCookie)
|
||||
.send({});
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.cordoned).toBe(false);
|
||||
expect(res.body.cordoned_reason).toBeNull();
|
||||
});
|
||||
|
||||
it('lets a node-admin uncordon a node', async () => {
|
||||
const node = seedCordonedNode();
|
||||
const res = await request(app)
|
||||
.post(`/api/nodes/${node.id}/uncordon`)
|
||||
.set('Cookie', roleCookie['node-admin'])
|
||||
.send({});
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.cordoned).toBe(false);
|
||||
});
|
||||
|
||||
it.each(['viewer', 'deployer', 'auditor'])(
|
||||
'rejects a %s without node:manage with PERMISSION_DENIED',
|
||||
async (role) => {
|
||||
const node = seedCordonedNode();
|
||||
const res = await request(app)
|
||||
.post(`/api/nodes/${node.id}/uncordon`)
|
||||
.set('Cookie', roleCookie[role])
|
||||
.send({});
|
||||
expect(res.status).toBe(403);
|
||||
expect(res.body.code).toBe('PERMISSION_DENIED');
|
||||
},
|
||||
);
|
||||
|
||||
it('rejects an admin on a Skipper license with ADMIRAL_REQUIRED', async () => {
|
||||
setLicense('paid', 'skipper');
|
||||
const node = seedCordonedNode();
|
||||
const res = await request(app)
|
||||
.post(`/api/nodes/${node.id}/uncordon`)
|
||||
.set('Cookie', adminCookie)
|
||||
.send({});
|
||||
expect(res.status).toBe(403);
|
||||
expect(res.body.code).toBe('ADMIRAL_REQUIRED');
|
||||
});
|
||||
|
||||
it('rejects a full-admin API token with SCOPE_DENIED', async () => {
|
||||
const node = seedCordonedNode();
|
||||
const token = createTestApiToken({ db: DatabaseService, scope: 'full-admin', userId: adminUserId });
|
||||
const res = await request(app)
|
||||
.post(`/api/nodes/${node.id}/uncordon`)
|
||||
.set('Authorization', `Bearer ${token}`)
|
||||
.send({});
|
||||
expect(res.status).toBe(403);
|
||||
expect(res.body.code).toBe('SCOPE_DENIED');
|
||||
});
|
||||
|
||||
it('returns 404 for a node that does not exist', async () => {
|
||||
const res = await request(app)
|
||||
.post('/api/nodes/999999/uncordon')
|
||||
.set('Cookie', adminCookie)
|
||||
.send({});
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it('rejects an invalid node id with 400', async () => {
|
||||
const res = await request(app)
|
||||
.post('/api/nodes/0/uncordon')
|
||||
.set('Cookie', adminCookie)
|
||||
.send({});
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it('rejects a numeric-prefix node id with 400', async () => {
|
||||
const res = await request(app)
|
||||
.post('/api/nodes/1abc/uncordon')
|
||||
.set('Cookie', adminCookie)
|
||||
.send({});
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
});
|
||||
@@ -12,6 +12,8 @@ import { BlueprintAnalyzer } from '../services/BlueprintAnalyzer';
|
||||
import { NodeLabelService } from '../services/NodeLabelService';
|
||||
import { isValidStackName } from '../utils/validation';
|
||||
import { parseIntParam } from '../utils/parseIntParam';
|
||||
import { isDebugEnabled } from '../utils/debug';
|
||||
import { sanitizeForLog } from '../utils/safeLog';
|
||||
import { isSqliteUniqueViolation, getErrorMessage } from '../utils/errors';
|
||||
|
||||
export const blueprintsRouter = Router();
|
||||
@@ -477,6 +479,7 @@ blueprintsRouter.put('/:id/pin', async (req: Request, res: Response): Promise<vo
|
||||
}
|
||||
const updated = DatabaseService.getInstance().setBlueprintPinnedNode(id, nodeId);
|
||||
if (!updated) { res.status(404).json({ error: 'Blueprint not found' }); return; }
|
||||
if (isDebugEnabled()) console.log('[Federation:diag] pinned blueprint=%s node=%s', sanitizeForLog(id), sanitizeForLog(nodeId));
|
||||
// Trigger immediate reconciliation so the pin takes effect without
|
||||
// waiting for the next 60s tick. Errors here are logged but do not
|
||||
// fail the request: the pin is already persisted.
|
||||
|
||||
@@ -18,6 +18,8 @@ import { FleetUpdateTrackerService } from '../services/FleetUpdateTrackerService
|
||||
import { FleetSyncService } from '../services/FleetSyncService';
|
||||
import { isValidRemoteUrl } from '../utils/validation';
|
||||
import { getErrorMessage } from '../utils/errors';
|
||||
import { isDebugEnabled } from '../utils/debug';
|
||||
import { sanitizeForLog } from '../utils/safeLog';
|
||||
|
||||
const NODE_SCOPE_MESSAGE = 'API tokens cannot manage nodes.';
|
||||
const REMOTE_META_CACHE_TTL = 3 * 60 * 1000;
|
||||
@@ -328,11 +330,11 @@ nodesRouter.post('/:id/cordon', (req: Request, res: Response) => {
|
||||
const nodeIdParam = req.params.id as string;
|
||||
if (!requirePermission(req, res, 'node:manage', 'node', nodeIdParam)) return;
|
||||
if (!requireAdmiral(req, res)) return;
|
||||
const id = parseInt(nodeIdParam, 10);
|
||||
if (!Number.isInteger(id) || id <= 0) {
|
||||
if (!/^[1-9]\d*$/.test(nodeIdParam)) {
|
||||
res.status(400).json({ error: 'Invalid node id' });
|
||||
return;
|
||||
}
|
||||
const id = parseInt(nodeIdParam, 10);
|
||||
const rawReason = (req.body && typeof req.body === 'object') ? (req.body as { reason?: unknown }).reason : undefined;
|
||||
let reason: string | null = null;
|
||||
if (rawReason !== undefined && rawReason !== null) {
|
||||
@@ -354,6 +356,7 @@ nodesRouter.post('/:id/cordon', (req: Request, res: Response) => {
|
||||
return;
|
||||
}
|
||||
const updated = DatabaseService.getInstance().setNodeCordoned(id, true, reason);
|
||||
if (isDebugEnabled()) console.log('[Federation:diag] cordoned node=%s reasonLen=%s', sanitizeForLog(id), sanitizeForLog(reason?.length ?? 0));
|
||||
res.set('cache-control', 'no-store').json(updated);
|
||||
} catch (error: unknown) {
|
||||
console.error('Failed to cordon node:', error);
|
||||
@@ -366,11 +369,11 @@ nodesRouter.post('/:id/uncordon', (req: Request, res: Response) => {
|
||||
const nodeIdParam = req.params.id as string;
|
||||
if (!requirePermission(req, res, 'node:manage', 'node', nodeIdParam)) return;
|
||||
if (!requireAdmiral(req, res)) return;
|
||||
const id = parseInt(nodeIdParam, 10);
|
||||
if (!Number.isInteger(id) || id <= 0) {
|
||||
if (!/^[1-9]\d*$/.test(nodeIdParam)) {
|
||||
res.status(400).json({ error: 'Invalid node id' });
|
||||
return;
|
||||
}
|
||||
const id = parseInt(nodeIdParam, 10);
|
||||
try {
|
||||
const existing = DatabaseService.getInstance().getNode(id);
|
||||
if (!existing) {
|
||||
|
||||
@@ -68,13 +68,16 @@ export function NodeCard({ node, onNavigate, labelMap, updateStatus, onUpdate, u
|
||||
const [cordonSubmitting, setCordonSubmitting] = useState(false);
|
||||
|
||||
const { isPaid, license } = useLicense();
|
||||
const { isAdmin } = useAuth();
|
||||
const { isAdmin, can } = useAuth();
|
||||
const { nodes: registryNodes } = useNodes();
|
||||
const isAdmiral = isPaid && license?.variant === 'admiral';
|
||||
const registryNode = registryNodes.find(n => n.id === node.id);
|
||||
const canEdit = Boolean(isAdmin && onEdit && registryNode);
|
||||
const canDelete = Boolean(isAdmin && onDelete && registryNode && !registryNode.is_default);
|
||||
const canCordon = isAdmiral;
|
||||
// Cordon is Admiral-tier AND requires node:manage, matching the backend guard
|
||||
// (requirePermission('node:manage','node',id) + requireAdmiral). Gating on tier
|
||||
// alone would surface the control to deployer/viewer/auditor users whose calls 403.
|
||||
const canCordon = isAdmiral && can('node:manage', 'node', String(node.id));
|
||||
const showMenu = canEdit || canDelete || canCordon;
|
||||
|
||||
const isOnline = node.status === 'online';
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
|
||||
const useLicenseMock = vi.fn();
|
||||
const useAuthMock = vi.fn();
|
||||
@@ -34,7 +35,7 @@ function baseProps(node: FleetNode) {
|
||||
|
||||
beforeEach(() => {
|
||||
useNodesMock.mockReturnValue({ nodes: [] });
|
||||
useAuthMock.mockReturnValue({ isAdmin: true });
|
||||
useAuthMock.mockReturnValue({ isAdmin: true, can: vi.fn(() => true) });
|
||||
useLicenseMock.mockReturnValue({ isPaid: false, license: null });
|
||||
});
|
||||
afterEach(() => vi.clearAllMocks());
|
||||
@@ -60,13 +61,43 @@ describe('NodeCard', () => {
|
||||
expect(screen.queryByRole('button', { name: 'Node actions' })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('exposes the actions menu (cordon entry point) for an admiral user', () => {
|
||||
it('exposes the actions menu (cordon entry point) for an admiral admin', () => {
|
||||
useLicenseMock.mockReturnValue({ isPaid: true, license: { variant: 'admiral' } });
|
||||
render(<NodeCard {...baseProps(onlineNode())} />);
|
||||
// The actions menu only renders when cordon (admiral-only here) is available.
|
||||
// With no edit/delete affordances wired, the menu renders iff cordon is
|
||||
// allowed: isAdmiral && can('node:manage'). The admin's can() returns true.
|
||||
expect(screen.getByRole('button', { name: 'Node actions' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('exposes the cordon control for a node-admin via the node:manage permission', async () => {
|
||||
const can = vi.fn((action: string) => action === 'node:manage');
|
||||
useAuthMock.mockReturnValue({ isAdmin: false, can });
|
||||
useLicenseMock.mockReturnValue({ isPaid: true, license: { variant: 'admiral' } });
|
||||
render(<NodeCard {...baseProps(onlineNode())} />);
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Node actions' }));
|
||||
expect(await screen.findByText('Cordon node')).toBeInTheDocument();
|
||||
expect(can).toHaveBeenCalledWith('node:manage', 'node', '2');
|
||||
});
|
||||
|
||||
it('hides the cordon control from an admiral user lacking node:manage', () => {
|
||||
useAuthMock.mockReturnValue({ isAdmin: false, can: vi.fn(() => false) });
|
||||
useLicenseMock.mockReturnValue({ isPaid: true, license: { variant: 'admiral' } });
|
||||
render(<NodeCard {...baseProps(onlineNode())} />);
|
||||
// Admiral tier alone must not surface cordon to a deployer/viewer/auditor.
|
||||
expect(screen.queryByRole('button', { name: 'Node actions' })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows Uncordon when the node is already cordoned', async () => {
|
||||
const can = vi.fn((action: string) => action === 'node:manage');
|
||||
useAuthMock.mockReturnValue({ isAdmin: false, can });
|
||||
useLicenseMock.mockReturnValue({ isPaid: true, license: { variant: 'admiral' } });
|
||||
render(<NodeCard {...baseProps({ ...onlineNode(), cordoned: true, cordoned_reason: 'patching' })} />);
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Node actions' }));
|
||||
expect(await screen.findByText('Uncordon node')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const updateAvailableStatus = {
|
||||
nodeId: 2, name: 'Edge', type: 'remote' as const, version: '1.0.0', latestVersion: '1.1.0',
|
||||
updateAvailable: true, updateStatus: null,
|
||||
|
||||
Reference in New Issue
Block a user