mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-12 19:57:37 +00:00
adcd04b01a
* refactor(auto-update): retire per-stack gate, drive auto-update from schedules only
The per-stack Auto-update toggle in the stack sidebar context menu wrote a
gate row to `stack_auto_update_settings`, but actual updates only ran when a
`scheduled_tasks` row with `action='update'` fired. On a fresh install the
toggle was inert: detection ran every 6h, nothing was applied.
The same context menu already exposes `Schedule task`, which opens
ScheduledOperationsView pre-filled for the stack where the user can pick
`Auto-update Stack` and any cron. Keeping the toggle alongside that flow
duplicated the same action and turned the gate table into a parallel store
of "is a covering schedule active" derivable from `scheduled_tasks` itself.
Drop the gate model entirely:
- Backend: remove the `stack_auto_update_settings` table and its four
accessors, the three routes under /api/stacks/*/auto-update, the per-stack
skip in /api/auto-update/execute and SchedulerService.executeUpdate's
fleet branch, and the clearStackAutoUpdateSetting call on stack delete.
Dashboard `autoUpdate` count derives from scheduled_tasks (action='update'
rows pinned to the node, total/enabled split).
- Frontend: drop the Auto-update entry from the sidebar context menu and its
optimistic toggle plumbing. Drop autoUpdateSettings state, the
/stacks/auto-update-settings fetch, and the auto-update-settings-changed
WebSocket branch. Slim useSidebarActivitySummary (just nextRunAt; no
enabled/total counts). AutoUpdateReadinessView's per-card autoUpdateEnabled
now means "a covering enabled action='update' schedule exists" (per-stack
row or fleet row on this node, earliest next_run_at wins, per-stack row
wins on ties), with the gate-fetch removed.
- New: scheduledTasksRouter broadcasts scope: 'scheduled-tasks' on POST,
PUT, PATCH /toggle, and DELETE so useConfigurationStatus and
useNextAutoUpdateRun refetch under the 250ms debounce instead of waiting
for the 60s poll. The broadcast is wrapped so a broken subscriber socket
cannot turn a successful mutation into a 500.
- Docs: rewrite the "Per-stack control" section of auto-update-policies.mdx
to describe the schedule-based model; update the matching troubleshooting
entry. The misleading fleet-update help text in ScheduledOperationsView
is corrected to reflect that every stack on the node is covered.
Tier parity: the surviving auto-update path (Schedule task -> Auto-update
Stack / All Stacks) is gated `requirePaid + requireAdmin` backend and
`isPaid + isAdmin` frontend, matching the gate the deleted routes carried.
The pre-commit grep returns no tier-related diff outside this PR's scope.
No data migration is provided: greenfield rules apply, and the leftover
table on already-shipped instances is harmless because no code reads or
writes it after this PR.
* docs: sweep remaining references to the per-stack auto-update toggle
The previous commit retired the per-stack Auto-update gate in favor of
configuring auto-update purely through scheduled tasks. This commit
removes the now-stale mentions of that toggle across the operator docs:
- docs/features/sidebar.mdx: drop the Auto-update entry from the Inspect
group description, the matching screenshot alt-text, and the Skipper
Note that listed it. Schedule task now carries the cross-link to
Auto-Update Policies.
- docs/features/stack-management.mdx: drop the Auto-update list item;
refresh the Schedule task entry to mention the Auto-update Stack action.
- docs/features/dashboard.mdx: rename the Configuration Status row from
"Auto-update stacks" to "Auto-update schedules" with the new value
shape, and rewrite the troubleshooting accordion to describe the
scheduled-tasks invalidation path.
- docs/features/scheduled-operations.mdx: rewrite the Auto-update All
Stacks row and helper text to reflect that every stack on the node is
covered (no per-stack opt-out from this surface anymore).
- docs/features/multi-node.mdx: rewrite the Updates column definition to
derive the Auto/Off flag from enabled Auto-update Stack / Auto-update
All Stacks schedules instead of the removed per-stack policy.
The auto-update-policies.mdx rewrite in the previous commit already
covered the main reference page. The sidebar-context-menu.png screenshot
will be refreshed on release once the new menu is live in production;
the alt text is updated in this commit so it accurately describes the
shipping state.
No website edits needed: the Auto-Update Policies feature card description
("Schedule automatic image pulls and redeployments per stack on your own
cadence") and the feature matrix labels ("Auto-update stack schedule",
"Auto-update all stacks schedule") remain accurate under the new model.
* fix(stacks): drop orphaned requireAdmin import after auto-update route removal
CI's backend lint step flagged this PR's earlier deletion of the three
/api/stacks/*/auto-update routes: those handlers were the only callers of
`requireAdmin` inside routes/stacks.ts, leaving the named import on line 15
unreferenced. `requirePaid` and `effectiveTier` from the same line are still
in use elsewhere in the file and stay.
tsc --noEmit does not flag unused named imports; ESLint's no-unused-vars
does. Local backend lint reproduces and now reports 0 errors against the
existing 334-warning baseline.
243 lines
10 KiB
TypeScript
243 lines
10 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 Community, Skipper, and
|
|
* Admiral personas (toggled via LicenseService spies).
|
|
* - 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 { 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 a paid+admiral 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');
|
|
vi.spyOn(LicenseService.getInstance(), 'getVariant').mockReturnValue('admiral');
|
|
vi.spyOn(LicenseService.getInstance(), 'getSeatLimits').mockReturnValue({ maxAdmins: null, maxViewers: null });
|
|
|
|
({ app } = await import('../index'));
|
|
adminCookie = await loginAsTestAdmin(app);
|
|
});
|
|
|
|
afterAll(() => cleanupTestDb(tmpDir));
|
|
|
|
beforeEach(() => {
|
|
// Reset to the default Admiral baseline before each test; individual
|
|
// tests below re-spy as needed for Community/Skipper personas.
|
|
vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValue('paid');
|
|
vi.spyOn(LicenseService.getInstance(), 'getVariant').mockReturnValue('admiral');
|
|
});
|
|
|
|
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) } },
|
|
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('flags routingRules / webhooks / scheduledTasks / scanPolicies as locked for Community', async () => {
|
|
vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValue('community');
|
|
vi.spyOn(LicenseService.getInstance(), 'getVariant').mockReturnValue(null);
|
|
|
|
const res = await request(app).get('/api/dashboard/configuration').set('Cookie', adminCookie);
|
|
expect(res.status).toBe(200);
|
|
expect(res.body.notifications.routingRules.locked).toBe(true);
|
|
expect(res.body.automation.webhooks.locked).toBe(true);
|
|
expect(res.body.automation.scheduledTasks.locked).toBe(true);
|
|
expect(res.body.security.scanPolicies.locked).toBe(true);
|
|
});
|
|
|
|
it('unlocks paid-tier rows but keeps Admiral-only rows locked for Skipper', async () => {
|
|
vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValue('paid');
|
|
vi.spyOn(LicenseService.getInstance(), 'getVariant').mockReturnValue('skipper');
|
|
|
|
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.security.scanPolicies.locked).toBe(false);
|
|
// Scheduled tasks remain Admiral-only.
|
|
expect(res.body.automation.scheduledTasks.locked).toBe(true);
|
|
});
|
|
|
|
it('unlocks every gated row for Admiral', async () => {
|
|
// The beforeEach already sets Admiral; reassert for clarity.
|
|
vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValue('paid');
|
|
vi.spyOn(LicenseService.getInstance(), 'getVariant').mockReturnValue('admiral');
|
|
|
|
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,
|
|
};
|
|
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);
|
|
}
|
|
});
|
|
});
|
|
|
|
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);
|
|
});
|
|
});
|