mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-19 14:56:27 +00:00
0ba09ebdee
* chore: bump brace-expansion and fast-uri via npm audit fix Resolves GHSA-rgw5-rvv9-x895 (brace-expansion DoS via unbounded intermediate arrays). Both transitive dev dependencies updated: - brace-expansion 5.0.8 -> 5.0.9 - fast-uri 3.1.4 -> 3.1.5 * chore: also bump frontend deps via npm audit fix Fixes brace-expansion and postcss in the frontend lockfile so npm audit --audit-level=high passes on both packages. * chore: bump ip-address transitive dep via npm audit fix Resolves three new ip-address advisories (GHSA-mwp4-54f8-5fhr, GHSA-4xrf-jv44-h6hh, GHSA-22jq-vg5j-6vgg) published between prior push and CI run. * feat: add ntfy notification channel Add ntfy (https://ntfy.sh) as the fifth notification channel alongside Discord, Slack, Webhook, and Apprise. ntfy speaks its native protocol: plain-text POST body with Content-Type, Title, Priority, and Tags headers. Priority maps info/warning/error to ntfy's default/high/urgent. URL validation allows both HTTP and HTTPS (common for LAN self-hosting) but rejects embedded credentials, consistent with Apprise. Token auth via ntfy's documented ?auth= query parameter is supported. * fix: correct ntfy channel test cases for Linux URL parsing and required type field - notification-channels.test.ts: replace http:///topic host check with a cross-platform invalid-URL case (WHATWG parser treats triple-slash authority differently on Linux vs Windows) - ConfigurationStatus.test.tsx: add ntfy agent slot to makePayload and inline agents fixtures (required by the expanded ConfigurationAgents type) * fix: remove unused import and update 0/4 masthead assertions to 0/5 * ci: exclude NotificationService.ts from js/request-forgery CodeQL rule Notification channel dispatch methods (Discord, Slack, Webhook, Apprise, ntfy) all call fetch() with admin-configured URLs and notification bodies that may embed stack or path data. This matches the trust model already documented for registry-api.ts: single-tenant self-hosted, admin owns the server, outbound posting is the intended behavior. The write path is gated by requireAdmin or requirePermission(node:manage), and every dispatch runs with a 10s AbortSignal.timeout. * ci: also exclude NotificationService.ts from js/file-access-to-http Notification messages may embed stack names, paths, or compose-derived content. Same trust model as js/request-forgery: admin owns the server and the configured endpoints, write path is gated. * fix: correct ntfy channel tab copy and validation error message The ntfy settings tab was reusing the generic webhook label, helper, and placeholder (Webhook URL / JSON payloads / https://...). Give ntfy its own copy: label names the server-and-topic URL, helper states plain-text delivery and the mandatory topic path, placeholder matches the routing section. Also fix the routing-rule validation toast: the guard correctly exempts ntfy from the HTTPS check but the error message was not updated alongside it, so ntfy URLs received a misleading HTTPS-required message. * fix: strip trailing slash from ntfy topic URL before dispatch A topic URL like https://ntfy.sh/mytopic/ validates fine (the check strips the trailing slash internally) but was stored and dispatched with the slash intact, causing the real ntfy server to 404. Normalize before fetch so the request reaches the correct topic path. Also add ntfy to the Channels card description in the settings registry.
286 lines
12 KiB
TypeScript
286 lines
12 KiB
TypeScript
/**
|
|
* Integration tests for the dashboard router.
|
|
*
|
|
* Covers:
|
|
* - Both endpoints reject unauthenticated requests (global authGate).
|
|
* - GET /api/dashboard/configuration returns the documented shape and
|
|
* applies tier-correct `locked` flags for the Community and paid
|
|
* personas (toggled via LicenseService spies).
|
|
* - Alert-rule counts are scoped to stacks present on the active node
|
|
* (dashboard and fleet local-node row agree on exact cardinality).
|
|
* - GET /api/dashboard/stack-restarts clamps the `days` query parameter
|
|
* to [1, 30] and falls back to 7 for invalid inputs.
|
|
* - Neither endpoint leaks secret material (agent URLs, tokens) in the
|
|
* response payload.
|
|
*/
|
|
import fs from 'fs';
|
|
import path from 'path';
|
|
import { describe, it, expect, beforeAll, afterAll, beforeEach, vi } from 'vitest';
|
|
import request from 'supertest';
|
|
import { setupTestDb, cleanupTestDb, loginAsTestAdmin } from './helpers/setupTestDb';
|
|
|
|
let tmpDir: string;
|
|
let app: import('express').Express;
|
|
let LicenseService: typeof import('../services/LicenseService').LicenseService;
|
|
let DatabaseService: typeof import('../services/DatabaseService').DatabaseService;
|
|
let adminCookie: string;
|
|
|
|
beforeAll(async () => {
|
|
tmpDir = await setupTestDb();
|
|
({ LicenseService } = await import('../services/LicenseService'));
|
|
({ DatabaseService } = await import('../services/DatabaseService'));
|
|
|
|
// Default the app to the paid tier so the import sees a fully populated
|
|
// license; individual tests override with vi.spyOn before hitting the route.
|
|
vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValue('paid');
|
|
|
|
({ app } = await import('../index'));
|
|
adminCookie = await loginAsTestAdmin(app);
|
|
});
|
|
|
|
afterAll(() => cleanupTestDb(tmpDir));
|
|
|
|
beforeEach(() => {
|
|
// Reset to the default paid baseline before each test; individual tests
|
|
// below re-spy as needed for the Community persona.
|
|
vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValue('paid');
|
|
});
|
|
|
|
describe('GET /api/dashboard/configuration', () => {
|
|
it('rejects unauthenticated requests with 401', async () => {
|
|
const res = await request(app).get('/api/dashboard/configuration');
|
|
expect(res.status).toBe(401);
|
|
});
|
|
|
|
it('returns the documented shape for an authenticated request', async () => {
|
|
const res = await request(app).get('/api/dashboard/configuration').set('Cookie', adminCookie);
|
|
expect(res.status).toBe(200);
|
|
expect(res.body).toMatchObject({
|
|
tier: expect.any(String),
|
|
notifications: {
|
|
agents: {
|
|
discord: { configured: expect.any(Boolean) },
|
|
slack: { configured: expect.any(Boolean) },
|
|
webhook: { configured: expect.any(Boolean) },
|
|
apprise: { configured: expect.any(Boolean) },
|
|
ntfy: { configured: expect.any(Boolean) },
|
|
},
|
|
alertRules: expect.any(Number),
|
|
routingRules: { count: expect.any(Number), enabledCount: expect.any(Number), locked: expect.any(Boolean) },
|
|
},
|
|
automation: {
|
|
autoHeal: { total: expect.any(Number), enabled: expect.any(Number) },
|
|
autoUpdate: { total: expect.any(Number), enabled: expect.any(Number) },
|
|
scheduledTasks: { total: expect.any(Number), enabled: expect.any(Number), locked: expect.any(Boolean) },
|
|
webhooks: { total: expect.any(Number), enabled: expect.any(Number), locked: expect.any(Boolean) },
|
|
},
|
|
security: { scanPolicies: { total: expect.any(Number), enabled: expect.any(Number), locked: expect.any(Boolean) } },
|
|
thresholds: expect.any(Object),
|
|
backup: { provider: expect.any(String), autoUpload: expect.any(Boolean), locked: expect.any(Boolean) },
|
|
});
|
|
});
|
|
|
|
it('keeps every freed row unlocked for Community', async () => {
|
|
vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValue('community');
|
|
|
|
const res = await request(app).get('/api/dashboard/configuration').set('Cookie', adminCookie);
|
|
expect(res.status).toBe(200);
|
|
// routing rules, webhooks, scheduled tasks, and scan policies are all free.
|
|
expect(res.body.notifications.routingRules.locked).toBe(false);
|
|
expect(res.body.automation.webhooks.locked).toBe(false);
|
|
expect(res.body.automation.scheduledTasks.locked).toBe(false);
|
|
expect(res.body.security.scanPolicies.locked).toBe(false);
|
|
});
|
|
|
|
it('unlocks every gated row for the paid tier', async () => {
|
|
// The beforeEach already sets the paid tier; reassert for clarity.
|
|
vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValue('paid');
|
|
|
|
const res = await request(app).get('/api/dashboard/configuration').set('Cookie', adminCookie);
|
|
expect(res.status).toBe(200);
|
|
expect(res.body.notifications.routingRules.locked).toBe(false);
|
|
expect(res.body.automation.webhooks.locked).toBe(false);
|
|
expect(res.body.automation.scheduledTasks.locked).toBe(false);
|
|
expect(res.body.security.scanPolicies.locked).toBe(false);
|
|
});
|
|
|
|
it('counts autoUpdate as enabled action=update scheduled tasks targeting this node, not other actions', async () => {
|
|
const db = DatabaseService.getInstance();
|
|
const now = Date.now();
|
|
const nodeId = 1;
|
|
const baseTask = {
|
|
created_by: 'admin',
|
|
created_at: now,
|
|
updated_at: now,
|
|
last_run_at: null,
|
|
next_run_at: now + 3600_000,
|
|
last_status: null,
|
|
last_error: null,
|
|
prune_targets: null,
|
|
target_services: null,
|
|
prune_label_filter: null, selector_type: null, selector_value: null,
|
|
};
|
|
const idA = db.createScheduledTask({
|
|
...baseTask,
|
|
name: 'au-on',
|
|
target_type: 'stack',
|
|
target_id: 'app1',
|
|
node_id: nodeId,
|
|
action: 'update',
|
|
cron_expression: '0 3 * * *',
|
|
enabled: 1,
|
|
});
|
|
const idB = db.createScheduledTask({
|
|
...baseTask,
|
|
name: 'au-off',
|
|
target_type: 'stack',
|
|
target_id: 'app2',
|
|
node_id: nodeId,
|
|
action: 'update',
|
|
cron_expression: '0 3 * * *',
|
|
enabled: 0,
|
|
});
|
|
const idC = db.createScheduledTask({
|
|
...baseTask,
|
|
name: 'scan-row',
|
|
target_type: 'system',
|
|
target_id: null,
|
|
node_id: nodeId,
|
|
action: 'scan',
|
|
cron_expression: '0 3 * * *',
|
|
enabled: 1,
|
|
});
|
|
const idD = db.createScheduledTask({
|
|
...baseTask,
|
|
name: 'au-other-node',
|
|
target_type: 'stack',
|
|
target_id: 'app3',
|
|
node_id: 999,
|
|
action: 'update',
|
|
cron_expression: '0 3 * * *',
|
|
enabled: 1,
|
|
});
|
|
try {
|
|
const res = await request(app).get('/api/dashboard/configuration').set('Cookie', adminCookie);
|
|
expect(res.status).toBe(200);
|
|
// Two update rows on node 1 (one enabled, one disabled); the scan row
|
|
// and the update row on node 999 must not leak into the count.
|
|
expect(res.body.automation.autoUpdate.total).toBe(2);
|
|
expect(res.body.automation.autoUpdate.enabled).toBe(1);
|
|
} finally {
|
|
db.deleteScheduledTask(idA);
|
|
db.deleteScheduledTask(idB);
|
|
db.deleteScheduledTask(idC);
|
|
db.deleteScheduledTask(idD);
|
|
}
|
|
});
|
|
|
|
it('does not leak agent URLs, tokens, or other secret material in the response', async () => {
|
|
// Seed a node-1 agent with a Discord URL so the configuration path
|
|
// exercises the `configured` truthy branch. The URL must never appear
|
|
// anywhere in the JSON response.
|
|
const SECRET_URL = 'https://discord.example.invalid/webhook/SECRET-SHOULD-NEVER-LEAK';
|
|
const db = DatabaseService.getInstance();
|
|
db.upsertAgent(1, { type: 'discord', url: SECRET_URL, enabled: true });
|
|
|
|
try {
|
|
const res = await request(app).get('/api/dashboard/configuration').set('Cookie', adminCookie);
|
|
expect(res.status).toBe(200);
|
|
const serialized = JSON.stringify(res.body);
|
|
expect(serialized).not.toContain('SECRET-SHOULD-NEVER-LEAK');
|
|
expect(serialized).not.toContain('discord.example.invalid');
|
|
// The agent's `configured` flag is what the dashboard renders;
|
|
// confirm it surfaced so the test proves it walked the right
|
|
// branch.
|
|
expect(res.body.notifications.agents.discord.configured).toBe(true);
|
|
} finally {
|
|
db.getDb().prepare('DELETE FROM agents WHERE url = ?').run(SECRET_URL);
|
|
}
|
|
});
|
|
|
|
it('scopes alertRules to stacks on the active node for dashboard and fleet local row', async () => {
|
|
const db = DatabaseService.getInstance();
|
|
const composeDir = process.env.COMPOSE_DIR as string;
|
|
const stackName = 'cfg-alert-scope';
|
|
const stackDir = path.join(composeDir, stackName);
|
|
const alertIds: number[] = [];
|
|
|
|
fs.mkdirSync(stackDir, { recursive: true });
|
|
fs.writeFileSync(
|
|
path.join(stackDir, 'compose.yaml'),
|
|
'services:\n web:\n image: nginx:latest\n',
|
|
);
|
|
|
|
const baseAlert = {
|
|
service_name: null as string | null,
|
|
metric: 'cpu_percent',
|
|
operator: '>',
|
|
threshold: 80,
|
|
duration_mins: 5,
|
|
cooldown_mins: 15,
|
|
last_fired_at: 0,
|
|
};
|
|
|
|
try {
|
|
// Two rules on a discovered stack (exact rule count, not unique stacks)
|
|
// plus one orphaned rule that must not inflate the scoped count.
|
|
alertIds.push(db.addStackAlert({ ...baseAlert, stack_name: stackName, threshold: 80 }).id!);
|
|
alertIds.push(db.addStackAlert({ ...baseAlert, stack_name: stackName, threshold: 90 }).id!);
|
|
alertIds.push(db.addStackAlert({ ...baseAlert, stack_name: 'cfg-alert-orphan', threshold: 70 }).id!);
|
|
|
|
const dash = await request(app).get('/api/dashboard/configuration').set('Cookie', adminCookie);
|
|
expect(dash.status).toBe(200);
|
|
expect(dash.body.notifications.alertRules).toBe(2);
|
|
|
|
const fleet = await request(app).get('/api/fleet/configuration').set('Cookie', adminCookie);
|
|
expect(fleet.status).toBe(200);
|
|
expect(Array.isArray(fleet.body)).toBe(true);
|
|
const localRow = fleet.body.find(
|
|
(row: { type: string; configuration: { notifications: { alertRules: number } } | null }) =>
|
|
row.type === 'local' && row.configuration != null,
|
|
);
|
|
expect(localRow).toBeDefined();
|
|
expect(localRow.configuration.notifications.alertRules).toBe(2);
|
|
} finally {
|
|
for (const id of alertIds) {
|
|
db.deleteStackAlert(id);
|
|
}
|
|
fs.rmSync(stackDir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('GET /api/dashboard/stack-restarts', () => {
|
|
it('rejects unauthenticated requests with 401', async () => {
|
|
const res = await request(app).get('/api/dashboard/stack-restarts');
|
|
expect(res.status).toBe(401);
|
|
});
|
|
|
|
it('returns an array for authenticated requests', async () => {
|
|
const res = await request(app).get('/api/dashboard/stack-restarts').set('Cookie', adminCookie);
|
|
expect(res.status).toBe(200);
|
|
expect(Array.isArray(res.body)).toBe(true);
|
|
});
|
|
|
|
it('clamps days=0 to the 7-day default', async () => {
|
|
const res = await request(app).get('/api/dashboard/stack-restarts?days=0').set('Cookie', adminCookie);
|
|
expect(res.status).toBe(200);
|
|
// Cannot easily observe the clamped value from the response shape, but
|
|
// a 200 with an array proves the route did not bail on the invalid
|
|
// input.
|
|
expect(Array.isArray(res.body)).toBe(true);
|
|
});
|
|
|
|
it('clamps days=999 to the 30-day ceiling', async () => {
|
|
const res = await request(app).get('/api/dashboard/stack-restarts?days=999').set('Cookie', adminCookie);
|
|
expect(res.status).toBe(200);
|
|
expect(Array.isArray(res.body)).toBe(true);
|
|
});
|
|
|
|
it('falls back to the 7-day default for a non-numeric days value', async () => {
|
|
const res = await request(app).get('/api/dashboard/stack-restarts?days=banana').set('Cookie', adminCookie);
|
|
expect(res.status).toBe(200);
|
|
expect(Array.isArray(res.body)).toBe(true);
|
|
});
|
|
});
|