import { describe, it, expect } from 'vitest'; import { classifyImageRemediation, buildUpdateServiceIndex, } from '../services/securityImageRemediation'; import type { StackServiceStatus, StackUpdateDetail } from '../services/DatabaseService'; const HOUR = 60 * 60 * 1000; const NOW = 1_700_000_000_000; const FRESH_WINDOW = 4 * HOUR; function service(partial: Partial & Pick): StackServiceStatus { return { image: 'nginx:1.25', hasUpdate: false, checkStatus: 'ok', lastError: null, ...partial, }; } function detail( services: StackServiceStatus[], opts: { checkedAt?: number; hasUpdate?: boolean; checkStatus?: 'ok' | 'partial' | 'failed' } = {}, ): StackUpdateDetail { return { hasUpdate: opts.hasUpdate ?? services.some((s) => s.hasUpdate), checkStatus: opts.checkStatus ?? 'ok', lastError: null, checkedAt: opts.checkedAt ?? NOW - HOUR, services, }; } describe('buildUpdateServiceIndex', () => { it('indexes declared image and runtimeImages through normalizeImageRef', () => { const index = buildUpdateServiceIndex({ web: detail([ service({ service: 'app', image: 'docker.io/library/nginx', runtimeImages: ['nginx:1.25'], }), ]), }); expect(index.has('nginx:latest')).toBe(true); expect(index.has('nginx:1.25')).toBe(true); }); }); describe('classifyImageRemediation', () => { it('counts confirmed updates only when hasUpdate and checkStatus are ok', () => { const facts = classifyImageRemediation({ findings: [{ image_ref: 'nginx:1.25', count: 3 }], details: { web: detail([service({ service: 'app', image: 'nginx:1.25', hasUpdate: true, checkStatus: 'ok' })]), }, checksEnabled: true, freshnessWindowMs: FRESH_WINDOW, now: NOW, }); expect(facts).toEqual({ fixableWithImageUpdate: 3, fixableWaitingUpstream: 0, fixableUpdateUnknown: 0, updateChecksDisabled: false, imageRefsUpdateAvailable: ['nginx:1.25'], imageRefsWaitingUpstream: [], imageRefsUpdateUnknown: [], }); }); it('treats sticky partial hasUpdate as uncertain, never update_available', () => { const facts = classifyImageRemediation({ findings: [{ image_ref: 'nginx:1.25', count: 2 }], details: { web: detail([service({ service: 'app', image: 'nginx:1.25', hasUpdate: true, checkStatus: 'partial' })]), }, checksEnabled: true, freshnessWindowMs: FRESH_WINDOW, now: NOW, }); expect(facts.fixableWithImageUpdate).toBe(0); expect(facts.fixableUpdateUnknown).toBe(2); expect(facts.fixableWaitingUpstream).toBe(0); }); it('treats not_checkable as uncertain, never waiting_upstream', () => { const facts = classifyImageRemediation({ findings: [{ image_ref: 'myapp:local', count: 1 }], details: { web: detail([service({ service: 'app', image: 'myapp:local', hasUpdate: false, checkStatus: 'not_checkable', })]), }, checksEnabled: true, freshnessWindowMs: FRESH_WINDOW, now: NOW, }); expect(facts.fixableWaitingUpstream).toBe(0); expect(facts.fixableUpdateUnknown).toBe(1); }); it('authoritative negative becomes waiting_upstream when fresh', () => { const facts = classifyImageRemediation({ findings: [{ image_ref: 'nginx:1.25', count: 4 }], details: { web: detail([service({ service: 'app', image: 'nginx:1.25', hasUpdate: false, checkStatus: 'ok' })]), }, checksEnabled: true, freshnessWindowMs: FRESH_WINDOW, now: NOW, }); expect(facts.fixableWaitingUpstream).toBe(4); expect(facts.fixableWithImageUpdate).toBe(0); expect(facts.fixableUpdateUnknown).toBe(0); }); it('stale stack-level checkedAt yields uncertain, not waiting (R4)', () => { const facts = classifyImageRemediation({ findings: [{ image_ref: 'nginx:1.25', count: 1 }], details: { web: detail( [service({ service: 'app', image: 'nginx:1.25', hasUpdate: false, checkStatus: 'ok' })], { checkedAt: NOW - (FRESH_WINDOW + 1) }, ), }, checksEnabled: true, freshnessWindowMs: FRESH_WINDOW, now: NOW, }); expect(facts.fixableWaitingUpstream).toBe(0); expect(facts.fixableUpdateUnknown).toBe(1); }); it('digest-pinned finding does not match tag-declared service (uncertain)', () => { const facts = classifyImageRemediation({ findings: [{ image_ref: 'nginx@sha256:abc', count: 1 }], details: { web: detail([service({ service: 'app', image: 'nginx:1.25', hasUpdate: false, checkStatus: 'ok' })]), }, checksEnabled: true, freshnessWindowMs: FRESH_WINDOW, now: NOW, }); expect(facts.fixableWaitingUpstream).toBe(0); expect(facts.fixableUpdateUnknown).toBe(1); }); it('disabled checks put all package-fix findings in uncertain', () => { const facts = classifyImageRemediation({ findings: [{ image_ref: 'nginx:1.25', count: 5 }], details: { web: detail([service({ service: 'app', image: 'nginx:1.25', hasUpdate: true, checkStatus: 'ok' })]), }, checksEnabled: false, freshnessWindowMs: FRESH_WINDOW, now: NOW, }); expect(facts).toEqual({ fixableWithImageUpdate: 0, fixableWaitingUpstream: 0, fixableUpdateUnknown: 5, updateChecksDisabled: true, imageRefsUpdateAvailable: [], imageRefsWaitingUpstream: [], imageRefsUpdateUnknown: ['nginx:1.25'], }); }); it('missing stack membership is uncertain', () => { const facts = classifyImageRemediation({ findings: [{ image_ref: 'orphan:1', count: 2 }], details: {}, checksEnabled: true, freshnessWindowMs: FRESH_WINDOW, now: NOW, }); expect(facts.fixableUpdateUnknown).toBe(2); }); it('matches via runtimeImages when declared image differs', () => { const facts = classifyImageRemediation({ findings: [{ image_ref: 'nginx:1.25.3', count: 1 }], details: { web: detail([service({ service: 'app', image: 'nginx:1.25', runtimeImages: ['nginx:1.25.3'], hasUpdate: true, checkStatus: 'ok', })]), }, checksEnabled: true, freshnessWindowMs: FRESH_WINDOW, now: NOW, }); expect(facts.fixableWithImageUpdate).toBe(1); }); it('cron-sized freshness window still allows authoritative negatives', () => { // Daily cron → 2×24h = 48h window (clamped max). A check from 12h ago is fresh. const cronWindow = 48 * HOUR; const facts = classifyImageRemediation({ findings: [{ image_ref: 'nginx:1.25', count: 1 }], details: { web: detail( [service({ service: 'app', image: 'nginx:1.25', hasUpdate: false, checkStatus: 'ok' })], { checkedAt: NOW - 12 * HOUR }, ), }, checksEnabled: true, freshnessWindowMs: cronWindow, now: NOW, }); expect(facts.fixableWaitingUpstream).toBe(1); }); it('confirmed update on one stack wins over sibling partial sticky hasUpdate', () => { const facts = classifyImageRemediation({ findings: [{ image_ref: 'nginx:1.25', count: 1 }], details: { web: detail([service({ service: 'app', image: 'nginx:1.25', hasUpdate: true, checkStatus: 'ok' })]), api: detail([service({ service: 'app', image: 'nginx:1.25', hasUpdate: true, checkStatus: 'partial' })]), }, checksEnabled: true, freshnessWindowMs: FRESH_WINDOW, now: NOW, }); expect(facts.fixableWithImageUpdate).toBe(1); expect(facts.fixableUpdateUnknown).toBe(0); }); it('partial-only sticky hasUpdate stays uncertain', () => { const facts = classifyImageRemediation({ findings: [{ image_ref: 'nginx:1.25', count: 1 }], details: { api: detail([service({ service: 'app', image: 'nginx:1.25', hasUpdate: true, checkStatus: 'partial' })]), }, checksEnabled: true, freshnessWindowMs: FRESH_WINDOW, now: NOW, }); expect(facts.fixableWithImageUpdate).toBe(0); expect(facts.fixableUpdateUnknown).toBe(1); }); it('returns raw finding image_ref even when stack match used normalizeImageRef', () => { const facts = classifyImageRemediation({ findings: [{ image_ref: 'nginx:1.14', count: 2 }], details: { web: detail([service({ service: 'app', image: 'docker.io/library/nginx:1.14', hasUpdate: true, checkStatus: 'ok', })]), }, checksEnabled: true, freshnessWindowMs: FRESH_WINDOW, now: NOW, }); expect(facts.fixableWithImageUpdate).toBe(2); expect(facts.imageRefsUpdateAvailable).toEqual(['nginx:1.14']); }); });