mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-09-10 01:15:55 +00:00
feat(git-sources): node-scoped polling settings endpoint
This commit is contained in:
@@ -2526,3 +2526,162 @@ describe('git-source policy compatibility', () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('git-source polling settings', () => {
|
||||
// A real viewer row, not just a signed token: the auth middleware resolves
|
||||
// users against the database, so a token for a username with no row is
|
||||
// rejected 401 before the node:manage gate ever runs.
|
||||
let pollingViewerToken: string;
|
||||
beforeAll(async () => {
|
||||
DatabaseService.getInstance().addUser({
|
||||
username: 'polling-viewer',
|
||||
password_hash: 'test',
|
||||
role: 'viewer',
|
||||
});
|
||||
pollingViewerToken = jwt.sign(
|
||||
{ username: 'polling-viewer', role: 'viewer' },
|
||||
TEST_JWT_SECRET,
|
||||
{ expiresIn: '1m' },
|
||||
);
|
||||
});
|
||||
|
||||
function seedPollApp(stackName: string, policy: 'manual' | 'review' | 'automatic'): void {
|
||||
const row = directApplicationFixture(`poll-app-${stackName}`, stackName);
|
||||
row.source_policy = policy;
|
||||
GitOpsStore.getInstance().insertApplication(row);
|
||||
}
|
||||
|
||||
function deletePollRows(stackName: string): void {
|
||||
DatabaseService.getInstance().getDb()
|
||||
.prepare('DELETE FROM gitops_applications WHERE id = ?')
|
||||
.run(`poll-app-${stackName}`);
|
||||
}
|
||||
|
||||
it('GET requires node:manage', async () => {
|
||||
const res = await request(app)
|
||||
.get('/api/git-sources/polling')
|
||||
.set('Authorization', `Bearer ${pollingViewerToken}`);
|
||||
expect(res.status).toBe(403);
|
||||
expect(res.body.code).toBe('PERMISSION_DENIED');
|
||||
});
|
||||
|
||||
it('GET returns the global interval and per-source rows', async () => {
|
||||
const stackName = 'polling-get-row';
|
||||
seedPollApp(stackName, 'review');
|
||||
DatabaseService.getInstance().updateGlobalSetting('gitops_poll_interval_mins', '7');
|
||||
try {
|
||||
const res = await request(app)
|
||||
.get('/api/git-sources/polling')
|
||||
.set('Authorization', `Bearer ${adminToken()}`);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.poll_interval_mins).toBe(7);
|
||||
const row = res.body.per_source.find((r: { stack_name: string }) => r.stack_name === stackName);
|
||||
expect(row).toBeDefined();
|
||||
expect(row.source_policy).toBe('review');
|
||||
expect(row.poll_interval_secs).toBeNull();
|
||||
expect(row.next_poll_at).toBeNull();
|
||||
} finally {
|
||||
DatabaseService.getInstance().updateGlobalSetting('gitops_poll_interval_mins', '0');
|
||||
deletePollRows(stackName);
|
||||
}
|
||||
});
|
||||
|
||||
it('PATCH rejects non-integer, negative, and oversized values with 400', async () => {
|
||||
for (const value of [-1, 0.5, 10081, 'five', true, null]) {
|
||||
const res = await request(app)
|
||||
.patch('/api/git-sources/polling')
|
||||
.set('Authorization', `Bearer ${adminToken()}`)
|
||||
.send({ poll_interval_mins: value });
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toMatch(/poll_interval_mins/i);
|
||||
}
|
||||
});
|
||||
|
||||
it('PATCH requires node:manage', async () => {
|
||||
const res = await request(app)
|
||||
.patch('/api/git-sources/polling')
|
||||
.set('Authorization', `Bearer ${pollingViewerToken}`)
|
||||
.send({ poll_interval_mins: 5 });
|
||||
expect(res.status).toBe(403);
|
||||
expect(res.body.code).toBe('PERMISSION_DENIED');
|
||||
});
|
||||
|
||||
it('PATCH stores the value and recomputes next_poll_at', async () => {
|
||||
const stackName = 'polling-patch-auto';
|
||||
seedPollApp(stackName, 'automatic');
|
||||
const before = Date.now();
|
||||
try {
|
||||
const res = await request(app)
|
||||
.patch('/api/git-sources/polling')
|
||||
.set('Authorization', `Bearer ${adminToken()}`)
|
||||
.send({ poll_interval_mins: 5 });
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.poll_interval_mins).toBe(5);
|
||||
const row = res.body.per_source.find((r: { stack_name: string }) => r.stack_name === stackName);
|
||||
// Automatic sources get a cursor at now + 300s; review sources
|
||||
// are re-armed on the same cadence, and only manual is left off
|
||||
// the unattended schedule.
|
||||
expect(row.next_poll_at).toBeGreaterThanOrEqual(before + 300_000);
|
||||
expect(row.next_poll_at).toBeLessThanOrEqual(Date.now() + 300_000);
|
||||
|
||||
const stored = GitOpsStore.getInstance().getApplication(`poll-app-${stackName}`);
|
||||
expect(stored?.next_poll_at).toBe(row.next_poll_at);
|
||||
} finally {
|
||||
DatabaseService.getInstance().updateGlobalSetting('gitops_poll_interval_mins', '0');
|
||||
deletePollRows(stackName);
|
||||
}
|
||||
});
|
||||
|
||||
it('PATCH re-arms review sources on the new cadence and skips manual', async () => {
|
||||
const reviewStack = 'polling-patch-review';
|
||||
const manualStack = 'polling-patch-manual';
|
||||
seedPollApp(reviewStack, 'review');
|
||||
seedPollApp(manualStack, 'manual');
|
||||
const staleCursor = 123456;
|
||||
for (const stackName of [reviewStack, manualStack]) {
|
||||
DatabaseService.getInstance().getDb()
|
||||
.prepare('UPDATE gitops_applications SET next_poll_at = ? WHERE id = ?')
|
||||
.run(staleCursor, `poll-app-${stackName}`);
|
||||
}
|
||||
try {
|
||||
const res = await request(app)
|
||||
.patch('/api/git-sources/polling')
|
||||
.set('Authorization', `Bearer ${adminToken()}`)
|
||||
.send({ poll_interval_mins: 5 });
|
||||
expect(res.status).toBe(200);
|
||||
const reviewRow = res.body.per_source.find((r: { stack_name: string }) => r.stack_name === reviewStack);
|
||||
const manualRow = res.body.per_source.find((r: { stack_name: string }) => r.stack_name === manualStack);
|
||||
// Review rides the unattended cadence like automatic; manual keeps
|
||||
// its stale cursor because rescheduleAll never arms it.
|
||||
expect(reviewRow.next_poll_at).toBeGreaterThan(staleCursor);
|
||||
expect(manualRow.next_poll_at).toBe(staleCursor);
|
||||
} finally {
|
||||
DatabaseService.getInstance().updateGlobalSetting('gitops_poll_interval_mins', '0');
|
||||
deletePollRows(reviewStack);
|
||||
deletePollRows(manualStack);
|
||||
}
|
||||
});
|
||||
|
||||
it('PATCH 0 leaves existing next_poll_at values alone (off means off)', async () => {
|
||||
const stackName = 'polling-patch-off';
|
||||
seedPollApp(stackName, 'automatic');
|
||||
const existing = GitOpsStore.getInstance().getApplication(`poll-app-${stackName}`)!;
|
||||
existing.next_poll_at = 123456;
|
||||
DatabaseService.getInstance().getDb()
|
||||
.prepare('UPDATE gitops_applications SET next_poll_at = ? WHERE id = ?')
|
||||
.run(123456, existing.id);
|
||||
try {
|
||||
const res = await request(app)
|
||||
.patch('/api/git-sources/polling')
|
||||
.set('Authorization', `Bearer ${adminToken()}`)
|
||||
.send({ poll_interval_mins: 0 });
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.poll_interval_mins).toBe(0);
|
||||
const row = res.body.per_source.find((r: { stack_name: string }) => r.stack_name === stackName);
|
||||
expect(row.next_poll_at).toBe(123456);
|
||||
} finally {
|
||||
DatabaseService.getInstance().updateGlobalSetting('gitops_poll_interval_mins', '0');
|
||||
deletePollRows(stackName);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -341,3 +341,51 @@ describe('remote proxy scoped node-admin settings writes', () => {
|
||||
db.deleteRoleAssignmentsByUser(viewerId);
|
||||
});
|
||||
});
|
||||
|
||||
describe('remote proxy scoped node-admin git-source polling writes', () => {
|
||||
it('forwards polling PATCH from scoped node-admin on granted node with elevated role header', async () => {
|
||||
const db = (await import('../services/DatabaseService')).DatabaseService.getInstance();
|
||||
db.addRoleAssignment({
|
||||
user_id: viewerId,
|
||||
role: 'node-admin',
|
||||
resource_type: 'node',
|
||||
resource_id: String(grantedNodeId),
|
||||
});
|
||||
|
||||
const res = await request(app)
|
||||
.patch('/api/git-sources/polling')
|
||||
.set('Authorization', `Bearer ${viewerBearer}`)
|
||||
.set('x-node-id', String(grantedNodeId))
|
||||
.send({ poll_interval_mins: 15 });
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
const hop = grantedHops.find((h) => h.url?.includes('/git-sources/polling'));
|
||||
expect(hop).toBeDefined();
|
||||
expect(hop!.method).toBe('PATCH');
|
||||
expect(hop!.roleHeader).toBe('node-admin');
|
||||
|
||||
db.deleteRoleAssignmentsByUser(viewerId);
|
||||
});
|
||||
|
||||
it('denies polling PATCH from scoped node-admin on ungranted remote node', async () => {
|
||||
const db = (await import('../services/DatabaseService')).DatabaseService.getInstance();
|
||||
db.addRoleAssignment({
|
||||
user_id: viewerId,
|
||||
role: 'node-admin',
|
||||
resource_type: 'node',
|
||||
resource_id: String(grantedNodeId),
|
||||
});
|
||||
|
||||
const res = await request(app)
|
||||
.patch('/api/git-sources/polling')
|
||||
.set('Authorization', `Bearer ${viewerBearer}`)
|
||||
.set('x-node-id', String(ungrantedNodeId))
|
||||
.send({ poll_interval_mins: 15 });
|
||||
|
||||
expect(res.status).toBe(403);
|
||||
expect(res.body.code).toBe('PERMISSION_DENIED');
|
||||
expect(ungrantedHops).toHaveLength(0);
|
||||
|
||||
db.deleteRoleAssignmentsByUser(viewerId);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -832,10 +832,17 @@ export function createRemoteProxyMiddleware(): RequestHandler {
|
||||
/** Max request body size for buffered settings writes (same as ALERT_PROXY_BODY_LIMIT). */
|
||||
const SETTINGS_PROXY_BODY_LIMIT = 100 * 1024;
|
||||
|
||||
/** True when the request is a settings write destined for a remote node (path is post-/api strip). */
|
||||
/**
|
||||
* True when the request configures a remote node's own settings and must run
|
||||
* the node-admin elevation check on the hop (path is post-/api strip).
|
||||
*/
|
||||
function isSettingsWrite(req: Request): boolean {
|
||||
if (req.method !== 'POST' && req.method !== 'PATCH') return false;
|
||||
return /^\/settings\/?$/.test(req.path);
|
||||
if (/^\/settings\/?$/.test(req.path)) return true;
|
||||
// Polling cadence is configured per instance, so the hub PATCH must reach
|
||||
// the target node's own route; classify it like a settings write so the
|
||||
// same node-admin elevation check applies before the hop.
|
||||
return /^\/git-sources\/polling\/?$/.test(req.path);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { Router, type Request, type Response } from 'express';
|
||||
import { GitSourceService, type PublicGitSource, type SourcePolicy } from '../services/GitSourceService';
|
||||
import { GitOpsStore } from '../services/gitops/store';
|
||||
import { SourceController } from '../services/gitops/SourceController';
|
||||
import type { GitOpsRevisionProjection } from '../services/gitops/types';
|
||||
import { GitProjectManifestService } from '../services/GitProjectManifestService';
|
||||
import { FileSystemService } from '../services/FileSystemService';
|
||||
@@ -244,6 +246,86 @@ gitSourcesRouter.post('/browse', async (req: Request, res: Response): Promise<vo
|
||||
await handleBrowse(req, res, null, null, null, null);
|
||||
});
|
||||
|
||||
/**
|
||||
* Strict parser for the global poll interval (minutes, 0..10080). Accepts
|
||||
* integer numbers or digit strings only; rejects null, booleans, decimals,
|
||||
* whitespace, and out-of-range values without coercion, so a malformed value
|
||||
* cannot silently disable polling the operator meant to arm.
|
||||
*/
|
||||
export function parsePollIntervalMins(raw: unknown): number | null {
|
||||
if (typeof raw === 'number') {
|
||||
if (!Number.isInteger(raw) || raw < 0 || raw > 10080) return null;
|
||||
return raw;
|
||||
}
|
||||
if (typeof raw === 'string') {
|
||||
if (!/^\d{1,5}$/.test(raw)) return null;
|
||||
const value = Number(raw);
|
||||
return value <= 10080 ? value : null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function requireNodeManage(req: Request, res: Response): boolean {
|
||||
if (typeof req.nodeId === 'number') {
|
||||
return requirePermission(req, res, 'node:manage', 'node', String(req.nodeId));
|
||||
}
|
||||
return requirePermission(req, res, 'node:manage');
|
||||
}
|
||||
|
||||
function pollingSettingsPayload(): {
|
||||
poll_interval_mins: number;
|
||||
per_source: Array<{
|
||||
stack_name: string;
|
||||
poll_interval_secs: number | null;
|
||||
next_poll_at: number | null;
|
||||
source_policy: string;
|
||||
}>;
|
||||
} {
|
||||
return {
|
||||
poll_interval_mins: DatabaseService.getInstance().getGitOpsPollIntervalMins(),
|
||||
per_source: GitOpsStore.getInstance().listActiveDirectApplications().map((app) => ({
|
||||
stack_name: app.stack_name ?? '',
|
||||
poll_interval_secs: app.poll_interval_secs,
|
||||
next_poll_at: app.next_poll_at,
|
||||
source_policy: app.source_policy,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Node-scoped polling configuration. The GET projects the global interval and
|
||||
* each live source's own cadence; the PATCH stores the interval and
|
||||
* reschedules every non-manual source, both gated by node:manage because the
|
||||
* cadence governs unattended fetches against the node.
|
||||
*/
|
||||
gitSourcesRouter.get('/polling', async (req: Request, res: Response): Promise<void> => {
|
||||
if (!requireNodeManage(req, res)) return;
|
||||
try {
|
||||
res.json(pollingSettingsPayload());
|
||||
} catch (error) {
|
||||
console.error('[GitSources] polling settings read failed:', error instanceof Error ? error.message : String(error));
|
||||
res.status(500).json({ error: 'Could not read polling settings.' });
|
||||
}
|
||||
});
|
||||
|
||||
gitSourcesRouter.patch('/polling', async (req: Request, res: Response): Promise<void> => {
|
||||
if (!requireNodeManage(req, res)) return;
|
||||
const value = parsePollIntervalMins((req.body ?? {}).poll_interval_mins);
|
||||
if (value === null) {
|
||||
res.status(400).json({ error: 'poll_interval_mins must be an integer between 0 and 10080' });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
DatabaseService.getInstance().updateGlobalSetting('gitops_poll_interval_mins', String(value));
|
||||
SourceController.getInstance().rescheduleAll('system:git-source');
|
||||
SourceController.getInstance().restartPolling();
|
||||
res.json(pollingSettingsPayload());
|
||||
} catch (error) {
|
||||
console.error('[GitSources] polling settings write failed:', error instanceof Error ? error.message : String(error));
|
||||
res.status(500).json({ error: 'Could not save polling settings.' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Router for per-stack git-source endpoints. Mount at `/api/stacks` so the
|
||||
* `/:stackName/git-source*` paths work alongside other stack-scoped routes
|
||||
|
||||
Reference in New Issue
Block a user