Add multi-device identity foundation
This commit is contained in:
parent
45c270c187
commit
8902efb92e
@ -15,8 +15,9 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "deviceId",
|
"name": "deviceId",
|
||||||
"required": true,
|
"required": false,
|
||||||
"in": "query",
|
"in": "query",
|
||||||
|
"description": "Legacy migration fallback. Omit when Authorization: Bearer <deviceAccessToken> is provided.",
|
||||||
"schema": {
|
"schema": {
|
||||||
"format": "uuid",
|
"format": "uuid",
|
||||||
"type": "string"
|
"type": "string"
|
||||||
@ -36,11 +37,62 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"security": [
|
||||||
|
{
|
||||||
|
"bearer": []
|
||||||
|
}
|
||||||
|
],
|
||||||
"tags": [
|
"tags": [
|
||||||
"assets"
|
"assets"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"/api/v1/artwork/{artworkId}/download": {
|
||||||
|
"get": {
|
||||||
|
"operationId": "ArtworkController_download_v1",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"name": "artworkId",
|
||||||
|
"required": true,
|
||||||
|
"in": "path",
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "deviceId",
|
||||||
|
"required": false,
|
||||||
|
"in": "query",
|
||||||
|
"description": "Legacy migration fallback. Omit when Authorization: Bearer <deviceAccessToken> is provided.",
|
||||||
|
"schema": {
|
||||||
|
"format": "uuid",
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "",
|
||||||
|
"content": {
|
||||||
|
"image/*": {
|
||||||
|
"schema": {
|
||||||
|
"type": "string",
|
||||||
|
"format": "binary"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"security": [
|
||||||
|
{
|
||||||
|
"bearer": []
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"tags": [
|
||||||
|
"artwork"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
"/api/v1/health": {
|
"/api/v1/health": {
|
||||||
"get": {
|
"get": {
|
||||||
"operationId": "HealthController_getHealth_v1",
|
"operationId": "HealthController_getHealth_v1",
|
||||||
@ -88,6 +140,11 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"security": [
|
||||||
|
{
|
||||||
|
"bearer": []
|
||||||
|
}
|
||||||
|
],
|
||||||
"tags": [
|
"tags": [
|
||||||
"devices"
|
"devices"
|
||||||
]
|
]
|
||||||
@ -119,6 +176,11 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"security": [
|
||||||
|
{
|
||||||
|
"bearer": []
|
||||||
|
}
|
||||||
|
],
|
||||||
"tags": [
|
"tags": [
|
||||||
"devices"
|
"devices"
|
||||||
]
|
]
|
||||||
@ -150,6 +212,11 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"security": [
|
||||||
|
{
|
||||||
|
"bearer": []
|
||||||
|
}
|
||||||
|
],
|
||||||
"tags": [
|
"tags": [
|
||||||
"uploads"
|
"uploads"
|
||||||
]
|
]
|
||||||
@ -180,6 +247,11 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"security": [
|
||||||
|
{
|
||||||
|
"bearer": []
|
||||||
|
}
|
||||||
|
],
|
||||||
"tags": [
|
"tags": [
|
||||||
"uploads"
|
"uploads"
|
||||||
]
|
]
|
||||||
@ -233,6 +305,11 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"security": [
|
||||||
|
{
|
||||||
|
"bearer": []
|
||||||
|
}
|
||||||
|
],
|
||||||
"tags": [
|
"tags": [
|
||||||
"uploads"
|
"uploads"
|
||||||
]
|
]
|
||||||
@ -273,6 +350,11 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"security": [
|
||||||
|
{
|
||||||
|
"bearer": []
|
||||||
|
}
|
||||||
|
],
|
||||||
"tags": [
|
"tags": [
|
||||||
"uploads"
|
"uploads"
|
||||||
]
|
]
|
||||||
@ -284,8 +366,9 @@
|
|||||||
"parameters": [
|
"parameters": [
|
||||||
{
|
{
|
||||||
"name": "deviceId",
|
"name": "deviceId",
|
||||||
"required": true,
|
"required": false,
|
||||||
"in": "query",
|
"in": "query",
|
||||||
|
"description": "Legacy migration fallback. Omit when Authorization: Bearer <deviceAccessToken> is provided.",
|
||||||
"schema": {
|
"schema": {
|
||||||
"format": "uuid",
|
"format": "uuid",
|
||||||
"type": "string"
|
"type": "string"
|
||||||
@ -304,6 +387,11 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"security": [
|
||||||
|
{
|
||||||
|
"bearer": []
|
||||||
|
}
|
||||||
|
],
|
||||||
"tags": [
|
"tags": [
|
||||||
"library"
|
"library"
|
||||||
]
|
]
|
||||||
@ -312,7 +400,18 @@
|
|||||||
"/api/v1/sync/bootstrap": {
|
"/api/v1/sync/bootstrap": {
|
||||||
"get": {
|
"get": {
|
||||||
"operationId": "SyncController_bootstrap_v1",
|
"operationId": "SyncController_bootstrap_v1",
|
||||||
"parameters": [],
|
"parameters": [
|
||||||
|
{
|
||||||
|
"name": "deviceId",
|
||||||
|
"required": false,
|
||||||
|
"in": "query",
|
||||||
|
"description": "Legacy migration fallback. Omit when Authorization: Bearer <deviceAccessToken> is provided.",
|
||||||
|
"schema": {
|
||||||
|
"format": "uuid",
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
"responses": {
|
"responses": {
|
||||||
"200": {
|
"200": {
|
||||||
"description": "",
|
"description": "",
|
||||||
@ -325,6 +424,11 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"security": [
|
||||||
|
{
|
||||||
|
"bearer": []
|
||||||
|
}
|
||||||
|
],
|
||||||
"tags": [
|
"tags": [
|
||||||
"sync"
|
"sync"
|
||||||
]
|
]
|
||||||
@ -334,6 +438,16 @@
|
|||||||
"get": {
|
"get": {
|
||||||
"operationId": "SyncController_changes_v1",
|
"operationId": "SyncController_changes_v1",
|
||||||
"parameters": [
|
"parameters": [
|
||||||
|
{
|
||||||
|
"name": "deviceId",
|
||||||
|
"required": false,
|
||||||
|
"in": "query",
|
||||||
|
"description": "Legacy migration fallback. Omit when Authorization: Bearer <deviceAccessToken> is provided.",
|
||||||
|
"schema": {
|
||||||
|
"format": "uuid",
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "after",
|
"name": "after",
|
||||||
"required": false,
|
"required": false,
|
||||||
@ -356,6 +470,11 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"security": [
|
||||||
|
{
|
||||||
|
"bearer": []
|
||||||
|
}
|
||||||
|
],
|
||||||
"tags": [
|
"tags": [
|
||||||
"sync"
|
"sync"
|
||||||
]
|
]
|
||||||
@ -464,6 +583,10 @@
|
|||||||
"type": "string",
|
"type": "string",
|
||||||
"format": "uuid"
|
"format": "uuid"
|
||||||
},
|
},
|
||||||
|
"deviceAccessToken": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Raw device access token returned only during registration. Store it securely."
|
||||||
|
},
|
||||||
"bootstrapToken": {
|
"bootstrapToken": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
@ -474,6 +597,7 @@
|
|||||||
},
|
},
|
||||||
"required": [
|
"required": [
|
||||||
"deviceId",
|
"deviceId",
|
||||||
|
"deviceAccessToken",
|
||||||
"bootstrapToken",
|
"bootstrapToken",
|
||||||
"serverTime"
|
"serverTime"
|
||||||
]
|
]
|
||||||
@ -483,7 +607,8 @@
|
|||||||
"properties": {
|
"properties": {
|
||||||
"deviceId": {
|
"deviceId": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"format": "uuid"
|
"format": "uuid",
|
||||||
|
"description": "Legacy migration fallback. Omit when Authorization: Bearer <deviceAccessToken> is provided."
|
||||||
},
|
},
|
||||||
"appVersion": {
|
"appVersion": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
@ -491,7 +616,6 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"required": [
|
"required": [
|
||||||
"deviceId",
|
|
||||||
"appVersion"
|
"appVersion"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
@ -517,7 +641,8 @@
|
|||||||
"properties": {
|
"properties": {
|
||||||
"deviceId": {
|
"deviceId": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"format": "uuid"
|
"format": "uuid",
|
||||||
|
"description": "Legacy migration fallback. Omit when Authorization: Bearer <deviceAccessToken> is provided."
|
||||||
},
|
},
|
||||||
"sha256": {
|
"sha256": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
@ -533,7 +658,6 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"required": [
|
"required": [
|
||||||
"deviceId",
|
|
||||||
"sha256",
|
"sha256",
|
||||||
"originalFilename",
|
"originalFilename",
|
||||||
"sizeBytes"
|
"sizeBytes"
|
||||||
@ -611,6 +735,39 @@
|
|||||||
"nextOffset"
|
"nextOffset"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
"UploadFinalizeArtworkDto": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"dataBase64": {
|
||||||
|
"type": "string",
|
||||||
|
"example": "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJ..."
|
||||||
|
},
|
||||||
|
"sha256": {
|
||||||
|
"type": "string",
|
||||||
|
"example": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"
|
||||||
|
},
|
||||||
|
"mimeType": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": [
|
||||||
|
"image/jpeg",
|
||||||
|
"image/png"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"width": {
|
||||||
|
"type": "number",
|
||||||
|
"example": 512
|
||||||
|
},
|
||||||
|
"height": {
|
||||||
|
"type": "number",
|
||||||
|
"example": 512
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"dataBase64",
|
||||||
|
"sha256",
|
||||||
|
"mimeType"
|
||||||
|
]
|
||||||
|
},
|
||||||
"UploadFinalizeRequestDto": {
|
"UploadFinalizeRequestDto": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
@ -629,6 +786,9 @@
|
|||||||
"durationMs": {
|
"durationMs": {
|
||||||
"type": "number",
|
"type": "number",
|
||||||
"example": 245000
|
"example": 245000
|
||||||
|
},
|
||||||
|
"artwork": {
|
||||||
|
"$ref": "#/components/schemas/UploadFinalizeArtworkDto"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"required": [
|
"required": [
|
||||||
@ -653,6 +813,38 @@
|
|||||||
"assetId"
|
"assetId"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
"RemoteArtworkDto": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"artworkId": {
|
||||||
|
"type": "string",
|
||||||
|
"format": "uuid"
|
||||||
|
},
|
||||||
|
"sha256": {
|
||||||
|
"type": "string",
|
||||||
|
"example": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"
|
||||||
|
},
|
||||||
|
"mimeType": {
|
||||||
|
"type": "string",
|
||||||
|
"example": "image/jpeg"
|
||||||
|
},
|
||||||
|
"width": {
|
||||||
|
"type": "object",
|
||||||
|
"example": 512,
|
||||||
|
"nullable": true
|
||||||
|
},
|
||||||
|
"height": {
|
||||||
|
"type": "object",
|
||||||
|
"example": 512,
|
||||||
|
"nullable": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"artworkId",
|
||||||
|
"sha256",
|
||||||
|
"mimeType"
|
||||||
|
]
|
||||||
|
},
|
||||||
"RemoteLibraryTrackDto": {
|
"RemoteLibraryTrackDto": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
@ -687,6 +879,15 @@
|
|||||||
"updatedAt": {
|
"updatedAt": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"example": "2026-05-29T08:05:00.000Z"
|
"example": "2026-05-29T08:05:00.000Z"
|
||||||
|
},
|
||||||
|
"artwork": {
|
||||||
|
"nullable": true,
|
||||||
|
"type": "object",
|
||||||
|
"allOf": [
|
||||||
|
{
|
||||||
|
"$ref": "#/components/schemas/RemoteArtworkDto"
|
||||||
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"required": [
|
"required": [
|
||||||
|
|||||||
@ -6,7 +6,44 @@ import { RequestContextService } from './request-context.service';
|
|||||||
export class RequestContextMiddleware implements NestMiddleware {
|
export class RequestContextMiddleware implements NestMiddleware {
|
||||||
constructor(private readonly requestContext: RequestContextService) {}
|
constructor(private readonly requestContext: RequestContextService) {}
|
||||||
|
|
||||||
use(_request: Request, _response: Response, next: NextFunction): void {
|
use(request: Request, _response: Response, next: NextFunction): void {
|
||||||
this.requestContext.run(() => next());
|
this.requestContext.run(() => {
|
||||||
|
const legacyDeviceId = this.extractLegacyDeviceId(request);
|
||||||
|
|
||||||
|
if (legacyDeviceId) {
|
||||||
|
this.requestContext.setLegacyDeviceId(legacyDeviceId);
|
||||||
|
}
|
||||||
|
|
||||||
|
next();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private extractLegacyDeviceId(request: Request): string | null {
|
||||||
|
return (
|
||||||
|
this.readDeviceIdCandidate(request.body) ??
|
||||||
|
this.readDeviceIdCandidate(request.query)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private readDeviceIdCandidate(source: unknown): string | null {
|
||||||
|
if (!source || typeof source !== 'object') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rawDeviceId = (source as { deviceId?: unknown }).deviceId;
|
||||||
|
|
||||||
|
if (typeof rawDeviceId === 'string' && rawDeviceId.trim()) {
|
||||||
|
return rawDeviceId.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!Array.isArray(rawDeviceId)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const firstString = rawDeviceId.find(
|
||||||
|
(value): value is string => typeof value === 'string' && value.trim().length > 0,
|
||||||
|
);
|
||||||
|
|
||||||
|
return firstString?.trim() ?? null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -8,6 +8,7 @@ export interface AuthenticatedDeviceContextValue {
|
|||||||
|
|
||||||
interface RequestContextState {
|
interface RequestContextState {
|
||||||
authenticatedDevice: AuthenticatedDeviceContextValue | null;
|
authenticatedDevice: AuthenticatedDeviceContextValue | null;
|
||||||
|
legacyDeviceId: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
@ -18,6 +19,7 @@ export class RequestContextService {
|
|||||||
return this.storage.run(
|
return this.storage.run(
|
||||||
{
|
{
|
||||||
authenticatedDevice: null,
|
authenticatedDevice: null,
|
||||||
|
legacyDeviceId: null,
|
||||||
},
|
},
|
||||||
callback,
|
callback,
|
||||||
);
|
);
|
||||||
@ -36,4 +38,18 @@ export class RequestContextService {
|
|||||||
|
|
||||||
store.authenticatedDevice = device;
|
store.authenticatedDevice = device;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getLegacyDeviceId(): string | null {
|
||||||
|
return this.storage.getStore()?.legacyDeviceId ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
setLegacyDeviceId(deviceId: string): void {
|
||||||
|
const store = this.storage.getStore();
|
||||||
|
|
||||||
|
if (!store) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
store.legacyDeviceId = deviceId;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2,12 +2,11 @@ import { MiddlewareConsumer, Module, NestModule } from '@nestjs/common';
|
|||||||
import { PrismaModule } from '../../infrastructure/database/prisma.module';
|
import { PrismaModule } from '../../infrastructure/database/prisma.module';
|
||||||
import { RequestContextMiddleware } from '../../infrastructure/request-context/request-context.middleware';
|
import { RequestContextMiddleware } from '../../infrastructure/request-context/request-context.middleware';
|
||||||
import { RequestContextModule } from '../../infrastructure/request-context/request-context.module';
|
import { RequestContextModule } from '../../infrastructure/request-context/request-context.module';
|
||||||
import { UsersModule } from '../users/users.module';
|
|
||||||
import { DeviceAuthGuard } from './device-auth.guard';
|
import { DeviceAuthGuard } from './device-auth.guard';
|
||||||
import { DeviceAuthService } from './device-auth.service';
|
import { DeviceAuthService } from './device-auth.service';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [PrismaModule, RequestContextModule, UsersModule],
|
imports: [PrismaModule, RequestContextModule],
|
||||||
providers: [DeviceAuthService, DeviceAuthGuard],
|
providers: [DeviceAuthService, DeviceAuthGuard],
|
||||||
exports: [DeviceAuthService, DeviceAuthGuard],
|
exports: [DeviceAuthService, DeviceAuthGuard],
|
||||||
})
|
})
|
||||||
|
|||||||
@ -5,7 +5,6 @@ import {
|
|||||||
UnauthorizedException,
|
UnauthorizedException,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { RequestContextService } from '../../infrastructure/request-context/request-context.service';
|
import { RequestContextService } from '../../infrastructure/request-context/request-context.service';
|
||||||
import { OwnerContext } from '../users/owner-context.service';
|
|
||||||
import { DeviceAuthService } from './device-auth.service';
|
import { DeviceAuthService } from './device-auth.service';
|
||||||
|
|
||||||
describe('DeviceAuthService', () => {
|
describe('DeviceAuthService', () => {
|
||||||
@ -13,9 +12,6 @@ describe('DeviceAuthService', () => {
|
|||||||
const userId = randomUUID();
|
const userId = randomUUID();
|
||||||
const deviceId = randomUUID();
|
const deviceId = randomUUID();
|
||||||
const requestContext = new RequestContextService();
|
const requestContext = new RequestContextService();
|
||||||
const ownerContext = {
|
|
||||||
resolve: jest.fn().mockResolvedValue({ userId }),
|
|
||||||
} as OwnerContext;
|
|
||||||
const service = new DeviceAuthService(
|
const service = new DeviceAuthService(
|
||||||
{
|
{
|
||||||
device: {
|
device: {
|
||||||
@ -28,7 +24,6 @@ describe('DeviceAuthService', () => {
|
|||||||
},
|
},
|
||||||
} as any,
|
} as any,
|
||||||
requestContext,
|
requestContext,
|
||||||
ownerContext,
|
|
||||||
);
|
);
|
||||||
const token = service.generateDeviceAccessToken();
|
const token = service.generateDeviceAccessToken();
|
||||||
|
|
||||||
@ -63,9 +58,6 @@ describe('DeviceAuthService', () => {
|
|||||||
},
|
},
|
||||||
} as any,
|
} as any,
|
||||||
requestContext,
|
requestContext,
|
||||||
{
|
|
||||||
resolve: jest.fn(),
|
|
||||||
} as any,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
@ -83,11 +75,8 @@ describe('DeviceAuthService', () => {
|
|||||||
it('falls back to legacy device ids only when no authenticated device is present', async () => {
|
it('falls back to legacy device ids only when no authenticated device is present', async () => {
|
||||||
const userId = randomUUID();
|
const userId = randomUUID();
|
||||||
const deviceId = randomUUID();
|
const deviceId = randomUUID();
|
||||||
const otherDeviceId = randomUUID();
|
const missingDeviceId = randomUUID();
|
||||||
const requestContext = new RequestContextService();
|
const requestContext = new RequestContextService();
|
||||||
const ownerContext = {
|
|
||||||
resolve: jest.fn().mockResolvedValue({ userId }),
|
|
||||||
} as any;
|
|
||||||
const service = new DeviceAuthService(
|
const service = new DeviceAuthService(
|
||||||
{
|
{
|
||||||
device: {
|
device: {
|
||||||
@ -99,27 +88,19 @@ describe('DeviceAuthService', () => {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (where.id === otherDeviceId) {
|
|
||||||
return {
|
|
||||||
id: otherDeviceId,
|
|
||||||
userId: randomUUID(),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}),
|
}),
|
||||||
update: jest.fn(),
|
update: jest.fn(),
|
||||||
},
|
},
|
||||||
} as any,
|
} as any,
|
||||||
requestContext,
|
requestContext,
|
||||||
ownerContext as OwnerContext,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
requestContext.run(() => service.resolveCurrentDevice()),
|
requestContext.run(() => service.resolveCurrentDevice()),
|
||||||
).rejects.toBeInstanceOf(BadRequestException);
|
).rejects.toBeInstanceOf(BadRequestException);
|
||||||
await expect(
|
await expect(
|
||||||
requestContext.run(() => service.resolveCurrentDevice(otherDeviceId)),
|
requestContext.run(() => service.resolveCurrentDevice(missingDeviceId)),
|
||||||
).rejects.toBeInstanceOf(NotFoundException);
|
).rejects.toBeInstanceOf(NotFoundException);
|
||||||
await expect(
|
await expect(
|
||||||
requestContext.run(() => service.resolveCurrentDevice(deviceId)),
|
requestContext.run(() => service.resolveCurrentDevice(deviceId)),
|
||||||
@ -128,4 +109,32 @@ describe('DeviceAuthService', () => {
|
|||||||
userId,
|
userId,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('uses the request-context legacy device id when the caller does not pass one explicitly', async () => {
|
||||||
|
const userId = randomUUID();
|
||||||
|
const deviceId = randomUUID();
|
||||||
|
const requestContext = new RequestContextService();
|
||||||
|
const service = new DeviceAuthService(
|
||||||
|
{
|
||||||
|
device: {
|
||||||
|
findUnique: jest.fn().mockResolvedValue({
|
||||||
|
id: deviceId,
|
||||||
|
userId,
|
||||||
|
}),
|
||||||
|
update: jest.fn(),
|
||||||
|
},
|
||||||
|
} as any,
|
||||||
|
requestContext,
|
||||||
|
);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
requestContext.run(async () => {
|
||||||
|
requestContext.setLegacyDeviceId(deviceId);
|
||||||
|
return service.resolveCurrentDevice();
|
||||||
|
}),
|
||||||
|
).resolves.toEqual({
|
||||||
|
deviceId,
|
||||||
|
userId,
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -10,14 +10,15 @@ import {
|
|||||||
AuthenticatedDeviceContextValue,
|
AuthenticatedDeviceContextValue,
|
||||||
RequestContextService,
|
RequestContextService,
|
||||||
} from '../../infrastructure/request-context/request-context.service';
|
} from '../../infrastructure/request-context/request-context.service';
|
||||||
import { OwnerContext } from '../users/owner-context.service';
|
|
||||||
|
const UUID_PATTERN =
|
||||||
|
/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class DeviceAuthService {
|
export class DeviceAuthService {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly prismaService: PrismaService,
|
private readonly prismaService: PrismaService,
|
||||||
private readonly requestContext: RequestContextService,
|
private readonly requestContext: RequestContextService,
|
||||||
private readonly ownerContext: OwnerContext,
|
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
generateDeviceAccessToken(): string {
|
generateDeviceAccessToken(): string {
|
||||||
@ -76,16 +77,22 @@ export class DeviceAuthService {
|
|||||||
return authenticatedDevice;
|
return authenticatedDevice;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!legacyDeviceId) {
|
const requestedDeviceId =
|
||||||
|
legacyDeviceId ?? this.requestContext.getLegacyDeviceId();
|
||||||
|
|
||||||
|
if (!requestedDeviceId) {
|
||||||
throw new BadRequestException(
|
throw new BadRequestException(
|
||||||
'deviceId is required when Authorization is missing.',
|
'deviceId is required when Authorization is missing.',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const owner = await this.ownerContext.resolve();
|
if (!UUID_PATTERN.test(requestedDeviceId)) {
|
||||||
|
throw new BadRequestException('deviceId must be a UUID');
|
||||||
|
}
|
||||||
|
|
||||||
const device = await this.prismaService.device.findUnique({
|
const device = await this.prismaService.device.findUnique({
|
||||||
where: {
|
where: {
|
||||||
id: legacyDeviceId,
|
id: requestedDeviceId,
|
||||||
},
|
},
|
||||||
select: {
|
select: {
|
||||||
id: true,
|
id: true,
|
||||||
@ -93,7 +100,7 @@ export class DeviceAuthService {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!device || device.userId !== owner.userId) {
|
if (!device) {
|
||||||
throw new NotFoundException('Device not found');
|
throw new NotFoundException('Device not found');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -23,6 +23,8 @@ export class DevicesController {
|
|||||||
constructor(private readonly devicesService: DevicesService) {}
|
constructor(private readonly devicesService: DevicesService) {}
|
||||||
|
|
||||||
@Post('register')
|
@Post('register')
|
||||||
|
@UseGuards(DeviceAuthGuard)
|
||||||
|
@ApiBearerAuth()
|
||||||
@ApiCreatedResponse({ type: RegisterDeviceResponseDto })
|
@ApiCreatedResponse({ type: RegisterDeviceResponseDto })
|
||||||
async register(
|
async register(
|
||||||
@Body() body: RegisterDeviceRequestDto,
|
@Body() body: RegisterDeviceRequestDto,
|
||||||
|
|||||||
@ -2,6 +2,7 @@ import { Controller, Get, Query, UseGuards } from '@nestjs/common';
|
|||||||
import { ApiBearerAuth, ApiOkResponse, ApiTags } from '@nestjs/swagger';
|
import { ApiBearerAuth, ApiOkResponse, ApiTags } from '@nestjs/swagger';
|
||||||
import { DeviceAuthGuard } from '../auth/device-auth.guard';
|
import { DeviceAuthGuard } from '../auth/device-auth.guard';
|
||||||
import {
|
import {
|
||||||
|
SyncBootstrapQueryDto,
|
||||||
SyncBootstrapResponseDto,
|
SyncBootstrapResponseDto,
|
||||||
SyncChangesQueryDto,
|
SyncChangesQueryDto,
|
||||||
SyncChangesResponseDto,
|
SyncChangesResponseDto,
|
||||||
@ -20,7 +21,9 @@ export class SyncController {
|
|||||||
|
|
||||||
@Get('bootstrap')
|
@Get('bootstrap')
|
||||||
@ApiOkResponse({ type: SyncBootstrapResponseDto })
|
@ApiOkResponse({ type: SyncBootstrapResponseDto })
|
||||||
async bootstrap(): Promise<SyncBootstrapResponseDto> {
|
async bootstrap(
|
||||||
|
@Query() _query?: SyncBootstrapQueryDto,
|
||||||
|
): Promise<SyncBootstrapResponseDto> {
|
||||||
return this.syncService.bootstrap();
|
return this.syncService.bootstrap();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import { ApiProperty } from '@nestjs/swagger';
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
import { IsOptional, IsString, Matches } from 'class-validator';
|
import { IsOptional, IsString, IsUUID, Matches } from 'class-validator';
|
||||||
|
|
||||||
export class LibraryTrackDto {
|
export class LibraryTrackDto {
|
||||||
@ApiProperty({ format: 'uuid', required: false })
|
@ApiProperty({ format: 'uuid', required: false })
|
||||||
@ -43,7 +43,19 @@ export class SyncBootstrapResponseDto {
|
|||||||
serverTime!: string;
|
serverTime!: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class SyncChangesQueryDto {
|
export class SyncBootstrapQueryDto {
|
||||||
|
@ApiProperty({
|
||||||
|
format: 'uuid',
|
||||||
|
required: false,
|
||||||
|
description:
|
||||||
|
'Legacy migration fallback. Omit when Authorization: Bearer <deviceAccessToken> is provided.',
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
@IsUUID()
|
||||||
|
deviceId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class SyncChangesQueryDto extends SyncBootstrapQueryDto {
|
||||||
@ApiProperty({ required: false, example: '0' })
|
@ApiProperty({ required: false, example: '0' })
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
|
|||||||
@ -16,6 +16,11 @@ describe('BootstrapOwnerContextService', () => {
|
|||||||
const service = new BootstrapOwnerContextService(
|
const service = new BootstrapOwnerContextService(
|
||||||
defaultUserService,
|
defaultUserService,
|
||||||
new RequestContextService(),
|
new RequestContextService(),
|
||||||
|
{
|
||||||
|
device: {
|
||||||
|
findUnique: jest.fn(),
|
||||||
|
},
|
||||||
|
} as any,
|
||||||
);
|
);
|
||||||
|
|
||||||
await expect(service.resolve()).resolves.toEqual({
|
await expect(service.resolve()).resolves.toEqual({
|
||||||
@ -29,13 +34,23 @@ describe('BootstrapOwnerContextService', () => {
|
|||||||
const defaultUserService = {
|
const defaultUserService = {
|
||||||
getOrCreateDefaultUser: jest.fn(),
|
getOrCreateDefaultUser: jest.fn(),
|
||||||
} as any;
|
} as any;
|
||||||
|
const legacyOwnerId = randomUUID();
|
||||||
|
const prismaService = {
|
||||||
|
device: {
|
||||||
|
findUnique: jest.fn().mockResolvedValue({
|
||||||
|
userId: legacyOwnerId,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
} as any;
|
||||||
const service = new BootstrapOwnerContextService(
|
const service = new BootstrapOwnerContextService(
|
||||||
defaultUserService,
|
defaultUserService,
|
||||||
requestContext,
|
requestContext,
|
||||||
|
prismaService,
|
||||||
);
|
);
|
||||||
const authenticatedOwnerId = randomUUID();
|
const authenticatedOwnerId = randomUUID();
|
||||||
|
|
||||||
await requestContext.run(async () => {
|
await requestContext.run(async () => {
|
||||||
|
requestContext.setLegacyDeviceId(randomUUID());
|
||||||
requestContext.setAuthenticatedDevice({
|
requestContext.setAuthenticatedDevice({
|
||||||
deviceId: randomUUID(),
|
deviceId: randomUUID(),
|
||||||
userId: authenticatedOwnerId,
|
userId: authenticatedOwnerId,
|
||||||
@ -47,5 +62,45 @@ describe('BootstrapOwnerContextService', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
expect(defaultUserService.getOrCreateDefaultUser).not.toHaveBeenCalled();
|
expect(defaultUserService.getOrCreateDefaultUser).not.toHaveBeenCalled();
|
||||||
|
expect(prismaService.device.findUnique).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to the legacy device owner before the bootstrap default owner', async () => {
|
||||||
|
const requestContext = new RequestContextService();
|
||||||
|
const defaultUserService = {
|
||||||
|
getOrCreateDefaultUser: jest.fn(),
|
||||||
|
} as any;
|
||||||
|
const legacyDeviceId = randomUUID();
|
||||||
|
const legacyOwnerId = randomUUID();
|
||||||
|
const prismaService = {
|
||||||
|
device: {
|
||||||
|
findUnique: jest.fn().mockResolvedValue({
|
||||||
|
userId: legacyOwnerId,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
} as any;
|
||||||
|
const service = new BootstrapOwnerContextService(
|
||||||
|
defaultUserService,
|
||||||
|
requestContext,
|
||||||
|
prismaService,
|
||||||
|
);
|
||||||
|
|
||||||
|
await requestContext.run(async () => {
|
||||||
|
requestContext.setLegacyDeviceId(legacyDeviceId);
|
||||||
|
|
||||||
|
await expect(service.resolve()).resolves.toEqual({
|
||||||
|
userId: legacyOwnerId,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(prismaService.device.findUnique).toHaveBeenCalledWith({
|
||||||
|
where: {
|
||||||
|
id: legacyDeviceId,
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
userId: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(defaultUserService.getOrCreateDefaultUser).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -1,4 +1,9 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
import {
|
||||||
|
BadRequestException,
|
||||||
|
Injectable,
|
||||||
|
NotFoundException,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { PrismaService } from '../../infrastructure/database/prisma.service';
|
||||||
import { RequestContextService } from '../../infrastructure/request-context/request-context.service';
|
import { RequestContextService } from '../../infrastructure/request-context/request-context.service';
|
||||||
import { DefaultUserService } from './default-user.service';
|
import { DefaultUserService } from './default-user.service';
|
||||||
|
|
||||||
@ -10,11 +15,15 @@ export abstract class OwnerContext {
|
|||||||
abstract resolve(): Promise<ResolvedOwnerContext>;
|
abstract resolve(): Promise<ResolvedOwnerContext>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const UUID_PATTERN =
|
||||||
|
/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class BootstrapOwnerContextService extends OwnerContext {
|
export class BootstrapOwnerContextService extends OwnerContext {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly defaultUserService: DefaultUserService,
|
private readonly defaultUserService: DefaultUserService,
|
||||||
private readonly requestContext: RequestContextService,
|
private readonly requestContext: RequestContextService,
|
||||||
|
private readonly prismaService: PrismaService,
|
||||||
) {
|
) {
|
||||||
super();
|
super();
|
||||||
}
|
}
|
||||||
@ -28,6 +37,31 @@ export class BootstrapOwnerContextService extends OwnerContext {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const legacyDeviceId = this.requestContext.getLegacyDeviceId();
|
||||||
|
|
||||||
|
if (legacyDeviceId) {
|
||||||
|
if (!UUID_PATTERN.test(legacyDeviceId)) {
|
||||||
|
throw new BadRequestException('deviceId must be a UUID');
|
||||||
|
}
|
||||||
|
|
||||||
|
const legacyDevice = await this.prismaService.device.findUnique({
|
||||||
|
where: {
|
||||||
|
id: legacyDeviceId,
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
userId: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!legacyDevice) {
|
||||||
|
throw new NotFoundException('Device not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
userId: legacyDevice.userId,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
const defaultUser = await this.defaultUserService.getOrCreateDefaultUser();
|
const defaultUser = await this.defaultUserService.getOrCreateDefaultUser();
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@ -353,6 +353,71 @@ describe('Velody API wiring (e2e)', () => {
|
|||||||
let prismaState: ReturnType<typeof createPrismaMock>['state'];
|
let prismaState: ReturnType<typeof createPrismaMock>['state'];
|
||||||
let storageRoot: string;
|
let storageRoot: string;
|
||||||
|
|
||||||
|
async function registerDeviceWithAuthorization(
|
||||||
|
body: {
|
||||||
|
platform: 'MACOS' | 'IPHONE';
|
||||||
|
deviceName: string;
|
||||||
|
appVersion: string;
|
||||||
|
},
|
||||||
|
authorizationHeader: string,
|
||||||
|
) {
|
||||||
|
return requestContextService.run(async () => {
|
||||||
|
await deviceAuthService.authenticateAuthorizationHeader(
|
||||||
|
authorizationHeader,
|
||||||
|
);
|
||||||
|
|
||||||
|
return devicesController.register(body);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function seedDevice(params: {
|
||||||
|
userId: string;
|
||||||
|
deviceAccessToken?: string;
|
||||||
|
deviceId?: string;
|
||||||
|
deviceName?: string;
|
||||||
|
appVersion?: string;
|
||||||
|
platform?: 'MACOS' | 'IPHONE';
|
||||||
|
tokenRevokedAt?: Date | null;
|
||||||
|
}) {
|
||||||
|
const deviceId = params.deviceId ?? randomUUID();
|
||||||
|
const deviceAccessToken = params.deviceAccessToken;
|
||||||
|
|
||||||
|
prismaState.devices.set(deviceId, {
|
||||||
|
id: deviceId,
|
||||||
|
userId: params.userId,
|
||||||
|
platform: params.platform ?? 'IPHONE',
|
||||||
|
deviceName: params.deviceName ?? 'Seeded Device',
|
||||||
|
appVersion: params.appVersion ?? '0.1.0',
|
||||||
|
installTokenHash: `seeded-install-${deviceId}`,
|
||||||
|
tokenHash: deviceAccessToken
|
||||||
|
? sha256Hex(Buffer.from(deviceAccessToken, 'utf8'))
|
||||||
|
: undefined,
|
||||||
|
tokenCreatedAt: deviceAccessToken ? new Date() : undefined,
|
||||||
|
tokenRevokedAt: params.tokenRevokedAt ?? undefined,
|
||||||
|
lastSeenAt: new Date(),
|
||||||
|
createdAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
deviceId,
|
||||||
|
deviceAccessToken,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runAsDevice<T>(
|
||||||
|
deviceAccessToken: string,
|
||||||
|
callback: () => Promise<T>,
|
||||||
|
): Promise<T> {
|
||||||
|
return requestContextService.run(async () => {
|
||||||
|
await deviceAuthService.authenticateAuthorizationHeader(
|
||||||
|
`Bearer ${deviceAccessToken}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
return callback();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
const prismaSetup = createPrismaMock();
|
const prismaSetup = createPrismaMock();
|
||||||
prismaMock = prismaSetup.prismaMock;
|
prismaMock = prismaSetup.prismaMock;
|
||||||
@ -463,7 +528,68 @@ 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 () => {
|
it('registers a linked device under the authenticated device owner when Authorization is present', async () => {
|
||||||
|
const linkedOwnerId = randomUUID();
|
||||||
|
const existingDevice = seedDevice({
|
||||||
|
userId: linkedOwnerId,
|
||||||
|
deviceAccessToken: 'linked-owner-access-token',
|
||||||
|
deviceName: 'Existing Linked Owner Device',
|
||||||
|
platform: 'MACOS',
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await registerDeviceWithAuthorization(
|
||||||
|
{
|
||||||
|
platform: 'IPHONE',
|
||||||
|
deviceName: 'Linked iPhone',
|
||||||
|
appVersion: '0.1.0',
|
||||||
|
},
|
||||||
|
`Bearer ${existingDevice.deviceAccessToken}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(response.deviceId).toBeDefined();
|
||||||
|
expect(response.deviceAccessToken).toBeDefined();
|
||||||
|
expect(prismaState.devices.get(response.deviceId)?.userId).toBe(
|
||||||
|
linkedOwnerId,
|
||||||
|
);
|
||||||
|
expect(prismaState.devices.get(response.deviceId)?.userId).not.toBe(
|
||||||
|
prismaState.defaultUser.id,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns 401 when register receives an invalid Authorization header', async () => {
|
||||||
|
await expect(
|
||||||
|
registerDeviceWithAuthorization(
|
||||||
|
{
|
||||||
|
platform: 'IPHONE',
|
||||||
|
deviceName: 'Rejected iPhone',
|
||||||
|
appVersion: '0.1.0',
|
||||||
|
},
|
||||||
|
'Bearer invalid-device-token',
|
||||||
|
),
|
||||||
|
).rejects.toBeInstanceOf(UnauthorizedException);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects linked registration when the authenticating device token has been revoked', async () => {
|
||||||
|
const revokedDevice = seedDevice({
|
||||||
|
userId: randomUUID(),
|
||||||
|
deviceAccessToken: 'revoked-link-token',
|
||||||
|
tokenRevokedAt: new Date('2026-06-09T10:00:00.000Z'),
|
||||||
|
deviceName: 'Revoked Device',
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
registerDeviceWithAuthorization(
|
||||||
|
{
|
||||||
|
platform: 'MACOS',
|
||||||
|
deviceName: 'Blocked Mac',
|
||||||
|
appVersion: '0.1.0',
|
||||||
|
},
|
||||||
|
`Bearer ${revokedDevice.deviceAccessToken}`,
|
||||||
|
),
|
||||||
|
).rejects.toBeInstanceOf(UnauthorizedException);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts heartbeat updates for a legacy device id outside the bootstrap owner', async () => {
|
||||||
const foreignDeviceId = randomUUID();
|
const foreignDeviceId = randomUUID();
|
||||||
prismaState.devices.set(foreignDeviceId, {
|
prismaState.devices.set(foreignDeviceId, {
|
||||||
id: foreignDeviceId,
|
id: foreignDeviceId,
|
||||||
@ -477,12 +603,13 @@ describe('Velody API wiring (e2e)', () => {
|
|||||||
updatedAt: new Date(),
|
updatedAt: new Date(),
|
||||||
});
|
});
|
||||||
|
|
||||||
await expect(
|
const response = await devicesController.heartbeat({
|
||||||
devicesController.heartbeat({
|
deviceId: foreignDeviceId,
|
||||||
deviceId: foreignDeviceId,
|
appVersion: '0.1.1',
|
||||||
appVersion: '0.1.1',
|
});
|
||||||
}),
|
|
||||||
).rejects.toBeInstanceOf(NotFoundException);
|
expect(response.ok).toBe(true);
|
||||||
|
expect(prismaState.devices.get(foreignDeviceId)?.appVersion).toBe('0.1.1');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns sync bootstrap and changes payloads', async () => {
|
it('returns sync bootstrap and changes payloads', async () => {
|
||||||
@ -1138,6 +1265,81 @@ describe('Velody API wiring (e2e)', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('lets linked devices under the same identity see the same library', async () => {
|
||||||
|
const identityUserId = randomUUID();
|
||||||
|
const primaryDevice = seedDevice({
|
||||||
|
userId: identityUserId,
|
||||||
|
deviceAccessToken: 'shared-library-primary-token',
|
||||||
|
deviceName: 'Identity Mac',
|
||||||
|
platform: 'MACOS',
|
||||||
|
});
|
||||||
|
const linkResponse = await registerDeviceWithAuthorization(
|
||||||
|
{
|
||||||
|
platform: 'IPHONE',
|
||||||
|
deviceName: 'Identity iPhone',
|
||||||
|
appVersion: '0.1.0',
|
||||||
|
},
|
||||||
|
`Bearer ${primaryDevice.deviceAccessToken}`,
|
||||||
|
);
|
||||||
|
const linkedDeviceToken = linkResponse.deviceAccessToken;
|
||||||
|
const linkedDeviceId = linkResponse.deviceId;
|
||||||
|
const trackId = randomUUID();
|
||||||
|
const assetId = randomUUID();
|
||||||
|
|
||||||
|
prismaState.audioAssets.set(assetId, {
|
||||||
|
id: assetId,
|
||||||
|
userId: identityUserId,
|
||||||
|
trackId,
|
||||||
|
sha256: 'shared-library-sha',
|
||||||
|
storageKey: `users/${identityUserId}/audio/shared-library-sha.mp3`,
|
||||||
|
originalFilename: 'shared-library.mp3',
|
||||||
|
mimeType: 'audio/mpeg',
|
||||||
|
fileExtension: 'mp3',
|
||||||
|
fileSizeBytes: BigInt(42),
|
||||||
|
durationMs: 198000,
|
||||||
|
sourceDeviceId: primaryDevice.deviceId,
|
||||||
|
createdAt: new Date('2026-05-29T08:00:00.000Z'),
|
||||||
|
});
|
||||||
|
prismaState.tracks.set(trackId, {
|
||||||
|
id: trackId,
|
||||||
|
userId: identityUserId,
|
||||||
|
primaryAudioAssetId: assetId,
|
||||||
|
artworkAssetId: null,
|
||||||
|
title: 'Shared Library Track',
|
||||||
|
artist: 'Velody',
|
||||||
|
album: null,
|
||||||
|
albumArtist: null,
|
||||||
|
genre: null,
|
||||||
|
discNumber: null,
|
||||||
|
trackNumber: null,
|
||||||
|
year: null,
|
||||||
|
durationMs: 198000,
|
||||||
|
status: 'ACTIVE',
|
||||||
|
deletedAt: null,
|
||||||
|
createdAt: new Date('2026-05-29T08:00:00.000Z'),
|
||||||
|
updatedAt: new Date('2026-05-29T08:02:00.000Z'),
|
||||||
|
});
|
||||||
|
|
||||||
|
const primaryLibrary = await runAsDevice(
|
||||||
|
primaryDevice.deviceAccessToken!,
|
||||||
|
() => libraryController.getTracks({}),
|
||||||
|
);
|
||||||
|
const linkedLibrary = await runAsDevice(linkedDeviceToken, () =>
|
||||||
|
libraryController.getTracks({
|
||||||
|
deviceId: linkedDeviceId,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(primaryLibrary.tracks).toEqual([
|
||||||
|
expect.objectContaining({
|
||||||
|
trackId,
|
||||||
|
assetId,
|
||||||
|
title: 'Shared Library Track',
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
expect(linkedLibrary.tracks).toEqual(primaryLibrary.tracks);
|
||||||
|
});
|
||||||
|
|
||||||
it('keeps the legacy library deviceId path working when Authorization is missing', async () => {
|
it('keeps the legacy library deviceId path working when Authorization is missing', async () => {
|
||||||
const ownerDevice = await devicesController.register({
|
const ownerDevice = await devicesController.register({
|
||||||
platform: 'IPHONE',
|
platform: 'IPHONE',
|
||||||
@ -1321,6 +1523,69 @@ describe('Velody API wiring (e2e)', () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('makes an upload from one device visible to another linked device under the same owner', async () => {
|
||||||
|
const identityUserId = randomUUID();
|
||||||
|
const primaryDevice = seedDevice({
|
||||||
|
userId: identityUserId,
|
||||||
|
deviceAccessToken: 'linked-upload-primary-token',
|
||||||
|
deviceName: 'Upload Mac',
|
||||||
|
platform: 'MACOS',
|
||||||
|
});
|
||||||
|
const linkResponse = await registerDeviceWithAuthorization(
|
||||||
|
{
|
||||||
|
platform: 'IPHONE',
|
||||||
|
deviceName: 'Upload iPhone',
|
||||||
|
appVersion: '0.1.0',
|
||||||
|
},
|
||||||
|
`Bearer ${primaryDevice.deviceAccessToken}`,
|
||||||
|
);
|
||||||
|
const linkedDeviceToken = linkResponse.deviceAccessToken;
|
||||||
|
const bytes = sampleMp3Bytes('linked-upload');
|
||||||
|
const sha256 = sha256Hex(bytes);
|
||||||
|
|
||||||
|
const prepareResponse = await runAsDevice(
|
||||||
|
primaryDevice.deviceAccessToken!,
|
||||||
|
() =>
|
||||||
|
uploadsController.prepare({
|
||||||
|
sha256,
|
||||||
|
originalFilename: 'linked-upload.mp3',
|
||||||
|
sizeBytes: bytes.length,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(prepareResponse.status).toBe('upload_required');
|
||||||
|
expect(prepareResponse.uploadId).toBeDefined();
|
||||||
|
|
||||||
|
await runAsDevice(primaryDevice.deviceAccessToken!, () =>
|
||||||
|
uploadsController.uploadFile(
|
||||||
|
prepareResponse.uploadId!,
|
||||||
|
createUploadRequest(bytes),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
const finalizeResponse = await runAsDevice(
|
||||||
|
primaryDevice.deviceAccessToken!,
|
||||||
|
() =>
|
||||||
|
uploadsController.finalize(prepareResponse.uploadId!, {
|
||||||
|
title: 'Linked Upload Track',
|
||||||
|
artist: 'Velody',
|
||||||
|
durationMs: 123000,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const linkedLibrary = await runAsDevice(linkedDeviceToken, () =>
|
||||||
|
libraryController.getTracks({}),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(linkedLibrary.tracks).toEqual([
|
||||||
|
expect.objectContaining({
|
||||||
|
trackId: finalizeResponse.trackId,
|
||||||
|
assetId: finalizeResponse.assetId,
|
||||||
|
title: 'Linked Upload Track',
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
it('supports the MP3 upload pipeline through the Nest app wiring', async () => {
|
it('supports the MP3 upload pipeline through the Nest app wiring', async () => {
|
||||||
const registerResponse = await devicesController.register({
|
const registerResponse = await devicesController.register({
|
||||||
platform: 'MACOS',
|
platform: 'MACOS',
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user