mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-28 11:17:07 +00:00
ba017ee665
* fix: forward scoped node-admin permission for Settings writes through remote proxy The proxy forwards only the user's global role via PROXY_ROLE_HEADER to remote nodes. Scoped role assignments live only on the hub's role_assignments table and are never transmitted, so a scoped Node Admin could not save settings on their granted remote node through the hub proxy. Add a settings-write pre-authorization gate in runGatedProxy that buffers the body, extracts required permission buckets from SETTING_WRITE_PERMISSIONS, checks them hub-side, and elevates PROXY_ROLE_HEADER to 'node-admin' when the scoped check passes. The gate is fail-closed: empty or unparseable bodies require checkNodeManage on the hub, matching the existing requireSettingsWritePermission empty-keys branch. Fixes the gate-parity gap where scoped node:manage worked locally but not through the proxy for Settings writes. * fix: remove unused UserRole import from remoteNodeProxy.ts * test(self-update): poll instead of a fixed delay in triggerUpdate assertion The 600ms sleep raced the route's 500ms post-response timer plus the persist/watch work executeClaimedCommunityUpdate does before calling triggerUpdate, leaving too little margin under CI's forked test pool. Poll with vi.waitFor instead, matching the pattern already used elsewhere in this suite.
220 lines
8.5 KiB
TypeScript
220 lines
8.5 KiB
TypeScript
/**
|
|
* Route coverage for pinned-compose self-update: targetVersion validation,
|
|
* synchronous preflight (409 before 202), and the safe public /api/meta pin
|
|
* subset (imagePinKind + updateBlocked, never composeImageRef).
|
|
*/
|
|
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest';
|
|
import request from 'supertest';
|
|
import jwt from 'jsonwebtoken';
|
|
import { buildTargetImageRef } from '../helpers/selfUpdateCompose';
|
|
import { setupTestDb, cleanupTestDb, TEST_USERNAME, TEST_JWT_SECRET } from './helpers/setupTestDb';
|
|
|
|
let tmpDir: string;
|
|
let app: import('express').Express;
|
|
let adminAuth: string;
|
|
let localNodeId: number;
|
|
let SelfUpdateService: typeof import('../services/SelfUpdateService').default;
|
|
let FleetUpdateTrackerService: typeof import('../services/FleetUpdateTrackerService').FleetUpdateTrackerService;
|
|
let DatabaseService: typeof import('../services/DatabaseService').DatabaseService;
|
|
|
|
function mockSelfUpdateAvailable(over: {
|
|
pinInfo?: { pinKind: 'semver' | 'floating' | 'digest' | 'unknown'; composeImageRef: string; filePath: string } | null;
|
|
preflight?: { ok: true } | { ok: false; reason: string };
|
|
available?: boolean;
|
|
} = {}) {
|
|
const svc = SelfUpdateService.getInstance();
|
|
vi.spyOn(svc, 'isAvailable').mockReturnValue(over.available ?? true);
|
|
vi.spyOn(svc, 'getPinInfo').mockResolvedValue(over.pinInfo ?? null);
|
|
vi.spyOn(svc, 'canSelfUpdateTarget').mockResolvedValue(over.preflight ?? { ok: true });
|
|
vi.spyOn(svc, 'triggerUpdate').mockResolvedValue(undefined);
|
|
vi.spyOn(svc, 'getLastError').mockReturnValue(null);
|
|
}
|
|
|
|
function mockCompareTargetFetch() {
|
|
vi.spyOn(globalThis, 'fetch').mockImplementation(async () =>
|
|
new Response(JSON.stringify({ tag_name: 'v0.99.0' }), {
|
|
status: 200,
|
|
headers: { 'content-type': 'application/json' },
|
|
}),
|
|
);
|
|
}
|
|
|
|
beforeAll(async () => {
|
|
tmpDir = await setupTestDb();
|
|
({ app } = await import('../index'));
|
|
SelfUpdateService = (await import('../services/SelfUpdateService')).default;
|
|
({ FleetUpdateTrackerService } = await import('../services/FleetUpdateTrackerService'));
|
|
({ DatabaseService } = await import('../services/DatabaseService'));
|
|
|
|
localNodeId = DatabaseService.getInstance().getNodes().find(n => n.type === 'local')!.id;
|
|
adminAuth = `Bearer ${jwt.sign({ username: TEST_USERNAME }, TEST_JWT_SECRET, { expiresIn: '1m' })}`;
|
|
});
|
|
|
|
afterAll(() => cleanupTestDb(tmpDir));
|
|
|
|
afterEach(() => {
|
|
vi.restoreAllMocks();
|
|
const tracker = FleetUpdateTrackerService.getInstance();
|
|
for (const [id] of tracker.entries()) tracker.delete(id);
|
|
});
|
|
|
|
describe('POST /api/system/update', () => {
|
|
it('rejects hardened updates from node-proxy credentials', async () => {
|
|
mockSelfUpdateAvailable({
|
|
pinInfo: {
|
|
pinKind: 'digest',
|
|
composeImageRef: 'ghcr.io/studio-saelix/sencho-hardened@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',
|
|
filePath: '/opt/sencho/docker-compose.yml',
|
|
},
|
|
});
|
|
const machineAuth = `Bearer ${jwt.sign({ scope: 'node_proxy' }, TEST_JWT_SECRET, { expiresIn: '1m' })}`;
|
|
|
|
const res = await request(app)
|
|
.post('/api/system/update')
|
|
.set('Authorization', machineAuth);
|
|
|
|
expect(res.status).toBe(403);
|
|
expect(res.body.code).toBe('HARDENED_REMOTE_UPDATE_UNSUPPORTED');
|
|
});
|
|
|
|
it('returns 400 for a supplied but invalid targetVersion', async () => {
|
|
mockSelfUpdateAvailable();
|
|
|
|
const res = await request(app)
|
|
.post('/api/system/update')
|
|
.set('Authorization', adminAuth)
|
|
.send({ targetVersion: 'not-a-version' });
|
|
|
|
expect(res.status).toBe(400);
|
|
expect(res.body?.error).toMatch(/invalid target version/i);
|
|
});
|
|
|
|
it('returns 409 when preflight blocks a digest or unknown pin', async () => {
|
|
mockSelfUpdateAvailable({
|
|
preflight: { ok: false, reason: 'Cannot repin digest pin.' },
|
|
});
|
|
|
|
const res = await request(app)
|
|
.post('/api/system/update')
|
|
.set('Authorization', adminAuth)
|
|
.send({ targetVersion: '0.99.0' });
|
|
|
|
expect(res.status).toBe(409);
|
|
expect(res.body?.code).toBe('update_blocked');
|
|
expect(res.body?.error).toMatch(/cannot repin/i);
|
|
});
|
|
|
|
it('returns 202 and schedules the update when preflight passes', async () => {
|
|
const triggerSpy = vi.spyOn(SelfUpdateService.getInstance(), 'triggerUpdate').mockResolvedValue(undefined);
|
|
mockSelfUpdateAvailable({ preflight: { ok: true } });
|
|
|
|
const res = await request(app)
|
|
.post('/api/system/update')
|
|
.set('Authorization', adminAuth)
|
|
.send({ targetVersion: '0.99.0' });
|
|
|
|
expect(res.status).toBe(202);
|
|
expect(res.body?.message).toMatch(/restart/i);
|
|
// The route schedules triggerUpdate 500ms after responding, so poll for it.
|
|
await vi.waitFor(() => {
|
|
expect(triggerSpy).toHaveBeenCalledWith(expect.objectContaining({
|
|
targetVersion: '0.99.0',
|
|
successMarkerFile: expect.stringMatching(/image-op-success-[\w-]+\.json$/),
|
|
successMarkerContent: expect.stringMatching(/"operationId":"[\w-]+"/),
|
|
}));
|
|
}, { timeout: 5_000 });
|
|
});
|
|
});
|
|
|
|
describe('GET /api/meta pin subset', () => {
|
|
it('does not expose a raw update error with a private image reference', async () => {
|
|
mockSelfUpdateAvailable();
|
|
vi.spyOn(SelfUpdateService.getInstance(), 'getLastError')
|
|
.mockReturnValue('pull private.registry.example/internal/sencho:1.0 failed');
|
|
|
|
const res = await request(app).get('/api/meta');
|
|
|
|
expect(res.body.updateError).toBe('update_failed');
|
|
expect(JSON.stringify(res.body)).not.toContain('private.registry.example');
|
|
});
|
|
|
|
it('exposes imagePinKind, updateBlocked, and imageChannel but never composeImageRef', async () => {
|
|
mockSelfUpdateAvailable({
|
|
pinInfo: { pinKind: 'semver', composeImageRef: 'saelix/sencho:0.93.3', filePath: '/opt/sencho/docker-compose.yml' },
|
|
});
|
|
|
|
const res = await request(app).get('/api/meta');
|
|
|
|
expect(res.status).toBe(200);
|
|
expect(res.body.imagePinKind).toBe('semver');
|
|
expect(res.body.updateBlocked).toBe(false);
|
|
expect(res.body.imageChannel).toBe('community');
|
|
expect(res.body).not.toHaveProperty('composeImageRef');
|
|
expect(res.body).not.toHaveProperty('targetImageRef');
|
|
});
|
|
|
|
it('reports updateBlocked=true for a digest pin', async () => {
|
|
mockSelfUpdateAvailable({
|
|
pinInfo: { pinKind: 'digest', composeImageRef: 'ghcr.io/studio-saelix/sencho-hardened@sha256:abc', filePath: '/opt/sencho/docker-compose.yml' },
|
|
});
|
|
|
|
const res = await request(app).get('/api/meta');
|
|
|
|
expect(res.body.imagePinKind).toBe('digest');
|
|
expect(res.body.updateBlocked).toBe(true);
|
|
expect(res.body.imageChannel).toBe('hardened');
|
|
});
|
|
});
|
|
|
|
describe('POST /api/fleet/nodes/:nodeId/update (local preflight)', () => {
|
|
it('returns 409 before 202 when the local pin cannot be repinned', async () => {
|
|
mockCompareTargetFetch();
|
|
mockSelfUpdateAvailable({
|
|
preflight: { ok: false, reason: 'Blocked by digest pin.' },
|
|
});
|
|
|
|
const res = await request(app)
|
|
.post(`/api/fleet/nodes/${localNodeId}/update`)
|
|
.set('Authorization', adminAuth)
|
|
.send({ targetVersion: '0.99.0' });
|
|
|
|
expect(res.status).toBe(409);
|
|
expect(res.body?.code).toBe('update_blocked');
|
|
});
|
|
});
|
|
|
|
describe('GET /api/fleet/update-status pin projection', () => {
|
|
it('includes compose pin fields for the local node on the authenticated route', async () => {
|
|
mockCompareTargetFetch();
|
|
mockSelfUpdateAvailable({
|
|
pinInfo: { pinKind: 'semver', composeImageRef: 'saelix/sencho:0.83.0', filePath: '/opt/sencho/docker-compose.yml' },
|
|
});
|
|
|
|
const res = await request(app)
|
|
.get('/api/fleet/update-status')
|
|
.set('Authorization', adminAuth);
|
|
|
|
expect(res.status).toBe(200);
|
|
const local = res.body.nodes.find((n: { nodeId: number }) => n.nodeId === localNodeId);
|
|
expect(local.imagePinKind).toBe('semver');
|
|
expect(local.composeImageRef).toBe('saelix/sencho:0.83.0');
|
|
expect(local.targetImageRef).toBe(buildTargetImageRef(local.composeImageRef, local.latestVersion));
|
|
expect(local.updateBlocked).toBe(false);
|
|
});
|
|
|
|
it('marks the local node updateBlocked when the pin is unknown', async () => {
|
|
mockCompareTargetFetch();
|
|
mockSelfUpdateAvailable({
|
|
pinInfo: { pinKind: 'unknown', composeImageRef: 'saelix/sencho:${TAG}', filePath: '/opt/sencho/docker-compose.yml' },
|
|
});
|
|
|
|
const res = await request(app)
|
|
.get('/api/fleet/update-status')
|
|
.set('Authorization', adminAuth);
|
|
|
|
const local = res.body.nodes.find((n: { nodeId: number }) => n.nodeId === localNodeId);
|
|
expect(local.updateBlocked).toBe(true);
|
|
expect(local.updateBlockedReason).toMatch(/cannot update automatically/i);
|
|
});
|
|
});
|