Add multi-device identity foundation

This commit is contained in:
diyaa
2026-06-10 10:50:46 +02:00
parent 45c270c187
commit 8902efb92e
12 changed files with 689 additions and 49 deletions
+272 -7
View File
@@ -353,6 +353,71 @@ describe('Velody API wiring (e2e)', () => {
let prismaState: ReturnType<typeof createPrismaMock>['state'];
let storageRoot: string;
async function registerDeviceWithAuthorization(
body: {
platform: 'MACOS' | 'IPHONE';
deviceName: string;
appVersion: string;
},
authorizationHeader: string,
) {
return requestContextService.run(async () => {
await deviceAuthService.authenticateAuthorizationHeader(
authorizationHeader,
);
return devicesController.register(body);
});
}
function seedDevice(params: {
userId: string;
deviceAccessToken?: string;
deviceId?: string;
deviceName?: string;
appVersion?: string;
platform?: 'MACOS' | 'IPHONE';
tokenRevokedAt?: Date | null;
}) {
const deviceId = params.deviceId ?? randomUUID();
const deviceAccessToken = params.deviceAccessToken;
prismaState.devices.set(deviceId, {
id: deviceId,
userId: params.userId,
platform: params.platform ?? 'IPHONE',
deviceName: params.deviceName ?? 'Seeded Device',
appVersion: params.appVersion ?? '0.1.0',
installTokenHash: `seeded-install-${deviceId}`,
tokenHash: deviceAccessToken
? sha256Hex(Buffer.from(deviceAccessToken, 'utf8'))
: undefined,
tokenCreatedAt: deviceAccessToken ? new Date() : undefined,
tokenRevokedAt: params.tokenRevokedAt ?? undefined,
lastSeenAt: new Date(),
createdAt: new Date(),
updatedAt: new Date(),
});
return {
deviceId,
deviceAccessToken,
};
}
async function runAsDevice<T>(
deviceAccessToken: string,
callback: () => Promise<T>,
): Promise<T> {
return requestContextService.run(async () => {
await deviceAuthService.authenticateAuthorizationHeader(
`Bearer ${deviceAccessToken}`,
);
return callback();
});
}
beforeEach(async () => {
const prismaSetup = createPrismaMock();
prismaMock = prismaSetup.prismaMock;
@@ -463,7 +528,68 @@ describe('Velody API wiring (e2e)', () => {
expect(heartbeatResponse.ok).toBe(true);
});
it('rejects heartbeat updates for a foreign-owner device', async () => {
it('registers a linked device under the authenticated device owner when Authorization is present', async () => {
const linkedOwnerId = randomUUID();
const existingDevice = seedDevice({
userId: linkedOwnerId,
deviceAccessToken: 'linked-owner-access-token',
deviceName: 'Existing Linked Owner Device',
platform: 'MACOS',
});
const response = await registerDeviceWithAuthorization(
{
platform: 'IPHONE',
deviceName: 'Linked iPhone',
appVersion: '0.1.0',
},
`Bearer ${existingDevice.deviceAccessToken}`,
);
expect(response.deviceId).toBeDefined();
expect(response.deviceAccessToken).toBeDefined();
expect(prismaState.devices.get(response.deviceId)?.userId).toBe(
linkedOwnerId,
);
expect(prismaState.devices.get(response.deviceId)?.userId).not.toBe(
prismaState.defaultUser.id,
);
});
it('returns 401 when register receives an invalid Authorization header', async () => {
await expect(
registerDeviceWithAuthorization(
{
platform: 'IPHONE',
deviceName: 'Rejected iPhone',
appVersion: '0.1.0',
},
'Bearer invalid-device-token',
),
).rejects.toBeInstanceOf(UnauthorizedException);
});
it('rejects linked registration when the authenticating device token has been revoked', async () => {
const revokedDevice = seedDevice({
userId: randomUUID(),
deviceAccessToken: 'revoked-link-token',
tokenRevokedAt: new Date('2026-06-09T10:00:00.000Z'),
deviceName: 'Revoked Device',
});
await expect(
registerDeviceWithAuthorization(
{
platform: 'MACOS',
deviceName: 'Blocked Mac',
appVersion: '0.1.0',
},
`Bearer ${revokedDevice.deviceAccessToken}`,
),
).rejects.toBeInstanceOf(UnauthorizedException);
});
it('accepts heartbeat updates for a legacy device id outside the bootstrap owner', async () => {
const foreignDeviceId = randomUUID();
prismaState.devices.set(foreignDeviceId, {
id: foreignDeviceId,
@@ -477,12 +603,13 @@ describe('Velody API wiring (e2e)', () => {
updatedAt: new Date(),
});
await expect(
devicesController.heartbeat({
deviceId: foreignDeviceId,
appVersion: '0.1.1',
}),
).rejects.toBeInstanceOf(NotFoundException);
const response = await devicesController.heartbeat({
deviceId: foreignDeviceId,
appVersion: '0.1.1',
});
expect(response.ok).toBe(true);
expect(prismaState.devices.get(foreignDeviceId)?.appVersion).toBe('0.1.1');
});
it('returns sync bootstrap and changes payloads', async () => {
@@ -1138,6 +1265,81 @@ describe('Velody API wiring (e2e)', () => {
});
});
it('lets linked devices under the same identity see the same library', async () => {
const identityUserId = randomUUID();
const primaryDevice = seedDevice({
userId: identityUserId,
deviceAccessToken: 'shared-library-primary-token',
deviceName: 'Identity Mac',
platform: 'MACOS',
});
const linkResponse = await registerDeviceWithAuthorization(
{
platform: 'IPHONE',
deviceName: 'Identity iPhone',
appVersion: '0.1.0',
},
`Bearer ${primaryDevice.deviceAccessToken}`,
);
const linkedDeviceToken = linkResponse.deviceAccessToken;
const linkedDeviceId = linkResponse.deviceId;
const trackId = randomUUID();
const assetId = randomUUID();
prismaState.audioAssets.set(assetId, {
id: assetId,
userId: identityUserId,
trackId,
sha256: 'shared-library-sha',
storageKey: `users/${identityUserId}/audio/shared-library-sha.mp3`,
originalFilename: 'shared-library.mp3',
mimeType: 'audio/mpeg',
fileExtension: 'mp3',
fileSizeBytes: BigInt(42),
durationMs: 198000,
sourceDeviceId: primaryDevice.deviceId,
createdAt: new Date('2026-05-29T08:00:00.000Z'),
});
prismaState.tracks.set(trackId, {
id: trackId,
userId: identityUserId,
primaryAudioAssetId: assetId,
artworkAssetId: null,
title: 'Shared Library Track',
artist: 'Velody',
album: null,
albumArtist: null,
genre: null,
discNumber: null,
trackNumber: null,
year: null,
durationMs: 198000,
status: 'ACTIVE',
deletedAt: null,
createdAt: new Date('2026-05-29T08:00:00.000Z'),
updatedAt: new Date('2026-05-29T08:02:00.000Z'),
});
const primaryLibrary = await runAsDevice(
primaryDevice.deviceAccessToken!,
() => libraryController.getTracks({}),
);
const linkedLibrary = await runAsDevice(linkedDeviceToken, () =>
libraryController.getTracks({
deviceId: linkedDeviceId,
}),
);
expect(primaryLibrary.tracks).toEqual([
expect.objectContaining({
trackId,
assetId,
title: 'Shared Library Track',
}),
]);
expect(linkedLibrary.tracks).toEqual(primaryLibrary.tracks);
});
it('keeps the legacy library deviceId path working when Authorization is missing', async () => {
const ownerDevice = await devicesController.register({
platform: 'IPHONE',
@@ -1321,6 +1523,69 @@ describe('Velody API wiring (e2e)', () => {
);
});
it('makes an upload from one device visible to another linked device under the same owner', async () => {
const identityUserId = randomUUID();
const primaryDevice = seedDevice({
userId: identityUserId,
deviceAccessToken: 'linked-upload-primary-token',
deviceName: 'Upload Mac',
platform: 'MACOS',
});
const linkResponse = await registerDeviceWithAuthorization(
{
platform: 'IPHONE',
deviceName: 'Upload iPhone',
appVersion: '0.1.0',
},
`Bearer ${primaryDevice.deviceAccessToken}`,
);
const linkedDeviceToken = linkResponse.deviceAccessToken;
const bytes = sampleMp3Bytes('linked-upload');
const sha256 = sha256Hex(bytes);
const prepareResponse = await runAsDevice(
primaryDevice.deviceAccessToken!,
() =>
uploadsController.prepare({
sha256,
originalFilename: 'linked-upload.mp3',
sizeBytes: bytes.length,
}),
);
expect(prepareResponse.status).toBe('upload_required');
expect(prepareResponse.uploadId).toBeDefined();
await runAsDevice(primaryDevice.deviceAccessToken!, () =>
uploadsController.uploadFile(
prepareResponse.uploadId!,
createUploadRequest(bytes),
),
);
const finalizeResponse = await runAsDevice(
primaryDevice.deviceAccessToken!,
() =>
uploadsController.finalize(prepareResponse.uploadId!, {
title: 'Linked Upload Track',
artist: 'Velody',
durationMs: 123000,
}),
);
const linkedLibrary = await runAsDevice(linkedDeviceToken, () =>
libraryController.getTracks({}),
);
expect(linkedLibrary.tracks).toEqual([
expect.objectContaining({
trackId: finalizeResponse.trackId,
assetId: finalizeResponse.assetId,
title: 'Linked Upload Track',
}),
]);
});
it('supports the MP3 upload pipeline through the Nest app wiring', async () => {
const registerResponse = await devicesController.register({
platform: 'MACOS',