mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-13 04:06:59 +00:00
44dba59cab
Introduce a NotificationCategory string-literal union (11 values) and thread it through dispatchAlert as a required second argument. All callers (DockerEventService, AutoHealService, ImageUpdateService, MonitorService, PolicyEnforcement, policyGate, SchedulerService, imageUpdates route) pass an explicit category at every call site, giving TypeScript compile-time enforcement that no new emit site can be added without choosing a category. DatabaseService gains an idempotent migration that adds a nullable category TEXT column to notification_history; existing rows keep category=NULL (displayed as Uncategorized in the UI). The getNotificationHistory method accepts an optional category filter that is forwarded from the GET /api/notifications/history route via a ?category= query param. NotificationPanel gains a category Select dropdown so users can filter history by category. The frontend types mirror the backend union so API responses are type-safe end-to-end. All 75 test files (1410 tests) updated to the new 4-arg dispatchAlert signature and passing.
447 lines
15 KiB
TypeScript
447 lines
15 KiB
TypeScript
/**
|
|
* Unit tests for ImageUpdateService: image ref parsing, compose extraction,
|
|
* env file loading, checkImage digest comparison, and rate limiting.
|
|
*/
|
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
|
|
// ── Hoisted mocks ──────────────────────────────────────────────────────
|
|
|
|
const {
|
|
mockGetAuthForRegistry,
|
|
mockGetStackUpdateStatus, mockUpsertStackUpdateStatus, mockClearStackUpdateStatus,
|
|
mockGetSystemState, mockSetSystemState, mockAddNotificationHistory,
|
|
mockDispatchAlert,
|
|
mockGetStacks, mockGetStackContent, mockGetEnvContent,
|
|
mockGetAllContainers,
|
|
} = vi.hoisted(() => ({
|
|
mockGetAuthForRegistry: vi.fn().mockResolvedValue(null),
|
|
mockGetStackUpdateStatus: vi.fn().mockReturnValue({}),
|
|
mockUpsertStackUpdateStatus: vi.fn(),
|
|
mockClearStackUpdateStatus: vi.fn(),
|
|
mockGetSystemState: vi.fn().mockReturnValue('1'), // default: backfilled
|
|
mockSetSystemState: vi.fn(),
|
|
mockAddNotificationHistory: vi.fn(),
|
|
mockDispatchAlert: vi.fn().mockResolvedValue(undefined),
|
|
mockGetStacks: vi.fn().mockResolvedValue([]),
|
|
mockGetStackContent: vi.fn().mockResolvedValue(''),
|
|
mockGetEnvContent: vi.fn().mockRejectedValue(new Error('no env')),
|
|
mockGetAllContainers: vi.fn().mockResolvedValue([]),
|
|
}));
|
|
|
|
vi.mock('../services/RegistryService', () => ({
|
|
RegistryService: {
|
|
getInstance: () => ({
|
|
getAuthForRegistry: mockGetAuthForRegistry,
|
|
}),
|
|
},
|
|
}));
|
|
|
|
vi.mock('../services/DatabaseService', () => ({
|
|
DatabaseService: {
|
|
getInstance: () => ({
|
|
getGlobalSettings: () => ({ developer_mode: '0' }),
|
|
getNodes: () => [],
|
|
upsertStackUpdateStatus: mockUpsertStackUpdateStatus,
|
|
getStackUpdateStatus: mockGetStackUpdateStatus,
|
|
clearStackUpdateStatus: mockClearStackUpdateStatus,
|
|
getSystemState: mockGetSystemState,
|
|
setSystemState: mockSetSystemState,
|
|
addNotificationHistory: mockAddNotificationHistory,
|
|
}),
|
|
},
|
|
}));
|
|
|
|
vi.mock('../services/NotificationService', () => ({
|
|
NotificationService: {
|
|
getInstance: () => ({
|
|
dispatchAlert: mockDispatchAlert,
|
|
}),
|
|
},
|
|
}));
|
|
|
|
vi.mock('../services/FileSystemService', () => ({
|
|
FileSystemService: {
|
|
getInstance: () => ({
|
|
getStacks: mockGetStacks,
|
|
getStackContent: mockGetStackContent,
|
|
getEnvContent: mockGetEnvContent,
|
|
}),
|
|
},
|
|
}));
|
|
|
|
vi.mock('../services/DockerController', () => ({
|
|
default: {
|
|
getInstance: () => ({
|
|
getAllContainers: mockGetAllContainers,
|
|
}),
|
|
},
|
|
}));
|
|
|
|
vi.mock('../services/NodeRegistry', () => ({
|
|
NodeRegistry: {
|
|
getInstance: () => ({
|
|
getComposeDir: () => '/tmp/compose',
|
|
getDefaultNodeId: () => 1,
|
|
}),
|
|
},
|
|
}));
|
|
|
|
// ── Re-export internal helpers via the module ─────────────────────────
|
|
|
|
// We need the internal functions. Import the module after mocks are set up.
|
|
// parseImageRef, extractImagesFromCompose, loadDotEnv are module-scoped (not exported).
|
|
// We'll test them indirectly through checkImage and by importing the file and
|
|
// evaluating the functions via a workaround, or test via the public API.
|
|
|
|
// Since the pure functions are not exported, we test them by importing
|
|
// the module source and evaluating. For a cleaner approach, we test
|
|
// parseImageRef behavior through checkImage and test the compose helpers
|
|
// through a dynamic import of the raw source.
|
|
|
|
// For this test we re-implement the function signatures to test via the
|
|
// public checkImage method (which calls parseImageRef internally).
|
|
|
|
import { ImageUpdateService } from '../services/ImageUpdateService';
|
|
import YAML from 'yaml';
|
|
|
|
// ── parseImageRef (tested indirectly via checkImage) ──────────────────
|
|
|
|
describe('ImageUpdateService - image ref parsing (via checkImage)', () => {
|
|
let service: ImageUpdateService;
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
(ImageUpdateService as any).instance = undefined;
|
|
service = ImageUpdateService.getInstance();
|
|
});
|
|
|
|
function makeMockDocker(repoDigests: string[] = []) {
|
|
const inspectFn = vi.fn().mockResolvedValue({ RepoDigests: repoDigests });
|
|
return {
|
|
getDocker: () => ({
|
|
getImage: () => ({ inspect: inspectFn }),
|
|
}),
|
|
} as any;
|
|
}
|
|
|
|
it('returns { hasUpdate: false } for sha256-only refs', async () => {
|
|
const docker = makeMockDocker();
|
|
const result = await service.checkImage(docker, 'sha256:abc123');
|
|
expect(result).toEqual({ hasUpdate: false });
|
|
});
|
|
|
|
it('returns error when local image inspect fails', async () => {
|
|
const docker = {
|
|
getDocker: () => ({
|
|
getImage: () => ({ inspect: vi.fn().mockRejectedValue(new Error('not found')) }),
|
|
}),
|
|
} as any;
|
|
const result = await service.checkImage(docker, 'nginx:latest');
|
|
expect(result.hasUpdate).toBe(false);
|
|
expect(result.error).toContain('Failed to inspect local image');
|
|
});
|
|
|
|
it('returns { hasUpdate: false } when no RepoDigests match', async () => {
|
|
// Empty RepoDigests means locally built image
|
|
const docker = makeMockDocker([]);
|
|
const result = await service.checkImage(docker, 'nginx:latest');
|
|
expect(result).toEqual({ hasUpdate: false });
|
|
});
|
|
|
|
it('returns { hasUpdate: false } when RepoDigests have no sha256', async () => {
|
|
const docker = makeMockDocker(['library/nginx:latest']);
|
|
const result = await service.checkImage(docker, 'nginx:latest');
|
|
expect(result).toEqual({ hasUpdate: false });
|
|
});
|
|
});
|
|
|
|
// ── Rate limiting ─────────────────────────────────────────────────────
|
|
|
|
describe('ImageUpdateService - manual refresh cooldown', () => {
|
|
let service: ImageUpdateService;
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
(ImageUpdateService as any).instance = undefined;
|
|
service = ImageUpdateService.getInstance();
|
|
});
|
|
|
|
it('enforces cooldown between manual triggers', () => {
|
|
// First trigger should succeed
|
|
const first = service.triggerManualRefresh();
|
|
expect(first).toBe(true);
|
|
|
|
// Immediate second trigger should be rate-limited
|
|
const second = service.triggerManualRefresh();
|
|
expect(second).toBe(false);
|
|
});
|
|
|
|
it('reports isChecking state', () => {
|
|
// Initially not checking
|
|
expect(service.isChecking()).toBe(false);
|
|
});
|
|
});
|
|
|
|
// ── Compose parsing helpers (tested via source eval) ──────────────────
|
|
// Since loadDotEnv and extractImagesFromCompose are not exported, we
|
|
// test them by dynamically importing the raw module code and extracting
|
|
// the functions. This is a pragmatic approach for testing internal helpers.
|
|
|
|
describe('ImageUpdateService - loadDotEnv (internal)', () => {
|
|
// We replicate the loadDotEnv logic here since it's a pure function
|
|
// that is not exported. This tests the behavior specification.
|
|
function loadDotEnv(content: string): Record<string, string> {
|
|
const vars: Record<string, string> = {};
|
|
for (const line of content.split('\n')) {
|
|
const trimmed = line.trim();
|
|
if (!trimmed || trimmed.startsWith('#')) continue;
|
|
const eqIdx = trimmed.indexOf('=');
|
|
if (eqIdx < 1) continue;
|
|
const key = trimmed.slice(0, eqIdx).trim();
|
|
let val = trimmed.slice(eqIdx + 1).trim();
|
|
if ((val.startsWith('"') && val.endsWith('"')) ||
|
|
(val.startsWith("'") && val.endsWith("'"))) {
|
|
val = val.slice(1, -1);
|
|
}
|
|
vars[key] = val;
|
|
}
|
|
return vars;
|
|
}
|
|
|
|
it('parses basic key=value pairs', () => {
|
|
const result = loadDotEnv('FOO=bar\nBAZ=qux');
|
|
expect(result).toEqual({ FOO: 'bar', BAZ: 'qux' });
|
|
});
|
|
|
|
it('handles quoted values', () => {
|
|
const result = loadDotEnv('FOO="hello world"\nBAR=\'single\'');
|
|
expect(result).toEqual({ FOO: 'hello world', BAR: 'single' });
|
|
});
|
|
|
|
it('ignores comments and empty lines', () => {
|
|
const result = loadDotEnv('# comment\n\nFOO=bar\n # another comment');
|
|
expect(result).toEqual({ FOO: 'bar' });
|
|
});
|
|
|
|
it('handles values with equals signs', () => {
|
|
const result = loadDotEnv('CONNECTION=host=db port=5432');
|
|
expect(result).toEqual({ CONNECTION: 'host=db port=5432' });
|
|
});
|
|
|
|
it('returns empty object for empty input', () => {
|
|
expect(loadDotEnv('')).toEqual({});
|
|
});
|
|
});
|
|
|
|
describe('ImageUpdateService - extractImagesFromCompose (internal)', () => {
|
|
// Replicate the extraction logic for testing
|
|
|
|
function extractImagesFromCompose(
|
|
yamlContent: string,
|
|
envVars: Record<string, string>
|
|
): string[] {
|
|
let parsed: Record<string, unknown>;
|
|
try {
|
|
parsed = YAML.parse(yamlContent) as Record<string, unknown>;
|
|
} catch {
|
|
return [];
|
|
}
|
|
if (!parsed?.services || typeof parsed.services !== 'object') return [];
|
|
|
|
const images: string[] = [];
|
|
for (const svc of Object.values(parsed.services as Record<string, unknown>)) {
|
|
if (!svc || typeof svc !== 'object') continue;
|
|
const raw = (svc as Record<string, unknown>).image;
|
|
if (!raw || typeof raw !== 'string') continue;
|
|
|
|
let ref = raw.replace(
|
|
/\$\{([^}]+)\}/g,
|
|
(_: string, expr: string) => {
|
|
const defaultMatch = expr.match(/^([^:-]+)(?::?-)(.+)$/);
|
|
if (defaultMatch) {
|
|
return envVars[defaultMatch[1]] ?? defaultMatch[2];
|
|
}
|
|
return envVars[expr] ?? '';
|
|
}
|
|
);
|
|
|
|
ref = ref.trim();
|
|
if (!ref || ref.includes('${') || ref.startsWith('sha256:')) continue;
|
|
images.push(ref);
|
|
}
|
|
return images;
|
|
}
|
|
|
|
it('extracts images from a multi-service compose file', () => {
|
|
const yaml = `
|
|
services:
|
|
web:
|
|
image: nginx:latest
|
|
db:
|
|
image: postgres:15
|
|
`;
|
|
expect(extractImagesFromCompose(yaml, {})).toEqual(['nginx:latest', 'postgres:15']);
|
|
});
|
|
|
|
it('resolves environment variables in image refs', () => {
|
|
const yaml = `
|
|
services:
|
|
app:
|
|
image: \${IMAGE_NAME}:\${IMAGE_TAG:-latest}
|
|
`;
|
|
expect(extractImagesFromCompose(yaml, { IMAGE_NAME: 'myapp' })).toEqual(['myapp:latest']);
|
|
});
|
|
|
|
it('uses default values when env vars are missing', () => {
|
|
const yaml = `
|
|
services:
|
|
app:
|
|
image: \${IMAGE:-nginx}:\${TAG:-1.25}
|
|
`;
|
|
expect(extractImagesFromCompose(yaml, {})).toEqual(['nginx:1.25']);
|
|
});
|
|
|
|
it('skips services without image key', () => {
|
|
const yaml = `
|
|
services:
|
|
built:
|
|
build: ./app
|
|
pulled:
|
|
image: redis:7
|
|
`;
|
|
expect(extractImagesFromCompose(yaml, {})).toEqual(['redis:7']);
|
|
});
|
|
|
|
it('skips sha256-only image refs', () => {
|
|
const yaml = `
|
|
services:
|
|
app:
|
|
image: sha256:abc123def456
|
|
`;
|
|
expect(extractImagesFromCompose(yaml, {})).toEqual([]);
|
|
});
|
|
|
|
it('returns empty for invalid YAML', () => {
|
|
expect(extractImagesFromCompose('{{not: yaml', {})).toEqual([]);
|
|
});
|
|
|
|
it('returns empty when no services key', () => {
|
|
expect(extractImagesFromCompose('version: "3"', {})).toEqual([]);
|
|
});
|
|
|
|
it('skips unresolved variables', () => {
|
|
const yaml = `
|
|
services:
|
|
app:
|
|
image: \${UNSET_VAR}
|
|
`;
|
|
expect(extractImagesFromCompose(yaml, {})).toEqual([]);
|
|
});
|
|
});
|
|
|
|
// ── Notification dispatch on state transitions ────────────────────────
|
|
|
|
describe('ImageUpdateService - notification dispatch', () => {
|
|
const COMPOSE = `
|
|
services:
|
|
app:
|
|
image: nginx:latest
|
|
`;
|
|
|
|
const fakeDb = () => ({
|
|
getStackUpdateStatus: mockGetStackUpdateStatus,
|
|
upsertStackUpdateStatus: mockUpsertStackUpdateStatus,
|
|
clearStackUpdateStatus: mockClearStackUpdateStatus,
|
|
getSystemState: mockGetSystemState,
|
|
setSystemState: mockSetSystemState,
|
|
addNotificationHistory: mockAddNotificationHistory,
|
|
});
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
(ImageUpdateService as any).instance = undefined;
|
|
// Default: backfill complete so transition logic applies normally.
|
|
mockGetSystemState.mockReturnValue('1');
|
|
mockGetStacks.mockResolvedValue(['stackA']);
|
|
mockGetStackContent.mockResolvedValue(COMPOSE);
|
|
mockGetAllContainers.mockResolvedValue([]);
|
|
});
|
|
|
|
/**
|
|
* Stubs the private checkImage method so tests don't need to mock
|
|
* the entire registry-fetch stack.
|
|
*/
|
|
function stubCheckImage(service: ImageUpdateService, hasUpdate: boolean) {
|
|
(service as any).checkImage = vi.fn().mockResolvedValue({ hasUpdate });
|
|
}
|
|
|
|
it('dispatches notification when a stack transitions from no-update to has-update', async () => {
|
|
mockGetStackUpdateStatus.mockReturnValue({ stackA: false });
|
|
const service = ImageUpdateService.getInstance();
|
|
stubCheckImage(service, true);
|
|
|
|
await (service as any).checkNode(1, 'local', fakeDb());
|
|
|
|
expect(mockDispatchAlert).toHaveBeenCalledTimes(1);
|
|
expect(mockDispatchAlert).toHaveBeenCalledWith(
|
|
'info',
|
|
'image_update_available',
|
|
expect.stringContaining('stackA'),
|
|
{ stackName: 'stackA' },
|
|
);
|
|
expect(mockUpsertStackUpdateStatus).toHaveBeenCalledWith(1, 'stackA', true, expect.any(Number));
|
|
});
|
|
|
|
it('does not re-fire notification for a stack already known to have updates', async () => {
|
|
mockGetStackUpdateStatus.mockReturnValue({ stackA: true });
|
|
const service = ImageUpdateService.getInstance();
|
|
stubCheckImage(service, true);
|
|
|
|
await (service as any).checkNode(1, 'local', fakeDb());
|
|
|
|
expect(mockDispatchAlert).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('backfills catch-up notifications once for pre-existing has_update rows', async () => {
|
|
// Simulate a stale DB: two stacks already have has_update = true,
|
|
// but the backfill flag is not set.
|
|
mockGetSystemState.mockReturnValue(null);
|
|
mockGetStacks.mockResolvedValue(['stackA', 'stackB']);
|
|
mockGetStackUpdateStatus.mockReturnValue({ stackA: true, stackB: true });
|
|
const service = ImageUpdateService.getInstance();
|
|
stubCheckImage(service, true);
|
|
|
|
await (service as any).checkNode(1, 'local', fakeDb());
|
|
|
|
expect(mockDispatchAlert).toHaveBeenCalledTimes(2);
|
|
const dispatched = mockDispatchAlert.mock.calls.map(call => (call[3] as any)?.stackName);
|
|
expect(dispatched).toEqual(expect.arrayContaining(['stackA', 'stackB']));
|
|
expect(mockSetSystemState).toHaveBeenCalledWith('image_update_notifications_backfilled', '1');
|
|
|
|
// Second run with backfill flag set and the same state: no further notifications.
|
|
vi.clearAllMocks();
|
|
mockGetSystemState.mockReturnValue('1');
|
|
mockGetStacks.mockResolvedValue(['stackA', 'stackB']);
|
|
mockGetStackUpdateStatus.mockReturnValue({ stackA: true, stackB: true });
|
|
stubCheckImage(service, true);
|
|
|
|
await (service as any).checkNode(1, 'local', fakeDb());
|
|
|
|
expect(mockDispatchAlert).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('surfaces dispatch failures as an error entry in notification history', async () => {
|
|
mockGetStackUpdateStatus.mockReturnValue({ stackA: false });
|
|
mockDispatchAlert.mockRejectedValueOnce(new Error('webhook timeout'));
|
|
const service = ImageUpdateService.getInstance();
|
|
stubCheckImage(service, true);
|
|
|
|
await (service as any).checkNode(1, 'local', fakeDb());
|
|
|
|
expect(mockAddNotificationHistory).toHaveBeenCalledWith(1, expect.objectContaining({
|
|
level: 'error',
|
|
message: expect.stringContaining('webhook timeout'),
|
|
}));
|
|
});
|
|
});
|