Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
169 changes: 82 additions & 87 deletions packages/device-id/src/get-device-id.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,7 @@ describe('getDeviceId', function () {

const deviceId = await getDeviceId({
getMachineId,
isNodeMachineId: false,
}).value;
});

expect(deviceId).to.be.a('string');
expect(deviceId).to.have.lengthOf(64); // SHA-256 hex digest length
Expand All @@ -22,48 +21,46 @@ describe('getDeviceId', function () {

const resultA = await getDeviceId({
getMachineId,
isNodeMachineId: true,
}).value;
});

const resultB = await getDeviceId({
getMachineId: () => Promise.resolve(mockMachineId.toUpperCase()),
isNodeMachineId: true,
}).value;
});

expect(resultA).to.equal(resultB);
});

it('returns "unknown" when machine id is not found', async function () {
const getMachineId = () => Promise.resolve(undefined);
let capturedError: Error | undefined;
let capturedError: [string, Error] | undefined;

const deviceId = await getDeviceId({
getMachineId,
isNodeMachineId: false,
onError: (error) => {
capturedError = error;
onError: (reason, error) => {
capturedError = [reason, error];
},
}).value;
});

expect(deviceId).to.equal('unknown');
expect(capturedError?.message).to.equal('Failed to resolve machine ID');
expect(capturedError?.[0]).to.equal('resolutionError');
expect(capturedError?.[1].message).to.equal('Failed to resolve machine ID');
});

it('returns "unknown" and calls onError when getMachineId throws', async function () {
const error = new Error('Something went wrong');
const getMachineId = () => Promise.reject(error);
let capturedError: Error | undefined;
let capturedError: [string, Error] | undefined;

const result = await getDeviceId({
getMachineId,
isNodeMachineId: false,
onError: (err) => {
capturedError = err;
onError: (reason, err) => {
capturedError = [reason, err];
},
}).value;
});

expect(result).to.equal('unknown');
expect(capturedError).to.equal(error);
expect(capturedError?.[0]).to.equal('resolutionError');
expect(capturedError?.[1]).to.equal(error);
});

it('produces consistent hash for the same machine id', async function () {
Expand All @@ -72,86 +69,84 @@ describe('getDeviceId', function () {

const resultA = await getDeviceId({
getMachineId,
isNodeMachineId: false,
}).value;
});

const resultB = await getDeviceId({
getMachineId,
isNodeMachineId: false,
}).value;
});

expect(resultA).to.equal(resultB);
});

