mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-21 15:46:43 +00:00
d9b7911f12
* fix: distinguish failed image-update checks from "up to date" The image-update detector collapsed every failure (registry unreachable, missing auth, rate limit, unresolved local digest) into hasUpdate:false and dropped the captured reason, so a failed check was indistinguishable from a current image and never raised a notification, even while a manual stack update still pulled a newer image. Detection now records a tri-state per stack (ok / partial / failed) with the failure reason, exposed via a new GET /api/image-updates/detail (the boolean GET / is unchanged so fleet aggregation is unaffected). A fully-failed check preserves the last known has_update, so a transient outage neither erases a real update nor flaps the notification state. The sidebar shows a muted "couldn't check" indicator with the reason on hover, and the Update board lists stacks whose check failed in a "could not be checked" advisory. Detector hardening: the manifest digest lookup issues HEAD first (falling back to GET) so it no longer draws down Docker Hub's anonymous pull-rate budget, and local RepoDigest matching is normalized so official library/* images resolve their digest instead of falling through to a silent "no update". * fix: preserve confirmed updates through partial checks; tighten failure surfacing Address review findings on the tri-state image-update detection: - A partial check (some images errored) no longer erases a previously confirmed update; only a fully-ok check can lower has_update, so a single image's registry blip cannot drop the stack's update and re-fire the notification on recovery. Adds a regression test. - The image-level catch stores getErrorMessage(e) rather than raw String(e), since that value surfaces verbatim in the sidebar tooltip and readiness advisory. - useImageUpdates and the readiness detail fetch now log unexpected non-ok responses instead of silently leaving stale state. - Remove an unused checkFailedCount derivation (the row indicator is driven by the checkStatus prop). - Reword the recordStackCheckFailure docstring and the HEAD-first comment.
442 lines
19 KiB
TypeScript
442 lines
19 KiB
TypeScript
/**
|
|
* Integration tests for /api/image-updates and /api/auto-update/execute.
|
|
* Locks down auth, admin gating, rate limiting, and input validation
|
|
* before extraction.
|
|
*/
|
|
import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest';
|
|
import request from 'supertest';
|
|
import bcrypt from 'bcrypt';
|
|
import { setupTestDb, cleanupTestDb, loginAsTestAdmin } from './helpers/setupTestDb';
|
|
|
|
let tmpDir: string;
|
|
let app: import('express').Express;
|
|
let DatabaseService: typeof import('../services/DatabaseService').DatabaseService;
|
|
let adminCookie: string;
|
|
let viewerCookie: string;
|
|
|
|
beforeAll(async () => {
|
|
tmpDir = await setupTestDb();
|
|
({ DatabaseService } = await import('../services/DatabaseService'));
|
|
|
|
const { LicenseService } = await import('../services/LicenseService');
|
|
vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValue('paid');
|
|
|
|
({ app } = await import('../index'));
|
|
adminCookie = await loginAsTestAdmin(app);
|
|
|
|
const viewerHash = await bcrypt.hash('viewerpass', 1);
|
|
DatabaseService.getInstance().addUser({ username: 'iu-viewer', password_hash: viewerHash, role: 'viewer' });
|
|
const viewerRes = await request(app).post('/api/auth/login').send({ username: 'iu-viewer', password: 'viewerpass' });
|
|
const cookies = viewerRes.headers['set-cookie'] as string | string[];
|
|
viewerCookie = Array.isArray(cookies) ? cookies[0] : cookies;
|
|
});
|
|
|
|
afterAll(() => cleanupTestDb(tmpDir));
|
|
|
|
describe('GET /api/image-updates', () => {
|
|
it('rejects unauthenticated requests with 401', async () => {
|
|
const res = await request(app).get('/api/image-updates');
|
|
expect(res.status).toBe(401);
|
|
});
|
|
|
|
it('returns the current stack update status map for authenticated users', async () => {
|
|
const res = await request(app).get('/api/image-updates').set('Cookie', adminCookie);
|
|
expect(res.status).toBe(200);
|
|
expect(res.body).toBeInstanceOf(Object);
|
|
});
|
|
});
|
|
|
|
describe('GET /api/image-updates/detail', () => {
|
|
it('rejects unauthenticated requests with 401', async () => {
|
|
const res = await request(app).get('/api/image-updates/detail');
|
|
expect(res.status).toBe(401);
|
|
});
|
|
|
|
it('returns the rich per-stack detail shape for authenticated users', async () => {
|
|
const nodeId = DatabaseService.getInstance().getDefaultNode()!.id!;
|
|
DatabaseService.getInstance().upsertStackUpdateStatus(nodeId, 'detail-web', true, 1000, 'partial', 'Registry unreachable for ghcr.io/acme/api:v1');
|
|
const res = await request(app).get('/api/image-updates/detail').set('Cookie', adminCookie);
|
|
expect(res.status).toBe(200);
|
|
expect(res.body['detail-web']).toEqual({
|
|
hasUpdate: true,
|
|
checkStatus: 'partial',
|
|
lastError: 'Registry unreachable for ghcr.io/acme/api:v1',
|
|
checkedAt: 1000,
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('POST /api/image-updates/refresh', () => {
|
|
it('rejects unauthenticated requests with 401', async () => {
|
|
const res = await request(app).post('/api/image-updates/refresh');
|
|
expect(res.status).toBe(401);
|
|
});
|
|
|
|
it('rejects non-admin users with 403', async () => {
|
|
const res = await request(app).post('/api/image-updates/refresh').set('Cookie', viewerCookie);
|
|
expect(res.status).toBe(403);
|
|
});
|
|
|
|
it('returns 200 or 429 when admin hits it (cooldown-aware)', async () => {
|
|
// Running first: expect 200 unless the service is already mid-refresh
|
|
// or a previous manual trigger set the cooldown. Either way, only 200
|
|
// or 429 are acceptable; 4xx/5xx would indicate a regression.
|
|
const res = await request(app).post('/api/image-updates/refresh').set('Cookie', adminCookie);
|
|
expect([200, 429]).toContain(res.status);
|
|
});
|
|
});
|
|
|
|
describe('GET /api/image-updates/status', () => {
|
|
it('rejects unauthenticated requests with 401', async () => {
|
|
const res = await request(app).get('/api/image-updates/status');
|
|
expect(res.status).toBe(401);
|
|
});
|
|
|
|
it('returns the enriched status payload', async () => {
|
|
const res = await request(app).get('/api/image-updates/status').set('Cookie', adminCookie);
|
|
expect(res.status).toBe(200);
|
|
expect(typeof res.body.checking).toBe('boolean');
|
|
// start() never runs in route tests, so the interval reflects the seeded
|
|
// default (120) via the field initializer rather than NaN.
|
|
expect(res.body.intervalMinutes).toBe(120);
|
|
expect(res.body.manualCooldownMinutes).toBe(2);
|
|
expect(typeof res.body.manualCooldownRemainingMs).toBe('number');
|
|
expect('lastCheckedAt' in res.body).toBe(true);
|
|
expect('nextCheckAt' in res.body).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe('PUT /api/image-updates/interval', () => {
|
|
it('rejects unauthenticated requests with 401', async () => {
|
|
const res = await request(app).put('/api/image-updates/interval').send({ minutes: 30 });
|
|
expect(res.status).toBe(401);
|
|
});
|
|
|
|
it('rejects non-admin users with 403', async () => {
|
|
const res = await request(app).put('/api/image-updates/interval').set('Cookie', viewerCookie).send({ minutes: 30 });
|
|
expect(res.status).toBe(403);
|
|
});
|
|
|
|
it('rejects an interval below the minimum', async () => {
|
|
const res = await request(app).put('/api/image-updates/interval').set('Cookie', adminCookie).send({ minutes: 5 });
|
|
expect(res.status).toBe(400);
|
|
});
|
|
|
|
it('rejects an interval above the maximum', async () => {
|
|
const res = await request(app).put('/api/image-updates/interval').set('Cookie', adminCookie).send({ minutes: 5000 });
|
|
expect(res.status).toBe(400);
|
|
});
|
|
|
|
it('rejects a non-integer interval', async () => {
|
|
const res = await request(app).put('/api/image-updates/interval').set('Cookie', adminCookie).send({ minutes: 'soon' });
|
|
expect(res.status).toBe(400);
|
|
});
|
|
|
|
it('persists a valid interval and returns the enriched status', async () => {
|
|
const res = await request(app).put('/api/image-updates/interval').set('Cookie', adminCookie).send({ minutes: 30 });
|
|
expect(res.status).toBe(200);
|
|
expect(res.body.intervalMinutes).toBe(30);
|
|
// The value is persisted to global_settings...
|
|
expect(DatabaseService.getInstance().getGlobalSettings().image_update_check_interval_minutes).toBe('30');
|
|
// ...and a follow-up status read reflects the rescheduled cadence.
|
|
const statusRes = await request(app).get('/api/image-updates/status').set('Cookie', adminCookie);
|
|
expect(statusRes.body.intervalMinutes).toBe(30);
|
|
});
|
|
|
|
// ── Cron mode ──────────────────────────────────────────────────────────
|
|
|
|
it('persists a valid cron expression and returns the enriched status', async () => {
|
|
const res = await request(app).put('/api/image-updates/interval')
|
|
.set('Cookie', adminCookie)
|
|
.send({ minutes: 120, mode: 'cron', cron: '0 3 * * 1' });
|
|
expect(res.status).toBe(200);
|
|
expect(res.body.mode).toBe('cron');
|
|
expect(res.body.cronExpression).toBe('0 3 * * 1');
|
|
const settings = DatabaseService.getInstance().getGlobalSettings();
|
|
expect(settings.image_update_check_mode).toBe('cron');
|
|
expect(settings.image_update_check_cron).toBe('0 3 * * 1');
|
|
});
|
|
|
|
it('accepts a cron nickname like @daily', async () => {
|
|
const res = await request(app).put('/api/image-updates/interval')
|
|
.set('Cookie', adminCookie)
|
|
.send({ minutes: 120, mode: 'cron', cron: '@daily' });
|
|
expect(res.status).toBe(200);
|
|
expect(res.body.mode).toBe('cron');
|
|
expect(res.body.cronExpression).toBe('@daily');
|
|
});
|
|
|
|
it('rejects a 6-field cron expression', async () => {
|
|
const res = await request(app).put('/api/image-updates/interval')
|
|
.set('Cookie', adminCookie)
|
|
.send({ minutes: 120, mode: 'cron', cron: '0 0 3 * * 1' });
|
|
expect(res.status).toBe(400);
|
|
expect(res.body.error).toMatch(/5 fields/);
|
|
});
|
|
|
|
it('rejects a blank cron expression when mode is cron', async () => {
|
|
const res = await request(app).put('/api/image-updates/interval')
|
|
.set('Cookie', adminCookie)
|
|
.send({ minutes: 120, mode: 'cron', cron: ' ' });
|
|
expect(res.status).toBe(400);
|
|
});
|
|
|
|
it('rejects an invalid cron expression (backend-authoritative)', async () => {
|
|
const res = await request(app).put('/api/image-updates/interval')
|
|
.set('Cookie', adminCookie)
|
|
.send({ minutes: 120, mode: 'cron', cron: '0 0 31 2 *' });
|
|
expect(res.status).toBe(400);
|
|
expect(res.body.error).toMatch(/Invalid cron/);
|
|
});
|
|
|
|
it('rejects cron mode without a cron field', async () => {
|
|
const res = await request(app).put('/api/image-updates/interval')
|
|
.set('Cookie', adminCookie)
|
|
.send({ minutes: 120, mode: 'cron' });
|
|
expect(res.status).toBe(400);
|
|
expect(res.body.error).toMatch(/Cron expression/);
|
|
});
|
|
|
|
it('clears cron when switching back to interval mode', async () => {
|
|
// First set cron mode.
|
|
await request(app).put('/api/image-updates/interval')
|
|
.set('Cookie', adminCookie)
|
|
.send({ minutes: 120, mode: 'cron', cron: '0 3 * * 1' });
|
|
// Then switch to interval.
|
|
const res = await request(app).put('/api/image-updates/interval')
|
|
.set('Cookie', adminCookie)
|
|
.send({ minutes: 60, mode: 'interval' });
|
|
expect(res.status).toBe(200);
|
|
expect(res.body.mode).toBe('interval');
|
|
expect(res.body.cronExpression).toBeNull();
|
|
const settings = DatabaseService.getInstance().getGlobalSettings();
|
|
expect(settings.image_update_check_mode).toBe('interval');
|
|
expect(settings.image_update_check_cron).toBe('');
|
|
});
|
|
|
|
it('old-client { minutes } only does not change mode (backward compat)', async () => {
|
|
// First set cron mode.
|
|
await request(app).put('/api/image-updates/interval')
|
|
.set('Cookie', adminCookie)
|
|
.send({ minutes: 120, mode: 'cron', cron: '0 3 * * 1' });
|
|
// Then send old-client payload.
|
|
const res = await request(app).put('/api/image-updates/interval')
|
|
.set('Cookie', adminCookie)
|
|
.send({ minutes: 30 });
|
|
expect(res.status).toBe(200);
|
|
// Mode and cron are unchanged.
|
|
expect(res.body.mode).toBe('cron');
|
|
expect(res.body.cronExpression).toBe('0 3 * * 1');
|
|
// Interval was updated (the fallback value).
|
|
expect(res.body.intervalMinutes).toBe(30);
|
|
});
|
|
});
|
|
|
|
describe('GET /api/image-updates/fleet', () => {
|
|
it('rejects unauthenticated requests with 401', async () => {
|
|
const res = await request(app).get('/api/image-updates/fleet');
|
|
expect(res.status).toBe(401);
|
|
});
|
|
|
|
it('rejects non-admin users with 403', async () => {
|
|
// The cross-node aggregation is part of the admin-only readiness surface;
|
|
// the single-node GET / endpoint stays open for the sidebar update dot.
|
|
const res = await request(app).get('/api/image-updates/fleet').set('Cookie', viewerCookie);
|
|
expect(res.status).toBe(403);
|
|
});
|
|
|
|
it('returns the fleet-wide aggregation map', async () => {
|
|
const res = await request(app).get('/api/image-updates/fleet').set('Cookie', adminCookie);
|
|
expect(res.status).toBe(200);
|
|
expect(res.body).toBeInstanceOf(Object);
|
|
});
|
|
});
|
|
|
|
describe('POST /api/image-updates/fleet/refresh', () => {
|
|
it('rejects unauthenticated requests with 401', async () => {
|
|
const res = await request(app).post('/api/image-updates/fleet/refresh');
|
|
expect(res.status).toBe(401);
|
|
});
|
|
|
|
it('rejects non-admin users with 403', async () => {
|
|
const res = await request(app).post('/api/image-updates/fleet/refresh').set('Cookie', viewerCookie);
|
|
expect(res.status).toBe(403);
|
|
});
|
|
|
|
it('returns triggered/rateLimited/failed arrays for admin caller', async () => {
|
|
const res = await request(app).post('/api/image-updates/fleet/refresh').set('Cookie', adminCookie);
|
|
expect(res.status).toBe(200);
|
|
expect(Array.isArray(res.body.triggered)).toBe(true);
|
|
expect(Array.isArray(res.body.rateLimited)).toBe(true);
|
|
expect(Array.isArray(res.body.failed)).toBe(true);
|
|
// The single local node should land in either triggered (first hit) or
|
|
// rateLimited (cooldown from a prior /refresh in this suite).
|
|
const localNodeBuckets = res.body.triggered.length + res.body.rateLimited.length;
|
|
expect(localNodeBuckets).toBeGreaterThanOrEqual(1);
|
|
});
|
|
|
|
it('invalidates the fleet aggregation cache', async () => {
|
|
const { CacheService } = await import('../services/CacheService');
|
|
// Prime the cache by hitting the GET endpoint, then refresh, then
|
|
// confirm the cache key was wiped.
|
|
await request(app).get('/api/image-updates/fleet').set('Cookie', adminCookie);
|
|
expect(CacheService.getInstance().get('fleet-updates')).toBeDefined();
|
|
await request(app).post('/api/image-updates/fleet/refresh').set('Cookie', adminCookie);
|
|
expect(CacheService.getInstance().get('fleet-updates')).toBeUndefined();
|
|
});
|
|
|
|
it('still serves a community-licensed admin (no paid gate)', async () => {
|
|
const { LicenseService } = await import('../services/LicenseService');
|
|
const tierSpy = vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValue('community');
|
|
try {
|
|
const res = await request(app).post('/api/image-updates/fleet/refresh').set('Cookie', adminCookie);
|
|
expect(res.status).toBe(200);
|
|
expect(Array.isArray(res.body.triggered)).toBe(true);
|
|
} finally {
|
|
tierSpy.mockRestore();
|
|
vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValue('paid');
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('POST /api/auto-update/execute', () => {
|
|
it('rejects unauthenticated requests with 401', async () => {
|
|
const res = await request(app).post('/api/auto-update/execute').send({ target: '*' });
|
|
expect(res.status).toBe(401);
|
|
});
|
|
|
|
it('rejects non-admin users with 403', async () => {
|
|
const res = await request(app)
|
|
.post('/api/auto-update/execute')
|
|
.set('Cookie', viewerCookie)
|
|
.send({ target: '*' });
|
|
expect(res.status).toBe(403);
|
|
});
|
|
|
|
it('serves a community-licensed admin (no paid gate)', async () => {
|
|
// Auto-update execution is free; an admin on a Community license drives it
|
|
// directly through the API. With no stacks on the fresh instance the handler
|
|
// returns the "no stacks found" summary rather than a 403.
|
|
const { LicenseService } = await import('../services/LicenseService');
|
|
const tierSpy = vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValue('community');
|
|
try {
|
|
const res = await request(app)
|
|
.post('/api/auto-update/execute')
|
|
.set('Cookie', adminCookie)
|
|
.send({ target: '*' });
|
|
expect(res.status).toBe(200);
|
|
expect(typeof res.body.result).toBe('string');
|
|
} finally {
|
|
tierSpy.mockRestore();
|
|
vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValue('paid');
|
|
}
|
|
});
|
|
|
|
it('rejects missing target with 400', async () => {
|
|
const res = await request(app)
|
|
.post('/api/auto-update/execute')
|
|
.set('Cookie', adminCookie)
|
|
.send({});
|
|
expect(res.status).toBe(400);
|
|
expect(res.body.error).toMatch(/Missing "target"/);
|
|
});
|
|
|
|
it('rejects invalid stack name with 400', async () => {
|
|
const res = await request(app)
|
|
.post('/api/auto-update/execute')
|
|
.set('Cookie', adminCookie)
|
|
.send({ target: '../etc/passwd' });
|
|
expect(res.status).toBe(400);
|
|
expect(res.body.error).toMatch(/Invalid stack name/);
|
|
});
|
|
|
|
it('returns a summary string when no stacks exist (target="*")', async () => {
|
|
// On a fresh test instance there are no stacks on disk, so the handler
|
|
// short-circuits with the "no stacks found" branch.
|
|
const res = await request(app)
|
|
.post('/api/auto-update/execute')
|
|
.set('Cookie', adminCookie)
|
|
.send({ target: '*' });
|
|
expect(res.status).toBe(200);
|
|
expect(typeof res.body.result).toBe('string');
|
|
});
|
|
|
|
it('reports a reason-aware block message when the policy gate blocks auto-update', async () => {
|
|
// The Codex QA flagged this surface: a KEV- or fixable-driven block must
|
|
// name the matched input, never "exceed <max_severity>" (a ceiling the
|
|
// policy did not enforce). Force the gate to block on KEV and assert the
|
|
// per-stack result string names the reason and skips the update.
|
|
const DockerController = (await import('../services/DockerController')).default;
|
|
const { ImageUpdateService } = await import('../services/ImageUpdateService');
|
|
const { ComposeService } = await import('../services/ComposeService');
|
|
const PolicyEnforcement = await import('../services/PolicyEnforcement');
|
|
|
|
const containersSpy = vi.spyOn(DockerController.prototype, 'getContainersByStack')
|
|
.mockResolvedValue([{ Id: 'c1', Image: 'nginx:latest' }] as never);
|
|
const checkSpy = vi.spyOn(ImageUpdateService.getInstance(), 'checkImage')
|
|
.mockResolvedValue({ hasUpdate: true } as never);
|
|
const updateSpy = vi.spyOn(ComposeService.prototype, 'updateStack').mockResolvedValue();
|
|
const gateSpy = vi.spyOn(PolicyEnforcement, 'enforcePolicyPreDeploy').mockResolvedValue({
|
|
ok: false,
|
|
bypassed: false,
|
|
policy: { id: 1, name: 'kev-gate', max_severity: 'HIGH' },
|
|
violations: [{
|
|
imageRef: 'nginx:latest', severity: 'MEDIUM',
|
|
criticalCount: 0, highCount: 0, kevCount: 1, fixableCount: 0,
|
|
scanId: 9, reasons: ['kev'],
|
|
}],
|
|
} as never);
|
|
try {
|
|
const res = await request(app)
|
|
.post('/api/auto-update/execute')
|
|
.set('Cookie', adminCookie)
|
|
.send({ target: 'auto-upd-blocked' });
|
|
expect(res.status).toBe(200);
|
|
expect(res.body.result).toContain('blocked auto-update');
|
|
expect(res.body.result).toContain('matched known-exploited CVE (KEV)');
|
|
expect(res.body.result).not.toContain('exceed');
|
|
expect(res.body.result).not.toContain('HIGH');
|
|
// Blocked stacks are skipped, not updated.
|
|
expect(updateSpy).not.toHaveBeenCalled();
|
|
} finally {
|
|
containersSpy.mockRestore();
|
|
checkSpy.mockRestore();
|
|
updateSpy.mockRestore();
|
|
gateSpy.mockRestore();
|
|
}
|
|
});
|
|
|
|
it('begins an update health gate after an auto-update applies', async () => {
|
|
// Target a single stack; the route works off the running containers, so
|
|
// stub the container probe, the update check, and the compose update, then
|
|
// assert the gate begins for the applied stack.
|
|
const { TEST_USERNAME } = await import('./helpers/setupTestDb');
|
|
const DockerController = (await import('../services/DockerController')).default;
|
|
const { ImageUpdateService } = await import('../services/ImageUpdateService');
|
|
const { ComposeService } = await import('../services/ComposeService');
|
|
const { HealthGateService } = await import('../services/HealthGateService');
|
|
const nodeId = DatabaseService.getInstance().getDefaultNode()!.id!;
|
|
|
|
const containersSpy = vi.spyOn(DockerController.prototype, 'getContainersByStack')
|
|
.mockResolvedValue([{ Id: 'c1', Image: 'nginx:latest' }] as never);
|
|
const checkSpy = vi.spyOn(ImageUpdateService.getInstance(), 'checkImage')
|
|
.mockResolvedValue({ hasUpdate: true } as never);
|
|
const updateSpy = vi.spyOn(ComposeService.prototype, 'updateStack').mockResolvedValue();
|
|
const beginSpy = vi.spyOn(HealthGateService.getInstance(), 'begin').mockReturnValue('gate-au');
|
|
try {
|
|
const res = await request(app)
|
|
.post('/api/auto-update/execute')
|
|
.set('Cookie', adminCookie)
|
|
.send({ target: 'auto-upd-gate' });
|
|
expect(res.status).toBe(200);
|
|
expect(updateSpy).toHaveBeenCalledWith('auto-upd-gate', undefined, true);
|
|
expect(beginSpy).toHaveBeenCalledWith(nodeId, 'auto-upd-gate', 'update', `auto-update:${TEST_USERNAME}`);
|
|
} finally {
|
|
containersSpy.mockRestore();
|
|
checkSpy.mockRestore();
|
|
updateSpy.mockRestore();
|
|
beginSpy.mockRestore();
|
|
}
|
|
});
|
|
});
|