276 lines
12 KiB
JavaScript
276 lines
12 KiB
JavaScript
/**
|
|
* Tests for network-people-store (DC-141).
|
|
* Coverage:
|
|
* - CRUD: upsert/get/update/delete/list round-trips
|
|
* - atomicity: tmp+rename writes (no torn file), cache coherence
|
|
* - first-person-admin rule: first person gets groups ['Admin'] +
|
|
* protectedAt; later people do not
|
|
* - last-admin guard: single active admin throws; passes when a second
|
|
* active admin exists; throws again when the other admins are blocked
|
|
* - findByProviderUserId match + miss
|
|
* - corruption recovery: bad JSON restarts empty, .bak written
|
|
*/
|
|
|
|
'use strict';
|
|
|
|
const fs = require('fs');
|
|
const path = require('path');
|
|
const os = require('os');
|
|
const {
|
|
createPeopleStore,
|
|
LastAdminError,
|
|
ADMIN_GROUP,
|
|
} = require('../src/security/network-people-store');
|
|
|
|
function _tmpDir() {
|
|
return fs.mkdtempSync(path.join(os.tmpdir(), 'dashcaddy-peopletest-'));
|
|
}
|
|
|
|
function _cleanup(dir) {
|
|
try { fs.rmSync(dir, { recursive: true, force: true }); } catch {}
|
|
}
|
|
|
|
function _admin(name, opts = {}) {
|
|
return {
|
|
displayName: name,
|
|
providerUserId: opts.puid || `puid-${name}`,
|
|
...opts,
|
|
};
|
|
}
|
|
|
|
describe('network-people-store: CRUD', () => {
|
|
let dir, store;
|
|
beforeEach(() => { dir = _tmpDir(); store = createPeopleStore({ dataDir: dir }); });
|
|
afterEach(() => _cleanup(dir));
|
|
|
|
test('empty store lists zero people', async () => {
|
|
const list = await store.listPeople();
|
|
expect(list).toEqual([]);
|
|
});
|
|
|
|
test('upsert creates a person with defaults', async () => {
|
|
// Seed a bootstrap admin first so the subject is a NON-first person
|
|
// (first-person behavior is covered in its own describe block).
|
|
await store.upsertPerson({ displayName: 'Owner', providerUserId: 'ts-0' });
|
|
const p = await store.upsertPerson({ displayName: 'Alice', providerUserId: 'ts-1' });
|
|
expect(p.id).toBeTruthy();
|
|
expect(p.displayName).toBe('Alice');
|
|
expect(p.providerUserId).toBe('ts-1');
|
|
expect(Array.isArray(p.groups)).toBe(true);
|
|
expect(Array.isArray(p.devices)).toBe(true);
|
|
expect(p.status).toBe('active');
|
|
expect(p.createdAt).toBeTruthy();
|
|
expect(p.protectedAt).toBe(null);
|
|
});
|
|
|
|
test('getPerson returns stored person; null for unknown id', async () => {
|
|
const p = await store.upsertPerson({ displayName: 'Alice', providerUserId: 'ts-1' });
|
|
const got = await store.getPerson(p.id);
|
|
expect(got.id).toBe(p.id);
|
|
expect(await store.getPerson('nope')).toBe(null);
|
|
});
|
|
|
|
test('upsertPerson merges by id (update path)', async () => {
|
|
const p = await store.upsertPerson({ displayName: 'Alice', providerUserId: 'ts-1' });
|
|
const p2 = await store.upsertPerson({ id: p.id, displayName: 'Alice II', groups: ['Friends'] });
|
|
expect(p2.id).toBe(p.id);
|
|
expect(p2.displayName).toBe('Alice II');
|
|
expect(p2.providerUserId).toBe('ts-1'); // retained
|
|
expect(p2.createdAt).toBe(p.createdAt); // retained
|
|
expect(p2.groups).toEqual(['Friends']);
|
|
const list = await store.listPeople();
|
|
expect(list).toHaveLength(1); // merged, not duplicated
|
|
});
|
|
|
|
test('updatePerson patches only provided fields', async () => {
|
|
const p = await store.upsertPerson({ displayName: 'Bob', providerUserId: 'ts-2', groups: ['G'] });
|
|
const u = await store.updatePerson(p.id, { displayName: 'Bobby' });
|
|
expect(u.displayName).toBe('Bobby');
|
|
expect(u.groups).toEqual(['G']); // untouched by patch
|
|
expect(await store.updatePerson('nope', { displayName: 'x' })).toBe(null);
|
|
});
|
|
|
|
test('updatePerson cannot change id', async () => {
|
|
const p = await store.upsertPerson({ displayName: 'Bob', providerUserId: 'ts-2' });
|
|
const u = await store.updatePerson(p.id, { id: 'evil', displayName: 'B' });
|
|
expect(u.id).toBe(p.id);
|
|
const list = await store.listPeople();
|
|
expect(list).toHaveLength(1);
|
|
});
|
|
|
|
test('deletePerson removes; false for unknown id', async () => {
|
|
const p = await store.upsertPerson({ displayName: 'Bob', providerUserId: 'ts-2' });
|
|
expect(await store.deletePerson(p.id)).toBe(true);
|
|
expect(await store.getPerson(p.id)).toBe(null);
|
|
expect(await store.deletePerson(p.id)).toBe(false);
|
|
});
|
|
|
|
test('returned persons are copies (mutating them does not corrupt store)', async () => {
|
|
await store.upsertPerson({ displayName: 'Owner', providerUserId: 'ts-0' }); // bootstrap admin
|
|
const p = await store.upsertPerson({ displayName: 'Alice', providerUserId: 'ts-1' });
|
|
p.groups.push('Hacked');
|
|
const fresh = await store.getPerson(p.id);
|
|
expect(fresh.groups).toEqual([]);
|
|
});
|
|
});
|
|
|
|
describe('network-people-store: persistence + atomicity', () => {
|
|
let dir, store;
|
|
beforeEach(() => { dir = _tmpDir(); store = createPeopleStore({ dataDir: dir }); });
|
|
afterEach(() => _cleanup(dir));
|
|
|
|
test('people survive a new store instance (round-trip through disk)', async () => {
|
|
const p = await store.upsertPerson({ displayName: 'Alice', providerUserId: 'ts-1' });
|
|
const store2 = createPeopleStore({ dataDir: dir });
|
|
const got = await store2.getPerson(p.id);
|
|
expect(got.displayName).toBe('Alice');
|
|
expect(got.groups).toEqual(p.groups);
|
|
});
|
|
|
|
test('write is tmp+rename: no leftover tmp files after save', async () => {
|
|
await store.upsertPerson({ displayName: 'Alice', providerUserId: 'ts-1' });
|
|
const files = fs.readdirSync(dir).filter(f => f.includes('.tmp-'));
|
|
expect(files).toEqual([]);
|
|
expect(fs.existsSync(path.join(dir, 'network-people.json'))).toBe(true);
|
|
});
|
|
|
|
test('file contents are complete JSON (never torn)', async () => {
|
|
await store.upsertPerson({ displayName: 'Alice', providerUserId: 'ts-1' });
|
|
await store.upsertPerson({ displayName: 'Bob', providerUserId: 'ts-2' });
|
|
const raw = JSON.parse(fs.readFileSync(path.join(dir, 'network-people.json'), 'utf8'));
|
|
expect(Object.keys(raw.people)).toHaveLength(2);
|
|
});
|
|
|
|
test('rapid sequential writes are all durable (mutex serialization)', async () => {
|
|
const names = ['a', 'b', 'c', 'd', 'e', 'f', 'g', 'h'];
|
|
await Promise.all(names.map(n => store.upsertPerson({ displayName: n, providerUserId: `p-${n}` })));
|
|
const list = await store.listPeople();
|
|
expect(list).toHaveLength(names.length);
|
|
const raw = JSON.parse(fs.readFileSync(path.join(dir, 'network-people.json'), 'utf8'));
|
|
expect(Object.keys(raw.people)).toHaveLength(names.length);
|
|
});
|
|
|
|
test('corrupt file: store recovers empty and writes .bak', async () => {
|
|
await store.upsertPerson({ displayName: 'Alice', providerUserId: 'ts-1' });
|
|
fs.writeFileSync(path.join(dir, 'network-people.json'), '{corrupted!!');
|
|
const store2 = createPeopleStore({ dataDir: dir });
|
|
const list = await store2.listPeople();
|
|
expect(list).toEqual([]);
|
|
expect(fs.existsSync(path.join(dir, 'network-people.json.bak'))).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe('network-people-store: first-person-admin rule', () => {
|
|
let dir, store;
|
|
beforeEach(() => { dir = _tmpDir(); store = createPeopleStore({ dataDir: dir }); });
|
|
afterEach(() => _cleanup(dir));
|
|
|
|
test('first person created gets Admin group + protectedAt', async () => {
|
|
const first = await store.upsertPerson({ displayName: 'Sami', providerUserId: 'ts-owner' });
|
|
expect(first.groups).toEqual([ADMIN_GROUP]);
|
|
expect(first.protectedAt).toBeTruthy();
|
|
expect(new Date(first.protectedAt).getTime()).not.toBeNaN();
|
|
});
|
|
|
|
test('second person created does NOT get Admin/protectedAt', async () => {
|
|
await store.upsertPerson({ displayName: 'Sami', providerUserId: 'ts-owner' });
|
|
const second = await store.upsertPerson({ displayName: 'Friend', providerUserId: 'ts-2' });
|
|
expect(second.groups).toEqual([]);
|
|
expect(second.protectedAt).toBe(null);
|
|
});
|
|
|
|
test('rule applies only to a truly empty store (delete-then-create does not re-trigger)', async () => {
|
|
const first = await store.upsertPerson({ displayName: 'Sami', providerUserId: 'ts-owner' });
|
|
const second = await store.upsertPerson({ displayName: 'Friend', providerUserId: 'ts-2' });
|
|
await store.deletePerson(first.id);
|
|
const third = await store.upsertPerson({ displayName: 'Late', providerUserId: 'ts-3' });
|
|
expect(second.groups).toEqual([]);
|
|
expect(third.groups).toEqual([]);
|
|
expect(third.protectedAt).toBe(null);
|
|
});
|
|
});
|
|
|
|
describe('network-people-store: last-admin guard', () => {
|
|
let dir, store;
|
|
beforeEach(() => { dir = _tmpDir(); store = createPeopleStore({ dataDir: dir }); });
|
|
afterEach(() => _cleanup(dir));
|
|
|
|
test('throws LastAdminError for the only active admin', async () => {
|
|
const solo = await store.upsertPerson(_admin('Sami')); // bootstrap admin
|
|
await expect(store.assertNotLastAdmin(solo.id)).rejects.toBeInstanceOf(LastAdminError);
|
|
});
|
|
|
|
test('passes when a second active admin exists', async () => {
|
|
const a = await store.upsertPerson(_admin('Sami'));
|
|
const b = await store.upsertPerson(_admin('Deputy'));
|
|
await store.updatePerson(b.id, { groups: [ADMIN_GROUP] });
|
|
await expect(store.assertNotLastAdmin(a.id)).resolves.toBeUndefined();
|
|
await expect(store.assertNotLastAdmin(b.id)).resolves.toBeUndefined();
|
|
});
|
|
|
|
test('throws when the other admins are blocked (only one active left)', async () => {
|
|
const a = await store.upsertPerson(_admin('Sami'));
|
|
const b = await store.upsertPerson(_admin('Deputy'));
|
|
await store.updatePerson(b.id, { groups: [ADMIN_GROUP] });
|
|
// Block every admin except a.
|
|
await store.updatePerson(b.id, { status: 'blocked' });
|
|
await expect(store.assertNotLastAdmin(a.id)).rejects.toBeInstanceOf(LastAdminError);
|
|
// The blocked admin himself is not protected by the guard (he's not
|
|
// an ACTIVE admin of consequence — but he IS still the last *listed*
|
|
// admin member; the guard protects active access, so blocking b again
|
|
// is fine).
|
|
await expect(store.assertNotLastAdmin(b.id)).resolves.toBeUndefined();
|
|
});
|
|
|
|
test('removed admins also do not count as active', async () => {
|
|
const a = await store.upsertPerson(_admin('Sami'));
|
|
const b = await store.upsertPerson(_admin('Deputy'));
|
|
await store.updatePerson(b.id, { groups: [ADMIN_GROUP] });
|
|
await store.updatePerson(b.id, { status: 'removed' });
|
|
await expect(store.assertNotLastAdmin(a.id)).rejects.toBeInstanceOf(LastAdminError);
|
|
});
|
|
|
|
test('non-admins pass freely', async () => {
|
|
await store.upsertPerson(_admin('Sami'));
|
|
const user = await store.upsertPerson({ displayName: 'Pleb', providerUserId: 'ts-pleb' });
|
|
await expect(store.assertNotLastAdmin(user.id)).resolves.toBeUndefined();
|
|
});
|
|
|
|
test('unknown ids pass (nothing to protect)', async () => {
|
|
await expect(store.assertNotLastAdmin('ghost')).resolves.toBeUndefined();
|
|
});
|
|
|
|
test('LastAdminError carries code + personId', async () => {
|
|
const solo = await store.upsertPerson(_admin('Sami'));
|
|
try {
|
|
await store.assertNotLastAdmin(solo.id);
|
|
throw new Error('should have thrown');
|
|
} catch (err) {
|
|
expect(err).toBeInstanceOf(LastAdminError);
|
|
expect(err.code).toBe('DC_LAST_ADMIN');
|
|
expect(err.personId).toBe(solo.id);
|
|
expect(err.statusCode).toBe(409);
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('network-people-store: findByProviderUserId', () => {
|
|
let dir, store;
|
|
beforeEach(() => { dir = _tmpDir(); store = createPeopleStore({ dataDir: dir }); });
|
|
afterEach(() => _cleanup(dir));
|
|
|
|
test('finds the person with that provider identity', async () => {
|
|
await store.upsertPerson({ displayName: 'Alice', providerUserId: 'ts-1234' });
|
|
const found = await store.findByProviderUserId('ts-1234');
|
|
expect(found).toBeTruthy();
|
|
expect(found.displayName).toBe('Alice');
|
|
});
|
|
|
|
test('returns null on miss and for empty/invalid input', async () => {
|
|
await store.upsertPerson({ displayName: 'Alice', providerUserId: 'ts-1234' });
|
|
expect(await store.findByProviderUserId('ts-9999')).toBe(null);
|
|
expect(await store.findByProviderUserId('')).toBe(null);
|
|
expect(await store.findByProviderUserId(null)).toBe(null);
|
|
});
|
|
});
|