mirror of
https://github.com/SoPat712/allstarr.git
synced 2026-10-08 06:05:02 -04:00
842 lines
35 KiB
C#
842 lines
35 KiB
C#
using System.IO.Compression;
|
|
using System.Security.Cryptography;
|
|
using System.Text.Json;
|
|
using System.Text.Json.Nodes;
|
|
using allstarr.Core.Capabilities;
|
|
using allstarr.Core.Intelligence;
|
|
using allstarr.Core.Settings;
|
|
using allstarr.Core.Storage;
|
|
using Microsoft.EntityFrameworkCore;
|
|
|
|
namespace allstarr.Tests;
|
|
|
|
public sealed class SelectiveStateTransferServiceTests : IAsyncLifetime
|
|
{
|
|
private readonly string _root = Path.Combine(
|
|
Path.GetTempPath(),
|
|
"allstarr-selective-tests",
|
|
Guid.NewGuid().ToString("N"));
|
|
private readonly List<PostgresTestDatabase> _databases = [];
|
|
|
|
public Task InitializeAsync()
|
|
{
|
|
Directory.CreateDirectory(_root);
|
|
return Task.CompletedTask;
|
|
}
|
|
|
|
public async Task DisposeAsync()
|
|
{
|
|
foreach (var database in _databases)
|
|
{
|
|
await database.DisposeAsync();
|
|
}
|
|
|
|
if (Directory.Exists(_root))
|
|
{
|
|
try { Directory.Delete(_root, recursive: true); } catch { }
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ExportSelective_OnlyIncludesRequestedCategories()
|
|
{
|
|
var (factory, options, state) = await CreateSeededContextAsync();
|
|
var service = new SelectiveStateTransferService(factory, options, state);
|
|
|
|
var exportDir = Path.Combine(_root, "export-only-accounts");
|
|
var request = new SelectiveExportRequest
|
|
{
|
|
IncludeSettings = false,
|
|
IncludeAccounts = true,
|
|
IncludePlaylists = false,
|
|
IncludeIntelligence = false,
|
|
IncludeExtensions = false
|
|
};
|
|
|
|
var (artifact, report) = await service.ExportAsync(exportDir, request);
|
|
|
|
Assert.True(File.Exists(artifact.Path));
|
|
Assert.Contains("Settings", report.ExcludedCategories);
|
|
Assert.Contains("Playlists", report.ExcludedCategories);
|
|
Assert.Contains("Intelligence", report.ExcludedCategories);
|
|
Assert.Contains("Extensions", report.ExcludedCategories);
|
|
Assert.DoesNotContain("Accounts", report.ExcludedCategories);
|
|
|
|
Assert.True(report.RowsByEntry.ContainsKey("users"));
|
|
Assert.True(report.RowsByEntry.ContainsKey("backend-identities"));
|
|
Assert.True(report.RowsByEntry.ContainsKey("provider-accounts"));
|
|
Assert.False(report.RowsByEntry.ContainsKey("tenants"));
|
|
Assert.False(report.RowsByEntry.ContainsKey("library-tracks"));
|
|
|
|
using var archive = ZipFile.OpenRead(artifact.Path);
|
|
var entryNames = archive.Entries.Select(e => e.FullName).ToHashSet(StringComparer.Ordinal);
|
|
Assert.Contains("users.json", entryNames);
|
|
Assert.Contains("provider-accounts.json", entryNames);
|
|
Assert.DoesNotContain("tenants.json", entryNames);
|
|
Assert.DoesNotContain("library-tracks.json", entryNames);
|
|
|
|
var manifest = await ReadManifestAsync(archive);
|
|
Assert.Equal(false, manifest["isFullExport"]);
|
|
var includedCategories = (object[]?)manifest["includedCategories"];
|
|
Assert.NotNull(includedCategories);
|
|
Assert.Equal(new object[] { "Accounts" }, includedCategories!);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ImportSelective_RejectsMissingDependency()
|
|
{
|
|
var (sourceFactory, sourceOptions, sourceState) = await CreateSeededContextAsync();
|
|
var sourceService = new SelectiveStateTransferService(sourceFactory, sourceOptions, sourceState);
|
|
|
|
var exportDir = Path.Combine(_root, "export-just-playlists");
|
|
var (artifact, _) = await sourceService.ExportAsync(
|
|
exportDir,
|
|
new SelectiveExportRequest
|
|
{
|
|
IncludeSettings = false,
|
|
IncludeAccounts = false,
|
|
IncludePlaylists = true,
|
|
IncludeIntelligence = false,
|
|
IncludeExtensions = false
|
|
});
|
|
|
|
var (targetFactory, targetOptions, targetState) = await CreateEmptyContextAsync();
|
|
var targetService = new SelectiveStateTransferService(targetFactory, targetOptions, targetState);
|
|
|
|
await using var archive = File.OpenRead(artifact.Path);
|
|
var ex = await Assert.ThrowsAsync<SelectiveTransferValidationException>(() =>
|
|
targetService.ImportAsync(archive, new SelectiveImportRequest
|
|
{
|
|
ImportSettings = false,
|
|
ImportAccounts = false,
|
|
ImportPlaylists = true,
|
|
ImportIntelligence = false,
|
|
ImportExtensions = false
|
|
}, Guid.Empty, "missing-dependency"));
|
|
|
|
Assert.Contains("requires 'Settings'", ex.Message);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ImportSelective_RoundTripsSettingsAndAccountsIntoEmptyTarget()
|
|
{
|
|
var (sourceFactory, sourceOptions, sourceState) = await CreateSeededContextAsync();
|
|
var service = new SelectiveStateTransferService(sourceFactory, sourceOptions, sourceState);
|
|
|
|
var exportDir = Path.Combine(_root, "export-settings-accounts");
|
|
var (artifact, exportReport) = await service.ExportAsync(
|
|
exportDir,
|
|
new SelectiveExportRequest
|
|
{
|
|
IncludeSettings = true,
|
|
IncludeAccounts = true,
|
|
IncludePlaylists = false,
|
|
IncludeIntelligence = false,
|
|
IncludeExtensions = true
|
|
});
|
|
|
|
Assert.Equal(3, exportReport.IncludedCategories.Count);
|
|
Assert.Contains("Settings", exportReport.IncludedCategories);
|
|
Assert.Contains("Accounts", exportReport.IncludedCategories);
|
|
Assert.Contains("Extensions", exportReport.IncludedCategories);
|
|
|
|
var (targetFactory, targetOptions, targetState) = await CreateEmptyContextAsync();
|
|
var targetService = new SelectiveStateTransferService(targetFactory, targetOptions, targetState);
|
|
|
|
await using var archive = File.OpenRead(artifact.Path);
|
|
var report = await targetService.ImportAsync(archive, new SelectiveImportRequest
|
|
{
|
|
ImportSettings = true,
|
|
ImportAccounts = true,
|
|
ImportPlaylists = false,
|
|
ImportIntelligence = false,
|
|
ImportExtensions = true
|
|
}, Guid.Empty, "round-trip");
|
|
|
|
Assert.Equal(3, report.IncludedCategories.Count);
|
|
Assert.Equal(1, report.RowsByEntry.GetValueOrDefault("tenants"));
|
|
Assert.Equal(1, report.RowsByEntry.GetValueOrDefault("users"));
|
|
Assert.Equal(1, report.RowsByEntry.GetValueOrDefault("provider-accounts"));
|
|
Assert.False(report.RowsByEntry.ContainsKey("library-tracks"));
|
|
|
|
await using var verify = await targetFactory.CreateDbContextAsync();
|
|
Assert.Equal(1, await verify.Tenants.CountAsync());
|
|
Assert.Equal(1, await verify.Users.CountAsync());
|
|
Assert.Equal(1, await verify.ProviderAccounts.CountAsync());
|
|
Assert.Equal("\"HiResLossless\"", (await verify.TenantRuntimeSettings.SingleAsync()).ValueJson);
|
|
Assert.Equal("spotiflac-selective", (await verify.ExtensionPackages.SingleAsync()).ExtensionId);
|
|
Assert.Equal("spotiflac-selective", (await verify.ExtensionLogs.SingleAsync()).ExtensionId);
|
|
Assert.Contains(await verify.AuditEvents.ToListAsync(), item =>
|
|
item.Action == "selective-import.apply" &&
|
|
item.CorrelationId == "round-trip");
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ImportSelective_InvalidForeignKeyRollsBackEveryRowAndAudit()
|
|
{
|
|
var (sourceFactory, sourceOptions, sourceState) = await CreateSeededContextAsync();
|
|
var sourceService = new SelectiveStateTransferService(sourceFactory, sourceOptions, sourceState);
|
|
var (artifact, _) = await sourceService.ExportAsync(
|
|
Path.Combine(_root, "invalid-foreign-key"),
|
|
new SelectiveExportRequest
|
|
{
|
|
IncludeSettings = true,
|
|
IncludeAccounts = true,
|
|
IncludePlaylists = false,
|
|
IncludeIntelligence = false,
|
|
IncludeExtensions = false
|
|
});
|
|
await RewriteEntryAsync(artifact.Path, "provider-accounts.json", rows =>
|
|
{
|
|
rows[0]!["ownerUserId"] = Guid.NewGuid().ToString();
|
|
});
|
|
|
|
var (targetFactory, targetOptions, targetState) = await CreateEmptyContextAsync();
|
|
var targetService = new SelectiveStateTransferService(targetFactory, targetOptions, targetState);
|
|
await using var archive = File.OpenRead(artifact.Path);
|
|
var ex = await Assert.ThrowsAsync<SelectiveTransferConflictException>(() =>
|
|
targetService.ImportAsync(
|
|
archive,
|
|
new SelectiveImportRequest
|
|
{
|
|
ImportSettings = true,
|
|
ImportAccounts = true,
|
|
ImportPlaylists = false,
|
|
ImportIntelligence = false,
|
|
ImportExtensions = false
|
|
},
|
|
Guid.Empty,
|
|
"must-roll-back"));
|
|
|
|
Assert.Contains("database key or dependency", ex.Message);
|
|
await using var verify = await targetFactory.CreateDbContextAsync();
|
|
Assert.Empty(await verify.Tenants.ToListAsync());
|
|
Assert.Empty(await verify.Users.ToListAsync());
|
|
Assert.Empty(await verify.ProviderAccounts.ToListAsync());
|
|
Assert.Empty(await verify.AuditEvents.ToListAsync());
|
|
}
|
|
|
|
[Fact]
|
|
public async Task PreviewSelective_RejectsTamperedEntryChecksum()
|
|
{
|
|
var (factory, options, state) = await CreateSeededContextAsync();
|
|
var service = new SelectiveStateTransferService(factory, options, state);
|
|
var (artifact, _) = await service.ExportAsync(
|
|
Path.Combine(_root, "tampered"),
|
|
new SelectiveExportRequest
|
|
{
|
|
IncludeSettings = true,
|
|
IncludeAccounts = false,
|
|
IncludePlaylists = false,
|
|
IncludeIntelligence = false,
|
|
IncludeExtensions = false
|
|
});
|
|
|
|
using (var archive = ZipFile.Open(artifact.Path, ZipArchiveMode.Update))
|
|
{
|
|
var entry = archive.GetEntry("tenants.json")!;
|
|
entry.Delete();
|
|
await using var stream = archive.CreateEntry("tenants.json").Open();
|
|
await JsonSerializer.SerializeAsync(stream, Array.Empty<object>());
|
|
}
|
|
|
|
await using var upload = File.OpenRead(artifact.Path);
|
|
var ex = await Assert.ThrowsAsync<SelectiveTransferValidationException>(() =>
|
|
service.PreviewAsync(
|
|
upload,
|
|
new SelectiveImportRequest
|
|
{
|
|
ImportSettings = true,
|
|
ImportAccounts = false,
|
|
ImportPlaylists = false,
|
|
ImportIntelligence = false,
|
|
ImportExtensions = false
|
|
}));
|
|
Assert.Contains("checksum or bounds", ex.Message);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task PreviewSelective_ConflictModeReportsNonEmptyTarget()
|
|
{
|
|
var (factory, options, state) = await CreateSeededContextAsync();
|
|
var service = new SelectiveStateTransferService(factory, options, state);
|
|
var (artifact, _) = await service.ExportAsync(
|
|
Path.Combine(_root, "conflict-preview"),
|
|
new SelectiveExportRequest
|
|
{
|
|
IncludeSettings = true,
|
|
IncludeAccounts = false,
|
|
IncludePlaylists = false,
|
|
IncludeIntelligence = false,
|
|
IncludeExtensions = false
|
|
});
|
|
|
|
await using var archive = File.OpenRead(artifact.Path);
|
|
var preview = await service.PreviewAsync(
|
|
archive,
|
|
new SelectiveImportRequest
|
|
{
|
|
Mode = SelectiveImportMode.Conflict,
|
|
ImportSettings = true,
|
|
ImportAccounts = false,
|
|
ImportPlaylists = false,
|
|
ImportIntelligence = false,
|
|
ImportExtensions = false
|
|
});
|
|
|
|
Assert.False(preview.CanImport);
|
|
Assert.Contains(preview.Conflicts, conflict => conflict.Contains("empty target"));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ImportSelective_MergeAddsRowsWithoutReplacingDependencies()
|
|
{
|
|
var (sourceFactory, sourceOptions, sourceState) = await CreateSeededContextAsync();
|
|
await using var sourceContext = await sourceFactory.CreateDbContextAsync();
|
|
var sourceTenant = await sourceContext.Tenants.AsNoTracking().SingleAsync();
|
|
var sourceService = new SelectiveStateTransferService(sourceFactory, sourceOptions, sourceState);
|
|
var (artifact, _) = await sourceService.ExportAsync(
|
|
Path.Combine(_root, "merge"),
|
|
new SelectiveExportRequest
|
|
{
|
|
IncludeSettings = false,
|
|
IncludeAccounts = true,
|
|
IncludePlaylists = false,
|
|
IncludeIntelligence = false,
|
|
IncludeExtensions = false
|
|
});
|
|
|
|
var (targetFactory, targetOptions, targetState) = await CreateEmptyContextAsync();
|
|
await using (var targetContext = await targetFactory.CreateDbContextAsync())
|
|
{
|
|
targetContext.Tenants.Add(new TenantRecord
|
|
{
|
|
Id = sourceTenant.Id,
|
|
Slug = sourceTenant.Slug,
|
|
Name = "Existing target tenant",
|
|
CreatedAt = sourceTenant.CreatedAt
|
|
});
|
|
await targetContext.SaveChangesAsync();
|
|
}
|
|
var targetService = new SelectiveStateTransferService(targetFactory, targetOptions, targetState);
|
|
await using var archive = File.OpenRead(artifact.Path);
|
|
await targetService.ImportAsync(
|
|
archive,
|
|
new SelectiveImportRequest
|
|
{
|
|
Mode = SelectiveImportMode.Merge,
|
|
ImportSettings = false,
|
|
ImportAccounts = true,
|
|
ImportPlaylists = false,
|
|
ImportIntelligence = false,
|
|
ImportExtensions = false
|
|
},
|
|
Guid.Empty,
|
|
"merge");
|
|
|
|
await using var verify = await targetFactory.CreateDbContextAsync();
|
|
Assert.Equal("Existing target tenant", (await verify.Tenants.SingleAsync()).Name);
|
|
Assert.Single(await verify.Users.ToListAsync());
|
|
Assert.Single(await verify.ProviderAccounts.ToListAsync());
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ImportSelective_ReplaceReplacesEveryRowInSelectedCategory()
|
|
{
|
|
var (sourceFactory, sourceOptions, sourceState) = await CreateSeededContextAsync();
|
|
await using var sourceContext = await sourceFactory.CreateDbContextAsync();
|
|
var sourceTenantId = await sourceContext.Tenants.Select(item => item.Id).SingleAsync();
|
|
var sourceService = new SelectiveStateTransferService(sourceFactory, sourceOptions, sourceState);
|
|
var (artifact, _) = await sourceService.ExportAsync(
|
|
Path.Combine(_root, "replace"),
|
|
new SelectiveExportRequest
|
|
{
|
|
IncludeSettings = true,
|
|
IncludeAccounts = false,
|
|
IncludePlaylists = false,
|
|
IncludeIntelligence = false,
|
|
IncludeExtensions = false
|
|
});
|
|
|
|
var (targetFactory, targetOptions, targetState) = await CreateEmptyContextAsync();
|
|
var oldTenantId = Guid.NewGuid();
|
|
await using (var targetContext = await targetFactory.CreateDbContextAsync())
|
|
{
|
|
targetContext.Tenants.Add(new TenantRecord
|
|
{
|
|
Id = oldTenantId,
|
|
Slug = $"old-{oldTenantId:N}",
|
|
Name = "Old tenant",
|
|
CreatedAt = DateTimeOffset.UtcNow
|
|
});
|
|
await targetContext.SaveChangesAsync();
|
|
}
|
|
var targetService = new SelectiveStateTransferService(targetFactory, targetOptions, targetState);
|
|
await using var archive = File.OpenRead(artifact.Path);
|
|
await targetService.ImportAsync(
|
|
archive,
|
|
new SelectiveImportRequest
|
|
{
|
|
Mode = SelectiveImportMode.Replace,
|
|
ImportSettings = true,
|
|
ImportAccounts = false,
|
|
ImportPlaylists = false,
|
|
ImportIntelligence = false,
|
|
ImportExtensions = false
|
|
},
|
|
Guid.Empty,
|
|
"replace");
|
|
|
|
await using var verify = await targetFactory.CreateDbContextAsync();
|
|
Assert.False(await verify.Tenants.AnyAsync(item => item.Id == oldTenantId));
|
|
Assert.True(await verify.Tenants.AnyAsync(item => item.Id == sourceTenantId));
|
|
Assert.Contains(await verify.AuditEvents.ToListAsync(), item =>
|
|
item.CorrelationId == "replace");
|
|
}
|
|
|
|
[Fact]
|
|
public async Task IntelligenceRoundTripPreservesScopeExpiresUploadsAndRejectsConflict()
|
|
{
|
|
const string temporaryUploadCanary = "private-history-upload-must-not-transfer";
|
|
const string remoteTokenCanary = "remote-history-token-must-not-transfer";
|
|
var (sourceFactory, sourceOptions, sourceState) = await CreateSeededContextAsync();
|
|
Guid tenantId;
|
|
Guid userId;
|
|
await using (var source = await sourceFactory.CreateDbContextAsync())
|
|
{
|
|
tenantId = await source.Tenants.Select(item => item.Id).SingleAsync();
|
|
userId = await source.Users.Select(item => item.Id).SingleAsync();
|
|
var now = DateTimeOffset.UtcNow;
|
|
source.ListeningEvents.Add(new ListeningEventRecord
|
|
{
|
|
Id = Guid.CreateVersion7(),
|
|
TenantId = tenantId,
|
|
OwnerUserId = userId,
|
|
Protocol = "jellyfin",
|
|
BackendInstanceId = "inst",
|
|
LibraryScopeId = "music",
|
|
OccurrenceKey = new string('e', 64),
|
|
State = ListeningEventState.Completed,
|
|
StartedAt = now,
|
|
ListenedAt = now,
|
|
UpdatedAt = now,
|
|
SourceKind = "history-import",
|
|
TrackReference = "spotify:track:fixture"
|
|
});
|
|
source.ListeningHistoryImports.Add(new ListeningHistoryImportRecord
|
|
{
|
|
Id = Guid.CreateVersion7(),
|
|
TenantId = tenantId,
|
|
OwnerUserId = userId,
|
|
Protocol = "jellyfin",
|
|
BackendInstanceId = "inst",
|
|
LibraryScopeId = "music",
|
|
DisplayFileName = "StreamingHistory_music_0.json",
|
|
Format = "spotify-extended-streaming-history",
|
|
ContentSha256 = new string('a', 64),
|
|
SizeBytes = 1234,
|
|
PreviewJson = "{}",
|
|
PreviewRevision = new string('b', 64),
|
|
State = ListeningHistoryImportState.Running,
|
|
CreatedAt = now,
|
|
UpdatedAt = now,
|
|
ExpiresAt = now.AddHours(24),
|
|
Revision = 1
|
|
});
|
|
await source.SaveChangesAsync();
|
|
}
|
|
await File.WriteAllTextAsync(
|
|
Path.Combine(_root, "history-import.upload"),
|
|
$"{temporaryUploadCanary}|{remoteTokenCanary}");
|
|
|
|
var sourceService = new SelectiveStateTransferService(sourceFactory, sourceOptions, sourceState);
|
|
var exportRequest = new SelectiveExportRequest
|
|
{
|
|
IncludeSettings = true,
|
|
IncludeAccounts = true,
|
|
IncludePlaylists = false,
|
|
IncludeIntelligence = true,
|
|
IncludeExtensions = false
|
|
};
|
|
var importRequest = new SelectiveImportRequest
|
|
{
|
|
Mode = SelectiveImportMode.Conflict,
|
|
ImportSettings = true,
|
|
ImportAccounts = true,
|
|
ImportPlaylists = false,
|
|
ImportIntelligence = true,
|
|
ImportExtensions = false
|
|
};
|
|
var (artifact, report) = await sourceService.ExportAsync(
|
|
Path.Combine(_root, "intelligence-round-trip"),
|
|
exportRequest);
|
|
Assert.Equal(1, report.RowsByEntry["listening-events"]);
|
|
Assert.Equal(1, report.RowsByEntry["listening-history-imports"]);
|
|
using (var archive = ZipFile.OpenRead(artifact.Path))
|
|
{
|
|
var manifest = await ReadManifestAsync(archive);
|
|
Assert.Equal(SelectiveStateTransferService.CurrentFormatVersion.ToString(), manifest["formatVersion"]);
|
|
Assert.Equal(artifact.SchemaVersion, manifest["schemaVersion"]);
|
|
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 invalidPath = Path.Combine(_root, "invalid-history-scope.zip");
|
|
File.Copy(artifact.Path, invalidPath);
|
|
await RewriteEntryAsync(invalidPath, "listening-events.json", rows =>
|
|
rows[0]!["ownerUserId"] = Guid.CreateVersion7().ToString());
|
|
var (targetFactory, targetOptions, targetState) = await CreateEmptyContextAsync();
|
|
var targetService = new SelectiveStateTransferService(targetFactory, targetOptions, targetState);
|
|
await using (var invalidArchive = File.OpenRead(invalidPath))
|
|
{
|
|
var invalidPreview = await targetService.PreviewAsync(invalidArchive, importRequest);
|
|
Assert.False(invalidPreview.CanImport);
|
|
Assert.Contains(invalidPreview.Conflicts, item =>
|
|
item.Contains("database key or dependency", StringComparison.OrdinalIgnoreCase));
|
|
}
|
|
|
|
await using (var archive = File.OpenRead(artifact.Path))
|
|
{
|
|
await targetService.ImportAsync(archive, importRequest, userId, "history-round-trip");
|
|
}
|
|
var restarted = new SelectiveStateTransferService(targetFactory, targetOptions, targetState);
|
|
await using (var restored = await targetFactory.CreateDbContextAsync())
|
|
{
|
|
var occurrence = await restored.ListeningEvents.SingleAsync();
|
|
var historyImport = await restored.ListeningHistoryImports.SingleAsync();
|
|
Assert.Equal((tenantId, userId, "jellyfin", "inst", "music"),
|
|
(occurrence.TenantId, occurrence.OwnerUserId, occurrence.Protocol,
|
|
occurrence.BackendInstanceId, occurrence.LibraryScopeId));
|
|
Assert.Equal((tenantId, userId, "jellyfin", "inst", "music"),
|
|
(historyImport.TenantId, historyImport.OwnerUserId, historyImport.Protocol,
|
|
historyImport.BackendInstanceId, historyImport.LibraryScopeId));
|
|
Assert.Equal(ListeningHistoryImportState.Expired, historyImport.State);
|
|
Assert.Null(historyImport.JobId);
|
|
}
|
|
await using (var archive = File.OpenRead(artifact.Path))
|
|
{
|
|
var conflict = await restarted.PreviewAsync(archive, importRequest);
|
|
Assert.False(conflict.CanImport);
|
|
Assert.Contains(conflict.Conflicts, item => item.Contains("empty target", StringComparison.Ordinal));
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ExportSelective_EmptySelectionIsRejected()
|
|
{
|
|
var (factory, options, state) = await CreateSeededContextAsync();
|
|
var service = new SelectiveStateTransferService(factory, options, state);
|
|
|
|
var exportDir = Path.Combine(_root, "export-empty");
|
|
await Assert.ThrowsAsync<SelectiveTransferValidationException>(() =>
|
|
service.ExportAsync(
|
|
exportDir,
|
|
new SelectiveExportRequest
|
|
{
|
|
IncludeSettings = false,
|
|
IncludeAccounts = false,
|
|
IncludePlaylists = false,
|
|
IncludeIntelligence = false,
|
|
IncludeExtensions = false
|
|
}));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ImportSelective_RejectsEmptyArchive()
|
|
{
|
|
var (factory, options, state) = await CreateSeededContextAsync();
|
|
var service = new SelectiveStateTransferService(factory, options, state);
|
|
|
|
await using var archive = new MemoryStream();
|
|
await Assert.ThrowsAsync<SelectiveTransferValidationException>(() =>
|
|
service.ImportAsync(archive, new SelectiveImportRequest(), Guid.Empty, "empty"));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ImportSelective_RejectsWrongFormatVersion()
|
|
{
|
|
var (factory, options, state) = await CreateSeededContextAsync();
|
|
var service = new SelectiveStateTransferService(factory, options, state);
|
|
|
|
var fakeArchivePath = Path.Combine(_root, "wrong-version.zip");
|
|
using (var archive = ZipFile.Open(fakeArchivePath, ZipArchiveMode.Create))
|
|
{
|
|
var data = archive.CreateEntry("tenants.json");
|
|
await using (var dataStream = data.Open())
|
|
{
|
|
await JsonSerializer.SerializeAsync(dataStream, Array.Empty<object>());
|
|
}
|
|
var manifest = archive.CreateEntry("manifest.json");
|
|
await using var stream = manifest.Open();
|
|
await JsonSerializer.SerializeAsync(stream, new[]
|
|
{
|
|
new
|
|
{
|
|
formatVersion = 1,
|
|
isFullExport = false,
|
|
sourceProvider = "Postgres",
|
|
schemaVersion = "test",
|
|
applicationVersion = "test",
|
|
createdAt = DateTimeOffset.UtcNow,
|
|
secretKeyMaterialIncluded = false,
|
|
includedCategories = new[] { "Settings" }
|
|
}
|
|
});
|
|
}
|
|
await using var archiveStream = File.OpenRead(fakeArchivePath);
|
|
await Assert.ThrowsAsync<SelectiveTransferSchemaMismatchException>(() =>
|
|
service.ImportAsync(archiveStream, new SelectiveImportRequest
|
|
{
|
|
ImportSettings = true,
|
|
ImportAccounts = false,
|
|
ImportPlaylists = false,
|
|
ImportIntelligence = false,
|
|
ImportExtensions = false
|
|
}, Guid.Empty, "wrong-version"));
|
|
}
|
|
|
|
[Fact]
|
|
public void ResolveIncludedCategories_RespectsEachFlag()
|
|
{
|
|
var service = new SelectiveStateTransferService(
|
|
new TestDbContextFactory(new DbContextOptionsBuilder<AllstarrDbContext>().Options),
|
|
new DurableStorageOptions { Provider = "Postgres", ConnectionString = "Host=database;Database=allstarr" },
|
|
new DurableStorageState(new DurableStorageOptions { Provider = "Postgres", ConnectionString = "Host=database;Database=allstarr" }));
|
|
|
|
var included = service.ResolveIncludedCategories(new SelectiveExportRequest
|
|
{
|
|
IncludeSettings = true,
|
|
IncludeAccounts = false,
|
|
IncludePlaylists = true,
|
|
IncludeIntelligence = false,
|
|
IncludeExtensions = true
|
|
});
|
|
|
|
Assert.Equal(new[] { "Settings", "Playlists", "Extensions" }, included.Select(c => c.ToString()));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task PreviewSelective_RejectsUnknownImportModeBeforeReadingUpload()
|
|
{
|
|
var service = new SelectiveStateTransferService(
|
|
new TestDbContextFactory(new DbContextOptionsBuilder<AllstarrDbContext>().Options),
|
|
new DurableStorageOptions { Provider = "Postgres", ConnectionString = "Host=database;Database=allstarr" },
|
|
new DurableStorageState(new DurableStorageOptions { Provider = "Postgres", ConnectionString = "Host=database;Database=allstarr" }));
|
|
|
|
await Assert.ThrowsAsync<SelectiveTransferValidationException>(() =>
|
|
service.PreviewAsync(
|
|
Stream.Null,
|
|
new SelectiveImportRequest { Mode = (SelectiveImportMode)999 }));
|
|
}
|
|
|
|
private static async Task<Dictionary<string, object>> ReadManifestAsync(ZipArchive archive)
|
|
{
|
|
var entry = archive.Entries.First(e => e.FullName == "manifest.json");
|
|
await using var stream = entry.Open();
|
|
using var document = await JsonDocument.ParseAsync(stream);
|
|
var root = document.RootElement[0];
|
|
var result = new Dictionary<string, object>();
|
|
foreach (var prop in root.EnumerateObject())
|
|
{
|
|
result[prop.Name] = prop.Value.ValueKind switch
|
|
{
|
|
JsonValueKind.Array => prop.Value.EnumerateArray()
|
|
.Select(e => e.ValueKind == JsonValueKind.String ? e.GetString()! : e.GetRawText())
|
|
.ToArray(),
|
|
JsonValueKind.True => true,
|
|
JsonValueKind.False => false,
|
|
_ => prop.Value.ToString()
|
|
};
|
|
}
|
|
return result;
|
|
}
|
|
|
|
private static async Task RewriteEntryAsync(
|
|
string archivePath,
|
|
string entryName,
|
|
Action<JsonArray> mutate)
|
|
{
|
|
using (var archive = ZipFile.Open(archivePath, ZipArchiveMode.Update))
|
|
{
|
|
var entry = archive.GetEntry(entryName)!;
|
|
JsonArray rows;
|
|
await using (var stream = entry.Open())
|
|
{
|
|
rows = (await JsonNode.ParseAsync(stream))!.AsArray();
|
|
}
|
|
mutate(rows);
|
|
entry.Delete();
|
|
await using var replacement = archive.CreateEntry(entryName, CompressionLevel.Optimal).Open();
|
|
await JsonSerializer.SerializeAsync(replacement, rows);
|
|
}
|
|
|
|
using (var archive = ZipFile.Open(archivePath, ZipArchiveMode.Update))
|
|
{
|
|
var manifestEntry = archive.GetEntry("manifest.json")!;
|
|
JsonArray manifest;
|
|
await using (var stream = manifestEntry.Open())
|
|
{
|
|
manifest = (await JsonNode.ParseAsync(stream))!.AsArray();
|
|
}
|
|
var entry = archive.GetEntry(entryName)!;
|
|
var descriptor = manifest[0]!["entries"]!.AsArray()
|
|
.Single(item => item!["name"]!.GetValue<string>() == entryName)!;
|
|
descriptor["expandedBytes"] = entry.Length;
|
|
descriptor["compressedBytes"] = entry.CompressedLength;
|
|
await using (var stream = entry.Open())
|
|
{
|
|
descriptor["sha256"] = Convert.ToHexString(
|
|
await SHA256.HashDataAsync(stream)).ToLowerInvariant();
|
|
}
|
|
manifestEntry.Delete();
|
|
await using var replacement = archive.CreateEntry("manifest.json", CompressionLevel.Optimal).Open();
|
|
await JsonSerializer.SerializeAsync(replacement, manifest);
|
|
}
|
|
}
|
|
|
|
private async Task<(TestDbContextFactory Factory, DurableStorageOptions Options, DurableStorageState State)>
|
|
CreateSeededContextAsync()
|
|
{
|
|
var database = await PostgresTestDatabase.CreateAsync();
|
|
_databases.Add(database);
|
|
var options = new DurableStorageOptions
|
|
{
|
|
Provider = "Postgres",
|
|
ConnectionString = database.ConnectionString,
|
|
BackupDirectory = Path.Combine(_root, "backups"),
|
|
AutoMigrate = true
|
|
};
|
|
var factory = new TestDbContextFactory(database.Options);
|
|
await using var context = await factory.CreateDbContextAsync();
|
|
|
|
var tenantId = Guid.NewGuid();
|
|
var userId = Guid.NewGuid();
|
|
var backendId = Guid.NewGuid();
|
|
var accountId = Guid.NewGuid();
|
|
var now = DateTimeOffset.UtcNow;
|
|
|
|
context.Tenants.Add(new TenantRecord
|
|
{
|
|
Id = tenantId,
|
|
Slug = $"tenant-{Guid.NewGuid():N}",
|
|
Name = "Selective tenant",
|
|
CreatedAt = now
|
|
});
|
|
context.Users.Add(new PlatformUserRecord
|
|
{
|
|
Id = userId,
|
|
TenantId = tenantId,
|
|
DisplayName = "selective",
|
|
Status = PlatformUserStatus.Active,
|
|
CreatedAt = now,
|
|
UpdatedAt = now
|
|
});
|
|
context.BackendIdentities.Add(new BackendIdentityRecord
|
|
{
|
|
Id = backendId,
|
|
TenantId = tenantId,
|
|
UserId = userId,
|
|
BackendType = "jellyfin",
|
|
BackendInstanceId = "inst",
|
|
PrincipalId = "princ",
|
|
CreatedAt = now,
|
|
LastSeenAt = now
|
|
});
|
|
context.ProviderAccounts.Add(new ProviderAccountRecord
|
|
{
|
|
Id = accountId,
|
|
TenantId = tenantId,
|
|
OwnerUserId = userId,
|
|
ProviderId = "spotify",
|
|
DisplayName = "Spotify selective",
|
|
Scope = ProviderAccountScope.User,
|
|
Enabled = 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",
|
|
CreatedAt = now,
|
|
UpdatedAt = now,
|
|
Revision = 1
|
|
});
|
|
var packageId = Guid.CreateVersion7();
|
|
context.ExtensionPackages.Add(new ExtensionPackageRecord
|
|
{
|
|
Id = packageId,
|
|
ExtensionId = "spotiflac-selective",
|
|
DisplayName = "Selective extension",
|
|
Version = "1.0.0",
|
|
SdkVersion = "1",
|
|
Sha256 = new string('e', 64),
|
|
ContentSha256 = new string('f', 64),
|
|
PackagePath = "/extensions/spotiflac-selective",
|
|
ManifestJson = """{"id":"spotiflac-selective","compatibility":"spotiflac-v1"}""",
|
|
State = ExtensionPackageState.Active,
|
|
StagedAt = now,
|
|
ActivatedAt = now,
|
|
Revision = 1
|
|
});
|
|
context.ExtensionLogs.Add(new ExtensionLogRecord
|
|
{
|
|
Id = Guid.CreateVersion7(),
|
|
ExtensionPackageId = packageId,
|
|
ExtensionId = "spotiflac-selective",
|
|
Level = "Info",
|
|
EventCode = "selective",
|
|
Message = "Selective fixture",
|
|
CorrelationId = "selective-extension",
|
|
CreatedAt = now
|
|
});
|
|
await context.SaveChangesAsync();
|
|
|
|
var state = new DurableStorageState(options);
|
|
state.Set(DurableStorageReadiness.Ready, "test");
|
|
|
|
return (factory, options, state);
|
|
}
|
|
|
|
private async Task<(TestDbContextFactory Factory, DurableStorageOptions Options, DurableStorageState State)>
|
|
CreateEmptyContextAsync()
|
|
{
|
|
var database = await PostgresTestDatabase.CreateAsync();
|
|
_databases.Add(database);
|
|
var options = new DurableStorageOptions
|
|
{
|
|
Provider = "Postgres",
|
|
ConnectionString = database.ConnectionString,
|
|
BackupDirectory = Path.Combine(_root, "backups"),
|
|
AutoMigrate = true
|
|
};
|
|
var factory = new TestDbContextFactory(database.Options);
|
|
await using var context = await factory.CreateDbContextAsync();
|
|
|
|
var state = new DurableStorageState(options);
|
|
state.Set(DurableStorageReadiness.Ready, "test");
|
|
|
|
return (factory, options, state);
|
|
}
|
|
|
|
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));
|
|
}
|
|
}
|