Harden protected route authentication
This commit is contained in:
parent
8902efb92e
commit
fa7727d572
@ -17,7 +17,7 @@
|
|||||||
"name": "deviceId",
|
"name": "deviceId",
|
||||||
"required": false,
|
"required": false,
|
||||||
"in": "query",
|
"in": "query",
|
||||||
"description": "Legacy migration fallback. Omit when Authorization: Bearer <deviceAccessToken> is provided.",
|
"description": "Optional client metadata. Authorization: Bearer <deviceAccessToken> is required and determines access.",
|
||||||
"schema": {
|
"schema": {
|
||||||
"format": "uuid",
|
"format": "uuid",
|
||||||
"type": "string"
|
"type": "string"
|
||||||
@ -63,7 +63,7 @@
|
|||||||
"name": "deviceId",
|
"name": "deviceId",
|
||||||
"required": false,
|
"required": false,
|
||||||
"in": "query",
|
"in": "query",
|
||||||
"description": "Legacy migration fallback. Omit when Authorization: Bearer <deviceAccessToken> is provided.",
|
"description": "Optional client metadata. Authorization: Bearer <deviceAccessToken> is required and determines access.",
|
||||||
"schema": {
|
"schema": {
|
||||||
"format": "uuid",
|
"format": "uuid",
|
||||||
"type": "string"
|
"type": "string"
|
||||||
@ -140,11 +140,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"security": [
|
|
||||||
{
|
|
||||||
"bearer": []
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"tags": [
|
"tags": [
|
||||||
"devices"
|
"devices"
|
||||||
]
|
]
|
||||||
@ -368,7 +363,7 @@
|
|||||||
"name": "deviceId",
|
"name": "deviceId",
|
||||||
"required": false,
|
"required": false,
|
||||||
"in": "query",
|
"in": "query",
|
||||||
"description": "Legacy migration fallback. Omit when Authorization: Bearer <deviceAccessToken> is provided.",
|
"description": "Optional client metadata. Authorization: Bearer <deviceAccessToken> is required and determines access.",
|
||||||
"schema": {
|
"schema": {
|
||||||
"format": "uuid",
|
"format": "uuid",
|
||||||
"type": "string"
|
"type": "string"
|
||||||
@ -405,7 +400,7 @@
|
|||||||
"name": "deviceId",
|
"name": "deviceId",
|
||||||
"required": false,
|
"required": false,
|
||||||
"in": "query",
|
"in": "query",
|
||||||
"description": "Legacy migration fallback. Omit when Authorization: Bearer <deviceAccessToken> is provided.",
|
"description": "Optional client metadata. Authorization: Bearer <deviceAccessToken> is required and determines access.",
|
||||||
"schema": {
|
"schema": {
|
||||||
"format": "uuid",
|
"format": "uuid",
|
||||||
"type": "string"
|
"type": "string"
|
||||||
@ -442,7 +437,7 @@
|
|||||||
"name": "deviceId",
|
"name": "deviceId",
|
||||||
"required": false,
|
"required": false,
|
||||||
"in": "query",
|
"in": "query",
|
||||||
"description": "Legacy migration fallback. Omit when Authorization: Bearer <deviceAccessToken> is provided.",
|
"description": "Optional client metadata. Authorization: Bearer <deviceAccessToken> is required and determines access.",
|
||||||
"schema": {
|
"schema": {
|
||||||
"format": "uuid",
|
"format": "uuid",
|
||||||
"type": "string"
|
"type": "string"
|
||||||
@ -490,6 +485,14 @@
|
|||||||
"tags": [],
|
"tags": [],
|
||||||
"servers": [],
|
"servers": [],
|
||||||
"components": {
|
"components": {
|
||||||
|
"securitySchemes": {
|
||||||
|
"bearer": {
|
||||||
|
"scheme": "bearer",
|
||||||
|
"bearerFormat": "Bearer",
|
||||||
|
"type": "http",
|
||||||
|
"description": "Device access token"
|
||||||
|
}
|
||||||
|
},
|
||||||
"schemas": {
|
"schemas": {
|
||||||
"HealthDependencyDto": {
|
"HealthDependencyDto": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
@ -608,7 +611,7 @@
|
|||||||
"deviceId": {
|
"deviceId": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"format": "uuid",
|
"format": "uuid",
|
||||||
"description": "Legacy migration fallback. Omit when Authorization: Bearer <deviceAccessToken> is provided."
|
"description": "Optional client metadata. Authorization: Bearer <deviceAccessToken> is required and determines access."
|
||||||
},
|
},
|
||||||
"appVersion": {
|
"appVersion": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
@ -642,7 +645,7 @@
|
|||||||
"deviceId": {
|
"deviceId": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"format": "uuid",
|
"format": "uuid",
|
||||||
"description": "Legacy migration fallback. Omit when Authorization: Bearer <deviceAccessToken> is provided."
|
"description": "Optional client metadata. Authorization: Bearer <deviceAccessToken> is required and determines access."
|
||||||
},
|
},
|
||||||
"sha256": {
|
"sha256": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
|
|||||||
@ -21,6 +21,15 @@ async function generate(): Promise<void> {
|
|||||||
.setTitle('Velody API')
|
.setTitle('Velody API')
|
||||||
.setDescription('Velody Phase 1 foundation API')
|
.setDescription('Velody Phase 1 foundation API')
|
||||||
.setVersion('1.0.0')
|
.setVersion('1.0.0')
|
||||||
|
.addBearerAuth(
|
||||||
|
{
|
||||||
|
type: 'http',
|
||||||
|
scheme: 'bearer',
|
||||||
|
bearerFormat: 'Bearer',
|
||||||
|
description: 'Device access token',
|
||||||
|
},
|
||||||
|
'bearer',
|
||||||
|
)
|
||||||
.build(),
|
.build(),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@ -5,6 +5,7 @@ import type { NestExpressApplication } from '@nestjs/platform-express';
|
|||||||
const setTitle = jest.fn().mockReturnThis();
|
const setTitle = jest.fn().mockReturnThis();
|
||||||
const setDescription = jest.fn().mockReturnThis();
|
const setDescription = jest.fn().mockReturnThis();
|
||||||
const setVersion = jest.fn().mockReturnThis();
|
const setVersion = jest.fn().mockReturnThis();
|
||||||
|
const addBearerAuth = jest.fn().mockReturnThis();
|
||||||
const build = jest.fn().mockReturnValue({});
|
const build = jest.fn().mockReturnValue({});
|
||||||
const createDocument = jest.fn().mockReturnValue({});
|
const createDocument = jest.fn().mockReturnValue({});
|
||||||
const setup = jest.fn();
|
const setup = jest.fn();
|
||||||
@ -23,6 +24,7 @@ jest.mock('@nestjs/swagger', () => {
|
|||||||
setTitle,
|
setTitle,
|
||||||
setDescription,
|
setDescription,
|
||||||
setVersion,
|
setVersion,
|
||||||
|
addBearerAuth,
|
||||||
build,
|
build,
|
||||||
})),
|
})),
|
||||||
SwaggerModule: {
|
SwaggerModule: {
|
||||||
|
|||||||
@ -37,6 +37,15 @@ export async function createApp(): Promise<NestExpressApplication> {
|
|||||||
.setTitle('Velody API')
|
.setTitle('Velody API')
|
||||||
.setDescription('Velody Phase 1 foundation API')
|
.setDescription('Velody Phase 1 foundation API')
|
||||||
.setVersion('1.0.0')
|
.setVersion('1.0.0')
|
||||||
|
.addBearerAuth(
|
||||||
|
{
|
||||||
|
type: 'http',
|
||||||
|
scheme: 'bearer',
|
||||||
|
bearerFormat: 'Bearer',
|
||||||
|
description: 'Device access token',
|
||||||
|
},
|
||||||
|
'bearer',
|
||||||
|
)
|
||||||
.build(),
|
.build(),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@ -42,10 +42,8 @@ export class ArtworkController {
|
|||||||
@Query() query: AssetDownloadQueryDto,
|
@Query() query: AssetDownloadQueryDto,
|
||||||
@Res({ passthrough: true }) response: Response,
|
@Res({ passthrough: true }) response: Response,
|
||||||
): Promise<StreamableFile> {
|
): Promise<StreamableFile> {
|
||||||
const download = await this.artworkService.getOwnedArtworkDownload(
|
void query;
|
||||||
artworkId,
|
const download = await this.artworkService.getOwnedArtworkDownload(artworkId);
|
||||||
query.deviceId,
|
|
||||||
);
|
|
||||||
|
|
||||||
response.setHeader('Content-Type', download.mimeType);
|
response.setHeader('Content-Type', download.mimeType);
|
||||||
response.setHeader('Content-Length', String(download.contentLength));
|
response.setHeader('Content-Length', String(download.contentLength));
|
||||||
|
|||||||
@ -42,7 +42,7 @@ describe('ArtworkService', () => {
|
|||||||
let storageService: LocalFilesystemStorageService;
|
let storageService: LocalFilesystemStorageService;
|
||||||
let ownerUserId: string;
|
let ownerUserId: string;
|
||||||
let deviceAuthService: {
|
let deviceAuthService: {
|
||||||
resolveCurrentDevice: jest.Mock;
|
getAuthenticatedDeviceOrThrow: jest.Mock;
|
||||||
};
|
};
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
@ -52,7 +52,7 @@ describe('ArtworkService', () => {
|
|||||||
storageService = new LocalFilesystemStorageService(createAppConfig(storageRoot));
|
storageService = new LocalFilesystemStorageService(createAppConfig(storageRoot));
|
||||||
ownerUserId = randomUUID();
|
ownerUserId = randomUUID();
|
||||||
deviceAuthService = {
|
deviceAuthService = {
|
||||||
resolveCurrentDevice: jest.fn().mockResolvedValue({
|
getAuthenticatedDeviceOrThrow: jest.fn().mockReturnValue({
|
||||||
deviceId: randomUUID(),
|
deviceId: randomUUID(),
|
||||||
userId: ownerUserId,
|
userId: ownerUserId,
|
||||||
}),
|
}),
|
||||||
@ -87,7 +87,7 @@ describe('ArtworkService', () => {
|
|||||||
await storageService.ensureParentDirectory(filePath);
|
await storageService.ensureParentDirectory(filePath);
|
||||||
await writeFile(filePath, bytes);
|
await writeFile(filePath, bytes);
|
||||||
|
|
||||||
const download = await service.getOwnedArtworkDownload(artworkId, randomUUID());
|
const download = await service.getOwnedArtworkDownload(artworkId);
|
||||||
|
|
||||||
expect(download.filePath).toBe(filePath);
|
expect(download.filePath).toBe(filePath);
|
||||||
expect(download.contentLength).toBe(bytes.length);
|
expect(download.contentLength).toBe(bytes.length);
|
||||||
@ -105,17 +105,17 @@ describe('ArtworkService', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
service.getOwnedArtworkDownload(artworkId, randomUUID()),
|
service.getOwnedArtworkDownload(artworkId),
|
||||||
).rejects.toBeInstanceOf(ForbiddenException);
|
).rejects.toBeInstanceOf(ForbiddenException);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('rejects foreign-owner devices before reading artwork', async () => {
|
it('rejects foreign-owner devices before reading artwork', async () => {
|
||||||
deviceAuthService.resolveCurrentDevice.mockRejectedValueOnce(
|
deviceAuthService.getAuthenticatedDeviceOrThrow.mockImplementationOnce(() => {
|
||||||
new NotFoundException('Device not found'),
|
throw new NotFoundException('Device not found');
|
||||||
);
|
});
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
service.getOwnedArtworkDownload(randomUUID(), randomUUID()),
|
service.getOwnedArtworkDownload(randomUUID()),
|
||||||
).rejects.toBeInstanceOf(NotFoundException);
|
).rejects.toBeInstanceOf(NotFoundException);
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -130,13 +130,13 @@ describe('ArtworkService', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
service.getOwnedArtworkDownload(artworkId, randomUUID()),
|
service.getOwnedArtworkDownload(artworkId),
|
||||||
).rejects.toBeInstanceOf(NotFoundException);
|
).rejects.toBeInstanceOf(NotFoundException);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns not found when the artwork asset does not exist', async () => {
|
it('returns not found when the artwork asset does not exist', async () => {
|
||||||
await expect(
|
await expect(
|
||||||
service.getOwnedArtworkDownload(randomUUID(), randomUUID()),
|
service.getOwnedArtworkDownload(randomUUID()),
|
||||||
).rejects.toBeInstanceOf(NotFoundException);
|
).rejects.toBeInstanceOf(NotFoundException);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -24,10 +24,9 @@ export class ArtworkService {
|
|||||||
|
|
||||||
async getOwnedArtworkDownload(
|
async getOwnedArtworkDownload(
|
||||||
artworkId: string,
|
artworkId: string,
|
||||||
legacyDeviceId?: string,
|
|
||||||
): Promise<ArtworkDownload> {
|
): Promise<ArtworkDownload> {
|
||||||
const { userId: ownerUserId } =
|
const { userId: ownerUserId } =
|
||||||
await this.deviceAuthService.resolveCurrentDevice(legacyDeviceId);
|
this.deviceAuthService.getAuthenticatedDeviceOrThrow();
|
||||||
|
|
||||||
const artwork = await this.prismaService.artworkAsset.findUnique({
|
const artwork = await this.prismaService.artworkAsset.findUnique({
|
||||||
where: { id: artworkId },
|
where: { id: artworkId },
|
||||||
|
|||||||
@ -42,10 +42,8 @@ export class AssetsController {
|
|||||||
@Query() query: AssetDownloadQueryDto,
|
@Query() query: AssetDownloadQueryDto,
|
||||||
@Res({ passthrough: true }) response: Response,
|
@Res({ passthrough: true }) response: Response,
|
||||||
): Promise<StreamableFile> {
|
): Promise<StreamableFile> {
|
||||||
const download = await this.assetsService.getOwnedAudioAssetDownload(
|
void query;
|
||||||
assetId,
|
const download = await this.assetsService.getOwnedAudioAssetDownload(assetId);
|
||||||
query.deviceId,
|
|
||||||
);
|
|
||||||
|
|
||||||
response.setHeader('Content-Type', 'audio/mpeg');
|
response.setHeader('Content-Type', 'audio/mpeg');
|
||||||
response.setHeader('Content-Length', String(download.contentLength));
|
response.setHeader('Content-Length', String(download.contentLength));
|
||||||
|
|||||||
@ -6,7 +6,7 @@ export class AssetDownloadQueryDto {
|
|||||||
format: 'uuid',
|
format: 'uuid',
|
||||||
required: false,
|
required: false,
|
||||||
description:
|
description:
|
||||||
'Legacy migration fallback. Omit when Authorization: Bearer <deviceAccessToken> is provided.',
|
'Optional client metadata. Authorization: Bearer <deviceAccessToken> is required and determines access.',
|
||||||
})
|
})
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsUUID()
|
@IsUUID()
|
||||||
|
|||||||
@ -42,7 +42,7 @@ describe('AssetsService', () => {
|
|||||||
let storageService: LocalFilesystemStorageService;
|
let storageService: LocalFilesystemStorageService;
|
||||||
let ownerUserId: string;
|
let ownerUserId: string;
|
||||||
let deviceAuthService: {
|
let deviceAuthService: {
|
||||||
resolveCurrentDevice: jest.Mock;
|
getAuthenticatedDeviceOrThrow: jest.Mock;
|
||||||
};
|
};
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
@ -52,7 +52,7 @@ describe('AssetsService', () => {
|
|||||||
storageService = new LocalFilesystemStorageService(createAppConfig(storageRoot));
|
storageService = new LocalFilesystemStorageService(createAppConfig(storageRoot));
|
||||||
ownerUserId = randomUUID();
|
ownerUserId = randomUUID();
|
||||||
deviceAuthService = {
|
deviceAuthService = {
|
||||||
resolveCurrentDevice: jest.fn().mockResolvedValue({
|
getAuthenticatedDeviceOrThrow: jest.fn().mockReturnValue({
|
||||||
deviceId: randomUUID(),
|
deviceId: randomUUID(),
|
||||||
userId: ownerUserId,
|
userId: ownerUserId,
|
||||||
}),
|
}),
|
||||||
@ -84,7 +84,7 @@ describe('AssetsService', () => {
|
|||||||
await storageService.ensureParentDirectory(filePath);
|
await storageService.ensureParentDirectory(filePath);
|
||||||
await writeFile(filePath, assetBytes);
|
await writeFile(filePath, assetBytes);
|
||||||
|
|
||||||
const download = await service.getOwnedAudioAssetDownload(assetId, randomUUID());
|
const download = await service.getOwnedAudioAssetDownload(assetId);
|
||||||
|
|
||||||
expect(download.filePath).toBe(filePath);
|
expect(download.filePath).toBe(filePath);
|
||||||
expect(download.contentLength).toBe(assetBytes.length);
|
expect(download.contentLength).toBe(assetBytes.length);
|
||||||
@ -101,17 +101,17 @@ describe('AssetsService', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
service.getOwnedAudioAssetDownload(assetId, randomUUID()),
|
service.getOwnedAudioAssetDownload(assetId),
|
||||||
).rejects.toBeInstanceOf(ForbiddenException);
|
).rejects.toBeInstanceOf(ForbiddenException);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('rejects foreign-owner devices before reading audio assets', async () => {
|
it('rejects foreign-owner devices before reading audio assets', async () => {
|
||||||
deviceAuthService.resolveCurrentDevice.mockRejectedValueOnce(
|
deviceAuthService.getAuthenticatedDeviceOrThrow.mockImplementationOnce(() => {
|
||||||
new NotFoundException('Device not found'),
|
throw new NotFoundException('Device not found');
|
||||||
);
|
});
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
service.getOwnedAudioAssetDownload(randomUUID(), randomUUID()),
|
service.getOwnedAudioAssetDownload(randomUUID()),
|
||||||
).rejects.toBeInstanceOf(NotFoundException);
|
).rejects.toBeInstanceOf(NotFoundException);
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -126,17 +126,17 @@ describe('AssetsService', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
service.getOwnedAudioAssetDownload(assetId, randomUUID()),
|
service.getOwnedAudioAssetDownload(assetId),
|
||||||
).rejects.toBeInstanceOf(NotFoundException);
|
).rejects.toBeInstanceOf(NotFoundException);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns not found when the device does not exist', async () => {
|
it('returns not found when the device does not exist', async () => {
|
||||||
deviceAuthService.resolveCurrentDevice.mockRejectedValueOnce(
|
deviceAuthService.getAuthenticatedDeviceOrThrow.mockImplementationOnce(() => {
|
||||||
new NotFoundException('Device not found'),
|
throw new NotFoundException('Device not found');
|
||||||
);
|
});
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
service.getOwnedAudioAssetDownload(randomUUID(), randomUUID()),
|
service.getOwnedAudioAssetDownload(randomUUID()),
|
||||||
).rejects.toBeInstanceOf(NotFoundException);
|
).rejects.toBeInstanceOf(NotFoundException);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -23,10 +23,9 @@ export class AssetsService {
|
|||||||
|
|
||||||
async getOwnedAudioAssetDownload(
|
async getOwnedAudioAssetDownload(
|
||||||
assetId: string,
|
assetId: string,
|
||||||
legacyDeviceId?: string,
|
|
||||||
): Promise<AudioAssetDownload> {
|
): Promise<AudioAssetDownload> {
|
||||||
const { userId: ownerUserId } =
|
const { userId: ownerUserId } =
|
||||||
await this.deviceAuthService.resolveCurrentDevice(legacyDeviceId);
|
this.deviceAuthService.getAuthenticatedDeviceOrThrow();
|
||||||
|
|
||||||
const asset = await this.prismaService.audioAsset.findUnique({
|
const asset = await this.prismaService.audioAsset.findUnique({
|
||||||
where: { id: assetId },
|
where: { id: assetId },
|
||||||
|
|||||||
@ -4,14 +4,28 @@ import { RequestContextMiddleware } from '../../infrastructure/request-context/r
|
|||||||
import { RequestContextModule } from '../../infrastructure/request-context/request-context.module';
|
import { RequestContextModule } from '../../infrastructure/request-context/request-context.module';
|
||||||
import { DeviceAuthGuard } from './device-auth.guard';
|
import { DeviceAuthGuard } from './device-auth.guard';
|
||||||
import { DeviceAuthService } from './device-auth.service';
|
import { DeviceAuthService } from './device-auth.service';
|
||||||
|
import { OptionalDeviceAuthGuard } from './optional-device-auth.guard';
|
||||||
|
import { ProtectedDeviceAuthMiddleware } from './protected-device-auth.middleware';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [PrismaModule, RequestContextModule],
|
imports: [PrismaModule, RequestContextModule],
|
||||||
providers: [DeviceAuthService, DeviceAuthGuard],
|
providers: [
|
||||||
exports: [DeviceAuthService, DeviceAuthGuard],
|
DeviceAuthService,
|
||||||
|
DeviceAuthGuard,
|
||||||
|
OptionalDeviceAuthGuard,
|
||||||
|
ProtectedDeviceAuthMiddleware,
|
||||||
|
],
|
||||||
|
exports: [
|
||||||
|
DeviceAuthService,
|
||||||
|
DeviceAuthGuard,
|
||||||
|
OptionalDeviceAuthGuard,
|
||||||
|
ProtectedDeviceAuthMiddleware,
|
||||||
|
],
|
||||||
})
|
})
|
||||||
export class AuthModule implements NestModule {
|
export class AuthModule implements NestModule {
|
||||||
configure(consumer: MiddlewareConsumer): void {
|
configure(consumer: MiddlewareConsumer): void {
|
||||||
consumer.apply(RequestContextMiddleware).forRoutes('*');
|
consumer
|
||||||
|
.apply(RequestContextMiddleware, ProtectedDeviceAuthMiddleware)
|
||||||
|
.forRoutes('*');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2,6 +2,7 @@ import {
|
|||||||
CanActivate,
|
CanActivate,
|
||||||
ExecutionContext,
|
ExecutionContext,
|
||||||
Injectable,
|
Injectable,
|
||||||
|
UnauthorizedException,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import type { Request } from 'express';
|
import type { Request } from 'express';
|
||||||
import { DeviceAuthService } from './device-auth.service';
|
import { DeviceAuthService } from './device-auth.service';
|
||||||
@ -12,10 +13,20 @@ export class DeviceAuthGuard implements CanActivate {
|
|||||||
|
|
||||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||||
const request = context.switchToHttp().getRequest<Request>();
|
const request = context.switchToHttp().getRequest<Request>();
|
||||||
|
const existingAuthenticatedDevice =
|
||||||
|
this.deviceAuthService.getAuthenticatedDevice();
|
||||||
|
|
||||||
|
if (existingAuthenticatedDevice) {
|
||||||
|
(request as Request & { authenticatedDevice?: unknown }).authenticatedDevice =
|
||||||
|
existingAuthenticatedDevice;
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
const authorization = request.headers.authorization;
|
const authorization = request.headers.authorization;
|
||||||
|
|
||||||
if (!authorization) {
|
if (!authorization) {
|
||||||
return true;
|
throw new UnauthorizedException('Authorization header is required');
|
||||||
}
|
}
|
||||||
|
|
||||||
const authenticatedDevice =
|
const authenticatedDevice =
|
||||||
|
|||||||
@ -68,13 +68,29 @@ export class DeviceAuthService {
|
|||||||
return authenticatedDevice;
|
return authenticatedDevice;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getAuthenticatedDeviceOrThrow(): AuthenticatedDeviceContextValue {
|
||||||
|
const authenticatedDevice = this.getAuthenticatedDevice();
|
||||||
|
|
||||||
|
if (!authenticatedDevice) {
|
||||||
|
throw new UnauthorizedException('Authorization header is required');
|
||||||
|
}
|
||||||
|
|
||||||
|
return authenticatedDevice;
|
||||||
|
}
|
||||||
|
|
||||||
|
getAuthenticatedDevice(): AuthenticatedDeviceContextValue | null {
|
||||||
|
return this.requestContext.getAuthenticatedDevice();
|
||||||
|
}
|
||||||
|
|
||||||
async resolveCurrentDevice(
|
async resolveCurrentDevice(
|
||||||
legacyDeviceId?: string,
|
legacyDeviceId?: string,
|
||||||
): Promise<AuthenticatedDeviceContextValue> {
|
): Promise<AuthenticatedDeviceContextValue> {
|
||||||
const authenticatedDevice = this.requestContext.getAuthenticatedDevice();
|
try {
|
||||||
|
return this.getAuthenticatedDeviceOrThrow();
|
||||||
if (authenticatedDevice) {
|
} catch (error) {
|
||||||
return authenticatedDevice;
|
if (!(error instanceof UnauthorizedException)) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const requestedDeviceId =
|
const requestedDeviceId =
|
||||||
|
|||||||
41
backend/src/modules/auth/optional-device-auth.guard.ts
Normal file
41
backend/src/modules/auth/optional-device-auth.guard.ts
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
import {
|
||||||
|
CanActivate,
|
||||||
|
ExecutionContext,
|
||||||
|
Injectable,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import type { Request } from 'express';
|
||||||
|
import { DeviceAuthService } from './device-auth.service';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class OptionalDeviceAuthGuard implements CanActivate {
|
||||||
|
constructor(private readonly deviceAuthService: DeviceAuthService) {}
|
||||||
|
|
||||||
|
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||||
|
const request = context.switchToHttp().getRequest<Request>();
|
||||||
|
const existingAuthenticatedDevice =
|
||||||
|
this.deviceAuthService.getAuthenticatedDevice();
|
||||||
|
|
||||||
|
if (existingAuthenticatedDevice) {
|
||||||
|
(request as Request & { authenticatedDevice?: unknown }).authenticatedDevice =
|
||||||
|
existingAuthenticatedDevice;
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const authorization = request.headers.authorization;
|
||||||
|
|
||||||
|
if (!authorization) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const authenticatedDevice =
|
||||||
|
await this.deviceAuthService.authenticateAuthorizationHeader(
|
||||||
|
authorization,
|
||||||
|
);
|
||||||
|
|
||||||
|
(request as Request & { authenticatedDevice?: unknown }).authenticatedDevice =
|
||||||
|
authenticatedDevice;
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,165 @@
|
|||||||
|
import { UnauthorizedException } from '@nestjs/common';
|
||||||
|
import type { NextFunction, Request, Response } from 'express';
|
||||||
|
import { ProtectedDeviceAuthMiddleware } from './protected-device-auth.middleware';
|
||||||
|
|
||||||
|
function createResponseMock() {
|
||||||
|
const response = {
|
||||||
|
status: jest.fn().mockReturnThis(),
|
||||||
|
json: jest.fn().mockReturnThis(),
|
||||||
|
} as unknown as Response;
|
||||||
|
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('ProtectedDeviceAuthMiddleware', () => {
|
||||||
|
it.each([
|
||||||
|
'/api/v1/library/tracks',
|
||||||
|
'/api/v1/sync/bootstrap',
|
||||||
|
'/api/v1/sync/changes',
|
||||||
|
'/api/v1/assets/asset-id/download',
|
||||||
|
'/api/v1/artwork/artwork-id/download',
|
||||||
|
'/api/v1/uploads/prepare',
|
||||||
|
'/api/v1/uploads/upload-id',
|
||||||
|
'/api/v1/uploads/upload-id/file',
|
||||||
|
'/api/v1/uploads/upload-id/finalize',
|
||||||
|
'/api/v1/devices/heartbeat',
|
||||||
|
])(
|
||||||
|
'returns 401 before validation-relevant request data can matter when Authorization is missing on %s',
|
||||||
|
async (path) => {
|
||||||
|
const authenticateAuthorizationHeader = jest.fn();
|
||||||
|
const middleware = new ProtectedDeviceAuthMiddleware({
|
||||||
|
authenticateAuthorizationHeader,
|
||||||
|
} as any);
|
||||||
|
const request = {
|
||||||
|
path,
|
||||||
|
originalUrl: `${path}?deviceId=not-a-uuid`,
|
||||||
|
headers: {},
|
||||||
|
body: {
|
||||||
|
deviceId: 'not-a-uuid',
|
||||||
|
appVersion: '',
|
||||||
|
sha256: 'invalid-sha',
|
||||||
|
},
|
||||||
|
query: {
|
||||||
|
deviceId: 'not-a-uuid',
|
||||||
|
after: 'not-a-cursor',
|
||||||
|
},
|
||||||
|
} as unknown as Request;
|
||||||
|
const response = createResponseMock();
|
||||||
|
const next = jest.fn() as NextFunction;
|
||||||
|
|
||||||
|
await middleware.use(request, response, next);
|
||||||
|
|
||||||
|
expect(authenticateAuthorizationHeader).not.toHaveBeenCalled();
|
||||||
|
expect(response.status).toHaveBeenCalledWith(401);
|
||||||
|
expect(response.json).toHaveBeenCalledWith({
|
||||||
|
statusCode: 401,
|
||||||
|
message: 'Authorization header is required',
|
||||||
|
error: 'Unauthorized',
|
||||||
|
});
|
||||||
|
expect(next).not.toHaveBeenCalled();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
'/api/v1/library/tracks',
|
||||||
|
'/api/v1/sync/changes',
|
||||||
|
'/api/v1/uploads/prepare',
|
||||||
|
'/api/v1/devices/heartbeat',
|
||||||
|
])(
|
||||||
|
'returns 401 before validation-relevant request data can matter when Authorization is invalid on %s',
|
||||||
|
async (path) => {
|
||||||
|
const authenticateAuthorizationHeader = jest
|
||||||
|
.fn()
|
||||||
|
.mockRejectedValue(
|
||||||
|
new UnauthorizedException('Invalid device access token'),
|
||||||
|
);
|
||||||
|
const middleware = new ProtectedDeviceAuthMiddleware({
|
||||||
|
authenticateAuthorizationHeader,
|
||||||
|
} as any);
|
||||||
|
const request = {
|
||||||
|
path,
|
||||||
|
originalUrl: `${path}?deviceId=not-a-uuid`,
|
||||||
|
headers: {
|
||||||
|
authorization: 'Bearer invalid-device-token',
|
||||||
|
},
|
||||||
|
body: {
|
||||||
|
deviceId: 'not-a-uuid',
|
||||||
|
appVersion: '',
|
||||||
|
sha256: 'invalid-sha',
|
||||||
|
},
|
||||||
|
query: {
|
||||||
|
deviceId: 'not-a-uuid',
|
||||||
|
after: 'not-a-cursor',
|
||||||
|
},
|
||||||
|
} as unknown as Request;
|
||||||
|
const response = createResponseMock();
|
||||||
|
const next = jest.fn() as NextFunction;
|
||||||
|
|
||||||
|
await middleware.use(request, response, next);
|
||||||
|
|
||||||
|
expect(authenticateAuthorizationHeader).toHaveBeenCalledWith(
|
||||||
|
'Bearer invalid-device-token',
|
||||||
|
);
|
||||||
|
expect(response.status).toHaveBeenCalledWith(401);
|
||||||
|
expect(response.json).toHaveBeenCalledWith({
|
||||||
|
statusCode: 401,
|
||||||
|
message: 'Invalid device access token',
|
||||||
|
error: 'Unauthorized',
|
||||||
|
});
|
||||||
|
expect(next).not.toHaveBeenCalled();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
it('allows public routes through without requiring Authorization', async () => {
|
||||||
|
const authenticateAuthorizationHeader = jest.fn();
|
||||||
|
const middleware = new ProtectedDeviceAuthMiddleware({
|
||||||
|
authenticateAuthorizationHeader,
|
||||||
|
} as any);
|
||||||
|
const request = {
|
||||||
|
path: '/api/v1/devices/register',
|
||||||
|
originalUrl: '/api/v1/devices/register',
|
||||||
|
headers: {},
|
||||||
|
} as unknown as Request;
|
||||||
|
const response = createResponseMock();
|
||||||
|
const next = jest.fn() as NextFunction;
|
||||||
|
|
||||||
|
await middleware.use(request, response, next);
|
||||||
|
|
||||||
|
expect(authenticateAuthorizationHeader).not.toHaveBeenCalled();
|
||||||
|
expect(response.status).not.toHaveBeenCalled();
|
||||||
|
expect(response.json).not.toHaveBeenCalled();
|
||||||
|
expect(next).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('stores the authenticated device and proceeds when Authorization is valid', async () => {
|
||||||
|
const authenticatedDevice = {
|
||||||
|
deviceId: 'device-id',
|
||||||
|
userId: 'user-id',
|
||||||
|
};
|
||||||
|
const authenticateAuthorizationHeader = jest
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValue(authenticatedDevice);
|
||||||
|
const middleware = new ProtectedDeviceAuthMiddleware({
|
||||||
|
authenticateAuthorizationHeader,
|
||||||
|
} as any);
|
||||||
|
const request = {
|
||||||
|
path: '/api/v1/library/tracks',
|
||||||
|
originalUrl: '/api/v1/library/tracks?deviceId=not-a-uuid',
|
||||||
|
headers: {
|
||||||
|
authorization: 'Bearer valid-device-token',
|
||||||
|
},
|
||||||
|
} as Request & { authenticatedDevice?: unknown };
|
||||||
|
const response = createResponseMock();
|
||||||
|
const next = jest.fn() as NextFunction;
|
||||||
|
|
||||||
|
await middleware.use(request, response, next);
|
||||||
|
|
||||||
|
expect(authenticateAuthorizationHeader).toHaveBeenCalledWith(
|
||||||
|
'Bearer valid-device-token',
|
||||||
|
);
|
||||||
|
expect(request.authenticatedDevice).toEqual(authenticatedDevice);
|
||||||
|
expect(response.status).not.toHaveBeenCalled();
|
||||||
|
expect(response.json).not.toHaveBeenCalled();
|
||||||
|
expect(next).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
82
backend/src/modules/auth/protected-device-auth.middleware.ts
Normal file
82
backend/src/modules/auth/protected-device-auth.middleware.ts
Normal file
@ -0,0 +1,82 @@
|
|||||||
|
import {
|
||||||
|
HttpException,
|
||||||
|
Injectable,
|
||||||
|
NestMiddleware,
|
||||||
|
UnauthorizedException,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import type { NextFunction, Request, Response } from 'express';
|
||||||
|
import { DeviceAuthService } from './device-auth.service';
|
||||||
|
|
||||||
|
const PROTECTED_ROUTE_PREFIXES = [
|
||||||
|
'/api/v1/artwork',
|
||||||
|
'/api/v1/assets',
|
||||||
|
'/api/v1/library',
|
||||||
|
'/api/v1/sync',
|
||||||
|
'/api/v1/uploads',
|
||||||
|
];
|
||||||
|
|
||||||
|
const PROTECTED_ROUTE_EXACT_PATHS = new Set(['/api/v1/devices/heartbeat']);
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class ProtectedDeviceAuthMiddleware implements NestMiddleware {
|
||||||
|
constructor(private readonly deviceAuthService: DeviceAuthService) {}
|
||||||
|
|
||||||
|
async use(
|
||||||
|
request: Request,
|
||||||
|
response: Response,
|
||||||
|
next: NextFunction,
|
||||||
|
): Promise<void> {
|
||||||
|
if (!this.isProtectedRoute(request)) {
|
||||||
|
next();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const authorization = request.headers.authorization;
|
||||||
|
|
||||||
|
if (!authorization) {
|
||||||
|
this.replyWithException(
|
||||||
|
response,
|
||||||
|
new UnauthorizedException('Authorization header is required'),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const authenticatedDevice =
|
||||||
|
await this.deviceAuthService.authenticateAuthorizationHeader(
|
||||||
|
authorization,
|
||||||
|
);
|
||||||
|
|
||||||
|
(request as Request & { authenticatedDevice?: unknown }).authenticatedDevice =
|
||||||
|
authenticatedDevice;
|
||||||
|
|
||||||
|
next();
|
||||||
|
} catch (error) {
|
||||||
|
if (!(error instanceof UnauthorizedException)) {
|
||||||
|
next(error as Error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.replyWithException(response, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private isProtectedRoute(request: Request): boolean {
|
||||||
|
const requestPath = request.path ?? request.originalUrl.split('?')[0];
|
||||||
|
|
||||||
|
if (PROTECTED_ROUTE_EXACT_PATHS.has(requestPath)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return PROTECTED_ROUTE_PREFIXES.some(
|
||||||
|
(prefix) => requestPath === prefix || requestPath.startsWith(`${prefix}/`),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private replyWithException(
|
||||||
|
response: Response,
|
||||||
|
exception: HttpException,
|
||||||
|
): void {
|
||||||
|
response.status(exception.getStatus()).json(exception.getResponse());
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -6,6 +6,7 @@ import {
|
|||||||
ApiTags,
|
ApiTags,
|
||||||
} from '@nestjs/swagger';
|
} from '@nestjs/swagger';
|
||||||
import { DeviceAuthGuard } from '../auth/device-auth.guard';
|
import { DeviceAuthGuard } from '../auth/device-auth.guard';
|
||||||
|
import { OptionalDeviceAuthGuard } from '../auth/optional-device-auth.guard';
|
||||||
import {
|
import {
|
||||||
DeviceHeartbeatRequestDto,
|
DeviceHeartbeatRequestDto,
|
||||||
DeviceHeartbeatResponseDto,
|
DeviceHeartbeatResponseDto,
|
||||||
@ -23,8 +24,7 @@ export class DevicesController {
|
|||||||
constructor(private readonly devicesService: DevicesService) {}
|
constructor(private readonly devicesService: DevicesService) {}
|
||||||
|
|
||||||
@Post('register')
|
@Post('register')
|
||||||
@UseGuards(DeviceAuthGuard)
|
@UseGuards(OptionalDeviceAuthGuard)
|
||||||
@ApiBearerAuth()
|
|
||||||
@ApiCreatedResponse({ type: RegisterDeviceResponseDto })
|
@ApiCreatedResponse({ type: RegisterDeviceResponseDto })
|
||||||
async register(
|
async register(
|
||||||
@Body() body: RegisterDeviceRequestDto,
|
@Body() body: RegisterDeviceRequestDto,
|
||||||
|
|||||||
@ -46,7 +46,7 @@ export class DeviceHeartbeatRequestDto {
|
|||||||
format: 'uuid',
|
format: 'uuid',
|
||||||
required: false,
|
required: false,
|
||||||
description:
|
description:
|
||||||
'Legacy migration fallback. Omit when Authorization: Bearer <deviceAccessToken> is provided.',
|
'Optional client metadata. Authorization: Bearer <deviceAccessToken> is required and determines access.',
|
||||||
})
|
})
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsUUID()
|
@IsUUID()
|
||||||
|
|||||||
@ -25,7 +25,7 @@ describe('DevicesService', () => {
|
|||||||
const deviceAuthService = {
|
const deviceAuthService = {
|
||||||
generateDeviceAccessToken: jest.fn().mockReturnValue('device-access-token'),
|
generateDeviceAccessToken: jest.fn().mockReturnValue('device-access-token'),
|
||||||
hashDeviceAccessToken: jest.fn().mockReturnValue('device-token-hash'),
|
hashDeviceAccessToken: jest.fn().mockReturnValue('device-token-hash'),
|
||||||
resolveCurrentDevice: jest.fn(),
|
getAuthenticatedDeviceOrThrow: jest.fn(),
|
||||||
} as any;
|
} as any;
|
||||||
const service = new DevicesService(
|
const service = new DevicesService(
|
||||||
prismaService,
|
prismaService,
|
||||||
@ -65,9 +65,9 @@ describe('DevicesService', () => {
|
|||||||
const deviceAuthService = {
|
const deviceAuthService = {
|
||||||
generateDeviceAccessToken: jest.fn(),
|
generateDeviceAccessToken: jest.fn(),
|
||||||
hashDeviceAccessToken: jest.fn(),
|
hashDeviceAccessToken: jest.fn(),
|
||||||
resolveCurrentDevice: jest
|
getAuthenticatedDeviceOrThrow: jest.fn().mockImplementation(() => {
|
||||||
.fn()
|
throw new NotFoundException('Device not found');
|
||||||
.mockRejectedValue(new NotFoundException('Device not found')),
|
}),
|
||||||
} as any;
|
} as any;
|
||||||
const service = new DevicesService(
|
const service = new DevicesService(
|
||||||
prismaService,
|
prismaService,
|
||||||
|
|||||||
@ -54,7 +54,7 @@ export class DevicesService {
|
|||||||
async heartbeat(
|
async heartbeat(
|
||||||
body: DeviceHeartbeatRequestDto,
|
body: DeviceHeartbeatRequestDto,
|
||||||
): Promise<DeviceHeartbeatResponseDto> {
|
): Promise<DeviceHeartbeatResponseDto> {
|
||||||
const device = await this.deviceAuthService.resolveCurrentDevice(body.deviceId);
|
const device = this.deviceAuthService.getAuthenticatedDeviceOrThrow();
|
||||||
|
|
||||||
await this.prismaService.device.update({
|
await this.prismaService.device.update({
|
||||||
where: { id: device.deviceId },
|
where: { id: device.deviceId },
|
||||||
@ -69,9 +69,4 @@ export class DevicesService {
|
|||||||
serverTime: new Date().toISOString(),
|
serverTime: new Date().toISOString(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private async resolveCurrentOwnerUserId(): Promise<string> {
|
|
||||||
const owner = await this.ownerContext.resolve();
|
|
||||||
return owner.userId;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -22,8 +22,9 @@ export class LibraryController {
|
|||||||
async getTracks(
|
async getTracks(
|
||||||
@Query() query: LibraryTracksQueryDto,
|
@Query() query: LibraryTracksQueryDto,
|
||||||
): Promise<LibraryTracksResponseDto> {
|
): Promise<LibraryTracksResponseDto> {
|
||||||
|
void query;
|
||||||
return {
|
return {
|
||||||
tracks: await this.libraryService.getRemoteLibraryTracks(query.deviceId),
|
tracks: await this.libraryService.getRemoteLibraryTracks(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -6,7 +6,7 @@ export class LibraryTracksQueryDto {
|
|||||||
format: 'uuid',
|
format: 'uuid',
|
||||||
required: false,
|
required: false,
|
||||||
description:
|
description:
|
||||||
'Legacy migration fallback. Omit when Authorization: Bearer <deviceAccessToken> is provided.',
|
'Optional client metadata. Authorization: Bearer <deviceAccessToken> is required and determines access.',
|
||||||
})
|
})
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsUUID()
|
@IsUUID()
|
||||||
|
|||||||
@ -60,7 +60,7 @@ describe('LibraryService', () => {
|
|||||||
resolve: jest.Mock;
|
resolve: jest.Mock;
|
||||||
};
|
};
|
||||||
let deviceAuthServiceMock: {
|
let deviceAuthServiceMock: {
|
||||||
resolveCurrentDevice: jest.Mock;
|
getAuthenticatedDeviceOrThrow: jest.Mock;
|
||||||
};
|
};
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
@ -70,7 +70,7 @@ describe('LibraryService', () => {
|
|||||||
resolve: jest.fn(),
|
resolve: jest.fn(),
|
||||||
};
|
};
|
||||||
deviceAuthServiceMock = {
|
deviceAuthServiceMock = {
|
||||||
resolveCurrentDevice: jest.fn(),
|
getAuthenticatedDeviceOrThrow: jest.fn(),
|
||||||
};
|
};
|
||||||
|
|
||||||
const moduleRef = await Test.createTestingModule({
|
const moduleRef = await Test.createTestingModule({
|
||||||
@ -162,7 +162,7 @@ describe('LibraryService', () => {
|
|||||||
userId: ownerId,
|
userId: ownerId,
|
||||||
});
|
});
|
||||||
state.devices.set(ownerDeviceId, { id: ownerDeviceId, userId: ownerId });
|
state.devices.set(ownerDeviceId, { id: ownerDeviceId, userId: ownerId });
|
||||||
deviceAuthServiceMock.resolveCurrentDevice.mockResolvedValue({
|
deviceAuthServiceMock.getAuthenticatedDeviceOrThrow.mockReturnValue({
|
||||||
deviceId: ownerDeviceId,
|
deviceId: ownerDeviceId,
|
||||||
userId: ownerId,
|
userId: ownerId,
|
||||||
});
|
});
|
||||||
@ -225,7 +225,7 @@ describe('LibraryService', () => {
|
|||||||
updatedAt: new Date('2026-05-29T08:02:00.000Z'),
|
updatedAt: new Date('2026-05-29T08:02:00.000Z'),
|
||||||
});
|
});
|
||||||
|
|
||||||
const tracks = await libraryService.getRemoteLibraryTracks(ownerDeviceId);
|
const tracks = await libraryService.getRemoteLibraryTracks();
|
||||||
|
|
||||||
expect(tracks).toEqual([
|
expect(tracks).toEqual([
|
||||||
{
|
{
|
||||||
@ -266,14 +266,12 @@ describe('LibraryService', () => {
|
|||||||
userId: ownerId,
|
userId: ownerId,
|
||||||
});
|
});
|
||||||
state.devices.set(ownerDeviceId, { id: ownerDeviceId, userId: ownerId });
|
state.devices.set(ownerDeviceId, { id: ownerDeviceId, userId: ownerId });
|
||||||
deviceAuthServiceMock.resolveCurrentDevice.mockResolvedValue({
|
deviceAuthServiceMock.getAuthenticatedDeviceOrThrow.mockReturnValue({
|
||||||
deviceId: ownerDeviceId,
|
deviceId: ownerDeviceId,
|
||||||
userId: ownerId,
|
userId: ownerId,
|
||||||
});
|
});
|
||||||
|
|
||||||
await expect(
|
await expect(libraryService.getRemoteLibraryTracks()).resolves.toEqual([]);
|
||||||
libraryService.getRemoteLibraryTracks(ownerDeviceId),
|
|
||||||
).resolves.toEqual([]);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('does not leak remote library tracks from other owners', async () => {
|
it('does not leak remote library tracks from other owners', async () => {
|
||||||
@ -287,7 +285,7 @@ describe('LibraryService', () => {
|
|||||||
userId: ownerId,
|
userId: ownerId,
|
||||||
});
|
});
|
||||||
state.devices.set(ownerDeviceId, { id: ownerDeviceId, userId: ownerId });
|
state.devices.set(ownerDeviceId, { id: ownerDeviceId, userId: ownerId });
|
||||||
deviceAuthServiceMock.resolveCurrentDevice.mockResolvedValue({
|
deviceAuthServiceMock.getAuthenticatedDeviceOrThrow.mockReturnValue({
|
||||||
deviceId: ownerDeviceId,
|
deviceId: ownerDeviceId,
|
||||||
userId: ownerId,
|
userId: ownerId,
|
||||||
});
|
});
|
||||||
@ -308,19 +306,19 @@ describe('LibraryService', () => {
|
|||||||
updatedAt: new Date('2026-05-29T08:01:00.000Z'),
|
updatedAt: new Date('2026-05-29T08:01:00.000Z'),
|
||||||
});
|
});
|
||||||
|
|
||||||
await expect(
|
await expect(libraryService.getRemoteLibraryTracks()).resolves.toEqual([]);
|
||||||
libraryService.getRemoteLibraryTracks(ownerDeviceId),
|
|
||||||
).resolves.toEqual([]);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('throws for an unknown device', async () => {
|
it('throws for an unknown device', async () => {
|
||||||
deviceAuthServiceMock.resolveCurrentDevice.mockRejectedValueOnce(
|
deviceAuthServiceMock.getAuthenticatedDeviceOrThrow.mockImplementationOnce(
|
||||||
new NotFoundException('Device not found'),
|
() => {
|
||||||
|
throw new NotFoundException('Device not found');
|
||||||
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
await expect(
|
await expect(libraryService.getRemoteLibraryTracks()).rejects.toBeInstanceOf(
|
||||||
libraryService.getRemoteLibraryTracks(randomUUID()),
|
NotFoundException,
|
||||||
).rejects.toBeInstanceOf(NotFoundException);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('rejects cross-owner track access through a foreign-owner device', async () => {
|
it('rejects cross-owner track access through a foreign-owner device', async () => {
|
||||||
@ -333,13 +331,15 @@ describe('LibraryService', () => {
|
|||||||
id: foreignDeviceId,
|
id: foreignDeviceId,
|
||||||
userId: otherUserId,
|
userId: otherUserId,
|
||||||
});
|
});
|
||||||
deviceAuthServiceMock.resolveCurrentDevice.mockRejectedValueOnce(
|
deviceAuthServiceMock.getAuthenticatedDeviceOrThrow.mockImplementationOnce(
|
||||||
new NotFoundException('Device not found'),
|
() => {
|
||||||
|
throw new NotFoundException('Device not found');
|
||||||
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
await expect(
|
await expect(libraryService.getRemoteLibraryTracks()).rejects.toBeInstanceOf(
|
||||||
libraryService.getRemoteLibraryTracks(foreignDeviceId),
|
NotFoundException,
|
||||||
).rejects.toBeInstanceOf(NotFoundException);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('skips tracks without a primary audio asset', async () => {
|
it('skips tracks without a primary audio asset', async () => {
|
||||||
@ -349,7 +349,7 @@ describe('LibraryService', () => {
|
|||||||
userId: ownerId,
|
userId: ownerId,
|
||||||
});
|
});
|
||||||
state.devices.set(ownerDeviceId, { id: ownerDeviceId, userId: ownerId });
|
state.devices.set(ownerDeviceId, { id: ownerDeviceId, userId: ownerId });
|
||||||
deviceAuthServiceMock.resolveCurrentDevice.mockResolvedValue({
|
deviceAuthServiceMock.getAuthenticatedDeviceOrThrow.mockReturnValue({
|
||||||
deviceId: ownerDeviceId,
|
deviceId: ownerDeviceId,
|
||||||
userId: ownerId,
|
userId: ownerId,
|
||||||
});
|
});
|
||||||
@ -366,8 +366,6 @@ describe('LibraryService', () => {
|
|||||||
updatedAt: new Date('2026-05-29T08:01:00.000Z'),
|
updatedAt: new Date('2026-05-29T08:01:00.000Z'),
|
||||||
});
|
});
|
||||||
|
|
||||||
await expect(
|
await expect(libraryService.getRemoteLibraryTracks()).resolves.toEqual([]);
|
||||||
libraryService.getRemoteLibraryTracks(ownerDeviceId),
|
|
||||||
).resolves.toEqual([]);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -37,11 +37,9 @@ export class LibraryService {
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
async getRemoteLibraryTracks(
|
async getRemoteLibraryTracks(): Promise<RemoteLibraryTrackDto[]> {
|
||||||
legacyDeviceId?: string,
|
|
||||||
): Promise<RemoteLibraryTrackDto[]> {
|
|
||||||
const { userId: ownerUserId } =
|
const { userId: ownerUserId } =
|
||||||
await this.deviceAuthService.resolveCurrentDevice(legacyDeviceId);
|
this.deviceAuthService.getAuthenticatedDeviceOrThrow();
|
||||||
|
|
||||||
const tracks = await this.prismaService.track.findMany({
|
const tracks = await this.prismaService.track.findMany({
|
||||||
where: {
|
where: {
|
||||||
@ -113,7 +111,10 @@ export class LibraryService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async resolveCurrentOwnerUserId(): Promise<string> {
|
private async resolveCurrentOwnerUserId(): Promise<string> {
|
||||||
const owner = await this.ownerContext.resolve();
|
const owner = await this.ownerContext.resolve({
|
||||||
|
allowLegacyDeviceFallback: false,
|
||||||
|
allowBootstrapFallback: false,
|
||||||
|
});
|
||||||
return owner.userId;
|
return owner.userId;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -48,7 +48,7 @@ export class SyncBootstrapQueryDto {
|
|||||||
format: 'uuid',
|
format: 'uuid',
|
||||||
required: false,
|
required: false,
|
||||||
description:
|
description:
|
||||||
'Legacy migration fallback. Omit when Authorization: Bearer <deviceAccessToken> is provided.',
|
'Optional client metadata. Authorization: Bearer <deviceAccessToken> is required and determines access.',
|
||||||
})
|
})
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsUUID()
|
@IsUUID()
|
||||||
|
|||||||
@ -39,7 +39,10 @@ export class SyncService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async getLatestCursor(): Promise<string> {
|
private async getLatestCursor(): Promise<string> {
|
||||||
const owner = await this.ownerContext.resolve();
|
const owner = await this.ownerContext.resolve({
|
||||||
|
allowLegacyDeviceFallback: false,
|
||||||
|
allowBootstrapFallback: false,
|
||||||
|
});
|
||||||
const latest = await this.prismaService.libraryEvent.findFirst({
|
const latest = await this.prismaService.libraryEvent.findFirst({
|
||||||
where: {
|
where: {
|
||||||
userId: owner.userId,
|
userId: owner.userId,
|
||||||
|
|||||||
@ -18,7 +18,7 @@ export class UploadPrepareRequestDto {
|
|||||||
format: 'uuid',
|
format: 'uuid',
|
||||||
required: false,
|
required: false,
|
||||||
description:
|
description:
|
||||||
'Legacy migration fallback. Omit when Authorization: Bearer <deviceAccessToken> is provided.',
|
'Optional client metadata. Authorization: Bearer <deviceAccessToken> is required and determines access.',
|
||||||
})
|
})
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsUUID()
|
@IsUUID()
|
||||||
|
|||||||
@ -285,8 +285,9 @@ describe('UploadsService', () => {
|
|||||||
let storageService: LocalFilesystemStorageService;
|
let storageService: LocalFilesystemStorageService;
|
||||||
let service: UploadsService;
|
let service: UploadsService;
|
||||||
let ownerContext: OwnerContext;
|
let ownerContext: OwnerContext;
|
||||||
|
let currentAuthenticatedDeviceId: string | null;
|
||||||
let deviceAuthService: {
|
let deviceAuthService: {
|
||||||
resolveCurrentDevice: jest.Mock;
|
getAuthenticatedDeviceOrThrow: jest.Mock;
|
||||||
};
|
};
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
@ -295,18 +296,19 @@ describe('UploadsService', () => {
|
|||||||
state = mock.state;
|
state = mock.state;
|
||||||
storageRoot = await mkdtemp(join(tmpdir(), 'velody-upload-spec-'));
|
storageRoot = await mkdtemp(join(tmpdir(), 'velody-upload-spec-'));
|
||||||
storageService = new LocalFilesystemStorageService(createAppConfig(storageRoot));
|
storageService = new LocalFilesystemStorageService(createAppConfig(storageRoot));
|
||||||
|
currentAuthenticatedDeviceId = null;
|
||||||
ownerContext = {
|
ownerContext = {
|
||||||
resolve: jest.fn().mockResolvedValue({
|
resolve: jest.fn().mockResolvedValue({
|
||||||
userId: state.defaultUser.id,
|
userId: state.defaultUser.id,
|
||||||
}),
|
}),
|
||||||
} as OwnerContext;
|
} as OwnerContext;
|
||||||
deviceAuthService = {
|
deviceAuthService = {
|
||||||
resolveCurrentDevice: jest.fn().mockImplementation(async (deviceId?: string) => {
|
getAuthenticatedDeviceOrThrow: jest.fn().mockImplementation(() => {
|
||||||
if (!deviceId) {
|
if (!currentAuthenticatedDeviceId) {
|
||||||
throw new NotFoundException('Device not found');
|
throw new NotFoundException('Device not found');
|
||||||
}
|
}
|
||||||
|
|
||||||
const device = state.devices.get(deviceId);
|
const device = state.devices.get(currentAuthenticatedDeviceId);
|
||||||
|
|
||||||
if (!device || device.userId !== state.defaultUser.id) {
|
if (!device || device.userId !== state.defaultUser.id) {
|
||||||
throw new NotFoundException('Device not found');
|
throw new NotFoundException('Device not found');
|
||||||
@ -345,6 +347,7 @@ describe('UploadsService', () => {
|
|||||||
updatedAt: new Date(),
|
updatedAt: new Date(),
|
||||||
};
|
};
|
||||||
state.devices.set(deviceId, device);
|
state.devices.set(deviceId, device);
|
||||||
|
currentAuthenticatedDeviceId = deviceId;
|
||||||
return device;
|
return device;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -54,7 +54,7 @@ export class UploadsService {
|
|||||||
this.assertFileSizeWithinLimit(body.sizeBytes);
|
this.assertFileSizeWithinLimit(body.sizeBytes);
|
||||||
this.assertMp3Filename(body.originalFilename);
|
this.assertMp3Filename(body.originalFilename);
|
||||||
|
|
||||||
const device = await this.deviceAuthService.resolveCurrentDevice(body.deviceId);
|
const device = this.deviceAuthService.getAuthenticatedDeviceOrThrow();
|
||||||
const ownerUserId = device.userId;
|
const ownerUserId = device.userId;
|
||||||
|
|
||||||
const existingAsset = await this.prismaService.audioAsset.findUnique({
|
const existingAsset = await this.prismaService.audioAsset.findUnique({
|
||||||
@ -477,7 +477,10 @@ export class UploadsService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async resolveCurrentOwnerUserId(): Promise<string> {
|
private async resolveCurrentOwnerUserId(): Promise<string> {
|
||||||
const owner = await this.ownerContext.resolve();
|
const owner = await this.ownerContext.resolve({
|
||||||
|
allowLegacyDeviceFallback: false,
|
||||||
|
allowBootstrapFallback: false,
|
||||||
|
});
|
||||||
return owner.userId;
|
return owner.userId;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -2,6 +2,7 @@ import {
|
|||||||
BadRequestException,
|
BadRequestException,
|
||||||
Injectable,
|
Injectable,
|
||||||
NotFoundException,
|
NotFoundException,
|
||||||
|
UnauthorizedException,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { PrismaService } from '../../infrastructure/database/prisma.service';
|
import { PrismaService } from '../../infrastructure/database/prisma.service';
|
||||||
import { RequestContextService } from '../../infrastructure/request-context/request-context.service';
|
import { RequestContextService } from '../../infrastructure/request-context/request-context.service';
|
||||||
@ -11,8 +12,15 @@ export interface ResolvedOwnerContext {
|
|||||||
userId: string;
|
userId: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface OwnerContextResolveOptions {
|
||||||
|
allowLegacyDeviceFallback?: boolean;
|
||||||
|
allowBootstrapFallback?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
export abstract class OwnerContext {
|
export abstract class OwnerContext {
|
||||||
abstract resolve(): Promise<ResolvedOwnerContext>;
|
abstract resolve(
|
||||||
|
options?: OwnerContextResolveOptions,
|
||||||
|
): Promise<ResolvedOwnerContext>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const UUID_PATTERN =
|
const UUID_PATTERN =
|
||||||
@ -28,7 +36,13 @@ export class BootstrapOwnerContextService extends OwnerContext {
|
|||||||
super();
|
super();
|
||||||
}
|
}
|
||||||
|
|
||||||
async resolve(): Promise<ResolvedOwnerContext> {
|
private async resolveWithOptions(
|
||||||
|
options: OwnerContextResolveOptions = {},
|
||||||
|
): Promise<ResolvedOwnerContext> {
|
||||||
|
const {
|
||||||
|
allowLegacyDeviceFallback = true,
|
||||||
|
allowBootstrapFallback = true,
|
||||||
|
} = options;
|
||||||
const authenticatedDevice = this.requestContext.getAuthenticatedDevice();
|
const authenticatedDevice = this.requestContext.getAuthenticatedDevice();
|
||||||
|
|
||||||
if (authenticatedDevice) {
|
if (authenticatedDevice) {
|
||||||
@ -39,7 +53,7 @@ export class BootstrapOwnerContextService extends OwnerContext {
|
|||||||
|
|
||||||
const legacyDeviceId = this.requestContext.getLegacyDeviceId();
|
const legacyDeviceId = this.requestContext.getLegacyDeviceId();
|
||||||
|
|
||||||
if (legacyDeviceId) {
|
if (allowLegacyDeviceFallback && legacyDeviceId) {
|
||||||
if (!UUID_PATTERN.test(legacyDeviceId)) {
|
if (!UUID_PATTERN.test(legacyDeviceId)) {
|
||||||
throw new BadRequestException('deviceId must be a UUID');
|
throw new BadRequestException('deviceId must be a UUID');
|
||||||
}
|
}
|
||||||
@ -62,10 +76,20 @@ export class BootstrapOwnerContextService extends OwnerContext {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!allowBootstrapFallback) {
|
||||||
|
throw new UnauthorizedException('Authorization header is required');
|
||||||
|
}
|
||||||
|
|
||||||
const defaultUser = await this.defaultUserService.getOrCreateDefaultUser();
|
const defaultUser = await this.defaultUserService.getOrCreateDefaultUser();
|
||||||
|
|
||||||
return {
|
return {
|
||||||
userId: defaultUser.id,
|
userId: defaultUser.id,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async resolve(
|
||||||
|
options?: OwnerContextResolveOptions,
|
||||||
|
): Promise<ResolvedOwnerContext> {
|
||||||
|
return this.resolveWithOptions(options);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -520,12 +520,19 @@ describe('Velody API wiring (e2e)', () => {
|
|||||||
expect.any(Date),
|
expect.any(Date),
|
||||||
);
|
);
|
||||||
|
|
||||||
const heartbeatResponse = await devicesController.heartbeat({
|
const heartbeatResponse = await runAsDevice(
|
||||||
|
registerResponse.deviceAccessToken,
|
||||||
|
() =>
|
||||||
|
devicesController.heartbeat({
|
||||||
deviceId: registerResponse.deviceId,
|
deviceId: registerResponse.deviceId,
|
||||||
appVersion: '0.1.1',
|
appVersion: '0.1.1',
|
||||||
});
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
expect(heartbeatResponse.ok).toBe(true);
|
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 () => {
|
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);
|
).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();
|
const foreignDeviceId = randomUUID();
|
||||||
prismaState.devices.set(foreignDeviceId, {
|
prismaState.devices.set(foreignDeviceId, {
|
||||||
id: foreignDeviceId,
|
id: foreignDeviceId,
|
||||||
@ -603,18 +672,32 @@ describe('Velody API wiring (e2e)', () => {
|
|||||||
updatedAt: new Date(),
|
updatedAt: new Date(),
|
||||||
});
|
});
|
||||||
|
|
||||||
const response = await devicesController.heartbeat({
|
const response = await runAsDevice(ownerDevice.deviceAccessToken, () =>
|
||||||
|
devicesController.heartbeat({
|
||||||
deviceId: foreignDeviceId,
|
deviceId: foreignDeviceId,
|
||||||
appVersion: '0.1.1',
|
appVersion: '0.1.1',
|
||||||
});
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
expect(response.ok).toBe(true);
|
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 () => {
|
it('returns sync bootstrap and changes payloads', async () => {
|
||||||
const bootstrapResponse = await syncController.bootstrap();
|
const device = await devicesController.register({
|
||||||
const changesResponse = await syncController.changes({ after: '0' });
|
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(bootstrapResponse.tracks).toEqual([]);
|
||||||
expect(changesResponse.events).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 () => {
|
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 foreignUserId = randomUUID();
|
||||||
const foreignTrackId = randomUUID();
|
const foreignTrackId = randomUUID();
|
||||||
|
|
||||||
@ -654,8 +742,12 @@ describe('Velody API wiring (e2e)', () => {
|
|||||||
createdAt: new Date('2026-05-29T08:02:00.000Z'),
|
createdAt: new Date('2026-05-29T08:02:00.000Z'),
|
||||||
});
|
});
|
||||||
|
|
||||||
const bootstrapResponse = await syncController.bootstrap();
|
const bootstrapResponse = await runAsDevice(device.deviceAccessToken, () =>
|
||||||
const changesResponse = await syncController.changes({ after: '0' });
|
syncController.bootstrap(),
|
||||||
|
);
|
||||||
|
const changesResponse = await runAsDevice(device.deviceAccessToken, () =>
|
||||||
|
syncController.changes({ after: '0' }),
|
||||||
|
);
|
||||||
|
|
||||||
expect(bootstrapResponse.tracks).toEqual([]);
|
expect(bootstrapResponse.tracks).toEqual([]);
|
||||||
expect(changesResponse.events).toEqual([]);
|
expect(changesResponse.events).toEqual([]);
|
||||||
@ -704,10 +796,12 @@ describe('Velody API wiring (e2e)', () => {
|
|||||||
},
|
},
|
||||||
} as any;
|
} as any;
|
||||||
|
|
||||||
const streamable = await assetsController.download(
|
const streamable = await runAsDevice(registerResponse.deviceAccessToken, () =>
|
||||||
|
assetsController.download(
|
||||||
assetId,
|
assetId,
|
||||||
{ deviceId: registerResponse.deviceId },
|
{ deviceId: registerResponse.deviceId },
|
||||||
responseMock,
|
responseMock,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
const downloadedBytes = await streamToBuffer(streamable.getStream());
|
const downloadedBytes = await streamToBuffer(streamable.getStream());
|
||||||
|
|
||||||
@ -741,11 +835,13 @@ describe('Velody API wiring (e2e)', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
|
runAsDevice(registerResponse.deviceAccessToken, () =>
|
||||||
assetsController.download(
|
assetsController.download(
|
||||||
assetId,
|
assetId,
|
||||||
{ deviceId: registerResponse.deviceId },
|
{ deviceId: registerResponse.deviceId },
|
||||||
{ setHeader() {} } as any,
|
{ setHeader() {} } as any,
|
||||||
),
|
),
|
||||||
|
),
|
||||||
).rejects.toBeInstanceOf(ForbiddenException);
|
).rejects.toBeInstanceOf(ForbiddenException);
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -778,11 +874,13 @@ describe('Velody API wiring (e2e)', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
|
runAsDevice(registerResponse.deviceAccessToken, () =>
|
||||||
assetsController.download(
|
assetsController.download(
|
||||||
assetId,
|
assetId,
|
||||||
{ deviceId: registerResponse.deviceId },
|
{ deviceId: registerResponse.deviceId },
|
||||||
{ setHeader() {} } as any,
|
{ setHeader() {} } as any,
|
||||||
),
|
),
|
||||||
|
),
|
||||||
).rejects.toBeInstanceOf(NotFoundException);
|
).rejects.toBeInstanceOf(NotFoundException);
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -848,10 +946,12 @@ describe('Velody API wiring (e2e)', () => {
|
|||||||
},
|
},
|
||||||
} as any;
|
} as any;
|
||||||
|
|
||||||
const streamable = await artworkController.download(
|
const streamable = await runAsDevice(registerResponse.deviceAccessToken, () =>
|
||||||
|
artworkController.download(
|
||||||
artworkId,
|
artworkId,
|
||||||
{ deviceId: registerResponse.deviceId },
|
{ deviceId: registerResponse.deviceId },
|
||||||
responseMock,
|
responseMock,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
const downloadedBytes = await streamToBuffer(streamable.getStream());
|
const downloadedBytes = await streamToBuffer(streamable.getStream());
|
||||||
|
|
||||||
@ -882,11 +982,13 @@ describe('Velody API wiring (e2e)', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
|
runAsDevice(registerResponse.deviceAccessToken, () =>
|
||||||
artworkController.download(
|
artworkController.download(
|
||||||
artworkId,
|
artworkId,
|
||||||
{ deviceId: registerResponse.deviceId },
|
{ deviceId: registerResponse.deviceId },
|
||||||
{ setHeader() {} } as any,
|
{ setHeader() {} } as any,
|
||||||
),
|
),
|
||||||
|
),
|
||||||
).rejects.toBeInstanceOf(ForbiddenException);
|
).rejects.toBeInstanceOf(ForbiddenException);
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -917,11 +1019,13 @@ describe('Velody API wiring (e2e)', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
|
runAsDevice(registerResponse.deviceAccessToken, () =>
|
||||||
artworkController.download(
|
artworkController.download(
|
||||||
artworkId,
|
artworkId,
|
||||||
{ deviceId: registerResponse.deviceId },
|
{ deviceId: registerResponse.deviceId },
|
||||||
{ setHeader() {} } as any,
|
{ setHeader() {} } as any,
|
||||||
),
|
),
|
||||||
|
),
|
||||||
).rejects.toBeInstanceOf(NotFoundException);
|
).rejects.toBeInstanceOf(NotFoundException);
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -1089,9 +1193,11 @@ describe('Velody API wiring (e2e)', () => {
|
|||||||
updatedAt: new Date('2026-05-29T08:02:30.000Z'),
|
updatedAt: new Date('2026-05-29T08:02:30.000Z'),
|
||||||
});
|
});
|
||||||
|
|
||||||
const response = await libraryController.getTracks({
|
const response = await runAsDevice(primaryDevice.deviceAccessToken, () =>
|
||||||
|
libraryController.getTracks({
|
||||||
deviceId: primaryDevice.deviceId,
|
deviceId: primaryDevice.deviceId,
|
||||||
});
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
expect(response).toEqual({
|
expect(response).toEqual({
|
||||||
tracks: [
|
tracks: [
|
||||||
@ -1340,59 +1446,18 @@ describe('Velody API wiring (e2e)', () => {
|
|||||||
expect(linkedLibrary.tracks).toEqual(primaryLibrary.tracks);
|
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({
|
const ownerDevice = await devicesController.register({
|
||||||
platform: 'IPHONE',
|
platform: 'IPHONE',
|
||||||
deviceName: 'Legacy iPhone',
|
deviceName: 'Legacy iPhone',
|
||||||
appVersion: '0.1.0',
|
appVersion: '0.1.0',
|
||||||
});
|
});
|
||||||
const trackId = randomUUID();
|
|
||||||
const assetId = randomUUID();
|
|
||||||
|
|
||||||
prismaState.audioAssets.set(assetId, {
|
await expect(
|
||||||
id: assetId,
|
libraryController.getTracks({
|
||||||
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,
|
deviceId: ownerDevice.deviceId,
|
||||||
});
|
|
||||||
|
|
||||||
expect(response.tracks).toEqual([
|
|
||||||
expect.objectContaining({
|
|
||||||
trackId,
|
|
||||||
assetId,
|
|
||||||
}),
|
}),
|
||||||
]);
|
).rejects.toBeInstanceOf(UnauthorizedException);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('rejects invalid or revoked device tokens even when a legacy device id is supplied', async () => {
|
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 bytes = sampleMp3Bytes('e2e-upload');
|
||||||
const sha256 = sha256Hex(bytes);
|
const sha256 = sha256Hex(bytes);
|
||||||
|
|
||||||
const prepareResponse = await uploadsController.prepare({
|
const prepareResponse = await runAsDevice(registerResponse.deviceAccessToken, () =>
|
||||||
|
uploadsController.prepare({
|
||||||
deviceId: registerResponse.deviceId,
|
deviceId: registerResponse.deviceId,
|
||||||
sha256,
|
sha256,
|
||||||
originalFilename: 'e2e-upload.mp3',
|
originalFilename: 'e2e-upload.mp3',
|
||||||
sizeBytes: bytes.length,
|
sizeBytes: bytes.length,
|
||||||
});
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
expect(prepareResponse.status).toBe('upload_required');
|
expect(prepareResponse.status).toBe('upload_required');
|
||||||
|
|
||||||
const uploadResponse = await uploadsService.uploadFile(
|
const uploadResponse = await runAsDevice(registerResponse.deviceAccessToken, () =>
|
||||||
|
uploadsService.uploadFile(
|
||||||
prepareResponse.uploadId!,
|
prepareResponse.uploadId!,
|
||||||
createUploadRequest(bytes),
|
createUploadRequest(bytes),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(uploadResponse.status).toBe('COMPLETED');
|
expect(uploadResponse.status).toBe('COMPLETED');
|
||||||
|
|
||||||
const finalizeResponse = await uploadsController.finalize(
|
const finalizeResponse = await runAsDevice(
|
||||||
prepareResponse.uploadId!,
|
registerResponse.deviceAccessToken,
|
||||||
{
|
() =>
|
||||||
|
uploadsController.finalize(prepareResponse.uploadId!, {
|
||||||
title: 'Uploaded Track',
|
title: 'Uploaded Track',
|
||||||
artist: 'Velody',
|
artist: 'Velody',
|
||||||
album: 'Milestone 6',
|
album: 'Milestone 6',
|
||||||
durationMs: 222000,
|
durationMs: 222000,
|
||||||
},
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(finalizeResponse.trackId).toBeDefined();
|
expect(finalizeResponse.trackId).toBeDefined();
|
||||||
@ -1629,12 +1699,16 @@ describe('Velody API wiring (e2e)', () => {
|
|||||||
);
|
);
|
||||||
expect(storedBytes.equals(bytes)).toBe(true);
|
expect(storedBytes.equals(bytes)).toBe(true);
|
||||||
|
|
||||||
const duplicatePrepare = await uploadsController.prepare({
|
const duplicatePrepare = await runAsDevice(
|
||||||
|
registerResponse.deviceAccessToken,
|
||||||
|
() =>
|
||||||
|
uploadsController.prepare({
|
||||||
deviceId: registerResponse.deviceId,
|
deviceId: registerResponse.deviceId,
|
||||||
sha256,
|
sha256,
|
||||||
originalFilename: 'e2e-upload.mp3',
|
originalFilename: 'e2e-upload.mp3',
|
||||||
sizeBytes: bytes.length,
|
sizeBytes: bytes.length,
|
||||||
});
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
expect(duplicatePrepare.status).toBe('exists');
|
expect(duplicatePrepare.status).toBe('exists');
|
||||||
expect(duplicatePrepare.uploadId).toBeDefined();
|
expect(duplicatePrepare.uploadId).toBeDefined();
|
||||||
@ -1656,25 +1730,30 @@ describe('Velody API wiring (e2e)', () => {
|
|||||||
const sha256 = sha256Hex(bytes);
|
const sha256 = sha256Hex(bytes);
|
||||||
const artworkSha256 = sha256Hex(artworkBytes);
|
const artworkSha256 = sha256Hex(artworkBytes);
|
||||||
|
|
||||||
const prepareResponse = await uploadsController.prepare({
|
const prepareResponse = await runAsDevice(registerResponse.deviceAccessToken, () =>
|
||||||
|
uploadsController.prepare({
|
||||||
deviceId: registerResponse.deviceId,
|
deviceId: registerResponse.deviceId,
|
||||||
sha256,
|
sha256,
|
||||||
originalFilename: 'e2e-upload-artwork.mp3',
|
originalFilename: 'e2e-upload-artwork.mp3',
|
||||||
sizeBytes: bytes.length,
|
sizeBytes: bytes.length,
|
||||||
});
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
expect(prepareResponse.status).toBe('upload_required');
|
expect(prepareResponse.status).toBe('upload_required');
|
||||||
|
|
||||||
const uploadResponse = await uploadsService.uploadFile(
|
const uploadResponse = await runAsDevice(registerResponse.deviceAccessToken, () =>
|
||||||
|
uploadsService.uploadFile(
|
||||||
prepareResponse.uploadId!,
|
prepareResponse.uploadId!,
|
||||||
createUploadRequest(bytes),
|
createUploadRequest(bytes),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(uploadResponse.status).toBe('COMPLETED');
|
expect(uploadResponse.status).toBe('COMPLETED');
|
||||||
|
|
||||||
const finalizeResponse = await uploadsController.finalize(
|
const finalizeResponse = await runAsDevice(
|
||||||
prepareResponse.uploadId!,
|
registerResponse.deviceAccessToken,
|
||||||
{
|
() =>
|
||||||
|
uploadsController.finalize(prepareResponse.uploadId!, {
|
||||||
title: 'Uploaded Artwork Track',
|
title: 'Uploaded Artwork Track',
|
||||||
artist: 'Velody',
|
artist: 'Velody',
|
||||||
album: 'Milestone 8.1',
|
album: 'Milestone 8.1',
|
||||||
@ -1686,15 +1765,17 @@ describe('Velody API wiring (e2e)', () => {
|
|||||||
width: 1,
|
width: 1,
|
||||||
height: 1,
|
height: 1,
|
||||||
},
|
},
|
||||||
},
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(finalizeResponse.trackId).toBeDefined();
|
expect(finalizeResponse.trackId).toBeDefined();
|
||||||
expect(prismaState.artworkAssets.size).toBe(1);
|
expect(prismaState.artworkAssets.size).toBe(1);
|
||||||
|
|
||||||
const remoteLibrary = await libraryController.getTracks({
|
const remoteLibrary = await runAsDevice(registerResponse.deviceAccessToken, () =>
|
||||||
|
libraryController.getTracks({
|
||||||
deviceId: registerResponse.deviceId,
|
deviceId: registerResponse.deviceId,
|
||||||
});
|
}),
|
||||||
|
);
|
||||||
expect(remoteLibrary.tracks).toEqual([
|
expect(remoteLibrary.tracks).toEqual([
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
trackId: finalizeResponse.trackId,
|
trackId: finalizeResponse.trackId,
|
||||||
@ -1727,10 +1808,12 @@ describe('Velody API wiring (e2e)', () => {
|
|||||||
},
|
},
|
||||||
} as any;
|
} as any;
|
||||||
|
|
||||||
const streamable = await artworkController.download(
|
const streamable = await runAsDevice(registerResponse.deviceAccessToken, () =>
|
||||||
|
artworkController.download(
|
||||||
artworkAsset.id,
|
artworkAsset.id,
|
||||||
{ deviceId: registerResponse.deviceId },
|
{ deviceId: registerResponse.deviceId },
|
||||||
responseMock,
|
responseMock,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
const downloadedArtworkBytes = await streamToBuffer(streamable.getStream());
|
const downloadedArtworkBytes = await streamToBuffer(streamable.getStream());
|
||||||
|
|
||||||
@ -1752,46 +1835,56 @@ describe('Velody API wiring (e2e)', () => {
|
|||||||
const sha256 = sha256Hex(bytes);
|
const sha256 = sha256Hex(bytes);
|
||||||
const artworkSha256 = sha256Hex(artworkBytes);
|
const artworkSha256 = sha256Hex(artworkBytes);
|
||||||
|
|
||||||
const firstPrepare = await uploadsController.prepare({
|
const firstPrepare = await runAsDevice(registerResponse.deviceAccessToken, () =>
|
||||||
|
uploadsController.prepare({
|
||||||
deviceId: registerResponse.deviceId,
|
deviceId: registerResponse.deviceId,
|
||||||
sha256,
|
sha256,
|
||||||
originalFilename: 'e2e-deduped-artwork.mp3',
|
originalFilename: 'e2e-deduped-artwork.mp3',
|
||||||
sizeBytes: bytes.length,
|
sizeBytes: bytes.length,
|
||||||
});
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
expect(firstPrepare.status).toBe('upload_required');
|
expect(firstPrepare.status).toBe('upload_required');
|
||||||
|
|
||||||
const uploadResponse = await uploadsService.uploadFile(
|
const uploadResponse = await runAsDevice(registerResponse.deviceAccessToken, () =>
|
||||||
|
uploadsService.uploadFile(
|
||||||
firstPrepare.uploadId!,
|
firstPrepare.uploadId!,
|
||||||
createUploadRequest(bytes),
|
createUploadRequest(bytes),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(uploadResponse.status).toBe('COMPLETED');
|
expect(uploadResponse.status).toBe('COMPLETED');
|
||||||
|
|
||||||
const firstFinalize = await uploadsController.finalize(
|
const firstFinalize = await runAsDevice(
|
||||||
firstPrepare.uploadId!,
|
registerResponse.deviceAccessToken,
|
||||||
{
|
() =>
|
||||||
|
uploadsController.finalize(firstPrepare.uploadId!, {
|
||||||
title: 'Deduped Artwork Track',
|
title: 'Deduped Artwork Track',
|
||||||
artist: 'Velody',
|
artist: 'Velody',
|
||||||
album: 'Milestone 8.1',
|
album: 'Milestone 8.1',
|
||||||
durationMs: 222000,
|
durationMs: 222000,
|
||||||
},
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
const secondPrepare = await uploadsController.prepare({
|
const secondPrepare = await runAsDevice(
|
||||||
|
registerResponse.deviceAccessToken,
|
||||||
|
() =>
|
||||||
|
uploadsController.prepare({
|
||||||
deviceId: registerResponse.deviceId,
|
deviceId: registerResponse.deviceId,
|
||||||
sha256,
|
sha256,
|
||||||
originalFilename: 'e2e-deduped-artwork.mp3',
|
originalFilename: 'e2e-deduped-artwork.mp3',
|
||||||
sizeBytes: bytes.length,
|
sizeBytes: bytes.length,
|
||||||
});
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
expect(secondPrepare.status).toBe('exists');
|
expect(secondPrepare.status).toBe('exists');
|
||||||
expect(secondPrepare.uploadId).toBeDefined();
|
expect(secondPrepare.uploadId).toBeDefined();
|
||||||
expect(prismaState.audioAssets.size).toBe(1);
|
expect(prismaState.audioAssets.size).toBe(1);
|
||||||
|
|
||||||
const secondFinalize = await uploadsController.finalize(
|
const secondFinalize = await runAsDevice(
|
||||||
secondPrepare.uploadId!,
|
registerResponse.deviceAccessToken,
|
||||||
{
|
() =>
|
||||||
|
uploadsController.finalize(secondPrepare.uploadId!, {
|
||||||
title: 'Deduped Artwork Track',
|
title: 'Deduped Artwork Track',
|
||||||
artist: 'Velody',
|
artist: 'Velody',
|
||||||
album: 'Milestone 8.1',
|
album: 'Milestone 8.1',
|
||||||
@ -1803,16 +1896,18 @@ describe('Velody API wiring (e2e)', () => {
|
|||||||
width: 1,
|
width: 1,
|
||||||
height: 1,
|
height: 1,
|
||||||
},
|
},
|
||||||
},
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(secondFinalize.trackId).toBe(firstFinalize.trackId);
|
expect(secondFinalize.trackId).toBe(firstFinalize.trackId);
|
||||||
expect(secondFinalize.assetId).toBe(firstFinalize.assetId);
|
expect(secondFinalize.assetId).toBe(firstFinalize.assetId);
|
||||||
expect(prismaState.artworkAssets.size).toBe(1);
|
expect(prismaState.artworkAssets.size).toBe(1);
|
||||||
|
|
||||||
const remoteLibrary = await libraryController.getTracks({
|
const remoteLibrary = await runAsDevice(registerResponse.deviceAccessToken, () =>
|
||||||
|
libraryController.getTracks({
|
||||||
deviceId: registerResponse.deviceId,
|
deviceId: registerResponse.deviceId,
|
||||||
});
|
}),
|
||||||
|
);
|
||||||
expect(remoteLibrary.tracks).toEqual([
|
expect(remoteLibrary.tracks).toEqual([
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
trackId: firstFinalize.trackId,
|
trackId: firstFinalize.trackId,
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user