feat(api): integrate identity, notification scope and connection settings into control plane
- Wire identity, metrics and update services into the assembled API. - Add connection authorization modes and scoped console authorization. - Preserve notification group IDs for filtered delivery. - Extend maintenance, backup, resource and module flows with runtime metadata and upstream safety.
This commit is contained in:
@@ -13,6 +13,7 @@ export interface CentralNotificationRoutesOptions {
|
||||
readonly name: string;
|
||||
readonly state: string;
|
||||
readonly tags: readonly string[];
|
||||
readonly groupId: string | null;
|
||||
}[]
|
||||
>;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import Database from 'better-sqlite3';
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
import { Writable } from 'node:stream';
|
||||
|
||||
import { buildApp } from '../../app.js';
|
||||
import { ConsoleAuthService } from '../../application/auth/console-auth-service.js';
|
||||
@@ -15,18 +16,32 @@ function fixture() {
|
||||
db.pragma('foreign_keys = ON');
|
||||
migrateDatabase(db);
|
||||
dbs.push(db);
|
||||
let now = new Date('2026-07-19T00:00:00.000Z');
|
||||
const auth = new ConsoleAuthService({ db, now: () => now });
|
||||
const clock = { now: new Date('2026-07-19T00:00:00.000Z') };
|
||||
const auth = new ConsoleAuthService({ db, now: () => clock.now });
|
||||
let logs = '';
|
||||
const app = buildApp({
|
||||
logger: {
|
||||
stream: new (class extends Writable {
|
||||
override write(chunk: Buffer | string): boolean {
|
||||
logs += chunk.toString();
|
||||
return true;
|
||||
}
|
||||
})(),
|
||||
},
|
||||
registerRoutes: (fastify) => {
|
||||
registerConsoleAuth(fastify, auth);
|
||||
fastify.get('/api/v1/protected-probe', async () => ({ ok: true }));
|
||||
},
|
||||
});
|
||||
const advance = (milliseconds: number) => {
|
||||
clock.now = new Date(clock.now.getTime() + milliseconds);
|
||||
};
|
||||
return {
|
||||
app,
|
||||
db,
|
||||
auth,
|
||||
advance: (milliseconds: number) => (now = new Date(now.getTime() + milliseconds)),
|
||||
advance,
|
||||
logs: () => logs,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -44,6 +59,7 @@ describe('aggregate-console password protection HTTP boundary', () => {
|
||||
configured: false,
|
||||
protectionEnabled: false,
|
||||
authenticated: true,
|
||||
sessionIdleTimeoutMinutes: 30,
|
||||
});
|
||||
expect(JSON.stringify(response.json())).not.toMatch(/hash|salt|password/i);
|
||||
await app.close();
|
||||
@@ -225,6 +241,50 @@ describe('aggregate-console password protection HTTP boundary', () => {
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it('expires sessions after configurable browser-idle inactivity', async () => {
|
||||
const { app, auth, advance, logs } = fixture();
|
||||
const enable = await app.inject({
|
||||
method: 'PUT',
|
||||
url: '/api/v1/auth/settings',
|
||||
payload: { enabled: true, newPassword: PASSWORD },
|
||||
});
|
||||
expect(enable.statusCode).toBe(200);
|
||||
expect(enable.json()).toMatchObject({ sessionIdleTimeoutMinutes: 30 });
|
||||
|
||||
const login = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/auth/login',
|
||||
payload: { password: PASSWORD },
|
||||
});
|
||||
const cookie = String(login.headers['set-cookie']).split(';', 1)[0] ?? '';
|
||||
expect(
|
||||
(await app.inject({ method: 'GET', url: '/api/v1/auth/status', headers: { cookie } })).json(),
|
||||
).toMatchObject({ authenticated: true, sessionIdleTimeoutMinutes: 30 });
|
||||
|
||||
const updated = await app.inject({
|
||||
method: 'PUT',
|
||||
url: '/api/v1/auth/settings',
|
||||
headers: { cookie },
|
||||
payload: { enabled: true, idleTimeoutMinutes: 10 },
|
||||
});
|
||||
expect(updated.statusCode).toBe(200);
|
||||
expect(updated.json()).toMatchObject({ sessionIdleTimeoutMinutes: 10 });
|
||||
expect(auth.status().sessionIdleTimeoutMinutes).toBe(10);
|
||||
|
||||
advance(9 * 60 * 1000);
|
||||
expect(
|
||||
(await app.inject({ method: 'GET', url: '/api/v1/protected-probe', headers: { cookie } }))
|
||||
.statusCode,
|
||||
).toBe(200);
|
||||
advance(11 * 60 * 1000);
|
||||
expect(
|
||||
(await app.inject({ method: 'GET', url: '/api/v1/auth/status', headers: { cookie } })).json(),
|
||||
).toMatchObject({ authenticated: false });
|
||||
expect(logs()).not.toContain('request failed');
|
||||
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it('rejects anonymous password replacement while a stored credential is disabled', async () => {
|
||||
const { app } = fixture();
|
||||
const enable = await app.inject({
|
||||
|
||||
@@ -73,6 +73,7 @@ const settingsSchema = {
|
||||
properties: {
|
||||
enabled: { type: 'boolean' },
|
||||
newPassword: { type: 'string', minLength: 1, maxLength: 1024 },
|
||||
idleTimeoutMinutes: { type: 'number', minimum: 5, maximum: 1440, multipleOf: 1 },
|
||||
},
|
||||
} as const;
|
||||
const loginSchema = {
|
||||
@@ -88,7 +89,9 @@ export function registerConsoleAuth(app: FastifyInstance, auth: ConsoleAuthServi
|
||||
const pathname = request.url.split(/[?#]/u, 1)[0] ?? request.url;
|
||||
if (!auth.isProtectedPath(pathname)) return;
|
||||
try {
|
||||
auth.requireSession(parseCookie(request));
|
||||
const token = parseCookie(request);
|
||||
auth.requireSession(token);
|
||||
auth.touchSession(token);
|
||||
} catch (error) {
|
||||
return handle(request, reply, error);
|
||||
}
|
||||
@@ -133,7 +136,11 @@ export function registerConsoleAuth(app: FastifyInstance, auth: ConsoleAuthServi
|
||||
|
||||
app.put('/api/v1/auth/settings', { schema: { body: settingsSchema } }, async (request, reply) => {
|
||||
try {
|
||||
const body = request.body as { enabled: boolean; newPassword?: string };
|
||||
const body = request.body as {
|
||||
enabled: boolean;
|
||||
newPassword?: string;
|
||||
idleTimeoutMinutes?: number;
|
||||
};
|
||||
const result = await auth.updateSettings(body, parseCookie(request));
|
||||
if (result.sessionToken)
|
||||
reply.header('Set-Cookie', cookie(result.sessionToken, usesSecureCookie(request)));
|
||||
|
||||
@@ -169,6 +169,8 @@ describe('system maintenance routes', () => {
|
||||
expect(catalog.json().items).toEqual(
|
||||
expect.arrayContaining([expect.objectContaining({ key: 'devices', rows: 1 })]),
|
||||
);
|
||||
expect(typeof catalog.json().directory).toBe('string');
|
||||
expect(catalog.json().directory.length).toBeGreaterThan(0);
|
||||
|
||||
const created = await app.inject({
|
||||
method: 'POST',
|
||||
@@ -188,6 +190,15 @@ describe('system maintenance routes', () => {
|
||||
expect(preview.statusCode, preview.body).toBe(200);
|
||||
expect(preview.json()).toMatchObject({ integrity: 'ok', note: '升级前' });
|
||||
|
||||
const download = await app.inject({
|
||||
method: 'GET',
|
||||
url: `/api/v1/system/component-backups/${encodeURIComponent(filename)}/download`,
|
||||
});
|
||||
expect(download.statusCode, download.body).toBe(200);
|
||||
expect(download.headers['content-disposition']).toBe(`attachment; filename="${filename}"`);
|
||||
expect(download.headers['content-type']).toBe('application/octet-stream');
|
||||
expect(Number(download.headers['content-length'])).toBe(download.rawPayload.length);
|
||||
|
||||
db.prepare('DELETE FROM device_groups').run();
|
||||
const restored = await app.inject({
|
||||
method: 'POST',
|
||||
@@ -195,16 +206,32 @@ describe('system maintenance routes', () => {
|
||||
payload: { components: ['devices'] },
|
||||
});
|
||||
expect(restored.statusCode, restored.body).toBe(200);
|
||||
expect(restored.json()).toMatchObject({ devices: 1 });
|
||||
const restoredBody = restored.json() as {
|
||||
written: Record<string, number>;
|
||||
safetyBackup: string;
|
||||
};
|
||||
expect(restoredBody.written).toEqual({ devices: 1 });
|
||||
expect(restoredBody.safetyBackup.length).toBeGreaterThan(0);
|
||||
expect(
|
||||
(db.prepare('SELECT COUNT(*) AS count FROM device_groups').get() as { count: number }).count,
|
||||
).toBe(1);
|
||||
|
||||
const removed = await app.inject({
|
||||
method: 'DELETE',
|
||||
url: `/api/v1/system/component-backups/${encodeURIComponent(filename)}`,
|
||||
// The pre-restore snapshot is a normal backup file, listed and downloadable like any other.
|
||||
const afterRestore = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/v1/system/component-backups',
|
||||
});
|
||||
expect(removed.statusCode).toBe(200);
|
||||
const names = (afterRestore.json().items as { filename: string; note: string }[]).map(
|
||||
(item) => item.note,
|
||||
);
|
||||
expect(names.some((note) => note.startsWith('恢复前自动备份:'))).toBe(true);
|
||||
|
||||
for (const name of [filename, restoredBody.safetyBackup]) {
|
||||
const removed = await app.inject({
|
||||
method: 'DELETE',
|
||||
url: `/api/v1/system/component-backups/${encodeURIComponent(name)}`,
|
||||
});
|
||||
expect(removed.statusCode).toBe(200);
|
||||
}
|
||||
expect(
|
||||
(await app.inject({ method: 'GET', url: '/api/v1/system/component-backups' })).json(),
|
||||
).toEqual({
|
||||
@@ -257,16 +284,28 @@ describe('connection settings routes', () => {
|
||||
const { app, connectionSettings } = fixture();
|
||||
const read = await app.inject({ method: 'GET', url: '/api/v1/system/connection' });
|
||||
expect(read.statusCode, read.body).toBe(200);
|
||||
expect(read.json()).toEqual({ heartbeatSeconds: 30, offlineSeconds: 90 });
|
||||
expect(read.json()).toEqual({
|
||||
heartbeatSeconds: 30,
|
||||
offlineSeconds: 90,
|
||||
authorizationMode: 'auto',
|
||||
});
|
||||
|
||||
const updated = await app.inject({
|
||||
method: 'PUT',
|
||||
url: '/api/v1/system/connection',
|
||||
payload: { heartbeatSeconds: 45, offlineSeconds: 120 },
|
||||
payload: { heartbeatSeconds: 45, offlineSeconds: 120, authorizationMode: 'manual' },
|
||||
});
|
||||
expect(updated.statusCode, updated.body).toBe(200);
|
||||
expect(updated.json()).toEqual({ heartbeatSeconds: 45, offlineSeconds: 120 });
|
||||
expect(connectionSettings.get()).toEqual({ heartbeatSeconds: 45, offlineSeconds: 120 });
|
||||
expect(updated.json()).toEqual({
|
||||
heartbeatSeconds: 45,
|
||||
offlineSeconds: 120,
|
||||
authorizationMode: 'manual',
|
||||
});
|
||||
expect(connectionSettings.get()).toEqual({
|
||||
heartbeatSeconds: 45,
|
||||
offlineSeconds: 120,
|
||||
authorizationMode: 'manual',
|
||||
});
|
||||
expect(connectionSettings.snapshotTtlMs).toBe(120_000);
|
||||
|
||||
const refresh = await app.inject({ method: 'POST', url: '/api/v1/system/connection/refresh' });
|
||||
@@ -295,6 +334,7 @@ describe('connection settings routes', () => {
|
||||
expect((await app.inject({ method: 'GET', url: '/api/v1/system/connection' })).json()).toEqual({
|
||||
heartbeatSeconds: 30,
|
||||
offlineSeconds: 90,
|
||||
authorizationMode: 'auto',
|
||||
});
|
||||
await app.close();
|
||||
});
|
||||
|
||||
@@ -161,7 +161,7 @@ export function registerSystemRoutes(app: FastifyInstance, options: SystemRoutes
|
||||
|
||||
app.get(
|
||||
'/api/v1/system/component-backups/catalog',
|
||||
wrap(async () => ({ items: backups().catalog() })),
|
||||
wrap(async () => ({ items: backups().catalog(), directory: backups().directory() })),
|
||||
);
|
||||
app.get(
|
||||
'/api/v1/system/component-backups/auto/settings',
|
||||
@@ -196,6 +196,26 @@ export function registerSystemRoutes(app: FastifyInstance, options: SystemRoutes
|
||||
'/api/v1/system/component-backups/:filename/preview',
|
||||
wrap(async (request) => backups().preview((request.params as { filename: string }).filename)),
|
||||
);
|
||||
app.get(
|
||||
'/api/v1/system/component-backups/:filename/download',
|
||||
wrap(async (request, reply) => {
|
||||
const backup = await backups().backupFile((request.params as { filename: string }).filename);
|
||||
if (!backup)
|
||||
return reply.code(404).type('application/problem+json').send({
|
||||
type: 'about:blank',
|
||||
title: 'Not Found',
|
||||
status: 404,
|
||||
code: 'COMPONENT_BACKUP_NOT_FOUND',
|
||||
detail: 'The requested component backup file no longer exists.',
|
||||
requestId: request.id,
|
||||
});
|
||||
reply
|
||||
.header('content-disposition', `attachment; filename="${backup.filename}"`)
|
||||
.header('content-length', String(backup.sizeBytes))
|
||||
.type('application/octet-stream');
|
||||
return createReadStream(backup.path);
|
||||
}),
|
||||
);
|
||||
app.post(
|
||||
'/api/v1/system/component-backups/:filename/restore',
|
||||
{ bodyLimit: 8_192 },
|
||||
|
||||
Reference in New Issue
Block a user