Add multi-device identity foundation
This commit is contained in:
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user