Files
sencho/backend/src/__tests__/database-metrics.test.ts
T
Anso c4e2595ded docs: harden public docs by removing security-sensitive details (#331)
* docs: remove security-sensitive implementation details from public documentation

Generalize or remove internal architecture details that could aid targeted
attacks — CVE tables, database schema, rate limit thresholds, proxy internals,
encryption algorithm names, and WebSocket middleware bypass info.

* test(metrics): fix flaky minute-bucket aggregation test

Floor baseTime to the start of the current minute so baseTime + 5000
never crosses a minute boundary and produces 2 buckets instead of 1.
2026-04-01 23:48:49 -04:00

399 lines
12 KiB
TypeScript

/**
* Integration tests for DatabaseService metrics, cleanup, notification cap,
* and stack alert CRUD. Uses a real temp SQLite database.
*/
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { setupTestDb, cleanupTestDb } from './helpers/setupTestDb';
let tmpDir: string;
let DatabaseService: any;
let db: any;
beforeAll(async () => {
tmpDir = await setupTestDb();
DatabaseService = (await import('../services/DatabaseService')).DatabaseService;
db = DatabaseService.getInstance();
});
afterAll(() => {
cleanupTestDb(tmpDir);
});
describe('DatabaseService - container metrics', () => {
it('stores and retrieves a metric', () => {
const now = Date.now();
db.addContainerMetric({
container_id: 'abc123',
stack_name: 'test-stack',
cpu_percent: 42.5,
memory_mb: 256,
net_rx_mb: 1.2,
net_tx_mb: 0.8,
timestamp: now,
});
const metrics = db.getContainerMetrics(1);
expect(metrics.length).toBeGreaterThanOrEqual(1);
const found = metrics.find((m: any) => m.container_id === 'abc123');
expect(found).toBeDefined();
expect(found.stack_name).toBe('test-stack');
expect(found.cpu_percent).toBeCloseTo(42.5, 0);
});
it('aggregates metrics into minute buckets', () => {
const baseTime = Math.floor(Date.now() / 60000) * 60000; // floor to minute start
// Insert two metrics within the same minute
db.addContainerMetric({
container_id: 'bucket-test',
stack_name: 'bucket-stack',
cpu_percent: 20,
memory_mb: 100,
net_rx_mb: 1,
net_tx_mb: 2,
timestamp: baseTime,
});
db.addContainerMetric({
container_id: 'bucket-test',
stack_name: 'bucket-stack',
cpu_percent: 40,
memory_mb: 200,
net_rx_mb: 3,
net_tx_mb: 4,
timestamp: baseTime + 5000, // 5 seconds later, same minute bucket
});
const metrics = db.getContainerMetrics(1);
const bucketMetrics = metrics.filter((m: any) => m.container_id === 'bucket-test');
// Should be aggregated into 1 bucket (same minute)
expect(bucketMetrics.length).toBe(1);
// CPU and memory are averaged
expect(bucketMetrics[0].cpu_percent).toBeCloseTo(30, 0);
expect(bucketMetrics[0].memory_mb).toBeCloseTo(150, 0);
// Network uses MAX
expect(bucketMetrics[0].net_rx_mb).toBeCloseTo(3, 0);
expect(bucketMetrics[0].net_tx_mb).toBeCloseTo(4, 0);
});
it('filters out metrics older than hoursLookback', () => {
const oldTimestamp = Date.now() - 3 * 60 * 60 * 1000; // 3 hours ago
db.addContainerMetric({
container_id: 'old-container',
stack_name: 'old-stack',
cpu_percent: 10,
memory_mb: 50,
net_rx_mb: 0.1,
net_tx_mb: 0.1,
timestamp: oldTimestamp,
});
// Look back only 1 hour — should not find it
const recent = db.getContainerMetrics(1);
const found = recent.find((m: any) => m.container_id === 'old-container');
expect(found).toBeUndefined();
// Look back 4 hours — should find it
const wider = db.getContainerMetrics(4);
const foundWider = wider.find((m: any) => m.container_id === 'old-container');
expect(foundWider).toBeDefined();
});
it('handles multiple containers in the same stack', () => {
const now = Date.now();
db.addContainerMetric({
container_id: 'multi-1',
stack_name: 'multi-stack',
cpu_percent: 10,
memory_mb: 100,
net_rx_mb: 1,
net_tx_mb: 1,
timestamp: now,
});
db.addContainerMetric({
container_id: 'multi-2',
stack_name: 'multi-stack',
cpu_percent: 20,
memory_mb: 200,
net_rx_mb: 2,
net_tx_mb: 2,
timestamp: now,
});
const metrics = db.getContainerMetrics(1);
const stackMetrics = metrics.filter((m: any) => m.stack_name === 'multi-stack');
// Two distinct containers, so two rows (different container_id in GROUP BY)
expect(stackMetrics.length).toBe(2);
});
});
describe('DatabaseService - cleanupOldMetrics', () => {
it('deletes metrics older than specified hours', () => {
const oldTimestamp = Date.now() - 48 * 60 * 60 * 1000; // 48 hours ago
db.addContainerMetric({
container_id: 'cleanup-target',
stack_name: 'cleanup-stack',
cpu_percent: 5,
memory_mb: 32,
net_rx_mb: 0,
net_tx_mb: 0,
timestamp: oldTimestamp,
});
db.cleanupOldMetrics(24);
const all = db.getContainerMetrics(72);
const found = all.find((m: any) => m.container_id === 'cleanup-target');
expect(found).toBeUndefined();
});
it('retains metrics within the retention window', () => {
const recentTimestamp = Date.now() - 1 * 60 * 60 * 1000; // 1 hour ago
db.addContainerMetric({
container_id: 'keep-me',
stack_name: 'keep-stack',
cpu_percent: 15,
memory_mb: 64,
net_rx_mb: 0.5,
net_tx_mb: 0.5,
timestamp: recentTimestamp,
});
db.cleanupOldMetrics(24);
const metrics = db.getContainerMetrics(24);
const found = metrics.find((m: any) => m.container_id === 'keep-me');
expect(found).toBeDefined();
});
});
describe('DatabaseService - cleanupOldNotifications', () => {
it('deletes notifications older than specified days and retains recent ones', () => {
const oldTimestamp = Date.now() - 60 * 24 * 60 * 60 * 1000; // 60 days ago
const recentTimestamp = Date.now() - 1 * 24 * 60 * 60 * 1000; // 1 day ago
db.addNotificationHistory({ level: 'info', message: 'old notification', timestamp: oldTimestamp });
db.addNotificationHistory({ level: 'info', message: 'recent notification', timestamp: recentTimestamp });
db.cleanupOldNotifications(30);
const history = db.getNotificationHistory(200);
const old = history.find((n: any) => n.message === 'old notification');
const recent = history.find((n: any) => n.message === 'recent notification');
expect(old).toBeUndefined();
expect(recent).toBeDefined();
});
});
describe('DatabaseService - cleanupOldAuditLogs', () => {
it('deletes audit logs older than specified days and retains recent ones', () => {
const oldTimestamp = Date.now() - 120 * 24 * 60 * 60 * 1000; // 120 days ago
const recentTimestamp = Date.now() - 10 * 24 * 60 * 60 * 1000; // 10 days ago
db.insertAuditLog({
timestamp: oldTimestamp,
username: 'admin',
method: 'GET',
path: '/api/old',
status_code: 200,
node_id: null,
ip_address: '127.0.0.1',
summary: 'old audit entry',
});
db.insertAuditLog({
timestamp: recentTimestamp,
username: 'admin',
method: 'POST',
path: '/api/recent',
status_code: 200,
node_id: null,
ip_address: '127.0.0.1',
summary: 'recent audit entry',
});
db.cleanupOldAuditLogs(90);
const { entries } = db.getAuditLogs({ limit: 200, offset: 0 });
const old = entries.find((e: any) => e.summary === 'old audit entry');
const recent = entries.find((e: any) => e.summary === 'recent audit entry');
expect(old).toBeUndefined();
expect(recent).toBeDefined();
});
});
describe('DatabaseService - notification history cap', () => {
it('auto-prunes to 100 entries when adding notifications', () => {
// Insert 105 notifications
for (let i = 0; i < 105; i++) {
db.addNotificationHistory({
level: 'info',
message: `cap-test-${i}`,
timestamp: Date.now() + i,
});
}
// The table should have at most 100 rows
const all = db.getNotificationHistory(200);
expect(all.length).toBeLessThanOrEqual(100);
});
it('keeps the most recent entries after pruning', () => {
// Clear all first
db.deleteAllNotifications();
for (let i = 0; i < 105; i++) {
db.addNotificationHistory({
level: 'info',
message: `order-test-${i}`,
timestamp: Date.now() + i * 10,
});
}
const all = db.getNotificationHistory(200);
// The newest entries should survive (ordered DESC by timestamp)
expect(all[0].message).toContain('order-test-');
// The oldest entries (0-4) should have been pruned
const oldest = all.find((n: any) => n.message === 'order-test-0');
expect(oldest).toBeUndefined();
});
});
describe('DatabaseService - stack alerts CRUD', () => {
it('adds and retrieves stack alerts', () => {
db.addStackAlert({
stack_name: 'alert-stack',
metric: 'cpu_percent',
operator: '>',
threshold: 80,
duration_mins: 5,
cooldown_mins: 15,
});
const alerts = db.getStackAlerts();
const found = alerts.find((a: any) => a.stack_name === 'alert-stack');
expect(found).toBeDefined();
expect(found.metric).toBe('cpu_percent');
expect(found.operator).toBe('>');
expect(found.threshold).toBe(80);
expect(found.duration_mins).toBe(5);
expect(found.cooldown_mins).toBe(15);
});
it('filters alerts by stack name', () => {
db.addStackAlert({
stack_name: 'filter-stack-a',
metric: 'memory_mb',
operator: '>=',
threshold: 512,
duration_mins: 1,
cooldown_mins: 10,
});
db.addStackAlert({
stack_name: 'filter-stack-b',
metric: 'cpu_percent',
operator: '>',
threshold: 90,
duration_mins: 2,
cooldown_mins: 5,
});
const alertsA = db.getStackAlerts('filter-stack-a');
expect(alertsA.length).toBe(1);
expect(alertsA[0].stack_name).toBe('filter-stack-a');
const alertsB = db.getStackAlerts('filter-stack-b');
expect(alertsB.length).toBe(1);
expect(alertsB[0].stack_name).toBe('filter-stack-b');
});
it('updates last_fired_at timestamp', () => {
db.addStackAlert({
stack_name: 'fired-stack',
metric: 'net_rx',
operator: '>',
threshold: 100,
duration_mins: 1,
cooldown_mins: 30,
});
const alerts = db.getStackAlerts('fired-stack');
const alert = alerts[0];
const fireTime = Date.now();
db.updateStackAlertLastFired(alert.id, fireTime);
const updated = db.getStackAlerts('fired-stack');
expect(updated[0].last_fired_at).toBe(fireTime);
});
it('deletes an alert by id', () => {
db.addStackAlert({
stack_name: 'delete-stack',
metric: 'memory_percent',
operator: '>',
threshold: 95,
duration_mins: 1,
cooldown_mins: 5,
});
const before = db.getStackAlerts('delete-stack');
expect(before.length).toBe(1);
db.deleteStackAlert(before[0].id);
const after = db.getStackAlerts('delete-stack');
expect(after.length).toBe(0);
});
});
describe('DatabaseService - stress tests', () => {
it('handles 1000+ metrics and cleanup bounds growth', () => {
const now = Date.now();
// Insert 1200 metrics spread across 2 hours
for (let i = 0; i < 1200; i++) {
db.addContainerMetric({
container_id: `stress-container-${i % 10}`,
stack_name: 'stress-stack',
cpu_percent: Math.random() * 100,
memory_mb: Math.random() * 1024,
net_rx_mb: Math.random() * 10,
net_tx_mb: Math.random() * 10,
timestamp: now - (i * 6000), // Every 6 seconds over ~2 hours
});
}
// Cleanup with 1 hour retention
db.cleanupOldMetrics(1);
// Only metrics from last hour should remain (600 entries = 1 hour / 6 seconds)
const remaining = db.getContainerMetrics(2);
// Aggregated into minute buckets, so much fewer than 600
expect(remaining.length).toBeLessThan(700);
// But should still have data from the retained window
expect(remaining.length).toBeGreaterThan(0);
});
it('aggregation remains correct at scale', () => {
const now = Date.now();
const minuteBase = Math.floor(now / 60000) * 60000; // Start of current minute
// Insert 50 metrics in the same minute for one container
for (let i = 0; i < 50; i++) {
db.addContainerMetric({
container_id: 'agg-stress',
stack_name: 'agg-stack',
cpu_percent: 50, // Constant value so average should be 50
memory_mb: 256,
net_rx_mb: i, // Increasing, MAX should be 49
net_tx_mb: 0,
timestamp: minuteBase + i * 100,
});
}
const metrics = db.getContainerMetrics(1);
const aggMetrics = metrics.filter((m: any) => m.container_id === 'agg-stress');
expect(aggMetrics.length).toBe(1); // All in one minute bucket
expect(aggMetrics[0].cpu_percent).toBeCloseTo(50, 0);
expect(aggMetrics[0].memory_mb).toBeCloseTo(256, 0);
expect(aggMetrics[0].net_rx_mb).toBe(49);
});
});