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:
@@ -397,6 +397,7 @@ describe('buildControlPlaneApp', () => {
|
||||
checkedAt: null,
|
||||
},
|
||||
resources: {},
|
||||
identity: { tracked: false, status: 'confirmed', reasons: [] },
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -1296,4 +1297,132 @@ describe('buildControlPlaneApp', () => {
|
||||
expect(unbinds).toEqual([`http://192.168.1.20:8080/api/hub/unbind`]);
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it('holds controls for two nodes that claim the same device identity', async () => {
|
||||
const db = new Database(':memory:');
|
||||
db.pragma('foreign_keys=ON');
|
||||
migrateDatabase(db);
|
||||
dbs.push(db);
|
||||
const imei = '860000000000007';
|
||||
const app = buildControlPlaneApp({
|
||||
db,
|
||||
store: new Store(),
|
||||
upstream: {
|
||||
get: async () => ({ status: 200, headers: {}, body: '' }),
|
||||
request: async ({ url, method }) => {
|
||||
if (url.endsWith('/api/device'))
|
||||
return {
|
||||
status: 200,
|
||||
headers: {},
|
||||
body: JSON.stringify({ imei, model: 'RM500Q', manufacturer: 'Quectel' }),
|
||||
};
|
||||
return {
|
||||
status: method === 'POST' ? 200 : 404,
|
||||
headers: {},
|
||||
body: '{"status":"success"}',
|
||||
};
|
||||
},
|
||||
postNetworkRegisterAuto: async () => ({ status: 200 }),
|
||||
postServiceRestart: async () => ({ status: 200 }),
|
||||
postBasebandRestart: async () => ({ status: 200 }),
|
||||
postSystemReboot: async () => ({ status: 200 }),
|
||||
},
|
||||
});
|
||||
const created: string[] = [];
|
||||
for (const host of ['192.168.1.30', '192.168.1.31']) {
|
||||
const response = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/instances',
|
||||
payload: { name: `节点 ${host}`, origin: `http://${host}:8080` },
|
||||
});
|
||||
created.push(response.json().id as string);
|
||||
}
|
||||
const [first, second] = created;
|
||||
|
||||
// Reading the overview is enough: the device reports its own hardware and the guard notices
|
||||
// that two records now answer with one IMEI.
|
||||
for (const id of created) {
|
||||
const snapshot = await app.inject(`/api/v1/instances/${id}/modules/overview`);
|
||||
expect(snapshot.statusCode).toBe(200);
|
||||
}
|
||||
|
||||
const listed = await app.inject('/api/v1/fleet/identities');
|
||||
expect(listed.json()).toMatchObject({
|
||||
summary: { tracked: 2, pending: 2 },
|
||||
items: [
|
||||
{ instanceId: first, reasons: ['imei_claimed'] },
|
||||
{ instanceId: second, reasons: ['imei_claimed'] },
|
||||
],
|
||||
});
|
||||
|
||||
const blocked = await app.inject({
|
||||
method: 'POST',
|
||||
url: `/api/v1/instances/${first}/device-actions/call.waiting`,
|
||||
payload: { confirm: true, params: { value: 'enabled' } },
|
||||
});
|
||||
expect(blocked.statusCode).toBe(409);
|
||||
expect(blocked.json()).toMatchObject({ code: 'IDENTITY_UNCONFIRMED' });
|
||||
|
||||
const overview = await app.inject('/api/v1/fleet/overview');
|
||||
expect(overview.json().items[0].identity).toEqual({
|
||||
tracked: true,
|
||||
status: 'pending',
|
||||
reasons: ['imei_claimed'],
|
||||
});
|
||||
|
||||
// The operator vouches for one node; the other keeps waiting.
|
||||
const confirmed = await app.inject({
|
||||
method: 'POST',
|
||||
url: `/api/v1/fleet/identities/${first}/confirm`,
|
||||
});
|
||||
expect(confirmed.json()).toMatchObject({
|
||||
identity: { instanceId: first, status: 'confirmed', reasons: [] },
|
||||
});
|
||||
|
||||
const allowed = await app.inject({
|
||||
method: 'POST',
|
||||
url: `/api/v1/instances/${first}/device-actions/call.waiting`,
|
||||
payload: { confirm: true, params: { value: 'enabled' } },
|
||||
});
|
||||
expect(allowed.statusCode).toBe(200);
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it('scrapes the fused fleet through the metrics endpoint', async () => {
|
||||
const db = new Database(':memory:');
|
||||
db.pragma('foreign_keys=ON');
|
||||
migrateDatabase(db);
|
||||
dbs.push(db);
|
||||
const app = buildControlPlaneApp({
|
||||
db,
|
||||
store: new Store(),
|
||||
runtimeVersion: '9.9.9',
|
||||
upstream: {
|
||||
get: async () => ({ status: 200, headers: {}, body: '' }),
|
||||
request: async ({ method }) => ({
|
||||
status: method === 'POST' ? 200 : 404,
|
||||
headers: {},
|
||||
body: '{"status":"success"}',
|
||||
}),
|
||||
postNetworkRegisterAuto: async () => ({ status: 200 }),
|
||||
postServiceRestart: async () => ({ status: 200 }),
|
||||
postBasebandRestart: async () => ({ status: 200 }),
|
||||
postSystemReboot: async () => ({ status: 200 }),
|
||||
},
|
||||
});
|
||||
const created = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/instances',
|
||||
payload: { name: '节点 A', origin: 'http://192.168.1.40:8080' },
|
||||
});
|
||||
expect(created.statusCode).toBe(201);
|
||||
|
||||
const response = await app.inject({ method: 'GET', url: '/api/v1/metrics' });
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.headers['content-type']).toContain('text/plain');
|
||||
expect(response.body).toContain('multi_simadmin_build_info{version="9.9.9"} 1');
|
||||
expect(response.body).toContain('multi_simadmin_nodes_total 1');
|
||||
expect(response.body).toContain('multi_simadmin_notification_queue{status="pending"} 0');
|
||||
await app.close();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user