diff --git a/backend/prisma/migrations/20260604100000_milestone91_user_ownership_foundation/migration.sql b/backend/prisma/migrations/20260604100000_milestone91_user_ownership_foundation/migration.sql new file mode 100644 index 0000000..bdde3b4 --- /dev/null +++ b/backend/prisma/migrations/20260604100000_milestone91_user_ownership_foundation/migration.sql @@ -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"); diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index 82f7dbc..ed7e112 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -97,7 +97,7 @@ model AudioAsset { } model ArtworkAsset { - userId String? @db.Uuid @map("user_id") + userId String @db.Uuid @map("user_id") id String @id @default(uuid()) @db.Uuid sha256 String storageKey String @unique @map("storage_key") @@ -107,7 +107,7 @@ model ArtworkAsset { fileSizeBytes BigInt @map("file_size_bytes") createdAt DateTime @default(now()) @map("created_at") 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]) @@index([userId]) diff --git a/backend/src/app.module.ts b/backend/src/app.module.ts index 058aee4..3cc417b 100644 --- a/backend/src/app.module.ts +++ b/backend/src/app.module.ts @@ -7,10 +7,12 @@ import { HealthModule } from './modules/health/health.module'; import { LibraryModule } from './modules/library/library.module'; import { SyncModule } from './modules/sync/sync.module'; import { UploadsModule } from './modules/uploads/uploads.module'; +import { UsersModule } from './modules/users/users.module'; @Module({ imports: [ AppConfigModule, + UsersModule, AssetsModule, ArtworkModule, HealthModule, diff --git a/backend/src/modules/artwork/artwork.module.ts b/backend/src/modules/artwork/artwork.module.ts index 21aa927..c7ce75c 100644 --- a/backend/src/modules/artwork/artwork.module.ts +++ b/backend/src/modules/artwork/artwork.module.ts @@ -1,11 +1,12 @@ import { Module } from '@nestjs/common'; import { PrismaModule } from '../../infrastructure/database/prisma.module'; import { StorageModule } from '../storage/storage.module'; +import { UsersModule } from '../users/users.module'; import { ArtworkController } from './artwork.controller'; import { ArtworkService } from './artwork.service'; @Module({ - imports: [PrismaModule, StorageModule], + imports: [PrismaModule, StorageModule, UsersModule], controllers: [ArtworkController], providers: [ArtworkService], }) diff --git a/backend/src/modules/artwork/artwork.service.spec.ts b/backend/src/modules/artwork/artwork.service.spec.ts index 3586949..8e25edb 100644 --- a/backend/src/modules/artwork/artwork.service.spec.ts +++ b/backend/src/modules/artwork/artwork.service.spec.ts @@ -6,6 +6,7 @@ import { ForbiddenException, NotFoundException } from '@nestjs/common'; import { PrismaService } from '../../infrastructure/database/prisma.service'; import { AppConfigService } from '../config/config.service'; import { LocalFilesystemStorageService } from '../storage/storage.service'; +import { OwnerContext } from '../users/owner-context.service'; import { ArtworkService } from './artwork.service'; type MockState = ReturnType['state']; @@ -46,13 +47,23 @@ describe('ArtworkService', () => { let state: MockState; let storageRoot: string; let storageService: LocalFilesystemStorageService; + let ownerUserId: string; beforeEach(async () => { const mock = createPrismaMock(); state = mock.state; storageRoot = await mkdtemp(join(tmpdir(), 'velody-artwork-spec-')); 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 () => { @@ -60,7 +71,7 @@ describe('ArtworkService', () => { }); 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 artworkId = randomUUID(); const storageKey = join('users', userId, 'artwork', `${artworkId}.png`); @@ -74,7 +85,6 @@ describe('ArtworkService', () => { userId, storageKey, mimeType: 'image/png', - tracks: [{ userId }], }); const filePath = storageService.resolve(storageKey); @@ -88,18 +98,16 @@ describe('ArtworkService', () => { expect(download.mimeType).toBe('image/png'); }); - it('rejects download attempts from a different user device', async () => { - const ownerId = randomUUID(); + it('rejects cross-owner artwork downloads', async () => { const otherUserId = randomUUID(); const ownerDeviceId = randomUUID(); const artworkId = randomUUID(); - state.devices.set(ownerDeviceId, { id: ownerDeviceId, userId: otherUserId }); + state.devices.set(ownerDeviceId, { id: ownerDeviceId, userId: ownerUserId }); state.artworkAssets.set(artworkId, { - userId: ownerId, - storageKey: join('users', ownerId, 'artwork', `${artworkId}.jpg`), + userId: otherUserId, + storageKey: join('users', otherUserId, 'artwork', `${artworkId}.jpg`), mimeType: 'image/jpeg', - tracks: [{ userId: ownerId }], }); await expect( @@ -107,8 +115,21 @@ describe('ArtworkService', () => { ).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 () => { - const userId = randomUUID(); + const userId = ownerUserId; const deviceId = randomUUID(); const artworkId = randomUUID(); @@ -117,7 +138,6 @@ describe('ArtworkService', () => { userId, storageKey: join('users', userId, 'artwork', `${artworkId}.png`), mimeType: 'image/png', - tracks: [{ userId }], }); await expect( diff --git a/backend/src/modules/artwork/artwork.service.ts b/backend/src/modules/artwork/artwork.service.ts index 417c3d2..42c3eb0 100644 --- a/backend/src/modules/artwork/artwork.service.ts +++ b/backend/src/modules/artwork/artwork.service.ts @@ -6,6 +6,7 @@ import { import { stat } from 'node:fs/promises'; import { PrismaService } from '../../infrastructure/database/prisma.service'; import { LocalFilesystemStorageService } from '../storage/storage.service'; +import { OwnerContext } from '../users/owner-context.service'; export interface ArtworkDownload { filePath: string; @@ -18,12 +19,14 @@ export class ArtworkService { constructor( private readonly prismaService: PrismaService, private readonly storageService: LocalFilesystemStorageService, + private readonly ownerContext: OwnerContext, ) {} async getOwnedArtworkDownload( artworkId: string, deviceId: string, ): Promise { + const ownerUserId = await this.resolveCurrentOwnerUserId(); const device = await this.prismaService.device.findUnique({ where: { id: deviceId }, select: { @@ -31,7 +34,7 @@ export class ArtworkService { }, }); - if (!device) { + if (!device || device.userId !== ownerUserId) { throw new NotFoundException('Device not found'); } @@ -41,12 +44,6 @@ export class ArtworkService { userId: true, storageKey: true, mimeType: true, - tracks: { - take: 1, - select: { - userId: true, - }, - }, }, }); @@ -54,12 +51,7 @@ export class ArtworkService { throw new NotFoundException('Artwork not found'); } - const ownerUserId = artwork.userId ?? artwork.tracks[0]?.userId; - if (!ownerUserId) { - throw new NotFoundException('Artwork not found'); - } - - if (ownerUserId !== device.userId) { + if (artwork.userId !== ownerUserId) { 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'); } } + + private async resolveCurrentOwnerUserId(): Promise { + const owner = await this.ownerContext.resolve(); + return owner.userId; + } } diff --git a/backend/src/modules/assets/assets.module.ts b/backend/src/modules/assets/assets.module.ts index d4dce9e..f06095e 100644 --- a/backend/src/modules/assets/assets.module.ts +++ b/backend/src/modules/assets/assets.module.ts @@ -1,11 +1,12 @@ import { Module } from '@nestjs/common'; import { PrismaModule } from '../../infrastructure/database/prisma.module'; import { StorageModule } from '../storage/storage.module'; +import { UsersModule } from '../users/users.module'; import { AssetsController } from './assets.controller'; import { AssetsService } from './assets.service'; @Module({ - imports: [PrismaModule, StorageModule], + imports: [PrismaModule, StorageModule, UsersModule], controllers: [AssetsController], providers: [AssetsService], }) diff --git a/backend/src/modules/assets/assets.service.spec.ts b/backend/src/modules/assets/assets.service.spec.ts index 403a481..d9a5fc0 100644 --- a/backend/src/modules/assets/assets.service.spec.ts +++ b/backend/src/modules/assets/assets.service.spec.ts @@ -6,6 +6,7 @@ import { ForbiddenException, NotFoundException } from '@nestjs/common'; import { PrismaService } from '../../infrastructure/database/prisma.service'; import { AppConfigService } from '../config/config.service'; import { LocalFilesystemStorageService } from '../storage/storage.service'; +import { OwnerContext } from '../users/owner-context.service'; import { AssetsService } from './assets.service'; type MockState = ReturnType['state']; @@ -46,13 +47,23 @@ describe('AssetsService', () => { let state: MockState; let storageRoot: string; let storageService: LocalFilesystemStorageService; + let ownerUserId: string; beforeEach(async () => { const mock = createPrismaMock(); state = mock.state; storageRoot = await mkdtemp(join(tmpdir(), 'velody-assets-spec-')); 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 () => { @@ -60,7 +71,7 @@ describe('AssetsService', () => { }); 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 assetId = randomUUID(); const storageKey = join('users', userId, 'audio', 'owner.mp3'); @@ -83,17 +94,16 @@ describe('AssetsService', () => { expect(download.contentLength).toBe(assetBytes.length); }); - it('rejects download attempts from a different user device', async () => { - const ownerId = randomUUID(); + it('rejects cross-owner audio asset downloads', async () => { const otherUserId = randomUUID(); const ownerDeviceId = randomUUID(); const assetId = randomUUID(); - state.devices.set(ownerDeviceId, { id: ownerDeviceId, userId: otherUserId }); + state.devices.set(ownerDeviceId, { id: ownerDeviceId, userId: ownerUserId }); state.audioAssets.set(assetId, { id: assetId, - userId: ownerId, - storageKey: join('users', ownerId, 'audio', 'owner.mp3'), + userId: otherUserId, + storageKey: join('users', otherUserId, 'audio', 'other.mp3'), }); await expect( @@ -101,8 +111,22 @@ describe('AssetsService', () => { ).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 () => { - const userId = randomUUID(); + const userId = ownerUserId; const deviceId = randomUUID(); const assetId = randomUUID(); diff --git a/backend/src/modules/assets/assets.service.ts b/backend/src/modules/assets/assets.service.ts index b860760..c6553f9 100644 --- a/backend/src/modules/assets/assets.service.ts +++ b/backend/src/modules/assets/assets.service.ts @@ -6,6 +6,7 @@ import { import { stat } from 'node:fs/promises'; import { PrismaService } from '../../infrastructure/database/prisma.service'; import { LocalFilesystemStorageService } from '../storage/storage.service'; +import { OwnerContext } from '../users/owner-context.service'; export interface AudioAssetDownload { filePath: string; @@ -17,12 +18,14 @@ export class AssetsService { constructor( private readonly prismaService: PrismaService, private readonly storageService: LocalFilesystemStorageService, + private readonly ownerContext: OwnerContext, ) {} async getOwnedAudioAssetDownload( assetId: string, deviceId: string, ): Promise { + const ownerUserId = await this.resolveCurrentOwnerUserId(); const device = await this.prismaService.device.findUnique({ where: { id: deviceId }, select: { @@ -30,7 +33,7 @@ export class AssetsService { }, }); - if (!device) { + if (!device || device.userId !== ownerUserId) { throw new NotFoundException('Device not found'); } @@ -46,7 +49,7 @@ export class AssetsService { 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.'); } @@ -71,4 +74,9 @@ export class AssetsService { throw new NotFoundException('Audio asset file not found'); } } + + private async resolveCurrentOwnerUserId(): Promise { + const owner = await this.ownerContext.resolve(); + return owner.userId; + } } diff --git a/backend/src/modules/devices/devices.service.spec.ts b/backend/src/modules/devices/devices.service.spec.ts new file mode 100644 index 0000000..42c68eb --- /dev/null +++ b/backend/src/modules/devices/devices.service.spec.ts @@ -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(); + }); +}); diff --git a/backend/src/modules/devices/devices.service.ts b/backend/src/modules/devices/devices.service.ts index 94e4ff7..9c27ec5 100644 --- a/backend/src/modules/devices/devices.service.ts +++ b/backend/src/modules/devices/devices.service.ts @@ -1,7 +1,7 @@ import { Injectable, NotFoundException } from '@nestjs/common'; import { createHash, randomBytes } from 'node:crypto'; import { PrismaService } from '../../infrastructure/database/prisma.service'; -import { DefaultUserService } from '../users/default-user.service'; +import { OwnerContext } from '../users/owner-context.service'; import { DeviceHeartbeatRequestDto, DeviceHeartbeatResponseDto, @@ -13,7 +13,7 @@ import { export class DevicesService { constructor( private readonly prismaService: PrismaService, - private readonly defaultUserService: DefaultUserService, + private readonly ownerContext: OwnerContext, ) {} async register( @@ -23,11 +23,11 @@ export class DevicesService { const installTokenHash = createHash('sha256') .update(bootstrapToken) .digest('hex'); - const defaultUser = await this.defaultUserService.getOrCreateDefaultUser(); + const owner = await this.ownerContext.resolve(); const device = await this.prismaService.device.create({ data: { - userId: defaultUser.id, + userId: owner.userId, platform: body.platform, deviceName: body.deviceName, appVersion: body.appVersion, @@ -46,11 +46,15 @@ export class DevicesService { async heartbeat( body: DeviceHeartbeatRequestDto, ): Promise { + const ownerUserId = await this.resolveCurrentOwnerUserId(); const existing = await this.prismaService.device.findUnique({ where: { id: body.deviceId }, + select: { + userId: true, + }, }); - if (!existing) { + if (!existing || existing.userId !== ownerUserId) { throw new NotFoundException('Device not found'); } @@ -67,4 +71,9 @@ export class DevicesService { serverTime: new Date().toISOString(), }; } + + private async resolveCurrentOwnerUserId(): Promise { + const owner = await this.ownerContext.resolve(); + return owner.userId; + } } diff --git a/backend/src/modules/library/library.service.spec.ts b/backend/src/modules/library/library.service.spec.ts index 76ed2e8..fe34add 100644 --- a/backend/src/modules/library/library.service.spec.ts +++ b/backend/src/modules/library/library.service.spec.ts @@ -2,7 +2,7 @@ import { randomUUID } from 'node:crypto'; import { NotFoundException } from '@nestjs/common'; import { Test } from '@nestjs/testing'; 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'; function createPrismaMock() { @@ -55,10 +55,16 @@ function createPrismaMock() { describe('LibraryService', () => { let libraryService: LibraryService; let state: ReturnType['state']; + let ownerContextMock: { + resolve: jest.Mock; + }; beforeEach(async () => { const { prismaMock, state: nextState } = createPrismaMock(); state = nextState; + ownerContextMock = { + resolve: jest.fn(), + }; const moduleRef = await Test.createTestingModule({ providers: [ @@ -68,8 +74,8 @@ describe('LibraryService', () => { useValue: prismaMock, }, { - provide: DefaultUserService, - useValue: {}, + provide: OwnerContext, + useValue: ownerContextMock, }, ], }).compile(); @@ -77,6 +83,58 @@ describe('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 () => { const ownerId = randomUUID(); const otherUserId = randomUUID(); @@ -89,6 +147,9 @@ describe('LibraryService', () => { const otherTrackId = randomUUID(); const otherAssetId = randomUUID(); + ownerContextMock.resolve.mockResolvedValue({ + userId: ownerId, + }); state.devices.set(ownerDeviceId, { id: ownerDeviceId, userId: ownerId }); state.audioAssets.set(ownerAssetId, { @@ -186,6 +247,9 @@ describe('LibraryService', () => { it('returns an empty library when the user has no remote tracks', async () => { const ownerId = randomUUID(); const ownerDeviceId = randomUUID(); + ownerContextMock.resolve.mockResolvedValue({ + userId: ownerId, + }); state.devices.set(ownerDeviceId, { id: ownerDeviceId, userId: ownerId }); await expect( @@ -193,15 +257,72 @@ describe('LibraryService', () => { ).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 () => { + ownerContextMock.resolve.mockResolvedValue({ + userId: randomUUID(), + }); await expect( libraryService.getRemoteLibraryTracks(randomUUID()), ).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 () => { const ownerId = randomUUID(); const ownerDeviceId = randomUUID(); + ownerContextMock.resolve.mockResolvedValue({ + userId: ownerId, + }); state.devices.set(ownerDeviceId, { id: ownerDeviceId, userId: ownerId }); state.tracks.set(randomUUID(), { diff --git a/backend/src/modules/library/library.service.ts b/backend/src/modules/library/library.service.ts index 841d405..bf5f1f7 100644 --- a/backend/src/modules/library/library.service.ts +++ b/backend/src/modules/library/library.service.ts @@ -1,21 +1,21 @@ import { Injectable, NotFoundException } from '@nestjs/common'; import { PrismaService } from '../../infrastructure/database/prisma.service'; 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'; @Injectable() export class LibraryService { constructor( private readonly prismaService: PrismaService, - private readonly defaultUserService: DefaultUserService, + private readonly ownerContext: OwnerContext, ) {} async getBootstrapTracks(): Promise { - const defaultUser = await this.defaultUserService.getOrCreateDefaultUser(); + const ownerUserId = await this.resolveCurrentOwnerUserId(); const tracks = await this.prismaService.track.findMany({ where: { - userId: defaultUser.id, + userId: ownerUserId, status: 'ACTIVE', }, orderBy: { @@ -38,6 +38,7 @@ export class LibraryService { async getRemoteLibraryTracks( deviceId: string, ): Promise { + const ownerUserId = await this.resolveCurrentOwnerUserId(); const device = await this.prismaService.device.findUnique({ where: { id: deviceId }, select: { @@ -45,13 +46,13 @@ export class LibraryService { }, }); - if (!device) { + if (!device || device.userId !== ownerUserId) { throw new NotFoundException('Device not found'); } const tracks = await this.prismaService.track.findMany({ where: { - userId: device.userId, + userId: ownerUserId, status: 'ACTIVE', primaryAudioAssetId: { not: null, @@ -117,4 +118,9 @@ export class LibraryService { ]; }); } + + private async resolveCurrentOwnerUserId(): Promise { + const owner = await this.ownerContext.resolve(); + return owner.userId; + } } diff --git a/backend/src/modules/sync/sync.service.spec.ts b/backend/src/modules/sync/sync.service.spec.ts new file mode 100644 index 0000000..ac4517c --- /dev/null +++ b/backend/src/modules/sync/sync.service.spec.ts @@ -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', + }, + }); + }); +}); diff --git a/backend/src/modules/sync/sync.service.ts b/backend/src/modules/sync/sync.service.ts index cb96dbb..1951620 100644 --- a/backend/src/modules/sync/sync.service.ts +++ b/backend/src/modules/sync/sync.service.ts @@ -1,7 +1,7 @@ import { Injectable } from '@nestjs/common'; import { PrismaService } from '../../infrastructure/database/prisma.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'; @Injectable() @@ -9,7 +9,7 @@ export class SyncService { constructor( private readonly prismaService: PrismaService, private readonly libraryService: LibraryService, - private readonly defaultUserService: DefaultUserService, + private readonly ownerContext: OwnerContext, ) {} async bootstrap(): Promise { @@ -39,10 +39,10 @@ export class SyncService { } private async getLatestCursor(): Promise { - const defaultUser = await this.defaultUserService.getOrCreateDefaultUser(); + const owner = await this.ownerContext.resolve(); const latest = await this.prismaService.libraryEvent.findFirst({ where: { - userId: defaultUser.id, + userId: owner.userId, }, orderBy: { id: 'desc', diff --git a/backend/src/modules/uploads/uploads.service.spec.ts b/backend/src/modules/uploads/uploads.service.spec.ts index 0558921..e4aed8e 100644 --- a/backend/src/modules/uploads/uploads.service.spec.ts +++ b/backend/src/modules/uploads/uploads.service.spec.ts @@ -3,10 +3,11 @@ import { readFile, mkdtemp, rm } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { Readable } from 'node:stream'; -import { UnprocessableEntityException } from '@nestjs/common'; +import { NotFoundException, UnprocessableEntityException } from '@nestjs/common'; import { UploadSessionStatus } from '@prisma/client'; import { AppConfigService } from '../config/config.service'; import { LocalFilesystemStorageService } from '../storage/storage.service'; +import { OwnerContext } from '../users/owner-context.service'; import { UploadsService } from './uploads.service'; type MockState = ReturnType['state']; @@ -282,6 +283,7 @@ describe('UploadsService', () => { let storageRoot: string; let storageService: LocalFilesystemStorageService; let service: UploadsService; + let ownerContext: OwnerContext; beforeEach(async () => { const mock = createPrismaMock(); @@ -289,10 +291,16 @@ describe('UploadsService', () => { state = mock.state; storageRoot = await mkdtemp(join(tmpdir(), 'velody-upload-spec-')); storageService = new LocalFilesystemStorageService(createAppConfig(storageRoot)); + ownerContext = { + resolve: jest.fn().mockResolvedValue({ + userId: state.defaultUser.id, + }), + } as OwnerContext; service = new UploadsService( prismaMock, createAppConfig(storageRoot), storageService, + ownerContext, ); }); @@ -334,6 +342,36 @@ describe('UploadsService', () => { 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 () => { const device = seedDevice(); const uploadedBytes = sampleMp3Bytes('wrong-sha-upload'); @@ -401,6 +439,14 @@ describe('UploadsService', () => { expect(state.audioAssets.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!); expect(session.finalizedAt).toBeInstanceOf(Date); expect(session.trackId).toBe(finalizeResponse.trackId); @@ -550,4 +596,52 @@ describe('UploadsService', () => { ); 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); + }); }); diff --git a/backend/src/modules/uploads/uploads.service.ts b/backend/src/modules/uploads/uploads.service.ts index 168d136..e7a128e 100644 --- a/backend/src/modules/uploads/uploads.service.ts +++ b/backend/src/modules/uploads/uploads.service.ts @@ -1,4 +1,5 @@ import { + ForbiddenException, Injectable, NotFoundException, UnprocessableEntityException, @@ -17,6 +18,7 @@ import { extname } from 'node:path'; import { PrismaService } from '../../infrastructure/database/prisma.service'; import { AppConfigService } from '../config/config.service'; import { LocalFilesystemStorageService } from '../storage/storage.service'; +import { OwnerContext } from '../users/owner-context.service'; import { UploadFinalizeArtworkDto, UploadFinalizeRequestDto, @@ -41,6 +43,7 @@ export class UploadsService { private readonly prismaService: PrismaService, private readonly configService: AppConfigService, private readonly storageService: LocalFilesystemStorageService, + private readonly ownerContext: OwnerContext, ) {} async prepare( @@ -49,18 +52,13 @@ export class UploadsService { this.assertFileSizeWithinLimit(body.sizeBytes); this.assertMp3Filename(body.originalFilename); - const device = await this.prismaService.device.findUnique({ - where: { id: body.deviceId }, - }); - - if (!device) { - throw new NotFoundException('Device not found'); - } + const ownerUserId = await this.resolveCurrentOwnerUserId(); + await this.getOwnedDeviceOrThrow(body.deviceId, ownerUserId); const existingAsset = await this.prismaService.audioAsset.findUnique({ where: { userId_sha256: { - userId: device.userId, + userId: ownerUserId, sha256: body.sha256, }, }, @@ -71,7 +69,7 @@ export class UploadsService { const uploadSession = await this.prismaService.uploadSession.create({ data: { id: uploadId, - userId: device.userId, + userId: ownerUserId, deviceId: body.deviceId, trackId: existingAsset.trackId, audioAssetId: existingAsset.id, @@ -98,7 +96,7 @@ export class UploadsService { const uploadSession = await this.prismaService.uploadSession.create({ data: { id: uploadId, - userId: device.userId, + userId: ownerUserId, deviceId: body.deviceId, expectedSha256: body.sha256, originalFilename: body.originalFilename, @@ -118,14 +116,21 @@ export class UploadsService { } async getStatus(uploadId: string): Promise { - return this.toStatusResponse(await this.getUploadSessionOrThrow(uploadId)); + const ownerUserId = await this.resolveCurrentOwnerUserId(); + return this.toStatusResponse( + await this.getOwnedUploadSessionOrThrow(uploadId, ownerUserId), + ); } async uploadFile( uploadId: string, request: Request, ): Promise { - const uploadSession = await this.getUploadSessionOrThrow(uploadId); + const ownerUserId = await this.resolveCurrentOwnerUserId(); + const uploadSession = await this.getOwnedUploadSessionOrThrow( + uploadId, + ownerUserId, + ); if (uploadSession.status === UploadSessionStatus.COMPLETED) { return this.toStatusResponse(uploadSession); @@ -148,7 +153,7 @@ export class UploadsService { const tempPath = this.storageService.resolve(uploadSession.tempStoragePath); const finalPath = this.storageService.userAudioAssetPath( - uploadSession.userId, + ownerUserId, uploadSession.expectedSha256, ); @@ -239,13 +244,28 @@ export class UploadsService { uploadId: string, body: UploadFinalizeRequestDto, ): Promise { - const uploadSession = await this.getUploadSessionOrThrow(uploadId); + const ownerUserId = await this.resolveCurrentOwnerUserId(); + const uploadSession = await this.getOwnedUploadSessionOrThrow( + uploadId, + ownerUserId, + ); if ( uploadSession.finalizedAt && uploadSession.trackId && uploadSession.audioAssetId ) { + await this.assertOwnedTrackOrThrow( + this.prismaService, + uploadSession.trackId, + ownerUserId, + ); + await this.assertOwnedAudioAssetOrThrow( + this.prismaService, + uploadSession.audioAssetId, + ownerUserId, + ); + return { trackId: uploadSession.trackId, assetId: uploadSession.audioAssetId, @@ -281,19 +301,28 @@ export class UploadsService { } return this.prismaService.$transaction(async (tx) => { - const currentSession = await tx.uploadSession.findUnique({ - where: { id: uploadId }, - }); - - if (!currentSession) { - throw new NotFoundException('Upload session not found'); - } + const currentSession = await this.getOwnedUploadSessionOrThrow( + uploadId, + ownerUserId, + tx, + ); if ( currentSession.finalizedAt && currentSession.trackId && currentSession.audioAssetId ) { + await this.assertOwnedTrackOrThrow( + tx, + currentSession.trackId, + ownerUserId, + ); + await this.assertOwnedAudioAssetOrThrow( + tx, + currentSession.audioAssetId, + ownerUserId, + ); + return { trackId: currentSession.trackId, assetId: currentSession.audioAssetId, @@ -308,7 +337,7 @@ export class UploadsService { const preparedArtwork = body.artwork ? await this.prepareArtworkAssetInput( - currentSession.userId, + ownerUserId, body.artwork, ) : null; @@ -316,7 +345,7 @@ export class UploadsService { let audioAsset = await tx.audioAsset.findUnique({ where: { userId_sha256: { - userId: currentSession.userId, + userId: ownerUserId, sha256: currentSession.expectedSha256, }, }, @@ -324,20 +353,22 @@ export class UploadsService { let track = currentSession.trackId != null - ? await tx.track.findUnique({ where: { id: currentSession.trackId } }) + ? await this.findOwnedTrackById(tx, currentSession.trackId, ownerUserId) : null; + if (currentSession.trackId != null && !track) { + throw new NotFoundException('Track not found'); + } + if (!track && audioAsset?.trackId) { - track = await tx.track.findUnique({ - where: { id: audioAsset.trackId }, - }); + track = await this.findOwnedTrackById(tx, audioAsset.trackId, ownerUserId); } const createdTrack = !track; if (!track) { track = await tx.track.create({ data: { - userId: currentSession.userId, + userId: ownerUserId, title, artist, album, @@ -375,7 +406,7 @@ export class UploadsService { } else { audioAsset = await tx.audioAsset.create({ data: { - userId: currentSession.userId, + userId: ownerUserId, trackId: track.id, sha256: currentSession.expectedSha256, storageKey: finalStorageKey, @@ -402,7 +433,7 @@ export class UploadsService { ? ( await this.findOrCreateArtworkAsset( tx, - currentSession.userId, + ownerUserId, preparedArtwork, ) ).id @@ -419,7 +450,7 @@ export class UploadsService { await tx.libraryEvent.create({ data: { - userId: currentSession.userId, + userId: ownerUserId, entityType: EntityType.TRACK, entityId: track.id, action: createdTrack ? EventAction.CREATED : EventAction.UPDATED, @@ -443,18 +474,85 @@ export class UploadsService { }); } - private async getUploadSessionOrThrow(uploadId: string): Promise { - const uploadSession = await this.prismaService.uploadSession.findUnique({ + private async resolveCurrentOwnerUserId(): Promise { + 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 = this.prismaService, + ): Promise { + const uploadSession = await client.uploadSession.findUnique({ where: { id: uploadId }, }); - if (!uploadSession) { + if (!uploadSession || uploadSession.userId !== ownerUserId) { throw new NotFoundException('Upload session not found'); } return uploadSession; } + private async findOwnedTrackById( + client: Pick, + 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, + trackId: string, + ownerUserId: string, + ): Promise { + const track = await this.findOwnedTrackById(client, trackId, ownerUserId); + + if (!track) { + throw new NotFoundException('Track not found'); + } + } + + private async assertOwnedAudioAssetOrThrow( + client: Pick, + assetId: string, + ownerUserId: string, + ): Promise { + 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( uploadSession: Pick< UploadSession, diff --git a/backend/src/modules/users/default-user.service.spec.ts b/backend/src/modules/users/default-user.service.spec.ts new file mode 100644 index 0000000..d6f7015 --- /dev/null +++ b/backend/src/modules/users/default-user.service.spec.ts @@ -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); + }); +}); diff --git a/backend/src/modules/users/default-user.service.ts b/backend/src/modules/users/default-user.service.ts index 3ab0ed6..0872bed 100644 --- a/backend/src/modules/users/default-user.service.ts +++ b/backend/src/modules/users/default-user.service.ts @@ -1,14 +1,18 @@ -import { Injectable } from '@nestjs/common'; +import { Injectable, OnApplicationBootstrap } from '@nestjs/common'; import { User } from '@prisma/client'; import { PrismaService } from '../../infrastructure/database/prisma.service'; @Injectable() -export class DefaultUserService { +export class DefaultUserService implements OnApplicationBootstrap { static readonly defaultOwnerSlug = 'default-owner'; static readonly defaultOwnerDisplayName = 'Default Owner'; constructor(private readonly prismaService: PrismaService) {} + async onApplicationBootstrap(): Promise { + await this.getOrCreateDefaultUser(); + } + async getOrCreateDefaultUser(): Promise { return this.prismaService.user.upsert({ where: { diff --git a/backend/src/modules/users/owner-context.service.spec.ts b/backend/src/modules/users/owner-context.service.spec.ts new file mode 100644 index 0000000..3322218 --- /dev/null +++ b/backend/src/modules/users/owner-context.service.spec.ts @@ -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); + }); +}); diff --git a/backend/src/modules/users/owner-context.service.ts b/backend/src/modules/users/owner-context.service.ts new file mode 100644 index 0000000..ff0e47e --- /dev/null +++ b/backend/src/modules/users/owner-context.service.ts @@ -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; +} + +@Injectable() +export class BootstrapOwnerContextService extends OwnerContext { + constructor(private readonly defaultUserService: DefaultUserService) { + super(); + } + + async resolve(): Promise { + const defaultUser = await this.defaultUserService.getOrCreateDefaultUser(); + + return { + userId: defaultUser.id, + }; + } +} diff --git a/backend/src/modules/users/users.module.ts b/backend/src/modules/users/users.module.ts index f41675f..f282e82 100644 --- a/backend/src/modules/users/users.module.ts +++ b/backend/src/modules/users/users.module.ts @@ -1,10 +1,21 @@ import { Module } from '@nestjs/common'; import { PrismaModule } from '../../infrastructure/database/prisma.module'; import { DefaultUserService } from './default-user.service'; +import { + BootstrapOwnerContextService, + OwnerContext, +} from './owner-context.service'; @Module({ imports: [PrismaModule], - providers: [DefaultUserService], - exports: [DefaultUserService], + providers: [ + DefaultUserService, + BootstrapOwnerContextService, + { + provide: OwnerContext, + useExisting: BootstrapOwnerContextService, + }, + ], + exports: [DefaultUserService, OwnerContext], }) export class UsersModule {} diff --git a/backend/test/e2e/app.e2e-spec.ts b/backend/test/e2e/app.e2e-spec.ts index 9b5386c..aef2d03 100644 --- a/backend/test/e2e/app.e2e-spec.ts +++ b/backend/test/e2e/app.e2e-spec.ts @@ -313,6 +313,7 @@ function createPrismaMock() { describe('Velody API wiring (e2e)', () => { let app: NestExpressApplication; + let prismaMock: ReturnType['prismaMock']; let assetsController: AssetsController; let artworkController: ArtworkController; let healthController: HealthController; @@ -325,7 +326,9 @@ describe('Velody API wiring (e2e)', () => { let storageRoot: string; beforeEach(async () => { - const { prismaMock, state } = createPrismaMock(); + const prismaSetup = createPrismaMock(); + prismaMock = prismaSetup.prismaMock; + const { state } = prismaSetup; prismaState = state; storageRoot = await mkdtemp(join(tmpdir(), 'velody-e2e-')); @@ -385,6 +388,23 @@ describe('Velody API wiring (e2e)', () => { 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 () => { const registerResponse = await devicesController.register({ platform: 'MACOS', @@ -394,6 +414,9 @@ describe('Velody API wiring (e2e)', () => { expect(registerResponse.deviceId).toBeDefined(); expect(registerResponse.bootstrapToken).toBeDefined(); + expect(prismaState.devices.get(registerResponse.deviceId)?.userId).toBe( + prismaState.defaultUser.id, + ); const heartbeatResponse = await devicesController.heartbeat({ deviceId: registerResponse.deviceId, @@ -403,6 +426,28 @@ describe('Velody API wiring (e2e)', () => { 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 () => { const bootstrapResponse = await syncController.bootstrap(); const changesResponse = await syncController.changes({ after: '0' }); @@ -412,6 +457,47 @@ describe('Velody API wiring (e2e)', () => { 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 () => { const registerResponse = await devicesController.register({ platform: 'IPHONE', @@ -610,6 +696,36 @@ describe('Velody API wiring (e2e)', () => { 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 () => { const registerResponse = await devicesController.register({ platform: 'IPHONE',