it('handles timeout when getting machine id', async function () {
let timeoutId: NodeJS.Timeout;
const getMachineId = () =>
new Promise<string>((resolve) => {
timeoutId = setTimeout(() => resolve('delayed-id'), 10_000);
const fallbackTestCases: {
fallbackValue?: string;
expectedResult: string;
}[] = [
{ expectedResult: 'unknown' },
{ fallbackValue: 'fallback-id', expectedResult: 'fallback-id' },
];

describe('when timed out', function () {
for (const testCase of fallbackTestCases) {
it(`resolves with ${testCase.expectedResult} when fallback value is ${testCase.fallbackValue ?? 'undefined'}`, async function () {
let timeoutId: NodeJS.Timeout;
const getMachineId = () =>
new Promise<string>((resolve) => {
timeoutId = setTimeout(() => resolve('delayed-id'), 10_000);
});

let capturedError: [string, Error] | undefined;
const result = await getDeviceId({
getMachineId,
onError: (reason, error) => {
capturedError = [reason, error];
},
timeout: 5,
fallbackValue: testCase.fallbackValue,
});

// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
clearTimeout(timeoutId!);
expect(result).to.equal(testCase.expectedResult);
expect(capturedError?.[0]).to.equal('timeout');
expect(capturedError?.[1].message).to.equal(
'Timeout reached after 5 ms',
);
});

let errorCalled = false;
const result = await getDeviceId({
getMachineId,
isNodeMachineId: false,
onError: () => {
errorCalled = true;
},
timeout: 1,
}).value;

// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
clearTimeout(timeoutId!);
expect(result).to.equal('unknown');
expect(errorCalled).to.equal(false);
});

it('handles external promise resolution', async function () {
let timeoutId: NodeJS.Timeout;
const getMachineId = () =>
new Promise<string>((resolve) => {
timeoutId = setTimeout(() => resolve('delayed-id'), 10_000);
});

const { resolve, value } = getDeviceId({
getMachineId,
isNodeMachineId: false,
});

resolve('external-id');

const result = await value;

// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
clearTimeout(timeoutId!);
expect(result).to.be.a('string');
expect(result).to.equal('external-id');
expect(result).to.not.equal('unknown');
}
});

it('handles external promise rejection', async function () {
let timeoutId: NodeJS.Timeout;
const getMachineId = () =>
new Promise<string>((resolve) => {
timeoutId = setTimeout(() => resolve('delayed-id'), 10_000);
describe('when aborted', function () {
for (const testCase of fallbackTestCases) {
it(`resolves with ${testCase.expectedResult} when fallback value is ${testCase.fallbackValue ?? 'undefined'}`, async function () {
let timeoutId: NodeJS.Timeout;
const getMachineId = () =>
new Promise<string>((resolve) => {
timeoutId = setTimeout(() => resolve('delayed-id'), 10_000);
});

let capturedError: [string, Error] | undefined;
const abortController = new AbortController();
const value = getDeviceId({
getMachineId,
abortSignal: abortController.signal,
onError: (reason, error) => {
capturedError = [reason, error];
},
fallbackValue: testCase.fallbackValue,
});

abortController.abort();

const result = await value;

// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
clearTimeout(timeoutId!);
expect(result).to.be.a('string');
expect(result).to.equal(testCase.expectedResult);
expect(capturedError?.[0]).to.equal('abort');
expect(capturedError?.[1].message).to.equal('Aborted by abort signal');
});

const error = new Error('External rejection');

const { reject, value } = getDeviceId({
getMachineId,
isNodeMachineId: false,
});

reject(error);

// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
clearTimeout(timeoutId!);
try {
await value;
expect.fail('Expected promise to be rejected');
} catch (e) {
expect(e).to.equal(error);
}
});
});
79 changes: 34 additions & 45 deletions packages/device-id/src/get-device-id.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,94 +2,83 @@ import { createHmac } from 'crypto';

export function getDeviceId({
getMachineId,
isNodeMachineId,
onError,
timeout = 3000,
onTimeout,
}: GetDeviceIdOptions): {
/** A promise that resolves to the hashed device ID or `"unknown"` if an error or timeout occurs. */
value: Promise<string>;
/** A function which resolves the device ID promise. */
resolve: (value: string) => void;
/** A function which rejects the device ID promise. */
reject: (err: Error) => void;
} {
let resolveDeviceId!: (value: string) => void;
let rejectDeviceId!: (err: Error) => void;
abortSignal,
fallbackValue = 'unknown',
}: GetDeviceIdOptions): Promise<string> {
let timeoutId: NodeJS.Timeout | undefined;

const value = Promise.race([
resolveMachineId({
getMachineId,
isNodeMachineId,
onError,
}),
new Promise<string>((resolve, reject) => {
new Promise<string>((resolve) => {
abortSignal?.addEventListener('abort', () => {
onError?.('abort', new Error('Aborted by abort signal'));
resolve(fallbackValue);
});

timeoutId = setTimeout(() => {
if (onTimeout) {
onTimeout(resolve, reject);
} else {
resolve('unknown');
}
onError?.('timeout', new Error(`Timeout reached after ${timeout} ms`));
resolve(fallbackValue);
}, timeout).unref?.();

resolveDeviceId = resolve;
rejectDeviceId = reject;
}),
]).finally(() => clearTimeout(timeoutId));

return {
value,
resolve: resolveDeviceId,
reject: rejectDeviceId,
};
return value;
}

export type GetDeviceIdOptions = {
/** A function that returns a raw machine ID. */
getMachineId: () => Promise<string | undefined>;
/** When using node-machine-id, the ID is made uppercase to be consistent with other libraries. */
isNodeMachineId: boolean;

/** Runs when an error occurs while getting the machine ID. */
onError?: (error: Error) => void;
onError?: (
reason: 'abort' | 'timeout' | 'resolutionError',
error: Error,
) => void;

/** Timeout in milliseconds. Defaults to 3000ms. Set to `undefined` to disable. */
timeout?: number | undefined;
/** Runs when the timeout is reached. By default, resolves to "unknown". */
onTimeout?: (
resolve: (value: string) => void,
reject: (err: Error) => void,
) => void;

/**
* An optional abort signal that will cancel the async device ID resolution and will
* cause getDeviceId to resolve immediately with the value of `fallbackValue`.
*/
abortSignal?: AbortSignal;

/**
* An optional fallback value that will be returned if the abort signal is triggered
* or the timeout is reached. Defaults to "unknown".
*/
fallbackValue?: string;
};

async function resolveMachineId({
getMachineId,
isNodeMachineId,
onError,
}: GetDeviceIdOptions): Promise<string> {
try {
const originalId = isNodeMachineId
? (await getMachineId())?.toUpperCase()
: await getMachineId();
const originalId = (await getMachineId())?.toUpperCase();

if (!originalId) {
onError?.(new Error('Failed to resolve machine ID'));
onError?.('resolutionError', new Error('Failed to resolve machine ID'));
return 'unknown';
}

// Create a hashed format from the machine ID
// to match it exactly with the denisbrodbeck/machineid library that Atlas CLI uses.
const hmac = createHmac(
'sha256',
isNodeMachineId ? originalId : originalId,
);
const hmac = createHmac('sha256', originalId);

/** This matches the message used to create the hashes in Atlas CLI */
const DEVICE_ID_HASH_MESSAGE = 'atlascli';

hmac.update(DEVICE_ID_HASH_MESSAGE);
return hmac.digest('hex');
} catch (error) {
onError?.(error as Error);
onError?.('resolutionError', error as Error);
return 'unknown';
}
}