Compare commits
No commits in common. "45c270c18785ba6f202204940696b19266058f4e" and "d392e532e03a7c9bd37c356b256e97059e8b5c83" have entirely different histories.
45c270c187
...
d392e532e0
@ -1,10 +1,11 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<Scheme
|
<Scheme
|
||||||
LastUpgradeVersion = "1430"
|
LastUpgradeVersion = "1430"
|
||||||
version = "1.3">
|
version = "1.7">
|
||||||
<BuildAction
|
<BuildAction
|
||||||
parallelizeBuildables = "YES"
|
parallelizeBuildables = "YES"
|
||||||
buildImplicitDependencies = "YES">
|
buildImplicitDependencies = "YES"
|
||||||
|
runPostActionsOnFailure = "NO">
|
||||||
<BuildActionEntries>
|
<BuildActionEntries>
|
||||||
<BuildActionEntry
|
<BuildActionEntry
|
||||||
buildForTesting = "YES"
|
buildForTesting = "YES"
|
||||||
@ -26,7 +27,8 @@
|
|||||||
buildConfiguration = "Debug"
|
buildConfiguration = "Debug"
|
||||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||||
shouldUseLaunchSchemeArgsEnv = "YES">
|
shouldUseLaunchSchemeArgsEnv = "YES"
|
||||||
|
onlyGenerateCoverageForSpecifiedTargets = "NO">
|
||||||
<MacroExpansion>
|
<MacroExpansion>
|
||||||
<BuildableReference
|
<BuildableReference
|
||||||
BuildableIdentifier = "primary"
|
BuildableIdentifier = "primary"
|
||||||
@ -49,6 +51,8 @@
|
|||||||
</BuildableReference>
|
</BuildableReference>
|
||||||
</TestableReference>
|
</TestableReference>
|
||||||
</Testables>
|
</Testables>
|
||||||
|
<CommandLineArguments>
|
||||||
|
</CommandLineArguments>
|
||||||
</TestAction>
|
</TestAction>
|
||||||
<LaunchAction
|
<LaunchAction
|
||||||
buildConfiguration = "Debug"
|
buildConfiguration = "Debug"
|
||||||
@ -70,6 +74,8 @@
|
|||||||
ReferencedContainer = "container:Velody.xcodeproj">
|
ReferencedContainer = "container:Velody.xcodeproj">
|
||||||
</BuildableReference>
|
</BuildableReference>
|
||||||
</BuildableProductRunnable>
|
</BuildableProductRunnable>
|
||||||
|
<CommandLineArguments>
|
||||||
|
</CommandLineArguments>
|
||||||
</LaunchAction>
|
</LaunchAction>
|
||||||
<ProfileAction
|
<ProfileAction
|
||||||
buildConfiguration = "Release"
|
buildConfiguration = "Release"
|
||||||
@ -87,6 +93,8 @@
|
|||||||
ReferencedContainer = "container:Velody.xcodeproj">
|
ReferencedContainer = "container:Velody.xcodeproj">
|
||||||
</BuildableReference>
|
</BuildableReference>
|
||||||
</BuildableProductRunnable>
|
</BuildableProductRunnable>
|
||||||
|
<CommandLineArguments>
|
||||||
|
</CommandLineArguments>
|
||||||
</ProfileAction>
|
</ProfileAction>
|
||||||
<AnalyzeAction
|
<AnalyzeAction
|
||||||
buildConfiguration = "Debug">
|
buildConfiguration = "Debug">
|
||||||
|
|||||||
@ -345,7 +345,12 @@ final class MacLibraryViewModel {
|
|||||||
appVersion: environment.appVersion
|
appVersion: environment.appVersion
|
||||||
)
|
)
|
||||||
let response = try await makeAPIClient(for: environment).registerDevice(payload)
|
let response = try await makeAPIClient(for: environment).registerDevice(payload)
|
||||||
try await persistRegisteredDevice(response)
|
|
||||||
|
try await keychainService.save(response.deviceId, forKey: Self.deviceIdKey)
|
||||||
|
try await keychainService.save(response.bootstrapToken, forKey: Self.bootstrapTokenKey)
|
||||||
|
|
||||||
|
registeredDeviceId = response.deviceId
|
||||||
|
deviceRegistrationStatus = "Registered successfully at \(response.serverTime)."
|
||||||
} catch {
|
} catch {
|
||||||
deviceRegistrationStatus = "Registration failed: \(error.localizedDescription)"
|
deviceRegistrationStatus = "Registration failed: \(error.localizedDescription)"
|
||||||
}
|
}
|
||||||
@ -360,7 +365,7 @@ final class MacLibraryViewModel {
|
|||||||
|
|
||||||
do {
|
do {
|
||||||
let environment = try currentEnvironment()
|
let environment = try currentEnvironment()
|
||||||
let deviceId = try await currentOrRegisterDeviceId(for: environment)
|
let deviceId = try await currentDeviceId()
|
||||||
let response = try await makeAPIClient(for: environment).sendHeartbeat(
|
let response = try await makeAPIClient(for: environment).sendHeartbeat(
|
||||||
DeviceHeartbeatPayload(
|
DeviceHeartbeatPayload(
|
||||||
deviceId: deviceId,
|
deviceId: deviceId,
|
||||||
@ -416,7 +421,7 @@ final class MacLibraryViewModel {
|
|||||||
|
|
||||||
do {
|
do {
|
||||||
let environment = try currentEnvironment()
|
let environment = try currentEnvironment()
|
||||||
let deviceId = try await currentOrRegisterDeviceId(for: environment)
|
let deviceId = try await currentDeviceId()
|
||||||
let fileURL = URL(fileURLWithPath: initialTrack.localFilePath)
|
let fileURL = URL(fileURLWithPath: initialTrack.localFilePath)
|
||||||
|
|
||||||
try await withStoredFolderAccess {
|
try await withStoredFolderAccess {
|
||||||
@ -584,18 +589,13 @@ final class MacLibraryViewModel {
|
|||||||
private func restoreDeviceIdentity() async {
|
private func restoreDeviceIdentity() async {
|
||||||
do {
|
do {
|
||||||
let deviceId = try await keychainService.loadValue(forKey: Self.deviceIdKey)
|
let deviceId = try await keychainService.loadValue(forKey: Self.deviceIdKey)
|
||||||
let deviceAccessToken = try await keychainService.loadValue(
|
|
||||||
forKey: Self.deviceAccessTokenKey
|
|
||||||
)
|
|
||||||
let bootstrapToken = try await keychainService.loadValue(forKey: Self.bootstrapTokenKey)
|
let bootstrapToken = try await keychainService.loadValue(forKey: Self.bootstrapTokenKey)
|
||||||
|
|
||||||
registeredDeviceId = deviceId
|
registeredDeviceId = deviceId
|
||||||
|
|
||||||
if let deviceId {
|
if let deviceId {
|
||||||
if let deviceAccessToken, !deviceAccessToken.isEmpty {
|
if let bootstrapToken, !bootstrapToken.isEmpty {
|
||||||
deviceRegistrationStatus = "Registered locally."
|
deviceRegistrationStatus = "Registered locally."
|
||||||
} else if let bootstrapToken, !bootstrapToken.isEmpty {
|
|
||||||
deviceRegistrationStatus = "Device ID restored (\(deviceId)). Re-register to get a device access token."
|
|
||||||
} else {
|
} else {
|
||||||
deviceRegistrationStatus = "Device ID restored (\(deviceId)). Bootstrap token is missing."
|
deviceRegistrationStatus = "Device ID restored (\(deviceId)). Bootstrap token is missing."
|
||||||
}
|
}
|
||||||
@ -621,62 +621,23 @@ final class MacLibraryViewModel {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func currentOrRegisterDeviceId(
|
private func currentDeviceId() async throws -> String {
|
||||||
for environment: ServerEnvironment
|
|
||||||
) async throws -> String {
|
|
||||||
if let registeredDeviceId, !registeredDeviceId.isEmpty {
|
if let registeredDeviceId, !registeredDeviceId.isEmpty {
|
||||||
if let savedDeviceAccessToken = try await keychainService.loadValue(
|
return registeredDeviceId
|
||||||
forKey: Self.deviceAccessTokenKey
|
|
||||||
), !savedDeviceAccessToken.isEmpty {
|
|
||||||
return registeredDeviceId
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if let savedDeviceId = try await keychainService.loadValue(forKey: Self.deviceIdKey),
|
if let savedDeviceId = try await keychainService.loadValue(forKey: Self.deviceIdKey),
|
||||||
let savedDeviceAccessToken = try await keychainService.loadValue(
|
|
||||||
forKey: Self.deviceAccessTokenKey
|
|
||||||
),
|
|
||||||
!savedDeviceAccessToken.isEmpty,
|
|
||||||
!savedDeviceId.isEmpty
|
!savedDeviceId.isEmpty
|
||||||
{
|
{
|
||||||
registeredDeviceId = savedDeviceId
|
registeredDeviceId = savedDeviceId
|
||||||
return savedDeviceId
|
return savedDeviceId
|
||||||
}
|
}
|
||||||
|
|
||||||
let payload = DeviceRegistrationPayload(
|
throw BackendConnectionError.missingDeviceIdentity
|
||||||
platform: .macos,
|
|
||||||
deviceName: Self.currentDeviceName,
|
|
||||||
appVersion: environment.appVersion
|
|
||||||
)
|
|
||||||
let response = try await makeAPIClient(for: environment).registerDevice(payload)
|
|
||||||
try await persistRegisteredDevice(response)
|
|
||||||
return response.deviceId
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private func makeAPIClient(for environment: ServerEnvironment) -> URLSessionVelodyAPIClient {
|
private func makeAPIClient(for environment: ServerEnvironment) -> URLSessionVelodyAPIClient {
|
||||||
URLSessionVelodyAPIClient(
|
URLSessionVelodyAPIClient(environment: environment)
|
||||||
environment: environment,
|
|
||||||
deviceAccessTokenProvider: { [self] in
|
|
||||||
try await self.keychainService.loadValue(forKey: Self.deviceAccessTokenKey)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
private func persistRegisteredDevice(
|
|
||||||
_ response: DeviceRegistrationResponse
|
|
||||||
) async throws {
|
|
||||||
try await keychainService.save(response.deviceId, forKey: Self.deviceIdKey)
|
|
||||||
try await keychainService.save(
|
|
||||||
response.deviceAccessToken,
|
|
||||||
forKey: Self.deviceAccessTokenKey
|
|
||||||
)
|
|
||||||
try await keychainService.save(
|
|
||||||
response.bootstrapToken,
|
|
||||||
forKey: Self.bootstrapTokenKey
|
|
||||||
)
|
|
||||||
|
|
||||||
registeredDeviceId = response.deviceId
|
|
||||||
deviceRegistrationStatus = "Registered successfully at \(response.serverTime)."
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private func currentTrack(for trackID: String) -> LibraryTrack? {
|
private func currentTrack(for trackID: String) -> LibraryTrack? {
|
||||||
@ -886,7 +847,6 @@ final class MacLibraryViewModel {
|
|||||||
|
|
||||||
private static let serverURLDefaultsKey = "velody.server-environment.base-url"
|
private static let serverURLDefaultsKey = "velody.server-environment.base-url"
|
||||||
private static let deviceIdKey = "velody.device-id"
|
private static let deviceIdKey = "velody.device-id"
|
||||||
private static let deviceAccessTokenKey = "velody.device-access-token"
|
|
||||||
private static let bootstrapTokenKey = "velody.bootstrap-token"
|
private static let bootstrapTokenKey = "velody.bootstrap-token"
|
||||||
private static let playbackSessionDefaultsKey = "velody.playback.session"
|
private static let playbackSessionDefaultsKey = "velody.playback.session"
|
||||||
|
|
||||||
|
|||||||
@ -469,12 +469,7 @@ final class iPhoneLibraryViewModel {
|
|||||||
baseURL: ServerEnvironment.defaultLocalBaseURL,
|
baseURL: ServerEnvironment.defaultLocalBaseURL,
|
||||||
appVersion: "0.1.0"
|
appVersion: "0.1.0"
|
||||||
)
|
)
|
||||||
let apiClient = URLSessionVelodyAPIClient(
|
let apiClient = URLSessionVelodyAPIClient(environment: environment)
|
||||||
environment: environment,
|
|
||||||
deviceAccessTokenProvider: {
|
|
||||||
try await keychainService.loadValue(forKey: Self.deviceAccessTokenKey)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
let store = Self.makeRemoteLibraryStore()
|
let store = Self.makeRemoteLibraryStore()
|
||||||
let downloadStateStore = Self.makeRemoteTrackDownloadStateStore()
|
let downloadStateStore = Self.makeRemoteTrackDownloadStateStore()
|
||||||
let audioFileStore = Self.makeOfflineAudioFileStore()
|
let audioFileStore = Self.makeOfflineAudioFileStore()
|
||||||
@ -662,10 +657,7 @@ final class iPhoneLibraryViewModel {
|
|||||||
private func currentOrRegisterDeviceID() async throws -> String {
|
private func currentOrRegisterDeviceID() async throws -> String {
|
||||||
if let existingDeviceID = try await keychainService.loadValue(
|
if let existingDeviceID = try await keychainService.loadValue(
|
||||||
forKey: Self.deviceIDKey
|
forKey: Self.deviceIDKey
|
||||||
), !existingDeviceID.isEmpty,
|
), !existingDeviceID.isEmpty {
|
||||||
let existingDeviceAccessToken = try await keychainService.loadValue(
|
|
||||||
forKey: Self.deviceAccessTokenKey
|
|
||||||
), !existingDeviceAccessToken.isEmpty {
|
|
||||||
return existingDeviceID
|
return existingDeviceID
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -678,10 +670,6 @@ final class iPhoneLibraryViewModel {
|
|||||||
)
|
)
|
||||||
|
|
||||||
try await keychainService.save(response.deviceId, forKey: Self.deviceIDKey)
|
try await keychainService.save(response.deviceId, forKey: Self.deviceIDKey)
|
||||||
try await keychainService.save(
|
|
||||||
response.deviceAccessToken,
|
|
||||||
forKey: Self.deviceAccessTokenKey
|
|
||||||
)
|
|
||||||
try await keychainService.save(
|
try await keychainService.save(
|
||||||
response.bootstrapToken,
|
response.bootstrapToken,
|
||||||
forKey: Self.bootstrapTokenKey
|
forKey: Self.bootstrapTokenKey
|
||||||
@ -926,7 +914,6 @@ final class iPhoneLibraryViewModel {
|
|||||||
#endif
|
#endif
|
||||||
|
|
||||||
private static let deviceIDKey = "velody.iphone.device-id"
|
private static let deviceIDKey = "velody.iphone.device-id"
|
||||||
private static let deviceAccessTokenKey = "velody.iphone.device-access-token"
|
|
||||||
private static let bootstrapTokenKey = "velody.iphone.bootstrap-token"
|
private static let bootstrapTokenKey = "velody.iphone.bootstrap-token"
|
||||||
private static let remoteLibraryEmptyMessage = LibrarySectionMessage(
|
private static let remoteLibraryEmptyMessage = LibrarySectionMessage(
|
||||||
title: "No music synced yet",
|
title: "No music synced yet",
|
||||||
|
|||||||
@ -454,71 +454,6 @@ final class iPhoneLibraryViewModelPolishTests: XCTestCase {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@MainActor
|
|
||||||
final class iPhoneLibraryViewModelDeviceAuthTests: XCTestCase {
|
|
||||||
func testRefreshSyncStoresDeviceAccessTokenWhenRegistrationRuns() async throws {
|
|
||||||
let keychain = MemoryKeychainService()
|
|
||||||
let counter = RegisterCallCounter()
|
|
||||||
let viewModel = makeViewModel(
|
|
||||||
remoteTracks: [
|
|
||||||
makeRemoteTrack(
|
|
||||||
trackId: "remote-auth-store",
|
|
||||||
assetId: "asset-auth-store",
|
|
||||||
title: "Store Token"
|
|
||||||
),
|
|
||||||
],
|
|
||||||
apiClient: TestRegisterAPIClient(counter: counter),
|
|
||||||
keychainService: keychain
|
|
||||||
)
|
|
||||||
|
|
||||||
await viewModel.loadIfNeeded()
|
|
||||||
await viewModel.refreshSync()
|
|
||||||
|
|
||||||
let storedDeviceID = try await keychain.loadValue(forKey: "velody.iphone.device-id")
|
|
||||||
let storedDeviceAccessToken = try await keychain.loadValue(
|
|
||||||
forKey: "velody.iphone.device-access-token"
|
|
||||||
)
|
|
||||||
|
|
||||||
XCTAssertEqual(await counter.count, 1)
|
|
||||||
XCTAssertFalse((storedDeviceID ?? "").isEmpty)
|
|
||||||
XCTAssertFalse((storedDeviceAccessToken ?? "").isEmpty)
|
|
||||||
}
|
|
||||||
|
|
||||||
func testExistingDeviceWithoutAccessTokenReregistersCleanly() async throws {
|
|
||||||
let keychain = MemoryKeychainService()
|
|
||||||
let legacyDeviceID = "legacy-device-id"
|
|
||||||
try await keychain.save(legacyDeviceID, forKey: "velody.iphone.device-id")
|
|
||||||
try await keychain.save(
|
|
||||||
"legacy-bootstrap-token",
|
|
||||||
forKey: "velody.iphone.bootstrap-token"
|
|
||||||
)
|
|
||||||
let counter = RegisterCallCounter()
|
|
||||||
let viewModel = makeViewModel(
|
|
||||||
remoteTracks: [
|
|
||||||
makeRemoteTrack(
|
|
||||||
trackId: "remote-auth-reregister",
|
|
||||||
assetId: "asset-auth-reregister",
|
|
||||||
title: "Re-register Token"
|
|
||||||
),
|
|
||||||
],
|
|
||||||
apiClient: TestRegisterAPIClient(counter: counter),
|
|
||||||
keychainService: keychain
|
|
||||||
)
|
|
||||||
|
|
||||||
await viewModel.loadIfNeeded()
|
|
||||||
await viewModel.refreshSync()
|
|
||||||
|
|
||||||
let storedDeviceID = try await keychain.loadValue(forKey: "velody.iphone.device-id")
|
|
||||||
let storedDeviceAccessToken = try await keychain.loadValue(
|
|
||||||
forKey: "velody.iphone.device-access-token"
|
|
||||||
)
|
|
||||||
|
|
||||||
XCTAssertEqual(await counter.count, 1)
|
|
||||||
XCTAssertNotEqual(storedDeviceID, legacyDeviceID)
|
|
||||||
XCTAssertFalse((storedDeviceAccessToken ?? "").isEmpty)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private actor RegisterCallCounter {
|
private actor RegisterCallCounter {
|
||||||
private(set) var count = 0
|
private(set) var count = 0
|
||||||
|
|
||||||
|
|||||||
@ -1,90 +0,0 @@
|
|||||||
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");
|
|
||||||
@ -1,24 +0,0 @@
|
|||||||
-- AlterTable
|
|
||||||
ALTER TABLE "artwork_assets" ALTER COLUMN "id" DROP DEFAULT;
|
|
||||||
|
|
||||||
-- AlterTable
|
|
||||||
ALTER TABLE "audio_assets" ALTER COLUMN "id" DROP DEFAULT;
|
|
||||||
|
|
||||||
-- AlterTable
|
|
||||||
ALTER TABLE "device_sync_cursors" ALTER COLUMN "updated_at" DROP DEFAULT;
|
|
||||||
|
|
||||||
-- AlterTable
|
|
||||||
ALTER TABLE "devices" ALTER COLUMN "id" DROP DEFAULT,
|
|
||||||
ALTER COLUMN "updated_at" DROP DEFAULT;
|
|
||||||
|
|
||||||
-- AlterTable
|
|
||||||
ALTER TABLE "tracks" ALTER COLUMN "id" DROP DEFAULT,
|
|
||||||
ALTER COLUMN "updated_at" DROP DEFAULT;
|
|
||||||
|
|
||||||
-- AlterTable
|
|
||||||
ALTER TABLE "upload_sessions" ALTER COLUMN "id" DROP DEFAULT,
|
|
||||||
ALTER COLUMN "updated_at" DROP DEFAULT;
|
|
||||||
|
|
||||||
-- AlterTable
|
|
||||||
ALTER TABLE "users" ALTER COLUMN "id" DROP DEFAULT,
|
|
||||||
ALTER COLUMN "updated_at" DROP DEFAULT;
|
|
||||||
@ -1,7 +0,0 @@
|
|||||||
ALTER TABLE "devices"
|
|
||||||
ADD COLUMN "token_hash" TEXT,
|
|
||||||
ADD COLUMN "token_created_at" TIMESTAMP(3),
|
|
||||||
ADD COLUMN "token_last_used_at" TIMESTAMP(3),
|
|
||||||
ADD COLUMN "token_revoked_at" TIMESTAMP(3);
|
|
||||||
|
|
||||||
CREATE UNIQUE INDEX "devices_token_hash_key" ON "devices"("token_hash");
|
|
||||||
@ -31,10 +31,6 @@ model Device {
|
|||||||
deviceName String @map("device_name")
|
deviceName String @map("device_name")
|
||||||
appVersion String @map("app_version")
|
appVersion String @map("app_version")
|
||||||
installTokenHash String @map("install_token_hash")
|
installTokenHash String @map("install_token_hash")
|
||||||
tokenHash String? @unique @map("token_hash")
|
|
||||||
tokenCreatedAt DateTime? @map("token_created_at")
|
|
||||||
tokenLastUsedAt DateTime? @map("token_last_used_at")
|
|
||||||
tokenRevokedAt DateTime? @map("token_revoked_at")
|
|
||||||
lastSeenAt DateTime @map("last_seen_at")
|
lastSeenAt DateTime @map("last_seen_at")
|
||||||
createdAt DateTime @default(now()) @map("created_at")
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
updatedAt DateTime @updatedAt @map("updated_at")
|
updatedAt DateTime @updatedAt @map("updated_at")
|
||||||
@ -101,7 +97,7 @@ model AudioAsset {
|
|||||||
}
|
}
|
||||||
|
|
||||||
model ArtworkAsset {
|
model ArtworkAsset {
|
||||||
userId String @db.Uuid @map("user_id")
|
userId String? @db.Uuid @map("user_id")
|
||||||
id String @id @default(uuid()) @db.Uuid
|
id String @id @default(uuid()) @db.Uuid
|
||||||
sha256 String
|
sha256 String
|
||||||
storageKey String @unique @map("storage_key")
|
storageKey String @unique @map("storage_key")
|
||||||
@ -111,7 +107,7 @@ model ArtworkAsset {
|
|||||||
fileSizeBytes BigInt @map("file_size_bytes")
|
fileSizeBytes BigInt @map("file_size_bytes")
|
||||||
createdAt DateTime @default(now()) @map("created_at")
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
tracks Track[] @relation("TrackArtwork")
|
tracks Track[] @relation("TrackArtwork")
|
||||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade, onUpdate: Cascade)
|
user User? @relation(fields: [userId], references: [id], onDelete: Cascade, onUpdate: Cascade)
|
||||||
|
|
||||||
@@unique([userId, sha256])
|
@@unique([userId, sha256])
|
||||||
@@index([userId])
|
@@index([userId])
|
||||||
|
|||||||
@ -1,6 +1,5 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { AssetsModule } from './modules/assets/assets.module';
|
import { AssetsModule } from './modules/assets/assets.module';
|
||||||
import { AuthModule } from './modules/auth/auth.module';
|
|
||||||
import { ArtworkModule } from './modules/artwork/artwork.module';
|
import { ArtworkModule } from './modules/artwork/artwork.module';
|
||||||
import { AppConfigModule } from './modules/config/config.module';
|
import { AppConfigModule } from './modules/config/config.module';
|
||||||
import { DevicesModule } from './modules/devices/devices.module';
|
import { DevicesModule } from './modules/devices/devices.module';
|
||||||
@ -8,13 +7,10 @@ import { HealthModule } from './modules/health/health.module';
|
|||||||
import { LibraryModule } from './modules/library/library.module';
|
import { LibraryModule } from './modules/library/library.module';
|
||||||
import { SyncModule } from './modules/sync/sync.module';
|
import { SyncModule } from './modules/sync/sync.module';
|
||||||
import { UploadsModule } from './modules/uploads/uploads.module';
|
import { UploadsModule } from './modules/uploads/uploads.module';
|
||||||
import { UsersModule } from './modules/users/users.module';
|
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
AppConfigModule,
|
AppConfigModule,
|
||||||
AuthModule,
|
|
||||||
UsersModule,
|
|
||||||
AssetsModule,
|
AssetsModule,
|
||||||
ArtworkModule,
|
ArtworkModule,
|
||||||
HealthModule,
|
HealthModule,
|
||||||
|
|||||||
@ -1,12 +0,0 @@
|
|||||||
import { Injectable, NestMiddleware } from '@nestjs/common';
|
|
||||||
import type { NextFunction, Request, Response } from 'express';
|
|
||||||
import { RequestContextService } from './request-context.service';
|
|
||||||
|
|
||||||
@Injectable()
|
|
||||||
export class RequestContextMiddleware implements NestMiddleware {
|
|
||||||
constructor(private readonly requestContext: RequestContextService) {}
|
|
||||||
|
|
||||||
use(_request: Request, _response: Response, next: NextFunction): void {
|
|
||||||
this.requestContext.run(() => next());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,9 +0,0 @@
|
|||||||
import { Module } from '@nestjs/common';
|
|
||||||
import { RequestContextMiddleware } from './request-context.middleware';
|
|
||||||
import { RequestContextService } from './request-context.service';
|
|
||||||
|
|
||||||
@Module({
|
|
||||||
providers: [RequestContextService, RequestContextMiddleware],
|
|
||||||
exports: [RequestContextService, RequestContextMiddleware],
|
|
||||||
})
|
|
||||||
export class RequestContextModule {}
|
|
||||||
@ -1,39 +0,0 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
|
||||||
import { AsyncLocalStorage } from 'node:async_hooks';
|
|
||||||
|
|
||||||
export interface AuthenticatedDeviceContextValue {
|
|
||||||
deviceId: string;
|
|
||||||
userId: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface RequestContextState {
|
|
||||||
authenticatedDevice: AuthenticatedDeviceContextValue | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Injectable()
|
|
||||||
export class RequestContextService {
|
|
||||||
private readonly storage = new AsyncLocalStorage<RequestContextState>();
|
|
||||||
|
|
||||||
run<T>(callback: () => T): T {
|
|
||||||
return this.storage.run(
|
|
||||||
{
|
|
||||||
authenticatedDevice: null,
|
|
||||||
},
|
|
||||||
callback,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
getAuthenticatedDevice(): AuthenticatedDeviceContextValue | null {
|
|
||||||
return this.storage.getStore()?.authenticatedDevice ?? null;
|
|
||||||
}
|
|
||||||
|
|
||||||
setAuthenticatedDevice(device: AuthenticatedDeviceContextValue): void {
|
|
||||||
const store = this.storage.getStore();
|
|
||||||
|
|
||||||
if (!store) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
store.authenticatedDevice = device;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -5,23 +5,14 @@ import {
|
|||||||
Query,
|
Query,
|
||||||
Res,
|
Res,
|
||||||
StreamableFile,
|
StreamableFile,
|
||||||
UseGuards,
|
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import type { Response } from 'express';
|
import type { Response } from 'express';
|
||||||
import {
|
import { ApiOkResponse, ApiProduces, ApiTags } from '@nestjs/swagger';
|
||||||
ApiBearerAuth,
|
|
||||||
ApiOkResponse,
|
|
||||||
ApiProduces,
|
|
||||||
ApiTags,
|
|
||||||
} from '@nestjs/swagger';
|
|
||||||
import { createReadStream } from 'node:fs';
|
import { createReadStream } from 'node:fs';
|
||||||
import { AssetDownloadQueryDto } from '../assets/assets.dto';
|
import { AssetDownloadQueryDto } from '../assets/assets.dto';
|
||||||
import { DeviceAuthGuard } from '../auth/device-auth.guard';
|
|
||||||
import { ArtworkService } from './artwork.service';
|
import { ArtworkService } from './artwork.service';
|
||||||
|
|
||||||
@ApiTags('artwork')
|
@ApiTags('artwork')
|
||||||
@UseGuards(DeviceAuthGuard)
|
|
||||||
@ApiBearerAuth()
|
|
||||||
@Controller({
|
@Controller({
|
||||||
path: 'artwork',
|
path: 'artwork',
|
||||||
version: '1',
|
version: '1',
|
||||||
|
|||||||
@ -1,13 +1,11 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { PrismaModule } from '../../infrastructure/database/prisma.module';
|
import { PrismaModule } from '../../infrastructure/database/prisma.module';
|
||||||
import { AuthModule } from '../auth/auth.module';
|
|
||||||
import { StorageModule } from '../storage/storage.module';
|
import { StorageModule } from '../storage/storage.module';
|
||||||
import { UsersModule } from '../users/users.module';
|
|
||||||
import { ArtworkController } from './artwork.controller';
|
import { ArtworkController } from './artwork.controller';
|
||||||
import { ArtworkService } from './artwork.service';
|
import { ArtworkService } from './artwork.service';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [PrismaModule, StorageModule, UsersModule, AuthModule],
|
imports: [PrismaModule, StorageModule],
|
||||||
controllers: [ArtworkController],
|
controllers: [ArtworkController],
|
||||||
providers: [ArtworkService],
|
providers: [ArtworkService],
|
||||||
})
|
})
|
||||||
|
|||||||
@ -5,17 +5,22 @@ import { join } from 'node:path';
|
|||||||
import { ForbiddenException, NotFoundException } from '@nestjs/common';
|
import { ForbiddenException, NotFoundException } from '@nestjs/common';
|
||||||
import { PrismaService } from '../../infrastructure/database/prisma.service';
|
import { PrismaService } from '../../infrastructure/database/prisma.service';
|
||||||
import { AppConfigService } from '../config/config.service';
|
import { AppConfigService } from '../config/config.service';
|
||||||
import { DeviceAuthService } from '../auth/device-auth.service';
|
|
||||||
import { LocalFilesystemStorageService } from '../storage/storage.service';
|
import { LocalFilesystemStorageService } from '../storage/storage.service';
|
||||||
import { ArtworkService } from './artwork.service';
|
import { ArtworkService } from './artwork.service';
|
||||||
|
|
||||||
type MockState = ReturnType<typeof createPrismaMock>['state'];
|
type MockState = ReturnType<typeof createPrismaMock>['state'];
|
||||||
|
|
||||||
function createPrismaMock() {
|
function createPrismaMock() {
|
||||||
|
const devices = new Map<string, any>();
|
||||||
const artworkAssets = new Map<string, any>();
|
const artworkAssets = new Map<string, any>();
|
||||||
|
|
||||||
return {
|
return {
|
||||||
prismaMock: {
|
prismaMock: {
|
||||||
|
device: {
|
||||||
|
findUnique: jest.fn().mockImplementation(async ({ where }) => {
|
||||||
|
return devices.get(where.id) ?? null;
|
||||||
|
}),
|
||||||
|
},
|
||||||
artworkAsset: {
|
artworkAsset: {
|
||||||
findUnique: jest.fn().mockImplementation(async ({ where }) => {
|
findUnique: jest.fn().mockImplementation(async ({ where }) => {
|
||||||
return artworkAssets.get(where.id) ?? null;
|
return artworkAssets.get(where.id) ?? null;
|
||||||
@ -23,6 +28,7 @@ function createPrismaMock() {
|
|||||||
},
|
},
|
||||||
} as unknown as PrismaService,
|
} as unknown as PrismaService,
|
||||||
state: {
|
state: {
|
||||||
|
devices,
|
||||||
artworkAssets,
|
artworkAssets,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@ -40,28 +46,13 @@ describe('ArtworkService', () => {
|
|||||||
let state: MockState;
|
let state: MockState;
|
||||||
let storageRoot: string;
|
let storageRoot: string;
|
||||||
let storageService: LocalFilesystemStorageService;
|
let storageService: LocalFilesystemStorageService;
|
||||||
let ownerUserId: string;
|
|
||||||
let deviceAuthService: {
|
|
||||||
resolveCurrentDevice: jest.Mock;
|
|
||||||
};
|
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
const mock = createPrismaMock();
|
const mock = createPrismaMock();
|
||||||
state = mock.state;
|
state = mock.state;
|
||||||
storageRoot = await mkdtemp(join(tmpdir(), 'velody-artwork-spec-'));
|
storageRoot = await mkdtemp(join(tmpdir(), 'velody-artwork-spec-'));
|
||||||
storageService = new LocalFilesystemStorageService(createAppConfig(storageRoot));
|
storageService = new LocalFilesystemStorageService(createAppConfig(storageRoot));
|
||||||
ownerUserId = randomUUID();
|
service = new ArtworkService(mock.prismaMock, storageService);
|
||||||
deviceAuthService = {
|
|
||||||
resolveCurrentDevice: jest.fn().mockResolvedValue({
|
|
||||||
deviceId: randomUUID(),
|
|
||||||
userId: ownerUserId,
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
service = new ArtworkService(
|
|
||||||
mock.prismaMock,
|
|
||||||
storageService,
|
|
||||||
deviceAuthService as unknown as DeviceAuthService,
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(async () => {
|
afterEach(async () => {
|
||||||
@ -69,7 +60,8 @@ describe('ArtworkService', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('returns a local file path, content length, and mime type for the owning device user', async () => {
|
it('returns a local file path, content length, and mime type for the owning device user', async () => {
|
||||||
const userId = ownerUserId;
|
const userId = randomUUID();
|
||||||
|
const deviceId = randomUUID();
|
||||||
const artworkId = randomUUID();
|
const artworkId = randomUUID();
|
||||||
const storageKey = join('users', userId, 'artwork', `${artworkId}.png`);
|
const storageKey = join('users', userId, 'artwork', `${artworkId}.png`);
|
||||||
const bytes = Buffer.from(
|
const bytes = Buffer.from(
|
||||||
@ -77,66 +69,70 @@ describe('ArtworkService', () => {
|
|||||||
'base64',
|
'base64',
|
||||||
);
|
);
|
||||||
|
|
||||||
|
state.devices.set(deviceId, { id: deviceId, userId });
|
||||||
state.artworkAssets.set(artworkId, {
|
state.artworkAssets.set(artworkId, {
|
||||||
userId,
|
userId,
|
||||||
storageKey,
|
storageKey,
|
||||||
mimeType: 'image/png',
|
mimeType: 'image/png',
|
||||||
|
tracks: [{ userId }],
|
||||||
});
|
});
|
||||||
|
|
||||||
const filePath = storageService.resolve(storageKey);
|
const filePath = storageService.resolve(storageKey);
|
||||||
await storageService.ensureParentDirectory(filePath);
|
await storageService.ensureParentDirectory(filePath);
|
||||||
await writeFile(filePath, bytes);
|
await writeFile(filePath, bytes);
|
||||||
|
|
||||||
const download = await service.getOwnedArtworkDownload(artworkId, randomUUID());
|
const download = await service.getOwnedArtworkDownload(artworkId, deviceId);
|
||||||
|
|
||||||
expect(download.filePath).toBe(filePath);
|
expect(download.filePath).toBe(filePath);
|
||||||
expect(download.contentLength).toBe(bytes.length);
|
expect(download.contentLength).toBe(bytes.length);
|
||||||
expect(download.mimeType).toBe('image/png');
|
expect(download.mimeType).toBe('image/png');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('rejects cross-owner artwork downloads', async () => {
|
it('rejects download attempts from a different user device', async () => {
|
||||||
|
const ownerId = randomUUID();
|
||||||
const otherUserId = randomUUID();
|
const otherUserId = randomUUID();
|
||||||
|
const ownerDeviceId = randomUUID();
|
||||||
const artworkId = randomUUID();
|
const artworkId = randomUUID();
|
||||||
|
|
||||||
|
state.devices.set(ownerDeviceId, { id: ownerDeviceId, userId: otherUserId });
|
||||||
state.artworkAssets.set(artworkId, {
|
state.artworkAssets.set(artworkId, {
|
||||||
userId: otherUserId,
|
userId: ownerId,
|
||||||
storageKey: join('users', otherUserId, 'artwork', `${artworkId}.jpg`),
|
storageKey: join('users', ownerId, 'artwork', `${artworkId}.jpg`),
|
||||||
mimeType: 'image/jpeg',
|
mimeType: 'image/jpeg',
|
||||||
|
tracks: [{ userId: ownerId }],
|
||||||
});
|
});
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
service.getOwnedArtworkDownload(artworkId, randomUUID()),
|
service.getOwnedArtworkDownload(artworkId, ownerDeviceId),
|
||||||
).rejects.toBeInstanceOf(ForbiddenException);
|
).rejects.toBeInstanceOf(ForbiddenException);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('rejects foreign-owner devices before reading artwork', async () => {
|
|
||||||
deviceAuthService.resolveCurrentDevice.mockRejectedValueOnce(
|
|
||||||
new NotFoundException('Device not found'),
|
|
||||||
);
|
|
||||||
|
|
||||||
await expect(
|
|
||||||
service.getOwnedArtworkDownload(randomUUID(), randomUUID()),
|
|
||||||
).rejects.toBeInstanceOf(NotFoundException);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns not found when the artwork file is missing from storage', async () => {
|
it('returns not found when the artwork file is missing from storage', async () => {
|
||||||
const userId = ownerUserId;
|
const userId = randomUUID();
|
||||||
|
const deviceId = randomUUID();
|
||||||
const artworkId = randomUUID();
|
const artworkId = randomUUID();
|
||||||
|
|
||||||
|
state.devices.set(deviceId, { id: deviceId, userId });
|
||||||
state.artworkAssets.set(artworkId, {
|
state.artworkAssets.set(artworkId, {
|
||||||
userId,
|
userId,
|
||||||
storageKey: join('users', userId, 'artwork', `${artworkId}.png`),
|
storageKey: join('users', userId, 'artwork', `${artworkId}.png`),
|
||||||
mimeType: 'image/png',
|
mimeType: 'image/png',
|
||||||
|
tracks: [{ userId }],
|
||||||
});
|
});
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
service.getOwnedArtworkDownload(artworkId, randomUUID()),
|
service.getOwnedArtworkDownload(artworkId, deviceId),
|
||||||
).rejects.toBeInstanceOf(NotFoundException);
|
).rejects.toBeInstanceOf(NotFoundException);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns not found when the artwork asset does not exist', async () => {
|
it('returns not found when the artwork asset does not exist', async () => {
|
||||||
|
const userId = randomUUID();
|
||||||
|
const deviceId = randomUUID();
|
||||||
|
|
||||||
|
state.devices.set(deviceId, { id: deviceId, userId });
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
service.getOwnedArtworkDownload(randomUUID(), randomUUID()),
|
service.getOwnedArtworkDownload(randomUUID(), deviceId),
|
||||||
).rejects.toBeInstanceOf(NotFoundException);
|
).rejects.toBeInstanceOf(NotFoundException);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -5,7 +5,6 @@ import {
|
|||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { stat } from 'node:fs/promises';
|
import { stat } from 'node:fs/promises';
|
||||||
import { PrismaService } from '../../infrastructure/database/prisma.service';
|
import { PrismaService } from '../../infrastructure/database/prisma.service';
|
||||||
import { DeviceAuthService } from '../auth/device-auth.service';
|
|
||||||
import { LocalFilesystemStorageService } from '../storage/storage.service';
|
import { LocalFilesystemStorageService } from '../storage/storage.service';
|
||||||
|
|
||||||
export interface ArtworkDownload {
|
export interface ArtworkDownload {
|
||||||
@ -19,15 +18,22 @@ export class ArtworkService {
|
|||||||
constructor(
|
constructor(
|
||||||
private readonly prismaService: PrismaService,
|
private readonly prismaService: PrismaService,
|
||||||
private readonly storageService: LocalFilesystemStorageService,
|
private readonly storageService: LocalFilesystemStorageService,
|
||||||
private readonly deviceAuthService: DeviceAuthService,
|
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async getOwnedArtworkDownload(
|
async getOwnedArtworkDownload(
|
||||||
artworkId: string,
|
artworkId: string,
|
||||||
legacyDeviceId?: string,
|
deviceId: string,
|
||||||
): Promise<ArtworkDownload> {
|
): Promise<ArtworkDownload> {
|
||||||
const { userId: ownerUserId } =
|
const device = await this.prismaService.device.findUnique({
|
||||||
await this.deviceAuthService.resolveCurrentDevice(legacyDeviceId);
|
where: { id: deviceId },
|
||||||
|
select: {
|
||||||
|
userId: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!device) {
|
||||||
|
throw new NotFoundException('Device not found');
|
||||||
|
}
|
||||||
|
|
||||||
const artwork = await this.prismaService.artworkAsset.findUnique({
|
const artwork = await this.prismaService.artworkAsset.findUnique({
|
||||||
where: { id: artworkId },
|
where: { id: artworkId },
|
||||||
@ -35,6 +41,12 @@ export class ArtworkService {
|
|||||||
userId: true,
|
userId: true,
|
||||||
storageKey: true,
|
storageKey: true,
|
||||||
mimeType: true,
|
mimeType: true,
|
||||||
|
tracks: {
|
||||||
|
take: 1,
|
||||||
|
select: {
|
||||||
|
userId: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -42,7 +54,12 @@ export class ArtworkService {
|
|||||||
throw new NotFoundException('Artwork not found');
|
throw new NotFoundException('Artwork not found');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (artwork.userId !== ownerUserId) {
|
const ownerUserId = artwork.userId ?? artwork.tracks[0]?.userId;
|
||||||
|
if (!ownerUserId) {
|
||||||
|
throw new NotFoundException('Artwork not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ownerUserId !== device.userId) {
|
||||||
throw new ForbiddenException('Artwork does not belong to this device user.');
|
throw new ForbiddenException('Artwork does not belong to this device user.');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,27 +1,11 @@
|
|||||||
import {
|
import { Controller, Get, Param, Query, Res, StreamableFile } from '@nestjs/common';
|
||||||
Controller,
|
|
||||||
Get,
|
|
||||||
Param,
|
|
||||||
Query,
|
|
||||||
Res,
|
|
||||||
StreamableFile,
|
|
||||||
UseGuards,
|
|
||||||
} from '@nestjs/common';
|
|
||||||
import type { Response } from 'express';
|
import type { Response } from 'express';
|
||||||
import {
|
import { ApiOkResponse, ApiProduces, ApiTags } from '@nestjs/swagger';
|
||||||
ApiBearerAuth,
|
|
||||||
ApiOkResponse,
|
|
||||||
ApiProduces,
|
|
||||||
ApiTags,
|
|
||||||
} from '@nestjs/swagger';
|
|
||||||
import { createReadStream } from 'node:fs';
|
import { createReadStream } from 'node:fs';
|
||||||
import { DeviceAuthGuard } from '../auth/device-auth.guard';
|
|
||||||
import { AssetDownloadQueryDto } from './assets.dto';
|
import { AssetDownloadQueryDto } from './assets.dto';
|
||||||
import { AssetsService } from './assets.service';
|
import { AssetsService } from './assets.service';
|
||||||
|
|
||||||
@ApiTags('assets')
|
@ApiTags('assets')
|
||||||
@UseGuards(DeviceAuthGuard)
|
|
||||||
@ApiBearerAuth()
|
|
||||||
@Controller({
|
@Controller({
|
||||||
path: 'assets',
|
path: 'assets',
|
||||||
version: '1',
|
version: '1',
|
||||||
|
|||||||
@ -1,14 +1,8 @@
|
|||||||
import { ApiProperty } from '@nestjs/swagger';
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
import { IsOptional, IsUUID } from 'class-validator';
|
import { IsUUID } from 'class-validator';
|
||||||
|
|
||||||
export class AssetDownloadQueryDto {
|
export class AssetDownloadQueryDto {
|
||||||
@ApiProperty({
|
@ApiProperty({ format: 'uuid' })
|
||||||
format: 'uuid',
|
|
||||||
required: false,
|
|
||||||
description:
|
|
||||||
'Legacy migration fallback. Omit when Authorization: Bearer <deviceAccessToken> is provided.',
|
|
||||||
})
|
|
||||||
@IsOptional()
|
|
||||||
@IsUUID()
|
@IsUUID()
|
||||||
deviceId?: string;
|
deviceId!: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,13 +1,11 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { PrismaModule } from '../../infrastructure/database/prisma.module';
|
import { PrismaModule } from '../../infrastructure/database/prisma.module';
|
||||||
import { AuthModule } from '../auth/auth.module';
|
|
||||||
import { StorageModule } from '../storage/storage.module';
|
import { StorageModule } from '../storage/storage.module';
|
||||||
import { UsersModule } from '../users/users.module';
|
|
||||||
import { AssetsController } from './assets.controller';
|
import { AssetsController } from './assets.controller';
|
||||||
import { AssetsService } from './assets.service';
|
import { AssetsService } from './assets.service';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [PrismaModule, StorageModule, UsersModule, AuthModule],
|
imports: [PrismaModule, StorageModule],
|
||||||
controllers: [AssetsController],
|
controllers: [AssetsController],
|
||||||
providers: [AssetsService],
|
providers: [AssetsService],
|
||||||
})
|
})
|
||||||
|
|||||||
@ -5,17 +5,22 @@ import { join } from 'node:path';
|
|||||||
import { ForbiddenException, NotFoundException } from '@nestjs/common';
|
import { ForbiddenException, NotFoundException } from '@nestjs/common';
|
||||||
import { PrismaService } from '../../infrastructure/database/prisma.service';
|
import { PrismaService } from '../../infrastructure/database/prisma.service';
|
||||||
import { AppConfigService } from '../config/config.service';
|
import { AppConfigService } from '../config/config.service';
|
||||||
import { DeviceAuthService } from '../auth/device-auth.service';
|
|
||||||
import { LocalFilesystemStorageService } from '../storage/storage.service';
|
import { LocalFilesystemStorageService } from '../storage/storage.service';
|
||||||
import { AssetsService } from './assets.service';
|
import { AssetsService } from './assets.service';
|
||||||
|
|
||||||
type MockState = ReturnType<typeof createPrismaMock>['state'];
|
type MockState = ReturnType<typeof createPrismaMock>['state'];
|
||||||
|
|
||||||
function createPrismaMock() {
|
function createPrismaMock() {
|
||||||
|
const devices = new Map<string, any>();
|
||||||
const audioAssets = new Map<string, any>();
|
const audioAssets = new Map<string, any>();
|
||||||
|
|
||||||
return {
|
return {
|
||||||
prismaMock: {
|
prismaMock: {
|
||||||
|
device: {
|
||||||
|
findUnique: jest.fn().mockImplementation(async ({ where }) => {
|
||||||
|
return devices.get(where.id) ?? null;
|
||||||
|
}),
|
||||||
|
},
|
||||||
audioAsset: {
|
audioAsset: {
|
||||||
findUnique: jest.fn().mockImplementation(async ({ where }) => {
|
findUnique: jest.fn().mockImplementation(async ({ where }) => {
|
||||||
return audioAssets.get(where.id) ?? null;
|
return audioAssets.get(where.id) ?? null;
|
||||||
@ -23,6 +28,7 @@ function createPrismaMock() {
|
|||||||
},
|
},
|
||||||
} as unknown as PrismaService,
|
} as unknown as PrismaService,
|
||||||
state: {
|
state: {
|
||||||
|
devices,
|
||||||
audioAssets,
|
audioAssets,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@ -40,28 +46,13 @@ describe('AssetsService', () => {
|
|||||||
let state: MockState;
|
let state: MockState;
|
||||||
let storageRoot: string;
|
let storageRoot: string;
|
||||||
let storageService: LocalFilesystemStorageService;
|
let storageService: LocalFilesystemStorageService;
|
||||||
let ownerUserId: string;
|
|
||||||
let deviceAuthService: {
|
|
||||||
resolveCurrentDevice: jest.Mock;
|
|
||||||
};
|
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
const mock = createPrismaMock();
|
const mock = createPrismaMock();
|
||||||
state = mock.state;
|
state = mock.state;
|
||||||
storageRoot = await mkdtemp(join(tmpdir(), 'velody-assets-spec-'));
|
storageRoot = await mkdtemp(join(tmpdir(), 'velody-assets-spec-'));
|
||||||
storageService = new LocalFilesystemStorageService(createAppConfig(storageRoot));
|
storageService = new LocalFilesystemStorageService(createAppConfig(storageRoot));
|
||||||
ownerUserId = randomUUID();
|
service = new AssetsService(mock.prismaMock, storageService);
|
||||||
deviceAuthService = {
|
|
||||||
resolveCurrentDevice: jest.fn().mockResolvedValue({
|
|
||||||
deviceId: randomUUID(),
|
|
||||||
userId: ownerUserId,
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
service = new AssetsService(
|
|
||||||
mock.prismaMock,
|
|
||||||
storageService,
|
|
||||||
deviceAuthService as unknown as DeviceAuthService,
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(async () => {
|
afterEach(async () => {
|
||||||
@ -69,11 +60,13 @@ describe('AssetsService', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('returns a local file path and content length for the owning device user', async () => {
|
it('returns a local file path and content length for the owning device user', async () => {
|
||||||
const userId = ownerUserId;
|
const userId = randomUUID();
|
||||||
|
const deviceId = randomUUID();
|
||||||
const assetId = randomUUID();
|
const assetId = randomUUID();
|
||||||
const storageKey = join('users', userId, 'audio', 'owner.mp3');
|
const storageKey = join('users', userId, 'audio', 'owner.mp3');
|
||||||
const assetBytes = Buffer.from('ID3-owner-track', 'utf8');
|
const assetBytes = Buffer.from('ID3-owner-track', 'utf8');
|
||||||
|
|
||||||
|
state.devices.set(deviceId, { id: deviceId, userId });
|
||||||
state.audioAssets.set(assetId, {
|
state.audioAssets.set(assetId, {
|
||||||
id: assetId,
|
id: assetId,
|
||||||
userId,
|
userId,
|
||||||
@ -84,41 +77,36 @@ describe('AssetsService', () => {
|
|||||||
await storageService.ensureParentDirectory(filePath);
|
await storageService.ensureParentDirectory(filePath);
|
||||||
await writeFile(filePath, assetBytes);
|
await writeFile(filePath, assetBytes);
|
||||||
|
|
||||||
const download = await service.getOwnedAudioAssetDownload(assetId, randomUUID());
|
const download = await service.getOwnedAudioAssetDownload(assetId, deviceId);
|
||||||
|
|
||||||
expect(download.filePath).toBe(filePath);
|
expect(download.filePath).toBe(filePath);
|
||||||
expect(download.contentLength).toBe(assetBytes.length);
|
expect(download.contentLength).toBe(assetBytes.length);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('rejects cross-owner audio asset downloads', async () => {
|
it('rejects download attempts from a different user device', async () => {
|
||||||
|
const ownerId = randomUUID();
|
||||||
const otherUserId = randomUUID();
|
const otherUserId = randomUUID();
|
||||||
|
const ownerDeviceId = randomUUID();
|
||||||
const assetId = randomUUID();
|
const assetId = randomUUID();
|
||||||
|
|
||||||
|
state.devices.set(ownerDeviceId, { id: ownerDeviceId, userId: otherUserId });
|
||||||
state.audioAssets.set(assetId, {
|
state.audioAssets.set(assetId, {
|
||||||
id: assetId,
|
id: assetId,
|
||||||
userId: otherUserId,
|
userId: ownerId,
|
||||||
storageKey: join('users', otherUserId, 'audio', 'other.mp3'),
|
storageKey: join('users', ownerId, 'audio', 'owner.mp3'),
|
||||||
});
|
});
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
service.getOwnedAudioAssetDownload(assetId, randomUUID()),
|
service.getOwnedAudioAssetDownload(assetId, ownerDeviceId),
|
||||||
).rejects.toBeInstanceOf(ForbiddenException);
|
).rejects.toBeInstanceOf(ForbiddenException);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('rejects foreign-owner devices before reading audio assets', async () => {
|
|
||||||
deviceAuthService.resolveCurrentDevice.mockRejectedValueOnce(
|
|
||||||
new NotFoundException('Device not found'),
|
|
||||||
);
|
|
||||||
|
|
||||||
await expect(
|
|
||||||
service.getOwnedAudioAssetDownload(randomUUID(), randomUUID()),
|
|
||||||
).rejects.toBeInstanceOf(NotFoundException);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns not found when the asset file is missing from storage', async () => {
|
it('returns not found when the asset file is missing from storage', async () => {
|
||||||
const userId = ownerUserId;
|
const userId = randomUUID();
|
||||||
|
const deviceId = randomUUID();
|
||||||
const assetId = randomUUID();
|
const assetId = randomUUID();
|
||||||
|
|
||||||
|
state.devices.set(deviceId, { id: deviceId, userId });
|
||||||
state.audioAssets.set(assetId, {
|
state.audioAssets.set(assetId, {
|
||||||
id: assetId,
|
id: assetId,
|
||||||
userId,
|
userId,
|
||||||
@ -126,15 +114,11 @@ describe('AssetsService', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
service.getOwnedAudioAssetDownload(assetId, randomUUID()),
|
service.getOwnedAudioAssetDownload(assetId, deviceId),
|
||||||
).rejects.toBeInstanceOf(NotFoundException);
|
).rejects.toBeInstanceOf(NotFoundException);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns not found when the device does not exist', async () => {
|
it('returns not found when the device does not exist', async () => {
|
||||||
deviceAuthService.resolveCurrentDevice.mockRejectedValueOnce(
|
|
||||||
new NotFoundException('Device not found'),
|
|
||||||
);
|
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
service.getOwnedAudioAssetDownload(randomUUID(), randomUUID()),
|
service.getOwnedAudioAssetDownload(randomUUID(), randomUUID()),
|
||||||
).rejects.toBeInstanceOf(NotFoundException);
|
).rejects.toBeInstanceOf(NotFoundException);
|
||||||
|
|||||||
@ -5,7 +5,6 @@ import {
|
|||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { stat } from 'node:fs/promises';
|
import { stat } from 'node:fs/promises';
|
||||||
import { PrismaService } from '../../infrastructure/database/prisma.service';
|
import { PrismaService } from '../../infrastructure/database/prisma.service';
|
||||||
import { DeviceAuthService } from '../auth/device-auth.service';
|
|
||||||
import { LocalFilesystemStorageService } from '../storage/storage.service';
|
import { LocalFilesystemStorageService } from '../storage/storage.service';
|
||||||
|
|
||||||
export interface AudioAssetDownload {
|
export interface AudioAssetDownload {
|
||||||
@ -18,15 +17,22 @@ export class AssetsService {
|
|||||||
constructor(
|
constructor(
|
||||||
private readonly prismaService: PrismaService,
|
private readonly prismaService: PrismaService,
|
||||||
private readonly storageService: LocalFilesystemStorageService,
|
private readonly storageService: LocalFilesystemStorageService,
|
||||||
private readonly deviceAuthService: DeviceAuthService,
|
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async getOwnedAudioAssetDownload(
|
async getOwnedAudioAssetDownload(
|
||||||
assetId: string,
|
assetId: string,
|
||||||
legacyDeviceId?: string,
|
deviceId: string,
|
||||||
): Promise<AudioAssetDownload> {
|
): Promise<AudioAssetDownload> {
|
||||||
const { userId: ownerUserId } =
|
const device = await this.prismaService.device.findUnique({
|
||||||
await this.deviceAuthService.resolveCurrentDevice(legacyDeviceId);
|
where: { id: deviceId },
|
||||||
|
select: {
|
||||||
|
userId: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!device) {
|
||||||
|
throw new NotFoundException('Device not found');
|
||||||
|
}
|
||||||
|
|
||||||
const asset = await this.prismaService.audioAsset.findUnique({
|
const asset = await this.prismaService.audioAsset.findUnique({
|
||||||
where: { id: assetId },
|
where: { id: assetId },
|
||||||
@ -40,7 +46,7 @@ export class AssetsService {
|
|||||||
throw new NotFoundException('Audio asset not found');
|
throw new NotFoundException('Audio asset not found');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (asset.userId !== ownerUserId) {
|
if (asset.userId !== device.userId) {
|
||||||
throw new ForbiddenException('Audio asset does not belong to this device user.');
|
throw new ForbiddenException('Audio asset does not belong to this device user.');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,18 +0,0 @@
|
|||||||
import { MiddlewareConsumer, Module, NestModule } from '@nestjs/common';
|
|
||||||
import { PrismaModule } from '../../infrastructure/database/prisma.module';
|
|
||||||
import { RequestContextMiddleware } from '../../infrastructure/request-context/request-context.middleware';
|
|
||||||
import { RequestContextModule } from '../../infrastructure/request-context/request-context.module';
|
|
||||||
import { UsersModule } from '../users/users.module';
|
|
||||||
import { DeviceAuthGuard } from './device-auth.guard';
|
|
||||||
import { DeviceAuthService } from './device-auth.service';
|
|
||||||
|
|
||||||
@Module({
|
|
||||||
imports: [PrismaModule, RequestContextModule, UsersModule],
|
|
||||||
providers: [DeviceAuthService, DeviceAuthGuard],
|
|
||||||
exports: [DeviceAuthService, DeviceAuthGuard],
|
|
||||||
})
|
|
||||||
export class AuthModule implements NestModule {
|
|
||||||
configure(consumer: MiddlewareConsumer): void {
|
|
||||||
consumer.apply(RequestContextMiddleware).forRoutes('*');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,31 +0,0 @@
|
|||||||
import {
|
|
||||||
CanActivate,
|
|
||||||
ExecutionContext,
|
|
||||||
Injectable,
|
|
||||||
} from '@nestjs/common';
|
|
||||||
import type { Request } from 'express';
|
|
||||||
import { DeviceAuthService } from './device-auth.service';
|
|
||||||
|
|
||||||
@Injectable()
|
|
||||||
export class DeviceAuthGuard implements CanActivate {
|
|
||||||
constructor(private readonly deviceAuthService: DeviceAuthService) {}
|
|
||||||
|
|
||||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
|
||||||
const request = context.switchToHttp().getRequest<Request>();
|
|
||||||
const authorization = request.headers.authorization;
|
|
||||||
|
|
||||||
if (!authorization) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
const authenticatedDevice =
|
|
||||||
await this.deviceAuthService.authenticateAuthorizationHeader(
|
|
||||||
authorization,
|
|
||||||
);
|
|
||||||
|
|
||||||
(request as Request & { authenticatedDevice?: unknown }).authenticatedDevice =
|
|
||||||
authenticatedDevice;
|
|
||||||
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,131 +0,0 @@
|
|||||||
import { randomUUID } from 'node:crypto';
|
|
||||||
import {
|
|
||||||
BadRequestException,
|
|
||||||
NotFoundException,
|
|
||||||
UnauthorizedException,
|
|
||||||
} from '@nestjs/common';
|
|
||||||
import { RequestContextService } from '../../infrastructure/request-context/request-context.service';
|
|
||||||
import { OwnerContext } from '../users/owner-context.service';
|
|
||||||
import { DeviceAuthService } from './device-auth.service';
|
|
||||||
|
|
||||||
describe('DeviceAuthService', () => {
|
|
||||||
it('authenticates a valid bearer token and records it in request context', async () => {
|
|
||||||
const userId = randomUUID();
|
|
||||||
const deviceId = randomUUID();
|
|
||||||
const requestContext = new RequestContextService();
|
|
||||||
const ownerContext = {
|
|
||||||
resolve: jest.fn().mockResolvedValue({ userId }),
|
|
||||||
} as OwnerContext;
|
|
||||||
const service = new DeviceAuthService(
|
|
||||||
{
|
|
||||||
device: {
|
|
||||||
findUnique: jest.fn().mockResolvedValue({
|
|
||||||
id: deviceId,
|
|
||||||
userId,
|
|
||||||
tokenRevokedAt: null,
|
|
||||||
}),
|
|
||||||
update: jest.fn().mockResolvedValue({}),
|
|
||||||
},
|
|
||||||
} as any,
|
|
||||||
requestContext,
|
|
||||||
ownerContext,
|
|
||||||
);
|
|
||||||
const token = service.generateDeviceAccessToken();
|
|
||||||
|
|
||||||
await requestContext.run(async () => {
|
|
||||||
await expect(
|
|
||||||
service.authenticateAuthorizationHeader(`Bearer ${token}`),
|
|
||||||
).resolves.toEqual({
|
|
||||||
deviceId,
|
|
||||||
userId,
|
|
||||||
});
|
|
||||||
await expect(service.resolveCurrentDevice(randomUUID())).resolves.toEqual({
|
|
||||||
deviceId,
|
|
||||||
userId,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('rejects invalid or revoked tokens', async () => {
|
|
||||||
const requestContext = new RequestContextService();
|
|
||||||
const service = new DeviceAuthService(
|
|
||||||
{
|
|
||||||
device: {
|
|
||||||
findUnique: jest
|
|
||||||
.fn()
|
|
||||||
.mockResolvedValueOnce(null)
|
|
||||||
.mockResolvedValueOnce({
|
|
||||||
id: randomUUID(),
|
|
||||||
userId: randomUUID(),
|
|
||||||
tokenRevokedAt: new Date(),
|
|
||||||
}),
|
|
||||||
update: jest.fn(),
|
|
||||||
},
|
|
||||||
} as any,
|
|
||||||
requestContext,
|
|
||||||
{
|
|
||||||
resolve: jest.fn(),
|
|
||||||
} as any,
|
|
||||||
);
|
|
||||||
|
|
||||||
await expect(
|
|
||||||
requestContext.run(() =>
|
|
||||||
service.authenticateAuthorizationHeader('Bearer invalid-token'),
|
|
||||||
),
|
|
||||||
).rejects.toBeInstanceOf(UnauthorizedException);
|
|
||||||
await expect(
|
|
||||||
requestContext.run(() =>
|
|
||||||
service.authenticateAuthorizationHeader('Bearer revoked-token'),
|
|
||||||
),
|
|
||||||
).rejects.toBeInstanceOf(UnauthorizedException);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('falls back to legacy device ids only when no authenticated device is present', async () => {
|
|
||||||
const userId = randomUUID();
|
|
||||||
const deviceId = randomUUID();
|
|
||||||
const otherDeviceId = randomUUID();
|
|
||||||
const requestContext = new RequestContextService();
|
|
||||||
const ownerContext = {
|
|
||||||
resolve: jest.fn().mockResolvedValue({ userId }),
|
|
||||||
} as any;
|
|
||||||
const service = new DeviceAuthService(
|
|
||||||
{
|
|
||||||
device: {
|
|
||||||
findUnique: jest.fn().mockImplementation(async ({ where }) => {
|
|
||||||
if (where.id === deviceId) {
|
|
||||||
return {
|
|
||||||
id: deviceId,
|
|
||||||
userId,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (where.id === otherDeviceId) {
|
|
||||||
return {
|
|
||||||
id: otherDeviceId,
|
|
||||||
userId: randomUUID(),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}),
|
|
||||||
update: jest.fn(),
|
|
||||||
},
|
|
||||||
} as any,
|
|
||||||
requestContext,
|
|
||||||
ownerContext as OwnerContext,
|
|
||||||
);
|
|
||||||
|
|
||||||
await expect(
|
|
||||||
requestContext.run(() => service.resolveCurrentDevice()),
|
|
||||||
).rejects.toBeInstanceOf(BadRequestException);
|
|
||||||
await expect(
|
|
||||||
requestContext.run(() => service.resolveCurrentDevice(otherDeviceId)),
|
|
||||||
).rejects.toBeInstanceOf(NotFoundException);
|
|
||||||
await expect(
|
|
||||||
requestContext.run(() => service.resolveCurrentDevice(deviceId)),
|
|
||||||
).resolves.toEqual({
|
|
||||||
deviceId,
|
|
||||||
userId,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -1,123 +0,0 @@
|
|||||||
import {
|
|
||||||
BadRequestException,
|
|
||||||
Injectable,
|
|
||||||
NotFoundException,
|
|
||||||
UnauthorizedException,
|
|
||||||
} from '@nestjs/common';
|
|
||||||
import { createHash, randomBytes } from 'node:crypto';
|
|
||||||
import { PrismaService } from '../../infrastructure/database/prisma.service';
|
|
||||||
import {
|
|
||||||
AuthenticatedDeviceContextValue,
|
|
||||||
RequestContextService,
|
|
||||||
} from '../../infrastructure/request-context/request-context.service';
|
|
||||||
import { OwnerContext } from '../users/owner-context.service';
|
|
||||||
|
|
||||||
@Injectable()
|
|
||||||
export class DeviceAuthService {
|
|
||||||
constructor(
|
|
||||||
private readonly prismaService: PrismaService,
|
|
||||||
private readonly requestContext: RequestContextService,
|
|
||||||
private readonly ownerContext: OwnerContext,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
generateDeviceAccessToken(): string {
|
|
||||||
return randomBytes(32).toString('base64url');
|
|
||||||
}
|
|
||||||
|
|
||||||
hashDeviceAccessToken(token: string): string {
|
|
||||||
return createHash('sha256').update(token).digest('hex');
|
|
||||||
}
|
|
||||||
|
|
||||||
async authenticateAuthorizationHeader(
|
|
||||||
authorizationHeader: string | string[],
|
|
||||||
): Promise<AuthenticatedDeviceContextValue> {
|
|
||||||
const token = this.extractBearerToken(authorizationHeader);
|
|
||||||
const tokenHash = this.hashDeviceAccessToken(token);
|
|
||||||
const device = await this.prismaService.device.findUnique({
|
|
||||||
where: {
|
|
||||||
tokenHash,
|
|
||||||
},
|
|
||||||
select: {
|
|
||||||
id: true,
|
|
||||||
userId: true,
|
|
||||||
tokenRevokedAt: true,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!device || device.tokenRevokedAt) {
|
|
||||||
throw new UnauthorizedException('Invalid device access token');
|
|
||||||
}
|
|
||||||
|
|
||||||
await this.prismaService.device.update({
|
|
||||||
where: {
|
|
||||||
id: device.id,
|
|
||||||
},
|
|
||||||
data: {
|
|
||||||
tokenLastUsedAt: new Date(),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const authenticatedDevice = {
|
|
||||||
deviceId: device.id,
|
|
||||||
userId: device.userId,
|
|
||||||
};
|
|
||||||
|
|
||||||
this.requestContext.setAuthenticatedDevice(authenticatedDevice);
|
|
||||||
|
|
||||||
return authenticatedDevice;
|
|
||||||
}
|
|
||||||
|
|
||||||
async resolveCurrentDevice(
|
|
||||||
legacyDeviceId?: string,
|
|
||||||
): Promise<AuthenticatedDeviceContextValue> {
|
|
||||||
const authenticatedDevice = this.requestContext.getAuthenticatedDevice();
|
|
||||||
|
|
||||||
if (authenticatedDevice) {
|
|
||||||
return authenticatedDevice;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!legacyDeviceId) {
|
|
||||||
throw new BadRequestException(
|
|
||||||
'deviceId is required when Authorization is missing.',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const owner = await this.ownerContext.resolve();
|
|
||||||
const device = await this.prismaService.device.findUnique({
|
|
||||||
where: {
|
|
||||||
id: legacyDeviceId,
|
|
||||||
},
|
|
||||||
select: {
|
|
||||||
id: true,
|
|
||||||
userId: true,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!device || device.userId !== owner.userId) {
|
|
||||||
throw new NotFoundException('Device not found');
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
deviceId: device.id,
|
|
||||||
userId: device.userId,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
private extractBearerToken(authorizationHeader: string | string[]): string {
|
|
||||||
const normalizedHeader = Array.isArray(authorizationHeader)
|
|
||||||
? authorizationHeader[0]
|
|
||||||
: authorizationHeader;
|
|
||||||
|
|
||||||
if (!normalizedHeader) {
|
|
||||||
throw new UnauthorizedException('Invalid device access token');
|
|
||||||
}
|
|
||||||
|
|
||||||
const match = normalizedHeader.match(/^Bearer\s+(.+)$/i);
|
|
||||||
|
|
||||||
if (!match || !match[1]?.trim()) {
|
|
||||||
throw new UnauthorizedException('Invalid device access token');
|
|
||||||
}
|
|
||||||
|
|
||||||
return match[1].trim();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,11 +1,5 @@
|
|||||||
import { Body, Controller, Post, UseGuards } from '@nestjs/common';
|
import { Body, Controller, Post } from '@nestjs/common';
|
||||||
import {
|
import { ApiCreatedResponse, ApiOkResponse, ApiTags } from '@nestjs/swagger';
|
||||||
ApiBearerAuth,
|
|
||||||
ApiCreatedResponse,
|
|
||||||
ApiOkResponse,
|
|
||||||
ApiTags,
|
|
||||||
} from '@nestjs/swagger';
|
|
||||||
import { DeviceAuthGuard } from '../auth/device-auth.guard';
|
|
||||||
import {
|
import {
|
||||||
DeviceHeartbeatRequestDto,
|
DeviceHeartbeatRequestDto,
|
||||||
DeviceHeartbeatResponseDto,
|
DeviceHeartbeatResponseDto,
|
||||||
@ -31,8 +25,6 @@ export class DevicesController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Post('heartbeat')
|
@Post('heartbeat')
|
||||||
@UseGuards(DeviceAuthGuard)
|
|
||||||
@ApiBearerAuth()
|
|
||||||
@ApiOkResponse({ type: DeviceHeartbeatResponseDto })
|
@ApiOkResponse({ type: DeviceHeartbeatResponseDto })
|
||||||
async heartbeat(
|
async heartbeat(
|
||||||
@Body() body: DeviceHeartbeatRequestDto,
|
@Body() body: DeviceHeartbeatRequestDto,
|
||||||
|
|||||||
@ -1,12 +1,6 @@
|
|||||||
import { ApiProperty } from '@nestjs/swagger';
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
import { DevicePlatform } from '@prisma/client';
|
import { DevicePlatform } from '@prisma/client';
|
||||||
import {
|
import { IsEnum, IsString, IsUUID, MinLength } from 'class-validator';
|
||||||
IsEnum,
|
|
||||||
IsOptional,
|
|
||||||
IsString,
|
|
||||||
IsUUID,
|
|
||||||
MinLength,
|
|
||||||
} from 'class-validator';
|
|
||||||
|
|
||||||
export class RegisterDeviceRequestDto {
|
export class RegisterDeviceRequestDto {
|
||||||
@ApiProperty({ enum: DevicePlatform, example: DevicePlatform.MACOS })
|
@ApiProperty({ enum: DevicePlatform, example: DevicePlatform.MACOS })
|
||||||
@ -28,12 +22,6 @@ export class RegisterDeviceResponseDto {
|
|||||||
@ApiProperty({ format: 'uuid' })
|
@ApiProperty({ format: 'uuid' })
|
||||||
deviceId!: string;
|
deviceId!: string;
|
||||||
|
|
||||||
@ApiProperty({
|
|
||||||
description:
|
|
||||||
'Raw device access token returned only during registration. Store it securely.',
|
|
||||||
})
|
|
||||||
deviceAccessToken!: string;
|
|
||||||
|
|
||||||
@ApiProperty()
|
@ApiProperty()
|
||||||
bootstrapToken!: string;
|
bootstrapToken!: string;
|
||||||
|
|
||||||
@ -42,15 +30,9 @@ export class RegisterDeviceResponseDto {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export class DeviceHeartbeatRequestDto {
|
export class DeviceHeartbeatRequestDto {
|
||||||
@ApiProperty({
|
@ApiProperty({ format: 'uuid' })
|
||||||
format: 'uuid',
|
|
||||||
required: false,
|
|
||||||
description:
|
|
||||||
'Legacy migration fallback. Omit when Authorization: Bearer <deviceAccessToken> is provided.',
|
|
||||||
})
|
|
||||||
@IsOptional()
|
|
||||||
@IsUUID()
|
@IsUUID()
|
||||||
deviceId?: string;
|
deviceId!: string;
|
||||||
|
|
||||||
@ApiProperty({ example: '0.1.0' })
|
@ApiProperty({ example: '0.1.0' })
|
||||||
@IsString()
|
@IsString()
|
||||||
|
|||||||
@ -1,12 +1,11 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { PrismaModule } from '../../infrastructure/database/prisma.module';
|
import { PrismaModule } from '../../infrastructure/database/prisma.module';
|
||||||
import { AuthModule } from '../auth/auth.module';
|
|
||||||
import { DevicesController } from './devices.controller';
|
import { DevicesController } from './devices.controller';
|
||||||
import { DevicesService } from './devices.service';
|
import { DevicesService } from './devices.service';
|
||||||
import { UsersModule } from '../users/users.module';
|
import { UsersModule } from '../users/users.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [PrismaModule, UsersModule, AuthModule],
|
imports: [PrismaModule, UsersModule],
|
||||||
controllers: [DevicesController],
|
controllers: [DevicesController],
|
||||||
providers: [DevicesService],
|
providers: [DevicesService],
|
||||||
exports: [DevicesService],
|
exports: [DevicesService],
|
||||||
|
|||||||
@ -1,86 +0,0 @@
|
|||||||
import { randomUUID } from 'node:crypto';
|
|
||||||
import { NotFoundException } from '@nestjs/common';
|
|
||||||
import { DeviceAuthService } from '../auth/device-auth.service';
|
|
||||||
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 deviceAuthService = {
|
|
||||||
generateDeviceAccessToken: jest.fn().mockReturnValue('device-access-token'),
|
|
||||||
hashDeviceAccessToken: jest.fn().mockReturnValue('device-token-hash'),
|
|
||||||
resolveCurrentDevice: jest.fn(),
|
|
||||||
} as any;
|
|
||||||
const service = new DevicesService(
|
|
||||||
prismaService,
|
|
||||||
ownerContext as OwnerContext,
|
|
||||||
deviceAuthService as DeviceAuthService,
|
|
||||||
);
|
|
||||||
|
|
||||||
const response = 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',
|
|
||||||
tokenHash: 'device-token-hash',
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
expect(response.deviceAccessToken).toBe('device-access-token');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('rejects heartbeat updates for a foreign-owner device', async () => {
|
|
||||||
const deviceId = randomUUID();
|
|
||||||
const prismaService = {
|
|
||||||
device: {
|
|
||||||
update: jest.fn(),
|
|
||||||
},
|
|
||||||
} as any;
|
|
||||||
const ownerContext = {
|
|
||||||
resolve: jest.fn(),
|
|
||||||
} as any;
|
|
||||||
const deviceAuthService = {
|
|
||||||
generateDeviceAccessToken: jest.fn(),
|
|
||||||
hashDeviceAccessToken: jest.fn(),
|
|
||||||
resolveCurrentDevice: jest
|
|
||||||
.fn()
|
|
||||||
.mockRejectedValue(new NotFoundException('Device not found')),
|
|
||||||
} as any;
|
|
||||||
const service = new DevicesService(
|
|
||||||
prismaService,
|
|
||||||
ownerContext as OwnerContext,
|
|
||||||
deviceAuthService as DeviceAuthService,
|
|
||||||
);
|
|
||||||
|
|
||||||
await expect(
|
|
||||||
service.heartbeat({
|
|
||||||
deviceId,
|
|
||||||
appVersion: '0.1.1',
|
|
||||||
}),
|
|
||||||
).rejects.toBeInstanceOf(NotFoundException);
|
|
||||||
expect(prismaService.device.update).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -1,8 +1,7 @@
|
|||||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||||
import { createHash, randomBytes } from 'node:crypto';
|
import { createHash, randomBytes } from 'node:crypto';
|
||||||
import { PrismaService } from '../../infrastructure/database/prisma.service';
|
import { PrismaService } from '../../infrastructure/database/prisma.service';
|
||||||
import { DeviceAuthService } from '../auth/device-auth.service';
|
import { DefaultUserService } from '../users/default-user.service';
|
||||||
import { OwnerContext } from '../users/owner-context.service';
|
|
||||||
import {
|
import {
|
||||||
DeviceHeartbeatRequestDto,
|
DeviceHeartbeatRequestDto,
|
||||||
DeviceHeartbeatResponseDto,
|
DeviceHeartbeatResponseDto,
|
||||||
@ -14,38 +13,31 @@ import {
|
|||||||
export class DevicesService {
|
export class DevicesService {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly prismaService: PrismaService,
|
private readonly prismaService: PrismaService,
|
||||||
private readonly ownerContext: OwnerContext,
|
private readonly defaultUserService: DefaultUserService,
|
||||||
private readonly deviceAuthService: DeviceAuthService,
|
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async register(
|
async register(
|
||||||
body: RegisterDeviceRequestDto,
|
body: RegisterDeviceRequestDto,
|
||||||
): Promise<RegisterDeviceResponseDto> {
|
): Promise<RegisterDeviceResponseDto> {
|
||||||
const bootstrapToken = randomBytes(24).toString('hex');
|
const bootstrapToken = randomBytes(24).toString('hex');
|
||||||
const deviceAccessToken = this.deviceAuthService.generateDeviceAccessToken();
|
|
||||||
const installTokenHash = createHash('sha256')
|
const installTokenHash = createHash('sha256')
|
||||||
.update(bootstrapToken)
|
.update(bootstrapToken)
|
||||||
.digest('hex');
|
.digest('hex');
|
||||||
const tokenHash =
|
const defaultUser = await this.defaultUserService.getOrCreateDefaultUser();
|
||||||
this.deviceAuthService.hashDeviceAccessToken(deviceAccessToken);
|
|
||||||
const owner = await this.ownerContext.resolve();
|
|
||||||
|
|
||||||
const device = await this.prismaService.device.create({
|
const device = await this.prismaService.device.create({
|
||||||
data: {
|
data: {
|
||||||
userId: owner.userId,
|
userId: defaultUser.id,
|
||||||
platform: body.platform,
|
platform: body.platform,
|
||||||
deviceName: body.deviceName,
|
deviceName: body.deviceName,
|
||||||
appVersion: body.appVersion,
|
appVersion: body.appVersion,
|
||||||
installTokenHash,
|
installTokenHash,
|
||||||
tokenHash,
|
|
||||||
tokenCreatedAt: new Date(),
|
|
||||||
lastSeenAt: new Date(),
|
lastSeenAt: new Date(),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
deviceId: device.id,
|
deviceId: device.id,
|
||||||
deviceAccessToken,
|
|
||||||
bootstrapToken,
|
bootstrapToken,
|
||||||
serverTime: new Date().toISOString(),
|
serverTime: new Date().toISOString(),
|
||||||
};
|
};
|
||||||
@ -54,10 +46,16 @@ export class DevicesService {
|
|||||||
async heartbeat(
|
async heartbeat(
|
||||||
body: DeviceHeartbeatRequestDto,
|
body: DeviceHeartbeatRequestDto,
|
||||||
): Promise<DeviceHeartbeatResponseDto> {
|
): Promise<DeviceHeartbeatResponseDto> {
|
||||||
const device = await this.deviceAuthService.resolveCurrentDevice(body.deviceId);
|
const existing = await this.prismaService.device.findUnique({
|
||||||
|
where: { id: body.deviceId },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!existing) {
|
||||||
|
throw new NotFoundException('Device not found');
|
||||||
|
}
|
||||||
|
|
||||||
await this.prismaService.device.update({
|
await this.prismaService.device.update({
|
||||||
where: { id: device.deviceId },
|
where: { id: body.deviceId },
|
||||||
data: {
|
data: {
|
||||||
appVersion: body.appVersion,
|
appVersion: body.appVersion,
|
||||||
lastSeenAt: new Date(),
|
lastSeenAt: new Date(),
|
||||||
@ -69,9 +67,4 @@ export class DevicesService {
|
|||||||
serverTime: new Date().toISOString(),
|
serverTime: new Date().toISOString(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private async resolveCurrentOwnerUserId(): Promise<string> {
|
|
||||||
const owner = await this.ownerContext.resolve();
|
|
||||||
return owner.userId;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,6 +1,5 @@
|
|||||||
import { Controller, Get, Query, UseGuards } from '@nestjs/common';
|
import { Controller, Get, Query } from '@nestjs/common';
|
||||||
import { ApiBearerAuth, ApiOkResponse, ApiTags } from '@nestjs/swagger';
|
import { ApiOkResponse, ApiTags } from '@nestjs/swagger';
|
||||||
import { DeviceAuthGuard } from '../auth/device-auth.guard';
|
|
||||||
import {
|
import {
|
||||||
LibraryTracksQueryDto,
|
LibraryTracksQueryDto,
|
||||||
LibraryTracksResponseDto,
|
LibraryTracksResponseDto,
|
||||||
@ -8,8 +7,6 @@ import {
|
|||||||
import { LibraryService } from './library.service';
|
import { LibraryService } from './library.service';
|
||||||
|
|
||||||
@ApiTags('library')
|
@ApiTags('library')
|
||||||
@UseGuards(DeviceAuthGuard)
|
|
||||||
@ApiBearerAuth()
|
|
||||||
@Controller({
|
@Controller({
|
||||||
path: 'library',
|
path: 'library',
|
||||||
version: '1',
|
version: '1',
|
||||||
|
|||||||
@ -1,16 +1,10 @@
|
|||||||
import { ApiProperty } from '@nestjs/swagger';
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
import { IsOptional, IsUUID } from 'class-validator';
|
import { IsUUID } from 'class-validator';
|
||||||
|
|
||||||
export class LibraryTracksQueryDto {
|
export class LibraryTracksQueryDto {
|
||||||
@ApiProperty({
|
@ApiProperty({ format: 'uuid' })
|
||||||
format: 'uuid',
|
|
||||||
required: false,
|
|
||||||
description:
|
|
||||||
'Legacy migration fallback. Omit when Authorization: Bearer <deviceAccessToken> is provided.',
|
|
||||||
})
|
|
||||||
@IsOptional()
|
|
||||||
@IsUUID()
|
@IsUUID()
|
||||||
deviceId?: string;
|
deviceId!: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class RemoteArtworkDto {
|
export class RemoteArtworkDto {
|
||||||
|
|||||||
@ -1,12 +1,11 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { PrismaModule } from '../../infrastructure/database/prisma.module';
|
import { PrismaModule } from '../../infrastructure/database/prisma.module';
|
||||||
import { AuthModule } from '../auth/auth.module';
|
|
||||||
import { UsersModule } from '../users/users.module';
|
import { UsersModule } from '../users/users.module';
|
||||||
import { LibraryController } from './library.controller';
|
import { LibraryController } from './library.controller';
|
||||||
import { LibraryService } from './library.service';
|
import { LibraryService } from './library.service';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [PrismaModule, UsersModule, AuthModule],
|
imports: [PrismaModule, UsersModule],
|
||||||
controllers: [LibraryController],
|
controllers: [LibraryController],
|
||||||
providers: [LibraryService],
|
providers: [LibraryService],
|
||||||
exports: [LibraryService],
|
exports: [LibraryService],
|
||||||
|
|||||||
@ -2,8 +2,7 @@ import { randomUUID } from 'node:crypto';
|
|||||||
import { NotFoundException } from '@nestjs/common';
|
import { NotFoundException } from '@nestjs/common';
|
||||||
import { Test } from '@nestjs/testing';
|
import { Test } from '@nestjs/testing';
|
||||||
import { PrismaService } from '../../infrastructure/database/prisma.service';
|
import { PrismaService } from '../../infrastructure/database/prisma.service';
|
||||||
import { DeviceAuthService } from '../auth/device-auth.service';
|
import { DefaultUserService } from '../users/default-user.service';
|
||||||
import { OwnerContext } from '../users/owner-context.service';
|
|
||||||
import { LibraryService } from './library.service';
|
import { LibraryService } from './library.service';
|
||||||
|
|
||||||
function createPrismaMock() {
|
function createPrismaMock() {
|
||||||
@ -56,22 +55,10 @@ function createPrismaMock() {
|
|||||||
describe('LibraryService', () => {
|
describe('LibraryService', () => {
|
||||||
let libraryService: LibraryService;
|
let libraryService: LibraryService;
|
||||||
let state: ReturnType<typeof createPrismaMock>['state'];
|
let state: ReturnType<typeof createPrismaMock>['state'];
|
||||||
let ownerContextMock: {
|
|
||||||
resolve: jest.Mock;
|
|
||||||
};
|
|
||||||
let deviceAuthServiceMock: {
|
|
||||||
resolveCurrentDevice: jest.Mock;
|
|
||||||
};
|
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
const { prismaMock, state: nextState } = createPrismaMock();
|
const { prismaMock, state: nextState } = createPrismaMock();
|
||||||
state = nextState;
|
state = nextState;
|
||||||
ownerContextMock = {
|
|
||||||
resolve: jest.fn(),
|
|
||||||
};
|
|
||||||
deviceAuthServiceMock = {
|
|
||||||
resolveCurrentDevice: jest.fn(),
|
|
||||||
};
|
|
||||||
|
|
||||||
const moduleRef = await Test.createTestingModule({
|
const moduleRef = await Test.createTestingModule({
|
||||||
providers: [
|
providers: [
|
||||||
@ -81,12 +68,8 @@ describe('LibraryService', () => {
|
|||||||
useValue: prismaMock,
|
useValue: prismaMock,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
provide: OwnerContext,
|
provide: DefaultUserService,
|
||||||
useValue: ownerContextMock,
|
useValue: {},
|
||||||
},
|
|
||||||
{
|
|
||||||
provide: DeviceAuthService,
|
|
||||||
useValue: deviceAuthServiceMock,
|
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
}).compile();
|
}).compile();
|
||||||
@ -94,58 +77,6 @@ describe('LibraryService', () => {
|
|||||||
libraryService = moduleRef.get(LibraryService);
|
libraryService = moduleRef.get(LibraryService);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns bootstrap tracks for the default owner only', async () => {
|
|
||||||
const defaultOwnerId = randomUUID();
|
|
||||||
const otherUserId = randomUUID();
|
|
||||||
|
|
||||||
ownerContextMock.resolve.mockResolvedValue({
|
|
||||||
userId: defaultOwnerId,
|
|
||||||
});
|
|
||||||
|
|
||||||
state.tracks.set(randomUUID(), {
|
|
||||||
id: randomUUID(),
|
|
||||||
userId: defaultOwnerId,
|
|
||||||
title: 'Bootstrap Track',
|
|
||||||
artist: 'Default Owner',
|
|
||||||
durationMs: 181000,
|
|
||||||
status: 'ACTIVE',
|
|
||||||
primaryAudioAssetId: null,
|
|
||||||
createdAt: new Date('2026-05-29T08:00:00.000Z'),
|
|
||||||
updatedAt: new Date('2026-05-29T08:01:00.000Z'),
|
|
||||||
});
|
|
||||||
state.tracks.set(randomUUID(), {
|
|
||||||
id: randomUUID(),
|
|
||||||
userId: defaultOwnerId,
|
|
||||||
title: 'Deleted Track',
|
|
||||||
artist: 'Default Owner',
|
|
||||||
durationMs: 181000,
|
|
||||||
status: 'DELETED',
|
|
||||||
primaryAudioAssetId: null,
|
|
||||||
createdAt: new Date('2026-05-29T08:05:00.000Z'),
|
|
||||||
updatedAt: new Date('2026-05-29T08:06:00.000Z'),
|
|
||||||
});
|
|
||||||
state.tracks.set(randomUUID(), {
|
|
||||||
id: randomUUID(),
|
|
||||||
userId: otherUserId,
|
|
||||||
title: 'Other User Track',
|
|
||||||
artist: 'Elsewhere',
|
|
||||||
durationMs: 182000,
|
|
||||||
status: 'ACTIVE',
|
|
||||||
primaryAudioAssetId: null,
|
|
||||||
createdAt: new Date('2026-05-29T08:10:00.000Z'),
|
|
||||||
updatedAt: new Date('2026-05-29T08:11:00.000Z'),
|
|
||||||
});
|
|
||||||
|
|
||||||
await expect(libraryService.getBootstrapTracks()).resolves.toEqual([
|
|
||||||
{
|
|
||||||
id: expect.any(String),
|
|
||||||
title: 'Bootstrap Track',
|
|
||||||
artist: 'Default Owner',
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
expect(ownerContextMock.resolve).toHaveBeenCalledTimes(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns only tracks owned by the requesting device user in created order', async () => {
|
it('returns only tracks owned by the requesting device user in created order', async () => {
|
||||||
const ownerId = randomUUID();
|
const ownerId = randomUUID();
|
||||||
const otherUserId = randomUUID();
|
const otherUserId = randomUUID();
|
||||||
@ -158,14 +89,7 @@ describe('LibraryService', () => {
|
|||||||
const otherTrackId = randomUUID();
|
const otherTrackId = randomUUID();
|
||||||
const otherAssetId = randomUUID();
|
const otherAssetId = randomUUID();
|
||||||
|
|
||||||
ownerContextMock.resolve.mockResolvedValue({
|
|
||||||
userId: ownerId,
|
|
||||||
});
|
|
||||||
state.devices.set(ownerDeviceId, { id: ownerDeviceId, userId: ownerId });
|
state.devices.set(ownerDeviceId, { id: ownerDeviceId, userId: ownerId });
|
||||||
deviceAuthServiceMock.resolveCurrentDevice.mockResolvedValue({
|
|
||||||
deviceId: ownerDeviceId,
|
|
||||||
userId: ownerId,
|
|
||||||
});
|
|
||||||
|
|
||||||
state.audioAssets.set(ownerAssetId, {
|
state.audioAssets.set(ownerAssetId, {
|
||||||
id: ownerAssetId,
|
id: ownerAssetId,
|
||||||
@ -262,51 +186,7 @@ describe('LibraryService', () => {
|
|||||||
it('returns an empty library when the user has no remote tracks', async () => {
|
it('returns an empty library when the user has no remote tracks', async () => {
|
||||||
const ownerId = randomUUID();
|
const ownerId = randomUUID();
|
||||||
const ownerDeviceId = randomUUID();
|
const ownerDeviceId = randomUUID();
|
||||||
ownerContextMock.resolve.mockResolvedValue({
|
|
||||||
userId: ownerId,
|
|
||||||
});
|
|
||||||
state.devices.set(ownerDeviceId, { id: ownerDeviceId, userId: ownerId });
|
state.devices.set(ownerDeviceId, { id: ownerDeviceId, userId: ownerId });
|
||||||
deviceAuthServiceMock.resolveCurrentDevice.mockResolvedValue({
|
|
||||||
deviceId: ownerDeviceId,
|
|
||||||
userId: ownerId,
|
|
||||||
});
|
|
||||||
|
|
||||||
await expect(
|
|
||||||
libraryService.getRemoteLibraryTracks(ownerDeviceId),
|
|
||||||
).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 });
|
|
||||||
deviceAuthServiceMock.resolveCurrentDevice.mockResolvedValue({
|
|
||||||
deviceId: 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(
|
await expect(
|
||||||
libraryService.getRemoteLibraryTracks(ownerDeviceId),
|
libraryService.getRemoteLibraryTracks(ownerDeviceId),
|
||||||
@ -314,45 +194,15 @@ describe('LibraryService', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('throws for an unknown device', async () => {
|
it('throws for an unknown device', async () => {
|
||||||
deviceAuthServiceMock.resolveCurrentDevice.mockRejectedValueOnce(
|
|
||||||
new NotFoundException('Device not found'),
|
|
||||||
);
|
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
libraryService.getRemoteLibraryTracks(randomUUID()),
|
libraryService.getRemoteLibraryTracks(randomUUID()),
|
||||||
).rejects.toBeInstanceOf(NotFoundException);
|
).rejects.toBeInstanceOf(NotFoundException);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('rejects cross-owner track access through a foreign-owner device', async () => {
|
|
||||||
const ownerId = randomUUID();
|
|
||||||
const otherUserId = randomUUID();
|
|
||||||
const foreignDeviceId = randomUUID();
|
|
||||||
|
|
||||||
ownerContextMock.resolve.mockResolvedValue({ userId: ownerId });
|
|
||||||
state.devices.set(foreignDeviceId, {
|
|
||||||
id: foreignDeviceId,
|
|
||||||
userId: otherUserId,
|
|
||||||
});
|
|
||||||
deviceAuthServiceMock.resolveCurrentDevice.mockRejectedValueOnce(
|
|
||||||
new NotFoundException('Device not found'),
|
|
||||||
);
|
|
||||||
|
|
||||||
await expect(
|
|
||||||
libraryService.getRemoteLibraryTracks(foreignDeviceId),
|
|
||||||
).rejects.toBeInstanceOf(NotFoundException);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('skips tracks without a primary audio asset', async () => {
|
it('skips tracks without a primary audio asset', async () => {
|
||||||
const ownerId = randomUUID();
|
const ownerId = randomUUID();
|
||||||
const ownerDeviceId = randomUUID();
|
const ownerDeviceId = randomUUID();
|
||||||
ownerContextMock.resolve.mockResolvedValue({
|
|
||||||
userId: ownerId,
|
|
||||||
});
|
|
||||||
state.devices.set(ownerDeviceId, { id: ownerDeviceId, userId: ownerId });
|
state.devices.set(ownerDeviceId, { id: ownerDeviceId, userId: ownerId });
|
||||||
deviceAuthServiceMock.resolveCurrentDevice.mockResolvedValue({
|
|
||||||
deviceId: ownerDeviceId,
|
|
||||||
userId: ownerId,
|
|
||||||
});
|
|
||||||
|
|
||||||
state.tracks.set(randomUUID(), {
|
state.tracks.set(randomUUID(), {
|
||||||
id: randomUUID(),
|
id: randomUUID(),
|
||||||
|
|||||||
@ -1,23 +1,21 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||||
import { PrismaService } from '../../infrastructure/database/prisma.service';
|
import { PrismaService } from '../../infrastructure/database/prisma.service';
|
||||||
import { DeviceAuthService } from '../auth/device-auth.service';
|
|
||||||
import { LibraryTrackDto } from '../sync/sync.dto';
|
import { LibraryTrackDto } from '../sync/sync.dto';
|
||||||
import { OwnerContext } from '../users/owner-context.service';
|
import { DefaultUserService } from '../users/default-user.service';
|
||||||
import { RemoteLibraryTrackDto } from './library.dto';
|
import { RemoteLibraryTrackDto } from './library.dto';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class LibraryService {
|
export class LibraryService {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly prismaService: PrismaService,
|
private readonly prismaService: PrismaService,
|
||||||
private readonly ownerContext: OwnerContext,
|
private readonly defaultUserService: DefaultUserService,
|
||||||
private readonly deviceAuthService: DeviceAuthService,
|
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async getBootstrapTracks(): Promise<LibraryTrackDto[]> {
|
async getBootstrapTracks(): Promise<LibraryTrackDto[]> {
|
||||||
const ownerUserId = await this.resolveCurrentOwnerUserId();
|
const defaultUser = await this.defaultUserService.getOrCreateDefaultUser();
|
||||||
const tracks = await this.prismaService.track.findMany({
|
const tracks = await this.prismaService.track.findMany({
|
||||||
where: {
|
where: {
|
||||||
userId: ownerUserId,
|
userId: defaultUser.id,
|
||||||
status: 'ACTIVE',
|
status: 'ACTIVE',
|
||||||
},
|
},
|
||||||
orderBy: {
|
orderBy: {
|
||||||
@ -38,14 +36,22 @@ export class LibraryService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async getRemoteLibraryTracks(
|
async getRemoteLibraryTracks(
|
||||||
legacyDeviceId?: string,
|
deviceId: string,
|
||||||
): Promise<RemoteLibraryTrackDto[]> {
|
): Promise<RemoteLibraryTrackDto[]> {
|
||||||
const { userId: ownerUserId } =
|
const device = await this.prismaService.device.findUnique({
|
||||||
await this.deviceAuthService.resolveCurrentDevice(legacyDeviceId);
|
where: { id: deviceId },
|
||||||
|
select: {
|
||||||
|
userId: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!device) {
|
||||||
|
throw new NotFoundException('Device not found');
|
||||||
|
}
|
||||||
|
|
||||||
const tracks = await this.prismaService.track.findMany({
|
const tracks = await this.prismaService.track.findMany({
|
||||||
where: {
|
where: {
|
||||||
userId: ownerUserId,
|
userId: device.userId,
|
||||||
status: 'ACTIVE',
|
status: 'ACTIVE',
|
||||||
primaryAudioAssetId: {
|
primaryAudioAssetId: {
|
||||||
not: null,
|
not: null,
|
||||||
@ -111,9 +117,4 @@ export class LibraryService {
|
|||||||
];
|
];
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private async resolveCurrentOwnerUserId(): Promise<string> {
|
|
||||||
const owner = await this.ownerContext.resolve();
|
|
||||||
return owner.userId;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,6 +1,5 @@
|
|||||||
import { Controller, Get, Query, UseGuards } from '@nestjs/common';
|
import { Controller, Get, Query } from '@nestjs/common';
|
||||||
import { ApiBearerAuth, ApiOkResponse, ApiTags } from '@nestjs/swagger';
|
import { ApiOkResponse, ApiTags } from '@nestjs/swagger';
|
||||||
import { DeviceAuthGuard } from '../auth/device-auth.guard';
|
|
||||||
import {
|
import {
|
||||||
SyncBootstrapResponseDto,
|
SyncBootstrapResponseDto,
|
||||||
SyncChangesQueryDto,
|
SyncChangesQueryDto,
|
||||||
@ -9,8 +8,6 @@ import {
|
|||||||
import { SyncService } from './sync.service';
|
import { SyncService } from './sync.service';
|
||||||
|
|
||||||
@ApiTags('sync')
|
@ApiTags('sync')
|
||||||
@UseGuards(DeviceAuthGuard)
|
|
||||||
@ApiBearerAuth()
|
|
||||||
@Controller({
|
@Controller({
|
||||||
path: 'sync',
|
path: 'sync',
|
||||||
version: '1',
|
version: '1',
|
||||||
|
|||||||
@ -1,13 +1,12 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { PrismaModule } from '../../infrastructure/database/prisma.module';
|
import { PrismaModule } from '../../infrastructure/database/prisma.module';
|
||||||
import { AuthModule } from '../auth/auth.module';
|
|
||||||
import { LibraryModule } from '../library/library.module';
|
import { LibraryModule } from '../library/library.module';
|
||||||
import { UsersModule } from '../users/users.module';
|
import { UsersModule } from '../users/users.module';
|
||||||
import { SyncController } from './sync.controller';
|
import { SyncController } from './sync.controller';
|
||||||
import { SyncService } from './sync.service';
|
import { SyncService } from './sync.service';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [PrismaModule, LibraryModule, UsersModule, AuthModule],
|
imports: [PrismaModule, LibraryModule, UsersModule],
|
||||||
controllers: [SyncController],
|
controllers: [SyncController],
|
||||||
providers: [SyncService],
|
providers: [SyncService],
|
||||||
})
|
})
|
||||||
|
|||||||
@ -1,58 +0,0 @@
|
|||||||
import { Test } from '@nestjs/testing';
|
|
||||||
import { PrismaService } from '../../infrastructure/database/prisma.service';
|
|
||||||
import { LibraryService } from '../library/library.service';
|
|
||||||
import { OwnerContext } from '../users/owner-context.service';
|
|
||||||
import { SyncService } from './sync.service';
|
|
||||||
|
|
||||||
describe('SyncService', () => {
|
|
||||||
it('uses OwnerContext to scope the bootstrap cursor lookup', async () => {
|
|
||||||
const ownerContextMock = {
|
|
||||||
resolve: jest.fn().mockResolvedValue({
|
|
||||||
userId: 'bootstrap-owner-id',
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
const prismaMock = {
|
|
||||||
libraryEvent: {
|
|
||||||
findFirst: jest.fn().mockResolvedValue({
|
|
||||||
id: 7n,
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
};
|
|
||||||
const libraryServiceMock = {
|
|
||||||
getBootstrapTracks: jest.fn().mockResolvedValue([]),
|
|
||||||
};
|
|
||||||
|
|
||||||
const moduleRef = await Test.createTestingModule({
|
|
||||||
providers: [
|
|
||||||
SyncService,
|
|
||||||
{
|
|
||||||
provide: PrismaService,
|
|
||||||
useValue: prismaMock,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
provide: LibraryService,
|
|
||||||
useValue: libraryServiceMock,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
provide: OwnerContext,
|
|
||||||
useValue: ownerContextMock,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}).compile();
|
|
||||||
|
|
||||||
const service = moduleRef.get(SyncService);
|
|
||||||
|
|
||||||
await expect(service.changes('0')).resolves.toMatchObject({
|
|
||||||
nextCursor: '7',
|
|
||||||
});
|
|
||||||
expect(ownerContextMock.resolve).toHaveBeenCalledTimes(1);
|
|
||||||
expect(prismaMock.libraryEvent.findFirst).toHaveBeenCalledWith({
|
|
||||||
where: {
|
|
||||||
userId: 'bootstrap-owner-id',
|
|
||||||
},
|
|
||||||
orderBy: {
|
|
||||||
id: 'desc',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -1,7 +1,7 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { PrismaService } from '../../infrastructure/database/prisma.service';
|
import { PrismaService } from '../../infrastructure/database/prisma.service';
|
||||||
import { LibraryService } from '../library/library.service';
|
import { LibraryService } from '../library/library.service';
|
||||||
import { OwnerContext } from '../users/owner-context.service';
|
import { DefaultUserService } from '../users/default-user.service';
|
||||||
import { SyncBootstrapResponseDto, SyncChangesResponseDto } from './sync.dto';
|
import { SyncBootstrapResponseDto, SyncChangesResponseDto } from './sync.dto';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
@ -9,7 +9,7 @@ export class SyncService {
|
|||||||
constructor(
|
constructor(
|
||||||
private readonly prismaService: PrismaService,
|
private readonly prismaService: PrismaService,
|
||||||
private readonly libraryService: LibraryService,
|
private readonly libraryService: LibraryService,
|
||||||
private readonly ownerContext: OwnerContext,
|
private readonly defaultUserService: DefaultUserService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async bootstrap(): Promise<SyncBootstrapResponseDto> {
|
async bootstrap(): Promise<SyncBootstrapResponseDto> {
|
||||||
@ -39,10 +39,10 @@ export class SyncService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async getLatestCursor(): Promise<string> {
|
private async getLatestCursor(): Promise<string> {
|
||||||
const owner = await this.ownerContext.resolve();
|
const defaultUser = await this.defaultUserService.getOrCreateDefaultUser();
|
||||||
const latest = await this.prismaService.libraryEvent.findFirst({
|
const latest = await this.prismaService.libraryEvent.findFirst({
|
||||||
where: {
|
where: {
|
||||||
userId: owner.userId,
|
userId: defaultUser.id,
|
||||||
},
|
},
|
||||||
orderBy: {
|
orderBy: {
|
||||||
id: 'desc',
|
id: 'desc',
|
||||||
|
|||||||
@ -6,18 +6,15 @@ import {
|
|||||||
Post,
|
Post,
|
||||||
Put,
|
Put,
|
||||||
Req,
|
Req,
|
||||||
UseGuards,
|
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import type { Request } from 'express';
|
import type { Request } from 'express';
|
||||||
import {
|
import {
|
||||||
ApiBearerAuth,
|
|
||||||
ApiBody,
|
ApiBody,
|
||||||
ApiCreatedResponse,
|
ApiCreatedResponse,
|
||||||
ApiConsumes,
|
ApiConsumes,
|
||||||
ApiOkResponse,
|
ApiOkResponse,
|
||||||
ApiTags,
|
ApiTags,
|
||||||
} from '@nestjs/swagger';
|
} from '@nestjs/swagger';
|
||||||
import { DeviceAuthGuard } from '../auth/device-auth.guard';
|
|
||||||
import {
|
import {
|
||||||
UploadFinalizeRequestDto,
|
UploadFinalizeRequestDto,
|
||||||
UploadFinalizeResponseDto,
|
UploadFinalizeResponseDto,
|
||||||
@ -28,8 +25,6 @@ import {
|
|||||||
import { UploadsService } from './uploads.service';
|
import { UploadsService } from './uploads.service';
|
||||||
|
|
||||||
@ApiTags('uploads')
|
@ApiTags('uploads')
|
||||||
@UseGuards(DeviceAuthGuard)
|
|
||||||
@ApiBearerAuth()
|
|
||||||
@Controller({
|
@Controller({
|
||||||
path: 'uploads',
|
path: 'uploads',
|
||||||
version: '1',
|
version: '1',
|
||||||
|
|||||||
@ -14,15 +14,9 @@ import {
|
|||||||
} from 'class-validator';
|
} from 'class-validator';
|
||||||
|
|
||||||
export class UploadPrepareRequestDto {
|
export class UploadPrepareRequestDto {
|
||||||
@ApiProperty({
|
@ApiProperty({ format: 'uuid' })
|
||||||
format: 'uuid',
|
|
||||||
required: false,
|
|
||||||
description:
|
|
||||||
'Legacy migration fallback. Omit when Authorization: Bearer <deviceAccessToken> is provided.',
|
|
||||||
})
|
|
||||||
@IsOptional()
|
|
||||||
@IsUUID()
|
@IsUUID()
|
||||||
deviceId?: string;
|
deviceId!: string;
|
||||||
|
|
||||||
@ApiProperty({
|
@ApiProperty({
|
||||||
example:
|
example:
|
||||||
|
|||||||
@ -1,13 +1,12 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { PrismaModule } from '../../infrastructure/database/prisma.module';
|
import { PrismaModule } from '../../infrastructure/database/prisma.module';
|
||||||
import { AuthModule } from '../auth/auth.module';
|
|
||||||
import { StorageModule } from '../storage/storage.module';
|
import { StorageModule } from '../storage/storage.module';
|
||||||
import { UsersModule } from '../users/users.module';
|
import { UsersModule } from '../users/users.module';
|
||||||
import { UploadsController } from './uploads.controller';
|
import { UploadsController } from './uploads.controller';
|
||||||
import { UploadsService } from './uploads.service';
|
import { UploadsService } from './uploads.service';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [PrismaModule, StorageModule, UsersModule, AuthModule],
|
imports: [PrismaModule, StorageModule, UsersModule],
|
||||||
controllers: [UploadsController],
|
controllers: [UploadsController],
|
||||||
providers: [UploadsService],
|
providers: [UploadsService],
|
||||||
})
|
})
|
||||||
|
|||||||
@ -3,12 +3,10 @@ import { readFile, mkdtemp, rm } from 'node:fs/promises';
|
|||||||
import { tmpdir } from 'node:os';
|
import { tmpdir } from 'node:os';
|
||||||
import { join } from 'node:path';
|
import { join } from 'node:path';
|
||||||
import { Readable } from 'node:stream';
|
import { Readable } from 'node:stream';
|
||||||
import { NotFoundException, UnprocessableEntityException } from '@nestjs/common';
|
import { UnprocessableEntityException } from '@nestjs/common';
|
||||||
import { UploadSessionStatus } from '@prisma/client';
|
import { UploadSessionStatus } from '@prisma/client';
|
||||||
import { AppConfigService } from '../config/config.service';
|
import { AppConfigService } from '../config/config.service';
|
||||||
import { DeviceAuthService } from '../auth/device-auth.service';
|
|
||||||
import { LocalFilesystemStorageService } from '../storage/storage.service';
|
import { LocalFilesystemStorageService } from '../storage/storage.service';
|
||||||
import { OwnerContext } from '../users/owner-context.service';
|
|
||||||
import { UploadsService } from './uploads.service';
|
import { UploadsService } from './uploads.service';
|
||||||
|
|
||||||
type MockState = ReturnType<typeof createPrismaMock>['state'];
|
type MockState = ReturnType<typeof createPrismaMock>['state'];
|
||||||
@ -284,10 +282,6 @@ describe('UploadsService', () => {
|
|||||||
let storageRoot: string;
|
let storageRoot: string;
|
||||||
let storageService: LocalFilesystemStorageService;
|
let storageService: LocalFilesystemStorageService;
|
||||||
let service: UploadsService;
|
let service: UploadsService;
|
||||||
let ownerContext: OwnerContext;
|
|
||||||
let deviceAuthService: {
|
|
||||||
resolveCurrentDevice: jest.Mock;
|
|
||||||
};
|
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
const mock = createPrismaMock();
|
const mock = createPrismaMock();
|
||||||
@ -295,35 +289,10 @@ describe('UploadsService', () => {
|
|||||||
state = mock.state;
|
state = mock.state;
|
||||||
storageRoot = await mkdtemp(join(tmpdir(), 'velody-upload-spec-'));
|
storageRoot = await mkdtemp(join(tmpdir(), 'velody-upload-spec-'));
|
||||||
storageService = new LocalFilesystemStorageService(createAppConfig(storageRoot));
|
storageService = new LocalFilesystemStorageService(createAppConfig(storageRoot));
|
||||||
ownerContext = {
|
|
||||||
resolve: jest.fn().mockResolvedValue({
|
|
||||||
userId: state.defaultUser.id,
|
|
||||||
}),
|
|
||||||
} as OwnerContext;
|
|
||||||
deviceAuthService = {
|
|
||||||
resolveCurrentDevice: jest.fn().mockImplementation(async (deviceId?: string) => {
|
|
||||||
if (!deviceId) {
|
|
||||||
throw new NotFoundException('Device not found');
|
|
||||||
}
|
|
||||||
|
|
||||||
const device = state.devices.get(deviceId);
|
|
||||||
|
|
||||||
if (!device || device.userId !== state.defaultUser.id) {
|
|
||||||
throw new NotFoundException('Device not found');
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
deviceId: device.id,
|
|
||||||
userId: device.userId,
|
|
||||||
};
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
service = new UploadsService(
|
service = new UploadsService(
|
||||||
prismaMock,
|
prismaMock,
|
||||||
createAppConfig(storageRoot),
|
createAppConfig(storageRoot),
|
||||||
storageService,
|
storageService,
|
||||||
ownerContext,
|
|
||||||
deviceAuthService as unknown as DeviceAuthService,
|
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -365,36 +334,6 @@ describe('UploadsService', () => {
|
|||||||
expect(state.uploadSessions.size).toBe(1);
|
expect(state.uploadSessions.size).toBe(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('does not reuse foreign-owner audio assets during prepare', async () => {
|
|
||||||
const device = seedDevice();
|
|
||||||
const bytes = sampleMp3Bytes('foreign-owner-asset');
|
|
||||||
const sha256 = sha256Hex(bytes);
|
|
||||||
|
|
||||||
state.audioAssets.set(randomUUID(), {
|
|
||||||
id: randomUUID(),
|
|
||||||
userId: randomUUID(),
|
|
||||||
trackId: randomUUID(),
|
|
||||||
sha256,
|
|
||||||
storageKey: 'users/foreign/audio/foreign-owner-asset.mp3',
|
|
||||||
originalFilename: 'foreign-owner-asset.mp3',
|
|
||||||
mimeType: 'audio/mpeg',
|
|
||||||
fileExtension: 'mp3',
|
|
||||||
fileSizeBytes: BigInt(bytes.length),
|
|
||||||
durationMs: 180000,
|
|
||||||
sourceDeviceId: randomUUID(),
|
|
||||||
createdAt: new Date(),
|
|
||||||
});
|
|
||||||
|
|
||||||
const response = await service.prepare({
|
|
||||||
deviceId: device.id,
|
|
||||||
sha256,
|
|
||||||
originalFilename: 'foreign-owner-asset.mp3',
|
|
||||||
sizeBytes: bytes.length,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(response.status).toBe('upload_required');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('rejects an upload whose bytes do not match the prepared sha', async () => {
|
it('rejects an upload whose bytes do not match the prepared sha', async () => {
|
||||||
const device = seedDevice();
|
const device = seedDevice();
|
||||||
const uploadedBytes = sampleMp3Bytes('wrong-sha-upload');
|
const uploadedBytes = sampleMp3Bytes('wrong-sha-upload');
|
||||||
@ -462,14 +401,6 @@ describe('UploadsService', () => {
|
|||||||
expect(state.audioAssets.size).toBe(1);
|
expect(state.audioAssets.size).toBe(1);
|
||||||
expect(state.libraryEvents.size).toBe(1);
|
expect(state.libraryEvents.size).toBe(1);
|
||||||
|
|
||||||
const track = [...state.tracks.values()][0];
|
|
||||||
const audioAsset = [...state.audioAssets.values()][0];
|
|
||||||
const libraryEvent = [...state.libraryEvents.values()][0];
|
|
||||||
|
|
||||||
expect(track.userId).toBe(state.defaultUser.id);
|
|
||||||
expect(audioAsset.userId).toBe(state.defaultUser.id);
|
|
||||||
expect(libraryEvent.userId).toBe(state.defaultUser.id);
|
|
||||||
|
|
||||||
const session = state.uploadSessions.get(response.uploadId!);
|
const session = state.uploadSessions.get(response.uploadId!);
|
||||||
expect(session.finalizedAt).toBeInstanceOf(Date);
|
expect(session.finalizedAt).toBeInstanceOf(Date);
|
||||||
expect(session.trackId).toBe(finalizeResponse.trackId);
|
expect(session.trackId).toBe(finalizeResponse.trackId);
|
||||||
@ -619,52 +550,4 @@ describe('UploadsService', () => {
|
|||||||
);
|
);
|
||||||
expect(storedArtworkBytes.equals(artworkBytes)).toBe(true);
|
expect(storedArtworkBytes.equals(artworkBytes)).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('rejects finalize when the upload session references a foreign-owner track', async () => {
|
|
||||||
const device = seedDevice();
|
|
||||||
const uploadedBytes = sampleMp3Bytes('foreign-track-finalize');
|
|
||||||
const sha256 = sha256Hex(uploadedBytes);
|
|
||||||
const response = await service.prepare({
|
|
||||||
deviceId: device.id,
|
|
||||||
sha256,
|
|
||||||
originalFilename: 'foreign-track-finalize.mp3',
|
|
||||||
sizeBytes: uploadedBytes.length,
|
|
||||||
});
|
|
||||||
|
|
||||||
await service.uploadFile(response.uploadId!, createUploadRequest(uploadedBytes));
|
|
||||||
|
|
||||||
const foreignTrackId = randomUUID();
|
|
||||||
state.tracks.set(foreignTrackId, {
|
|
||||||
id: foreignTrackId,
|
|
||||||
userId: randomUUID(),
|
|
||||||
primaryAudioAssetId: null,
|
|
||||||
artworkAssetId: null,
|
|
||||||
title: 'Foreign Track',
|
|
||||||
artist: 'Elsewhere',
|
|
||||||
album: null,
|
|
||||||
albumArtist: null,
|
|
||||||
genre: null,
|
|
||||||
discNumber: null,
|
|
||||||
trackNumber: null,
|
|
||||||
year: null,
|
|
||||||
durationMs: 180000,
|
|
||||||
status: 'ACTIVE',
|
|
||||||
deletedAt: null,
|
|
||||||
createdAt: new Date(),
|
|
||||||
updatedAt: new Date(),
|
|
||||||
});
|
|
||||||
state.uploadSessions.set(response.uploadId!, {
|
|
||||||
...state.uploadSessions.get(response.uploadId!),
|
|
||||||
trackId: foreignTrackId,
|
|
||||||
});
|
|
||||||
|
|
||||||
await expect(
|
|
||||||
service.finalize(response.uploadId!, {
|
|
||||||
title: 'Finalize Track',
|
|
||||||
artist: 'Velody',
|
|
||||||
album: 'Milestone 9',
|
|
||||||
durationMs: 245000,
|
|
||||||
}),
|
|
||||||
).rejects.toBeInstanceOf(NotFoundException);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|||||||
@ -1,5 +1,4 @@
|
|||||||
import {
|
import {
|
||||||
ForbiddenException,
|
|
||||||
Injectable,
|
Injectable,
|
||||||
NotFoundException,
|
NotFoundException,
|
||||||
UnprocessableEntityException,
|
UnprocessableEntityException,
|
||||||
@ -17,9 +16,7 @@ import { access, open, rename, unlink, writeFile } from 'node:fs/promises';
|
|||||||
import { extname } from 'node:path';
|
import { extname } from 'node:path';
|
||||||
import { PrismaService } from '../../infrastructure/database/prisma.service';
|
import { PrismaService } from '../../infrastructure/database/prisma.service';
|
||||||
import { AppConfigService } from '../config/config.service';
|
import { AppConfigService } from '../config/config.service';
|
||||||
import { DeviceAuthService } from '../auth/device-auth.service';
|
|
||||||
import { LocalFilesystemStorageService } from '../storage/storage.service';
|
import { LocalFilesystemStorageService } from '../storage/storage.service';
|
||||||
import { OwnerContext } from '../users/owner-context.service';
|
|
||||||
import {
|
import {
|
||||||
UploadFinalizeArtworkDto,
|
UploadFinalizeArtworkDto,
|
||||||
UploadFinalizeRequestDto,
|
UploadFinalizeRequestDto,
|
||||||
@ -44,8 +41,6 @@ export class UploadsService {
|
|||||||
private readonly prismaService: PrismaService,
|
private readonly prismaService: PrismaService,
|
||||||
private readonly configService: AppConfigService,
|
private readonly configService: AppConfigService,
|
||||||
private readonly storageService: LocalFilesystemStorageService,
|
private readonly storageService: LocalFilesystemStorageService,
|
||||||
private readonly ownerContext: OwnerContext,
|
|
||||||
private readonly deviceAuthService: DeviceAuthService,
|
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async prepare(
|
async prepare(
|
||||||
@ -54,13 +49,18 @@ export class UploadsService {
|
|||||||
this.assertFileSizeWithinLimit(body.sizeBytes);
|
this.assertFileSizeWithinLimit(body.sizeBytes);
|
||||||
this.assertMp3Filename(body.originalFilename);
|
this.assertMp3Filename(body.originalFilename);
|
||||||
|
|
||||||
const device = await this.deviceAuthService.resolveCurrentDevice(body.deviceId);
|
const device = await this.prismaService.device.findUnique({
|
||||||
const ownerUserId = device.userId;
|
where: { id: body.deviceId },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!device) {
|
||||||
|
throw new NotFoundException('Device not found');
|
||||||
|
}
|
||||||
|
|
||||||
const existingAsset = await this.prismaService.audioAsset.findUnique({
|
const existingAsset = await this.prismaService.audioAsset.findUnique({
|
||||||
where: {
|
where: {
|
||||||
userId_sha256: {
|
userId_sha256: {
|
||||||
userId: ownerUserId,
|
userId: device.userId,
|
||||||
sha256: body.sha256,
|
sha256: body.sha256,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@ -71,8 +71,8 @@ export class UploadsService {
|
|||||||
const uploadSession = await this.prismaService.uploadSession.create({
|
const uploadSession = await this.prismaService.uploadSession.create({
|
||||||
data: {
|
data: {
|
||||||
id: uploadId,
|
id: uploadId,
|
||||||
userId: ownerUserId,
|
userId: device.userId,
|
||||||
deviceId: device.deviceId,
|
deviceId: body.deviceId,
|
||||||
trackId: existingAsset.trackId,
|
trackId: existingAsset.trackId,
|
||||||
audioAssetId: existingAsset.id,
|
audioAssetId: existingAsset.id,
|
||||||
expectedSha256: body.sha256,
|
expectedSha256: body.sha256,
|
||||||
@ -98,8 +98,8 @@ export class UploadsService {
|
|||||||
const uploadSession = await this.prismaService.uploadSession.create({
|
const uploadSession = await this.prismaService.uploadSession.create({
|
||||||
data: {
|
data: {
|
||||||
id: uploadId,
|
id: uploadId,
|
||||||
userId: ownerUserId,
|
userId: device.userId,
|
||||||
deviceId: device.deviceId,
|
deviceId: body.deviceId,
|
||||||
expectedSha256: body.sha256,
|
expectedSha256: body.sha256,
|
||||||
originalFilename: body.originalFilename,
|
originalFilename: body.originalFilename,
|
||||||
expectedSizeBytes: BigInt(body.sizeBytes),
|
expectedSizeBytes: BigInt(body.sizeBytes),
|
||||||
@ -118,21 +118,14 @@ export class UploadsService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async getStatus(uploadId: string): Promise<UploadSessionStatusResponseDto> {
|
async getStatus(uploadId: string): Promise<UploadSessionStatusResponseDto> {
|
||||||
const ownerUserId = await this.resolveCurrentOwnerUserId();
|
return this.toStatusResponse(await this.getUploadSessionOrThrow(uploadId));
|
||||||
return this.toStatusResponse(
|
|
||||||
await this.getOwnedUploadSessionOrThrow(uploadId, ownerUserId),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async uploadFile(
|
async uploadFile(
|
||||||
uploadId: string,
|
uploadId: string,
|
||||||
request: Request,
|
request: Request,
|
||||||
): Promise<UploadSessionStatusResponseDto> {
|
): Promise<UploadSessionStatusResponseDto> {
|
||||||
const ownerUserId = await this.resolveCurrentOwnerUserId();
|
const uploadSession = await this.getUploadSessionOrThrow(uploadId);
|
||||||
const uploadSession = await this.getOwnedUploadSessionOrThrow(
|
|
||||||
uploadId,
|
|
||||||
ownerUserId,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (uploadSession.status === UploadSessionStatus.COMPLETED) {
|
if (uploadSession.status === UploadSessionStatus.COMPLETED) {
|
||||||
return this.toStatusResponse(uploadSession);
|
return this.toStatusResponse(uploadSession);
|
||||||
@ -155,7 +148,7 @@ export class UploadsService {
|
|||||||
|
|
||||||
const tempPath = this.storageService.resolve(uploadSession.tempStoragePath);
|
const tempPath = this.storageService.resolve(uploadSession.tempStoragePath);
|
||||||
const finalPath = this.storageService.userAudioAssetPath(
|
const finalPath = this.storageService.userAudioAssetPath(
|
||||||
ownerUserId,
|
uploadSession.userId,
|
||||||
uploadSession.expectedSha256,
|
uploadSession.expectedSha256,
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -246,28 +239,13 @@ export class UploadsService {
|
|||||||
uploadId: string,
|
uploadId: string,
|
||||||
body: UploadFinalizeRequestDto,
|
body: UploadFinalizeRequestDto,
|
||||||
): Promise<UploadFinalizeResponseDto> {
|
): Promise<UploadFinalizeResponseDto> {
|
||||||
const ownerUserId = await this.resolveCurrentOwnerUserId();
|
const uploadSession = await this.getUploadSessionOrThrow(uploadId);
|
||||||
const uploadSession = await this.getOwnedUploadSessionOrThrow(
|
|
||||||
uploadId,
|
|
||||||
ownerUserId,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (
|
if (
|
||||||
uploadSession.finalizedAt &&
|
uploadSession.finalizedAt &&
|
||||||
uploadSession.trackId &&
|
uploadSession.trackId &&
|
||||||
uploadSession.audioAssetId
|
uploadSession.audioAssetId
|
||||||
) {
|
) {
|
||||||
await this.assertOwnedTrackOrThrow(
|
|
||||||
this.prismaService,
|
|
||||||
uploadSession.trackId,
|
|
||||||
ownerUserId,
|
|
||||||
);
|
|
||||||
await this.assertOwnedAudioAssetOrThrow(
|
|
||||||
this.prismaService,
|
|
||||||
uploadSession.audioAssetId,
|
|
||||||
ownerUserId,
|
|
||||||
);
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
trackId: uploadSession.trackId,
|
trackId: uploadSession.trackId,
|
||||||
assetId: uploadSession.audioAssetId,
|
assetId: uploadSession.audioAssetId,
|
||||||
@ -303,28 +281,19 @@ export class UploadsService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return this.prismaService.$transaction(async (tx) => {
|
return this.prismaService.$transaction(async (tx) => {
|
||||||
const currentSession = await this.getOwnedUploadSessionOrThrow(
|
const currentSession = await tx.uploadSession.findUnique({
|
||||||
uploadId,
|
where: { id: uploadId },
|
||||||
ownerUserId,
|
});
|
||||||
tx,
|
|
||||||
);
|
if (!currentSession) {
|
||||||
|
throw new NotFoundException('Upload session not found');
|
||||||
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
currentSession.finalizedAt &&
|
currentSession.finalizedAt &&
|
||||||
currentSession.trackId &&
|
currentSession.trackId &&
|
||||||
currentSession.audioAssetId
|
currentSession.audioAssetId
|
||||||
) {
|
) {
|
||||||
await this.assertOwnedTrackOrThrow(
|
|
||||||
tx,
|
|
||||||
currentSession.trackId,
|
|
||||||
ownerUserId,
|
|
||||||
);
|
|
||||||
await this.assertOwnedAudioAssetOrThrow(
|
|
||||||
tx,
|
|
||||||
currentSession.audioAssetId,
|
|
||||||
ownerUserId,
|
|
||||||
);
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
trackId: currentSession.trackId,
|
trackId: currentSession.trackId,
|
||||||
assetId: currentSession.audioAssetId,
|
assetId: currentSession.audioAssetId,
|
||||||
@ -339,7 +308,7 @@ export class UploadsService {
|
|||||||
|
|
||||||
const preparedArtwork = body.artwork
|
const preparedArtwork = body.artwork
|
||||||
? await this.prepareArtworkAssetInput(
|
? await this.prepareArtworkAssetInput(
|
||||||
ownerUserId,
|
currentSession.userId,
|
||||||
body.artwork,
|
body.artwork,
|
||||||
)
|
)
|
||||||
: null;
|
: null;
|
||||||
@ -347,7 +316,7 @@ export class UploadsService {
|
|||||||
let audioAsset = await tx.audioAsset.findUnique({
|
let audioAsset = await tx.audioAsset.findUnique({
|
||||||
where: {
|
where: {
|
||||||
userId_sha256: {
|
userId_sha256: {
|
||||||
userId: ownerUserId,
|
userId: currentSession.userId,
|
||||||
sha256: currentSession.expectedSha256,
|
sha256: currentSession.expectedSha256,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@ -355,22 +324,20 @@ export class UploadsService {
|
|||||||
|
|
||||||
let track =
|
let track =
|
||||||
currentSession.trackId != null
|
currentSession.trackId != null
|
||||||
? await this.findOwnedTrackById(tx, currentSession.trackId, ownerUserId)
|
? await tx.track.findUnique({ where: { id: currentSession.trackId } })
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
if (currentSession.trackId != null && !track) {
|
|
||||||
throw new NotFoundException('Track not found');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!track && audioAsset?.trackId) {
|
if (!track && audioAsset?.trackId) {
|
||||||
track = await this.findOwnedTrackById(tx, audioAsset.trackId, ownerUserId);
|
track = await tx.track.findUnique({
|
||||||
|
where: { id: audioAsset.trackId },
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const createdTrack = !track;
|
const createdTrack = !track;
|
||||||
if (!track) {
|
if (!track) {
|
||||||
track = await tx.track.create({
|
track = await tx.track.create({
|
||||||
data: {
|
data: {
|
||||||
userId: ownerUserId,
|
userId: currentSession.userId,
|
||||||
title,
|
title,
|
||||||
artist,
|
artist,
|
||||||
album,
|
album,
|
||||||
@ -408,7 +375,7 @@ export class UploadsService {
|
|||||||
} else {
|
} else {
|
||||||
audioAsset = await tx.audioAsset.create({
|
audioAsset = await tx.audioAsset.create({
|
||||||
data: {
|
data: {
|
||||||
userId: ownerUserId,
|
userId: currentSession.userId,
|
||||||
trackId: track.id,
|
trackId: track.id,
|
||||||
sha256: currentSession.expectedSha256,
|
sha256: currentSession.expectedSha256,
|
||||||
storageKey: finalStorageKey,
|
storageKey: finalStorageKey,
|
||||||
@ -435,7 +402,7 @@ export class UploadsService {
|
|||||||
? (
|
? (
|
||||||
await this.findOrCreateArtworkAsset(
|
await this.findOrCreateArtworkAsset(
|
||||||
tx,
|
tx,
|
||||||
ownerUserId,
|
currentSession.userId,
|
||||||
preparedArtwork,
|
preparedArtwork,
|
||||||
)
|
)
|
||||||
).id
|
).id
|
||||||
@ -452,7 +419,7 @@ export class UploadsService {
|
|||||||
|
|
||||||
await tx.libraryEvent.create({
|
await tx.libraryEvent.create({
|
||||||
data: {
|
data: {
|
||||||
userId: ownerUserId,
|
userId: currentSession.userId,
|
||||||
entityType: EntityType.TRACK,
|
entityType: EntityType.TRACK,
|
||||||
entityId: track.id,
|
entityId: track.id,
|
||||||
action: createdTrack ? EventAction.CREATED : EventAction.UPDATED,
|
action: createdTrack ? EventAction.CREATED : EventAction.UPDATED,
|
||||||
@ -476,69 +443,18 @@ export class UploadsService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private async resolveCurrentOwnerUserId(): Promise<string> {
|
private async getUploadSessionOrThrow(uploadId: string): Promise<UploadSession> {
|
||||||
const owner = await this.ownerContext.resolve();
|
const uploadSession = await this.prismaService.uploadSession.findUnique({
|
||||||
return owner.userId;
|
|
||||||
}
|
|
||||||
|
|
||||||
private async getOwnedUploadSessionOrThrow(
|
|
||||||
uploadId: string,
|
|
||||||
ownerUserId: string,
|
|
||||||
client: Pick<PrismaService, 'uploadSession'> = this.prismaService,
|
|
||||||
): Promise<UploadSession> {
|
|
||||||
const uploadSession = await client.uploadSession.findUnique({
|
|
||||||
where: { id: uploadId },
|
where: { id: uploadId },
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!uploadSession || uploadSession.userId !== ownerUserId) {
|
if (!uploadSession) {
|
||||||
throw new NotFoundException('Upload session not found');
|
throw new NotFoundException('Upload session not found');
|
||||||
}
|
}
|
||||||
|
|
||||||
return uploadSession;
|
return uploadSession;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async findOwnedTrackById(
|
|
||||||
client: Pick<PrismaService, 'track'>,
|
|
||||||
trackId: string,
|
|
||||||
ownerUserId: string,
|
|
||||||
) {
|
|
||||||
const track = await client.track.findUnique({
|
|
||||||
where: { id: trackId },
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!track || track.userId !== ownerUserId) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return track;
|
|
||||||
}
|
|
||||||
|
|
||||||
private async assertOwnedTrackOrThrow(
|
|
||||||
client: Pick<PrismaService, 'track'>,
|
|
||||||
trackId: string,
|
|
||||||
ownerUserId: string,
|
|
||||||
): Promise<void> {
|
|
||||||
const track = await this.findOwnedTrackById(client, trackId, ownerUserId);
|
|
||||||
|
|
||||||
if (!track) {
|
|
||||||
throw new NotFoundException('Track not found');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async assertOwnedAudioAssetOrThrow(
|
|
||||||
client: Pick<PrismaService, 'audioAsset'>,
|
|
||||||
assetId: string,
|
|
||||||
ownerUserId: string,
|
|
||||||
): Promise<void> {
|
|
||||||
const audioAsset = await client.audioAsset.findUnique({
|
|
||||||
where: { id: assetId },
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!audioAsset || audioAsset.userId !== ownerUserId) {
|
|
||||||
throw new ForbiddenException('Audio asset does not belong to the current owner.');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private toStatusResponse(
|
private toStatusResponse(
|
||||||
uploadSession: Pick<
|
uploadSession: Pick<
|
||||||
UploadSession,
|
UploadSession,
|
||||||
|
|||||||
@ -1,59 +0,0 @@
|
|||||||
import { randomUUID } from 'node:crypto';
|
|
||||||
import { DefaultUserService } from './default-user.service';
|
|
||||||
|
|
||||||
describe('DefaultUserService', () => {
|
|
||||||
it('upserts the bootstrap default owner with stable ownership metadata', async () => {
|
|
||||||
const defaultUser = {
|
|
||||||
id: randomUUID(),
|
|
||||||
slug: DefaultUserService.defaultOwnerSlug,
|
|
||||||
displayName: DefaultUserService.defaultOwnerDisplayName,
|
|
||||||
isDefault: true,
|
|
||||||
createdAt: new Date(),
|
|
||||||
updatedAt: new Date(),
|
|
||||||
};
|
|
||||||
const prismaService = {
|
|
||||||
user: {
|
|
||||||
upsert: jest.fn().mockResolvedValue(defaultUser),
|
|
||||||
},
|
|
||||||
} as any;
|
|
||||||
const service = new DefaultUserService(prismaService);
|
|
||||||
|
|
||||||
await expect(service.getOrCreateDefaultUser()).resolves.toEqual(defaultUser);
|
|
||||||
expect(prismaService.user.upsert).toHaveBeenCalledWith({
|
|
||||||
where: {
|
|
||||||
slug: DefaultUserService.defaultOwnerSlug,
|
|
||||||
},
|
|
||||||
update: {
|
|
||||||
displayName: DefaultUserService.defaultOwnerDisplayName,
|
|
||||||
isDefault: true,
|
|
||||||
},
|
|
||||||
create: {
|
|
||||||
slug: DefaultUserService.defaultOwnerSlug,
|
|
||||||
displayName: DefaultUserService.defaultOwnerDisplayName,
|
|
||||||
isDefault: true,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('creates the bootstrap default owner during application startup', async () => {
|
|
||||||
const service = new DefaultUserService({
|
|
||||||
user: {
|
|
||||||
upsert: jest.fn(),
|
|
||||||
},
|
|
||||||
} as any);
|
|
||||||
const getOrCreateDefaultUserSpy = jest
|
|
||||||
.spyOn(service, 'getOrCreateDefaultUser')
|
|
||||||
.mockResolvedValue({
|
|
||||||
id: randomUUID(),
|
|
||||||
slug: DefaultUserService.defaultOwnerSlug,
|
|
||||||
displayName: DefaultUserService.defaultOwnerDisplayName,
|
|
||||||
isDefault: true,
|
|
||||||
createdAt: new Date(),
|
|
||||||
updatedAt: new Date(),
|
|
||||||
});
|
|
||||||
|
|
||||||
await service.onApplicationBootstrap();
|
|
||||||
|
|
||||||
expect(getOrCreateDefaultUserSpy).toHaveBeenCalledTimes(1);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -1,18 +1,14 @@
|
|||||||
import { Injectable, OnApplicationBootstrap } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { User } from '@prisma/client';
|
import { User } from '@prisma/client';
|
||||||
import { PrismaService } from '../../infrastructure/database/prisma.service';
|
import { PrismaService } from '../../infrastructure/database/prisma.service';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class DefaultUserService implements OnApplicationBootstrap {
|
export class DefaultUserService {
|
||||||
static readonly defaultOwnerSlug = 'default-owner';
|
static readonly defaultOwnerSlug = 'default-owner';
|
||||||
static readonly defaultOwnerDisplayName = 'Default Owner';
|
static readonly defaultOwnerDisplayName = 'Default Owner';
|
||||||
|
|
||||||
constructor(private readonly prismaService: PrismaService) {}
|
constructor(private readonly prismaService: PrismaService) {}
|
||||||
|
|
||||||
async onApplicationBootstrap(): Promise<void> {
|
|
||||||
await this.getOrCreateDefaultUser();
|
|
||||||
}
|
|
||||||
|
|
||||||
async getOrCreateDefaultUser(): Promise<User> {
|
async getOrCreateDefaultUser(): Promise<User> {
|
||||||
return this.prismaService.user.upsert({
|
return this.prismaService.user.upsert({
|
||||||
where: {
|
where: {
|
||||||
|
|||||||
@ -1,51 +0,0 @@
|
|||||||
import { randomUUID } from 'node:crypto';
|
|
||||||
import { RequestContextService } from '../../infrastructure/request-context/request-context.service';
|
|
||||||
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,
|
|
||||||
new RequestContextService(),
|
|
||||||
);
|
|
||||||
|
|
||||||
await expect(service.resolve()).resolves.toEqual({
|
|
||||||
userId: defaultUser.id,
|
|
||||||
});
|
|
||||||
expect(defaultUserService.getOrCreateDefaultUser).toHaveBeenCalledTimes(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('prefers the authenticated device owner when present', async () => {
|
|
||||||
const requestContext = new RequestContextService();
|
|
||||||
const defaultUserService = {
|
|
||||||
getOrCreateDefaultUser: jest.fn(),
|
|
||||||
} as any;
|
|
||||||
const service = new BootstrapOwnerContextService(
|
|
||||||
defaultUserService,
|
|
||||||
requestContext,
|
|
||||||
);
|
|
||||||
const authenticatedOwnerId = randomUUID();
|
|
||||||
|
|
||||||
await requestContext.run(async () => {
|
|
||||||
requestContext.setAuthenticatedDevice({
|
|
||||||
deviceId: randomUUID(),
|
|
||||||
userId: authenticatedOwnerId,
|
|
||||||
});
|
|
||||||
|
|
||||||
await expect(service.resolve()).resolves.toEqual({
|
|
||||||
userId: authenticatedOwnerId,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(defaultUserService.getOrCreateDefaultUser).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -1,37 +0,0 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
|
||||||
import { RequestContextService } from '../../infrastructure/request-context/request-context.service';
|
|
||||||
import { DefaultUserService } from './default-user.service';
|
|
||||||
|
|
||||||
export interface ResolvedOwnerContext {
|
|
||||||
userId: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export abstract class OwnerContext {
|
|
||||||
abstract resolve(): Promise<ResolvedOwnerContext>;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Injectable()
|
|
||||||
export class BootstrapOwnerContextService extends OwnerContext {
|
|
||||||
constructor(
|
|
||||||
private readonly defaultUserService: DefaultUserService,
|
|
||||||
private readonly requestContext: RequestContextService,
|
|
||||||
) {
|
|
||||||
super();
|
|
||||||
}
|
|
||||||
|
|
||||||
async resolve(): Promise<ResolvedOwnerContext> {
|
|
||||||
const authenticatedDevice = this.requestContext.getAuthenticatedDevice();
|
|
||||||
|
|
||||||
if (authenticatedDevice) {
|
|
||||||
return {
|
|
||||||
userId: authenticatedDevice.userId,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const defaultUser = await this.defaultUserService.getOrCreateDefaultUser();
|
|
||||||
|
|
||||||
return {
|
|
||||||
userId: defaultUser.id,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,22 +1,10 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { PrismaModule } from '../../infrastructure/database/prisma.module';
|
import { PrismaModule } from '../../infrastructure/database/prisma.module';
|
||||||
import { RequestContextModule } from '../../infrastructure/request-context/request-context.module';
|
|
||||||
import { DefaultUserService } from './default-user.service';
|
import { DefaultUserService } from './default-user.service';
|
||||||
import {
|
|
||||||
BootstrapOwnerContextService,
|
|
||||||
OwnerContext,
|
|
||||||
} from './owner-context.service';
|
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [PrismaModule, RequestContextModule],
|
imports: [PrismaModule],
|
||||||
providers: [
|
providers: [DefaultUserService],
|
||||||
DefaultUserService,
|
exports: [DefaultUserService],
|
||||||
BootstrapOwnerContextService,
|
|
||||||
{
|
|
||||||
provide: OwnerContext,
|
|
||||||
useExisting: BootstrapOwnerContextService,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
exports: [DefaultUserService, OwnerContext],
|
|
||||||
})
|
})
|
||||||
export class UsersModule {}
|
export class UsersModule {}
|
||||||
|
|||||||
@ -6,7 +6,6 @@ import { Readable } from 'node:stream';
|
|||||||
import {
|
import {
|
||||||
ForbiddenException,
|
ForbiddenException,
|
||||||
NotFoundException,
|
NotFoundException,
|
||||||
UnauthorizedException,
|
|
||||||
ValidationPipe,
|
ValidationPipe,
|
||||||
VersioningType,
|
VersioningType,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
@ -14,10 +13,8 @@ import type { NestExpressApplication } from '@nestjs/platform-express';
|
|||||||
import { Test } from '@nestjs/testing';
|
import { Test } from '@nestjs/testing';
|
||||||
import { API_JSON_BODY_LIMIT } from '../../src/app.factory';
|
import { API_JSON_BODY_LIMIT } from '../../src/app.factory';
|
||||||
import { AppModule } from '../../src/app.module';
|
import { AppModule } from '../../src/app.module';
|
||||||
import { RequestContextService } from '../../src/infrastructure/request-context/request-context.service';
|
|
||||||
import { AssetsController } from '../../src/modules/assets/assets.controller';
|
import { AssetsController } from '../../src/modules/assets/assets.controller';
|
||||||
import { AssetDownloadQueryDto } from '../../src/modules/assets/assets.dto';
|
import { AssetDownloadQueryDto } from '../../src/modules/assets/assets.dto';
|
||||||
import { DeviceAuthService } from '../../src/modules/auth/device-auth.service';
|
|
||||||
import { ArtworkController } from '../../src/modules/artwork/artwork.controller';
|
import { ArtworkController } from '../../src/modules/artwork/artwork.controller';
|
||||||
import { AppConfigService } from '../../src/modules/config/config.service';
|
import { AppConfigService } from '../../src/modules/config/config.service';
|
||||||
import { DevicesController } from '../../src/modules/devices/devices.controller';
|
import { DevicesController } from '../../src/modules/devices/devices.controller';
|
||||||
@ -41,25 +38,6 @@ function sha256Hex(data: Buffer): string {
|
|||||||
return createHash('sha256').update(data).digest('hex');
|
return createHash('sha256').update(data).digest('hex');
|
||||||
}
|
}
|
||||||
|
|
||||||
function applySelect<T extends Record<string, any>>(
|
|
||||||
record: T | null,
|
|
||||||
select?: Record<string, boolean>,
|
|
||||||
) {
|
|
||||||
if (!record) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!select) {
|
|
||||||
return record;
|
|
||||||
}
|
|
||||||
|
|
||||||
return Object.fromEntries(
|
|
||||||
Object.entries(select)
|
|
||||||
.filter(([, enabled]) => enabled)
|
|
||||||
.map(([key]) => [key, record[key]]),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function createUploadRequest(data: Buffer): any {
|
function createUploadRequest(data: Buffer): any {
|
||||||
const request = Readable.from([data]) as any;
|
const request = Readable.from([data]) as any;
|
||||||
request.headers = {
|
request.headers = {
|
||||||
@ -118,21 +96,17 @@ function createPrismaMock() {
|
|||||||
devices.set(record.id, record);
|
devices.set(record.id, record);
|
||||||
return record;
|
return record;
|
||||||
}),
|
}),
|
||||||
findUnique: jest.fn().mockImplementation(async ({ where, select }) => {
|
findUnique: jest.fn().mockImplementation(async ({ where }) => {
|
||||||
if (where.id) {
|
const device = devices.get(where.id) ?? null;
|
||||||
return applySelect(devices.get(where.id) ?? null, select);
|
if (!device) {
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (where.tokenHash) {
|
if (where.id && typeof where.id === 'string') {
|
||||||
const matchingDevice =
|
return device;
|
||||||
[...devices.values()].find(
|
|
||||||
(device) => device.tokenHash === where.tokenHash,
|
|
||||||
) ?? null;
|
|
||||||
|
|
||||||
return applySelect(matchingDevice, select);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
return device;
|
||||||
}),
|
}),
|
||||||
update: jest.fn().mockImplementation(async ({ where, data }) => {
|
update: jest.fn().mockImplementation(async ({ where, data }) => {
|
||||||
const current = devices.get(where.id);
|
const current = devices.get(where.id);
|
||||||
@ -339,7 +313,6 @@ function createPrismaMock() {
|
|||||||
|
|
||||||
describe('Velody API wiring (e2e)', () => {
|
describe('Velody API wiring (e2e)', () => {
|
||||||
let app: NestExpressApplication;
|
let app: NestExpressApplication;
|
||||||
let prismaMock: ReturnType<typeof createPrismaMock>['prismaMock'];
|
|
||||||
let assetsController: AssetsController;
|
let assetsController: AssetsController;
|
||||||
let artworkController: ArtworkController;
|
let artworkController: ArtworkController;
|
||||||
let healthController: HealthController;
|
let healthController: HealthController;
|
||||||
@ -348,15 +321,11 @@ describe('Velody API wiring (e2e)', () => {
|
|||||||
let syncController: SyncController;
|
let syncController: SyncController;
|
||||||
let uploadsController: UploadsController;
|
let uploadsController: UploadsController;
|
||||||
let uploadsService: UploadsService;
|
let uploadsService: UploadsService;
|
||||||
let requestContextService: RequestContextService;
|
|
||||||
let deviceAuthService: DeviceAuthService;
|
|
||||||
let prismaState: ReturnType<typeof createPrismaMock>['state'];
|
let prismaState: ReturnType<typeof createPrismaMock>['state'];
|
||||||
let storageRoot: string;
|
let storageRoot: string;
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
const prismaSetup = createPrismaMock();
|
const { prismaMock, state } = createPrismaMock();
|
||||||
prismaMock = prismaSetup.prismaMock;
|
|
||||||
const { state } = prismaSetup;
|
|
||||||
prismaState = state;
|
prismaState = state;
|
||||||
storageRoot = await mkdtemp(join(tmpdir(), 'velody-e2e-'));
|
storageRoot = await mkdtemp(join(tmpdir(), 'velody-e2e-'));
|
||||||
|
|
||||||
@ -398,8 +367,6 @@ describe('Velody API wiring (e2e)', () => {
|
|||||||
syncController = moduleRef.get(SyncController);
|
syncController = moduleRef.get(SyncController);
|
||||||
uploadsController = moduleRef.get(UploadsController);
|
uploadsController = moduleRef.get(UploadsController);
|
||||||
uploadsService = moduleRef.get(UploadsService);
|
uploadsService = moduleRef.get(UploadsService);
|
||||||
requestContextService = moduleRef.get(RequestContextService);
|
|
||||||
deviceAuthService = moduleRef.get(DeviceAuthService);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(async () => {
|
afterEach(async () => {
|
||||||
@ -418,23 +385,6 @@ describe('Velody API wiring (e2e)', () => {
|
|||||||
expect(response.version).toBe('0.1.0');
|
expect(response.version).toBe('0.1.0');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('creates the bootstrap default owner during application startup', () => {
|
|
||||||
expect(prismaMock.user.upsert).toHaveBeenCalledWith({
|
|
||||||
where: {
|
|
||||||
slug: 'default-owner',
|
|
||||||
},
|
|
||||||
update: {
|
|
||||||
displayName: 'Default Owner',
|
|
||||||
isDefault: true,
|
|
||||||
},
|
|
||||||
create: {
|
|
||||||
slug: 'default-owner',
|
|
||||||
displayName: 'Default Owner',
|
|
||||||
isDefault: true,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('registers a device and accepts heartbeat', async () => {
|
it('registers a device and accepts heartbeat', async () => {
|
||||||
const registerResponse = await devicesController.register({
|
const registerResponse = await devicesController.register({
|
||||||
platform: 'MACOS',
|
platform: 'MACOS',
|
||||||
@ -443,17 +393,7 @@ describe('Velody API wiring (e2e)', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
expect(registerResponse.deviceId).toBeDefined();
|
expect(registerResponse.deviceId).toBeDefined();
|
||||||
expect(registerResponse.deviceAccessToken).toBeDefined();
|
|
||||||
expect(registerResponse.bootstrapToken).toBeDefined();
|
expect(registerResponse.bootstrapToken).toBeDefined();
|
||||||
expect(prismaState.devices.get(registerResponse.deviceId)?.userId).toBe(
|
|
||||||
prismaState.defaultUser.id,
|
|
||||||
);
|
|
||||||
expect(prismaState.devices.get(registerResponse.deviceId)?.tokenHash).toBe(
|
|
||||||
sha256Hex(Buffer.from(registerResponse.deviceAccessToken, 'utf8')),
|
|
||||||
);
|
|
||||||
expect(prismaState.devices.get(registerResponse.deviceId)?.tokenCreatedAt).toEqual(
|
|
||||||
expect.any(Date),
|
|
||||||
);
|
|
||||||
|
|
||||||
const heartbeatResponse = await devicesController.heartbeat({
|
const heartbeatResponse = await devicesController.heartbeat({
|
||||||
deviceId: registerResponse.deviceId,
|
deviceId: registerResponse.deviceId,
|
||||||
@ -463,28 +403,6 @@ describe('Velody API wiring (e2e)', () => {
|
|||||||
expect(heartbeatResponse.ok).toBe(true);
|
expect(heartbeatResponse.ok).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('rejects heartbeat updates for a foreign-owner device', async () => {
|
|
||||||
const foreignDeviceId = randomUUID();
|
|
||||||
prismaState.devices.set(foreignDeviceId, {
|
|
||||||
id: foreignDeviceId,
|
|
||||||
userId: randomUUID(),
|
|
||||||
platform: 'MACOS',
|
|
||||||
deviceName: 'Foreign Mac',
|
|
||||||
appVersion: '0.1.0',
|
|
||||||
installTokenHash: 'foreign-device-hash',
|
|
||||||
lastSeenAt: new Date(),
|
|
||||||
createdAt: new Date(),
|
|
||||||
updatedAt: new Date(),
|
|
||||||
});
|
|
||||||
|
|
||||||
await expect(
|
|
||||||
devicesController.heartbeat({
|
|
||||||
deviceId: foreignDeviceId,
|
|
||||||
appVersion: '0.1.1',
|
|
||||||
}),
|
|
||||||
).rejects.toBeInstanceOf(NotFoundException);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns sync bootstrap and changes payloads', async () => {
|
it('returns sync bootstrap and changes payloads', async () => {
|
||||||
const bootstrapResponse = await syncController.bootstrap();
|
const bootstrapResponse = await syncController.bootstrap();
|
||||||
const changesResponse = await syncController.changes({ after: '0' });
|
const changesResponse = await syncController.changes({ after: '0' });
|
||||||
@ -494,47 +412,6 @@ describe('Velody API wiring (e2e)', () => {
|
|||||||
expect(changesResponse.nextCursor).toBe('0');
|
expect(changesResponse.nextCursor).toBe('0');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('sync bootstrap and changes do not expose foreign-owner data', async () => {
|
|
||||||
const foreignUserId = randomUUID();
|
|
||||||
const foreignTrackId = randomUUID();
|
|
||||||
|
|
||||||
prismaState.tracks.set(foreignTrackId, {
|
|
||||||
id: foreignTrackId,
|
|
||||||
userId: foreignUserId,
|
|
||||||
primaryAudioAssetId: null,
|
|
||||||
artworkAssetId: null,
|
|
||||||
title: 'Foreign Bootstrap Track',
|
|
||||||
artist: 'Elsewhere',
|
|
||||||
album: null,
|
|
||||||
albumArtist: null,
|
|
||||||
genre: null,
|
|
||||||
discNumber: null,
|
|
||||||
trackNumber: null,
|
|
||||||
year: null,
|
|
||||||
durationMs: 180000,
|
|
||||||
status: 'ACTIVE',
|
|
||||||
deletedAt: null,
|
|
||||||
createdAt: new Date('2026-05-29T08:00:00.000Z'),
|
|
||||||
updatedAt: new Date('2026-05-29T08:01:00.000Z'),
|
|
||||||
});
|
|
||||||
prismaState.libraryEvents.set(1n, {
|
|
||||||
id: 1n,
|
|
||||||
userId: foreignUserId,
|
|
||||||
entityType: 'TRACK',
|
|
||||||
entityId: foreignTrackId,
|
|
||||||
action: 'CREATED',
|
|
||||||
payloadVersion: 1,
|
|
||||||
createdAt: new Date('2026-05-29T08:02:00.000Z'),
|
|
||||||
});
|
|
||||||
|
|
||||||
const bootstrapResponse = await syncController.bootstrap();
|
|
||||||
const changesResponse = await syncController.changes({ after: '0' });
|
|
||||||
|
|
||||||
expect(bootstrapResponse.tracks).toEqual([]);
|
|
||||||
expect(changesResponse.events).toEqual([]);
|
|
||||||
expect(changesResponse.nextCursor).toBe('0');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('downloads audio asset bytes for the owning device user', async () => {
|
it('downloads audio asset bytes for the owning device user', async () => {
|
||||||
const registerResponse = await devicesController.register({
|
const registerResponse = await devicesController.register({
|
||||||
platform: 'IPHONE',
|
platform: 'IPHONE',
|
||||||
@ -733,36 +610,6 @@ describe('Velody API wiring (e2e)', () => {
|
|||||||
expect(headers.get('content-length')).toBe(String(bytes.length));
|
expect(headers.get('content-length')).toBe(String(bytes.length));
|
||||||
});
|
});
|
||||||
|
|
||||||
it('rejects artwork download requests for another user artwork', async () => {
|
|
||||||
const registerResponse = await devicesController.register({
|
|
||||||
platform: 'IPHONE',
|
|
||||||
deviceName: 'Artwork iPhone',
|
|
||||||
appVersion: '0.1.0',
|
|
||||||
});
|
|
||||||
const artworkId = randomUUID();
|
|
||||||
const otherUserId = randomUUID();
|
|
||||||
|
|
||||||
prismaState.artworkAssets.set(artworkId, {
|
|
||||||
id: artworkId,
|
|
||||||
userId: otherUserId,
|
|
||||||
sha256: 'sha-other-artwork',
|
|
||||||
storageKey: join('users', otherUserId, 'artwork', 'sha-other-artwork.png'),
|
|
||||||
mimeType: 'image/png',
|
|
||||||
width: 1,
|
|
||||||
height: 1,
|
|
||||||
fileSizeBytes: BigInt(10),
|
|
||||||
createdAt: new Date('2026-05-29T08:00:00.000Z'),
|
|
||||||
});
|
|
||||||
|
|
||||||
await expect(
|
|
||||||
artworkController.download(
|
|
||||||
artworkId,
|
|
||||||
{ deviceId: registerResponse.deviceId },
|
|
||||||
{ setHeader() {} } as any,
|
|
||||||
),
|
|
||||||
).rejects.toBeInstanceOf(ForbiddenException);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns not found when the requested artwork file is missing', async () => {
|
it('returns not found when the requested artwork file is missing', async () => {
|
||||||
const registerResponse = await devicesController.register({
|
const registerResponse = await devicesController.register({
|
||||||
platform: 'IPHONE',
|
platform: 'IPHONE',
|
||||||
@ -1023,304 +870,6 @@ describe('Velody API wiring (e2e)', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('authenticates remote library requests with a valid device token and ignores spoofed device ids', async () => {
|
|
||||||
const ownerDevice = await devicesController.register({
|
|
||||||
platform: 'IPHONE',
|
|
||||||
deviceName: 'Auth iPhone',
|
|
||||||
appVersion: '0.1.0',
|
|
||||||
});
|
|
||||||
const foreignUserId = randomUUID();
|
|
||||||
const foreignDeviceId = randomUUID();
|
|
||||||
const ownerTrackId = randomUUID();
|
|
||||||
const ownerAssetId = randomUUID();
|
|
||||||
const foreignTrackId = randomUUID();
|
|
||||||
const foreignAssetId = randomUUID();
|
|
||||||
|
|
||||||
prismaState.devices.set(foreignDeviceId, {
|
|
||||||
id: foreignDeviceId,
|
|
||||||
userId: foreignUserId,
|
|
||||||
platform: 'IPHONE',
|
|
||||||
deviceName: 'Spoofed Foreign iPhone',
|
|
||||||
appVersion: '0.1.0',
|
|
||||||
installTokenHash: 'spoofed-device-hash',
|
|
||||||
tokenHash: sha256Hex(Buffer.from('foreign-device-token', 'utf8')),
|
|
||||||
tokenCreatedAt: new Date(),
|
|
||||||
lastSeenAt: new Date(),
|
|
||||||
createdAt: new Date(),
|
|
||||||
updatedAt: new Date(),
|
|
||||||
});
|
|
||||||
|
|
||||||
prismaState.audioAssets.set(ownerAssetId, {
|
|
||||||
id: ownerAssetId,
|
|
||||||
userId: prismaState.defaultUser.id,
|
|
||||||
trackId: ownerTrackId,
|
|
||||||
sha256: 'owner-library-sha',
|
|
||||||
storageKey: 'users/default/audio/owner-library-sha.mp3',
|
|
||||||
originalFilename: 'owner-library.mp3',
|
|
||||||
mimeType: 'audio/mpeg',
|
|
||||||
fileExtension: 'mp3',
|
|
||||||
fileSizeBytes: BigInt(42),
|
|
||||||
durationMs: 183000,
|
|
||||||
sourceDeviceId: ownerDevice.deviceId,
|
|
||||||
createdAt: new Date('2026-05-29T08:00:00.000Z'),
|
|
||||||
});
|
|
||||||
prismaState.audioAssets.set(foreignAssetId, {
|
|
||||||
id: foreignAssetId,
|
|
||||||
userId: foreignUserId,
|
|
||||||
trackId: foreignTrackId,
|
|
||||||
sha256: 'foreign-library-sha',
|
|
||||||
storageKey: 'users/foreign/audio/foreign-library-sha.mp3',
|
|
||||||
originalFilename: 'foreign-library.mp3',
|
|
||||||
mimeType: 'audio/mpeg',
|
|
||||||
fileExtension: 'mp3',
|
|
||||||
fileSizeBytes: BigInt(42),
|
|
||||||
durationMs: 204000,
|
|
||||||
sourceDeviceId: foreignDeviceId,
|
|
||||||
createdAt: new Date('2026-05-29T08:01:00.000Z'),
|
|
||||||
});
|
|
||||||
prismaState.tracks.set(ownerTrackId, {
|
|
||||||
id: ownerTrackId,
|
|
||||||
userId: prismaState.defaultUser.id,
|
|
||||||
primaryAudioAssetId: ownerAssetId,
|
|
||||||
artworkAssetId: null,
|
|
||||||
title: 'Authenticated Owner Track',
|
|
||||||
artist: 'Velody',
|
|
||||||
album: null,
|
|
||||||
albumArtist: null,
|
|
||||||
genre: null,
|
|
||||||
discNumber: null,
|
|
||||||
trackNumber: null,
|
|
||||||
year: null,
|
|
||||||
durationMs: 183000,
|
|
||||||
status: 'ACTIVE',
|
|
||||||
deletedAt: null,
|
|
||||||
createdAt: new Date('2026-05-29T08:00:00.000Z'),
|
|
||||||
updatedAt: new Date('2026-05-29T08:02:00.000Z'),
|
|
||||||
});
|
|
||||||
prismaState.tracks.set(foreignTrackId, {
|
|
||||||
id: foreignTrackId,
|
|
||||||
userId: foreignUserId,
|
|
||||||
primaryAudioAssetId: foreignAssetId,
|
|
||||||
artworkAssetId: null,
|
|
||||||
title: 'Foreign Track',
|
|
||||||
artist: 'Elsewhere',
|
|
||||||
album: null,
|
|
||||||
albumArtist: null,
|
|
||||||
genre: null,
|
|
||||||
discNumber: null,
|
|
||||||
trackNumber: null,
|
|
||||||
year: null,
|
|
||||||
durationMs: 204000,
|
|
||||||
status: 'ACTIVE',
|
|
||||||
deletedAt: null,
|
|
||||||
createdAt: new Date('2026-05-29T08:03:00.000Z'),
|
|
||||||
updatedAt: new Date('2026-05-29T08:04:00.000Z'),
|
|
||||||
});
|
|
||||||
|
|
||||||
await requestContextService.run(async () => {
|
|
||||||
await deviceAuthService.authenticateAuthorizationHeader(
|
|
||||||
`Bearer ${ownerDevice.deviceAccessToken}`,
|
|
||||||
);
|
|
||||||
|
|
||||||
await expect(
|
|
||||||
libraryController.getTracks({
|
|
||||||
deviceId: foreignDeviceId,
|
|
||||||
}),
|
|
||||||
).resolves.toEqual({
|
|
||||||
tracks: [
|
|
||||||
expect.objectContaining({
|
|
||||||
trackId: ownerTrackId,
|
|
||||||
title: 'Authenticated Owner Track',
|
|
||||||
assetId: ownerAssetId,
|
|
||||||
}),
|
|
||||||
],
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('keeps the legacy library deviceId path working when Authorization is missing', async () => {
|
|
||||||
const ownerDevice = await devicesController.register({
|
|
||||||
platform: 'IPHONE',
|
|
||||||
deviceName: 'Legacy iPhone',
|
|
||||||
appVersion: '0.1.0',
|
|
||||||
});
|
|
||||||
const trackId = randomUUID();
|
|
||||||
const assetId = randomUUID();
|
|
||||||
|
|
||||||
prismaState.audioAssets.set(assetId, {
|
|
||||||
id: assetId,
|
|
||||||
userId: prismaState.defaultUser.id,
|
|
||||||
trackId,
|
|
||||||
sha256: 'legacy-library-sha',
|
|
||||||
storageKey: 'users/default/audio/legacy-library-sha.mp3',
|
|
||||||
originalFilename: 'legacy-library.mp3',
|
|
||||||
mimeType: 'audio/mpeg',
|
|
||||||
fileExtension: 'mp3',
|
|
||||||
fileSizeBytes: BigInt(42),
|
|
||||||
durationMs: 210000,
|
|
||||||
sourceDeviceId: ownerDevice.deviceId,
|
|
||||||
createdAt: new Date('2026-05-29T08:00:00.000Z'),
|
|
||||||
});
|
|
||||||
prismaState.tracks.set(trackId, {
|
|
||||||
id: trackId,
|
|
||||||
userId: prismaState.defaultUser.id,
|
|
||||||
primaryAudioAssetId: assetId,
|
|
||||||
artworkAssetId: null,
|
|
||||||
title: 'Legacy Library Track',
|
|
||||||
artist: 'Velody',
|
|
||||||
album: null,
|
|
||||||
albumArtist: null,
|
|
||||||
genre: null,
|
|
||||||
discNumber: null,
|
|
||||||
trackNumber: null,
|
|
||||||
year: null,
|
|
||||||
durationMs: 210000,
|
|
||||||
status: 'ACTIVE',
|
|
||||||
deletedAt: null,
|
|
||||||
createdAt: new Date('2026-05-29T08:00:00.000Z'),
|
|
||||||
updatedAt: new Date('2026-05-29T08:02:00.000Z'),
|
|
||||||
});
|
|
||||||
|
|
||||||
const response = await libraryController.getTracks({
|
|
||||||
deviceId: ownerDevice.deviceId,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(response.tracks).toEqual([
|
|
||||||
expect.objectContaining({
|
|
||||||
trackId,
|
|
||||||
assetId,
|
|
||||||
}),
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('rejects invalid or revoked device tokens even when a legacy device id is supplied', async () => {
|
|
||||||
const ownerDevice = await devicesController.register({
|
|
||||||
platform: 'IPHONE',
|
|
||||||
deviceName: 'Rejected iPhone',
|
|
||||||
appVersion: '0.1.0',
|
|
||||||
});
|
|
||||||
|
|
||||||
await requestContextService.run(async () => {
|
|
||||||
await expect(
|
|
||||||
deviceAuthService.authenticateAuthorizationHeader(
|
|
||||||
'Bearer invalid-device-token',
|
|
||||||
),
|
|
||||||
).rejects.toBeInstanceOf(UnauthorizedException);
|
|
||||||
});
|
|
||||||
|
|
||||||
prismaState.devices.set(ownerDevice.deviceId, {
|
|
||||||
...prismaState.devices.get(ownerDevice.deviceId),
|
|
||||||
tokenRevokedAt: new Date('2026-06-09T10:00:00.000Z'),
|
|
||||||
});
|
|
||||||
|
|
||||||
await requestContextService.run(async () => {
|
|
||||||
await expect(
|
|
||||||
deviceAuthService.authenticateAuthorizationHeader(
|
|
||||||
`Bearer ${ownerDevice.deviceAccessToken}`,
|
|
||||||
),
|
|
||||||
).rejects.toBeInstanceOf(UnauthorizedException);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('rejects cross-owner asset and artwork downloads for authenticated tokens', async () => {
|
|
||||||
const ownerDevice = await devicesController.register({
|
|
||||||
platform: 'IPHONE',
|
|
||||||
deviceName: 'Owner iPhone',
|
|
||||||
appVersion: '0.1.0',
|
|
||||||
});
|
|
||||||
const foreignUserId = randomUUID();
|
|
||||||
const foreignDeviceId = randomUUID();
|
|
||||||
const foreignAssetId = randomUUID();
|
|
||||||
const foreignArtworkId = randomUUID();
|
|
||||||
|
|
||||||
prismaState.devices.set(foreignDeviceId, {
|
|
||||||
id: foreignDeviceId,
|
|
||||||
userId: foreignUserId,
|
|
||||||
platform: 'IPHONE',
|
|
||||||
deviceName: 'Foreign iPhone',
|
|
||||||
appVersion: '0.1.0',
|
|
||||||
installTokenHash: 'foreign-install-hash',
|
|
||||||
tokenHash: sha256Hex(Buffer.from('foreign-access-token', 'utf8')),
|
|
||||||
tokenCreatedAt: new Date(),
|
|
||||||
lastSeenAt: new Date(),
|
|
||||||
createdAt: new Date(),
|
|
||||||
updatedAt: new Date(),
|
|
||||||
});
|
|
||||||
prismaState.audioAssets.set(foreignAssetId, {
|
|
||||||
id: foreignAssetId,
|
|
||||||
userId: foreignUserId,
|
|
||||||
trackId: randomUUID(),
|
|
||||||
sha256: 'foreign-asset-sha',
|
|
||||||
storageKey: 'users/foreign/audio/foreign-asset-sha.mp3',
|
|
||||||
originalFilename: 'foreign-asset.mp3',
|
|
||||||
mimeType: 'audio/mpeg',
|
|
||||||
fileExtension: 'mp3',
|
|
||||||
fileSizeBytes: BigInt(64),
|
|
||||||
durationMs: 190000,
|
|
||||||
sourceDeviceId: foreignDeviceId,
|
|
||||||
createdAt: new Date(),
|
|
||||||
});
|
|
||||||
prismaState.artworkAssets.set(foreignArtworkId, {
|
|
||||||
id: foreignArtworkId,
|
|
||||||
userId: foreignUserId,
|
|
||||||
sha256: 'foreign-artwork-sha',
|
|
||||||
storageKey: 'users/foreign/artwork/foreign-artwork-sha.png',
|
|
||||||
mimeType: 'image/png',
|
|
||||||
width: 512,
|
|
||||||
height: 512,
|
|
||||||
fileSizeBytes: BigInt(64),
|
|
||||||
createdAt: new Date(),
|
|
||||||
tracks: [],
|
|
||||||
});
|
|
||||||
|
|
||||||
await requestContextService.run(async () => {
|
|
||||||
await deviceAuthService.authenticateAuthorizationHeader(
|
|
||||||
`Bearer ${ownerDevice.deviceAccessToken}`,
|
|
||||||
);
|
|
||||||
|
|
||||||
await expect(
|
|
||||||
assetsController.download(
|
|
||||||
foreignAssetId,
|
|
||||||
{},
|
|
||||||
{ setHeader() {} } as any,
|
|
||||||
),
|
|
||||||
).rejects.toBeInstanceOf(ForbiddenException);
|
|
||||||
await expect(
|
|
||||||
artworkController.download(
|
|
||||||
foreignArtworkId,
|
|
||||||
{},
|
|
||||||
{ setHeader() {} } as any,
|
|
||||||
),
|
|
||||||
).rejects.toBeInstanceOf(ForbiddenException);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('updates tokenLastUsedAt during authenticated heartbeat', async () => {
|
|
||||||
const ownerDevice = await devicesController.register({
|
|
||||||
platform: 'MACOS',
|
|
||||||
deviceName: 'Heartbeat Mac',
|
|
||||||
appVersion: '0.1.0',
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(prismaState.devices.get(ownerDevice.deviceId)?.tokenLastUsedAt).toBeUndefined();
|
|
||||||
|
|
||||||
await requestContextService.run(async () => {
|
|
||||||
await deviceAuthService.authenticateAuthorizationHeader(
|
|
||||||
`Bearer ${ownerDevice.deviceAccessToken}`,
|
|
||||||
);
|
|
||||||
|
|
||||||
const response = await devicesController.heartbeat({
|
|
||||||
appVersion: '0.1.1',
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(response.ok).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(prismaState.devices.get(ownerDevice.deviceId)?.tokenLastUsedAt).toEqual(
|
|
||||||
expect.any(Date),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
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',
|
||||||
|
|||||||
@ -93,18 +93,15 @@ public struct DeviceRegistrationPayload: Codable, Hashable, Sendable {
|
|||||||
|
|
||||||
public struct DeviceRegistrationResponse: Codable, Hashable, Sendable {
|
public struct DeviceRegistrationResponse: Codable, Hashable, Sendable {
|
||||||
public var deviceId: String
|
public var deviceId: String
|
||||||
public var deviceAccessToken: String
|
|
||||||
public var bootstrapToken: String
|
public var bootstrapToken: String
|
||||||
public var serverTime: String
|
public var serverTime: String
|
||||||
|
|
||||||
public init(
|
public init(
|
||||||
deviceId: String,
|
deviceId: String,
|
||||||
deviceAccessToken: String,
|
|
||||||
bootstrapToken: String,
|
bootstrapToken: String,
|
||||||
serverTime: String
|
serverTime: String
|
||||||
) {
|
) {
|
||||||
self.deviceId = deviceId
|
self.deviceId = deviceId
|
||||||
self.deviceAccessToken = deviceAccessToken
|
|
||||||
self.bootstrapToken = bootstrapToken
|
self.bootstrapToken = bootstrapToken
|
||||||
self.serverTime = serverTime
|
self.serverTime = serverTime
|
||||||
}
|
}
|
||||||
|
|||||||
@ -73,26 +73,21 @@ public protocol VelodyAPIClient: Sendable {
|
|||||||
) async throws -> UploadFinalizeResponse
|
) async throws -> UploadFinalizeResponse
|
||||||
}
|
}
|
||||||
|
|
||||||
public typealias DeviceAccessTokenProvider = @Sendable () async throws -> String?
|
|
||||||
|
|
||||||
public struct URLSessionVelodyAPIClient: VelodyAPIClient {
|
public struct URLSessionVelodyAPIClient: VelodyAPIClient {
|
||||||
public let environment: ServerEnvironment
|
public let environment: ServerEnvironment
|
||||||
|
|
||||||
private let session: URLSession
|
private let session: URLSession
|
||||||
private let encoder: JSONEncoder
|
private let encoder: JSONEncoder
|
||||||
private let decoder: JSONDecoder
|
private let decoder: JSONDecoder
|
||||||
private let deviceAccessTokenProvider: DeviceAccessTokenProvider?
|
|
||||||
|
|
||||||
public init(
|
public init(
|
||||||
environment: ServerEnvironment,
|
environment: ServerEnvironment,
|
||||||
session: URLSession = .shared,
|
session: URLSession = .shared
|
||||||
deviceAccessTokenProvider: DeviceAccessTokenProvider? = nil
|
|
||||||
) {
|
) {
|
||||||
self.environment = environment
|
self.environment = environment
|
||||||
self.session = session
|
self.session = session
|
||||||
self.encoder = JSONEncoder()
|
self.encoder = JSONEncoder()
|
||||||
self.decoder = JSONDecoder()
|
self.decoder = JSONDecoder()
|
||||||
self.deviceAccessTokenProvider = deviceAccessTokenProvider
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public func registerDevice(
|
public func registerDevice(
|
||||||
@ -113,7 +108,6 @@ public struct URLSessionVelodyAPIClient: VelodyAPIClient {
|
|||||||
method: "POST",
|
method: "POST",
|
||||||
pathComponents: ["api", "v1", "devices", "heartbeat"],
|
pathComponents: ["api", "v1", "devices", "heartbeat"],
|
||||||
body: payload,
|
body: payload,
|
||||||
includesDeviceAuthorization: true,
|
|
||||||
responseType: DeviceHeartbeatResponse.self
|
responseType: DeviceHeartbeatResponse.self
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@ -122,7 +116,6 @@ public struct URLSessionVelodyAPIClient: VelodyAPIClient {
|
|||||||
try await sendRequest(
|
try await sendRequest(
|
||||||
method: "GET",
|
method: "GET",
|
||||||
pathComponents: ["api", "v1", "sync", "bootstrap"],
|
pathComponents: ["api", "v1", "sync", "bootstrap"],
|
||||||
includesDeviceAuthorization: true,
|
|
||||||
responseType: SyncBootstrapResponse.self
|
responseType: SyncBootstrapResponse.self
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@ -136,7 +129,6 @@ public struct URLSessionVelodyAPIClient: VelodyAPIClient {
|
|||||||
queryItems: [
|
queryItems: [
|
||||||
URLQueryItem(name: "deviceId", value: deviceId),
|
URLQueryItem(name: "deviceId", value: deviceId),
|
||||||
],
|
],
|
||||||
includesDeviceAuthorization: true,
|
|
||||||
responseType: RemoteLibraryResponseDTO.self
|
responseType: RemoteLibraryResponseDTO.self
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@ -145,14 +137,13 @@ public struct URLSessionVelodyAPIClient: VelodyAPIClient {
|
|||||||
assetId: String,
|
assetId: String,
|
||||||
deviceId: String
|
deviceId: String
|
||||||
) async throws -> Data {
|
) async throws -> Data {
|
||||||
let request = try await buildRequest(
|
let request = try buildRequest(
|
||||||
method: "GET",
|
method: "GET",
|
||||||
pathComponents: ["api", "v1", "assets", assetId, "download"],
|
pathComponents: ["api", "v1", "assets", assetId, "download"],
|
||||||
queryItems: [
|
queryItems: [
|
||||||
URLQueryItem(name: "deviceId", value: deviceId),
|
URLQueryItem(name: "deviceId", value: deviceId),
|
||||||
],
|
],
|
||||||
bodyData: nil,
|
bodyData: nil,
|
||||||
includesDeviceAuthorization: true,
|
|
||||||
acceptType: "audio/mpeg"
|
acceptType: "audio/mpeg"
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -163,14 +154,13 @@ public struct URLSessionVelodyAPIClient: VelodyAPIClient {
|
|||||||
artworkId: String,
|
artworkId: String,
|
||||||
deviceId: String
|
deviceId: String
|
||||||
) async throws -> Data {
|
) async throws -> Data {
|
||||||
let request = try await buildRequest(
|
let request = try buildRequest(
|
||||||
method: "GET",
|
method: "GET",
|
||||||
pathComponents: ["api", "v1", "artwork", artworkId, "download"],
|
pathComponents: ["api", "v1", "artwork", artworkId, "download"],
|
||||||
queryItems: [
|
queryItems: [
|
||||||
URLQueryItem(name: "deviceId", value: deviceId),
|
URLQueryItem(name: "deviceId", value: deviceId),
|
||||||
],
|
],
|
||||||
bodyData: nil,
|
bodyData: nil,
|
||||||
includesDeviceAuthorization: true,
|
|
||||||
acceptType: "image/*"
|
acceptType: "image/*"
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -184,7 +174,6 @@ public struct URLSessionVelodyAPIClient: VelodyAPIClient {
|
|||||||
method: "POST",
|
method: "POST",
|
||||||
pathComponents: ["api", "v1", "uploads", "prepare"],
|
pathComponents: ["api", "v1", "uploads", "prepare"],
|
||||||
body: payload,
|
body: payload,
|
||||||
includesDeviceAuthorization: true,
|
|
||||||
responseType: UploadPrepareResponse.self
|
responseType: UploadPrepareResponse.self
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@ -195,7 +184,6 @@ public struct URLSessionVelodyAPIClient: VelodyAPIClient {
|
|||||||
try await sendRequest(
|
try await sendRequest(
|
||||||
method: "GET",
|
method: "GET",
|
||||||
pathComponents: ["api", "v1", "uploads", uploadId],
|
pathComponents: ["api", "v1", "uploads", uploadId],
|
||||||
includesDeviceAuthorization: true,
|
|
||||||
responseType: UploadSessionStatusResponse.self
|
responseType: UploadSessionStatusResponse.self
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@ -209,12 +197,11 @@ public struct URLSessionVelodyAPIClient: VelodyAPIClient {
|
|||||||
throw VelodyAPIError.requestFailed("The selected file could not be found.")
|
throw VelodyAPIError.requestFailed("The selected file could not be found.")
|
||||||
}
|
}
|
||||||
|
|
||||||
let request = try await buildRequest(
|
let request = try buildRequest(
|
||||||
method: "PUT",
|
method: "PUT",
|
||||||
pathComponents: ["api", "v1", "uploads", uploadId, "file"],
|
pathComponents: ["api", "v1", "uploads", uploadId, "file"],
|
||||||
queryItems: [],
|
queryItems: [],
|
||||||
bodyData: nil,
|
bodyData: nil,
|
||||||
includesDeviceAuthorization: true,
|
|
||||||
contentType: mimeType
|
contentType: mimeType
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -242,7 +229,6 @@ public struct URLSessionVelodyAPIClient: VelodyAPIClient {
|
|||||||
method: "POST",
|
method: "POST",
|
||||||
pathComponents: ["api", "v1", "uploads", uploadId, "finalize"],
|
pathComponents: ["api", "v1", "uploads", uploadId, "finalize"],
|
||||||
body: payload,
|
body: payload,
|
||||||
includesDeviceAuthorization: true,
|
|
||||||
responseType: UploadFinalizeResponse.self
|
responseType: UploadFinalizeResponse.self
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@ -251,15 +237,13 @@ public struct URLSessionVelodyAPIClient: VelodyAPIClient {
|
|||||||
method: String,
|
method: String,
|
||||||
pathComponents: [String],
|
pathComponents: [String],
|
||||||
queryItems: [URLQueryItem] = [],
|
queryItems: [URLQueryItem] = [],
|
||||||
includesDeviceAuthorization: Bool = false,
|
|
||||||
responseType: Response.Type
|
responseType: Response.Type
|
||||||
) async throws -> Response {
|
) async throws -> Response {
|
||||||
let request = try await buildRequest(
|
let request = try buildRequest(
|
||||||
method: method,
|
method: method,
|
||||||
pathComponents: pathComponents,
|
pathComponents: pathComponents,
|
||||||
queryItems: queryItems,
|
queryItems: queryItems,
|
||||||
bodyData: nil,
|
bodyData: nil
|
||||||
includesDeviceAuthorization: includesDeviceAuthorization
|
|
||||||
)
|
)
|
||||||
|
|
||||||
return try await execute(request, responseType: responseType)
|
return try await execute(request, responseType: responseType)
|
||||||
@ -270,7 +254,6 @@ public struct URLSessionVelodyAPIClient: VelodyAPIClient {
|
|||||||
pathComponents: [String],
|
pathComponents: [String],
|
||||||
queryItems: [URLQueryItem] = [],
|
queryItems: [URLQueryItem] = [],
|
||||||
body: Body,
|
body: Body,
|
||||||
includesDeviceAuthorization: Bool = false,
|
|
||||||
responseType: Response.Type
|
responseType: Response.Type
|
||||||
) async throws -> Response {
|
) async throws -> Response {
|
||||||
let bodyData: Data
|
let bodyData: Data
|
||||||
@ -281,12 +264,11 @@ public struct URLSessionVelodyAPIClient: VelodyAPIClient {
|
|||||||
throw VelodyAPIError.requestFailed(error.localizedDescription)
|
throw VelodyAPIError.requestFailed(error.localizedDescription)
|
||||||
}
|
}
|
||||||
|
|
||||||
let request = try await buildRequest(
|
let request = try buildRequest(
|
||||||
method: method,
|
method: method,
|
||||||
pathComponents: pathComponents,
|
pathComponents: pathComponents,
|
||||||
queryItems: queryItems,
|
queryItems: queryItems,
|
||||||
bodyData: bodyData,
|
bodyData: bodyData,
|
||||||
includesDeviceAuthorization: includesDeviceAuthorization,
|
|
||||||
contentType: "application/json"
|
contentType: "application/json"
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -298,10 +280,9 @@ public struct URLSessionVelodyAPIClient: VelodyAPIClient {
|
|||||||
pathComponents: [String],
|
pathComponents: [String],
|
||||||
queryItems: [URLQueryItem],
|
queryItems: [URLQueryItem],
|
||||||
bodyData: Data?,
|
bodyData: Data?,
|
||||||
includesDeviceAuthorization: Bool = false,
|
|
||||||
contentType: String? = nil,
|
contentType: String? = nil,
|
||||||
acceptType: String = "application/json"
|
acceptType: String = "application/json"
|
||||||
) async throws -> URLRequest {
|
) throws -> URLRequest {
|
||||||
guard let url = endpointURL(
|
guard let url = endpointURL(
|
||||||
pathComponents: pathComponents,
|
pathComponents: pathComponents,
|
||||||
queryItems: queryItems
|
queryItems: queryItems
|
||||||
@ -321,30 +302,9 @@ public struct URLSessionVelodyAPIClient: VelodyAPIClient {
|
|||||||
request.setValue(contentType, forHTTPHeaderField: "Content-Type")
|
request.setValue(contentType, forHTTPHeaderField: "Content-Type")
|
||||||
}
|
}
|
||||||
|
|
||||||
if includesDeviceAuthorization,
|
|
||||||
let deviceAccessToken = try await loadDeviceAccessToken(),
|
|
||||||
!deviceAccessToken.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
|
||||||
request.setValue(
|
|
||||||
"Bearer \(deviceAccessToken)",
|
|
||||||
forHTTPHeaderField: "Authorization"
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return request
|
return request
|
||||||
}
|
}
|
||||||
|
|
||||||
private func loadDeviceAccessToken() async throws -> String? {
|
|
||||||
guard let deviceAccessTokenProvider else {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
do {
|
|
||||||
return try await deviceAccessTokenProvider()
|
|
||||||
} catch {
|
|
||||||
throw VelodyAPIError.requestFailed(error.localizedDescription)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func execute<Response: Decodable>(
|
private func execute<Response: Decodable>(
|
||||||
_ request: URLRequest,
|
_ request: URLRequest,
|
||||||
responseType: Response.Type
|
responseType: Response.Type
|
||||||
@ -444,7 +404,6 @@ public struct StubVelodyAPIClient: VelodyAPIClient {
|
|||||||
|
|
||||||
return DeviceRegistrationResponse(
|
return DeviceRegistrationResponse(
|
||||||
deviceId: UUID().uuidString,
|
deviceId: UUID().uuidString,
|
||||||
deviceAccessToken: "stub-device-access-token",
|
|
||||||
bootstrapToken: "stub-bootstrap-token",
|
bootstrapToken: "stub-bootstrap-token",
|
||||||
serverTime: ISO8601DateFormatter().string(from: .now)
|
serverTime: ISO8601DateFormatter().string(from: .now)
|
||||||
)
|
)
|
||||||
|
|||||||
@ -1,130 +0,0 @@
|
|||||||
import Foundation
|
|
||||||
import XCTest
|
|
||||||
import VelodyDomain
|
|
||||||
@testable import VelodyNetworking
|
|
||||||
|
|
||||||
final class URLSessionVelodyAPIClientAuthorizationTests: XCTestCase {
|
|
||||||
override func tearDown() {
|
|
||||||
RecordingURLProtocol.handler = nil
|
|
||||||
super.tearDown()
|
|
||||||
}
|
|
||||||
|
|
||||||
func testFetchRemoteLibrarySendsAuthorizationHeaderWhenDeviceTokenIsAvailable() async throws {
|
|
||||||
RecordingURLProtocol.handler = { request in
|
|
||||||
XCTAssertEqual(
|
|
||||||
request.value(forHTTPHeaderField: "Authorization"),
|
|
||||||
"Bearer test-device-access-token"
|
|
||||||
)
|
|
||||||
XCTAssertEqual(request.url?.path, "/api/v1/library/tracks")
|
|
||||||
XCTAssertEqual(request.url?.query, "deviceId=device-123")
|
|
||||||
|
|
||||||
return (
|
|
||||||
HTTPURLResponse(
|
|
||||||
url: try XCTUnwrap(request.url),
|
|
||||||
statusCode: 200,
|
|
||||||
httpVersion: nil,
|
|
||||||
headerFields: ["Content-Type": "application/json"]
|
|
||||||
)!,
|
|
||||||
Data(#"{"tracks":[]}"#.utf8)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
let client = URLSessionVelodyAPIClient(
|
|
||||||
environment: ServerEnvironment(
|
|
||||||
baseURL: URL(string: "http://127.0.0.1:3007")!,
|
|
||||||
appVersion: "Tests"
|
|
||||||
),
|
|
||||||
session: makeSession(),
|
|
||||||
deviceAccessTokenProvider: {
|
|
||||||
"test-device-access-token"
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
let response = try await client.fetchRemoteLibrary(deviceId: "device-123")
|
|
||||||
XCTAssertEqual(response.tracks.count, 0)
|
|
||||||
}
|
|
||||||
|
|
||||||
func testRegisterDeviceDoesNotSendAuthorizationHeader() async throws {
|
|
||||||
RecordingURLProtocol.handler = { request in
|
|
||||||
XCTAssertNil(request.value(forHTTPHeaderField: "Authorization"))
|
|
||||||
XCTAssertEqual(request.url?.path, "/api/v1/devices/register")
|
|
||||||
|
|
||||||
return (
|
|
||||||
HTTPURLResponse(
|
|
||||||
url: try XCTUnwrap(request.url),
|
|
||||||
statusCode: 200,
|
|
||||||
httpVersion: nil,
|
|
||||||
headerFields: ["Content-Type": "application/json"]
|
|
||||||
)!,
|
|
||||||
Data(
|
|
||||||
"""
|
|
||||||
{
|
|
||||||
"deviceId": "device-123",
|
|
||||||
"deviceAccessToken": "registered-device-token",
|
|
||||||
"bootstrapToken": "bootstrap-token",
|
|
||||||
"serverTime": "2026-06-09T10:00:00.000Z"
|
|
||||||
}
|
|
||||||
""".utf8
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
let client = URLSessionVelodyAPIClient(
|
|
||||||
environment: ServerEnvironment(
|
|
||||||
baseURL: URL(string: "http://127.0.0.1:3007")!,
|
|
||||||
appVersion: "Tests"
|
|
||||||
),
|
|
||||||
session: makeSession(),
|
|
||||||
deviceAccessTokenProvider: {
|
|
||||||
"should-not-be-sent"
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
let response = try await client.registerDevice(
|
|
||||||
DeviceRegistrationPayload(
|
|
||||||
platform: .iphone,
|
|
||||||
deviceName: "Test iPhone",
|
|
||||||
appVersion: "Tests"
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
XCTAssertEqual(response.deviceId, "device-123")
|
|
||||||
XCTAssertEqual(response.deviceAccessToken, "registered-device-token")
|
|
||||||
}
|
|
||||||
|
|
||||||
private func makeSession() -> URLSession {
|
|
||||||
let configuration = URLSessionConfiguration.ephemeral
|
|
||||||
configuration.protocolClasses = [RecordingURLProtocol.self]
|
|
||||||
return URLSession(configuration: configuration)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private final class RecordingURLProtocol: URLProtocol {
|
|
||||||
static var handler: ((URLRequest) throws -> (HTTPURLResponse, Data))?
|
|
||||||
|
|
||||||
override class func canInit(with request: URLRequest) -> Bool {
|
|
||||||
request.url?.host == "127.0.0.1"
|
|
||||||
}
|
|
||||||
|
|
||||||
override class func canonicalRequest(for request: URLRequest) -> URLRequest {
|
|
||||||
request
|
|
||||||
}
|
|
||||||
|
|
||||||
override func startLoading() {
|
|
||||||
guard let handler = Self.handler else {
|
|
||||||
XCTFail("RecordingURLProtocol.handler must be set before use.")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
do {
|
|
||||||
let (response, data) = try handler(request)
|
|
||||||
client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed)
|
|
||||||
client?.urlProtocol(self, didLoad: data)
|
|
||||||
client?.urlProtocolDidFinishLoading(self)
|
|
||||||
} catch {
|
|
||||||
client?.urlProtocol(self, didFailWithError: error)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override func stopLoading() {}
|
|
||||||
}
|
|
||||||
@ -427,7 +427,6 @@ private struct OfflineLibraryMockAPIClient: VelodyAPIClient {
|
|||||||
_ = payload
|
_ = payload
|
||||||
return DeviceRegistrationResponse(
|
return DeviceRegistrationResponse(
|
||||||
deviceId: UUID().uuidString,
|
deviceId: UUID().uuidString,
|
||||||
deviceAccessToken: UUID().uuidString,
|
|
||||||
bootstrapToken: UUID().uuidString,
|
bootstrapToken: UUID().uuidString,
|
||||||
serverTime: "2026-05-30T08:00:00.000Z"
|
serverTime: "2026-05-30T08:00:00.000Z"
|
||||||
)
|
)
|
||||||
|
|||||||
@ -345,7 +345,6 @@ private struct MockVelodyAPIClient: VelodyAPIClient {
|
|||||||
_ = payload
|
_ = payload
|
||||||
return DeviceRegistrationResponse(
|
return DeviceRegistrationResponse(
|
||||||
deviceId: UUID().uuidString,
|
deviceId: UUID().uuidString,
|
||||||
deviceAccessToken: UUID().uuidString,
|
|
||||||
bootstrapToken: UUID().uuidString,
|
bootstrapToken: UUID().uuidString,
|
||||||
serverTime: "2026-05-29T08:00:00.000Z"
|
serverTime: "2026-05-29T08:00:00.000Z"
|
||||||
)
|
)
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user