From c430e02117c2054484db05697ca75e0206f58488 Mon Sep 17 00:00:00 2001 From: Hermes Agent Date: Sun, 4 Oct 2026 03:09:32 -0700 Subject: [PATCH] [grade=B urn:ump:bbfbsfru4nizg75wcxum7mwzhie6txhr22hw6jvz36wiihmgzmlq] P7 people store: dedupe upsert by providerUserId Dogfood finding #3: upsertPerson resolved existing records by id only. The wizard upserts a bootstrap admin AFTER a provider sync has already created a person for the same Tailscale user (no id passed), so the same providerUserId was duplicated into two records. Resolve existing by providerUserId when no id is given; bootstrap Admin group + protectedAt are preserved on the merge. New regression test; full suite 3414/3414. --- .../__tests__/network-people-store.test.js | 20 +++++++++++++++++++ .../src/security/network-people-store.js | 12 ++++++++++- 2 files changed, 31 insertions(+), 1 deletion(-) diff --git a/dashcaddy-api/__tests__/network-people-store.test.js b/dashcaddy-api/__tests__/network-people-store.test.js index 8d1c715..56079b9 100644 --- a/dashcaddy-api/__tests__/network-people-store.test.js +++ b/dashcaddy-api/__tests__/network-people-store.test.js @@ -82,6 +82,26 @@ describe('network-people-store: CRUD', () => { expect(list).toHaveLength(1); // merged, not duplicated }); + test('upsertPerson merges by providerUserId when id is omitted (wizard after provider sync)', async () => { + const synced = await store.upsertPerson({ + displayName: 'SamiAhmed7777', + providerUserId: 'ughVnuLDu621CNTRL', + }); + expect(synced.groups).toEqual(['Admin']); // bootstrap identity + + const fromWizard = await store.upsertPerson({ + displayName: 'Sami Ahmed', + providerUserId: 'ughVnuLDu621CNTRL', + }); + + expect(fromWizard.id).toBe(synced.id); + expect(fromWizard.displayName).toBe('Sami Ahmed'); + expect(fromWizard.groups).toEqual(['Admin']); + expect(fromWizard.protectedAt).toBe(synced.protectedAt); + const list = await store.listPeople(); + expect(list).toHaveLength(1); + }); + 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' }); diff --git a/dashcaddy-api/src/security/network-people-store.js b/dashcaddy-api/src/security/network-people-store.js index 71b9e6a..b9a028d 100644 --- a/dashcaddy-api/src/security/network-people-store.js +++ b/dashcaddy-api/src/security/network-people-store.js @@ -180,7 +180,17 @@ function createPeopleStore(opts = {}) { function upsertPerson(input = {}) { return _enqueue(() => { const data = _load(); - const existing = input.id ? data.people[input.id] : null; + // Resolve the existing record by id when given, else by providerUserId + // (dogfood finding 2026-10-04: the wizard upserts a person AFTER a + // provider sync already created one for the same Tailscale user with no + // id — without this lookup the same providerUserId was duplicated). + const existing = input.id + ? data.people[input.id] + : (input.providerUserId + ? Object.values(data.people).find( + (p) => p.providerUserId && p.providerUserId === input.providerUserId + ) || null + : null); const isFirst = Object.keys(data.people).length === 0; const id = existing ? existing.id : (input.id || crypto.randomUUID()); -- 2.54.0