Complete account schema foundation
This commit is contained in:
parent
538d259b63
commit
babea87140
@ -0,0 +1,188 @@
|
||||
CREATE TYPE "UserAccountKind" AS ENUM ('LEGACY_DEFAULT', 'GUEST', 'REGISTERED');
|
||||
CREATE TYPE "UserAccountStatus" AS ENUM ('ACTIVE', 'LOCKED', 'DELETED');
|
||||
|
||||
ALTER TABLE "users"
|
||||
ADD COLUMN "account_kind" "UserAccountKind",
|
||||
ADD COLUMN "account_status" "UserAccountStatus",
|
||||
ADD COLUMN "library_namespace" UUID;
|
||||
|
||||
UPDATE "users"
|
||||
SET
|
||||
"account_kind" = 'LEGACY_DEFAULT'::"UserAccountKind",
|
||||
"account_status" = 'ACTIVE'::"UserAccountStatus",
|
||||
"library_namespace" = COALESCE("library_namespace", gen_random_uuid())
|
||||
WHERE "account_kind" IS NULL
|
||||
OR "account_status" IS NULL
|
||||
OR "library_namespace" IS NULL;
|
||||
|
||||
UPDATE "users"
|
||||
SET
|
||||
"account_kind" = 'LEGACY_DEFAULT'::"UserAccountKind",
|
||||
"account_status" = 'ACTIVE'::"UserAccountStatus"
|
||||
WHERE "slug" = 'default-owner';
|
||||
|
||||
ALTER TABLE "users"
|
||||
ALTER COLUMN "account_kind" SET NOT NULL,
|
||||
ALTER COLUMN "account_kind" SET DEFAULT 'LEGACY_DEFAULT'::"UserAccountKind",
|
||||
ALTER COLUMN "account_status" SET NOT NULL,
|
||||
ALTER COLUMN "account_status" SET DEFAULT 'ACTIVE'::"UserAccountStatus",
|
||||
ALTER COLUMN "library_namespace" SET NOT NULL;
|
||||
|
||||
CREATE UNIQUE INDEX "users_library_namespace_key"
|
||||
ON "users"("library_namespace");
|
||||
|
||||
CREATE TABLE "user_password_credentials" (
|
||||
"id" UUID NOT NULL,
|
||||
"user_id" UUID NOT NULL,
|
||||
"password_hash" TEXT NOT NULL,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMP(3) NOT NULL,
|
||||
CONSTRAINT "user_password_credentials_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX "user_password_credentials_user_id_key"
|
||||
ON "user_password_credentials"("user_id");
|
||||
|
||||
CREATE TABLE "user_oauth_identities" (
|
||||
"id" UUID NOT NULL,
|
||||
"user_id" UUID NOT NULL,
|
||||
"provider" TEXT NOT NULL,
|
||||
"provider_subject" TEXT NOT NULL,
|
||||
"email" TEXT,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMP(3) NOT NULL,
|
||||
CONSTRAINT "user_oauth_identities_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX "user_oauth_identities_provider_provider_subject_key"
|
||||
ON "user_oauth_identities"("provider", "provider_subject");
|
||||
|
||||
CREATE INDEX "user_oauth_identities_user_id_idx"
|
||||
ON "user_oauth_identities"("user_id");
|
||||
|
||||
CREATE TABLE "email_verification_tokens" (
|
||||
"id" UUID NOT NULL,
|
||||
"user_id" UUID NOT NULL,
|
||||
"email" TEXT NOT NULL,
|
||||
"token_hash" TEXT NOT NULL,
|
||||
"expires_at" TIMESTAMP(3) NOT NULL,
|
||||
"consumed_at" TIMESTAMP(3),
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT "email_verification_tokens_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX "email_verification_tokens_token_hash_key"
|
||||
ON "email_verification_tokens"("token_hash");
|
||||
|
||||
CREATE INDEX "email_verification_tokens_user_id_idx"
|
||||
ON "email_verification_tokens"("user_id");
|
||||
|
||||
CREATE TABLE "password_reset_tokens" (
|
||||
"id" UUID NOT NULL,
|
||||
"user_id" UUID NOT NULL,
|
||||
"token_hash" TEXT NOT NULL,
|
||||
"expires_at" TIMESTAMP(3) NOT NULL,
|
||||
"consumed_at" TIMESTAMP(3),
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT "password_reset_tokens_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX "password_reset_tokens_token_hash_key"
|
||||
ON "password_reset_tokens"("token_hash");
|
||||
|
||||
CREATE INDEX "password_reset_tokens_user_id_idx"
|
||||
ON "password_reset_tokens"("user_id");
|
||||
|
||||
CREATE TABLE "device_link_history" (
|
||||
"id" UUID NOT NULL,
|
||||
"user_id" UUID NOT NULL,
|
||||
"device_id" UUID,
|
||||
"linked_by_device_id" UUID,
|
||||
"link_method" TEXT NOT NULL,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT "device_link_history_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
CREATE INDEX "device_link_history_user_id_idx"
|
||||
ON "device_link_history"("user_id");
|
||||
|
||||
CREATE INDEX "device_link_history_device_id_idx"
|
||||
ON "device_link_history"("device_id");
|
||||
|
||||
CREATE TABLE "ownership_transfers" (
|
||||
"id" UUID NOT NULL,
|
||||
"from_user_id" UUID NOT NULL,
|
||||
"to_user_id" UUID,
|
||||
"requested_by_device_id" UUID,
|
||||
"status" TEXT NOT NULL,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMP(3) NOT NULL,
|
||||
"completed_at" TIMESTAMP(3),
|
||||
CONSTRAINT "ownership_transfers_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
CREATE INDEX "ownership_transfers_from_user_id_idx"
|
||||
ON "ownership_transfers"("from_user_id");
|
||||
|
||||
CREATE INDEX "ownership_transfers_to_user_id_idx"
|
||||
ON "ownership_transfers"("to_user_id");
|
||||
|
||||
ALTER TABLE "user_password_credentials"
|
||||
ADD CONSTRAINT "user_password_credentials_user_id_fkey"
|
||||
FOREIGN KEY ("user_id") REFERENCES "users"("id")
|
||||
ON DELETE CASCADE
|
||||
ON UPDATE CASCADE;
|
||||
|
||||
ALTER TABLE "user_oauth_identities"
|
||||
ADD CONSTRAINT "user_oauth_identities_user_id_fkey"
|
||||
FOREIGN KEY ("user_id") REFERENCES "users"("id")
|
||||
ON DELETE CASCADE
|
||||
ON UPDATE CASCADE;
|
||||
|
||||
ALTER TABLE "email_verification_tokens"
|
||||
ADD CONSTRAINT "email_verification_tokens_user_id_fkey"
|
||||
FOREIGN KEY ("user_id") REFERENCES "users"("id")
|
||||
ON DELETE CASCADE
|
||||
ON UPDATE CASCADE;
|
||||
|
||||
ALTER TABLE "password_reset_tokens"
|
||||
ADD CONSTRAINT "password_reset_tokens_user_id_fkey"
|
||||
FOREIGN KEY ("user_id") REFERENCES "users"("id")
|
||||
ON DELETE CASCADE
|
||||
ON UPDATE CASCADE;
|
||||
|
||||
ALTER TABLE "device_link_history"
|
||||
ADD CONSTRAINT "device_link_history_user_id_fkey"
|
||||
FOREIGN KEY ("user_id") REFERENCES "users"("id")
|
||||
ON DELETE CASCADE
|
||||
ON UPDATE CASCADE;
|
||||
|
||||
ALTER TABLE "device_link_history"
|
||||
ADD CONSTRAINT "device_link_history_device_id_fkey"
|
||||
FOREIGN KEY ("device_id") REFERENCES "devices"("id")
|
||||
ON DELETE SET NULL
|
||||
ON UPDATE CASCADE;
|
||||
|
||||
ALTER TABLE "device_link_history"
|
||||
ADD CONSTRAINT "device_link_history_linked_by_device_id_fkey"
|
||||
FOREIGN KEY ("linked_by_device_id") REFERENCES "devices"("id")
|
||||
ON DELETE SET NULL
|
||||
ON UPDATE CASCADE;
|
||||
|
||||
ALTER TABLE "ownership_transfers"
|
||||
ADD CONSTRAINT "ownership_transfers_from_user_id_fkey"
|
||||
FOREIGN KEY ("from_user_id") REFERENCES "users"("id")
|
||||
ON DELETE CASCADE
|
||||
ON UPDATE CASCADE;
|
||||
|
||||
ALTER TABLE "ownership_transfers"
|
||||
ADD CONSTRAINT "ownership_transfers_to_user_id_fkey"
|
||||
FOREIGN KEY ("to_user_id") REFERENCES "users"("id")
|
||||
ON DELETE SET NULL
|
||||
ON UPDATE CASCADE;
|
||||
|
||||
ALTER TABLE "ownership_transfers"
|
||||
ADD CONSTRAINT "ownership_transfers_requested_by_device_id_fkey"
|
||||
FOREIGN KEY ("requested_by_device_id") REFERENCES "devices"("id")
|
||||
ON DELETE SET NULL
|
||||
ON UPDATE CASCADE;
|
||||
@ -12,6 +12,9 @@ model User {
|
||||
slug String @unique
|
||||
displayName String @map("display_name")
|
||||
isDefault Boolean @default(false) @map("is_default")
|
||||
accountKind UserAccountKind @default(LEGACY_DEFAULT) @map("account_kind")
|
||||
accountStatus UserAccountStatus @default(ACTIVE) @map("account_status")
|
||||
libraryNamespace String @unique @default(uuid()) @map("library_namespace") @db.Uuid
|
||||
libraryCursor BigInt @default(0) @map("library_cursor")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
@ -22,13 +25,20 @@ model User {
|
||||
uploadSessions UploadSession[]
|
||||
libraryEvents LibraryEvent[]
|
||||
syncCursors DeviceSyncCursor[]
|
||||
passwordCredential UserPasswordCredential?
|
||||
oauthIdentities UserOAuthIdentity[]
|
||||
emailVerificationTokens EmailVerificationToken[]
|
||||
passwordResetTokens PasswordResetToken[]
|
||||
deviceLinkHistory DeviceLinkHistory[]
|
||||
outgoingTransfers OwnershipTransfer[] @relation("OwnershipTransferFromUser")
|
||||
incomingTransfers OwnershipTransfer[] @relation("OwnershipTransferToUser")
|
||||
|
||||
@@map("users")
|
||||
}
|
||||
|
||||
model Device {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
userId String @db.Uuid @map("user_id")
|
||||
userId String @map("user_id") @db.Uuid
|
||||
platform DevicePlatform
|
||||
deviceName String @map("device_name")
|
||||
appVersion String @map("app_version")
|
||||
@ -43,6 +53,9 @@ model Device {
|
||||
uploadSessions UploadSession[]
|
||||
syncCursor DeviceSyncCursor?
|
||||
audioAssets AudioAsset[]
|
||||
linkHistory DeviceLinkHistory[] @relation("DeviceLinkHistoryDevice")
|
||||
linkedByHistory DeviceLinkHistory[] @relation("DeviceLinkHistoryLinkedByDevice")
|
||||
requestedTransfers OwnershipTransfer[] @relation("OwnershipTransferRequestedByDevice")
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade, onUpdate: Cascade)
|
||||
|
||||
@@index([userId])
|
||||
@ -51,9 +64,9 @@ model Device {
|
||||
|
||||
model Track {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
userId String @db.Uuid @map("user_id")
|
||||
primaryAudioAssetId String? @unique @db.Uuid @map("primary_audio_asset_id")
|
||||
artworkAssetId String? @db.Uuid @map("artwork_asset_id")
|
||||
userId String @map("user_id") @db.Uuid
|
||||
primaryAudioAssetId String? @unique @map("primary_audio_asset_id") @db.Uuid
|
||||
artworkAssetId String? @map("artwork_asset_id") @db.Uuid
|
||||
title String
|
||||
artist String
|
||||
album String?
|
||||
@ -78,8 +91,8 @@ model Track {
|
||||
|
||||
model AudioAsset {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
userId String @db.Uuid @map("user_id")
|
||||
trackId String? @db.Uuid @map("track_id")
|
||||
userId String @map("user_id") @db.Uuid
|
||||
trackId String? @map("track_id") @db.Uuid
|
||||
sha256 String
|
||||
storageKey String @unique @map("storage_key")
|
||||
originalFilename String @map("original_filename")
|
||||
@ -90,7 +103,7 @@ model AudioAsset {
|
||||
sampleRateHz Int? @map("sample_rate_hz")
|
||||
channels Int?
|
||||
durationMs Int? @map("duration_ms")
|
||||
sourceDeviceId String? @db.Uuid @map("source_device_id")
|
||||
sourceDeviceId String? @map("source_device_id") @db.Uuid
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
track Track? @relation("TrackAudioAssets", fields: [trackId], references: [id], onDelete: SetNull, onUpdate: Cascade)
|
||||
primaryForTrack Track? @relation("PrimaryAudioAsset")
|
||||
@ -103,7 +116,7 @@ model AudioAsset {
|
||||
}
|
||||
|
||||
model ArtworkAsset {
|
||||
userId String @db.Uuid @map("user_id")
|
||||
userId String @map("user_id") @db.Uuid
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
sha256 String
|
||||
storageKey String @unique @map("storage_key")
|
||||
@ -122,10 +135,10 @@ model ArtworkAsset {
|
||||
|
||||
model UploadSession {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
userId String @db.Uuid @map("user_id")
|
||||
deviceId String @db.Uuid @map("device_id")
|
||||
trackId String? @db.Uuid @map("track_id")
|
||||
audioAssetId String? @db.Uuid @map("audio_asset_id")
|
||||
userId String @map("user_id") @db.Uuid
|
||||
deviceId String @map("device_id") @db.Uuid
|
||||
trackId String? @map("track_id") @db.Uuid
|
||||
audioAssetId String? @map("audio_asset_id") @db.Uuid
|
||||
expectedSha256 String @map("expected_sha256")
|
||||
originalFilename String @map("original_filename")
|
||||
expectedSizeBytes BigInt @map("expected_size_bytes")
|
||||
@ -146,25 +159,25 @@ model UploadSession {
|
||||
|
||||
model LibraryEvent {
|
||||
id BigInt @id @default(autoincrement())
|
||||
userId String @db.Uuid @map("user_id")
|
||||
userId String @map("user_id") @db.Uuid
|
||||
cursor BigInt
|
||||
entityType EntityType @map("entity_type")
|
||||
entityId String @db.Uuid @map("entity_id")
|
||||
entityId String @map("entity_id") @db.Uuid
|
||||
action EventAction
|
||||
payload Json
|
||||
payloadVersion Int @default(1) @map("payload_version")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade, onUpdate: Cascade)
|
||||
|
||||
@@index([userId])
|
||||
@@unique([userId, cursor])
|
||||
@@index([userId])
|
||||
@@index([userId, cursor])
|
||||
@@map("library_events")
|
||||
}
|
||||
|
||||
model DeviceSyncCursor {
|
||||
deviceId String @id @db.Uuid @map("device_id")
|
||||
userId String @db.Uuid @map("user_id")
|
||||
deviceId String @id @map("device_id") @db.Uuid
|
||||
userId String @map("user_id") @db.Uuid
|
||||
cursor BigInt @default(0)
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
device Device @relation(fields: [deviceId], references: [id], onDelete: Cascade, onUpdate: Cascade)
|
||||
@ -174,6 +187,105 @@ model DeviceSyncCursor {
|
||||
@@map("device_sync_cursors")
|
||||
}
|
||||
|
||||
model UserPasswordCredential {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
userId String @unique @map("user_id") @db.Uuid
|
||||
passwordHash String @map("password_hash")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade, onUpdate: Cascade)
|
||||
|
||||
@@map("user_password_credentials")
|
||||
}
|
||||
|
||||
model UserOAuthIdentity {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
userId String @map("user_id") @db.Uuid
|
||||
provider String
|
||||
providerSubject String @map("provider_subject")
|
||||
email String?
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade, onUpdate: Cascade)
|
||||
|
||||
@@unique([provider, providerSubject])
|
||||
@@index([userId])
|
||||
@@map("user_oauth_identities")
|
||||
}
|
||||
|
||||
model EmailVerificationToken {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
userId String @map("user_id") @db.Uuid
|
||||
email String
|
||||
tokenHash String @unique @map("token_hash")
|
||||
expiresAt DateTime @map("expires_at")
|
||||
consumedAt DateTime? @map("consumed_at")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade, onUpdate: Cascade)
|
||||
|
||||
@@index([userId])
|
||||
@@map("email_verification_tokens")
|
||||
}
|
||||
|
||||
model PasswordResetToken {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
userId String @map("user_id") @db.Uuid
|
||||
tokenHash String @unique @map("token_hash")
|
||||
expiresAt DateTime @map("expires_at")
|
||||
consumedAt DateTime? @map("consumed_at")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade, onUpdate: Cascade)
|
||||
|
||||
@@index([userId])
|
||||
@@map("password_reset_tokens")
|
||||
}
|
||||
|
||||
model DeviceLinkHistory {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
userId String @map("user_id") @db.Uuid
|
||||
deviceId String? @map("device_id") @db.Uuid
|
||||
linkedByDeviceId String? @map("linked_by_device_id") @db.Uuid
|
||||
linkMethod String @map("link_method")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade, onUpdate: Cascade)
|
||||
device Device? @relation("DeviceLinkHistoryDevice", fields: [deviceId], references: [id], onDelete: SetNull, onUpdate: Cascade)
|
||||
linkedByDevice Device? @relation("DeviceLinkHistoryLinkedByDevice", fields: [linkedByDeviceId], references: [id], onDelete: SetNull, onUpdate: Cascade)
|
||||
|
||||
@@index([userId])
|
||||
@@index([deviceId])
|
||||
@@map("device_link_history")
|
||||
}
|
||||
|
||||
model OwnershipTransfer {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
fromUserId String @map("from_user_id") @db.Uuid
|
||||
toUserId String? @map("to_user_id") @db.Uuid
|
||||
requestedByDeviceId String? @map("requested_by_device_id") @db.Uuid
|
||||
status String
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
completedAt DateTime? @map("completed_at")
|
||||
fromUser User @relation("OwnershipTransferFromUser", fields: [fromUserId], references: [id], onDelete: Cascade, onUpdate: Cascade)
|
||||
toUser User? @relation("OwnershipTransferToUser", fields: [toUserId], references: [id], onDelete: SetNull, onUpdate: Cascade)
|
||||
requestedByDevice Device? @relation("OwnershipTransferRequestedByDevice", fields: [requestedByDeviceId], references: [id], onDelete: SetNull, onUpdate: Cascade)
|
||||
|
||||
@@index([fromUserId])
|
||||
@@index([toUserId])
|
||||
@@map("ownership_transfers")
|
||||
}
|
||||
|
||||
enum UserAccountKind {
|
||||
LEGACY_DEFAULT
|
||||
GUEST
|
||||
REGISTERED
|
||||
}
|
||||
|
||||
enum UserAccountStatus {
|
||||
ACTIVE
|
||||
LOCKED
|
||||
DELETED
|
||||
}
|
||||
|
||||
enum DevicePlatform {
|
||||
MACOS
|
||||
IPHONE
|
||||
|
||||
@ -23,6 +23,7 @@ describe('ProtectedDeviceAuthMiddleware', () => {
|
||||
'/api/v1/uploads/upload-id/file',
|
||||
'/api/v1/uploads/upload-id/finalize',
|
||||
'/api/v1/devices/heartbeat',
|
||||
'/api/v1/me',
|
||||
])(
|
||||
'returns 401 before validation-relevant request data can matter when Authorization is missing on %s',
|
||||
async (path) => {
|
||||
@ -65,6 +66,7 @@ describe('ProtectedDeviceAuthMiddleware', () => {
|
||||
'/api/v1/sync/changes',
|
||||
'/api/v1/uploads/prepare',
|
||||
'/api/v1/devices/heartbeat',
|
||||
'/api/v1/me',
|
||||
])(
|
||||
'returns 401 before validation-relevant request data can matter when Authorization is invalid on %s',
|
||||
async (path) => {
|
||||
|
||||
@ -15,7 +15,10 @@ const PROTECTED_ROUTE_PREFIXES = [
|
||||
'/api/v1/uploads',
|
||||
];
|
||||
|
||||
const PROTECTED_ROUTE_EXACT_PATHS = new Set(['/api/v1/devices/heartbeat']);
|
||||
const PROTECTED_ROUTE_EXACT_PATHS = new Set([
|
||||
'/api/v1/devices/heartbeat',
|
||||
'/api/v1/me',
|
||||
]);
|
||||
|
||||
@Injectable()
|
||||
export class ProtectedDeviceAuthMiddleware implements NestMiddleware {
|
||||
|
||||
@ -39,7 +39,9 @@ describe('DevicesService', () => {
|
||||
appVersion: '0.1.0',
|
||||
});
|
||||
|
||||
expect(ownerContext.resolve).toHaveBeenCalledTimes(1);
|
||||
expect(ownerContext.resolve).toHaveBeenCalledWith({
|
||||
allowLegacyDeviceFallback: false,
|
||||
});
|
||||
expect(prismaService.device.create).toHaveBeenCalledWith({
|
||||
data: expect.objectContaining({
|
||||
userId: ownerId,
|
||||
@ -52,6 +54,50 @@ describe('DevicesService', () => {
|
||||
expect(response.deviceAccessToken).toBe('device-access-token');
|
||||
});
|
||||
|
||||
it('does not allow raw deviceId fallback to choose the registration 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 deviceAuthService = {
|
||||
generateDeviceAccessToken: jest.fn().mockReturnValue('device-access-token'),
|
||||
hashDeviceAccessToken: jest.fn().mockReturnValue('device-token-hash'),
|
||||
getAuthenticatedDeviceOrThrow: jest.fn(),
|
||||
} as any;
|
||||
const service = new DevicesService(
|
||||
prismaService,
|
||||
ownerContext as OwnerContext,
|
||||
deviceAuthService as DeviceAuthService,
|
||||
);
|
||||
|
||||
await service.register({
|
||||
platform: 'IPHONE',
|
||||
deviceName: 'Velody iPhone',
|
||||
appVersion: '0.1.0',
|
||||
});
|
||||
|
||||
expect(ownerContext.resolve).toHaveBeenCalledWith({
|
||||
allowLegacyDeviceFallback: false,
|
||||
});
|
||||
expect(prismaService.device.create).toHaveBeenCalledWith({
|
||||
data: expect.objectContaining({
|
||||
userId: ownerId,
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects heartbeat updates for a foreign-owner device', async () => {
|
||||
const deviceId = randomUUID();
|
||||
const prismaService = {
|
||||
|
||||
@ -28,7 +28,9 @@ export class DevicesService {
|
||||
.digest('hex');
|
||||
const tokenHash =
|
||||
this.deviceAuthService.hashDeviceAccessToken(deviceAccessToken);
|
||||
const owner = await this.ownerContext.resolve();
|
||||
const owner = await this.ownerContext.resolve({
|
||||
allowLegacyDeviceFallback: false,
|
||||
});
|
||||
|
||||
const device = await this.prismaService.device.create({
|
||||
data: {
|
||||
|
||||
25
backend/src/modules/users/account-migration.spec.ts
Normal file
25
backend/src/modules/users/account-migration.spec.ts
Normal file
@ -0,0 +1,25 @@
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { join } from 'node:path';
|
||||
|
||||
describe('account foundations migration', () => {
|
||||
it('backfills default owner account metadata and library namespaces', async () => {
|
||||
const migrationSql = await readFile(
|
||||
join(
|
||||
process.cwd(),
|
||||
'prisma/migrations/20260623110000_milestone112_account_foundations/migration.sql',
|
||||
),
|
||||
'utf8',
|
||||
);
|
||||
|
||||
expect(migrationSql).toContain(
|
||||
`"account_kind" = 'LEGACY_DEFAULT'::"UserAccountKind"`,
|
||||
);
|
||||
expect(migrationSql).toContain(
|
||||
`"account_status" = 'ACTIVE'::"UserAccountStatus"`,
|
||||
);
|
||||
expect(migrationSql).toContain(
|
||||
`"library_namespace" = COALESCE("library_namespace", gen_random_uuid())`,
|
||||
);
|
||||
expect(migrationSql).toContain(`WHERE "slug" = 'default-owner'`);
|
||||
});
|
||||
});
|
||||
21
backend/src/modules/users/account.controller.ts
Normal file
21
backend/src/modules/users/account.controller.ts
Normal file
@ -0,0 +1,21 @@
|
||||
import { Controller, Get, UseGuards } from '@nestjs/common';
|
||||
import { ApiBearerAuth, ApiOkResponse, ApiTags } from '@nestjs/swagger';
|
||||
import { DeviceAuthGuard } from '../auth/device-auth.guard';
|
||||
import { CurrentAccountResponseDto } from './account.dto';
|
||||
import { AccountService } from './account.service';
|
||||
|
||||
@ApiTags('account')
|
||||
@Controller({
|
||||
version: '1',
|
||||
})
|
||||
export class AccountController {
|
||||
constructor(private readonly accountService: AccountService) {}
|
||||
|
||||
@Get('me')
|
||||
@UseGuards(DeviceAuthGuard)
|
||||
@ApiBearerAuth()
|
||||
@ApiOkResponse({ type: CurrentAccountResponseDto })
|
||||
async getMe(): Promise<CurrentAccountResponseDto> {
|
||||
return this.accountService.getCurrentAccount();
|
||||
}
|
||||
}
|
||||
19
backend/src/modules/users/account.dto.ts
Normal file
19
backend/src/modules/users/account.dto.ts
Normal file
@ -0,0 +1,19 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { UserAccountKind, UserAccountStatus } from '@prisma/client';
|
||||
|
||||
export class CurrentAccountResponseDto {
|
||||
@ApiProperty({ format: 'uuid' })
|
||||
accountId!: string;
|
||||
|
||||
@ApiProperty({ enum: UserAccountKind, example: UserAccountKind.LEGACY_DEFAULT })
|
||||
accountKind!: UserAccountKind;
|
||||
|
||||
@ApiProperty({ enum: UserAccountStatus, example: UserAccountStatus.ACTIVE })
|
||||
accountStatus!: UserAccountStatus;
|
||||
|
||||
@ApiProperty({ format: 'uuid' })
|
||||
libraryNamespace!: string;
|
||||
|
||||
@ApiProperty({ format: 'uuid' })
|
||||
currentDeviceId!: string;
|
||||
}
|
||||
90
backend/src/modules/users/account.service.spec.ts
Normal file
90
backend/src/modules/users/account.service.spec.ts
Normal file
@ -0,0 +1,90 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { NotFoundException, UnauthorizedException } from '@nestjs/common';
|
||||
import { DeviceAuthService } from '../auth/device-auth.service';
|
||||
import { AccountService } from './account.service';
|
||||
|
||||
describe('AccountService', () => {
|
||||
it('returns the authenticated device owner account metadata', async () => {
|
||||
const accountId = randomUUID();
|
||||
const currentDeviceId = randomUUID();
|
||||
const libraryNamespace = randomUUID();
|
||||
const prismaService = {
|
||||
user: {
|
||||
findUnique: jest.fn().mockResolvedValue({
|
||||
id: accountId,
|
||||
accountKind: 'LEGACY_DEFAULT',
|
||||
accountStatus: 'ACTIVE',
|
||||
libraryNamespace,
|
||||
}),
|
||||
},
|
||||
} as any;
|
||||
const deviceAuthService = {
|
||||
getAuthenticatedDeviceOrThrow: jest.fn().mockReturnValue({
|
||||
deviceId: currentDeviceId,
|
||||
userId: accountId,
|
||||
}),
|
||||
} as any;
|
||||
const service = new AccountService(
|
||||
prismaService,
|
||||
deviceAuthService as DeviceAuthService,
|
||||
);
|
||||
|
||||
await expect(service.getCurrentAccount()).resolves.toEqual({
|
||||
accountId,
|
||||
accountKind: 'LEGACY_DEFAULT',
|
||||
accountStatus: 'ACTIVE',
|
||||
libraryNamespace,
|
||||
currentDeviceId,
|
||||
});
|
||||
expect(prismaService.user.findUnique).toHaveBeenCalledWith({
|
||||
where: {
|
||||
id: accountId,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
accountKind: true,
|
||||
accountStatus: true,
|
||||
libraryNamespace: true,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('requires device bearer authentication', async () => {
|
||||
const service = new AccountService(
|
||||
{
|
||||
user: {
|
||||
findUnique: jest.fn(),
|
||||
},
|
||||
} as any,
|
||||
{
|
||||
getAuthenticatedDeviceOrThrow: jest.fn().mockImplementation(() => {
|
||||
throw new UnauthorizedException('Authorization header is required');
|
||||
}),
|
||||
} as any,
|
||||
);
|
||||
|
||||
await expect(service.getCurrentAccount()).rejects.toBeInstanceOf(
|
||||
UnauthorizedException,
|
||||
);
|
||||
});
|
||||
|
||||
it('returns 404 when the authenticated device owner no longer exists', async () => {
|
||||
const service = new AccountService(
|
||||
{
|
||||
user: {
|
||||
findUnique: jest.fn().mockResolvedValue(null),
|
||||
},
|
||||
} as any,
|
||||
{
|
||||
getAuthenticatedDeviceOrThrow: jest.fn().mockReturnValue({
|
||||
deviceId: randomUUID(),
|
||||
userId: randomUUID(),
|
||||
}),
|
||||
} as any,
|
||||
);
|
||||
|
||||
await expect(service.getCurrentAccount()).rejects.toBeInstanceOf(
|
||||
NotFoundException,
|
||||
);
|
||||
});
|
||||
});
|
||||
41
backend/src/modules/users/account.service.ts
Normal file
41
backend/src/modules/users/account.service.ts
Normal file
@ -0,0 +1,41 @@
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { PrismaService } from '../../infrastructure/database/prisma.service';
|
||||
import { DeviceAuthService } from '../auth/device-auth.service';
|
||||
import { CurrentAccountResponseDto } from './account.dto';
|
||||
|
||||
@Injectable()
|
||||
export class AccountService {
|
||||
constructor(
|
||||
private readonly prismaService: PrismaService,
|
||||
private readonly deviceAuthService: DeviceAuthService,
|
||||
) {}
|
||||
|
||||
async getCurrentAccount(): Promise<CurrentAccountResponseDto> {
|
||||
const authenticatedDevice =
|
||||
this.deviceAuthService.getAuthenticatedDeviceOrThrow();
|
||||
|
||||
const account = await this.prismaService.user.findUnique({
|
||||
where: {
|
||||
id: authenticatedDevice.userId,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
accountKind: true,
|
||||
accountStatus: true,
|
||||
libraryNamespace: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!account) {
|
||||
throw new NotFoundException('Account not found');
|
||||
}
|
||||
|
||||
return {
|
||||
accountId: account.id,
|
||||
accountKind: account.accountKind,
|
||||
accountStatus: account.accountStatus,
|
||||
libraryNamespace: account.libraryNamespace,
|
||||
currentDeviceId: authenticatedDevice.deviceId,
|
||||
};
|
||||
}
|
||||
}
|
||||
@ -8,6 +8,9 @@ describe('DefaultUserService', () => {
|
||||
slug: DefaultUserService.defaultOwnerSlug,
|
||||
displayName: DefaultUserService.defaultOwnerDisplayName,
|
||||
isDefault: true,
|
||||
accountKind: 'LEGACY_DEFAULT',
|
||||
accountStatus: 'ACTIVE',
|
||||
libraryNamespace: randomUUID(),
|
||||
libraryCursor: 0n,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
@ -27,11 +30,15 @@ describe('DefaultUserService', () => {
|
||||
update: {
|
||||
displayName: DefaultUserService.defaultOwnerDisplayName,
|
||||
isDefault: true,
|
||||
accountKind: 'LEGACY_DEFAULT',
|
||||
accountStatus: 'ACTIVE',
|
||||
},
|
||||
create: {
|
||||
slug: DefaultUserService.defaultOwnerSlug,
|
||||
displayName: DefaultUserService.defaultOwnerDisplayName,
|
||||
isDefault: true,
|
||||
accountKind: 'LEGACY_DEFAULT',
|
||||
accountStatus: 'ACTIVE',
|
||||
},
|
||||
});
|
||||
});
|
||||
@ -49,6 +56,9 @@ describe('DefaultUserService', () => {
|
||||
slug: DefaultUserService.defaultOwnerSlug,
|
||||
displayName: DefaultUserService.defaultOwnerDisplayName,
|
||||
isDefault: true,
|
||||
accountKind: 'LEGACY_DEFAULT',
|
||||
accountStatus: 'ACTIVE',
|
||||
libraryNamespace: randomUUID(),
|
||||
libraryCursor: 0n,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import { Injectable, OnApplicationBootstrap } from '@nestjs/common';
|
||||
import { User } from '@prisma/client';
|
||||
import { User, UserAccountKind, UserAccountStatus } from '@prisma/client';
|
||||
import { PrismaService } from '../../infrastructure/database/prisma.service';
|
||||
|
||||
@Injectable()
|
||||
@ -21,11 +21,15 @@ export class DefaultUserService implements OnApplicationBootstrap {
|
||||
update: {
|
||||
displayName: DefaultUserService.defaultOwnerDisplayName,
|
||||
isDefault: true,
|
||||
accountKind: UserAccountKind.LEGACY_DEFAULT,
|
||||
accountStatus: UserAccountStatus.ACTIVE,
|
||||
},
|
||||
create: {
|
||||
slug: DefaultUserService.defaultOwnerSlug,
|
||||
displayName: DefaultUserService.defaultOwnerDisplayName,
|
||||
isDefault: true,
|
||||
accountKind: UserAccountKind.LEGACY_DEFAULT,
|
||||
accountStatus: UserAccountStatus.ACTIVE,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@ -103,4 +103,40 @@ describe('BootstrapOwnerContextService', () => {
|
||||
});
|
||||
expect(defaultUserService.getOrCreateDefaultUser).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('uses the bootstrap default owner when legacy device fallback is disabled', async () => {
|
||||
const requestContext = new RequestContextService();
|
||||
const defaultUser = {
|
||||
id: randomUUID(),
|
||||
slug: 'default-owner',
|
||||
displayName: 'Default Owner',
|
||||
isDefault: true,
|
||||
};
|
||||
const defaultUserService = {
|
||||
getOrCreateDefaultUser: jest.fn().mockResolvedValue(defaultUser),
|
||||
} as any;
|
||||
const prismaService = {
|
||||
device: {
|
||||
findUnique: jest.fn(),
|
||||
},
|
||||
} as any;
|
||||
const service = new BootstrapOwnerContextService(
|
||||
defaultUserService,
|
||||
requestContext,
|
||||
prismaService,
|
||||
);
|
||||
|
||||
await requestContext.run(async () => {
|
||||
requestContext.setLegacyDeviceId(randomUUID());
|
||||
|
||||
await expect(
|
||||
service.resolve({ allowLegacyDeviceFallback: false }),
|
||||
).resolves.toEqual({
|
||||
userId: defaultUser.id,
|
||||
});
|
||||
});
|
||||
|
||||
expect(prismaService.device.findUnique).not.toHaveBeenCalled();
|
||||
expect(defaultUserService.getOrCreateDefaultUser).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
@ -1,6 +1,9 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { PrismaModule } from '../../infrastructure/database/prisma.module';
|
||||
import { RequestContextModule } from '../../infrastructure/request-context/request-context.module';
|
||||
import { AuthModule } from '../auth/auth.module';
|
||||
import { AccountController } from './account.controller';
|
||||
import { AccountService } from './account.service';
|
||||
import { DefaultUserService } from './default-user.service';
|
||||
import {
|
||||
BootstrapOwnerContextService,
|
||||
@ -8,8 +11,10 @@ import {
|
||||
} from './owner-context.service';
|
||||
|
||||
@Module({
|
||||
imports: [PrismaModule, RequestContextModule],
|
||||
imports: [PrismaModule, RequestContextModule, AuthModule],
|
||||
controllers: [AccountController],
|
||||
providers: [
|
||||
AccountService,
|
||||
DefaultUserService,
|
||||
BootstrapOwnerContextService,
|
||||
{
|
||||
@ -17,6 +22,6 @@ import {
|
||||
useExisting: BootstrapOwnerContextService,
|
||||
},
|
||||
],
|
||||
exports: [DefaultUserService, OwnerContext],
|
||||
exports: [DefaultUserService, OwnerContext, AccountService],
|
||||
})
|
||||
export class UsersModule {}
|
||||
|
||||
@ -28,6 +28,7 @@ import { SyncController } from '../../src/modules/sync/sync.controller';
|
||||
import { UploadsController } from '../../src/modules/uploads/uploads.controller';
|
||||
import { UploadsService } from '../../src/modules/uploads/uploads.service';
|
||||
import { PrismaService } from '../../src/infrastructure/database/prisma.service';
|
||||
import { AccountController } from '../../src/modules/users/account.controller';
|
||||
|
||||
function sampleMp3Bytes(seed: string): Buffer {
|
||||
return Buffer.concat([
|
||||
@ -98,6 +99,9 @@ function createPrismaMock() {
|
||||
id: data.id ?? randomUUID(),
|
||||
createdAt: data.createdAt ?? now,
|
||||
updatedAt: data.updatedAt ?? now,
|
||||
accountKind: data.accountKind ?? 'LEGACY_DEFAULT',
|
||||
accountStatus: data.accountStatus ?? 'ACTIVE',
|
||||
libraryNamespace: data.libraryNamespace ?? randomUUID(),
|
||||
...data,
|
||||
libraryCursor:
|
||||
data.libraryCursor == null ? 0n : BigInt(data.libraryCursor),
|
||||
@ -138,6 +142,21 @@ function createPrismaMock() {
|
||||
users.set(created.id, created);
|
||||
return created;
|
||||
}),
|
||||
findUnique: jest.fn().mockImplementation(async ({ where, select }) => {
|
||||
if (where.id) {
|
||||
return applySelect(users.get(where.id) ?? null, select);
|
||||
}
|
||||
|
||||
if (where.slug) {
|
||||
const matchingUser =
|
||||
[...users.values()].find((user) => user.slug === where.slug) ??
|
||||
null;
|
||||
|
||||
return applySelect(matchingUser, select);
|
||||
}
|
||||
|
||||
return null;
|
||||
}),
|
||||
create: jest.fn().mockImplementation(async ({ data }) => {
|
||||
const created = createUserRecord(data);
|
||||
users.set(created.id, created);
|
||||
@ -437,6 +456,7 @@ describe('Velody API wiring (e2e)', () => {
|
||||
let app: NestExpressApplication;
|
||||
let prismaMock: ReturnType<typeof createPrismaMock>['prismaMock'];
|
||||
let assetsController: AssetsController;
|
||||
let accountController: AccountController;
|
||||
let artworkController: ArtworkController;
|
||||
let healthController: HealthController;
|
||||
let devicesController: DevicesController;
|
||||
@ -552,6 +572,7 @@ describe('Velody API wiring (e2e)', () => {
|
||||
await app.init();
|
||||
|
||||
assetsController = moduleRef.get(AssetsController);
|
||||
accountController = moduleRef.get(AccountController);
|
||||
artworkController = moduleRef.get(ArtworkController);
|
||||
healthController = moduleRef.get(HealthController);
|
||||
devicesController = moduleRef.get(DevicesController);
|
||||
@ -587,13 +608,22 @@ describe('Velody API wiring (e2e)', () => {
|
||||
update: {
|
||||
displayName: 'Default Owner',
|
||||
isDefault: true,
|
||||
accountKind: 'LEGACY_DEFAULT',
|
||||
accountStatus: 'ACTIVE',
|
||||
},
|
||||
create: {
|
||||
slug: 'default-owner',
|
||||
displayName: 'Default Owner',
|
||||
isDefault: true,
|
||||
accountKind: 'LEGACY_DEFAULT',
|
||||
accountStatus: 'ACTIVE',
|
||||
},
|
||||
});
|
||||
expect(prismaState.defaultUser).toMatchObject({
|
||||
accountKind: 'LEGACY_DEFAULT',
|
||||
accountStatus: 'ACTIVE',
|
||||
libraryNamespace: expect.any(String),
|
||||
});
|
||||
});
|
||||
|
||||
it('registers a device and accepts heartbeat', async () => {
|
||||
@ -631,6 +661,32 @@ describe('Velody API wiring (e2e)', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('returns current account metadata from device bearer auth only', async () => {
|
||||
const registerResponse = await devicesController.register({
|
||||
platform: 'MACOS',
|
||||
deviceName: 'Account Mac',
|
||||
appVersion: '0.1.0',
|
||||
});
|
||||
|
||||
const meResponse = await runAsDevice(registerResponse.deviceAccessToken, () =>
|
||||
accountController.getMe(),
|
||||
);
|
||||
|
||||
expect(meResponse).toEqual({
|
||||
accountId: prismaState.defaultUser.id,
|
||||
accountKind: 'LEGACY_DEFAULT',
|
||||
accountStatus: 'ACTIVE',
|
||||
libraryNamespace: prismaState.defaultUser.libraryNamespace,
|
||||
currentDeviceId: registerResponse.deviceId,
|
||||
});
|
||||
});
|
||||
|
||||
it('returns 401 from /me when Authorization is missing', async () => {
|
||||
await expect(accountController.getMe()).rejects.toBeInstanceOf(
|
||||
UnauthorizedException,
|
||||
);
|
||||
});
|
||||
|
||||
it('registers a linked device under the authenticated device owner when Authorization is present', async () => {
|
||||
const linkedOwnerId = randomUUID();
|
||||
const existingDevice = seedDevice({
|
||||
@ -659,6 +715,78 @@ describe('Velody API wiring (e2e)', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('ignores raw deviceId owner fallback during unauthenticated registration', async () => {
|
||||
const foreignOwnerId = randomUUID();
|
||||
const foreignDeviceId = randomUUID();
|
||||
prismaState.users.set(
|
||||
foreignOwnerId,
|
||||
{
|
||||
id: foreignOwnerId,
|
||||
slug: 'foreign-owner',
|
||||
displayName: 'Foreign Owner',
|
||||
isDefault: false,
|
||||
accountKind: 'LEGACY_DEFAULT',
|
||||
accountStatus: 'ACTIVE',
|
||||
libraryNamespace: randomUUID(),
|
||||
libraryCursor: 0n,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
);
|
||||
seedDevice({
|
||||
userId: foreignOwnerId,
|
||||
deviceId: foreignDeviceId,
|
||||
deviceName: 'Foreign Existing Device',
|
||||
});
|
||||
|
||||
const response = await requestContextService.run(async () => {
|
||||
requestContextService.setLegacyDeviceId(foreignDeviceId);
|
||||
|
||||
return devicesController.register({
|
||||
platform: 'IPHONE',
|
||||
deviceName: 'Fresh iPhone',
|
||||
appVersion: '0.1.0',
|
||||
});
|
||||
});
|
||||
|
||||
expect(prismaState.devices.get(response.deviceId)?.userId).toBe(
|
||||
prismaState.defaultUser.id,
|
||||
);
|
||||
});
|
||||
|
||||
it('uses authenticated device owner during registration even when raw deviceId points elsewhere', async () => {
|
||||
const authenticatedOwnerId = randomUUID();
|
||||
const rawDeviceOwnerId = randomUUID();
|
||||
const rawDeviceId = randomUUID();
|
||||
const authenticatedDevice = seedDevice({
|
||||
userId: authenticatedOwnerId,
|
||||
deviceAccessToken: 'authenticated-link-token',
|
||||
deviceName: 'Authenticated Existing Device',
|
||||
});
|
||||
seedDevice({
|
||||
userId: rawDeviceOwnerId,
|
||||
deviceId: rawDeviceId,
|
||||
deviceName: 'Raw Device',
|
||||
});
|
||||
|
||||
const response = await requestContextService.run(async () => {
|
||||
requestContextService.setLegacyDeviceId(rawDeviceId);
|
||||
await deviceAuthService.authenticateAuthorizationHeader(
|
||||
`Bearer ${authenticatedDevice.deviceAccessToken}`,
|
||||
);
|
||||
|
||||
return devicesController.register({
|
||||
platform: 'IPHONE',
|
||||
deviceName: 'Linked iPhone',
|
||||
appVersion: '0.1.0',
|
||||
});
|
||||
});
|
||||
|
||||
expect(prismaState.devices.get(response.deviceId)?.userId).toBe(
|
||||
authenticatedOwnerId,
|
||||
);
|
||||
});
|
||||
|
||||
it('returns 401 when register receives an invalid Authorization header', async () => {
|
||||
await expect(
|
||||
registerDeviceWithAuthorization(
|
||||
|
||||
Loading…
Reference in New Issue
Block a user