311 lines
12 KiB
JavaScript
311 lines
12 KiB
JavaScript
/**
|
|
* Route tests for network-people routes (DC-141).
|
|
* Coverage:
|
|
* - contract for all verbs (GET list, GET one, POST, PATCH, DELETE, POST /sync)
|
|
* - first-person-admin bootstrap through the API
|
|
* - 404s for unknown ids
|
|
* - validation errors (400) for bad bodies
|
|
* - DELETE refuses the last active admin (409)
|
|
* - sync: mocked provider merge, idempotency, protectedAt never
|
|
* downgraded, blocked/removed not resurrected, missing registry →
|
|
* clean inactive response
|
|
* Hermetic — supertest against an in-process express app, no network.
|
|
*/
|
|
|
|
'use strict';
|
|
|
|
const express = require('express');
|
|
const request = require('supertest');
|
|
const fs = require('fs');
|
|
const path = require('path');
|
|
const os = require('os');
|
|
const { createPeopleStore, ADMIN_GROUP } = require('../src/security/network-people-store');
|
|
const networkPeopleRoutes = require('../routes/network-people');
|
|
|
|
function asyncHandler(fn) {
|
|
return (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next);
|
|
}
|
|
|
|
function createApp({ peopleStore, networkRegistry } = {}) {
|
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'dashcaddy-peopleroutes-'));
|
|
const store = peopleStore || createPeopleStore({ dataDir: dir });
|
|
const factory = networkPeopleRoutes;
|
|
const router = factory({
|
|
peopleStore: store,
|
|
networkRegistry,
|
|
asyncHandler,
|
|
log: { info: () => {}, warn: () => {}, error: () => {} },
|
|
});
|
|
const app = express();
|
|
app.use(express.json());
|
|
app.use('/network/people', router);
|
|
app.use((err, _req, res, _next) => {
|
|
const status = err.statusCode || 500;
|
|
res.status(status).json({ success: false, error: err.message, code: err.code || null });
|
|
});
|
|
return { app, store, dir };
|
|
}
|
|
|
|
function _cleanup(dir) {
|
|
try { fs.rmSync(dir, { recursive: true, force: true }); } catch {}
|
|
}
|
|
|
|
function _mockRegistry(people) {
|
|
return {
|
|
getActiveProvider: () => ({
|
|
id: () => 'mock',
|
|
listPeople: () => Promise.resolve(people),
|
|
}),
|
|
};
|
|
}
|
|
|
|
describe('network-people routes: contract', () => {
|
|
let dir, app, store;
|
|
beforeEach(() => {
|
|
({ app, store, dir } = createApp());
|
|
});
|
|
afterEach(() => _cleanup(dir));
|
|
|
|
test('GET / lists people', async () => {
|
|
await store.upsertPerson({ displayName: 'Alice', providerUserId: 'ts-1' });
|
|
const res = await request(app).get('/network/people');
|
|
expect(res.status).toBe(200);
|
|
expect(res.body.success).toBe(true);
|
|
expect(Array.isArray(res.body.data)).toBe(true);
|
|
expect(res.body.count).toBe(1);
|
|
expect(res.body.data[0].displayName).toBe('Alice');
|
|
});
|
|
|
|
test('GET /:id returns a person; 404 for unknown', async () => {
|
|
const p = await store.upsertPerson({ displayName: 'Alice', providerUserId: 'ts-1' });
|
|
const good = await request(app).get(`/network/people/${p.id}`);
|
|
expect(good.status).toBe(200);
|
|
expect(good.body.person.id).toBe(p.id);
|
|
const bad = await request(app).get('/network/people/ghost');
|
|
expect(bad.status).toBe(404);
|
|
expect(bad.body.success).toBe(false);
|
|
});
|
|
|
|
test('POST / creates a person (201)', async () => {
|
|
const res = await request(app)
|
|
.post('/network/people')
|
|
.send({ displayName: 'Alice', providerUserId: 'ts-1' });
|
|
expect(res.status).toBe(201);
|
|
expect(res.body.success).toBe(true);
|
|
expect(res.body.person.displayName).toBe('Alice');
|
|
expect(res.body.person.status).toBe('active');
|
|
});
|
|
|
|
test('POST / first person ever gets Admin group + protectedAt', async () => {
|
|
const res = await request(app)
|
|
.post('/network/people')
|
|
.send({ displayName: 'Sami', providerUserId: 'ts-owner' });
|
|
expect(res.status).toBe(201);
|
|
expect(res.body.person.groups).toEqual([ADMIN_GROUP]);
|
|
expect(res.body.person.protectedAt).toBeTruthy();
|
|
const second = await request(app)
|
|
.post('/network/people')
|
|
.send({ displayName: 'Friend', providerUserId: 'ts-2' });
|
|
expect(second.body.person.groups).toEqual([]);
|
|
expect(second.body.person.protectedAt).toBe(null);
|
|
});
|
|
|
|
test('POST / validation errors: missing displayName (400)', async () => {
|
|
const res = await request(app)
|
|
.post('/network/people')
|
|
.send({ providerUserId: 'ts-1' });
|
|
expect(res.status).toBe(400);
|
|
expect(res.body.success).toBe(false);
|
|
expect(res.body.error).toMatch(/displayName/);
|
|
});
|
|
|
|
test('POST / validation errors: missing providerUserId (400)', async () => {
|
|
const res = await request(app)
|
|
.post('/network/people')
|
|
.send({ displayName: 'Alice' });
|
|
expect(res.status).toBe(400);
|
|
expect(res.body.error).toMatch(/providerUserId/);
|
|
});
|
|
|
|
test('POST / validation errors: groups not an array (400)', async () => {
|
|
const res = await request(app)
|
|
.post('/network/people')
|
|
.send({ displayName: 'Alice', providerUserId: 'ts-1', groups: 'Admin' });
|
|
expect(res.status).toBe(400);
|
|
expect(res.body.error).toMatch(/groups/);
|
|
});
|
|
|
|
test('PATCH /:id updates fields', async () => {
|
|
const p = await store.upsertPerson({ displayName: 'Bob', providerUserId: 'ts-2' });
|
|
const res = await request(app)
|
|
.patch(`/network/people/${p.id}`)
|
|
.send({ displayName: 'Bobby', groups: ['Friends'] });
|
|
expect(res.status).toBe(200);
|
|
expect(res.body.person.displayName).toBe('Bobby');
|
|
expect(res.body.person.groups).toEqual(['Friends']);
|
|
});
|
|
|
|
test('PATCH /:id 404 for unknown id; 400 for bad status', async () => {
|
|
const missing = await request(app).patch('/network/people/ghost').send({ displayName: 'X' });
|
|
expect(missing.status).toBe(404);
|
|
const p = await store.upsertPerson({ displayName: 'Bob', providerUserId: 'ts-2' });
|
|
const bad = await request(app)
|
|
.patch(`/network/people/${p.id}`)
|
|
.send({ status: 'zombie' });
|
|
expect(bad.status).toBe(400);
|
|
expect(bad.body.error).toMatch(/status/);
|
|
});
|
|
|
|
test('PATCH /:id 400 for bad displayName', async () => {
|
|
const p = await store.upsertPerson({ displayName: 'Bob', providerUserId: 'ts-2' });
|
|
const res = await request(app)
|
|
.patch(`/network/people/${p.id}`)
|
|
.send({ displayName: '' });
|
|
expect(res.status).toBe(400);
|
|
});
|
|
|
|
test('DELETE /:id deletes a person', async () => {
|
|
await store.upsertPerson({ displayName: 'Owner', providerUserId: 'ts-0' }); // bootstrap admin
|
|
const p = await store.upsertPerson({ displayName: 'Bob', providerUserId: 'ts-2' });
|
|
const res = await request(app).delete(`/network/people/${p.id}`);
|
|
expect(res.status).toBe(200);
|
|
expect(res.body.deleted).toBe(true);
|
|
expect(await store.getPerson(p.id)).toBe(null);
|
|
});
|
|
|
|
test('DELETE /:id 404 for unknown id', async () => {
|
|
const res = await request(app).delete('/network/people/ghost');
|
|
expect(res.status).toBe(404);
|
|
});
|
|
|
|
test('DELETE /:id refuses the last active admin (409)', async () => {
|
|
const solo = await store.upsertPerson({ displayName: 'Sami', providerUserId: 'ts-owner' });
|
|
expect(solo.groups).toEqual([ADMIN_GROUP]); // bootstrap admin
|
|
const res = await request(app).delete(`/network/people/${solo.id}`);
|
|
expect(res.status).toBe(409);
|
|
expect(res.body.success).toBe(false);
|
|
expect(res.body.error).toMatch(/last active admin/);
|
|
// Person is still there.
|
|
expect(await store.getPerson(solo.id)).toBeTruthy();
|
|
});
|
|
|
|
test('DELETE /:id succeeds when another active admin exists', async () => {
|
|
const a = await store.upsertPerson({ displayName: 'Sami', providerUserId: 'ts-owner' });
|
|
const b = await store.upsertPerson({ displayName: 'Deputy', providerUserId: 'ts-2' });
|
|
await store.updatePerson(b.id, { groups: [ADMIN_GROUP] });
|
|
const res = await request(app).delete(`/network/people/${a.id}`);
|
|
expect(res.status).toBe(200);
|
|
});
|
|
});
|
|
|
|
describe('network-people routes: sync', () => {
|
|
let dir, app, store;
|
|
afterEach(() => _cleanup(dir));
|
|
|
|
test('no networkRegistry → synced:false, no throw', async () => {
|
|
({ app, store, dir } = createApp());
|
|
const res = await request(app).post('/network/people/sync');
|
|
expect(res.status).toBe(200);
|
|
expect(res.body.success).toBe(true);
|
|
expect(res.body.synced).toBe(false);
|
|
expect(res.body.added).toBe(0);
|
|
});
|
|
|
|
test('registry without getActiveProvider → synced:false', async () => {
|
|
({ app, store, dir } = createApp({ networkRegistry: {} }));
|
|
const res = await request(app).post('/network/people/sync');
|
|
expect(res.status).toBe(200);
|
|
expect(res.body.synced).toBe(false);
|
|
});
|
|
|
|
test('getActiveProvider returning undefined → synced:false', async () => {
|
|
({ app, store, dir } = createApp({ networkRegistry: { getActiveProvider: () => undefined } }));
|
|
const res = await request(app).post('/network/people/sync');
|
|
expect(res.status).toBe(200);
|
|
expect(res.body.synced).toBe(false);
|
|
});
|
|
|
|
test('adds missing people and updates displayNames of existing', async () => {
|
|
({ app, store, dir } = createApp({
|
|
networkRegistry: _mockRegistry([
|
|
{ providerUserId: 'ts-1', displayName: 'Alice Updated' },
|
|
{ providerUserId: 'ts-9', displayName: 'New Person' },
|
|
]),
|
|
}));
|
|
await store.upsertPerson({ displayName: 'Alice', providerUserId: 'ts-1' });
|
|
const res = await request(app).post('/network/people/sync');
|
|
expect(res.status).toBe(200);
|
|
expect(res.body.synced).toBe(true);
|
|
expect(res.body.added).toBe(1);
|
|
expect(res.body.updated).toBe(1);
|
|
const list = await store.listPeople();
|
|
const alice = list.find(p => p.providerUserId === 'ts-1');
|
|
const newbie = list.find(p => p.providerUserId === 'ts-9');
|
|
expect(alice.displayName).toBe('Alice Updated');
|
|
expect(newbie.displayName).toBe('New Person');
|
|
expect(newbie.groups).toEqual([]);
|
|
});
|
|
|
|
test('idempotent: second sync changes nothing', async () => {
|
|
({ app, store, dir } = createApp({
|
|
networkRegistry: _mockRegistry([
|
|
{ providerUserId: 'ts-1', displayName: 'Alice' },
|
|
{ providerUserId: 'ts-2', displayName: 'Bob' },
|
|
]),
|
|
}));
|
|
const first = await request(app).post('/network/people/sync');
|
|
expect(first.body.added).toBe(2);
|
|
const second = await request(app).post('/network/people/sync');
|
|
expect(second.status).toBe(200);
|
|
expect(second.body.added).toBe(0);
|
|
expect(second.body.updated).toBe(0);
|
|
const list = await store.listPeople();
|
|
expect(list).toHaveLength(2);
|
|
});
|
|
|
|
test('never downgrades protectedAt on the bootstrap admin', async () => {
|
|
({ app, store, dir } = createApp({
|
|
networkRegistry: _mockRegistry([
|
|
{ providerUserId: 'ts-owner', displayName: 'Sami' },
|
|
]),
|
|
}));
|
|
const owner = await store.upsertPerson({ displayName: 'Sami', providerUserId: 'ts-owner' });
|
|
expect(owner.protectedAt).toBeTruthy();
|
|
const before = owner.protectedAt;
|
|
await request(app).post('/network/people/sync');
|
|
await request(app).post('/network/people/sync');
|
|
const after = await store.getPerson(owner.id);
|
|
expect(after.protectedAt).toBe(before);
|
|
expect(after.groups).toEqual([ADMIN_GROUP]);
|
|
});
|
|
|
|
test('blocked/removed people are NOT resurrected by sync', async () => {
|
|
({ app, store, dir } = createApp({
|
|
networkRegistry: _mockRegistry([
|
|
{ providerUserId: 'ts-1', displayName: 'Blocked Person' },
|
|
]),
|
|
}));
|
|
const p = await store.upsertPerson({ displayName: 'Blocked Person', providerUserId: 'ts-1' });
|
|
await store.updatePerson(p.id, { status: 'blocked' });
|
|
const res = await request(app).post('/network/people/sync');
|
|
expect(res.body.added).toBe(0);
|
|
expect(res.body.updated).toBe(0);
|
|
const after = await store.getPerson(p.id);
|
|
expect(after.status).toBe('blocked');
|
|
expect((await store.listPeople())).toHaveLength(1); // no duplicate either
|
|
});
|
|
|
|
test('skips malformed provider entries without failing the sync', async () => {
|
|
({ app, store, dir } = createApp({
|
|
networkRegistry: _mockRegistry([
|
|
null,
|
|
{ providerUserId: '', displayName: 'NoId' },
|
|
{ providerUserId: 'ts-5', displayName: 'Good' },
|
|
]),
|
|
}));
|
|
const res = await request(app).post('/network/people/sync');
|
|
expect(res.status).toBe(200);
|
|
expect(res.body.added).toBe(1);
|
|
});
|
|
});
|