Harden protected route authentication

This commit is contained in:
diyaa
2026-06-14 09:43:41 +02:00
parent 8902efb92e
commit fa7727d572
32 changed files with 765 additions and 296 deletions
+277 -182
View File
@@ -520,12 +520,19 @@ describe('Velody API wiring (e2e)', () => {
expect.any(Date),
);
const heartbeatResponse = await devicesController.heartbeat({
deviceId: registerResponse.deviceId,
appVersion: '0.1.1',
});
const heartbeatResponse = await runAsDevice(
registerResponse.deviceAccessToken,
() =>
devicesController.heartbeat({
deviceId: registerResponse.deviceId,
appVersion: '0.1.1',
}),
);
expect(heartbeatResponse.ok).toBe(true);
expect(prismaState.devices.get(registerResponse.deviceId)?.appVersion).toBe(
'0.1.1',
);
});
it('registers a linked device under the authenticated device owner when Authorization is present', async () => {
@@ -589,7 +596,69 @@ describe('Velody API wiring (e2e)', () => {
).rejects.toBeInstanceOf(UnauthorizedException);
});
it('accepts heartbeat updates for a legacy device id outside the bootstrap owner', async () => {
it('returns 401 when heartbeat is requested without Authorization', async () => {
await expect(
devicesController.heartbeat({
deviceId: randomUUID(),
appVersion: '0.1.1',
}),
).rejects.toBeInstanceOf(UnauthorizedException);
});
it('returns 401 when sync bootstrap is requested without Authorization', async () => {
await expect(syncController.bootstrap()).rejects.toBeInstanceOf(
UnauthorizedException,
);
});
it('returns 401 when upload prepare is requested without Authorization', async () => {
const bytes = sampleMp3Bytes('missing-upload-auth');
await expect(
uploadsController.prepare({
deviceId: randomUUID(),
sha256: sha256Hex(bytes),
originalFilename: 'missing-upload-auth.mp3',
sizeBytes: bytes.length,
}),
).rejects.toBeInstanceOf(UnauthorizedException);
});
it('returns 401 when asset download is requested without Authorization', async () => {
await expect(
assetsController.download(
randomUUID(),
{ deviceId: randomUUID() },
{ setHeader() {} } as any,
),
).rejects.toBeInstanceOf(UnauthorizedException);
});
it('returns 401 when artwork download is requested without Authorization', async () => {
await expect(
artworkController.download(
randomUUID(),
{ deviceId: randomUUID() },
{ setHeader() {} } as any,
),
).rejects.toBeInstanceOf(UnauthorizedException);
});
it('returns 401 when a protected route receives an invalid Authorization header', async () => {
await requestContextService.run(async () => {
await expect(
deviceAuthService.authenticateAuthorizationHeader(
'Bearer invalid-device-token',
),
).rejects.toBeInstanceOf(UnauthorizedException);
});
});
it('uses the authenticated device for heartbeat even when the body deviceId is spoofed', async () => {
const ownerDevice = await devicesController.register({
platform: 'MACOS',
deviceName: 'Owner Mac',
appVersion: '0.1.0',
});
const foreignDeviceId = randomUUID();
prismaState.devices.set(foreignDeviceId, {
id: foreignDeviceId,
@@ -603,18 +672,32 @@ describe('Velody API wiring (e2e)', () => {
updatedAt: new Date(),
});
const response = await devicesController.heartbeat({
deviceId: foreignDeviceId,
appVersion: '0.1.1',
});
const response = await runAsDevice(ownerDevice.deviceAccessToken, () =>
devicesController.heartbeat({
deviceId: foreignDeviceId,
appVersion: '0.1.1',
}),
);
expect(response.ok).toBe(true);
expect(prismaState.devices.get(foreignDeviceId)?.appVersion).toBe('0.1.1');
expect(prismaState.devices.get(ownerDevice.deviceId)?.appVersion).toBe(
'0.1.1',
);
expect(prismaState.devices.get(foreignDeviceId)?.appVersion).toBe('0.1.0');
});
it('returns sync bootstrap and changes payloads', async () => {
const bootstrapResponse = await syncController.bootstrap();
const changesResponse = await syncController.changes({ after: '0' });
const device = await devicesController.register({
platform: 'IPHONE',
deviceName: 'Sync iPhone',
appVersion: '0.1.0',
});
const bootstrapResponse = await runAsDevice(device.deviceAccessToken, () =>
syncController.bootstrap(),
);
const changesResponse = await runAsDevice(device.deviceAccessToken, () =>
syncController.changes({ after: '0' }),
);
expect(bootstrapResponse.tracks).toEqual([]);
expect(changesResponse.events).toEqual([]);
@@ -622,6 +705,11 @@ describe('Velody API wiring (e2e)', () => {
});
it('sync bootstrap and changes do not expose foreign-owner data', async () => {
const device = await devicesController.register({
platform: 'IPHONE',
deviceName: 'Scoped Sync iPhone',
appVersion: '0.1.0',
});
const foreignUserId = randomUUID();
const foreignTrackId = randomUUID();
@@ -654,8 +742,12 @@ describe('Velody API wiring (e2e)', () => {
createdAt: new Date('2026-05-29T08:02:00.000Z'),
});
const bootstrapResponse = await syncController.bootstrap();
const changesResponse = await syncController.changes({ after: '0' });
const bootstrapResponse = await runAsDevice(device.deviceAccessToken, () =>
syncController.bootstrap(),
);
const changesResponse = await runAsDevice(device.deviceAccessToken, () =>
syncController.changes({ after: '0' }),
);
expect(bootstrapResponse.tracks).toEqual([]);
expect(changesResponse.events).toEqual([]);
@@ -704,10 +796,12 @@ describe('Velody API wiring (e2e)', () => {
},
} as any;
const streamable = await assetsController.download(
assetId,
{ deviceId: registerResponse.deviceId },
responseMock,
const streamable = await runAsDevice(registerResponse.deviceAccessToken, () =>
assetsController.download(
assetId,
{ deviceId: registerResponse.deviceId },
responseMock,
),
);
const downloadedBytes = await streamToBuffer(streamable.getStream());
@@ -741,10 +835,12 @@ describe('Velody API wiring (e2e)', () => {
});
await expect(
assetsController.download(
assetId,
{ deviceId: registerResponse.deviceId },
{ setHeader() {} } as any,
runAsDevice(registerResponse.deviceAccessToken, () =>
assetsController.download(
assetId,
{ deviceId: registerResponse.deviceId },
{ setHeader() {} } as any,
),
),
).rejects.toBeInstanceOf(ForbiddenException);
});
@@ -778,10 +874,12 @@ describe('Velody API wiring (e2e)', () => {
});
await expect(
assetsController.download(
assetId,
{ deviceId: registerResponse.deviceId },
{ setHeader() {} } as any,
runAsDevice(registerResponse.deviceAccessToken, () =>
assetsController.download(
assetId,
{ deviceId: registerResponse.deviceId },
{ setHeader() {} } as any,
),
),
).rejects.toBeInstanceOf(NotFoundException);
});
@@ -848,10 +946,12 @@ describe('Velody API wiring (e2e)', () => {
},
} as any;
const streamable = await artworkController.download(
artworkId,
{ deviceId: registerResponse.deviceId },
responseMock,
const streamable = await runAsDevice(registerResponse.deviceAccessToken, () =>
artworkController.download(
artworkId,
{ deviceId: registerResponse.deviceId },
responseMock,
),
);
const downloadedBytes = await streamToBuffer(streamable.getStream());
@@ -882,10 +982,12 @@ describe('Velody API wiring (e2e)', () => {
});
await expect(
artworkController.download(
artworkId,
{ deviceId: registerResponse.deviceId },
{ setHeader() {} } as any,
runAsDevice(registerResponse.deviceAccessToken, () =>
artworkController.download(
artworkId,
{ deviceId: registerResponse.deviceId },
{ setHeader() {} } as any,
),
),
).rejects.toBeInstanceOf(ForbiddenException);
});
@@ -917,10 +1019,12 @@ describe('Velody API wiring (e2e)', () => {
});
await expect(
artworkController.download(
artworkId,
{ deviceId: registerResponse.deviceId },
{ setHeader() {} } as any,
runAsDevice(registerResponse.deviceAccessToken, () =>
artworkController.download(
artworkId,
{ deviceId: registerResponse.deviceId },
{ setHeader() {} } as any,
),
),
).rejects.toBeInstanceOf(NotFoundException);
});
@@ -1089,9 +1193,11 @@ describe('Velody API wiring (e2e)', () => {
updatedAt: new Date('2026-05-29T08:02:30.000Z'),
});
const response = await libraryController.getTracks({
deviceId: primaryDevice.deviceId,
});
const response = await runAsDevice(primaryDevice.deviceAccessToken, () =>
libraryController.getTracks({
deviceId: primaryDevice.deviceId,
}),
);
expect(response).toEqual({
tracks: [
@@ -1340,59 +1446,18 @@ describe('Velody API wiring (e2e)', () => {
expect(linkedLibrary.tracks).toEqual(primaryLibrary.tracks);
});
it('keeps the legacy library deviceId path working when Authorization is missing', async () => {
it('returns 401 when library is requested without Authorization even if deviceId is supplied', async () => {
const ownerDevice = await devicesController.register({
platform: 'IPHONE',
deviceName: 'Legacy iPhone',
appVersion: '0.1.0',
});
const trackId = randomUUID();
const assetId = randomUUID();
prismaState.audioAssets.set(assetId, {
id: assetId,
userId: prismaState.defaultUser.id,
trackId,
sha256: 'legacy-library-sha',
storageKey: 'users/default/audio/legacy-library-sha.mp3',
originalFilename: 'legacy-library.mp3',
mimeType: 'audio/mpeg',
fileExtension: 'mp3',
fileSizeBytes: BigInt(42),
durationMs: 210000,
sourceDeviceId: ownerDevice.deviceId,
createdAt: new Date('2026-05-29T08:00:00.000Z'),
});
prismaState.tracks.set(trackId, {
id: trackId,
userId: prismaState.defaultUser.id,
primaryAudioAssetId: assetId,
artworkAssetId: null,
title: 'Legacy Library Track',
artist: 'Velody',
album: null,
albumArtist: null,
genre: null,
discNumber: null,
trackNumber: null,
year: null,
durationMs: 210000,
status: 'ACTIVE',
deletedAt: null,
createdAt: new Date('2026-05-29T08:00:00.000Z'),
updatedAt: new Date('2026-05-29T08:02:00.000Z'),
});
const response = await libraryController.getTracks({
deviceId: ownerDevice.deviceId,
});
expect(response.tracks).toEqual([
expect.objectContaining({
trackId,
assetId,
await expect(
libraryController.getTracks({
deviceId: ownerDevice.deviceId,
}),
]);
).rejects.toBeInstanceOf(UnauthorizedException);
});
it('rejects invalid or revoked device tokens even when a legacy device id is supplied', async () => {
@@ -1595,30 +1660,35 @@ describe('Velody API wiring (e2e)', () => {
const bytes = sampleMp3Bytes('e2e-upload');
const sha256 = sha256Hex(bytes);
const prepareResponse = await uploadsController.prepare({
deviceId: registerResponse.deviceId,
sha256,
originalFilename: 'e2e-upload.mp3',
sizeBytes: bytes.length,
});
const prepareResponse = await runAsDevice(registerResponse.deviceAccessToken, () =>
uploadsController.prepare({
deviceId: registerResponse.deviceId,
sha256,
originalFilename: 'e2e-upload.mp3',
sizeBytes: bytes.length,
}),
);
expect(prepareResponse.status).toBe('upload_required');
const uploadResponse = await uploadsService.uploadFile(
prepareResponse.uploadId!,
createUploadRequest(bytes),
const uploadResponse = await runAsDevice(registerResponse.deviceAccessToken, () =>
uploadsService.uploadFile(
prepareResponse.uploadId!,
createUploadRequest(bytes),
),
);
expect(uploadResponse.status).toBe('COMPLETED');
const finalizeResponse = await uploadsController.finalize(
prepareResponse.uploadId!,
{
title: 'Uploaded Track',
artist: 'Velody',
album: 'Milestone 6',
durationMs: 222000,
},
const finalizeResponse = await runAsDevice(
registerResponse.deviceAccessToken,
() =>
uploadsController.finalize(prepareResponse.uploadId!, {
title: 'Uploaded Track',
artist: 'Velody',
album: 'Milestone 6',
durationMs: 222000,
}),
);
expect(finalizeResponse.trackId).toBeDefined();
@@ -1629,12 +1699,16 @@ describe('Velody API wiring (e2e)', () => {
);
expect(storedBytes.equals(bytes)).toBe(true);
const duplicatePrepare = await uploadsController.prepare({
deviceId: registerResponse.deviceId,
sha256,
originalFilename: 'e2e-upload.mp3',
sizeBytes: bytes.length,
});
const duplicatePrepare = await runAsDevice(
registerResponse.deviceAccessToken,
() =>
uploadsController.prepare({
deviceId: registerResponse.deviceId,
sha256,
originalFilename: 'e2e-upload.mp3',
sizeBytes: bytes.length,
}),
);
expect(duplicatePrepare.status).toBe('exists');
expect(duplicatePrepare.uploadId).toBeDefined();
@@ -1656,45 +1730,52 @@ describe('Velody API wiring (e2e)', () => {
const sha256 = sha256Hex(bytes);
const artworkSha256 = sha256Hex(artworkBytes);
const prepareResponse = await uploadsController.prepare({
deviceId: registerResponse.deviceId,
sha256,
originalFilename: 'e2e-upload-artwork.mp3',
sizeBytes: bytes.length,
});
const prepareResponse = await runAsDevice(registerResponse.deviceAccessToken, () =>
uploadsController.prepare({
deviceId: registerResponse.deviceId,
sha256,
originalFilename: 'e2e-upload-artwork.mp3',
sizeBytes: bytes.length,
}),
);
expect(prepareResponse.status).toBe('upload_required');
const uploadResponse = await uploadsService.uploadFile(
prepareResponse.uploadId!,
createUploadRequest(bytes),
const uploadResponse = await runAsDevice(registerResponse.deviceAccessToken, () =>
uploadsService.uploadFile(
prepareResponse.uploadId!,
createUploadRequest(bytes),
),
);
expect(uploadResponse.status).toBe('COMPLETED');
const finalizeResponse = await uploadsController.finalize(
prepareResponse.uploadId!,
{
title: 'Uploaded Artwork Track',
artist: 'Velody',
album: 'Milestone 8.1',
durationMs: 222000,
artwork: {
dataBase64: artworkBytes.toString('base64'),
sha256: artworkSha256,
mimeType: 'image/png',
width: 1,
height: 1,
},
},
const finalizeResponse = await runAsDevice(
registerResponse.deviceAccessToken,
() =>
uploadsController.finalize(prepareResponse.uploadId!, {
title: 'Uploaded Artwork Track',
artist: 'Velody',
album: 'Milestone 8.1',
durationMs: 222000,
artwork: {
dataBase64: artworkBytes.toString('base64'),
sha256: artworkSha256,
mimeType: 'image/png',
width: 1,
height: 1,
},
}),
);
expect(finalizeResponse.trackId).toBeDefined();
expect(prismaState.artworkAssets.size).toBe(1);
const remoteLibrary = await libraryController.getTracks({
deviceId: registerResponse.deviceId,
});
const remoteLibrary = await runAsDevice(registerResponse.deviceAccessToken, () =>
libraryController.getTracks({
deviceId: registerResponse.deviceId,
}),
);
expect(remoteLibrary.tracks).toEqual([
expect.objectContaining({
trackId: finalizeResponse.trackId,
@@ -1727,10 +1808,12 @@ describe('Velody API wiring (e2e)', () => {
},
} as any;
const streamable = await artworkController.download(
artworkAsset.id,
{ deviceId: registerResponse.deviceId },
responseMock,
const streamable = await runAsDevice(registerResponse.deviceAccessToken, () =>
artworkController.download(
artworkAsset.id,
{ deviceId: registerResponse.deviceId },
responseMock,
),
);
const downloadedArtworkBytes = await streamToBuffer(streamable.getStream());
@@ -1752,67 +1835,79 @@ describe('Velody API wiring (e2e)', () => {
const sha256 = sha256Hex(bytes);
const artworkSha256 = sha256Hex(artworkBytes);
const firstPrepare = await uploadsController.prepare({
deviceId: registerResponse.deviceId,
sha256,
originalFilename: 'e2e-deduped-artwork.mp3',
sizeBytes: bytes.length,
});
const firstPrepare = await runAsDevice(registerResponse.deviceAccessToken, () =>
uploadsController.prepare({
deviceId: registerResponse.deviceId,
sha256,
originalFilename: 'e2e-deduped-artwork.mp3',
sizeBytes: bytes.length,
}),
);
expect(firstPrepare.status).toBe('upload_required');
const uploadResponse = await uploadsService.uploadFile(
firstPrepare.uploadId!,
createUploadRequest(bytes),
const uploadResponse = await runAsDevice(registerResponse.deviceAccessToken, () =>
uploadsService.uploadFile(
firstPrepare.uploadId!,
createUploadRequest(bytes),
),
);
expect(uploadResponse.status).toBe('COMPLETED');
const firstFinalize = await uploadsController.finalize(
firstPrepare.uploadId!,
{
title: 'Deduped Artwork Track',
artist: 'Velody',
album: 'Milestone 8.1',
durationMs: 222000,
},
const firstFinalize = await runAsDevice(
registerResponse.deviceAccessToken,
() =>
uploadsController.finalize(firstPrepare.uploadId!, {
title: 'Deduped Artwork Track',
artist: 'Velody',
album: 'Milestone 8.1',
durationMs: 222000,
}),
);
const secondPrepare = await uploadsController.prepare({
deviceId: registerResponse.deviceId,
sha256,
originalFilename: 'e2e-deduped-artwork.mp3',
sizeBytes: bytes.length,
});
const secondPrepare = await runAsDevice(
registerResponse.deviceAccessToken,
() =>
uploadsController.prepare({
deviceId: registerResponse.deviceId,
sha256,
originalFilename: 'e2e-deduped-artwork.mp3',
sizeBytes: bytes.length,
}),
);
expect(secondPrepare.status).toBe('exists');
expect(secondPrepare.uploadId).toBeDefined();
expect(prismaState.audioAssets.size).toBe(1);
const secondFinalize = await uploadsController.finalize(
secondPrepare.uploadId!,
{
title: 'Deduped Artwork Track',
artist: 'Velody',
album: 'Milestone 8.1',
durationMs: 222000,
artwork: {
dataBase64: artworkBytes.toString('base64'),
sha256: artworkSha256,
mimeType: 'image/png',
width: 1,
height: 1,
},
},
const secondFinalize = await runAsDevice(
registerResponse.deviceAccessToken,
() =>
uploadsController.finalize(secondPrepare.uploadId!, {
title: 'Deduped Artwork Track',
artist: 'Velody',
album: 'Milestone 8.1',
durationMs: 222000,
artwork: {
dataBase64: artworkBytes.toString('base64'),
sha256: artworkSha256,
mimeType: 'image/png',
width: 1,
height: 1,
},
}),
);
expect(secondFinalize.trackId).toBe(firstFinalize.trackId);
expect(secondFinalize.assetId).toBe(firstFinalize.assetId);
expect(prismaState.artworkAssets.size).toBe(1);
const remoteLibrary = await libraryController.getTracks({
deviceId: registerResponse.deviceId,
});
const remoteLibrary = await runAsDevice(registerResponse.deviceAccessToken, () =>
libraryController.getTracks({
deviceId: registerResponse.deviceId,
}),
);
expect(remoteLibrary.tracks).toEqual([
expect.objectContaining({
trackId: firstFinalize.trackId,