Add owner context and harden ownership boundaries
This commit is contained in:
parent
d392e532e0
commit
958ebb71f5
@ -0,0 +1,90 @@
|
|||||||
|
INSERT INTO "users" (
|
||||||
|
"id",
|
||||||
|
"slug",
|
||||||
|
"display_name",
|
||||||
|
"is_default",
|
||||||
|
"created_at",
|
||||||
|
"updated_at"
|
||||||
|
)
|
||||||
|
VALUES (
|
||||||
|
gen_random_uuid(),
|
||||||
|
'default-owner',
|
||||||
|
'Default Owner',
|
||||||
|
true,
|
||||||
|
CURRENT_TIMESTAMP,
|
||||||
|
CURRENT_TIMESTAMP
|
||||||
|
)
|
||||||
|
ON CONFLICT ("slug") DO UPDATE
|
||||||
|
SET
|
||||||
|
"display_name" = EXCLUDED."display_name",
|
||||||
|
"is_default" = true,
|
||||||
|
"updated_at" = CURRENT_TIMESTAMP;
|
||||||
|
|
||||||
|
DROP INDEX IF EXISTS "artwork_assets_user_id_sha256_key";
|
||||||
|
|
||||||
|
WITH "tracked_artwork_owner" AS (
|
||||||
|
SELECT DISTINCT ON ("t"."artwork_asset_id")
|
||||||
|
"t"."artwork_asset_id" AS "artwork_id",
|
||||||
|
"t"."user_id"
|
||||||
|
FROM "tracks" AS "t"
|
||||||
|
WHERE "t"."artwork_asset_id" IS NOT NULL
|
||||||
|
ORDER BY "t"."artwork_asset_id", "t"."created_at" ASC, "t"."id" ASC
|
||||||
|
)
|
||||||
|
UPDATE "artwork_assets" AS "aa"
|
||||||
|
SET "user_id" = "tracked_artwork_owner"."user_id"
|
||||||
|
FROM "tracked_artwork_owner"
|
||||||
|
WHERE "aa"."id" = "tracked_artwork_owner"."artwork_id"
|
||||||
|
AND "aa"."user_id" IS NULL;
|
||||||
|
|
||||||
|
UPDATE "artwork_assets"
|
||||||
|
SET "user_id" = (SELECT "id" FROM "users" WHERE "slug" = 'default-owner')
|
||||||
|
WHERE "user_id" IS NULL;
|
||||||
|
|
||||||
|
WITH "ranked_artwork" AS (
|
||||||
|
SELECT
|
||||||
|
"id",
|
||||||
|
"user_id",
|
||||||
|
"sha256",
|
||||||
|
FIRST_VALUE("id") OVER (
|
||||||
|
PARTITION BY "user_id", "sha256"
|
||||||
|
ORDER BY "created_at" ASC, "id" ASC
|
||||||
|
) AS "canonical_id",
|
||||||
|
ROW_NUMBER() OVER (
|
||||||
|
PARTITION BY "user_id", "sha256"
|
||||||
|
ORDER BY "created_at" ASC, "id" ASC
|
||||||
|
) AS "row_number"
|
||||||
|
FROM "artwork_assets"
|
||||||
|
),
|
||||||
|
"duplicate_artwork" AS (
|
||||||
|
SELECT "id", "canonical_id"
|
||||||
|
FROM "ranked_artwork"
|
||||||
|
WHERE "row_number" > 1
|
||||||
|
)
|
||||||
|
UPDATE "tracks" AS "t"
|
||||||
|
SET "artwork_asset_id" = "d"."canonical_id"
|
||||||
|
FROM "duplicate_artwork" AS "d"
|
||||||
|
WHERE "t"."artwork_asset_id" = "d"."id";
|
||||||
|
|
||||||
|
WITH "ranked_artwork" AS (
|
||||||
|
SELECT
|
||||||
|
"id",
|
||||||
|
ROW_NUMBER() OVER (
|
||||||
|
PARTITION BY "user_id", "sha256"
|
||||||
|
ORDER BY "created_at" ASC, "id" ASC
|
||||||
|
) AS "row_number"
|
||||||
|
FROM "artwork_assets"
|
||||||
|
),
|
||||||
|
"duplicate_artwork" AS (
|
||||||
|
SELECT "id"
|
||||||
|
FROM "ranked_artwork"
|
||||||
|
WHERE "row_number" > 1
|
||||||
|
)
|
||||||
|
DELETE FROM "artwork_assets" AS "aa"
|
||||||
|
USING "duplicate_artwork" AS "d"
|
||||||
|
WHERE "aa"."id" = "d"."id";
|
||||||
|
|
||||||
|
ALTER TABLE "artwork_assets"
|
||||||
|
ALTER COLUMN "user_id" SET NOT NULL;
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX "artwork_assets_user_id_sha256_key"
|
||||||
|
ON "artwork_assets"("user_id", "sha256");
|
||||||
@ -97,7 +97,7 @@ model AudioAsset {
|
|||||||
}
|
}
|
||||||
|
|
||||||
model ArtworkAsset {
|
model ArtworkAsset {
|
||||||
userId String? @db.Uuid @map("user_id")
|
userId String @db.Uuid @map("user_id")
|
||||||
id String @id @default(uuid()) @db.Uuid
|
id String @id @default(uuid()) @db.Uuid
|
||||||
sha256 String
|
sha256 String
|
||||||
storageKey String @unique @map("storage_key")
|
storageKey String @unique @map("storage_key")
|
||||||
@ -107,7 +107,7 @@ model ArtworkAsset {
|
|||||||
fileSizeBytes BigInt @map("file_size_bytes")
|
fileSizeBytes BigInt @map("file_size_bytes")
|
||||||
createdAt DateTime @default(now()) @map("created_at")
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
tracks Track[] @relation("TrackArtwork")
|
tracks Track[] @relation("TrackArtwork")
|
||||||
user User? @relation(fields: [userId], references: [id], onDelete: Cascade, onUpdate: Cascade)
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade, onUpdate: Cascade)
|
||||||
|
|
||||||
@@unique([userId, sha256])
|
@@unique([userId, sha256])
|
||||||
@@index([userId])
|
@@index([userId])
|
||||||
|
|||||||
@ -7,10 +7,12 @@ import { HealthModule } from './modules/health/health.module';
|
|||||||
import { LibraryModule } from './modules/library/library.module';
|
import { LibraryModule } from './modules/library/library.module';
|
||||||
import { SyncModule } from './modules/sync/sync.module';
|
import { SyncModule } from './modules/sync/sync.module';
|
||||||
import { UploadsModule } from './modules/uploads/uploads.module';
|
import { UploadsModule } from './modules/uploads/uploads.module';
|
||||||
|
import { UsersModule } from './modules/users/users.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
AppConfigModule,
|
AppConfigModule,
|
||||||
|
UsersModule,
|
||||||
AssetsModule,
|
AssetsModule,
|
||||||
ArtworkModule,
|
ArtworkModule,
|
||||||
HealthModule,
|
HealthModule,
|
||||||
|
|||||||
@ -1,11 +1,12 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { PrismaModule } from '../../infrastructure/database/prisma.module';
|
import { PrismaModule } from '../../infrastructure/database/prisma.module';
|
||||||
import { StorageModule } from '../storage/storage.module';
|
import { StorageModule } from '../storage/storage.module';
|
||||||
|
import { UsersModule } from '../users/users.module';
|
||||||
import { ArtworkController } from './artwork.controller';
|
import { ArtworkController } from './artwork.controller';
|
||||||
import { ArtworkService } from './artwork.service';
|
import { ArtworkService } from './artwork.service';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [PrismaModule, StorageModule],
|
imports: [PrismaModule, StorageModule, UsersModule],
|
||||||
controllers: [ArtworkController],
|
controllers: [ArtworkController],
|
||||||
providers: [ArtworkService],
|
providers: [ArtworkService],
|
||||||
})
|
})
|
||||||
|
|||||||
@ -6,6 +6,7 @@ import { ForbiddenException, NotFoundException } from '@nestjs/common';
|
|||||||
import { PrismaService } from '../../infrastructure/database/prisma.service';
|
import { PrismaService } from '../../infrastructure/database/prisma.service';
|
||||||
import { AppConfigService } from '../config/config.service';
|
import { AppConfigService } from '../config/config.service';
|
||||||
import { LocalFilesystemStorageService } from '../storage/storage.service';
|
import { LocalFilesystemStorageService } from '../storage/storage.service';
|
||||||
|
import { OwnerContext } from '../users/owner-context.service';
|
||||||
import { ArtworkService } from './artwork.service';
|
import { ArtworkService } from './artwork.service';
|
||||||
|
|
||||||
type MockState = ReturnType<typeof createPrismaMock>['state'];
|
type MockState = ReturnType<typeof createPrismaMock>['state'];
|
||||||
@ -46,13 +47,23 @@ describe('ArtworkService', () => {
|
|||||||
let state: MockState;
|
let state: MockState;
|
||||||
let storageRoot: string;
|
let storageRoot: string;
|
||||||
let storageService: LocalFilesystemStorageService;
|
let storageService: LocalFilesystemStorageService;
|
||||||
|
let ownerUserId: string;
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
const mock = createPrismaMock();
|
const mock = createPrismaMock();
|
||||||
state = mock.state;
|
state = mock.state;
|
||||||
storageRoot = await mkdtemp(join(tmpdir(), 'velody-artwork-spec-'));
|
storageRoot = await mkdtemp(join(tmpdir(), 'velody-artwork-spec-'));
|
||||||
storageService = new LocalFilesystemStorageService(createAppConfig(storageRoot));
|
storageService = new LocalFilesystemStorageService(createAppConfig(storageRoot));
|
||||||
service = new ArtworkService(mock.prismaMock, storageService);
|
ownerUserId = randomUUID();
|
||||||
|
service = new ArtworkService(
|
||||||
|
mock.prismaMock,
|
||||||
|
storageService,
|
||||||
|
{
|
||||||
|
resolve: jest.fn().mockResolvedValue({
|
||||||
|
userId: ownerUserId,
|
||||||
|
}),
|
||||||
|
} as OwnerContext,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(async () => {
|
afterEach(async () => {
|
||||||
@ -60,7 +71,7 @@ describe('ArtworkService', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('returns a local file path, content length, and mime type for the owning device user', async () => {
|
it('returns a local file path, content length, and mime type for the owning device user', async () => {
|
||||||
const userId = randomUUID();
|
const userId = ownerUserId;
|
||||||
const deviceId = randomUUID();
|
const deviceId = randomUUID();
|
||||||
const artworkId = randomUUID();
|
const artworkId = randomUUID();
|
||||||
const storageKey = join('users', userId, 'artwork', `${artworkId}.png`);
|
const storageKey = join('users', userId, 'artwork', `${artworkId}.png`);
|
||||||
@ -74,7 +85,6 @@ describe('ArtworkService', () => {
|
|||||||
userId,
|
userId,
|
||||||
storageKey,
|
storageKey,
|
||||||
mimeType: 'image/png',
|
mimeType: 'image/png',
|
||||||
tracks: [{ userId }],
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const filePath = storageService.resolve(storageKey);
|
const filePath = storageService.resolve(storageKey);
|
||||||
@ -88,18 +98,16 @@ describe('ArtworkService', () => {
|
|||||||
expect(download.mimeType).toBe('image/png');
|
expect(download.mimeType).toBe('image/png');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('rejects download attempts from a different user device', async () => {
|
it('rejects cross-owner artwork downloads', async () => {
|
||||||
const ownerId = randomUUID();
|
|
||||||
const otherUserId = randomUUID();
|
const otherUserId = randomUUID();
|
||||||
const ownerDeviceId = randomUUID();
|
const ownerDeviceId = randomUUID();
|
||||||
const artworkId = randomUUID();
|
const artworkId = randomUUID();
|
||||||
|
|
||||||
state.devices.set(ownerDeviceId, { id: ownerDeviceId, userId: otherUserId });
|
state.devices.set(ownerDeviceId, { id: ownerDeviceId, userId: ownerUserId });
|
||||||
state.artworkAssets.set(artworkId, {
|
state.artworkAssets.set(artworkId, {
|
||||||
userId: ownerId,
|
userId: otherUserId,
|
||||||
storageKey: join('users', ownerId, 'artwork', `${artworkId}.jpg`),
|
storageKey: join('users', otherUserId, 'artwork', `${artworkId}.jpg`),
|
||||||
mimeType: 'image/jpeg',
|
mimeType: 'image/jpeg',
|
||||||
tracks: [{ userId: ownerId }],
|
|
||||||
});
|
});
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
@ -107,8 +115,21 @@ describe('ArtworkService', () => {
|
|||||||
).rejects.toBeInstanceOf(ForbiddenException);
|
).rejects.toBeInstanceOf(ForbiddenException);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('rejects foreign-owner devices before reading artwork', async () => {
|
||||||
|
const foreignDeviceId = randomUUID();
|
||||||
|
|
||||||
|
state.devices.set(foreignDeviceId, {
|
||||||
|
id: foreignDeviceId,
|
||||||
|
userId: randomUUID(),
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.getOwnedArtworkDownload(randomUUID(), foreignDeviceId),
|
||||||
|
).rejects.toBeInstanceOf(NotFoundException);
|
||||||
|
});
|
||||||
|
|
||||||
it('returns not found when the artwork file is missing from storage', async () => {
|
it('returns not found when the artwork file is missing from storage', async () => {
|
||||||
const userId = randomUUID();
|
const userId = ownerUserId;
|
||||||
const deviceId = randomUUID();
|
const deviceId = randomUUID();
|
||||||
const artworkId = randomUUID();
|
const artworkId = randomUUID();
|
||||||
|
|
||||||
@ -117,7 +138,6 @@ describe('ArtworkService', () => {
|
|||||||
userId,
|
userId,
|
||||||
storageKey: join('users', userId, 'artwork', `${artworkId}.png`),
|
storageKey: join('users', userId, 'artwork', `${artworkId}.png`),
|
||||||
mimeType: 'image/png',
|
mimeType: 'image/png',
|
||||||
tracks: [{ userId }],
|
|
||||||
});
|
});
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
|
|||||||
@ -6,6 +6,7 @@ import {
|
|||||||
import { stat } from 'node:fs/promises';
|
import { stat } from 'node:fs/promises';
|
||||||
import { PrismaService } from '../../infrastructure/database/prisma.service';
|
import { PrismaService } from '../../infrastructure/database/prisma.service';
|
||||||
import { LocalFilesystemStorageService } from '../storage/storage.service';
|
import { LocalFilesystemStorageService } from '../storage/storage.service';
|
||||||
|
import { OwnerContext } from '../users/owner-context.service';
|
||||||
|
|
||||||
export interface ArtworkDownload {
|
export interface ArtworkDownload {
|
||||||
filePath: string;
|
filePath: string;
|
||||||
@ -18,12 +19,14 @@ export class ArtworkService {
|
|||||||
constructor(
|
constructor(
|
||||||
private readonly prismaService: PrismaService,
|
private readonly prismaService: PrismaService,
|
||||||
private readonly storageService: LocalFilesystemStorageService,
|
private readonly storageService: LocalFilesystemStorageService,
|
||||||
|
private readonly ownerContext: OwnerContext,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async getOwnedArtworkDownload(
|
async getOwnedArtworkDownload(
|
||||||
artworkId: string,
|
artworkId: string,
|
||||||
deviceId: string,
|
deviceId: string,
|
||||||
): Promise<ArtworkDownload> {
|
): Promise<ArtworkDownload> {
|
||||||
|
const ownerUserId = await this.resolveCurrentOwnerUserId();
|
||||||
const device = await this.prismaService.device.findUnique({
|
const device = await this.prismaService.device.findUnique({
|
||||||
where: { id: deviceId },
|
where: { id: deviceId },
|
||||||
select: {
|
select: {
|
||||||
@ -31,7 +34,7 @@ export class ArtworkService {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!device) {
|
if (!device || device.userId !== ownerUserId) {
|
||||||
throw new NotFoundException('Device not found');
|
throw new NotFoundException('Device not found');
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -41,12 +44,6 @@ export class ArtworkService {
|
|||||||
userId: true,
|
userId: true,
|
||||||
storageKey: true,
|
storageKey: true,
|
||||||
mimeType: true,
|
mimeType: true,
|
||||||
tracks: {
|
|
||||||
take: 1,
|
|
||||||
select: {
|
|
||||||
userId: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -54,12 +51,7 @@ export class ArtworkService {
|
|||||||
throw new NotFoundException('Artwork not found');
|
throw new NotFoundException('Artwork not found');
|
||||||
}
|
}
|
||||||
|
|
||||||
const ownerUserId = artwork.userId ?? artwork.tracks[0]?.userId;
|
if (artwork.userId !== ownerUserId) {
|
||||||
if (!ownerUserId) {
|
|
||||||
throw new NotFoundException('Artwork not found');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (ownerUserId !== device.userId) {
|
|
||||||
throw new ForbiddenException('Artwork does not belong to this device user.');
|
throw new ForbiddenException('Artwork does not belong to this device user.');
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -85,4 +77,9 @@ export class ArtworkService {
|
|||||||
throw new NotFoundException('Artwork file not found');
|
throw new NotFoundException('Artwork file not found');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async resolveCurrentOwnerUserId(): Promise<string> {
|
||||||
|
const owner = await this.ownerContext.resolve();
|
||||||
|
return owner.userId;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,11 +1,12 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { PrismaModule } from '../../infrastructure/database/prisma.module';
|
import { PrismaModule } from '../../infrastructure/database/prisma.module';
|
||||||
import { StorageModule } from '../storage/storage.module';
|
import { StorageModule } from '../storage/storage.module';
|
||||||
|
import { UsersModule } from '../users/users.module';
|
||||||
import { AssetsController } from './assets.controller';
|
import { AssetsController } from './assets.controller';
|
||||||
import { AssetsService } from './assets.service';
|
import { AssetsService } from './assets.service';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [PrismaModule, StorageModule],
|
imports: [PrismaModule, StorageModule, UsersModule],
|
||||||
controllers: [AssetsController],
|
controllers: [AssetsController],
|
||||||
providers: [AssetsService],
|
providers: [AssetsService],
|
||||||
})
|
})
|
||||||
|
|||||||
@ -6,6 +6,7 @@ import { ForbiddenException, NotFoundException } from '@nestjs/common';
|
|||||||
import { PrismaService } from '../../infrastructure/database/prisma.service';
|
import { PrismaService } from '../../infrastructure/database/prisma.service';
|
||||||
import { AppConfigService } from '../config/config.service';
|
import { AppConfigService } from '../config/config.service';
|
||||||
import { LocalFilesystemStorageService } from '../storage/storage.service';
|
import { LocalFilesystemStorageService } from '../storage/storage.service';
|
||||||
|
import { OwnerContext } from '../users/owner-context.service';
|
||||||
import { AssetsService } from './assets.service';
|
import { AssetsService } from './assets.service';
|
||||||
|
|
||||||
type MockState = ReturnType<typeof createPrismaMock>['state'];
|
type MockState = ReturnType<typeof createPrismaMock>['state'];
|
||||||
@ -46,13 +47,23 @@ describe('AssetsService', () => {
|
|||||||
let state: MockState;
|
let state: MockState;
|
||||||
let storageRoot: string;
|
let storageRoot: string;
|
||||||
let storageService: LocalFilesystemStorageService;
|
let storageService: LocalFilesystemStorageService;
|
||||||
|
let ownerUserId: string;
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
const mock = createPrismaMock();
|
const mock = createPrismaMock();
|
||||||
state = mock.state;
|
state = mock.state;
|
||||||
storageRoot = await mkdtemp(join(tmpdir(), 'velody-assets-spec-'));
|
storageRoot = await mkdtemp(join(tmpdir(), 'velody-assets-spec-'));
|
||||||
storageService = new LocalFilesystemStorageService(createAppConfig(storageRoot));
|
storageService = new LocalFilesystemStorageService(createAppConfig(storageRoot));
|
||||||
service = new AssetsService(mock.prismaMock, storageService);
|
ownerUserId = randomUUID();
|
||||||
|
service = new AssetsService(
|
||||||
|
mock.prismaMock,
|
||||||
|
storageService,
|
||||||
|
{
|
||||||
|
resolve: jest.fn().mockResolvedValue({
|
||||||
|
userId: ownerUserId,
|
||||||
|
}),
|
||||||
|
} as OwnerContext,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(async () => {
|
afterEach(async () => {
|
||||||
@ -60,7 +71,7 @@ describe('AssetsService', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('returns a local file path and content length for the owning device user', async () => {
|
it('returns a local file path and content length for the owning device user', async () => {
|
||||||
const userId = randomUUID();
|
const userId = ownerUserId;
|
||||||
const deviceId = randomUUID();
|
const deviceId = randomUUID();
|
||||||
const assetId = randomUUID();
|
const assetId = randomUUID();
|
||||||
const storageKey = join('users', userId, 'audio', 'owner.mp3');
|
const storageKey = join('users', userId, 'audio', 'owner.mp3');
|
||||||
@ -83,17 +94,16 @@ describe('AssetsService', () => {
|
|||||||
expect(download.contentLength).toBe(assetBytes.length);
|
expect(download.contentLength).toBe(assetBytes.length);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('rejects download attempts from a different user device', async () => {
|
it('rejects cross-owner audio asset downloads', async () => {
|
||||||
const ownerId = randomUUID();
|
|
||||||
const otherUserId = randomUUID();
|
const otherUserId = randomUUID();
|
||||||
const ownerDeviceId = randomUUID();
|
const ownerDeviceId = randomUUID();
|
||||||
const assetId = randomUUID();
|
const assetId = randomUUID();
|
||||||
|
|
||||||
state.devices.set(ownerDeviceId, { id: ownerDeviceId, userId: otherUserId });
|
state.devices.set(ownerDeviceId, { id: ownerDeviceId, userId: ownerUserId });
|
||||||
state.audioAssets.set(assetId, {
|
state.audioAssets.set(assetId, {
|
||||||
id: assetId,
|
id: assetId,
|
||||||
userId: ownerId,
|
userId: otherUserId,
|
||||||
storageKey: join('users', ownerId, 'audio', 'owner.mp3'),
|
storageKey: join('users', otherUserId, 'audio', 'other.mp3'),
|
||||||
});
|
});
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
@ -101,8 +111,22 @@ describe('AssetsService', () => {
|
|||||||
).rejects.toBeInstanceOf(ForbiddenException);
|
).rejects.toBeInstanceOf(ForbiddenException);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('rejects foreign-owner devices before reading audio assets', async () => {
|
||||||
|
const foreignOwnerId = randomUUID();
|
||||||
|
const foreignDeviceId = randomUUID();
|
||||||
|
|
||||||
|
state.devices.set(foreignDeviceId, {
|
||||||
|
id: foreignDeviceId,
|
||||||
|
userId: foreignOwnerId,
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.getOwnedAudioAssetDownload(randomUUID(), foreignDeviceId),
|
||||||
|
).rejects.toBeInstanceOf(NotFoundException);
|
||||||
|
});
|
||||||
|
|
||||||
it('returns not found when the asset file is missing from storage', async () => {
|
it('returns not found when the asset file is missing from storage', async () => {
|
||||||
const userId = randomUUID();
|
const userId = ownerUserId;
|
||||||
const deviceId = randomUUID();
|
const deviceId = randomUUID();
|
||||||
const assetId = randomUUID();
|
const assetId = randomUUID();
|
||||||
|
|
||||||
|
|||||||
@ -6,6 +6,7 @@ import {
|
|||||||
import { stat } from 'node:fs/promises';
|
import { stat } from 'node:fs/promises';
|
||||||
import { PrismaService } from '../../infrastructure/database/prisma.service';
|
import { PrismaService } from '../../infrastructure/database/prisma.service';
|
||||||
import { LocalFilesystemStorageService } from '../storage/storage.service';
|
import { LocalFilesystemStorageService } from '../storage/storage.service';
|
||||||
|
import { OwnerContext } from '../users/owner-context.service';
|
||||||
|
|
||||||
export interface AudioAssetDownload {
|
export interface AudioAssetDownload {
|
||||||
filePath: string;
|
filePath: string;
|
||||||
@ -17,12 +18,14 @@ export class AssetsService {
|
|||||||
constructor(
|
constructor(
|
||||||
private readonly prismaService: PrismaService,
|
private readonly prismaService: PrismaService,
|
||||||
private readonly storageService: LocalFilesystemStorageService,
|
private readonly storageService: LocalFilesystemStorageService,
|
||||||
|
private readonly ownerContext: OwnerContext,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async getOwnedAudioAssetDownload(
|
async getOwnedAudioAssetDownload(
|
||||||
assetId: string,
|
assetId: string,
|
||||||
deviceId: string,
|
deviceId: string,
|
||||||
): Promise<AudioAssetDownload> {
|
): Promise<AudioAssetDownload> {
|
||||||
|
const ownerUserId = await this.resolveCurrentOwnerUserId();
|
||||||
const device = await this.prismaService.device.findUnique({
|
const device = await this.prismaService.device.findUnique({
|
||||||
where: { id: deviceId },
|
where: { id: deviceId },
|
||||||
select: {
|
select: {
|
||||||
@ -30,7 +33,7 @@ export class AssetsService {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!device) {
|
if (!device || device.userId !== ownerUserId) {
|
||||||
throw new NotFoundException('Device not found');
|
throw new NotFoundException('Device not found');
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -46,7 +49,7 @@ export class AssetsService {
|
|||||||
throw new NotFoundException('Audio asset not found');
|
throw new NotFoundException('Audio asset not found');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (asset.userId !== device.userId) {
|
if (asset.userId !== ownerUserId) {
|
||||||
throw new ForbiddenException('Audio asset does not belong to this device user.');
|
throw new ForbiddenException('Audio asset does not belong to this device user.');
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -71,4 +74,9 @@ export class AssetsService {
|
|||||||
throw new NotFoundException('Audio asset file not found');
|
throw new NotFoundException('Audio asset file not found');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async resolveCurrentOwnerUserId(): Promise<string> {
|
||||||
|
const owner = await this.ownerContext.resolve();
|
||||||
|
return owner.userId;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
76
backend/src/modules/devices/devices.service.spec.ts
Normal file
76
backend/src/modules/devices/devices.service.spec.ts
Normal file
@ -0,0 +1,76 @@
|
|||||||
|
import { randomUUID } from 'node:crypto';
|
||||||
|
import { NotFoundException } from '@nestjs/common';
|
||||||
|
import { OwnerContext } from '../users/owner-context.service';
|
||||||
|
import { DevicesService } from './devices.service';
|
||||||
|
|
||||||
|
describe('DevicesService', () => {
|
||||||
|
it('assigns newly registered devices to the bootstrap default owner', async () => {
|
||||||
|
const ownerId = randomUUID();
|
||||||
|
const prismaService = {
|
||||||
|
device: {
|
||||||
|
create: jest.fn().mockImplementation(async ({ data }) => ({
|
||||||
|
id: randomUUID(),
|
||||||
|
createdAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
...data,
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
} as any;
|
||||||
|
const ownerContext = {
|
||||||
|
resolve: jest.fn().mockResolvedValue({
|
||||||
|
userId: ownerId,
|
||||||
|
}),
|
||||||
|
} as any;
|
||||||
|
const service = new DevicesService(
|
||||||
|
prismaService,
|
||||||
|
ownerContext as OwnerContext,
|
||||||
|
);
|
||||||
|
|
||||||
|
await service.register({
|
||||||
|
platform: 'MACOS',
|
||||||
|
deviceName: 'Velody Mac',
|
||||||
|
appVersion: '0.1.0',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(ownerContext.resolve).toHaveBeenCalledTimes(1);
|
||||||
|
expect(prismaService.device.create).toHaveBeenCalledWith({
|
||||||
|
data: expect.objectContaining({
|
||||||
|
userId: ownerId,
|
||||||
|
platform: 'MACOS',
|
||||||
|
deviceName: 'Velody Mac',
|
||||||
|
appVersion: '0.1.0',
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects heartbeat updates for a foreign-owner device', async () => {
|
||||||
|
const ownerId = randomUUID();
|
||||||
|
const foreignOwnerId = randomUUID();
|
||||||
|
const deviceId = randomUUID();
|
||||||
|
const prismaService = {
|
||||||
|
device: {
|
||||||
|
findUnique: jest.fn().mockResolvedValue({
|
||||||
|
userId: foreignOwnerId,
|
||||||
|
}),
|
||||||
|
update: jest.fn(),
|
||||||
|
},
|
||||||
|
} as any;
|
||||||
|
const ownerContext = {
|
||||||
|
resolve: jest.fn().mockResolvedValue({
|
||||||
|
userId: ownerId,
|
||||||
|
}),
|
||||||
|
} as any;
|
||||||
|
const service = new DevicesService(
|
||||||
|
prismaService,
|
||||||
|
ownerContext as OwnerContext,
|
||||||
|
);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.heartbeat({
|
||||||
|
deviceId,
|
||||||
|
appVersion: '0.1.1',
|
||||||
|
}),
|
||||||
|
).rejects.toBeInstanceOf(NotFoundException);
|
||||||
|
expect(prismaService.device.update).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -1,7 +1,7 @@
|
|||||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||||
import { createHash, randomBytes } from 'node:crypto';
|
import { createHash, randomBytes } from 'node:crypto';
|
||||||
import { PrismaService } from '../../infrastructure/database/prisma.service';
|
import { PrismaService } from '../../infrastructure/database/prisma.service';
|
||||||
import { DefaultUserService } from '../users/default-user.service';
|
import { OwnerContext } from '../users/owner-context.service';
|
||||||
import {
|
import {
|
||||||
DeviceHeartbeatRequestDto,
|
DeviceHeartbeatRequestDto,
|
||||||
DeviceHeartbeatResponseDto,
|
DeviceHeartbeatResponseDto,
|
||||||
@ -13,7 +13,7 @@ import {
|
|||||||
export class DevicesService {
|
export class DevicesService {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly prismaService: PrismaService,
|
private readonly prismaService: PrismaService,
|
||||||
private readonly defaultUserService: DefaultUserService,
|
private readonly ownerContext: OwnerContext,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async register(
|
async register(
|
||||||
@ -23,11 +23,11 @@ export class DevicesService {
|
|||||||
const installTokenHash = createHash('sha256')
|
const installTokenHash = createHash('sha256')
|
||||||
.update(bootstrapToken)
|
.update(bootstrapToken)
|
||||||
.digest('hex');
|
.digest('hex');
|
||||||
const defaultUser = await this.defaultUserService.getOrCreateDefaultUser();
|
const owner = await this.ownerContext.resolve();
|
||||||
|
|
||||||
const device = await this.prismaService.device.create({
|
const device = await this.prismaService.device.create({
|
||||||
data: {
|
data: {
|
||||||
userId: defaultUser.id,
|
userId: owner.userId,
|
||||||
platform: body.platform,
|
platform: body.platform,
|
||||||
deviceName: body.deviceName,
|
deviceName: body.deviceName,
|
||||||
appVersion: body.appVersion,
|
appVersion: body.appVersion,
|
||||||
@ -46,11 +46,15 @@ export class DevicesService {
|
|||||||
async heartbeat(
|
async heartbeat(
|
||||||
body: DeviceHeartbeatRequestDto,
|
body: DeviceHeartbeatRequestDto,
|
||||||
): Promise<DeviceHeartbeatResponseDto> {
|
): Promise<DeviceHeartbeatResponseDto> {
|
||||||
|
const ownerUserId = await this.resolveCurrentOwnerUserId();
|
||||||
const existing = await this.prismaService.device.findUnique({
|
const existing = await this.prismaService.device.findUnique({
|
||||||
where: { id: body.deviceId },
|
where: { id: body.deviceId },
|
||||||
|
select: {
|
||||||
|
userId: true,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!existing) {
|
if (!existing || existing.userId !== ownerUserId) {
|
||||||
throw new NotFoundException('Device not found');
|
throw new NotFoundException('Device not found');
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -67,4 +71,9 @@ 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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2,7 +2,7 @@ import { randomUUID } from 'node:crypto';
|
|||||||
import { NotFoundException } from '@nestjs/common';
|
import { NotFoundException } from '@nestjs/common';
|
||||||
import { Test } from '@nestjs/testing';
|
import { Test } from '@nestjs/testing';
|
||||||
import { PrismaService } from '../../infrastructure/database/prisma.service';
|
import { PrismaService } from '../../infrastructure/database/prisma.service';
|
||||||
import { DefaultUserService } from '../users/default-user.service';
|
import { OwnerContext } from '../users/owner-context.service';
|
||||||
import { LibraryService } from './library.service';
|
import { LibraryService } from './library.service';
|
||||||
|
|
||||||
function createPrismaMock() {
|
function createPrismaMock() {
|
||||||
@ -55,10 +55,16 @@ function createPrismaMock() {
|
|||||||
describe('LibraryService', () => {
|
describe('LibraryService', () => {
|
||||||
let libraryService: LibraryService;
|
let libraryService: LibraryService;
|
||||||
let state: ReturnType<typeof createPrismaMock>['state'];
|
let state: ReturnType<typeof createPrismaMock>['state'];
|
||||||
|
let ownerContextMock: {
|
||||||
|
resolve: jest.Mock;
|
||||||
|
};
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
const { prismaMock, state: nextState } = createPrismaMock();
|
const { prismaMock, state: nextState } = createPrismaMock();
|
||||||
state = nextState;
|
state = nextState;
|
||||||
|
ownerContextMock = {
|
||||||
|
resolve: jest.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
const moduleRef = await Test.createTestingModule({
|
const moduleRef = await Test.createTestingModule({
|
||||||
providers: [
|
providers: [
|
||||||
@ -68,8 +74,8 @@ describe('LibraryService', () => {
|
|||||||
useValue: prismaMock,
|
useValue: prismaMock,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
provide: DefaultUserService,
|
provide: OwnerContext,
|
||||||
useValue: {},
|
useValue: ownerContextMock,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
}).compile();
|
}).compile();
|
||||||
@ -77,6 +83,58 @@ describe('LibraryService', () => {
|
|||||||
libraryService = moduleRef.get(LibraryService);
|
libraryService = moduleRef.get(LibraryService);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('returns bootstrap tracks for the default owner only', async () => {
|
||||||
|
const defaultOwnerId = randomUUID();
|
||||||
|
const otherUserId = randomUUID();
|
||||||
|
|
||||||
|
ownerContextMock.resolve.mockResolvedValue({
|
||||||
|
userId: defaultOwnerId,
|
||||||
|
});
|
||||||
|
|
||||||
|
state.tracks.set(randomUUID(), {
|
||||||
|
id: randomUUID(),
|
||||||
|
userId: defaultOwnerId,
|
||||||
|
title: 'Bootstrap Track',
|
||||||
|
artist: 'Default Owner',
|
||||||
|
durationMs: 181000,
|
||||||
|
status: 'ACTIVE',
|
||||||
|
primaryAudioAssetId: null,
|
||||||
|
createdAt: new Date('2026-05-29T08:00:00.000Z'),
|
||||||
|
updatedAt: new Date('2026-05-29T08:01:00.000Z'),
|
||||||
|
});
|
||||||
|
state.tracks.set(randomUUID(), {
|
||||||
|
id: randomUUID(),
|
||||||
|
userId: defaultOwnerId,
|
||||||
|
title: 'Deleted Track',
|
||||||
|
artist: 'Default Owner',
|
||||||
|
durationMs: 181000,
|
||||||
|
status: 'DELETED',
|
||||||
|
primaryAudioAssetId: null,
|
||||||
|
createdAt: new Date('2026-05-29T08:05:00.000Z'),
|
||||||
|
updatedAt: new Date('2026-05-29T08:06:00.000Z'),
|
||||||
|
});
|
||||||
|
state.tracks.set(randomUUID(), {
|
||||||
|
id: randomUUID(),
|
||||||
|
userId: otherUserId,
|
||||||
|
title: 'Other User Track',
|
||||||
|
artist: 'Elsewhere',
|
||||||
|
durationMs: 182000,
|
||||||
|
status: 'ACTIVE',
|
||||||
|
primaryAudioAssetId: null,
|
||||||
|
createdAt: new Date('2026-05-29T08:10:00.000Z'),
|
||||||
|
updatedAt: new Date('2026-05-29T08:11:00.000Z'),
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(libraryService.getBootstrapTracks()).resolves.toEqual([
|
||||||
|
{
|
||||||
|
id: expect.any(String),
|
||||||
|
title: 'Bootstrap Track',
|
||||||
|
artist: 'Default Owner',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
expect(ownerContextMock.resolve).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
it('returns only tracks owned by the requesting device user in created order', async () => {
|
it('returns only tracks owned by the requesting device user in created order', async () => {
|
||||||
const ownerId = randomUUID();
|
const ownerId = randomUUID();
|
||||||
const otherUserId = randomUUID();
|
const otherUserId = randomUUID();
|
||||||
@ -89,6 +147,9 @@ describe('LibraryService', () => {
|
|||||||
const otherTrackId = randomUUID();
|
const otherTrackId = randomUUID();
|
||||||
const otherAssetId = randomUUID();
|
const otherAssetId = randomUUID();
|
||||||
|
|
||||||
|
ownerContextMock.resolve.mockResolvedValue({
|
||||||
|
userId: ownerId,
|
||||||
|
});
|
||||||
state.devices.set(ownerDeviceId, { id: ownerDeviceId, userId: ownerId });
|
state.devices.set(ownerDeviceId, { id: ownerDeviceId, userId: ownerId });
|
||||||
|
|
||||||
state.audioAssets.set(ownerAssetId, {
|
state.audioAssets.set(ownerAssetId, {
|
||||||
@ -186,6 +247,9 @@ describe('LibraryService', () => {
|
|||||||
it('returns an empty library when the user has no remote tracks', async () => {
|
it('returns an empty library when the user has no remote tracks', async () => {
|
||||||
const ownerId = randomUUID();
|
const ownerId = randomUUID();
|
||||||
const ownerDeviceId = randomUUID();
|
const ownerDeviceId = randomUUID();
|
||||||
|
ownerContextMock.resolve.mockResolvedValue({
|
||||||
|
userId: ownerId,
|
||||||
|
});
|
||||||
state.devices.set(ownerDeviceId, { id: ownerDeviceId, userId: ownerId });
|
state.devices.set(ownerDeviceId, { id: ownerDeviceId, userId: ownerId });
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
@ -193,15 +257,72 @@ describe('LibraryService', () => {
|
|||||||
).resolves.toEqual([]);
|
).resolves.toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('does not leak remote library tracks from other owners', async () => {
|
||||||
|
const ownerId = randomUUID();
|
||||||
|
const otherUserId = randomUUID();
|
||||||
|
const ownerDeviceId = randomUUID();
|
||||||
|
const otherTrackId = randomUUID();
|
||||||
|
const otherAssetId = randomUUID();
|
||||||
|
|
||||||
|
ownerContextMock.resolve.mockResolvedValue({
|
||||||
|
userId: ownerId,
|
||||||
|
});
|
||||||
|
state.devices.set(ownerDeviceId, { id: ownerDeviceId, userId: ownerId });
|
||||||
|
state.audioAssets.set(otherAssetId, {
|
||||||
|
id: otherAssetId,
|
||||||
|
sha256: 'sha-other-owner',
|
||||||
|
durationMs: 183000,
|
||||||
|
});
|
||||||
|
state.tracks.set(otherTrackId, {
|
||||||
|
id: otherTrackId,
|
||||||
|
userId: otherUserId,
|
||||||
|
title: 'Other Owner Track',
|
||||||
|
artist: 'Elsewhere',
|
||||||
|
durationMs: 183000,
|
||||||
|
status: 'ACTIVE',
|
||||||
|
primaryAudioAssetId: otherAssetId,
|
||||||
|
createdAt: new Date('2026-05-29T08:00:00.000Z'),
|
||||||
|
updatedAt: new Date('2026-05-29T08:01:00.000Z'),
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
libraryService.getRemoteLibraryTracks(ownerDeviceId),
|
||||||
|
).resolves.toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
it('throws for an unknown device', async () => {
|
it('throws for an unknown device', async () => {
|
||||||
|
ownerContextMock.resolve.mockResolvedValue({
|
||||||
|
userId: randomUUID(),
|
||||||
|
});
|
||||||
await expect(
|
await expect(
|
||||||
libraryService.getRemoteLibraryTracks(randomUUID()),
|
libraryService.getRemoteLibraryTracks(randomUUID()),
|
||||||
).rejects.toBeInstanceOf(NotFoundException);
|
).rejects.toBeInstanceOf(NotFoundException);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('rejects cross-owner track access through a foreign-owner device', async () => {
|
||||||
|
const ownerId = randomUUID();
|
||||||
|
const otherUserId = randomUUID();
|
||||||
|
const foreignDeviceId = randomUUID();
|
||||||
|
|
||||||
|
ownerContextMock.resolve.mockResolvedValue({
|
||||||
|
userId: ownerId,
|
||||||
|
});
|
||||||
|
state.devices.set(foreignDeviceId, {
|
||||||
|
id: foreignDeviceId,
|
||||||
|
userId: otherUserId,
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
libraryService.getRemoteLibraryTracks(foreignDeviceId),
|
||||||
|
).rejects.toBeInstanceOf(NotFoundException);
|
||||||
|
});
|
||||||
|
|
||||||
it('skips tracks without a primary audio asset', async () => {
|
it('skips tracks without a primary audio asset', async () => {
|
||||||
const ownerId = randomUUID();
|
const ownerId = randomUUID();
|
||||||
const ownerDeviceId = randomUUID();
|
const ownerDeviceId = randomUUID();
|
||||||
|
ownerContextMock.resolve.mockResolvedValue({
|
||||||
|
userId: ownerId,
|
||||||
|
});
|
||||||
state.devices.set(ownerDeviceId, { id: ownerDeviceId, userId: ownerId });
|
state.devices.set(ownerDeviceId, { id: ownerDeviceId, userId: ownerId });
|
||||||
|
|
||||||
state.tracks.set(randomUUID(), {
|
state.tracks.set(randomUUID(), {
|
||||||
|
|||||||
@ -1,21 +1,21 @@
|
|||||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||||
import { PrismaService } from '../../infrastructure/database/prisma.service';
|
import { PrismaService } from '../../infrastructure/database/prisma.service';
|
||||||
import { LibraryTrackDto } from '../sync/sync.dto';
|
import { LibraryTrackDto } from '../sync/sync.dto';
|
||||||
import { DefaultUserService } from '../users/default-user.service';
|
import { OwnerContext } from '../users/owner-context.service';
|
||||||
import { RemoteLibraryTrackDto } from './library.dto';
|
import { RemoteLibraryTrackDto } from './library.dto';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class LibraryService {
|
export class LibraryService {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly prismaService: PrismaService,
|
private readonly prismaService: PrismaService,
|
||||||
private readonly defaultUserService: DefaultUserService,
|
private readonly ownerContext: OwnerContext,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async getBootstrapTracks(): Promise<LibraryTrackDto[]> {
|
async getBootstrapTracks(): Promise<LibraryTrackDto[]> {
|
||||||
const defaultUser = await this.defaultUserService.getOrCreateDefaultUser();
|
const ownerUserId = await this.resolveCurrentOwnerUserId();
|
||||||
const tracks = await this.prismaService.track.findMany({
|
const tracks = await this.prismaService.track.findMany({
|
||||||
where: {
|
where: {
|
||||||
userId: defaultUser.id,
|
userId: ownerUserId,
|
||||||
status: 'ACTIVE',
|
status: 'ACTIVE',
|
||||||
},
|
},
|
||||||
orderBy: {
|
orderBy: {
|
||||||
@ -38,6 +38,7 @@ export class LibraryService {
|
|||||||
async getRemoteLibraryTracks(
|
async getRemoteLibraryTracks(
|
||||||
deviceId: string,
|
deviceId: string,
|
||||||
): Promise<RemoteLibraryTrackDto[]> {
|
): Promise<RemoteLibraryTrackDto[]> {
|
||||||
|
const ownerUserId = await this.resolveCurrentOwnerUserId();
|
||||||
const device = await this.prismaService.device.findUnique({
|
const device = await this.prismaService.device.findUnique({
|
||||||
where: { id: deviceId },
|
where: { id: deviceId },
|
||||||
select: {
|
select: {
|
||||||
@ -45,13 +46,13 @@ export class LibraryService {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!device) {
|
if (!device || device.userId !== ownerUserId) {
|
||||||
throw new NotFoundException('Device not found');
|
throw new NotFoundException('Device not found');
|
||||||
}
|
}
|
||||||
|
|
||||||
const tracks = await this.prismaService.track.findMany({
|
const tracks = await this.prismaService.track.findMany({
|
||||||
where: {
|
where: {
|
||||||
userId: device.userId,
|
userId: ownerUserId,
|
||||||
status: 'ACTIVE',
|
status: 'ACTIVE',
|
||||||
primaryAudioAssetId: {
|
primaryAudioAssetId: {
|
||||||
not: null,
|
not: null,
|
||||||
@ -117,4 +118,9 @@ export class LibraryService {
|
|||||||
];
|
];
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async resolveCurrentOwnerUserId(): Promise<string> {
|
||||||
|
const owner = await this.ownerContext.resolve();
|
||||||
|
return owner.userId;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
58
backend/src/modules/sync/sync.service.spec.ts
Normal file
58
backend/src/modules/sync/sync.service.spec.ts
Normal file
@ -0,0 +1,58 @@
|
|||||||
|
import { Test } from '@nestjs/testing';
|
||||||
|
import { PrismaService } from '../../infrastructure/database/prisma.service';
|
||||||
|
import { LibraryService } from '../library/library.service';
|
||||||
|
import { OwnerContext } from '../users/owner-context.service';
|
||||||
|
import { SyncService } from './sync.service';
|
||||||
|
|
||||||
|
describe('SyncService', () => {
|
||||||
|
it('uses OwnerContext to scope the bootstrap cursor lookup', async () => {
|
||||||
|
const ownerContextMock = {
|
||||||
|
resolve: jest.fn().mockResolvedValue({
|
||||||
|
userId: 'bootstrap-owner-id',
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
const prismaMock = {
|
||||||
|
libraryEvent: {
|
||||||
|
findFirst: jest.fn().mockResolvedValue({
|
||||||
|
id: 7n,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const libraryServiceMock = {
|
||||||
|
getBootstrapTracks: jest.fn().mockResolvedValue([]),
|
||||||
|
};
|
||||||
|
|
||||||
|
const moduleRef = await Test.createTestingModule({
|
||||||
|
providers: [
|
||||||
|
SyncService,
|
||||||
|
{
|
||||||
|
provide: PrismaService,
|
||||||
|
useValue: prismaMock,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
provide: LibraryService,
|
||||||
|
useValue: libraryServiceMock,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
provide: OwnerContext,
|
||||||
|
useValue: ownerContextMock,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}).compile();
|
||||||
|
|
||||||
|
const service = moduleRef.get(SyncService);
|
||||||
|
|
||||||
|
await expect(service.changes('0')).resolves.toMatchObject({
|
||||||
|
nextCursor: '7',
|
||||||
|
});
|
||||||
|
expect(ownerContextMock.resolve).toHaveBeenCalledTimes(1);
|
||||||
|
expect(prismaMock.libraryEvent.findFirst).toHaveBeenCalledWith({
|
||||||
|
where: {
|
||||||
|
userId: 'bootstrap-owner-id',
|
||||||
|
},
|
||||||
|
orderBy: {
|
||||||
|
id: 'desc',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -1,7 +1,7 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { PrismaService } from '../../infrastructure/database/prisma.service';
|
import { PrismaService } from '../../infrastructure/database/prisma.service';
|
||||||
import { LibraryService } from '../library/library.service';
|
import { LibraryService } from '../library/library.service';
|
||||||
import { DefaultUserService } from '../users/default-user.service';
|
import { OwnerContext } from '../users/owner-context.service';
|
||||||
import { SyncBootstrapResponseDto, SyncChangesResponseDto } from './sync.dto';
|
import { SyncBootstrapResponseDto, SyncChangesResponseDto } from './sync.dto';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
@ -9,7 +9,7 @@ export class SyncService {
|
|||||||
constructor(
|
constructor(
|
||||||
private readonly prismaService: PrismaService,
|
private readonly prismaService: PrismaService,
|
||||||
private readonly libraryService: LibraryService,
|
private readonly libraryService: LibraryService,
|
||||||
private readonly defaultUserService: DefaultUserService,
|
private readonly ownerContext: OwnerContext,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async bootstrap(): Promise<SyncBootstrapResponseDto> {
|
async bootstrap(): Promise<SyncBootstrapResponseDto> {
|
||||||
@ -39,10 +39,10 @@ export class SyncService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async getLatestCursor(): Promise<string> {
|
private async getLatestCursor(): Promise<string> {
|
||||||
const defaultUser = await this.defaultUserService.getOrCreateDefaultUser();
|
const owner = await this.ownerContext.resolve();
|
||||||
const latest = await this.prismaService.libraryEvent.findFirst({
|
const latest = await this.prismaService.libraryEvent.findFirst({
|
||||||
where: {
|
where: {
|
||||||
userId: defaultUser.id,
|
userId: owner.userId,
|
||||||
},
|
},
|
||||||
orderBy: {
|
orderBy: {
|
||||||
id: 'desc',
|
id: 'desc',
|
||||||
|
|||||||
@ -3,10 +3,11 @@ import { readFile, mkdtemp, rm } from 'node:fs/promises';
|
|||||||
import { tmpdir } from 'node:os';
|
import { tmpdir } from 'node:os';
|
||||||
import { join } from 'node:path';
|
import { join } from 'node:path';
|
||||||
import { Readable } from 'node:stream';
|
import { Readable } from 'node:stream';
|
||||||
import { UnprocessableEntityException } from '@nestjs/common';
|
import { NotFoundException, UnprocessableEntityException } from '@nestjs/common';
|
||||||
import { UploadSessionStatus } from '@prisma/client';
|
import { UploadSessionStatus } from '@prisma/client';
|
||||||
import { AppConfigService } from '../config/config.service';
|
import { AppConfigService } from '../config/config.service';
|
||||||
import { LocalFilesystemStorageService } from '../storage/storage.service';
|
import { LocalFilesystemStorageService } from '../storage/storage.service';
|
||||||
|
import { OwnerContext } from '../users/owner-context.service';
|
||||||
import { UploadsService } from './uploads.service';
|
import { UploadsService } from './uploads.service';
|
||||||
|
|
||||||
type MockState = ReturnType<typeof createPrismaMock>['state'];
|
type MockState = ReturnType<typeof createPrismaMock>['state'];
|
||||||
@ -282,6 +283,7 @@ describe('UploadsService', () => {
|
|||||||
let storageRoot: string;
|
let storageRoot: string;
|
||||||
let storageService: LocalFilesystemStorageService;
|
let storageService: LocalFilesystemStorageService;
|
||||||
let service: UploadsService;
|
let service: UploadsService;
|
||||||
|
let ownerContext: OwnerContext;
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
const mock = createPrismaMock();
|
const mock = createPrismaMock();
|
||||||
@ -289,10 +291,16 @@ 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));
|
||||||
|
ownerContext = {
|
||||||
|
resolve: jest.fn().mockResolvedValue({
|
||||||
|
userId: state.defaultUser.id,
|
||||||
|
}),
|
||||||
|
} as OwnerContext;
|
||||||
service = new UploadsService(
|
service = new UploadsService(
|
||||||
prismaMock,
|
prismaMock,
|
||||||
createAppConfig(storageRoot),
|
createAppConfig(storageRoot),
|
||||||
storageService,
|
storageService,
|
||||||
|
ownerContext,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -334,6 +342,36 @@ describe('UploadsService', () => {
|
|||||||
expect(state.uploadSessions.size).toBe(1);
|
expect(state.uploadSessions.size).toBe(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('does not reuse foreign-owner audio assets during prepare', async () => {
|
||||||
|
const device = seedDevice();
|
||||||
|
const bytes = sampleMp3Bytes('foreign-owner-asset');
|
||||||
|
const sha256 = sha256Hex(bytes);
|
||||||
|
|
||||||
|
state.audioAssets.set(randomUUID(), {
|
||||||
|
id: randomUUID(),
|
||||||
|
userId: randomUUID(),
|
||||||
|
trackId: randomUUID(),
|
||||||
|
sha256,
|
||||||
|
storageKey: 'users/foreign/audio/foreign-owner-asset.mp3',
|
||||||
|
originalFilename: 'foreign-owner-asset.mp3',
|
||||||
|
mimeType: 'audio/mpeg',
|
||||||
|
fileExtension: 'mp3',
|
||||||
|
fileSizeBytes: BigInt(bytes.length),
|
||||||
|
durationMs: 180000,
|
||||||
|
sourceDeviceId: randomUUID(),
|
||||||
|
createdAt: new Date(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await service.prepare({
|
||||||
|
deviceId: device.id,
|
||||||
|
sha256,
|
||||||
|
originalFilename: 'foreign-owner-asset.mp3',
|
||||||
|
sizeBytes: bytes.length,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.status).toBe('upload_required');
|
||||||
|
});
|
||||||
|
|
||||||
it('rejects an upload whose bytes do not match the prepared sha', async () => {
|
it('rejects an upload whose bytes do not match the prepared sha', async () => {
|
||||||
const device = seedDevice();
|
const device = seedDevice();
|
||||||
const uploadedBytes = sampleMp3Bytes('wrong-sha-upload');
|
const uploadedBytes = sampleMp3Bytes('wrong-sha-upload');
|
||||||
@ -401,6 +439,14 @@ describe('UploadsService', () => {
|
|||||||
expect(state.audioAssets.size).toBe(1);
|
expect(state.audioAssets.size).toBe(1);
|
||||||
expect(state.libraryEvents.size).toBe(1);
|
expect(state.libraryEvents.size).toBe(1);
|
||||||
|
|
||||||
|
const track = [...state.tracks.values()][0];
|
||||||
|
const audioAsset = [...state.audioAssets.values()][0];
|
||||||
|
const libraryEvent = [...state.libraryEvents.values()][0];
|
||||||
|
|
||||||
|
expect(track.userId).toBe(state.defaultUser.id);
|
||||||
|
expect(audioAsset.userId).toBe(state.defaultUser.id);
|
||||||
|
expect(libraryEvent.userId).toBe(state.defaultUser.id);
|
||||||
|
|
||||||
const session = state.uploadSessions.get(response.uploadId!);
|
const session = state.uploadSessions.get(response.uploadId!);
|
||||||
expect(session.finalizedAt).toBeInstanceOf(Date);
|
expect(session.finalizedAt).toBeInstanceOf(Date);
|
||||||
expect(session.trackId).toBe(finalizeResponse.trackId);
|
expect(session.trackId).toBe(finalizeResponse.trackId);
|
||||||
@ -550,4 +596,52 @@ describe('UploadsService', () => {
|
|||||||
);
|
);
|
||||||
expect(storedArtworkBytes.equals(artworkBytes)).toBe(true);
|
expect(storedArtworkBytes.equals(artworkBytes)).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('rejects finalize when the upload session references a foreign-owner track', async () => {
|
||||||
|
const device = seedDevice();
|
||||||
|
const uploadedBytes = sampleMp3Bytes('foreign-track-finalize');
|
||||||
|
const sha256 = sha256Hex(uploadedBytes);
|
||||||
|
const response = await service.prepare({
|
||||||
|
deviceId: device.id,
|
||||||
|
sha256,
|
||||||
|
originalFilename: 'foreign-track-finalize.mp3',
|
||||||
|
sizeBytes: uploadedBytes.length,
|
||||||
|
});
|
||||||
|
|
||||||
|
await service.uploadFile(response.uploadId!, createUploadRequest(uploadedBytes));
|
||||||
|
|
||||||
|
const foreignTrackId = randomUUID();
|
||||||
|
state.tracks.set(foreignTrackId, {
|
||||||
|
id: foreignTrackId,
|
||||||
|
userId: randomUUID(),
|
||||||
|
primaryAudioAssetId: null,
|
||||||
|
artworkAssetId: null,
|
||||||
|
title: 'Foreign Track',
|
||||||
|
artist: 'Elsewhere',
|
||||||
|
album: null,
|
||||||
|
albumArtist: null,
|
||||||
|
genre: null,
|
||||||
|
discNumber: null,
|
||||||
|
trackNumber: null,
|
||||||
|
year: null,
|
||||||
|
durationMs: 180000,
|
||||||
|
status: 'ACTIVE',
|
||||||
|
deletedAt: null,
|
||||||
|
createdAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
});
|
||||||
|
state.uploadSessions.set(response.uploadId!, {
|
||||||
|
...state.uploadSessions.get(response.uploadId!),
|
||||||
|
trackId: foreignTrackId,
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.finalize(response.uploadId!, {
|
||||||
|
title: 'Finalize Track',
|
||||||
|
artist: 'Velody',
|
||||||
|
album: 'Milestone 9',
|
||||||
|
durationMs: 245000,
|
||||||
|
}),
|
||||||
|
).rejects.toBeInstanceOf(NotFoundException);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
import {
|
import {
|
||||||
|
ForbiddenException,
|
||||||
Injectable,
|
Injectable,
|
||||||
NotFoundException,
|
NotFoundException,
|
||||||
UnprocessableEntityException,
|
UnprocessableEntityException,
|
||||||
@ -17,6 +18,7 @@ import { extname } from 'node:path';
|
|||||||
import { PrismaService } from '../../infrastructure/database/prisma.service';
|
import { PrismaService } from '../../infrastructure/database/prisma.service';
|
||||||
import { AppConfigService } from '../config/config.service';
|
import { AppConfigService } from '../config/config.service';
|
||||||
import { LocalFilesystemStorageService } from '../storage/storage.service';
|
import { LocalFilesystemStorageService } from '../storage/storage.service';
|
||||||
|
import { OwnerContext } from '../users/owner-context.service';
|
||||||
import {
|
import {
|
||||||
UploadFinalizeArtworkDto,
|
UploadFinalizeArtworkDto,
|
||||||
UploadFinalizeRequestDto,
|
UploadFinalizeRequestDto,
|
||||||
@ -41,6 +43,7 @@ export class UploadsService {
|
|||||||
private readonly prismaService: PrismaService,
|
private readonly prismaService: PrismaService,
|
||||||
private readonly configService: AppConfigService,
|
private readonly configService: AppConfigService,
|
||||||
private readonly storageService: LocalFilesystemStorageService,
|
private readonly storageService: LocalFilesystemStorageService,
|
||||||
|
private readonly ownerContext: OwnerContext,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async prepare(
|
async prepare(
|
||||||
@ -49,18 +52,13 @@ export class UploadsService {
|
|||||||
this.assertFileSizeWithinLimit(body.sizeBytes);
|
this.assertFileSizeWithinLimit(body.sizeBytes);
|
||||||
this.assertMp3Filename(body.originalFilename);
|
this.assertMp3Filename(body.originalFilename);
|
||||||
|
|
||||||
const device = await this.prismaService.device.findUnique({
|
const ownerUserId = await this.resolveCurrentOwnerUserId();
|
||||||
where: { id: body.deviceId },
|
await this.getOwnedDeviceOrThrow(body.deviceId, ownerUserId);
|
||||||
});
|
|
||||||
|
|
||||||
if (!device) {
|
|
||||||
throw new NotFoundException('Device not found');
|
|
||||||
}
|
|
||||||
|
|
||||||
const existingAsset = await this.prismaService.audioAsset.findUnique({
|
const existingAsset = await this.prismaService.audioAsset.findUnique({
|
||||||
where: {
|
where: {
|
||||||
userId_sha256: {
|
userId_sha256: {
|
||||||
userId: device.userId,
|
userId: ownerUserId,
|
||||||
sha256: body.sha256,
|
sha256: body.sha256,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@ -71,7 +69,7 @@ export class UploadsService {
|
|||||||
const uploadSession = await this.prismaService.uploadSession.create({
|
const uploadSession = await this.prismaService.uploadSession.create({
|
||||||
data: {
|
data: {
|
||||||
id: uploadId,
|
id: uploadId,
|
||||||
userId: device.userId,
|
userId: ownerUserId,
|
||||||
deviceId: body.deviceId,
|
deviceId: body.deviceId,
|
||||||
trackId: existingAsset.trackId,
|
trackId: existingAsset.trackId,
|
||||||
audioAssetId: existingAsset.id,
|
audioAssetId: existingAsset.id,
|
||||||
@ -98,7 +96,7 @@ export class UploadsService {
|
|||||||
const uploadSession = await this.prismaService.uploadSession.create({
|
const uploadSession = await this.prismaService.uploadSession.create({
|
||||||
data: {
|
data: {
|
||||||
id: uploadId,
|
id: uploadId,
|
||||||
userId: device.userId,
|
userId: ownerUserId,
|
||||||
deviceId: body.deviceId,
|
deviceId: body.deviceId,
|
||||||
expectedSha256: body.sha256,
|
expectedSha256: body.sha256,
|
||||||
originalFilename: body.originalFilename,
|
originalFilename: body.originalFilename,
|
||||||
@ -118,14 +116,21 @@ export class UploadsService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async getStatus(uploadId: string): Promise<UploadSessionStatusResponseDto> {
|
async getStatus(uploadId: string): Promise<UploadSessionStatusResponseDto> {
|
||||||
return this.toStatusResponse(await this.getUploadSessionOrThrow(uploadId));
|
const ownerUserId = await this.resolveCurrentOwnerUserId();
|
||||||
|
return this.toStatusResponse(
|
||||||
|
await this.getOwnedUploadSessionOrThrow(uploadId, ownerUserId),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async uploadFile(
|
async uploadFile(
|
||||||
uploadId: string,
|
uploadId: string,
|
||||||
request: Request,
|
request: Request,
|
||||||
): Promise<UploadSessionStatusResponseDto> {
|
): Promise<UploadSessionStatusResponseDto> {
|
||||||
const uploadSession = await this.getUploadSessionOrThrow(uploadId);
|
const ownerUserId = await this.resolveCurrentOwnerUserId();
|
||||||
|
const uploadSession = await this.getOwnedUploadSessionOrThrow(
|
||||||
|
uploadId,
|
||||||
|
ownerUserId,
|
||||||
|
);
|
||||||
|
|
||||||
if (uploadSession.status === UploadSessionStatus.COMPLETED) {
|
if (uploadSession.status === UploadSessionStatus.COMPLETED) {
|
||||||
return this.toStatusResponse(uploadSession);
|
return this.toStatusResponse(uploadSession);
|
||||||
@ -148,7 +153,7 @@ export class UploadsService {
|
|||||||
|
|
||||||
const tempPath = this.storageService.resolve(uploadSession.tempStoragePath);
|
const tempPath = this.storageService.resolve(uploadSession.tempStoragePath);
|
||||||
const finalPath = this.storageService.userAudioAssetPath(
|
const finalPath = this.storageService.userAudioAssetPath(
|
||||||
uploadSession.userId,
|
ownerUserId,
|
||||||
uploadSession.expectedSha256,
|
uploadSession.expectedSha256,
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -239,13 +244,28 @@ export class UploadsService {
|
|||||||
uploadId: string,
|
uploadId: string,
|
||||||
body: UploadFinalizeRequestDto,
|
body: UploadFinalizeRequestDto,
|
||||||
): Promise<UploadFinalizeResponseDto> {
|
): Promise<UploadFinalizeResponseDto> {
|
||||||
const uploadSession = await this.getUploadSessionOrThrow(uploadId);
|
const ownerUserId = await this.resolveCurrentOwnerUserId();
|
||||||
|
const uploadSession = await this.getOwnedUploadSessionOrThrow(
|
||||||
|
uploadId,
|
||||||
|
ownerUserId,
|
||||||
|
);
|
||||||
|
|
||||||
if (
|
if (
|
||||||
uploadSession.finalizedAt &&
|
uploadSession.finalizedAt &&
|
||||||
uploadSession.trackId &&
|
uploadSession.trackId &&
|
||||||
uploadSession.audioAssetId
|
uploadSession.audioAssetId
|
||||||
) {
|
) {
|
||||||
|
await this.assertOwnedTrackOrThrow(
|
||||||
|
this.prismaService,
|
||||||
|
uploadSession.trackId,
|
||||||
|
ownerUserId,
|
||||||
|
);
|
||||||
|
await this.assertOwnedAudioAssetOrThrow(
|
||||||
|
this.prismaService,
|
||||||
|
uploadSession.audioAssetId,
|
||||||
|
ownerUserId,
|
||||||
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
trackId: uploadSession.trackId,
|
trackId: uploadSession.trackId,
|
||||||
assetId: uploadSession.audioAssetId,
|
assetId: uploadSession.audioAssetId,
|
||||||
@ -281,19 +301,28 @@ export class UploadsService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return this.prismaService.$transaction(async (tx) => {
|
return this.prismaService.$transaction(async (tx) => {
|
||||||
const currentSession = await tx.uploadSession.findUnique({
|
const currentSession = await this.getOwnedUploadSessionOrThrow(
|
||||||
where: { id: uploadId },
|
uploadId,
|
||||||
});
|
ownerUserId,
|
||||||
|
tx,
|
||||||
if (!currentSession) {
|
);
|
||||||
throw new NotFoundException('Upload session not found');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
if (
|
||||||
currentSession.finalizedAt &&
|
currentSession.finalizedAt &&
|
||||||
currentSession.trackId &&
|
currentSession.trackId &&
|
||||||
currentSession.audioAssetId
|
currentSession.audioAssetId
|
||||||
) {
|
) {
|
||||||
|
await this.assertOwnedTrackOrThrow(
|
||||||
|
tx,
|
||||||
|
currentSession.trackId,
|
||||||
|
ownerUserId,
|
||||||
|
);
|
||||||
|
await this.assertOwnedAudioAssetOrThrow(
|
||||||
|
tx,
|
||||||
|
currentSession.audioAssetId,
|
||||||
|
ownerUserId,
|
||||||
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
trackId: currentSession.trackId,
|
trackId: currentSession.trackId,
|
||||||
assetId: currentSession.audioAssetId,
|
assetId: currentSession.audioAssetId,
|
||||||
@ -308,7 +337,7 @@ export class UploadsService {
|
|||||||
|
|
||||||
const preparedArtwork = body.artwork
|
const preparedArtwork = body.artwork
|
||||||
? await this.prepareArtworkAssetInput(
|
? await this.prepareArtworkAssetInput(
|
||||||
currentSession.userId,
|
ownerUserId,
|
||||||
body.artwork,
|
body.artwork,
|
||||||
)
|
)
|
||||||
: null;
|
: null;
|
||||||
@ -316,7 +345,7 @@ export class UploadsService {
|
|||||||
let audioAsset = await tx.audioAsset.findUnique({
|
let audioAsset = await tx.audioAsset.findUnique({
|
||||||
where: {
|
where: {
|
||||||
userId_sha256: {
|
userId_sha256: {
|
||||||
userId: currentSession.userId,
|
userId: ownerUserId,
|
||||||
sha256: currentSession.expectedSha256,
|
sha256: currentSession.expectedSha256,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@ -324,20 +353,22 @@ export class UploadsService {
|
|||||||
|
|
||||||
let track =
|
let track =
|
||||||
currentSession.trackId != null
|
currentSession.trackId != null
|
||||||
? await tx.track.findUnique({ where: { id: currentSession.trackId } })
|
? await this.findOwnedTrackById(tx, currentSession.trackId, ownerUserId)
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
|
if (currentSession.trackId != null && !track) {
|
||||||
|
throw new NotFoundException('Track not found');
|
||||||
|
}
|
||||||
|
|
||||||
if (!track && audioAsset?.trackId) {
|
if (!track && audioAsset?.trackId) {
|
||||||
track = await tx.track.findUnique({
|
track = await this.findOwnedTrackById(tx, audioAsset.trackId, ownerUserId);
|
||||||
where: { id: audioAsset.trackId },
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const createdTrack = !track;
|
const createdTrack = !track;
|
||||||
if (!track) {
|
if (!track) {
|
||||||
track = await tx.track.create({
|
track = await tx.track.create({
|
||||||
data: {
|
data: {
|
||||||
userId: currentSession.userId,
|
userId: ownerUserId,
|
||||||
title,
|
title,
|
||||||
artist,
|
artist,
|
||||||
album,
|
album,
|
||||||
@ -375,7 +406,7 @@ export class UploadsService {
|
|||||||
} else {
|
} else {
|
||||||
audioAsset = await tx.audioAsset.create({
|
audioAsset = await tx.audioAsset.create({
|
||||||
data: {
|
data: {
|
||||||
userId: currentSession.userId,
|
userId: ownerUserId,
|
||||||
trackId: track.id,
|
trackId: track.id,
|
||||||
sha256: currentSession.expectedSha256,
|
sha256: currentSession.expectedSha256,
|
||||||
storageKey: finalStorageKey,
|
storageKey: finalStorageKey,
|
||||||
@ -402,7 +433,7 @@ export class UploadsService {
|
|||||||
? (
|
? (
|
||||||
await this.findOrCreateArtworkAsset(
|
await this.findOrCreateArtworkAsset(
|
||||||
tx,
|
tx,
|
||||||
currentSession.userId,
|
ownerUserId,
|
||||||
preparedArtwork,
|
preparedArtwork,
|
||||||
)
|
)
|
||||||
).id
|
).id
|
||||||
@ -419,7 +450,7 @@ export class UploadsService {
|
|||||||
|
|
||||||
await tx.libraryEvent.create({
|
await tx.libraryEvent.create({
|
||||||
data: {
|
data: {
|
||||||
userId: currentSession.userId,
|
userId: ownerUserId,
|
||||||
entityType: EntityType.TRACK,
|
entityType: EntityType.TRACK,
|
||||||
entityId: track.id,
|
entityId: track.id,
|
||||||
action: createdTrack ? EventAction.CREATED : EventAction.UPDATED,
|
action: createdTrack ? EventAction.CREATED : EventAction.UPDATED,
|
||||||
@ -443,18 +474,85 @@ export class UploadsService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private async getUploadSessionOrThrow(uploadId: string): Promise<UploadSession> {
|
private async resolveCurrentOwnerUserId(): Promise<string> {
|
||||||
const uploadSession = await this.prismaService.uploadSession.findUnique({
|
const owner = await this.ownerContext.resolve();
|
||||||
|
return owner.userId;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getOwnedDeviceOrThrow(deviceId: string, ownerUserId: string) {
|
||||||
|
const device = await this.prismaService.device.findUnique({
|
||||||
|
where: { id: deviceId },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
userId: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!device || device.userId !== ownerUserId) {
|
||||||
|
throw new NotFoundException('Device not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
return device;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getOwnedUploadSessionOrThrow(
|
||||||
|
uploadId: string,
|
||||||
|
ownerUserId: string,
|
||||||
|
client: Pick<PrismaService, 'uploadSession'> = this.prismaService,
|
||||||
|
): Promise<UploadSession> {
|
||||||
|
const uploadSession = await client.uploadSession.findUnique({
|
||||||
where: { id: uploadId },
|
where: { id: uploadId },
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!uploadSession) {
|
if (!uploadSession || uploadSession.userId !== ownerUserId) {
|
||||||
throw new NotFoundException('Upload session not found');
|
throw new NotFoundException('Upload session not found');
|
||||||
}
|
}
|
||||||
|
|
||||||
return uploadSession;
|
return uploadSession;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async findOwnedTrackById(
|
||||||
|
client: Pick<PrismaService, 'track'>,
|
||||||
|
trackId: string,
|
||||||
|
ownerUserId: string,
|
||||||
|
) {
|
||||||
|
const track = await client.track.findUnique({
|
||||||
|
where: { id: trackId },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!track || track.userId !== ownerUserId) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return track;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async assertOwnedTrackOrThrow(
|
||||||
|
client: Pick<PrismaService, 'track'>,
|
||||||
|
trackId: string,
|
||||||
|
ownerUserId: string,
|
||||||
|
): Promise<void> {
|
||||||
|
const track = await this.findOwnedTrackById(client, trackId, ownerUserId);
|
||||||
|
|
||||||
|
if (!track) {
|
||||||
|
throw new NotFoundException('Track not found');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async assertOwnedAudioAssetOrThrow(
|
||||||
|
client: Pick<PrismaService, 'audioAsset'>,
|
||||||
|
assetId: string,
|
||||||
|
ownerUserId: string,
|
||||||
|
): Promise<void> {
|
||||||
|
const audioAsset = await client.audioAsset.findUnique({
|
||||||
|
where: { id: assetId },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!audioAsset || audioAsset.userId !== ownerUserId) {
|
||||||
|
throw new ForbiddenException('Audio asset does not belong to the current owner.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private toStatusResponse(
|
private toStatusResponse(
|
||||||
uploadSession: Pick<
|
uploadSession: Pick<
|
||||||
UploadSession,
|
UploadSession,
|
||||||
|
|||||||
59
backend/src/modules/users/default-user.service.spec.ts
Normal file
59
backend/src/modules/users/default-user.service.spec.ts
Normal file
@ -0,0 +1,59 @@
|
|||||||
|
import { randomUUID } from 'node:crypto';
|
||||||
|
import { DefaultUserService } from './default-user.service';
|
||||||
|
|
||||||
|
describe('DefaultUserService', () => {
|
||||||
|
it('upserts the bootstrap default owner with stable ownership metadata', async () => {
|
||||||
|
const defaultUser = {
|
||||||
|
id: randomUUID(),
|
||||||
|
slug: DefaultUserService.defaultOwnerSlug,
|
||||||
|
displayName: DefaultUserService.defaultOwnerDisplayName,
|
||||||
|
isDefault: true,
|
||||||
|
createdAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
};
|
||||||
|
const prismaService = {
|
||||||
|
user: {
|
||||||
|
upsert: jest.fn().mockResolvedValue(defaultUser),
|
||||||
|
},
|
||||||
|
} as any;
|
||||||
|
const service = new DefaultUserService(prismaService);
|
||||||
|
|
||||||
|
await expect(service.getOrCreateDefaultUser()).resolves.toEqual(defaultUser);
|
||||||
|
expect(prismaService.user.upsert).toHaveBeenCalledWith({
|
||||||
|
where: {
|
||||||
|
slug: DefaultUserService.defaultOwnerSlug,
|
||||||
|
},
|
||||||
|
update: {
|
||||||
|
displayName: DefaultUserService.defaultOwnerDisplayName,
|
||||||
|
isDefault: true,
|
||||||
|
},
|
||||||
|
create: {
|
||||||
|
slug: DefaultUserService.defaultOwnerSlug,
|
||||||
|
displayName: DefaultUserService.defaultOwnerDisplayName,
|
||||||
|
isDefault: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('creates the bootstrap default owner during application startup', async () => {
|
||||||
|
const service = new DefaultUserService({
|
||||||
|
user: {
|
||||||
|
upsert: jest.fn(),
|
||||||
|
},
|
||||||
|
} as any);
|
||||||
|
const getOrCreateDefaultUserSpy = jest
|
||||||
|
.spyOn(service, 'getOrCreateDefaultUser')
|
||||||
|
.mockResolvedValue({
|
||||||
|
id: randomUUID(),
|
||||||
|
slug: DefaultUserService.defaultOwnerSlug,
|
||||||
|
displayName: DefaultUserService.defaultOwnerDisplayName,
|
||||||
|
isDefault: true,
|
||||||
|
createdAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
});
|
||||||
|
|
||||||
|
await service.onApplicationBootstrap();
|
||||||
|
|
||||||
|
expect(getOrCreateDefaultUserSpy).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -1,14 +1,18 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable, OnApplicationBootstrap } from '@nestjs/common';
|
||||||
import { User } from '@prisma/client';
|
import { User } from '@prisma/client';
|
||||||
import { PrismaService } from '../../infrastructure/database/prisma.service';
|
import { PrismaService } from '../../infrastructure/database/prisma.service';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class DefaultUserService {
|
export class DefaultUserService implements OnApplicationBootstrap {
|
||||||
static readonly defaultOwnerSlug = 'default-owner';
|
static readonly defaultOwnerSlug = 'default-owner';
|
||||||
static readonly defaultOwnerDisplayName = 'Default Owner';
|
static readonly defaultOwnerDisplayName = 'Default Owner';
|
||||||
|
|
||||||
constructor(private readonly prismaService: PrismaService) {}
|
constructor(private readonly prismaService: PrismaService) {}
|
||||||
|
|
||||||
|
async onApplicationBootstrap(): Promise<void> {
|
||||||
|
await this.getOrCreateDefaultUser();
|
||||||
|
}
|
||||||
|
|
||||||
async getOrCreateDefaultUser(): Promise<User> {
|
async getOrCreateDefaultUser(): Promise<User> {
|
||||||
return this.prismaService.user.upsert({
|
return this.prismaService.user.upsert({
|
||||||
where: {
|
where: {
|
||||||
|
|||||||
22
backend/src/modules/users/owner-context.service.spec.ts
Normal file
22
backend/src/modules/users/owner-context.service.spec.ts
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
import { randomUUID } from 'node:crypto';
|
||||||
|
import { BootstrapOwnerContextService } from './owner-context.service';
|
||||||
|
|
||||||
|
describe('BootstrapOwnerContextService', () => {
|
||||||
|
it('resolves the bootstrap default owner today', async () => {
|
||||||
|
const defaultUser = {
|
||||||
|
id: randomUUID(),
|
||||||
|
slug: 'default-owner',
|
||||||
|
displayName: 'Default Owner',
|
||||||
|
isDefault: true,
|
||||||
|
};
|
||||||
|
const defaultUserService = {
|
||||||
|
getOrCreateDefaultUser: jest.fn().mockResolvedValue(defaultUser),
|
||||||
|
} as any;
|
||||||
|
const service = new BootstrapOwnerContextService(defaultUserService);
|
||||||
|
|
||||||
|
await expect(service.resolve()).resolves.toEqual({
|
||||||
|
userId: defaultUser.id,
|
||||||
|
});
|
||||||
|
expect(defaultUserService.getOrCreateDefaultUser).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
25
backend/src/modules/users/owner-context.service.ts
Normal file
25
backend/src/modules/users/owner-context.service.ts
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { DefaultUserService } from './default-user.service';
|
||||||
|
|
||||||
|
export interface ResolvedOwnerContext {
|
||||||
|
userId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export abstract class OwnerContext {
|
||||||
|
abstract resolve(): Promise<ResolvedOwnerContext>;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class BootstrapOwnerContextService extends OwnerContext {
|
||||||
|
constructor(private readonly defaultUserService: DefaultUserService) {
|
||||||
|
super();
|
||||||
|
}
|
||||||
|
|
||||||
|
async resolve(): Promise<ResolvedOwnerContext> {
|
||||||
|
const defaultUser = await this.defaultUserService.getOrCreateDefaultUser();
|
||||||
|
|
||||||
|
return {
|
||||||
|
userId: defaultUser.id,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,10 +1,21 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { PrismaModule } from '../../infrastructure/database/prisma.module';
|
import { PrismaModule } from '../../infrastructure/database/prisma.module';
|
||||||
import { DefaultUserService } from './default-user.service';
|
import { DefaultUserService } from './default-user.service';
|
||||||
|
import {
|
||||||
|
BootstrapOwnerContextService,
|
||||||
|
OwnerContext,
|
||||||
|
} from './owner-context.service';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [PrismaModule],
|
imports: [PrismaModule],
|
||||||
providers: [DefaultUserService],
|
providers: [
|
||||||
exports: [DefaultUserService],
|
DefaultUserService,
|
||||||
|
BootstrapOwnerContextService,
|
||||||
|
{
|
||||||
|
provide: OwnerContext,
|
||||||
|
useExisting: BootstrapOwnerContextService,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
exports: [DefaultUserService, OwnerContext],
|
||||||
})
|
})
|
||||||
export class UsersModule {}
|
export class UsersModule {}
|
||||||
|
|||||||
@ -313,6 +313,7 @@ function createPrismaMock() {
|
|||||||
|
|
||||||
describe('Velody API wiring (e2e)', () => {
|
describe('Velody API wiring (e2e)', () => {
|
||||||
let app: NestExpressApplication;
|
let app: NestExpressApplication;
|
||||||
|
let prismaMock: ReturnType<typeof createPrismaMock>['prismaMock'];
|
||||||
let assetsController: AssetsController;
|
let assetsController: AssetsController;
|
||||||
let artworkController: ArtworkController;
|
let artworkController: ArtworkController;
|
||||||
let healthController: HealthController;
|
let healthController: HealthController;
|
||||||
@ -325,7 +326,9 @@ describe('Velody API wiring (e2e)', () => {
|
|||||||
let storageRoot: string;
|
let storageRoot: string;
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
const { prismaMock, state } = createPrismaMock();
|
const prismaSetup = createPrismaMock();
|
||||||
|
prismaMock = prismaSetup.prismaMock;
|
||||||
|
const { state } = prismaSetup;
|
||||||
prismaState = state;
|
prismaState = state;
|
||||||
storageRoot = await mkdtemp(join(tmpdir(), 'velody-e2e-'));
|
storageRoot = await mkdtemp(join(tmpdir(), 'velody-e2e-'));
|
||||||
|
|
||||||
@ -385,6 +388,23 @@ describe('Velody API wiring (e2e)', () => {
|
|||||||
expect(response.version).toBe('0.1.0');
|
expect(response.version).toBe('0.1.0');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('creates the bootstrap default owner during application startup', () => {
|
||||||
|
expect(prismaMock.user.upsert).toHaveBeenCalledWith({
|
||||||
|
where: {
|
||||||
|
slug: 'default-owner',
|
||||||
|
},
|
||||||
|
update: {
|
||||||
|
displayName: 'Default Owner',
|
||||||
|
isDefault: true,
|
||||||
|
},
|
||||||
|
create: {
|
||||||
|
slug: 'default-owner',
|
||||||
|
displayName: 'Default Owner',
|
||||||
|
isDefault: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it('registers a device and accepts heartbeat', async () => {
|
it('registers a device and accepts heartbeat', async () => {
|
||||||
const registerResponse = await devicesController.register({
|
const registerResponse = await devicesController.register({
|
||||||
platform: 'MACOS',
|
platform: 'MACOS',
|
||||||
@ -394,6 +414,9 @@ describe('Velody API wiring (e2e)', () => {
|
|||||||
|
|
||||||
expect(registerResponse.deviceId).toBeDefined();
|
expect(registerResponse.deviceId).toBeDefined();
|
||||||
expect(registerResponse.bootstrapToken).toBeDefined();
|
expect(registerResponse.bootstrapToken).toBeDefined();
|
||||||
|
expect(prismaState.devices.get(registerResponse.deviceId)?.userId).toBe(
|
||||||
|
prismaState.defaultUser.id,
|
||||||
|
);
|
||||||
|
|
||||||
const heartbeatResponse = await devicesController.heartbeat({
|
const heartbeatResponse = await devicesController.heartbeat({
|
||||||
deviceId: registerResponse.deviceId,
|
deviceId: registerResponse.deviceId,
|
||||||
@ -403,6 +426,28 @@ describe('Velody API wiring (e2e)', () => {
|
|||||||
expect(heartbeatResponse.ok).toBe(true);
|
expect(heartbeatResponse.ok).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('rejects heartbeat updates for a foreign-owner device', async () => {
|
||||||
|
const foreignDeviceId = randomUUID();
|
||||||
|
prismaState.devices.set(foreignDeviceId, {
|
||||||
|
id: foreignDeviceId,
|
||||||
|
userId: randomUUID(),
|
||||||
|
platform: 'MACOS',
|
||||||
|
deviceName: 'Foreign Mac',
|
||||||
|
appVersion: '0.1.0',
|
||||||
|
installTokenHash: 'foreign-device-hash',
|
||||||
|
lastSeenAt: new Date(),
|
||||||
|
createdAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
devicesController.heartbeat({
|
||||||
|
deviceId: foreignDeviceId,
|
||||||
|
appVersion: '0.1.1',
|
||||||
|
}),
|
||||||
|
).rejects.toBeInstanceOf(NotFoundException);
|
||||||
|
});
|
||||||
|
|
||||||
it('returns sync bootstrap and changes payloads', async () => {
|
it('returns sync bootstrap and changes payloads', async () => {
|
||||||
const bootstrapResponse = await syncController.bootstrap();
|
const bootstrapResponse = await syncController.bootstrap();
|
||||||
const changesResponse = await syncController.changes({ after: '0' });
|
const changesResponse = await syncController.changes({ after: '0' });
|
||||||
@ -412,6 +457,47 @@ describe('Velody API wiring (e2e)', () => {
|
|||||||
expect(changesResponse.nextCursor).toBe('0');
|
expect(changesResponse.nextCursor).toBe('0');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('sync bootstrap and changes do not expose foreign-owner data', async () => {
|
||||||
|
const foreignUserId = randomUUID();
|
||||||
|
const foreignTrackId = randomUUID();
|
||||||
|
|
||||||
|
prismaState.tracks.set(foreignTrackId, {
|
||||||
|
id: foreignTrackId,
|
||||||
|
userId: foreignUserId,
|
||||||
|
primaryAudioAssetId: null,
|
||||||
|
artworkAssetId: null,
|
||||||
|
title: 'Foreign Bootstrap Track',
|
||||||
|
artist: 'Elsewhere',
|
||||||
|
album: null,
|
||||||
|
albumArtist: null,
|
||||||
|
genre: null,
|
||||||
|
discNumber: null,
|
||||||
|
trackNumber: null,
|
||||||
|
year: null,
|
||||||
|
durationMs: 180000,
|
||||||
|
status: 'ACTIVE',
|
||||||
|
deletedAt: null,
|
||||||
|
createdAt: new Date('2026-05-29T08:00:00.000Z'),
|
||||||
|
updatedAt: new Date('2026-05-29T08:01:00.000Z'),
|
||||||
|
});
|
||||||
|
prismaState.libraryEvents.set(1n, {
|
||||||
|
id: 1n,
|
||||||
|
userId: foreignUserId,
|
||||||
|
entityType: 'TRACK',
|
||||||
|
entityId: foreignTrackId,
|
||||||
|
action: 'CREATED',
|
||||||
|
payloadVersion: 1,
|
||||||
|
createdAt: new Date('2026-05-29T08:02:00.000Z'),
|
||||||
|
});
|
||||||
|
|
||||||
|
const bootstrapResponse = await syncController.bootstrap();
|
||||||
|
const changesResponse = await syncController.changes({ after: '0' });
|
||||||
|
|
||||||
|
expect(bootstrapResponse.tracks).toEqual([]);
|
||||||
|
expect(changesResponse.events).toEqual([]);
|
||||||
|
expect(changesResponse.nextCursor).toBe('0');
|
||||||
|
});
|
||||||
|
|
||||||
it('downloads audio asset bytes for the owning device user', async () => {
|
it('downloads audio asset bytes for the owning device user', async () => {
|
||||||
const registerResponse = await devicesController.register({
|
const registerResponse = await devicesController.register({
|
||||||
platform: 'IPHONE',
|
platform: 'IPHONE',
|
||||||
@ -610,6 +696,36 @@ describe('Velody API wiring (e2e)', () => {
|
|||||||
expect(headers.get('content-length')).toBe(String(bytes.length));
|
expect(headers.get('content-length')).toBe(String(bytes.length));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('rejects artwork download requests for another user artwork', async () => {
|
||||||
|
const registerResponse = await devicesController.register({
|
||||||
|
platform: 'IPHONE',
|
||||||
|
deviceName: 'Artwork iPhone',
|
||||||
|
appVersion: '0.1.0',
|
||||||
|
});
|
||||||
|
const artworkId = randomUUID();
|
||||||
|
const otherUserId = randomUUID();
|
||||||
|
|
||||||
|
prismaState.artworkAssets.set(artworkId, {
|
||||||
|
id: artworkId,
|
||||||
|
userId: otherUserId,
|
||||||
|
sha256: 'sha-other-artwork',
|
||||||
|
storageKey: join('users', otherUserId, 'artwork', 'sha-other-artwork.png'),
|
||||||
|
mimeType: 'image/png',
|
||||||
|
width: 1,
|
||||||
|
height: 1,
|
||||||
|
fileSizeBytes: BigInt(10),
|
||||||
|
createdAt: new Date('2026-05-29T08:00:00.000Z'),
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
artworkController.download(
|
||||||
|
artworkId,
|
||||||
|
{ deviceId: registerResponse.deviceId },
|
||||||
|
{ setHeader() {} } as any,
|
||||||
|
),
|
||||||
|
).rejects.toBeInstanceOf(ForbiddenException);
|
||||||
|
});
|
||||||
|
|
||||||
it('returns not found when the requested artwork file is missing', async () => {
|
it('returns not found when the requested artwork file is missing', async () => {
|
||||||
const registerResponse = await devicesController.register({
|
const registerResponse = await devicesController.register({
|
||||||
platform: 'IPHONE',
|
platform: 'IPHONE',
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user