Add owner context and harden ownership boundaries
This commit is contained in:
@@ -313,6 +313,7 @@ function createPrismaMock() {
|
||||
|
||||
describe('Velody API wiring (e2e)', () => {
|
||||
let app: NestExpressApplication;
|
||||
let prismaMock: ReturnType<typeof createPrismaMock>['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',
|
||||
|
||||
Reference in New Issue
Block a user