diff --git a/apps/api/src/device-agent/device-registration.helpers.spec.ts b/apps/api/src/device-agent/device-registration.helpers.spec.ts new file mode 100644 index 0000000000..2e2f6a8862 --- /dev/null +++ b/apps/api/src/device-agent/device-registration.helpers.spec.ts @@ -0,0 +1,157 @@ +jest.mock('@db', () => ({ + db: { + device: { + findUnique: jest.fn(), + findFirst: jest.fn(), + update: jest.fn(), + create: jest.fn(), + }, + }, +})); + +import { db } from '@db'; +import { + registerWithSerial, + registerWithoutSerial, +} from './device-registration.helpers'; +import type { RegisterDeviceDto } from './dto/register-device.dto'; + +const mockDb = db as jest.Mocked; + +const orgId = 'org_test'; +const member = { id: 'mem_test' }; + +function makeDto( + overrides: Partial = {}, +): RegisterDeviceDto { + return { + organizationId: orgId, + hostname: 'my-laptop.local', + name: 'My Laptop', + platform: 'macos', + osVersion: '15.0', + serialNumber: 'ABC123', + hardwareModel: 'MacBookPro18,1', + agentVersion: '1.0.0', + ...overrides, + }; +} + +describe('registerWithSerial — orphan adoption', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('adopts an existing serial-less row for the same hostname+member instead of creating a duplicate', async () => { + // The bug scenario: agent first registered without a serial (e.g. cold- + // boot `system_profiler` returned empty), creating a row with + // serialNumber=null. A later registration succeeds in reading the + // serial. Without adoption, registerWithSerial would create a brand-new + // row and the old one would stay orphaned. + (mockDb.device.findUnique as jest.Mock).mockResolvedValue(null); + (mockDb.device.findFirst as jest.Mock).mockResolvedValue({ + id: 'dev_orphan', + }); + (mockDb.device.update as jest.Mock).mockResolvedValue({ + id: 'dev_orphan', + }); + + const dto = makeDto(); + await registerWithSerial({ member, dto }); + + expect(mockDb.device.findFirst).toHaveBeenCalledWith({ + where: { + hostname: dto.hostname, + memberId: member.id, + organizationId: orgId, + serialNumber: null, + }, + select: { id: true }, + }); + expect(mockDb.device.update).toHaveBeenCalledWith({ + where: { id: 'dev_orphan' }, + data: expect.objectContaining({ + serialNumber: dto.serialNumber, + hostname: dto.hostname, + }), + }); + expect(mockDb.device.create).not.toHaveBeenCalled(); + }); + + it('creates a fresh row when no orphan exists', async () => { + (mockDb.device.findUnique as jest.Mock).mockResolvedValue(null); + (mockDb.device.findFirst as jest.Mock).mockResolvedValue(null); + (mockDb.device.create as jest.Mock).mockResolvedValue({ id: 'dev_new' }); + + const dto = makeDto(); + await registerWithSerial({ member, dto }); + + expect(mockDb.device.update).not.toHaveBeenCalled(); + expect(mockDb.device.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ + serialNumber: dto.serialNumber, + memberId: member.id, + organizationId: orgId, + }), + }); + }); + + it('updates the existing serial-match row without looking for an orphan', async () => { + // Plain re-registration of an already-known device — must not trigger + // the orphan lookup or do anything other than an in-place update. + (mockDb.device.findUnique as jest.Mock).mockResolvedValue({ + id: 'dev_existing', + memberId: member.id, + }); + (mockDb.device.update as jest.Mock).mockResolvedValue({ + id: 'dev_existing', + }); + + const dto = makeDto(); + await registerWithSerial({ member, dto }); + + expect(mockDb.device.findFirst).not.toHaveBeenCalled(); + expect(mockDb.device.create).not.toHaveBeenCalled(); + expect(mockDb.device.update).toHaveBeenCalledWith({ + where: { id: 'dev_existing' }, + data: expect.objectContaining({ hostname: dto.hostname }), + }); + }); + + it('only adopts an orphan that belongs to the same member', async () => { + // Safety: the orphan lookup is scoped by memberId, so another member's + // serial-less row for the same hostname must not be hijacked. + (mockDb.device.findUnique as jest.Mock).mockResolvedValue(null); + (mockDb.device.findFirst as jest.Mock).mockResolvedValue(null); + (mockDb.device.create as jest.Mock).mockResolvedValue({ id: 'dev_new' }); + + const dto = makeDto(); + await registerWithSerial({ member, dto }); + + const call = (mockDb.device.findFirst as jest.Mock).mock.calls[0]?.[0]; + expect(call?.where.memberId).toBe(member.id); + expect(call?.where.serialNumber).toBeNull(); + }); +}); + +describe('registerWithoutSerial — unchanged behavior', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('updates the matching null-serial row when one exists', async () => { + (mockDb.device.findFirst as jest.Mock).mockResolvedValue({ + id: 'dev_null', + }); + (mockDb.device.update as jest.Mock).mockResolvedValue({ id: 'dev_null' }); + + const dto = makeDto({ serialNumber: undefined }); + await registerWithoutSerial({ member, dto }); + + expect(mockDb.device.update).toHaveBeenCalledWith({ + where: { id: 'dev_null' }, + data: expect.any(Object), + }); + expect(mockDb.device.create).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/api/src/device-agent/device-registration.helpers.ts b/apps/api/src/device-agent/device-registration.helpers.ts index e170c4e8b9..57c6e5ac57 100644 --- a/apps/api/src/device-agent/device-registration.helpers.ts +++ b/apps/api/src/device-agent/device-registration.helpers.ts @@ -46,6 +46,33 @@ export async function registerWithSerial({ }); } + // Adopt any prior serial-less registration for the same physical device + // before creating a new row. The agent's serial extraction can return + // undefined on a cold boot (e.g. macOS `system_profiler` cache not yet + // built) and a real value on a subsequent boot — without this, the second + // registration creates a duplicate while the first row stays orphaned and + // never receives another check-in (frozen at its old compliance state). + const orphan = await db.device.findFirst({ + where: { + hostname: dto.hostname, + memberId: member.id, + organizationId: dto.organizationId, + serialNumber: null, + }, + select: { id: true }, + }); + + if (orphan) { + return db.device.update({ + where: { id: orphan.id }, + data: { + ...updateData, + hostname: dto.hostname, + serialNumber: dto.serialNumber!, + }, + }); + } + return db.device.create({ data: { ...updateData, diff --git a/packages/device-agent/src/main/device-info.ts b/packages/device-agent/src/main/device-info.ts index 8b5811977c..a4c0f74593 100644 --- a/packages/device-agent/src/main/device-info.ts +++ b/packages/device-agent/src/main/device-info.ts @@ -73,11 +73,19 @@ function getOSVersion(platform: DevicePlatform): string { function getSerialNumber(platform: DevicePlatform): string | undefined { try { if (platform === 'macos') { + // `$NF` (last field) handles both "Serial Number: ABC" (3 fields) and + // "Serial Number (system): ABC" (4 fields, newer macOS). The fixed + // `$4` we used before silently returned empty on the 3-field variant, + // which made the agent register without a serial and later create a + // duplicate row once `system_profiler`'s cache warmed up and produced + // the 4-field variant. `exit` stops after the first match so any + // sub-component "Serial Number" lines added by future hardware can't + // smuggle a second value into the output. return ( - execSync("system_profiler SPHardwareDataType | awk '/Serial Number/{print $4}'", { - encoding: 'utf-8', - timeout: 5000, - }).trim() || undefined + execSync( + "system_profiler SPHardwareDataType | awk '/Serial Number/{print $NF; exit}'", + { encoding: 'utf-8', timeout: 5000 }, + ).trim() || undefined ); } if (platform === 'linux') {