mirror of
https://github.com/SoPat712/allstarr.git
synced 2026-10-06 21:55:39 -04:00
2407 lines
103 KiB
C#
2407 lines
103 KiB
C#
using System.IO.Compression;
|
|
using System.Security.Cryptography;
|
|
using System.Text;
|
|
using System.Text.Json;
|
|
using System.Text.Json.Nodes;
|
|
using allstarr.Core.Capabilities;
|
|
using allstarr.Core.Configuration;
|
|
using allstarr.Core.Favorites;
|
|
using allstarr.Core.ManagedFiles;
|
|
using allstarr.Core.Downloads;
|
|
using allstarr.Core.Intelligence;
|
|
using allstarr.Core.Jobs;
|
|
using allstarr.Core.Playback;
|
|
using allstarr.Core.Routing;
|
|
using allstarr.Core.Storage;
|
|
using allstarr.Core.Settings;
|
|
using Microsoft.EntityFrameworkCore;
|
|
|
|
namespace allstarr.Tests;
|
|
|
|
[Trait("Lane", "ReleaseCritical")]
|
|
public sealed class DurableStateTransferServiceTests : IAsyncLifetime
|
|
{
|
|
private readonly string _root = Path.Combine(
|
|
Path.GetTempPath(),
|
|
"allstarr-tests",
|
|
Guid.NewGuid().ToString("N"));
|
|
private readonly Dictionary<string, PostgresTestDatabase> _databases = [];
|
|
private TestDbContextFactory _sourceFactory = null!;
|
|
private DurableStateTransferService _service = null!;
|
|
private string _currentSchema = null!;
|
|
|
|
public async Task InitializeAsync()
|
|
{
|
|
Directory.CreateDirectory(_root);
|
|
var sourceDatabase = await PostgresTestDatabase.CreateAsync();
|
|
_databases["source"] = sourceDatabase;
|
|
var options = new DurableStorageOptions
|
|
{
|
|
Provider = "Postgres",
|
|
ConnectionString = sourceDatabase.ConnectionString,
|
|
BackupDirectory = Path.Combine(_root, "backups")
|
|
};
|
|
_sourceFactory = new TestDbContextFactory(sourceDatabase.Options);
|
|
await using var context = await _sourceFactory.CreateDbContextAsync();
|
|
_currentSchema = context.Database.GetMigrations().Last();
|
|
var tenantId = Guid.CreateVersion7();
|
|
var userId = Guid.CreateVersion7();
|
|
var secretId = Guid.CreateVersion7();
|
|
var intakeSecretId = Guid.CreateVersion7();
|
|
var accountId = Guid.CreateVersion7();
|
|
var canonicalRecordingId = Guid.CreateVersion7();
|
|
var providerIdentityId = Guid.CreateVersion7();
|
|
var backendIdentityId = Guid.CreateVersion7();
|
|
var libraryTrackId = Guid.CreateVersion7();
|
|
var externalSnapshotId = Guid.CreateVersion7();
|
|
var scheduleId = Guid.CreateVersion7();
|
|
var playlistLinkId = Guid.CreateVersion7();
|
|
var playlistSnapshotId = Guid.CreateVersion7();
|
|
var sourceEntryId = Guid.CreateVersion7();
|
|
var matchId = Guid.CreateVersion7();
|
|
var syncRunId = Guid.CreateVersion7();
|
|
var jobId = Guid.CreateVersion7();
|
|
var favoriteEventId = Guid.CreateVersion7();
|
|
var managedFileId = Guid.CreateVersion7();
|
|
var enrichmentPlanId = Guid.CreateVersion7();
|
|
var downloadWorkspaceId = Guid.CreateVersion7();
|
|
var downloadArtifactId = Guid.CreateVersion7();
|
|
var intelligenceJobId = Guid.CreateVersion7();
|
|
var playbackJobId = Guid.CreateVersion7();
|
|
var recommendationRunId = Guid.CreateVersion7();
|
|
var generatedSetId = Guid.CreateVersion7();
|
|
var directGeneratedSetId = Guid.CreateVersion7();
|
|
var routeDecisionId = Guid.CreateVersion7();
|
|
var extensionPackageId = Guid.CreateVersion7();
|
|
var now = DateTimeOffset.UtcNow;
|
|
var stableHash = HashExternalId("phase4-transfer");
|
|
context.Tenants.Add(new TenantRecord
|
|
{
|
|
Id = tenantId,
|
|
Slug = "transfer",
|
|
Name = "Transfer tenant",
|
|
CreatedAt = DateTimeOffset.UtcNow
|
|
});
|
|
context.Users.Add(new PlatformUserRecord
|
|
{
|
|
Id = userId,
|
|
TenantId = tenantId,
|
|
DisplayName = "Transfer user",
|
|
Status = PlatformUserStatus.Active,
|
|
CreatedAt = DateTimeOffset.UtcNow,
|
|
UpdatedAt = DateTimeOffset.UtcNow
|
|
});
|
|
context.SecretReferences.Add(new SecretReferenceRecord
|
|
{
|
|
Id = secretId,
|
|
TenantId = tenantId,
|
|
Purpose = "fixture.encrypted",
|
|
ActiveVersion = 1,
|
|
CreatedAt = DateTimeOffset.UtcNow,
|
|
UpdatedAt = DateTimeOffset.UtcNow
|
|
});
|
|
context.SecretVersions.Add(new SecretVersionRecord
|
|
{
|
|
Id = Guid.CreateVersion7(),
|
|
SecretReferenceId = secretId,
|
|
Version = 1,
|
|
KeyId = "external-key-1",
|
|
Nonce = [1, 2, 3],
|
|
Ciphertext = [9, 8, 7, 6],
|
|
AuthenticationTag = [4, 5, 6],
|
|
CreatedAt = DateTimeOffset.UtcNow
|
|
});
|
|
context.SecretReferences.Add(new SecretReferenceRecord
|
|
{
|
|
Id = intakeSecretId,
|
|
TenantId = tenantId,
|
|
Purpose = "listening-intake-token",
|
|
ActiveVersion = 1,
|
|
CreatedAt = now,
|
|
UpdatedAt = now
|
|
});
|
|
context.SecretVersions.Add(new SecretVersionRecord
|
|
{
|
|
Id = Guid.CreateVersion7(),
|
|
SecretReferenceId = intakeSecretId,
|
|
Version = 1,
|
|
KeyId = "external-key-1",
|
|
Nonce = [7, 8, 9],
|
|
Ciphertext = [1, 3, 5, 7],
|
|
AuthenticationTag = [2, 4, 6],
|
|
CreatedAt = now
|
|
});
|
|
context.ProviderAccounts.Add(new ProviderAccountRecord
|
|
{
|
|
Id = accountId,
|
|
TenantId = tenantId,
|
|
OwnerUserId = userId,
|
|
ProviderId = "fixture",
|
|
DisplayName = "Fixture account",
|
|
Scope = ProviderAccountScope.User,
|
|
SecretReferenceId = secretId,
|
|
Enabled = true,
|
|
CreatedAt = DateTimeOffset.UtcNow,
|
|
UpdatedAt = DateTimeOffset.UtcNow
|
|
});
|
|
context.CanonicalRecordings.Add(new CanonicalRecordingRecord
|
|
{
|
|
Id = canonicalRecordingId,
|
|
TenantId = tenantId,
|
|
CreatedByUserId = userId,
|
|
Isrc = "USRC17607839",
|
|
MusicBrainzRecordingId = "0d34fc3f-4f36-4b8d-a0d3-e0d7a8b6ff23",
|
|
CreatedAt = DateTimeOffset.UtcNow,
|
|
UpdatedAt = DateTimeOffset.UtcNow,
|
|
Revision = 1
|
|
});
|
|
context.ProviderTrackIdentities.Add(new ProviderTrackIdentityRecord
|
|
{
|
|
Id = providerIdentityId,
|
|
TenantId = tenantId,
|
|
CanonicalRecordingId = canonicalRecordingId,
|
|
ProviderAccountId = accountId,
|
|
ProviderId = "fixture",
|
|
ResourceKind = ProviderResourceKind.Track,
|
|
CatalogNamespace = "default",
|
|
Scope = ProviderIdentityScope.Account,
|
|
ExternalId = "fixture-track-42",
|
|
ExternalIdHash = HashExternalId("fixture-track-42"),
|
|
Verification = ProviderIdentityVerification.Pinned,
|
|
VerificationMethod = "fixture",
|
|
DecisionVersion = 1,
|
|
VerifiedAt = DateTimeOffset.UtcNow,
|
|
CreatedAt = DateTimeOffset.UtcNow,
|
|
UpdatedAt = DateTimeOffset.UtcNow,
|
|
Revision = 1
|
|
});
|
|
context.Jobs.Add(new DurableJobRecord
|
|
{
|
|
Id = jobId,
|
|
ScopeKey = tenantId.ToString("N"),
|
|
TenantId = tenantId,
|
|
OwnerUserId = userId,
|
|
LibraryScopeId = "music",
|
|
Type = "fixture.transfer",
|
|
PayloadJson = "{\"secretReferenceId\":\"fixture\"}",
|
|
IdempotencyKey = "transfer-1",
|
|
State = DurableJobState.Pending,
|
|
MaxAttempts = 3,
|
|
AvailableAt = DateTimeOffset.UtcNow,
|
|
CreatedAt = DateTimeOffset.UtcNow,
|
|
UpdatedAt = DateTimeOffset.UtcNow
|
|
});
|
|
context.Jobs.Add(new DurableJobRecord
|
|
{
|
|
Id = playbackJobId,
|
|
ScopeKey = $"user:{tenantId:N}:{userId:N}",
|
|
TenantId = tenantId,
|
|
OwnerUserId = userId,
|
|
LibraryScopeId = "music",
|
|
PolicySnapshotJson = "{}",
|
|
RequestFingerprint = new string('e', 64),
|
|
CorrelationId = "playback-transfer",
|
|
Type = "playback.signal",
|
|
PayloadJson = "{}",
|
|
IdempotencyKey = "playback-transfer-signal",
|
|
State = DurableJobState.Succeeded,
|
|
Priority = 0,
|
|
MaxAttempts = 3,
|
|
MaxDeferrals = 3,
|
|
AvailableAt = now,
|
|
StartedAt = now,
|
|
CompletedAt = now,
|
|
CreatedAt = now,
|
|
UpdatedAt = now
|
|
});
|
|
await context.SaveChangesAsync();
|
|
context.ProviderRouteDecisions.Add(new ProviderRouteDecisionEntity
|
|
{
|
|
Id = routeDecisionId,
|
|
TenantId = tenantId,
|
|
ActorUserId = userId,
|
|
DurableJobId = jobId,
|
|
RouteKey = new string('7', 64),
|
|
OperationId = "favorite-download",
|
|
CorrelationId = "phase6-transfer",
|
|
Capability = ProviderCapabilityKind.Download,
|
|
LibraryScopeId = "music",
|
|
SelectedProviderId = "fixture",
|
|
SelectedProviderAccountId = accountId,
|
|
CandidateDecisionsJson = JsonSerializer.Serialize(
|
|
new[] { new ProviderRouteCandidateDecision("fixture", accountId,
|
|
ProviderRouteDecisionStatus.Accepted, "selected", 0) },
|
|
new JsonSerializerOptions(JsonSerializerDefaults.Web)),
|
|
CreatedAt = now
|
|
});
|
|
context.ProviderRouteOutcomes.Add(new ProviderRouteOutcomeEntity
|
|
{
|
|
Id = Guid.CreateVersion7(),
|
|
TenantId = tenantId,
|
|
RouteDecisionId = routeDecisionId,
|
|
OutcomeKey = new string('8', 64),
|
|
Sequence = 0,
|
|
Stage = "download",
|
|
ProviderId = "fixture",
|
|
ProviderAccountId = accountId,
|
|
Status = ProviderRouteOutcomeStatus.Succeeded,
|
|
ReasonCode = "download-verified",
|
|
CreatedAt = now
|
|
});
|
|
await context.SaveChangesAsync();
|
|
context.FavoriteEvents.Add(new FavoriteEventRecord
|
|
{
|
|
Id = favoriteEventId,
|
|
TenantId = tenantId,
|
|
OwnerUserId = userId,
|
|
Protocol = "jellyfin",
|
|
BackendInstanceId = "transfer-backend",
|
|
BackendPrincipalId = "transfer-principal",
|
|
LibraryScopeId = "music",
|
|
ItemId = "favorite-track-42",
|
|
Operation = FavoriteOperation.Favorite,
|
|
SourceRevision = "favorite-rev-1",
|
|
EventKey = new string('e', 64),
|
|
CorrelationId = "phase6-transfer",
|
|
PolicySnapshotJson = "{\"version\":1}",
|
|
JobId = jobId,
|
|
State = FavoriteEventState.Succeeded,
|
|
CreatedAt = now,
|
|
UpdatedAt = now,
|
|
CompletedAt = now
|
|
});
|
|
context.ManagedFiles.Add(new ManagedFileOwnershipEntity
|
|
{
|
|
Id = managedFileId,
|
|
RootId = Guid.CreateVersion7(),
|
|
TargetRootPath = "/managed/music",
|
|
CanonicalPath = "/managed/music/Fixture/Transfer.flac",
|
|
ContentSha256 = new string('a', 64),
|
|
Length = 1234,
|
|
FileSystemDeviceId = "2a",
|
|
FileSystemFileId = "3b",
|
|
FileSystemLinkCount = 1,
|
|
PlacementMethod = ManagedFilePlacementMethod.Copy,
|
|
TenantId = tenantId,
|
|
OwnerUserId = userId,
|
|
LibraryScopeId = "music",
|
|
SourceJobId = jobId,
|
|
ScopeKey = "user:transfer",
|
|
ReferenceCount = 1,
|
|
IsManaged = true,
|
|
CreatedAt = now
|
|
});
|
|
context.ManagedFileReferences.Add(new ManagedFileReferenceEntity
|
|
{
|
|
Id = Guid.CreateVersion7(),
|
|
ManagedFileId = managedFileId,
|
|
TenantId = tenantId,
|
|
OwnerUserId = userId,
|
|
ScopeKey = "user:transfer",
|
|
ReferenceKey = "favorite:transfer",
|
|
CreatedAt = now,
|
|
Revision = 1
|
|
});
|
|
context.ProviderDownloadWorkspaces.Add(new ProviderDownloadWorkspaceEntity
|
|
{
|
|
Id = downloadWorkspaceId,
|
|
WorkspaceId = new string('c', 64),
|
|
TenantId = tenantId,
|
|
OwnerUserId = userId,
|
|
LibraryScopeId = "music",
|
|
DurableJobId = jobId,
|
|
ProviderId = "fixture",
|
|
ProviderAccountId = accountId,
|
|
IdempotencyKey = "phase6-download-workspace",
|
|
CreatedAt = now,
|
|
CompletedAt = now,
|
|
Revision = 1
|
|
});
|
|
// Persist the managed parent before the placed artifact so PostgreSQL's
|
|
// database-native lineage trigger can validate the child insert.
|
|
await context.SaveChangesAsync();
|
|
context.ProviderDownloadArtifacts.Add(new ProviderDownloadArtifactEntity
|
|
{
|
|
Id = downloadArtifactId,
|
|
WorkspaceRecordId = downloadWorkspaceId,
|
|
WorkspaceId = new string('c', 64),
|
|
TenantId = tenantId,
|
|
OwnerUserId = userId,
|
|
LibraryScopeId = "music",
|
|
DurableJobId = jobId,
|
|
ProviderId = "fixture",
|
|
ProviderAccountId = accountId,
|
|
ProviderArtifactId = "provider/output.flac",
|
|
RelativePath = "provider/output.flac",
|
|
ContentSha256 = new string('d', 64),
|
|
Length = 4321,
|
|
State = ProviderDownloadArtifactState.Placed,
|
|
ManagedFileId = managedFileId,
|
|
CreatedAt = now,
|
|
VerifiedAt = now,
|
|
PlacedAt = now,
|
|
Revision = 1
|
|
});
|
|
await context.SaveChangesAsync();
|
|
context.FavoriteActions.Add(new FavoriteActionRecord
|
|
{
|
|
Id = Guid.CreateVersion7(),
|
|
EventId = favoriteEventId,
|
|
TenantId = tenantId,
|
|
OwnerUserId = userId,
|
|
ActionType = "enrich",
|
|
IdempotencyKey = "phase6-action-1",
|
|
State = FavoriteActionState.Succeeded,
|
|
AttemptCount = 1,
|
|
CreatedAt = now,
|
|
UpdatedAt = now,
|
|
CompletedAt = now
|
|
});
|
|
context.FavoriteStates.Add(new FavoriteStateRecord
|
|
{
|
|
Id = Guid.CreateVersion7(),
|
|
TenantId = tenantId,
|
|
OwnerUserId = userId,
|
|
Protocol = "jellyfin",
|
|
BackendInstanceId = "transfer-backend",
|
|
ItemId = "favorite-track-42",
|
|
IsFavorite = true,
|
|
LastEventId = favoriteEventId,
|
|
UpdatedAt = now
|
|
});
|
|
context.MetadataEnrichmentPlans.Add(new MetadataEnrichmentPlanRecord
|
|
{
|
|
Id = enrichmentPlanId,
|
|
TenantId = tenantId,
|
|
OwnerUserId = userId,
|
|
LineageJobId = jobId,
|
|
ManagedArtifactId = managedFileId,
|
|
Fingerprint = new string('b', 64),
|
|
PlanVersion = 1,
|
|
SourceRevisionsJson = "[\"musicbrainz:rev-1\"]",
|
|
DecisionsJson = "[{\"field\":\"title\"}]",
|
|
TagsJson = "{\"title\":\"Transfer\"}",
|
|
PathValuesJson = "{\"artist\":\"Fixture\"}",
|
|
CreatedAt = now
|
|
});
|
|
await context.SaveChangesAsync();
|
|
context.MetadataEnrichmentApplications.Add(new MetadataEnrichmentApplicationRecord
|
|
{
|
|
Id = Guid.CreateVersion7(),
|
|
TenantId = tenantId,
|
|
OwnerUserId = userId,
|
|
PlanId = enrichmentPlanId,
|
|
ManagedArtifactId = managedFileId,
|
|
LineageJobId = jobId,
|
|
ArtifactContentSha256 = new string('a', 64),
|
|
State = MetadataEnrichmentApplicationState.Applied,
|
|
CreatedAt = now,
|
|
UpdatedAt = now
|
|
});
|
|
await context.SaveChangesAsync();
|
|
context.BackendIdentities.Add(new BackendIdentityRecord
|
|
{
|
|
Id = backendIdentityId,
|
|
TenantId = tenantId,
|
|
UserId = userId,
|
|
BackendType = "jellyfin",
|
|
BackendInstanceId = "transfer-backend",
|
|
PrincipalId = "transfer-principal",
|
|
CreatedAt = now,
|
|
LastSeenAt = now
|
|
});
|
|
context.FavoriteActionPolicies.AddRange(
|
|
new FavoriteActionPolicyRecord
|
|
{
|
|
Id = Guid.CreateVersion7(),
|
|
TenantId = tenantId,
|
|
Scope = FavoriteActionPolicyScope.Global,
|
|
Protocol = "jellyfin",
|
|
BackendInstanceId = "transfer-backend",
|
|
LibraryScopeId = "music",
|
|
AddToVirtualLiked = true,
|
|
MatchLocalLibrary = false,
|
|
AutoDownload = false,
|
|
EnrichMetadata = false,
|
|
PlaceManagedFile = false,
|
|
RefreshBackendLibrary = true,
|
|
UpdatedByUserId = userId,
|
|
CreatedAt = now,
|
|
UpdatedAt = now,
|
|
Revision = 1
|
|
},
|
|
new FavoriteActionPolicyRecord
|
|
{
|
|
Id = Guid.CreateVersion7(),
|
|
TenantId = tenantId,
|
|
OwnerUserId = userId,
|
|
Scope = FavoriteActionPolicyScope.User,
|
|
Protocol = "jellyfin",
|
|
BackendInstanceId = "transfer-backend",
|
|
LibraryScopeId = "music",
|
|
AutoDownload = true,
|
|
RefreshBackendLibrary = false,
|
|
UpdatedByUserId = userId,
|
|
CreatedAt = now,
|
|
UpdatedAt = now,
|
|
Revision = 1
|
|
});
|
|
context.LibraryTracks.Add(new LibraryTrackRecord
|
|
{
|
|
Id = libraryTrackId,
|
|
TenantId = tenantId,
|
|
OwnerUserId = userId,
|
|
BackendIdentityId = backendIdentityId,
|
|
CanonicalRecordingId = canonicalRecordingId,
|
|
LibraryScopeId = "music",
|
|
Protocol = "jellyfin",
|
|
BackendInstanceId = "transfer-backend",
|
|
BackendItemId = "local-42",
|
|
FilePath = "/media/Music/Transfer.flac",
|
|
Title = "Transfer",
|
|
Artist = "Fixture",
|
|
DurationMilliseconds = 123000,
|
|
ProviderIdsJson = "{}",
|
|
IndexedAt = now,
|
|
SourceModifiedAt = now,
|
|
UpdatedAt = now
|
|
});
|
|
context.Jobs.Add(new DurableJobRecord
|
|
{
|
|
Id = intelligenceJobId,
|
|
ScopeKey = $"user:{tenantId:N}:{userId:N}",
|
|
TenantId = tenantId,
|
|
OwnerUserId = userId,
|
|
LibraryScopeId = "music",
|
|
PolicySnapshotJson = "{}",
|
|
RequestFingerprint = new string('c', 64),
|
|
CorrelationId = "intelligence-transfer",
|
|
Type = "recommendation.generate",
|
|
PayloadJson = $"{{\"RunId\":\"{recommendationRunId}\"}}",
|
|
IdempotencyKey = "intelligence-transfer-run",
|
|
State = DurableJobState.Succeeded,
|
|
Priority = 0,
|
|
MaxAttempts = 3,
|
|
MaxDeferrals = 3,
|
|
AvailableAt = now,
|
|
StartedAt = now,
|
|
CompletedAt = now,
|
|
CreatedAt = now,
|
|
UpdatedAt = now
|
|
});
|
|
context.IntelligencePolicies.Add(new IntelligencePolicyRecord
|
|
{
|
|
Id = Guid.CreateVersion7(),
|
|
TenantId = tenantId,
|
|
OwnerUserId = userId,
|
|
Protocol = "jellyfin",
|
|
BackendInstanceId = "transfer-backend",
|
|
LibraryScopeId = "music",
|
|
Enabled = true,
|
|
RetentionDays = 30,
|
|
AllowedSignalTypesJson = "[\"favorite\",\"play\"]",
|
|
EnabledProvidersJson = "[\"local-rules\"]",
|
|
CreatedAt = now,
|
|
UpdatedAt = now,
|
|
Revision = 1
|
|
});
|
|
context.ListeningIntakeTokens.Add(new ListeningIntakeTokenRecord
|
|
{
|
|
Id = Guid.CreateVersion7(),
|
|
TenantId = tenantId,
|
|
OwnerUserId = userId,
|
|
Protocol = "jellyfin",
|
|
BackendInstanceId = "transfer-backend",
|
|
LibraryScopeId = "music",
|
|
SecretReferenceId = intakeSecretId,
|
|
RelayExternally = false,
|
|
CreatedAt = now
|
|
});
|
|
context.ListeningEvents.Add(new ListeningEventRecord
|
|
{
|
|
Id = Guid.CreateVersion7(),
|
|
TenantId = tenantId,
|
|
OwnerUserId = userId,
|
|
Protocol = "jellyfin",
|
|
BackendInstanceId = "transfer-backend",
|
|
LibraryScopeId = "music",
|
|
OccurrenceKey = new string('9', 64),
|
|
State = ListeningEventState.Completed,
|
|
StartedAt = now,
|
|
ListenedAt = now,
|
|
UpdatedAt = now,
|
|
PositionTicks = TimeSpan.FromSeconds(62).Ticks,
|
|
DurationMilliseconds = 123_000,
|
|
ClientClass = "fixture-client",
|
|
DeviceClass = "fixture-device",
|
|
SourceKind = "protocol",
|
|
TrackReference = "local-42",
|
|
Title = "Transfer",
|
|
Artist = "Fixture",
|
|
AlbumArtist = "Fixture album artist",
|
|
RecordingMusicBrainzId = "11111111-1111-1111-1111-111111111111",
|
|
Isrc = "USABC1234567",
|
|
MusicBrainzEnrichmentState = MusicBrainzEnrichmentState.Resolved,
|
|
MusicBrainzEnrichmentConfidence = .98,
|
|
MusicBrainzSourceRevision = "musicbrainz:ws2",
|
|
MusicBrainzFactsJson = "{\"id\":\"11111111-1111-1111-1111-111111111111\"}",
|
|
MusicBrainzEnrichedAt = now,
|
|
TrackNumber = 3,
|
|
CanonicalRecordingId = canonicalRecordingId,
|
|
LibraryTrackId = libraryTrackId
|
|
});
|
|
context.ListeningHistoryImports.Add(new ListeningHistoryImportRecord
|
|
{
|
|
Id = Guid.CreateVersion7(),
|
|
TenantId = tenantId,
|
|
OwnerUserId = userId,
|
|
Protocol = "jellyfin",
|
|
BackendInstanceId = "transfer-backend",
|
|
LibraryScopeId = "music",
|
|
DisplayFileName = "StreamingHistory_music_0.json",
|
|
Format = "spotify-extended-streaming-history",
|
|
ContentSha256 = new string('a', 64),
|
|
SizeBytes = 1234,
|
|
PreviewJson = JsonSerializer.Serialize(new ListeningHistoryImportPreview(
|
|
"spotify-extended-streaming-history", 2, 2, 1, 1, 0, 0, 0, 0, 0, 0, 2, 1, 1, 0, 1, 1,
|
|
now.AddDays(-2), now.AddDays(-1), new Dictionary<string, long> { ["completed"] = 1 })),
|
|
PreviewRevision = new string('b', 64),
|
|
State = ListeningHistoryImportState.Previewed,
|
|
CreatedAt = now,
|
|
UpdatedAt = now,
|
|
ExpiresAt = now.AddHours(24),
|
|
Revision = 1
|
|
});
|
|
context.ListeningSignals.Add(new ListeningSignalRecord
|
|
{
|
|
Id = Guid.CreateVersion7(),
|
|
TenantId = tenantId,
|
|
OwnerUserId = userId,
|
|
Protocol = "jellyfin",
|
|
BackendInstanceId = "transfer-backend",
|
|
LibraryScopeId = "music",
|
|
SignalType = "favorite",
|
|
TrackKeyHash = new string('d', 64),
|
|
TrackReference = $"library:{libraryTrackId:N}",
|
|
Value = 1,
|
|
SignalKey = new string('f', 64),
|
|
SourceJobId = playbackJobId,
|
|
ObservedAt = now,
|
|
ExpiresAt = now.AddDays(30)
|
|
});
|
|
context.PlaybackDeliveryCheckpoints.Add(new PlaybackDeliveryCheckpointEntity
|
|
{
|
|
Id = Guid.CreateVersion7(),
|
|
TenantId = tenantId,
|
|
OwnerUserId = userId,
|
|
OccurrenceKey = new string('9', 64),
|
|
SignalKey = new string('f', 64),
|
|
TargetId = "lastfm",
|
|
Kind = PlaybackScrobbleDeliveryKind.Completed,
|
|
State = ScopedPlaybackScrobbleOutcome.Delivered,
|
|
DetailsJson = "{\"accepted\":1}",
|
|
UpdatedAt = now
|
|
});
|
|
var profile = new ListeningProfile(tenantId, userId, "transfer-backend", "music", 1, 0, 1,
|
|
new Dictionary<string, double>(), now, now)
|
|
{ TopTrackKeys = [$"library:{libraryTrackId:N}"] };
|
|
context.ListeningProfiles.Add(new ListeningProfileRecord
|
|
{
|
|
Id = Guid.CreateVersion7(),
|
|
TenantId = tenantId,
|
|
OwnerUserId = userId,
|
|
Protocol = "jellyfin",
|
|
BackendInstanceId = "transfer-backend",
|
|
LibraryScopeId = "music",
|
|
ProfileJson = JsonSerializer.Serialize(profile),
|
|
WindowStart = now,
|
|
WindowEnd = now,
|
|
CreatedAt = now
|
|
});
|
|
context.RecommendationRuns.Add(new RecommendationRunRecord
|
|
{
|
|
Id = recommendationRunId,
|
|
TenantId = tenantId,
|
|
OwnerUserId = userId,
|
|
Protocol = "jellyfin",
|
|
BackendInstanceId = "transfer-backend",
|
|
LibraryScopeId = "music",
|
|
JobId = intelligenceJobId,
|
|
IdempotencyKey = "intelligence-transfer-run",
|
|
PolicySnapshotJson = JsonSerializer.Serialize(
|
|
new RecommendationPolicySnapshot(1, ["local-rules"], 30)),
|
|
SeedTrackKeysJson = "[]",
|
|
Limit = 10,
|
|
State = RecommendationRunState.Succeeded,
|
|
CreatedAt = now,
|
|
UpdatedAt = now,
|
|
CompletedAt = now,
|
|
Revision = 1
|
|
});
|
|
var identityJson = JsonSerializer.Serialize(new RecommendationTrackIdentity("local", LibraryTrackId: libraryTrackId, BackendItemId: "local-42"));
|
|
var signalsJson = JsonSerializer.Serialize(new[] { new RecommendationSignal("shared-artist", .8, "Shares an artist.") });
|
|
var recommendationCandidateId = Guid.CreateVersion7();
|
|
context.RecommendationCandidates.Add(new RecommendationCandidateRecord
|
|
{
|
|
Id = recommendationCandidateId,
|
|
RunId = recommendationRunId,
|
|
TenantId = tenantId,
|
|
OwnerUserId = userId,
|
|
Position = 0,
|
|
TrackKey = "local-42",
|
|
Score = .8,
|
|
Source = "local-rules",
|
|
SignalsJson = signalsJson,
|
|
IdentityJson = identityJson,
|
|
CreatedAt = now
|
|
});
|
|
context.RecommendationFeedback.Add(new RecommendationFeedbackRecord
|
|
{
|
|
Id = Guid.CreateVersion7(),
|
|
CandidateId = recommendationCandidateId,
|
|
TenantId = tenantId,
|
|
OwnerUserId = userId,
|
|
Protocol = "jellyfin",
|
|
BackendInstanceId = "transfer-backend",
|
|
LibraryScopeId = "music",
|
|
TrackKey = "local-42",
|
|
Kind = "like",
|
|
ReasonCode = "great-fit",
|
|
CreatedAt = now,
|
|
UpdatedAt = now,
|
|
Revision = 1
|
|
});
|
|
context.GeneratedSets.Add(new GeneratedSetRecord
|
|
{
|
|
Id = generatedSetId,
|
|
RunId = recommendationRunId,
|
|
TenantId = tenantId,
|
|
OwnerUserId = userId,
|
|
Protocol = "jellyfin",
|
|
BackendInstanceId = "transfer-backend",
|
|
LibraryScopeId = "music",
|
|
Name = "Transfer mix",
|
|
MaterializationState = GeneratedSetMaterializationState.Pending,
|
|
CreatedAt = now,
|
|
UpdatedAt = now,
|
|
Revision = 1
|
|
});
|
|
context.GeneratedSetEntries.Add(new GeneratedSetEntryRecord
|
|
{
|
|
Id = Guid.CreateVersion7(),
|
|
GeneratedSetId = generatedSetId,
|
|
TenantId = tenantId,
|
|
OwnerUserId = userId,
|
|
Position = 0,
|
|
TrackKey = "local-42",
|
|
Score = .8,
|
|
Source = "local-rules",
|
|
ExplanationJson = signalsJson,
|
|
IdentityJson = identityJson
|
|
});
|
|
context.GeneratedSets.Add(new GeneratedSetRecord
|
|
{
|
|
Id = directGeneratedSetId,
|
|
TenantId = tenantId,
|
|
OwnerUserId = userId,
|
|
Protocol = "jellyfin",
|
|
BackendInstanceId = "transfer-backend",
|
|
LibraryScopeId = "music",
|
|
Name = "Sound preview",
|
|
MaterializationState = GeneratedSetMaterializationState.Pending,
|
|
CreatedAt = now,
|
|
UpdatedAt = now,
|
|
Revision = 1
|
|
});
|
|
context.GeneratedSetEntries.Add(new GeneratedSetEntryRecord
|
|
{
|
|
Id = Guid.CreateVersion7(),
|
|
GeneratedSetId = directGeneratedSetId,
|
|
TenantId = tenantId,
|
|
OwnerUserId = userId,
|
|
Position = 0,
|
|
TrackKey = "local-42",
|
|
Score = .9,
|
|
Source = "audiomuse-ai",
|
|
ExplanationJson = JsonSerializer.Serialize(new[]
|
|
{ new RecommendationSignal("audiomuse-preview", 1, "Selected preview.") }),
|
|
IdentityJson = identityJson
|
|
});
|
|
context.ExternalMetadataSnapshots.Add(new ExternalMetadataSnapshotRecord
|
|
{
|
|
Id = externalSnapshotId,
|
|
TenantId = tenantId,
|
|
OwnerUserId = userId,
|
|
ProviderAccountId = accountId,
|
|
ProviderTrackIdentityId = providerIdentityId,
|
|
LibraryScopeId = "music",
|
|
BackendInstanceId = "transfer-backend",
|
|
BackendPrincipalId = "transfer-principal",
|
|
Protocol = "jellyfin",
|
|
ProviderId = "fixture",
|
|
ResourceKind = "track",
|
|
ExternalIdHash = stableHash,
|
|
SnapshotVersion = 1,
|
|
ProviderRevision = "track-rev-1",
|
|
PayloadJson = "{\"title\":\"Transfer\"}",
|
|
PayloadSha256 = stableHash,
|
|
CorrelationId = "phase4-transfer",
|
|
RetrievedAt = now
|
|
});
|
|
await context.SaveChangesAsync();
|
|
context.TrackMatches.Add(new TrackMatchRecord
|
|
{
|
|
Id = matchId,
|
|
TenantId = tenantId,
|
|
OwnerUserId = userId,
|
|
ExternalSnapshotId = externalSnapshotId,
|
|
LibraryTrackId = libraryTrackId,
|
|
CanonicalRecordingId = canonicalRecordingId,
|
|
LibraryScopeId = "music",
|
|
State = TrackMatchState.Accepted,
|
|
Confidence = .99,
|
|
Threshold = .85,
|
|
DecisionVersion = 1,
|
|
PolicyVersion = "match-v1",
|
|
CandidateResultsJson = "[]",
|
|
ReasonsJson = "[\"exact-id\"]",
|
|
WarningsJson = "[]",
|
|
CorrelationId = "phase4-transfer",
|
|
DecidedAt = now
|
|
});
|
|
context.ManualTrackOverrides.Add(new ManualTrackOverrideRecord
|
|
{
|
|
Id = Guid.CreateVersion7(),
|
|
TenantId = tenantId,
|
|
OwnerUserId = userId,
|
|
ExternalSnapshotId = externalSnapshotId,
|
|
LibraryTrackId = libraryTrackId,
|
|
LibraryScopeId = "music",
|
|
Decision = ManualOverrideDecision.Pin,
|
|
Reason = "transfer fixture",
|
|
DecisionVersion = 1,
|
|
CreatedAt = now
|
|
});
|
|
context.JobSchedules.Add(new JobScheduleRecord
|
|
{
|
|
Id = scheduleId,
|
|
TenantId = tenantId,
|
|
OwnerUserId = userId,
|
|
LibraryScopeId = "music",
|
|
JobType = "playlist-sync",
|
|
CronExpression = "0 0 * * *",
|
|
TimeZoneId = "UTC",
|
|
OverlapPolicy = ScheduleOverlapPolicy.Skip,
|
|
MisfirePolicy = ScheduleMisfirePolicy.RunOnce,
|
|
Enabled = true,
|
|
NextRunAt = now.AddDays(1),
|
|
CreatedAt = now,
|
|
UpdatedAt = now
|
|
});
|
|
await context.SaveChangesAsync();
|
|
context.PlaylistLinks.Add(new PlaylistLinkRecord
|
|
{
|
|
Id = playlistLinkId,
|
|
TenantId = tenantId,
|
|
OwnerUserId = userId,
|
|
ProviderAccountId = accountId,
|
|
ScheduleId = scheduleId,
|
|
LibraryScopeId = "music",
|
|
SourceProviderId = "fixture",
|
|
SourcePlaylistId = "playlist-42",
|
|
SourcePlaylistIdHash = stableHash,
|
|
TargetProtocol = "jellyfin",
|
|
TargetBackendInstanceId = "transfer-backend",
|
|
Mode = PlaylistLinkMode.Materialized,
|
|
MaterializationMode = PlaylistMaterializationMode.Reconcile,
|
|
RuleVersion = "rules-v1",
|
|
PolicyVersion = "policy-v1",
|
|
CreatedAt = now,
|
|
UpdatedAt = now
|
|
});
|
|
await context.SaveChangesAsync();
|
|
context.PlaylistSourceSnapshots.Add(new PlaylistSourceSnapshotRecord
|
|
{
|
|
Id = playlistSnapshotId,
|
|
TenantId = tenantId,
|
|
OwnerUserId = userId,
|
|
PlaylistLinkId = playlistLinkId,
|
|
ProviderAccountId = accountId,
|
|
SnapshotVersion = 1,
|
|
ProviderRevision = "playlist-rev-1",
|
|
Name = "Transfer playlist",
|
|
ArtworkReferenceKey = "fixture:art:42",
|
|
PayloadSha256 = stableHash,
|
|
CorrelationId = "phase4-transfer",
|
|
RetrievedAt = now
|
|
});
|
|
await context.SaveChangesAsync();
|
|
context.PlaylistSourceEntries.Add(new PlaylistSourceEntryRecord
|
|
{
|
|
Id = sourceEntryId,
|
|
TenantId = tenantId,
|
|
PlaylistSourceSnapshotId = playlistSnapshotId,
|
|
ExternalMetadataSnapshotId = externalSnapshotId,
|
|
SourcePosition = 0,
|
|
SourceEntryIdHash = stableHash
|
|
});
|
|
await context.SaveChangesAsync();
|
|
context.PlaylistSyncRuns.Add(new PlaylistSyncRunRecord
|
|
{
|
|
Id = syncRunId,
|
|
TenantId = tenantId,
|
|
OwnerUserId = userId,
|
|
PlaylistLinkId = playlistLinkId,
|
|
PlaylistSourceSnapshotId = playlistSnapshotId,
|
|
ScheduleId = scheduleId,
|
|
Generation = 1,
|
|
IdempotencyKey = "phase4-transfer-run",
|
|
RuleVersion = "rules-v1",
|
|
MaterializationMode = PlaylistMaterializationMode.Reconcile,
|
|
State = PlaylistSyncState.Succeeded,
|
|
StartedAt = now,
|
|
CompletedAt = now
|
|
});
|
|
await context.SaveChangesAsync();
|
|
context.PlaylistSyncEntryResults.Add(new PlaylistSyncEntryResultRecord
|
|
{
|
|
Id = Guid.CreateVersion7(),
|
|
TenantId = tenantId,
|
|
PlaylistSyncRunId = syncRunId,
|
|
PlaylistSourceEntryId = sourceEntryId,
|
|
TrackMatchId = matchId,
|
|
LibraryTrackId = libraryTrackId,
|
|
SourcePosition = 0,
|
|
TargetPosition = 0,
|
|
Outcome = PlaylistEntryOutcome.Reused
|
|
});
|
|
context.PlaylistTargetMemberships.Add(new PlaylistTargetMembershipRecord
|
|
{
|
|
Id = Guid.CreateVersion7(),
|
|
TenantId = tenantId,
|
|
PlaylistLinkId = playlistLinkId,
|
|
LibraryTrackId = libraryTrackId,
|
|
CreatedBySyncRunId = syncRunId,
|
|
TargetEntryId = "target-entry-42",
|
|
LastKnownPosition = 0,
|
|
Active = true,
|
|
CreatedAt = now,
|
|
UpdatedAt = now
|
|
});
|
|
context.TenantRuntimeSettings.Add(new TenantRuntimeSettingRecord
|
|
{
|
|
Id = Guid.CreateVersion7(),
|
|
TenantId = tenantId,
|
|
Key = AudioQualityPolicy.SettingKey,
|
|
ValueType = RuntimeSettingValueType.String,
|
|
ValueJson = "\"HiResLossless\"",
|
|
Source = "v3-compatibility-migration",
|
|
UpdatedByUserId = userId,
|
|
CreatedAt = now,
|
|
UpdatedAt = now,
|
|
Revision = 2
|
|
});
|
|
context.ExtensionPackages.Add(new ExtensionPackageRecord
|
|
{
|
|
Id = extensionPackageId,
|
|
ExtensionId = "spotiflac-transfer",
|
|
DisplayName = "Transfer extension",
|
|
Version = "1.0.0",
|
|
SdkVersion = "1",
|
|
Sha256 = new string('c', 64),
|
|
ContentSha256 = new string('d', 64),
|
|
PackagePath = "/extensions/spotiflac-transfer",
|
|
ManifestJson = """{"id":"spotiflac-transfer","compatibility":"spotiflac-v1"}""",
|
|
State = ExtensionPackageState.Active,
|
|
StagedAt = now,
|
|
ActivatedAt = now,
|
|
Revision = 1
|
|
});
|
|
context.ExtensionLogs.Add(new ExtensionLogRecord
|
|
{
|
|
Id = Guid.CreateVersion7(),
|
|
ExtensionPackageId = extensionPackageId,
|
|
ExtensionId = "spotiflac-transfer",
|
|
Level = "Info",
|
|
EventCode = "transfer",
|
|
Message = "Transfer fixture",
|
|
CorrelationId = "transfer-extension",
|
|
CreatedAt = now
|
|
});
|
|
context.OnboardingStates.Add(new OnboardingStateRecord
|
|
{
|
|
Id = Guid.CreateVersion7(),
|
|
TenantId = tenantId,
|
|
UserId = userId,
|
|
SchemaVersion = OnboardingStateService.SchemaVersion,
|
|
CompletedStepsJson = """["backend-identity"]""",
|
|
CompletionSource = "transfer-fixture",
|
|
CompletedAt = now,
|
|
CreatedAt = now,
|
|
UpdatedAt = now,
|
|
Revision = 1
|
|
});
|
|
var migrationAuditId = Guid.CreateVersion7();
|
|
const string migrationSource = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
|
|
var migrationResult = new LegacyEnvMigrationApplyResult(
|
|
true, false, 1, 0, 0, 0, 0, 0, [], migrationSource, now);
|
|
context.AuditEvents.Add(new AuditEventRecord
|
|
{
|
|
Id = migrationAuditId,
|
|
TenantId = tenantId,
|
|
ActorUserId = userId,
|
|
Category = "configuration-migration",
|
|
Action = "legacy-env.apply",
|
|
Outcome = "succeeded",
|
|
CorrelationId = "transfer-legacy-env",
|
|
DetailsJson = "{}",
|
|
CreatedAt = now
|
|
});
|
|
context.LegacyEnvImports.Add(new LegacyEnvImportRecord
|
|
{
|
|
Id = Guid.CreateVersion7(),
|
|
TenantId = tenantId,
|
|
SourceSha256 = migrationSource,
|
|
ActorUserId = userId,
|
|
AuditEventId = migrationAuditId,
|
|
ResultJson = JsonSerializer.Serialize(migrationResult, new JsonSerializerOptions(JsonSerializerDefaults.Web)),
|
|
AppliedAt = now
|
|
});
|
|
await context.SaveChangesAsync();
|
|
var state = new DurableStorageState(options);
|
|
state.Set(DurableStorageReadiness.Ready, _currentSchema);
|
|
_service = new DurableStateTransferService(_sourceFactory, options, state);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ExportImport_PreservesTenantScopedStateTrackIdentitiesAndEncryptedSecretBytes()
|
|
{
|
|
const string temporaryUploadCanary = "private-history-upload-must-not-transfer";
|
|
const string remoteTokenCanary = "remote-history-token-must-not-transfer";
|
|
await File.WriteAllTextAsync(
|
|
Path.Combine(_root, "history-import.upload"),
|
|
$"{temporaryUploadCanary}|{remoteTokenCanary}");
|
|
var artifact = await _service.ExportAsync(
|
|
Path.Combine(_root, "transfers"),
|
|
writesQuiesced: true);
|
|
Assert.Equal(_currentSchema, artifact.SchemaVersion);
|
|
using (var archive = ZipFile.OpenRead(artifact.Path))
|
|
{
|
|
Assert.Contains(archive.Entries, item => item.FullName == "listening-events.json");
|
|
Assert.Contains(archive.Entries, item => item.FullName == "listening-history-imports.json");
|
|
var contents = new List<string>();
|
|
foreach (var entry in archive.Entries)
|
|
{
|
|
using var reader = new StreamReader(entry.Open());
|
|
contents.Add(await reader.ReadToEndAsync());
|
|
}
|
|
var archiveText = string.Join('\n', contents);
|
|
Assert.DoesNotContain(temporaryUploadCanary, archiveText, StringComparison.Ordinal);
|
|
Assert.DoesNotContain(remoteTokenCanary, archiveText, StringComparison.Ordinal);
|
|
}
|
|
var targetPath = Path.Combine(_root, "target.db");
|
|
var targetFactory = Factory($"postgres-fixture:{targetPath}");
|
|
|
|
await DurableStateTransferService.ImportAsync(
|
|
artifact,
|
|
targetFactory,
|
|
targetConfirmedEmpty: true);
|
|
|
|
await using var target = await targetFactory.CreateDbContextAsync();
|
|
Assert.Single(await target.Tenants.ToListAsync());
|
|
Assert.Single(await target.Users.ToListAsync());
|
|
Assert.Single(await target.ProviderAccounts.ToListAsync());
|
|
var runtimeSetting = await target.TenantRuntimeSettings.SingleAsync();
|
|
Assert.Equal(AudioQualityPolicy.SettingKey, runtimeSetting.Key);
|
|
Assert.Equal("\"HiResLossless\"", runtimeSetting.ValueJson);
|
|
Assert.Equal(2, runtimeSetting.Revision);
|
|
Assert.Equal("spotiflac-transfer", (await target.ExtensionPackages.SingleAsync()).ExtensionId);
|
|
Assert.Equal("spotiflac-transfer", (await target.ExtensionLogs.SingleAsync()).ExtensionId);
|
|
var onboarding = await target.OnboardingStates.SingleAsync();
|
|
Assert.Equal(OnboardingStateService.SchemaVersion, onboarding.SchemaVersion);
|
|
Assert.NotNull(onboarding.CompletedAt);
|
|
var legacyImport = await target.LegacyEnvImports.SingleAsync();
|
|
Assert.Equal("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", legacyImport.SourceSha256);
|
|
Assert.Equal((await target.AuditEvents.SingleAsync()).Id, legacyImport.AuditEventId);
|
|
var canonicalRecording = await target.CanonicalRecordings.SingleAsync();
|
|
Assert.Equal("USRC17607839", canonicalRecording.Isrc);
|
|
var trackIdentity = await target.ProviderTrackIdentities.SingleAsync();
|
|
Assert.Equal(canonicalRecording.Id, trackIdentity.CanonicalRecordingId);
|
|
Assert.Equal("fixture-track-42", trackIdentity.ExternalId);
|
|
Assert.Equal(ProviderIdentityScope.Account, trackIdentity.Scope);
|
|
Assert.Equal(ProviderIdentityVerification.Pinned, trackIdentity.Verification);
|
|
Assert.Equal(3, await target.Jobs.CountAsync());
|
|
Assert.Single(await target.LibraryTracks.ToListAsync());
|
|
Assert.Single(await target.ExternalMetadataSnapshots.ToListAsync());
|
|
Assert.Equal(TrackMatchState.Accepted, (await target.TrackMatches.SingleAsync()).State);
|
|
Assert.Equal(ManualOverrideDecision.Pin, (await target.ManualTrackOverrides.SingleAsync()).Decision);
|
|
Assert.Single(await target.JobSchedules.ToListAsync());
|
|
Assert.Single(await target.PlaylistLinks.ToListAsync());
|
|
Assert.Equal("Transfer playlist", (await target.PlaylistSourceSnapshots.SingleAsync()).Name);
|
|
Assert.Single(await target.PlaylistSourceEntries.ToListAsync());
|
|
Assert.Equal(PlaylistSyncState.Succeeded, (await target.PlaylistSyncRuns.SingleAsync()).State);
|
|
Assert.Equal(PlaylistEntryOutcome.Reused, (await target.PlaylistSyncEntryResults.SingleAsync()).Outcome);
|
|
Assert.Single(await target.PlaylistTargetMemberships.ToListAsync());
|
|
Assert.Equal(FavoriteEventState.Succeeded, (await target.FavoriteEvents.SingleAsync()).State);
|
|
Assert.Equal(FavoriteActionState.Succeeded, (await target.FavoriteActions.SingleAsync()).State);
|
|
Assert.True((await target.FavoriteStates.SingleAsync()).IsFavorite);
|
|
var restoredPolicies = await target.FavoriteActionPolicies.OrderBy(item => item.Scope).ToListAsync();
|
|
Assert.Equal(2, restoredPolicies.Count);
|
|
Assert.Contains(restoredPolicies, item => item.Scope == FavoriteActionPolicyScope.Global && item.RefreshBackendLibrary == true);
|
|
Assert.Contains(restoredPolicies, item => item.Scope == FavoriteActionPolicyScope.User && item.AutoDownload == true);
|
|
var managedFile = await target.ManagedFiles.SingleAsync();
|
|
Assert.Equal("/managed/music/Fixture/Transfer.flac", managedFile.CanonicalPath);
|
|
Assert.True(managedFile.IsManaged);
|
|
Assert.Equal("2a", managedFile.FileSystemDeviceId);
|
|
var managedReference = await target.ManagedFileReferences.SingleAsync();
|
|
Assert.Equal(managedFile.Id, managedReference.ManagedFileId);
|
|
Assert.Equal("favorite:transfer", managedReference.ReferenceKey);
|
|
Assert.Null(managedReference.ReleasedAt);
|
|
var workspace = await target.ProviderDownloadWorkspaces.SingleAsync();
|
|
Assert.Equal((await target.Jobs.SingleAsync(item => item.Type == "fixture.transfer")).Id, workspace.DurableJobId);
|
|
var downloadArtifact = await target.ProviderDownloadArtifacts.SingleAsync();
|
|
Assert.Equal(workspace.Id, downloadArtifact.WorkspaceRecordId);
|
|
Assert.Equal(managedFile.Id, downloadArtifact.ManagedFileId);
|
|
Assert.Equal(ProviderDownloadArtifactState.Placed, downloadArtifact.State);
|
|
var enrichmentPlan = await target.MetadataEnrichmentPlans.SingleAsync();
|
|
Assert.Equal(managedFile.Id, enrichmentPlan.ManagedArtifactId);
|
|
Assert.Contains("musicbrainz:rev-1", enrichmentPlan.SourceRevisionsJson, StringComparison.Ordinal);
|
|
Assert.Equal(MetadataEnrichmentApplicationState.Applied,
|
|
(await target.MetadataEnrichmentApplications.SingleAsync()).State);
|
|
Assert.True((await target.IntelligencePolicies.SingleAsync()).Enabled);
|
|
var listeningIntake = await target.ListeningIntakeTokens.SingleAsync();
|
|
Assert.False(listeningIntake.RelayExternally);
|
|
Assert.Equal("listening-intake-token", (await target.SecretReferences.SingleAsync(item =>
|
|
item.Id == listeningIntake.SecretReferenceId)).Purpose);
|
|
var listeningEvent = await target.ListeningEvents.SingleAsync();
|
|
Assert.Equal(ListeningEventState.Completed, listeningEvent.State);
|
|
Assert.Equal("local-42", listeningEvent.TrackReference);
|
|
Assert.Equal("USABC1234567", listeningEvent.Isrc);
|
|
Assert.Equal(MusicBrainzEnrichmentState.Resolved, listeningEvent.MusicBrainzEnrichmentState);
|
|
Assert.Equal(.98, listeningEvent.MusicBrainzEnrichmentConfidence);
|
|
Assert.Contains("11111111-1111-1111-1111-111111111111", listeningEvent.MusicBrainzFactsJson);
|
|
var listeningImport = await target.ListeningHistoryImports.SingleAsync();
|
|
Assert.Equal(listeningEvent.TenantId, listeningImport.TenantId);
|
|
Assert.Equal(listeningEvent.OwnerUserId, listeningImport.OwnerUserId);
|
|
Assert.Equal(listeningEvent.Protocol, listeningImport.Protocol);
|
|
Assert.Equal(listeningEvent.BackendInstanceId, listeningImport.BackendInstanceId);
|
|
Assert.Equal(listeningEvent.LibraryScopeId, listeningImport.LibraryScopeId);
|
|
Assert.Equal("spotify-extended-streaming-history", listeningImport.Format);
|
|
Assert.Equal(new string('a', 64), listeningImport.ContentSha256);
|
|
Assert.Equal(ListeningHistoryImportState.Expired, listeningImport.State);
|
|
Assert.Null(listeningImport.JobId);
|
|
Assert.StartsWith("library:", (await target.ListeningSignals.SingleAsync()).TrackReference, StringComparison.Ordinal);
|
|
var playbackCheckpoint = await target.PlaybackDeliveryCheckpoints.SingleAsync();
|
|
Assert.Equal("lastfm", playbackCheckpoint.TargetId);
|
|
Assert.Equal(ScopedPlaybackScrobbleOutcome.Delivered, playbackCheckpoint.State);
|
|
Assert.Single(await target.ListeningProfiles.ToListAsync());
|
|
Assert.Equal(RecommendationRunState.Succeeded, (await target.RecommendationRuns.SingleAsync()).State);
|
|
Assert.Contains("shared-artist", (await target.RecommendationCandidates.SingleAsync()).SignalsJson, StringComparison.Ordinal);
|
|
Assert.Equal("great-fit", (await target.RecommendationFeedback.SingleAsync()).ReasonCode);
|
|
Assert.Equal(["Sound preview", "Transfer mix"], await target.GeneratedSets
|
|
.OrderBy(item => item.Name).Select(item => item.Name).ToArrayAsync());
|
|
Assert.Equal(["audiomuse-ai", "local-rules"], await target.GeneratedSetEntries
|
|
.OrderBy(item => item.Source).Select(item => item.Source).ToArrayAsync());
|
|
var fixtureSecret = await target.SecretReferences.SingleAsync(item => item.Purpose == "fixture.encrypted");
|
|
var secret = await target.SecretVersions.SingleAsync(item => item.SecretReferenceId == fixtureSecret.Id);
|
|
Assert.Equal("external-key-1", secret.KeyId);
|
|
Assert.Equal([9, 8, 7, 6], secret.Ciphertext);
|
|
Assert.False(File.ReadAllText(artifact.Path).Contains("plaintext-secret", StringComparison.Ordinal));
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData("intelligence-policies.json", "retentionDays", -1)]
|
|
[InlineData("intelligence-policies.json", "allowedSignalTypesJson", "[\"Play\"]")]
|
|
[InlineData("intelligence-policies.json", "targetCredentialReferenceId", "00000000-0000-0000-0000-000000000001")]
|
|
[InlineData("listening-events.json", "occurrenceKey", "short")]
|
|
[InlineData("listening-events.json", "libraryTrackId", "00000000-0000-0000-0000-000000000001")]
|
|
[InlineData("listening-signals.json", "ownerUserId", "00000000-0000-0000-0000-000000000001")]
|
|
[InlineData("listening-signals.json", "trackReference", "library:00000000000000000000000000000001")]
|
|
[InlineData("playback-delivery-checkpoints.json", "signalKey", "short")]
|
|
[InlineData("playback-delivery-checkpoints.json", "targetId", "unsupported")]
|
|
[InlineData("playback-delivery-checkpoints.json", "ownerUserId", "00000000-0000-0000-0000-000000000001")]
|
|
[InlineData("listening-profiles.json", "profileJson", "{}")]
|
|
[InlineData("recommendation-runs.json", "idempotencyKey", "")]
|
|
[InlineData("recommendation-runs.json", "policySnapshotJson", "{}")]
|
|
[InlineData("recommendation-runs.json", "state", 4)]
|
|
[InlineData("recommendation-candidates.json", "position", 4)]
|
|
[InlineData("recommendation-candidates.json", "signalsJson", "[]")]
|
|
[InlineData("recommendation-candidates.json", "sourceRevision", "")]
|
|
[InlineData("recommendation-candidates.json", "exclusionsJson", "{broken")]
|
|
[InlineData("recommendation-feedback.json", "kind", "maybe")]
|
|
[InlineData("generated-sets.json", "ownerUserId", "00000000-0000-0000-0000-000000000001")]
|
|
[InlineData("generated-sets.json", "backendPlaylistId", "playlist-with-pending-state")]
|
|
[InlineData("generated-sets.json", "revision", 0)]
|
|
[InlineData("generated-set-entries.json", "position", 3)]
|
|
[InlineData("generated-set-entries.json", "identityJson", "{broken")]
|
|
public async Task Import_RejectsMalformedIntelligenceGraphBeforeAnyDatabaseWrite(
|
|
string entryName, string property, object replacement)
|
|
{
|
|
var artifact = await _service.ExportAsync(Path.Combine(_root, $"bad-intelligence-{Guid.NewGuid():N}"), true);
|
|
artifact = await RewriteJsonArrayEntryAsync(artifact, entryName, values =>
|
|
{
|
|
var item = values[0]!.AsObject();
|
|
item[property] = replacement switch
|
|
{
|
|
int number => JsonValue.Create(number),
|
|
string text => JsonValue.Create(text),
|
|
_ => throw new InvalidOperationException()
|
|
};
|
|
});
|
|
var targetFactory = Factory($"postgres-fixture:{Path.Combine(_root, $"bad-target-{Guid.NewGuid():N}.db")}");
|
|
|
|
var error = await Assert.ThrowsAsync<BackupVerificationException>(() =>
|
|
DurableStateTransferService.ImportAsync(artifact, targetFactory, true));
|
|
Assert.Contains("intelligence", error.Message, StringComparison.OrdinalIgnoreCase);
|
|
await using var target = await targetFactory.CreateDbContextAsync();
|
|
Assert.Empty(await target.Tenants.ToListAsync());
|
|
Assert.Empty(await target.IntelligencePolicies.ToListAsync());
|
|
Assert.Empty(await target.RecommendationRuns.ToListAsync());
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Import_RejectsRecommendationScheduleThatCrossesItsPolicyScope()
|
|
{
|
|
Guid scheduleId;
|
|
await using (var source = await _sourceFactory.CreateDbContextAsync())
|
|
{
|
|
var policy = await source.IntelligencePolicies.SingleAsync();
|
|
scheduleId = Guid.CreateVersion7();
|
|
var now = DateTimeOffset.UtcNow;
|
|
source.JobSchedules.Add(new JobScheduleRecord
|
|
{
|
|
Id = scheduleId,
|
|
TenantId = policy.TenantId,
|
|
OwnerUserId = policy.OwnerUserId,
|
|
LibraryScopeId = policy.LibraryScopeId,
|
|
JobType = DurableScheduleEngine.RecommendationJobType,
|
|
CronExpression = "0 8 * * *",
|
|
TimeZoneId = "UTC",
|
|
OverlapPolicy = ScheduleOverlapPolicy.Skip,
|
|
MisfirePolicy = ScheduleMisfirePolicy.RunOnce,
|
|
RetryPolicyJson = "{}",
|
|
PayloadTemplateJson = JsonSerializer.Serialize(
|
|
new RecommendationScheduleTemplate(1, policy.Id, 25, "Transfer recommendations")),
|
|
Enabled = true,
|
|
NextRunAt = now.AddDays(1),
|
|
CreatedAt = now,
|
|
UpdatedAt = now,
|
|
Revision = 0
|
|
});
|
|
await source.SaveChangesAsync();
|
|
}
|
|
var artifact = await _service.ExportAsync(Path.Combine(_root, "bad-recommendation-schedule"), true);
|
|
var validTargetFactory = Factory($"postgres-fixture:{Path.Combine(_root, "valid-recommendation-schedule-target.db")}");
|
|
await DurableStateTransferService.ImportAsync(artifact, validTargetFactory, true);
|
|
await using (var validTarget = await validTargetFactory.CreateDbContextAsync())
|
|
{
|
|
var restored = await validTarget.JobSchedules.SingleAsync(item => item.Id == scheduleId);
|
|
Assert.Equal(DurableScheduleEngine.RecommendationJobType, restored.JobType);
|
|
Assert.Contains("Transfer recommendations", restored.PayloadTemplateJson, StringComparison.Ordinal);
|
|
}
|
|
artifact = await RewriteJsonArrayEntryAsync(artifact, "job-schedules.json", values =>
|
|
{
|
|
var schedule = values.Select(item => item!.AsObject()).Single(item =>
|
|
Guid.Parse(item["id"]!.GetValue<string>()) == scheduleId);
|
|
schedule["libraryScopeId"] = "another-library";
|
|
});
|
|
var targetFactory = Factory($"postgres-fixture:{Path.Combine(_root, "bad-recommendation-schedule-target.db")}");
|
|
|
|
var error = await Assert.ThrowsAsync<BackupVerificationException>(() =>
|
|
DurableStateTransferService.ImportAsync(artifact, targetFactory, true));
|
|
|
|
Assert.Contains("intelligence", error.Message, StringComparison.OrdinalIgnoreCase);
|
|
await using var target = await targetFactory.CreateDbContextAsync();
|
|
Assert.Empty(await target.Tenants.ToListAsync());
|
|
Assert.Empty(await target.JobSchedules.ToListAsync());
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ExportImport_PreservesSameExternalIdAcrossTenantsAndDisabledGlobalAccount()
|
|
{
|
|
var second = await AddTenantFixtureAsync("second");
|
|
Guid firstTenantId;
|
|
Guid firstCanonicalId;
|
|
var globalAccountId = Guid.CreateVersion7();
|
|
await using (var source = await _sourceFactory.CreateDbContextAsync())
|
|
{
|
|
firstTenantId = await source.Tenants
|
|
.Where(item => item.Slug == "transfer")
|
|
.Select(item => item.Id)
|
|
.SingleAsync();
|
|
firstCanonicalId = await source.CanonicalRecordings
|
|
.Where(item => item.TenantId == firstTenantId)
|
|
.Select(item => item.Id)
|
|
.SingleAsync();
|
|
source.ProviderAccounts.Add(new ProviderAccountRecord
|
|
{
|
|
Id = globalAccountId,
|
|
ProviderId = "shared-fixture",
|
|
DisplayName = "Disabled shared fixture",
|
|
Scope = ProviderAccountScope.Global,
|
|
Enabled = false,
|
|
CreatedAt = DateTimeOffset.UtcNow,
|
|
UpdatedAt = DateTimeOffset.UtcNow
|
|
});
|
|
const string externalId = "shared-track-42";
|
|
source.ProviderTrackIdentities.AddRange(
|
|
CreateAccountIdentity(
|
|
firstTenantId,
|
|
firstCanonicalId,
|
|
globalAccountId,
|
|
"shared-fixture",
|
|
externalId),
|
|
CreateAccountIdentity(
|
|
second.TenantId,
|
|
second.CanonicalRecordingId,
|
|
globalAccountId,
|
|
"shared-fixture",
|
|
externalId));
|
|
await source.SaveChangesAsync();
|
|
}
|
|
|
|
var artifact = await _service.ExportAsync(
|
|
Path.Combine(_root, "transfers"),
|
|
writesQuiesced: true);
|
|
var targetFactory = Factory(
|
|
$"postgres-fixture:{Path.Combine(_root, "two-tenant-target.db")}");
|
|
|
|
await DurableStateTransferService.ImportAsync(
|
|
artifact,
|
|
targetFactory,
|
|
targetConfirmedEmpty: true);
|
|
|
|
await using var target = await targetFactory.CreateDbContextAsync();
|
|
var sharedIdentities = await target.ProviderTrackIdentities
|
|
.Where(item => item.ProviderId == "shared-fixture")
|
|
.ToListAsync();
|
|
Assert.Equal(2, sharedIdentities.Count);
|
|
Assert.Equal(2, sharedIdentities.Select(item => item.TenantId).Distinct().Count());
|
|
Assert.All(sharedIdentities, item => Assert.Equal("shared-track-42", item.ExternalId));
|
|
var restoredAccount = await target.ProviderAccounts.SingleAsync(
|
|
item => item.Id == globalAccountId);
|
|
Assert.Equal(ProviderAccountScope.Global, restoredAccount.Scope);
|
|
Assert.False(restoredAccount.Enabled);
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData("../escaped.flac")]
|
|
[InlineData("/another-root/escaped.flac")]
|
|
[InlineData("/managed/music/../escaped.flac")]
|
|
public async Task Import_RejectsUnsafeManagedFilePathBeforeDatabaseWrite(string unsafePath)
|
|
{
|
|
var artifact = await _service.ExportAsync(Path.Combine(_root, "transfers"), writesQuiesced: true);
|
|
artifact = await RewriteJsonArrayEntryAsync(artifact, "managed-files.json",
|
|
values => values[0]!["canonicalPath"] = unsafePath);
|
|
|
|
var exception = await Assert.ThrowsAsync<BackupVerificationException>(() =>
|
|
DurableStateTransferService.ImportAsync(artifact,
|
|
Factory($"postgres-fixture:{Path.Combine(_root, $"unsafe-path-{Guid.NewGuid():N}.db")}"), true));
|
|
|
|
Assert.Contains("managed file", exception.Message, StringComparison.OrdinalIgnoreCase);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Import_RejectsManagedReferenceCountThatDoesNotMatchActiveReferences()
|
|
{
|
|
var artifact = await _service.ExportAsync(Path.Combine(_root, "transfers"), writesQuiesced: true);
|
|
artifact = await RewriteJsonArrayEntryAsync(artifact, "managed-files.json",
|
|
values => values[0]!["referenceCount"] = 2);
|
|
|
|
var exception = await Assert.ThrowsAsync<BackupVerificationException>(() =>
|
|
DurableStateTransferService.ImportAsync(artifact,
|
|
Factory($"postgres-fixture:{Path.Combine(_root, "invalid-managed-reference-count.db")}"), true));
|
|
|
|
Assert.Contains("reference count", exception.Message, StringComparison.OrdinalIgnoreCase);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Import_RejectsManagedReferenceOutsideFileOwnershipScope()
|
|
{
|
|
var second = await AddTenantFixtureAsync("managed-reference-cross-scope");
|
|
var artifact = await _service.ExportAsync(Path.Combine(_root, "transfers"), writesQuiesced: true);
|
|
artifact = await RewriteJsonArrayEntryAsync(artifact, "managed-file-references.json", values =>
|
|
{
|
|
values[0]!["tenantId"] = second.TenantId;
|
|
values[0]!["ownerUserId"] = second.UserId;
|
|
});
|
|
|
|
var exception = await Assert.ThrowsAsync<BackupVerificationException>(() =>
|
|
DurableStateTransferService.ImportAsync(artifact,
|
|
Factory($"postgres-fixture:{Path.Combine(_root, "cross-scope-managed-reference.db")}"), true));
|
|
|
|
Assert.Contains("managed file reference", exception.Message, StringComparison.OrdinalIgnoreCase);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Import_RejectsRouteCandidateUsingAnotherSameTenantUsersAccount()
|
|
{
|
|
var other = await AddSameTenantRouteUserAsync("candidate");
|
|
var artifact = await _service.ExportAsync(Path.Combine(_root, "transfers"), writesQuiesced: true);
|
|
artifact = await RewriteJsonArrayEntryAsync(artifact, "provider-route-decisions.json", values =>
|
|
{
|
|
var route = values[0]!.AsObject();
|
|
var candidates = JsonNode.Parse(route["candidateDecisionsJson"]!.GetValue<string>())!.AsArray();
|
|
candidates.Add(new JsonObject
|
|
{
|
|
["providerId"] = "other-fixture",
|
|
["providerAccountId"] = other.AccountId,
|
|
["status"] = (int)ProviderRouteDecisionStatus.Rejected,
|
|
["reasonCode"] = "not-selected",
|
|
["priority"] = 1
|
|
});
|
|
route["candidateDecisionsJson"] = candidates.ToJsonString();
|
|
});
|
|
|
|
var exception = await Assert.ThrowsAsync<BackupVerificationException>(() =>
|
|
DurableStateTransferService.ImportAsync(artifact,
|
|
Factory($"postgres-fixture:{Path.Combine(_root, "cross-user-route-candidate.db")}"), true));
|
|
|
|
Assert.Contains("provider route", exception.Message, StringComparison.OrdinalIgnoreCase);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Import_RejectsRouteJobOwnedByAnotherSameTenantUser()
|
|
{
|
|
var other = await AddSameTenantRouteUserAsync("job");
|
|
var artifact = await _service.ExportAsync(Path.Combine(_root, "transfers"), writesQuiesced: true);
|
|
artifact = await RewriteJsonArrayEntryAsync(artifact, "provider-route-decisions.json",
|
|
values => values[0]!["durableJobId"] = other.JobId);
|
|
|
|
var exception = await Assert.ThrowsAsync<BackupVerificationException>(() =>
|
|
DurableStateTransferService.ImportAsync(artifact,
|
|
Factory($"postgres-fixture:{Path.Combine(_root, "cross-user-route-job.db")}"), true));
|
|
|
|
Assert.Contains("provider route", exception.Message, StringComparison.OrdinalIgnoreCase);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Export_IncludesProviderDownloadWorkspaceAndArtifactEntries()
|
|
{
|
|
var artifact = await _service.ExportAsync(Path.Combine(_root, "transfers"), writesQuiesced: true);
|
|
using var archive = ZipFile.OpenRead(artifact.Path);
|
|
Assert.True(archive.GetEntry("provider-download-workspaces.json")!.Length > 2);
|
|
Assert.True(archive.GetEntry("provider-download-artifacts.json")!.Length > 2);
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData("../escape.flac")]
|
|
[InlineData("/absolute.flac")]
|
|
[InlineData("provider\\output.flac")]
|
|
[InlineData("provider//output.flac")]
|
|
public async Task Import_RejectsUnsafeProviderDownloadArtifactRelativePath(string unsafePath)
|
|
{
|
|
var artifact = await _service.ExportAsync(Path.Combine(_root, "transfers"), writesQuiesced: true);
|
|
artifact = await RewriteJsonArrayEntryAsync(artifact, "provider-download-artifacts.json", values =>
|
|
{
|
|
values[0]!["relativePath"] = unsafePath;
|
|
values[0]!["providerArtifactId"] = unsafePath;
|
|
});
|
|
|
|
var exception = await Assert.ThrowsAsync<BackupVerificationException>(() =>
|
|
DurableStateTransferService.ImportAsync(artifact,
|
|
Factory($"postgres-fixture:{Path.Combine(_root, $"unsafe-download-{Guid.NewGuid():N}.db")}"), true));
|
|
|
|
Assert.Contains("download artifact", exception.Message, StringComparison.OrdinalIgnoreCase);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Import_RejectsProviderDownloadWorkspaceCrossingJobScope()
|
|
{
|
|
var second = await AddTenantFixtureAsync("download-cross-scope");
|
|
var artifact = await _service.ExportAsync(Path.Combine(_root, "transfers"), writesQuiesced: true);
|
|
artifact = await RewriteJsonArrayEntryAsync(artifact, "provider-download-workspaces.json", values =>
|
|
{
|
|
values[0]!["tenantId"] = second.TenantId;
|
|
values[0]!["ownerUserId"] = second.UserId;
|
|
});
|
|
|
|
var exception = await Assert.ThrowsAsync<BackupVerificationException>(() =>
|
|
DurableStateTransferService.ImportAsync(artifact,
|
|
Factory($"postgres-fixture:{Path.Combine(_root, "cross-download-workspace.db")}"), true));
|
|
Assert.Contains("download workspace", exception.Message, StringComparison.OrdinalIgnoreCase);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Import_RejectsProviderDownloadArtifactCrossingWorkspaceScope()
|
|
{
|
|
var second = await AddTenantFixtureAsync("download-artifact-cross-scope");
|
|
var artifact = await _service.ExportAsync(Path.Combine(_root, "transfers"), writesQuiesced: true);
|
|
artifact = await RewriteJsonArrayEntryAsync(artifact, "provider-download-artifacts.json", values =>
|
|
{
|
|
values[0]!["tenantId"] = second.TenantId;
|
|
values[0]!["ownerUserId"] = second.UserId;
|
|
});
|
|
|
|
var exception = await Assert.ThrowsAsync<BackupVerificationException>(() =>
|
|
DurableStateTransferService.ImportAsync(artifact,
|
|
Factory($"postgres-fixture:{Path.Combine(_root, "cross-download-artifact.db")}"), true));
|
|
Assert.Contains("download artifact", exception.Message, StringComparison.OrdinalIgnoreCase);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Import_RejectsProviderDownloadArtifactWithInvalidHashLengthOrLifecycle()
|
|
{
|
|
var artifact = await _service.ExportAsync(Path.Combine(_root, "transfers"), writesQuiesced: true);
|
|
artifact = await RewriteJsonArrayEntryAsync(artifact, "provider-download-artifacts.json", values =>
|
|
{
|
|
values[0]!["contentSha256"] = "BAD";
|
|
values[0]!["length"] = 0;
|
|
values[0]!["state"] = (int)ProviderDownloadArtifactState.Verified;
|
|
});
|
|
|
|
var exception = await Assert.ThrowsAsync<BackupVerificationException>(() =>
|
|
DurableStateTransferService.ImportAsync(artifact,
|
|
Factory($"postgres-fixture:{Path.Combine(_root, "invalid-download-facts.db")}"), true));
|
|
Assert.Contains("download artifact", exception.Message, StringComparison.OrdinalIgnoreCase);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Import_RejectsProviderDownloadArtifactLinkedToWrongManagedFileScope()
|
|
{
|
|
var second = await AddTenantFixtureAsync("download-managed-cross-scope");
|
|
Guid foreignManagedFileId;
|
|
await using (var source = await _sourceFactory.CreateDbContextAsync())
|
|
{
|
|
foreignManagedFileId = Guid.CreateVersion7();
|
|
source.ManagedFiles.Add(new ManagedFileOwnershipEntity
|
|
{
|
|
Id = foreignManagedFileId,
|
|
RootId = Guid.CreateVersion7(),
|
|
TargetRootPath = "/managed/foreign",
|
|
CanonicalPath = "/managed/foreign/file.flac",
|
|
ContentSha256 = new string('e', 64),
|
|
Length = 1,
|
|
PlacementMethod = ManagedFilePlacementMethod.Copy,
|
|
TenantId = second.TenantId,
|
|
OwnerUserId = second.UserId,
|
|
ScopeKey = "foreign",
|
|
ReferenceCount = 1,
|
|
IsManaged = true,
|
|
CreatedAt = DateTimeOffset.UtcNow
|
|
});
|
|
source.ManagedFileReferences.Add(new ManagedFileReferenceEntity
|
|
{
|
|
Id = Guid.CreateVersion7(),
|
|
ManagedFileId = foreignManagedFileId,
|
|
TenantId = second.TenantId,
|
|
OwnerUserId = second.UserId,
|
|
ScopeKey = "foreign",
|
|
ReferenceKey = "foreign:fixture",
|
|
CreatedAt = DateTimeOffset.UtcNow,
|
|
Revision = 1
|
|
});
|
|
await source.SaveChangesAsync();
|
|
}
|
|
var artifact = await _service.ExportAsync(Path.Combine(_root, "transfers"), writesQuiesced: true);
|
|
artifact = await RewriteJsonArrayEntryAsync(artifact, "provider-download-artifacts.json",
|
|
values => values[0]!["managedFileId"] = foreignManagedFileId);
|
|
|
|
var exception = await Assert.ThrowsAsync<BackupVerificationException>(() =>
|
|
DurableStateTransferService.ImportAsync(artifact,
|
|
Factory($"postgres-fixture:{Path.Combine(_root, "cross-download-managed.db")}"), true));
|
|
Assert.Contains("download artifact", exception.Message, StringComparison.OrdinalIgnoreCase);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Import_RejectsRepeatedProviderDownloadArtifactIdentity()
|
|
{
|
|
var artifact = await _service.ExportAsync(Path.Combine(_root, "transfers"), writesQuiesced: true);
|
|
artifact = await RewriteJsonArrayEntryAsync(artifact, "provider-download-artifacts.json", values =>
|
|
{
|
|
var duplicate = values[0]!.DeepClone();
|
|
duplicate!["id"] = Guid.CreateVersion7();
|
|
values.Add(duplicate);
|
|
});
|
|
|
|
var exception = await Assert.ThrowsAsync<BackupVerificationException>(() =>
|
|
DurableStateTransferService.ImportAsync(artifact,
|
|
Factory($"postgres-fixture:{Path.Combine(_root, "duplicate-download-artifact.db")}"), true));
|
|
Assert.Contains("download artifact", exception.Message, StringComparison.OrdinalIgnoreCase);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Import_RejectsFavoriteEventCrossTenantJobBeforeDatabaseWrite()
|
|
{
|
|
var second = await AddTenantFixtureAsync("phase6-favorite");
|
|
var artifact = await _service.ExportAsync(Path.Combine(_root, "transfers"), writesQuiesced: true);
|
|
artifact = await RewriteJsonArrayEntryAsync(artifact, "favorite-events.json", values =>
|
|
{
|
|
values[0]!["tenantId"] = second.TenantId;
|
|
values[0]!["ownerUserId"] = second.UserId;
|
|
});
|
|
|
|
var exception = await Assert.ThrowsAsync<BackupVerificationException>(() =>
|
|
DurableStateTransferService.ImportAsync(artifact,
|
|
Factory($"postgres-fixture:{Path.Combine(_root, "cross-favorite-target.db")}"), true));
|
|
|
|
Assert.Contains("favorite event", exception.Message, StringComparison.OrdinalIgnoreCase);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Import_RejectsEnrichmentPlanCrossingManagedFileScopeBeforeDatabaseWrite()
|
|
{
|
|
var second = await AddTenantFixtureAsync("phase6-enrichment");
|
|
var artifact = await _service.ExportAsync(Path.Combine(_root, "transfers"), writesQuiesced: true);
|
|
artifact = await RewriteJsonArrayEntryAsync(artifact, "metadata-enrichment-plans.json", values =>
|
|
{
|
|
values[0]!["tenantId"] = second.TenantId;
|
|
values[0]!["ownerUserId"] = second.UserId;
|
|
});
|
|
|
|
var exception = await Assert.ThrowsAsync<BackupVerificationException>(() =>
|
|
DurableStateTransferService.ImportAsync(artifact,
|
|
Factory($"postgres-fixture:{Path.Combine(_root, "cross-enrichment-target.db")}"), true));
|
|
|
|
Assert.Contains("enrichment plan", exception.Message, StringComparison.OrdinalIgnoreCase);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Import_RejectsMalformedEnrichmentDecisionJsonBeforeDatabaseWrite()
|
|
{
|
|
var artifact = await _service.ExportAsync(Path.Combine(_root, "transfers"), writesQuiesced: true);
|
|
artifact = await RewriteJsonArrayEntryAsync(artifact, "metadata-enrichment-plans.json",
|
|
values => values[0]!["decisionsJson"] = "{\"not\":\"an array\"}");
|
|
|
|
var exception = await Assert.ThrowsAsync<BackupVerificationException>(() =>
|
|
DurableStateTransferService.ImportAsync(artifact,
|
|
Factory($"postgres-fixture:{Path.Combine(_root, "malformed-enrichment-target.db")}"), true));
|
|
|
|
Assert.Contains("enrichment plan", exception.Message, StringComparison.OrdinalIgnoreCase);
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData("global-owner")]
|
|
[InlineData("global-null-action")]
|
|
[InlineData("user-without-owner")]
|
|
[InlineData("cross-tenant-actor")]
|
|
[InlineData("invalid-protocol")]
|
|
[InlineData("unsafe-library")]
|
|
[InlineData("zero-revision")]
|
|
[InlineData("duplicate-scope")]
|
|
public async Task Import_RejectsMalformedFavoriteActionPolicyBeforeDatabaseWrite(string mutation)
|
|
{
|
|
var second = await AddTenantFixtureAsync($"policy-{mutation}");
|
|
var artifact = await _service.ExportAsync(Path.Combine(_root, "transfers"), writesQuiesced: true);
|
|
artifact = await RewriteJsonArrayEntryAsync(artifact, "favorite-action-policies.json", values =>
|
|
{
|
|
var global = values.Single(value => value!["scope"]!.GetValue<int>() == (int)FavoriteActionPolicyScope.Global)!;
|
|
var user = values.Single(value => value!["scope"]!.GetValue<int>() == (int)FavoriteActionPolicyScope.User)!;
|
|
switch (mutation)
|
|
{
|
|
case "global-owner": global["ownerUserId"] = second.UserId; break;
|
|
case "global-null-action": global["autoDownload"] = null; break;
|
|
case "user-without-owner": user["ownerUserId"] = null; break;
|
|
case "cross-tenant-actor": global["updatedByUserId"] = second.UserId; break;
|
|
case "invalid-protocol": global["protocol"] = "spotify"; break;
|
|
case "unsafe-library": global["libraryScopeId"] = "music\nother"; break;
|
|
case "zero-revision": global["revision"] = 0; break;
|
|
case "duplicate-scope": values.Add(global.DeepClone()); break;
|
|
}
|
|
});
|
|
|
|
var exception = await Assert.ThrowsAsync<BackupVerificationException>(() =>
|
|
DurableStateTransferService.ImportAsync(artifact,
|
|
Factory($"postgres-fixture:{Path.Combine(_root, $"policy-{mutation}-target.db")}"), true));
|
|
|
|
Assert.Contains("favorite action policy", exception.Message, StringComparison.OrdinalIgnoreCase);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Import_RejectsCanonicalCreatorFromAnotherTenantInTamperedArchive()
|
|
{
|
|
var second = await AddTenantFixtureAsync("cross-creator");
|
|
var artifact = await _service.ExportAsync(
|
|
Path.Combine(_root, "transfers"),
|
|
writesQuiesced: true);
|
|
artifact = await RewriteJsonArrayEntryAsync(
|
|
artifact,
|
|
"canonical-recordings.json",
|
|
values => values[0]!["createdByUserId"] = second.UserId);
|
|
|
|
var exception = await Assert.ThrowsAsync<BackupVerificationException>(() =>
|
|
DurableStateTransferService.ImportAsync(
|
|
artifact,
|
|
Factory($"postgres-fixture:{Path.Combine(_root, "cross-creator-target.db")}"),
|
|
targetConfirmedEmpty: true));
|
|
|
|
Assert.Contains("canonical recording", exception.Message, StringComparison.OrdinalIgnoreCase);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Import_RejectsCanonicalLinkFromAnotherTenantInTamperedArchive()
|
|
{
|
|
var second = await AddTenantFixtureAsync("cross-canonical");
|
|
var artifact = await _service.ExportAsync(
|
|
Path.Combine(_root, "transfers"),
|
|
writesQuiesced: true);
|
|
artifact = await RewriteJsonArrayEntryAsync(
|
|
artifact,
|
|
"provider-track-identities.json",
|
|
values => values[0]!["tenantId"] = second.TenantId);
|
|
|
|
var exception = await Assert.ThrowsAsync<BackupVerificationException>(() =>
|
|
DurableStateTransferService.ImportAsync(
|
|
artifact,
|
|
Factory($"postgres-fixture:{Path.Combine(_root, "cross-canonical-target.db")}"),
|
|
targetConfirmedEmpty: true));
|
|
|
|
Assert.Contains("canonical recording boundary", exception.Message, StringComparison.OrdinalIgnoreCase);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Import_RejectsAccountFromAnotherTenantInTamperedArchive()
|
|
{
|
|
var second = await AddTenantFixtureAsync("cross-account");
|
|
var artifact = await _service.ExportAsync(
|
|
Path.Combine(_root, "transfers"),
|
|
writesQuiesced: true);
|
|
artifact = await RewriteJsonArrayEntryAsync(
|
|
artifact,
|
|
"provider-accounts.json",
|
|
values =>
|
|
{
|
|
values[0]!["tenantId"] = second.TenantId;
|
|
values[0]!["ownerUserId"] = second.UserId;
|
|
});
|
|
|
|
var exception = await Assert.ThrowsAsync<BackupVerificationException>(() =>
|
|
DurableStateTransferService.ImportAsync(
|
|
artifact,
|
|
Factory($"postgres-fixture:{Path.Combine(_root, "cross-account-target.db")}"),
|
|
targetConfirmedEmpty: true));
|
|
|
|
Assert.Contains("invalid tenant", exception.Message, StringComparison.OrdinalIgnoreCase);
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData("malformed-provider")]
|
|
[InlineData("invalid-scope-shape")]
|
|
[InlineData("undefined-scope")]
|
|
public async Task Import_RejectsMalformedProviderAccountInChecksumValidArchive(
|
|
string mutation)
|
|
{
|
|
var artifact = await _service.ExportAsync(
|
|
Path.Combine(_root, "transfers"),
|
|
writesQuiesced: true);
|
|
artifact = await RewriteJsonArrayEntryAsync(
|
|
artifact,
|
|
"provider-accounts.json",
|
|
values => MutateProviderAccount(values[0]!.AsObject(), mutation));
|
|
|
|
var exception = await Assert.ThrowsAsync<BackupVerificationException>(() =>
|
|
DurableStateTransferService.ImportAsync(
|
|
artifact,
|
|
Factory($"postgres-fixture:{Path.Combine(_root, $"account-{mutation}-target.db")}"),
|
|
targetConfirmedEmpty: true));
|
|
|
|
Assert.Contains("provider account", exception.Message, StringComparison.OrdinalIgnoreCase);
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData("catalog-with-account")]
|
|
[InlineData("provider-mismatch")]
|
|
[InlineData("uppercase-hash")]
|
|
[InlineData("mismatched-hash")]
|
|
[InlineData("unknown-kind")]
|
|
[InlineData("non-track-kind")]
|
|
[InlineData("unknown-scope")]
|
|
[InlineData("unknown-verification")]
|
|
[InlineData("zero-decision-version")]
|
|
[InlineData("malformed-provider")]
|
|
[InlineData("malformed-catalog")]
|
|
[InlineData("malformed-external-id")]
|
|
public async Task Import_RejectsMalformedTrackIdentityInChecksumValidArchive(string mutation)
|
|
{
|
|
var artifact = await _service.ExportAsync(
|
|
Path.Combine(_root, "transfers"),
|
|
writesQuiesced: true);
|
|
artifact = await RewriteJsonArrayEntryAsync(
|
|
artifact,
|
|
"provider-track-identities.json",
|
|
values => MutateTrackIdentity(values[0]!.AsObject(), mutation));
|
|
|
|
var exception = await Assert.ThrowsAsync<BackupVerificationException>(() =>
|
|
DurableStateTransferService.ImportAsync(
|
|
artifact,
|
|
Factory($"postgres-fixture:{Path.Combine(_root, $"{mutation}-target.db")}"),
|
|
targetConfirmedEmpty: true));
|
|
|
|
Assert.Contains("identity data is invalid", exception.Message, StringComparison.OrdinalIgnoreCase);
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData("isrc", "usrc17607839")]
|
|
[InlineData("musicBrainzRecordingId", "0D34FC3F-4F36-4B8D-A0D3-E0D7A8B6FF23")]
|
|
public async Task Import_RejectsMalformedCanonicalSignalInChecksumValidArchive(
|
|
string property,
|
|
string value)
|
|
{
|
|
var artifact = await _service.ExportAsync(
|
|
Path.Combine(_root, "transfers"),
|
|
writesQuiesced: true);
|
|
artifact = await RewriteJsonArrayEntryAsync(
|
|
artifact,
|
|
"canonical-recordings.json",
|
|
values => values[0]![property] = value);
|
|
|
|
var exception = await Assert.ThrowsAsync<BackupVerificationException>(() =>
|
|
DurableStateTransferService.ImportAsync(
|
|
artifact,
|
|
Factory($"postgres-fixture:{Path.Combine(_root, $"{property}-target.db")}"),
|
|
targetConfirmedEmpty: true));
|
|
|
|
Assert.Contains("malformed", exception.Message, StringComparison.OrdinalIgnoreCase);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Export_RequiresCanonicalRecordingAndProviderTrackIdentityEntries()
|
|
{
|
|
var artifact = await _service.ExportAsync(
|
|
Path.Combine(_root, "transfers"),
|
|
writesQuiesced: true);
|
|
|
|
using var archive = ZipFile.OpenRead(artifact.Path);
|
|
var entryNames = archive.Entries.Select(entry => entry.FullName).ToHashSet(StringComparer.Ordinal);
|
|
Assert.Contains("canonical-recordings.json", entryNames);
|
|
Assert.Contains("provider-track-identities.json", entryNames);
|
|
|
|
var canonicalEntry = Assert.Single(
|
|
archive.Entries,
|
|
entry => entry.FullName == "canonical-recordings.json");
|
|
var identityEntry = Assert.Single(
|
|
archive.Entries,
|
|
entry => entry.FullName == "provider-track-identities.json");
|
|
Assert.True(canonicalEntry.Length > 2);
|
|
Assert.True(identityEntry.Length > 2);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Export_RequiresExplicitWriteQuiescence()
|
|
{
|
|
var exception = await Assert.ThrowsAsync<InvalidOperationException>(() =>
|
|
_service.ExportAsync(Path.Combine(_root, "transfers"), writesQuiesced: false));
|
|
|
|
Assert.Contains("quiescence", exception.Message, StringComparison.OrdinalIgnoreCase);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Import_RejectsNonEmptyTarget()
|
|
{
|
|
var artifact = await _service.ExportAsync(
|
|
Path.Combine(_root, "transfers"),
|
|
writesQuiesced: true);
|
|
|
|
var exception = await Assert.ThrowsAsync<InvalidOperationException>(() =>
|
|
DurableStateTransferService.ImportAsync(
|
|
artifact,
|
|
_sourceFactory,
|
|
targetConfirmedEmpty: true));
|
|
|
|
Assert.Contains("not empty", exception.Message, StringComparison.OrdinalIgnoreCase);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Import_RejectsTargetContainingOnlyOperationalState()
|
|
{
|
|
var artifact = await _service.ExportAsync(
|
|
Path.Combine(_root, "transfers"),
|
|
writesQuiesced: true);
|
|
var targetFactory = Factory(
|
|
$"postgres-fixture:{Path.Combine(_root, "operational-state-target.db")}");
|
|
await using (var target = await targetFactory.CreateDbContextAsync())
|
|
{
|
|
await target.Database.MigrateAsync();
|
|
target.Backups.Add(new BackupRecord
|
|
{
|
|
Id = Guid.CreateVersion7(),
|
|
StorageProvider = "Postgres",
|
|
ArtifactPath = "/fixture/backup.dump",
|
|
Sha256 = new string('a', 64),
|
|
SchemaVersion = _currentSchema,
|
|
ApplicationVersion = AppVersion.Version,
|
|
Status = "verified",
|
|
CreatedAt = DateTimeOffset.UtcNow
|
|
});
|
|
await target.SaveChangesAsync();
|
|
}
|
|
|
|
var exception = await Assert.ThrowsAsync<InvalidOperationException>(() =>
|
|
DurableStateTransferService.ImportAsync(
|
|
artifact,
|
|
targetFactory,
|
|
targetConfirmedEmpty: true));
|
|
|
|
Assert.Contains("not empty", exception.Message, StringComparison.OrdinalIgnoreCase);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Import_RejectsTargetContainingProviderDownloadWorkspaceState()
|
|
{
|
|
var artifact = await _service.ExportAsync(Path.Combine(_root, "transfers"), writesQuiesced: true);
|
|
var targetFactory = Factory($"postgres-fixture:{Path.Combine(_root, "download-workspace-state-target.db")}");
|
|
await using (var target = await targetFactory.CreateDbContextAsync())
|
|
{
|
|
await target.Database.MigrateAsync();
|
|
var tenantId = Guid.CreateVersion7();
|
|
var userId = Guid.CreateVersion7();
|
|
var jobId = Guid.CreateVersion7();
|
|
var now = DateTimeOffset.UtcNow;
|
|
target.Tenants.Add(new TenantRecord
|
|
{
|
|
Id = tenantId,
|
|
Slug = "target-download-state",
|
|
Name = "Target download state",
|
|
CreatedAt = now
|
|
});
|
|
target.Users.Add(new PlatformUserRecord
|
|
{
|
|
Id = userId,
|
|
TenantId = tenantId,
|
|
DisplayName = "Target user",
|
|
Status = PlatformUserStatus.Active,
|
|
CreatedAt = now,
|
|
UpdatedAt = now
|
|
});
|
|
target.Jobs.Add(new DurableJobRecord
|
|
{
|
|
Id = jobId,
|
|
ScopeKey = $"user:{tenantId:N}:{userId:N}",
|
|
TenantId = tenantId,
|
|
OwnerUserId = userId,
|
|
PolicySnapshotJson = "{}",
|
|
RequestFingerprint = new string('f', 64),
|
|
CorrelationId = "target-download-state",
|
|
Type = "fixture.download",
|
|
PayloadJson = "{}",
|
|
IdempotencyKey = "target-only",
|
|
State = DurableJobState.Pending,
|
|
MaxAttempts = 3,
|
|
MaxDeferrals = 3,
|
|
AvailableAt = now,
|
|
CreatedAt = now,
|
|
UpdatedAt = now
|
|
});
|
|
target.ProviderDownloadWorkspaces.Add(new ProviderDownloadWorkspaceEntity
|
|
{
|
|
Id = Guid.CreateVersion7(),
|
|
WorkspaceId = new string('f', 64),
|
|
TenantId = tenantId,
|
|
OwnerUserId = userId,
|
|
DurableJobId = jobId,
|
|
ProviderId = "fixture",
|
|
IdempotencyKey = "target-only",
|
|
CreatedAt = now,
|
|
Revision = 1
|
|
});
|
|
await target.SaveChangesAsync();
|
|
}
|
|
|
|
var exception = await Assert.ThrowsAsync<InvalidOperationException>(() =>
|
|
DurableStateTransferService.ImportAsync(artifact, targetFactory, targetConfirmedEmpty: true));
|
|
Assert.Contains("not empty", exception.Message, StringComparison.OrdinalIgnoreCase);
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData("canonical_recordings")]
|
|
[InlineData("provider_track_identities")]
|
|
public async Task Import_RejectsTargetContainingOnlyIncompleteIdentityState(string table)
|
|
{
|
|
var artifact = await _service.ExportAsync(
|
|
Path.Combine(_root, "transfers"),
|
|
writesQuiesced: true);
|
|
var targetFactory = Factory(
|
|
$"postgres-fixture:{Path.Combine(_root, $"{table}-target.db")}");
|
|
await using (var target = await targetFactory.CreateDbContextAsync())
|
|
{
|
|
await target.Database.MigrateAsync();
|
|
var tenantId = Guid.CreateVersion7();
|
|
var userId = Guid.CreateVersion7();
|
|
var canonicalRecordingId = Guid.CreateVersion7();
|
|
var now = DateTimeOffset.UtcNow;
|
|
target.Tenants.Add(new TenantRecord
|
|
{
|
|
Id = tenantId,
|
|
Slug = $"target-{tenantId:N}",
|
|
Name = "Target fixture",
|
|
CreatedAt = now
|
|
});
|
|
target.Users.Add(new PlatformUserRecord
|
|
{
|
|
Id = userId,
|
|
TenantId = tenantId,
|
|
DisplayName = "Target fixture",
|
|
Status = PlatformUserStatus.Active,
|
|
CreatedAt = now,
|
|
UpdatedAt = now
|
|
});
|
|
target.CanonicalRecordings.Add(new CanonicalRecordingRecord
|
|
{
|
|
Id = canonicalRecordingId,
|
|
TenantId = tenantId,
|
|
CreatedByUserId = userId,
|
|
CreatedAt = now,
|
|
UpdatedAt = now,
|
|
Revision = 1
|
|
});
|
|
if (table == "provider_track_identities")
|
|
{
|
|
target.ProviderTrackIdentities.Add(new ProviderTrackIdentityRecord
|
|
{
|
|
Id = Guid.CreateVersion7(),
|
|
TenantId = tenantId,
|
|
CanonicalRecordingId = canonicalRecordingId,
|
|
ProviderId = "fixture",
|
|
ResourceKind = ProviderResourceKind.Track,
|
|
CatalogNamespace = "default",
|
|
Scope = ProviderIdentityScope.Catalog,
|
|
ExternalId = "fixture-track",
|
|
ExternalIdHash = HashExternalId("fixture-track"),
|
|
Verification = ProviderIdentityVerification.Verified,
|
|
VerificationMethod = "fixture",
|
|
DecisionVersion = 1,
|
|
VerifiedAt = now,
|
|
CreatedAt = now,
|
|
UpdatedAt = now,
|
|
Revision = 1
|
|
});
|
|
}
|
|
await target.SaveChangesAsync();
|
|
}
|
|
|
|
var exception = await Assert.ThrowsAsync<InvalidOperationException>(() =>
|
|
DurableStateTransferService.ImportAsync(
|
|
artifact,
|
|
targetFactory,
|
|
targetConfirmedEmpty: true));
|
|
|
|
Assert.Contains("not empty", exception.Message, StringComparison.OrdinalIgnoreCase);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Import_RejectsSchemaFromAnotherBuildBeforeMigratingTarget()
|
|
{
|
|
var artifact = await _service.ExportAsync(
|
|
Path.Combine(_root, "transfers"),
|
|
writesQuiesced: true);
|
|
const string futureSchema = "99991231235959_FutureAllstarrSchema";
|
|
artifact = await RewriteManifestAsync(
|
|
artifact,
|
|
json => json.Replace(
|
|
$"\"schemaVersion\":\"{_currentSchema}\"",
|
|
$"\"schemaVersion\":\"{futureSchema}\"",
|
|
StringComparison.Ordinal));
|
|
artifact = artifact with { SchemaVersion = futureSchema };
|
|
var targetPath = Path.Combine(_root, "incompatible-schema-target.db");
|
|
|
|
var exception = await Assert.ThrowsAsync<BackupVerificationException>(() =>
|
|
DurableStateTransferService.ImportAsync(
|
|
artifact,
|
|
Factory($"postgres-fixture:{targetPath}"),
|
|
targetConfirmedEmpty: true));
|
|
|
|
Assert.Contains("schema", exception.Message, StringComparison.OrdinalIgnoreCase);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Import_RejectsArtifactFromAnotherApplicationVersion()
|
|
{
|
|
var artifact = await _service.ExportAsync(
|
|
Path.Combine(_root, "transfers"),
|
|
writesQuiesced: true);
|
|
artifact = await RewriteManifestAsync(
|
|
artifact,
|
|
json => json.Replace(
|
|
$"\"applicationVersion\":\"{AppVersion.Version}\"",
|
|
"\"applicationVersion\":\"999.0.0\"",
|
|
StringComparison.Ordinal));
|
|
var targetPath = Path.Combine(_root, "incompatible-application-target.db");
|
|
|
|
var exception = await Assert.ThrowsAsync<BackupVerificationException>(() =>
|
|
DurableStateTransferService.ImportAsync(
|
|
artifact,
|
|
Factory($"postgres-fixture:{targetPath}"),
|
|
targetConfirmedEmpty: true));
|
|
|
|
Assert.Contains("application", exception.Message, StringComparison.OrdinalIgnoreCase);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task LoadArtifact_RejectsUnknownManifestFieldsAndProviderValues()
|
|
{
|
|
var artifact = await _service.ExportAsync(
|
|
Path.Combine(_root, "transfers"),
|
|
writesQuiesced: true);
|
|
var unknownFieldArtifact = await RewriteManifestAsync(
|
|
artifact,
|
|
json => json.Replace(
|
|
"\"secretKeyMaterialIncluded\":false",
|
|
"\"secretKeyMaterialIncluded\":false,\"unexpected\":\"value\"",
|
|
StringComparison.Ordinal));
|
|
|
|
var unknownFieldException = await Assert.ThrowsAsync<BackupVerificationException>(() =>
|
|
DurableStateTransferService.LoadArtifactAsync(
|
|
unknownFieldArtifact.Path,
|
|
unknownFieldArtifact.Sha256));
|
|
Assert.Contains("unknown", unknownFieldException.Message, StringComparison.OrdinalIgnoreCase);
|
|
|
|
var secondArtifact = await _service.ExportAsync(
|
|
Path.Combine(_root, "transfers"),
|
|
writesQuiesced: true);
|
|
var unknownProviderArtifact = await RewriteManifestAsync(
|
|
secondArtifact,
|
|
json => json.Replace(
|
|
"\"sourceProvider\":\"Postgres\"",
|
|
"\"sourceProvider\":\"Automatic\"",
|
|
StringComparison.Ordinal));
|
|
|
|
var providerException = await Assert.ThrowsAsync<BackupVerificationException>(() =>
|
|
DurableStateTransferService.LoadArtifactAsync(
|
|
unknownProviderArtifact.Path,
|
|
unknownProviderArtifact.Sha256));
|
|
Assert.Contains("provider", providerException.Message, StringComparison.OrdinalIgnoreCase);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task LoadArtifact_RejectsUnknownArchiveEntries()
|
|
{
|
|
var artifact = await _service.ExportAsync(
|
|
Path.Combine(_root, "transfers"),
|
|
writesQuiesced: true);
|
|
using (var archive = ZipFile.Open(artifact.Path, ZipArchiveMode.Update))
|
|
{
|
|
var entry = archive.CreateEntry("secret-key-material.json");
|
|
await using var stream = entry.Open();
|
|
await stream.WriteAsync("not-allowed"u8.ToArray());
|
|
}
|
|
|
|
artifact = artifact with { Sha256 = await ComputeSha256Async(artifact.Path) };
|
|
|
|
var exception = await Assert.ThrowsAsync<BackupVerificationException>(() =>
|
|
DurableStateTransferService.LoadArtifactAsync(artifact.Path, artifact.Sha256));
|
|
|
|
Assert.Contains("unknown", exception.Message, StringComparison.OrdinalIgnoreCase);
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData("canonical-recordings.json")]
|
|
[InlineData("provider-track-identities.json")]
|
|
[InlineData("onboarding-states.json")]
|
|
public async Task LoadArtifact_RejectsMissingTrackIdentityArchiveEntries(string entryName)
|
|
{
|
|
var artifact = await _service.ExportAsync(
|
|
Path.Combine(_root, "transfers"),
|
|
writesQuiesced: true);
|
|
using (var archive = ZipFile.Open(artifact.Path, ZipArchiveMode.Update))
|
|
{
|
|
(archive.GetEntry(entryName)
|
|
?? throw new InvalidOperationException($"Fixture entry '{entryName}' is missing."))
|
|
.Delete();
|
|
}
|
|
|
|
artifact = artifact with { Sha256 = await ComputeSha256Async(artifact.Path) };
|
|
|
|
var exception = await Assert.ThrowsAsync<BackupVerificationException>(() =>
|
|
DurableStateTransferService.LoadArtifactAsync(artifact.Path, artifact.Sha256));
|
|
|
|
Assert.Contains("missing", exception.Message, StringComparison.OrdinalIgnoreCase);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Import_RejectsRequestedMetadataThatDoesNotMatchManifest()
|
|
{
|
|
var artifact = await _service.ExportAsync(
|
|
Path.Combine(_root, "transfers"),
|
|
writesQuiesced: true);
|
|
|
|
var exception = await Assert.ThrowsAsync<BackupVerificationException>(() =>
|
|
DurableStateTransferService.ImportAsync(
|
|
artifact with { SourceProvider = "Other" },
|
|
Factory($"postgres-fixture:{Path.Combine(_root, "metadata-target.db")}"),
|
|
targetConfirmedEmpty: true));
|
|
|
|
Assert.Contains("metadata", exception.Message, StringComparison.OrdinalIgnoreCase);
|
|
}
|
|
|
|
private async Task<(Guid TenantId, Guid UserId, Guid CanonicalRecordingId)>
|
|
AddTenantFixtureAsync(string suffix)
|
|
{
|
|
var tenantId = Guid.CreateVersion7();
|
|
var userId = Guid.CreateVersion7();
|
|
var canonicalRecordingId = Guid.CreateVersion7();
|
|
await using var source = await _sourceFactory.CreateDbContextAsync();
|
|
source.Tenants.Add(new TenantRecord
|
|
{
|
|
Id = tenantId,
|
|
Slug = $"transfer-{suffix}",
|
|
Name = $"Transfer {suffix}",
|
|
CreatedAt = DateTimeOffset.UtcNow
|
|
});
|
|
source.Users.Add(new PlatformUserRecord
|
|
{
|
|
Id = userId,
|
|
TenantId = tenantId,
|
|
DisplayName = $"Transfer {suffix} user",
|
|
Status = PlatformUserStatus.Active,
|
|
CreatedAt = DateTimeOffset.UtcNow,
|
|
UpdatedAt = DateTimeOffset.UtcNow
|
|
});
|
|
source.CanonicalRecordings.Add(new CanonicalRecordingRecord
|
|
{
|
|
Id = canonicalRecordingId,
|
|
TenantId = tenantId,
|
|
CreatedByUserId = userId,
|
|
CreatedAt = DateTimeOffset.UtcNow,
|
|
UpdatedAt = DateTimeOffset.UtcNow,
|
|
Revision = 1
|
|
});
|
|
await source.SaveChangesAsync();
|
|
return (tenantId, userId, canonicalRecordingId);
|
|
}
|
|
|
|
private async Task<(Guid UserId, Guid AccountId, Guid JobId)> AddSameTenantRouteUserAsync(string suffix)
|
|
{
|
|
await using var source = await _sourceFactory.CreateDbContextAsync();
|
|
var route = await source.ProviderRouteDecisions.AsNoTracking().SingleAsync();
|
|
var userId = Guid.CreateVersion7();
|
|
var accountId = Guid.CreateVersion7();
|
|
var jobId = Guid.CreateVersion7();
|
|
var now = DateTimeOffset.UtcNow;
|
|
source.Users.Add(new PlatformUserRecord
|
|
{
|
|
Id = userId,
|
|
TenantId = route.TenantId,
|
|
DisplayName = $"Other route {suffix} user",
|
|
Status = PlatformUserStatus.Active,
|
|
CreatedAt = now,
|
|
UpdatedAt = now
|
|
});
|
|
source.ProviderAccounts.Add(new ProviderAccountRecord
|
|
{
|
|
Id = accountId,
|
|
TenantId = route.TenantId,
|
|
OwnerUserId = userId,
|
|
ProviderId = "other-fixture",
|
|
DisplayName = $"Other route {suffix} account",
|
|
Scope = ProviderAccountScope.User,
|
|
Enabled = true,
|
|
CreatedAt = now,
|
|
UpdatedAt = now
|
|
});
|
|
source.Jobs.Add(new DurableJobRecord
|
|
{
|
|
Id = jobId,
|
|
TenantId = route.TenantId,
|
|
OwnerUserId = userId,
|
|
ScopeKey = $"user:{route.TenantId:N}:{userId:N}",
|
|
Type = "route.transfer.test",
|
|
PayloadJson = "{}",
|
|
IdempotencyKey = $"route-transfer-{suffix}",
|
|
State = DurableJobState.Succeeded,
|
|
MaxAttempts = 3,
|
|
MaxDeferrals = 3,
|
|
AvailableAt = now,
|
|
PolicySnapshotJson = "{}",
|
|
RequestFingerprint = new string('9', 64),
|
|
CorrelationId = $"route-transfer-{suffix}",
|
|
CreatedAt = now,
|
|
UpdatedAt = now,
|
|
CompletedAt = now
|
|
});
|
|
await source.SaveChangesAsync();
|
|
return (userId, accountId, jobId);
|
|
}
|
|
|
|
private static ProviderTrackIdentityRecord CreateAccountIdentity(
|
|
Guid tenantId,
|
|
Guid canonicalRecordingId,
|
|
Guid providerAccountId,
|
|
string providerId,
|
|
string externalId) => new()
|
|
{
|
|
Id = Guid.CreateVersion7(),
|
|
TenantId = tenantId,
|
|
CanonicalRecordingId = canonicalRecordingId,
|
|
ProviderAccountId = providerAccountId,
|
|
ProviderId = providerId,
|
|
ResourceKind = ProviderResourceKind.Track,
|
|
CatalogNamespace = "default",
|
|
Scope = ProviderIdentityScope.Account,
|
|
ExternalId = externalId,
|
|
ExternalIdHash = HashExternalId(externalId),
|
|
Verification = ProviderIdentityVerification.Verified,
|
|
VerificationMethod = "fixture",
|
|
DecisionVersion = 1,
|
|
VerifiedAt = DateTimeOffset.UtcNow,
|
|
CreatedAt = DateTimeOffset.UtcNow,
|
|
UpdatedAt = DateTimeOffset.UtcNow,
|
|
Revision = 1
|
|
};
|
|
|
|
private static void MutateTrackIdentity(JsonObject identity, string mutation)
|
|
{
|
|
switch (mutation)
|
|
{
|
|
case "catalog-with-account":
|
|
identity["scope"] = (int)ProviderIdentityScope.Catalog;
|
|
break;
|
|
case "provider-mismatch":
|
|
identity["providerId"] = "other-provider";
|
|
break;
|
|
case "uppercase-hash":
|
|
identity["externalIdHash"] = identity["externalIdHash"]!
|
|
.GetValue<string>()
|
|
.ToUpperInvariant();
|
|
break;
|
|
case "mismatched-hash":
|
|
identity["externalIdHash"] = new string('a', 64);
|
|
break;
|
|
case "unknown-kind":
|
|
identity["resourceKind"] = (int)ProviderResourceKind.Unknown;
|
|
break;
|
|
case "non-track-kind":
|
|
identity["resourceKind"] = (int)ProviderResourceKind.Album;
|
|
break;
|
|
case "unknown-scope":
|
|
identity["scope"] = (int)ProviderIdentityScope.Unknown;
|
|
break;
|
|
case "unknown-verification":
|
|
identity["verification"] = (int)ProviderIdentityVerification.Unknown;
|
|
break;
|
|
case "zero-decision-version":
|
|
identity["decisionVersion"] = 0;
|
|
break;
|
|
case "malformed-provider":
|
|
identity["providerId"] = "Fixture";
|
|
break;
|
|
case "malformed-catalog":
|
|
identity["catalogNamespace"] = " Default ";
|
|
break;
|
|
case "malformed-external-id":
|
|
identity["externalId"] = " fixture-track-42 ";
|
|
break;
|
|
default:
|
|
throw new ArgumentOutOfRangeException(nameof(mutation), mutation, null);
|
|
}
|
|
}
|
|
|
|
private static void MutateProviderAccount(JsonObject account, string mutation)
|
|
{
|
|
switch (mutation)
|
|
{
|
|
case "malformed-provider":
|
|
account["providerId"] = "Fixture";
|
|
break;
|
|
case "invalid-scope-shape":
|
|
account["ownerUserId"] = null;
|
|
break;
|
|
case "undefined-scope":
|
|
account["scope"] = 99;
|
|
break;
|
|
default:
|
|
throw new ArgumentOutOfRangeException(nameof(mutation), mutation, null);
|
|
}
|
|
}
|
|
|
|
private static async Task<DurableStateTransferArtifact> RewriteJsonArrayEntryAsync(
|
|
DurableStateTransferArtifact artifact,
|
|
string entryName,
|
|
Action<JsonArray> transform)
|
|
{
|
|
string entryJson;
|
|
using (var archive = ZipFile.Open(artifact.Path, ZipArchiveMode.Update))
|
|
{
|
|
var entry = archive.GetEntry(entryName)
|
|
?? throw new InvalidOperationException(
|
|
$"Fixture entry '{entryName}' is missing.");
|
|
await using (var stream = entry.Open())
|
|
using (var reader = new StreamReader(stream, Encoding.UTF8))
|
|
{
|
|
entryJson = await reader.ReadToEndAsync();
|
|
}
|
|
|
|
entry.Delete();
|
|
var values = JsonNode.Parse(entryJson)?.AsArray()
|
|
?? throw new InvalidOperationException(
|
|
$"Fixture entry '{entryName}' is not a JSON array.");
|
|
transform(values);
|
|
var replacement = archive.CreateEntry(entryName, CompressionLevel.Optimal);
|
|
await using var replacementStream = replacement.Open();
|
|
await using var writer = new StreamWriter(
|
|
replacementStream,
|
|
new UTF8Encoding(false));
|
|
await writer.WriteAsync(values.ToJsonString());
|
|
}
|
|
|
|
return artifact with { Sha256 = await ComputeSha256Async(artifact.Path) };
|
|
}
|
|
|
|
private static async Task<DurableStateTransferArtifact> RewriteManifestAsync(
|
|
DurableStateTransferArtifact artifact,
|
|
Func<string, string> transform)
|
|
{
|
|
string manifestJson;
|
|
using (var archive = ZipFile.Open(artifact.Path, ZipArchiveMode.Update))
|
|
{
|
|
var manifest = archive.GetEntry("manifest.json")
|
|
?? throw new InvalidOperationException("Fixture manifest is missing.");
|
|
await using (var stream = manifest.Open())
|
|
using (var reader = new StreamReader(stream, Encoding.UTF8))
|
|
{
|
|
manifestJson = await reader.ReadToEndAsync();
|
|
}
|
|
|
|
manifest.Delete();
|
|
var replacement = archive.CreateEntry("manifest.json", CompressionLevel.Optimal);
|
|
await using var replacementStream = replacement.Open();
|
|
await using var writer = new StreamWriter(replacementStream, new UTF8Encoding(false));
|
|
await writer.WriteAsync(transform(manifestJson));
|
|
}
|
|
|
|
return artifact with { Sha256 = await ComputeSha256Async(artifact.Path) };
|
|
}
|
|
|
|
private static async Task<string> ComputeSha256Async(string path)
|
|
{
|
|
await using var stream = File.OpenRead(path);
|
|
return Convert.ToHexString(await SHA256.HashDataAsync(stream)).ToLowerInvariant();
|
|
}
|
|
|
|
private static string HashExternalId(string externalId) =>
|
|
Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(externalId)))
|
|
.ToLowerInvariant();
|
|
|
|
private TestDbContextFactory Factory(string databaseKey)
|
|
{
|
|
if (!_databases.TryGetValue(databaseKey, out var database))
|
|
{
|
|
database = PostgresTestDatabase.CreateAsync().GetAwaiter().GetResult();
|
|
_databases[databaseKey] = database;
|
|
}
|
|
|
|
return new TestDbContextFactory(database.Options);
|
|
}
|
|
|
|
public async Task DisposeAsync()
|
|
{
|
|
foreach (var database in _databases.Values.Distinct())
|
|
{
|
|
await database.DisposeAsync();
|
|
}
|
|
|
|
if (Directory.Exists(_root))
|
|
{
|
|
Directory.Delete(_root, recursive: true);
|
|
}
|
|
}
|
|
|
|
private sealed class TestDbContextFactory(DbContextOptions<AllstarrDbContext> options)
|
|
: IDbContextFactory<AllstarrDbContext>
|
|
{
|
|
public AllstarrDbContext CreateDbContext() => new(options);
|
|
|
|
public Task<AllstarrDbContext> CreateDbContextAsync(
|
|
CancellationToken cancellationToken = default) => Task.FromResult(new AllstarrDbContext(options));
|
|
}
|
|
}
|