mirror of
https://github.com/SoPat712/allstarr.git
synced 2026-10-08 06:05:02 -04:00
350 lines
15 KiB
C#
350 lines
15 KiB
C#
using allstarr.Core.Downloads;
|
|
using allstarr.Core.Favorites;
|
|
using allstarr.Core.ManagedFiles;
|
|
using allstarr.Core.Storage;
|
|
using Microsoft.EntityFrameworkCore;
|
|
using Microsoft.EntityFrameworkCore.Infrastructure;
|
|
using Microsoft.EntityFrameworkCore.Migrations;
|
|
using Npgsql;
|
|
|
|
namespace allstarr.Tests;
|
|
|
|
public sealed class DatabaseLineageConstraintTests : IAsyncLifetime
|
|
{
|
|
private PostgresTestDatabase _database = null!;
|
|
private TestDbContextFactory _factory = null!;
|
|
private Guid _tenantA;
|
|
private Guid _tenantB;
|
|
private Guid _userA;
|
|
private Guid _userB;
|
|
private Guid _jobA;
|
|
private Guid _jobB;
|
|
private Guid _fileA;
|
|
private Guid _fileB;
|
|
private Guid _accountB;
|
|
|
|
public async Task InitializeAsync()
|
|
{
|
|
_database = await PostgresTestDatabase.CreateAsync();
|
|
_factory = new TestDbContextFactory(_database.Options);
|
|
await using var db = await _factory.CreateDbContextAsync();
|
|
|
|
_tenantA = Guid.CreateVersion7();
|
|
_tenantB = Guid.CreateVersion7();
|
|
_userA = Guid.CreateVersion7();
|
|
_userB = Guid.CreateVersion7();
|
|
_jobA = Guid.CreateVersion7();
|
|
_jobB = Guid.CreateVersion7();
|
|
_fileA = Guid.CreateVersion7();
|
|
_fileB = Guid.CreateVersion7();
|
|
_accountB = Guid.CreateVersion7();
|
|
var now = DateTimeOffset.UtcNow;
|
|
|
|
db.Tenants.AddRange(
|
|
new TenantRecord { Id = _tenantA, Slug = "lineage-a", Name = "Lineage A", CreatedAt = now },
|
|
new TenantRecord { Id = _tenantB, Slug = "lineage-b", Name = "Lineage B", CreatedAt = now });
|
|
db.Users.AddRange(
|
|
User(_userA, _tenantA, "User A", now),
|
|
User(_userB, _tenantB, "User B", now));
|
|
db.ProviderAccounts.Add(new ProviderAccountRecord
|
|
{
|
|
Id = _accountB,
|
|
TenantId = _tenantB,
|
|
OwnerUserId = _userB,
|
|
ProviderId = "lineage-provider",
|
|
DisplayName = "Tenant B account",
|
|
Scope = ProviderAccountScope.User,
|
|
Enabled = true,
|
|
CreatedAt = now,
|
|
UpdatedAt = now
|
|
});
|
|
db.Jobs.AddRange(
|
|
Job(_jobA, _tenantA, _userA, "job-a", now),
|
|
Job(_jobB, _tenantB, _userB, "job-b", now));
|
|
db.ManagedFiles.AddRange(
|
|
File(_fileA, _tenantA, _userA, _jobA, "a", now),
|
|
File(_fileB, _tenantB, _userB, _jobB, "b", now));
|
|
await db.SaveChangesAsync();
|
|
}
|
|
|
|
[Fact]
|
|
public async Task PostgresMigration_RejectsCrossScopeJobAndArtifactLineage()
|
|
{
|
|
await RejectAsync(db => db.Jobs.Add(Job(
|
|
Guid.CreateVersion7(), _tenantA, _userB, "cross-owner", DateTimeOffset.UtcNow)));
|
|
|
|
await RejectAsync(db =>
|
|
{
|
|
var job = Job(Guid.CreateVersion7(), _tenantA, _userA, "cross-account", DateTimeOffset.UtcNow);
|
|
job.ProviderAccountId = _accountB;
|
|
job.ProviderCapability = "download";
|
|
db.Jobs.Add(job);
|
|
});
|
|
|
|
await RejectAsync(db => db.ManagedFiles.Add(File(
|
|
Guid.CreateVersion7(), _tenantB, _userB, _jobA, "cross-job", DateTimeOffset.UtcNow)));
|
|
|
|
await RejectAsync(db => db.FavoriteEvents.Add(new FavoriteEventRecord
|
|
{
|
|
Id = Guid.CreateVersion7(),
|
|
TenantId = _tenantB,
|
|
OwnerUserId = _userB,
|
|
Protocol = "subsonic",
|
|
BackendInstanceId = "primary",
|
|
BackendPrincipalId = "user-b",
|
|
ItemId = "track",
|
|
Operation = FavoriteOperation.Favorite,
|
|
SourceRevision = "1",
|
|
EventKey = Convert.ToHexString(Guid.NewGuid().ToByteArray()).ToLowerInvariant(),
|
|
CorrelationId = "lineage-test",
|
|
PolicySnapshotJson = "{}",
|
|
JobId = _jobA,
|
|
State = FavoriteEventState.Pending,
|
|
CreatedAt = DateTimeOffset.UtcNow,
|
|
UpdatedAt = DateTimeOffset.UtcNow
|
|
}));
|
|
|
|
await RejectAsync(db => db.ProviderDownloadWorkspaces.Add(new ProviderDownloadWorkspaceEntity
|
|
{
|
|
Id = Guid.CreateVersion7(),
|
|
WorkspaceId = Guid.NewGuid().ToString("N"),
|
|
TenantId = _tenantB,
|
|
OwnerUserId = _userB,
|
|
DurableJobId = _jobA,
|
|
ProviderId = "lineage-provider",
|
|
IdempotencyKey = Guid.NewGuid().ToString("N"),
|
|
CreatedAt = DateTimeOffset.UtcNow
|
|
}));
|
|
|
|
await RejectAsync(db => db.MetadataEnrichmentPlans.Add(Plan(
|
|
_tenantB, _userB, _jobA, _fileB, "1")));
|
|
await RejectAsync(db => db.MetadataEnrichmentPlans.Add(Plan(
|
|
_tenantB, _userB, _jobB, _fileA, "2")));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task PostgresMigration_EnforcesReferenceDmlAndDerivesReferenceCount()
|
|
{
|
|
var first = Guid.CreateVersion7();
|
|
var second = Guid.CreateVersion7();
|
|
await using (var db = await _factory.CreateDbContextAsync())
|
|
{
|
|
var now = DateTimeOffset.UtcNow.UtcTicks;
|
|
var scope = $"{_tenantA:N}:{_userA:N}";
|
|
await db.Database.ExecuteSqlInterpolatedAsync($"""
|
|
INSERT INTO managed_file_references
|
|
("Id", "ManagedFileId", "TenantId", "OwnerUserId", "ScopeKey", "ReferenceKey", "CreatedAt", "ReleasedAt", "Revision")
|
|
VALUES ({first}, {_fileA}, {_tenantA}, {_userA}, {scope}, {"direct:first"}, {now}, NULL, {1})
|
|
""");
|
|
await db.Database.ExecuteSqlInterpolatedAsync($"""
|
|
INSERT INTO managed_file_references
|
|
("Id", "ManagedFileId", "TenantId", "OwnerUserId", "ScopeKey", "ReferenceKey", "CreatedAt", "ReleasedAt", "Revision")
|
|
VALUES ({second}, {_fileA}, {_tenantA}, {_userA}, {scope}, {"direct:second"}, {now}, NULL, {1})
|
|
""");
|
|
Assert.Equal(2, await db.ManagedFiles.Where(item => item.Id == _fileA)
|
|
.Select(item => item.ReferenceCount).SingleAsync());
|
|
|
|
await Assert.ThrowsAsync<PostgresException>(() => db.Database.ExecuteSqlInterpolatedAsync($"""
|
|
UPDATE managed_files SET "ReferenceCount"={9} WHERE "Id"={_fileA}
|
|
"""));
|
|
|
|
await db.Database.ExecuteSqlInterpolatedAsync($"""
|
|
UPDATE managed_file_references SET "ReleasedAt"={DateTimeOffset.UtcNow.UtcTicks}, "Revision"="Revision"+1 WHERE "Id"={first}
|
|
""");
|
|
Assert.Equal(1, await db.ManagedFiles.Where(item => item.Id == _fileA)
|
|
.Select(item => item.ReferenceCount).SingleAsync());
|
|
}
|
|
|
|
await using var crossed = await _factory.CreateDbContextAsync();
|
|
await Assert.ThrowsAsync<PostgresException>(() => crossed.Database.ExecuteSqlInterpolatedAsync($"""
|
|
INSERT INTO managed_file_references
|
|
("Id", "ManagedFileId", "TenantId", "OwnerUserId", "ScopeKey", "ReferenceKey", "CreatedAt", "ReleasedAt", "Revision")
|
|
VALUES ({Guid.CreateVersion7()}, {_fileA}, {_tenantB}, {_userB}, {$"{_tenantB:N}:{_userB:N}"}, {"direct:crossed"}, {DateTimeOffset.UtcNow.UtcTicks}, NULL, {1})
|
|
"""));
|
|
}
|
|
|
|
[Fact]
|
|
[Trait("Lane", "ReleaseCritical")]
|
|
public async Task PostgresMigration_PreservesEveryLegacyReferenceAcrossUpgradeAndRollback()
|
|
{
|
|
const string previous = "20260713225623_Phase2BLegacyEnvImportIdempotency";
|
|
await using var legacyDatabase = await PostgresTestDatabase.CreateAsync();
|
|
var options = legacyDatabase.Options;
|
|
var tenant = Guid.CreateVersion7();
|
|
var user = Guid.CreateVersion7();
|
|
var file = Guid.CreateVersion7();
|
|
var now = DateTimeOffset.UtcNow.UtcTicks;
|
|
|
|
await using var db = new AllstarrDbContext(options);
|
|
var migrator = db.Database.GetService<IMigrator>();
|
|
await migrator.MigrateAsync(previous);
|
|
await db.Database.ExecuteSqlInterpolatedAsync($"INSERT INTO tenants (\"Id\",\"Slug\",\"Name\",\"CreatedAt\") VALUES ({tenant},{"legacy-ref"},{"Legacy refs"},{now})");
|
|
await db.Database.ExecuteSqlInterpolatedAsync($"INSERT INTO users (\"Id\",\"TenantId\",\"DisplayName\",\"Status\",\"CreatedAt\",\"UpdatedAt\") VALUES ({user},{tenant},{"Legacy user"},{"Active"},{now},{now})");
|
|
await db.Database.ExecuteSqlInterpolatedAsync($"""
|
|
INSERT INTO managed_files
|
|
("Id","RootId","TargetRootPath","CanonicalPath","ContentSha256","Length","PlacementMethod","TenantId","OwnerUserId","LibraryScopeId","SourceJobId","ScopeKey","ReferenceCount","IsManaged","CreatedAt","RemovedAt","Revision")
|
|
VALUES ({file},{Guid.CreateVersion7()},{"/legacy"},{"/legacy/song.flac"},{new string('a', 64)},{1L},{"Copy"},{tenant},{user},{"music"},NULL,{"tenant:user:music"},{3},{true},{now},NULL,{1L})
|
|
""");
|
|
|
|
await migrator.MigrateAsync();
|
|
var originalIds = await db.ManagedFileReferences.Where(item => item.ManagedFileId == file)
|
|
.OrderBy(item => item.ReferenceKey).Select(item => item.Id).ToListAsync();
|
|
Assert.Equal(3, originalIds.Count);
|
|
Assert.Equal(3, await db.ManagedFiles.Where(item => item.Id == file).Select(item => item.ReferenceCount).SingleAsync());
|
|
}
|
|
|
|
[Fact]
|
|
public async Task PostgresMigration_RejectsArtifactManagedFileOutsideExactScope()
|
|
{
|
|
var workspaceId = Guid.CreateVersion7();
|
|
var workspaceKey = Guid.NewGuid().ToString("N");
|
|
var verifiedArtifactId = Guid.CreateVersion7();
|
|
await using var db = await _factory.CreateDbContextAsync();
|
|
db.ProviderDownloadWorkspaces.Add(new ProviderDownloadWorkspaceEntity
|
|
{
|
|
Id = workspaceId,
|
|
WorkspaceId = workspaceKey,
|
|
TenantId = _tenantA,
|
|
OwnerUserId = _userA,
|
|
LibraryScopeId = "music",
|
|
DurableJobId = _jobA,
|
|
ProviderId = "lineage-provider",
|
|
IdempotencyKey = Guid.NewGuid().ToString("N"),
|
|
CreatedAt = DateTimeOffset.UtcNow
|
|
});
|
|
db.ProviderDownloadArtifacts.Add(new ProviderDownloadArtifactEntity
|
|
{
|
|
Id = verifiedArtifactId,
|
|
WorkspaceRecordId = workspaceId,
|
|
WorkspaceId = workspaceKey,
|
|
TenantId = _tenantA,
|
|
OwnerUserId = _userA,
|
|
LibraryScopeId = "music",
|
|
DurableJobId = _jobA,
|
|
ProviderId = "lineage-provider",
|
|
ProviderArtifactId = "verified",
|
|
RelativePath = "verified.flac",
|
|
ContentSha256 = new string('c', 64),
|
|
Length = 1,
|
|
State = ProviderDownloadArtifactState.Verified,
|
|
CreatedAt = DateTimeOffset.UtcNow,
|
|
VerifiedAt = DateTimeOffset.UtcNow,
|
|
Revision = 1
|
|
});
|
|
await db.SaveChangesAsync();
|
|
|
|
var runtimeStore = new EfProviderDownloadArtifactStore(_factory);
|
|
await Assert.ThrowsAsync<UnauthorizedAccessException>(() =>
|
|
runtimeStore.MarkPlacedAsync(verifiedArtifactId, _fileB, default));
|
|
|
|
db.ProviderDownloadArtifacts.Add(new ProviderDownloadArtifactEntity
|
|
{
|
|
Id = Guid.CreateVersion7(),
|
|
WorkspaceRecordId = workspaceId,
|
|
WorkspaceId = Guid.NewGuid().ToString("N"),
|
|
TenantId = _tenantA,
|
|
OwnerUserId = _userA,
|
|
LibraryScopeId = "music",
|
|
DurableJobId = _jobA,
|
|
ProviderId = "lineage-provider",
|
|
ProviderArtifactId = "cross-file",
|
|
RelativePath = "song.flac",
|
|
ContentSha256 = new string('b', 64),
|
|
Length = 1,
|
|
State = ProviderDownloadArtifactState.Placed,
|
|
ManagedFileId = _fileB,
|
|
CreatedAt = DateTimeOffset.UtcNow,
|
|
VerifiedAt = DateTimeOffset.UtcNow,
|
|
Revision = 1
|
|
});
|
|
await Assert.ThrowsAsync<DbUpdateException>(() => db.SaveChangesAsync());
|
|
}
|
|
|
|
private async Task RejectAsync(Action<AllstarrDbContext> addInvalid)
|
|
{
|
|
await using var db = await _factory.CreateDbContextAsync();
|
|
addInvalid(db);
|
|
await Assert.ThrowsAsync<DbUpdateException>(() => db.SaveChangesAsync());
|
|
}
|
|
|
|
private static PlatformUserRecord User(Guid id, Guid tenantId, string name, DateTimeOffset now) => new()
|
|
{
|
|
Id = id,
|
|
TenantId = tenantId,
|
|
DisplayName = name,
|
|
Status = PlatformUserStatus.Active,
|
|
CreatedAt = now,
|
|
UpdatedAt = now
|
|
};
|
|
|
|
internal static DurableJobRecord Job(Guid id, Guid tenantId, Guid ownerUserId, string key, DateTimeOffset now) => new()
|
|
{
|
|
Id = id,
|
|
ScopeKey = $"{tenantId:N}:{ownerUserId:N}",
|
|
TenantId = tenantId,
|
|
OwnerUserId = ownerUserId,
|
|
PolicySnapshotJson = "{}",
|
|
RequestFingerprint = new string('a', 64),
|
|
CorrelationId = "lineage-test",
|
|
Type = "lineage.test",
|
|
PayloadJson = "{}",
|
|
IdempotencyKey = key,
|
|
State = DurableJobState.Pending,
|
|
MaxAttempts = 3,
|
|
MaxDeferrals = 3,
|
|
AvailableAt = now,
|
|
CreatedAt = now,
|
|
UpdatedAt = now
|
|
};
|
|
|
|
private static ManagedFileOwnershipEntity File(
|
|
Guid id, Guid tenantId, Guid ownerUserId, Guid jobId, string suffix, DateTimeOffset now) => new()
|
|
{
|
|
Id = id,
|
|
RootId = Guid.CreateVersion7(),
|
|
TargetRootPath = $"/library/{suffix}",
|
|
CanonicalPath = $"/library/{suffix}/{id:N}.flac",
|
|
ContentSha256 = new string(suffix[0], 64),
|
|
Length = 1,
|
|
PlacementMethod = ManagedFilePlacementMethod.Copy,
|
|
TenantId = tenantId,
|
|
OwnerUserId = ownerUserId,
|
|
SourceJobId = jobId,
|
|
ScopeKey = $"{tenantId:N}:{ownerUserId:N}",
|
|
ReferenceCount = 1,
|
|
IsManaged = true,
|
|
CreatedAt = now
|
|
};
|
|
|
|
private static MetadataEnrichmentPlanRecord Plan(
|
|
Guid tenantId, Guid ownerUserId, Guid jobId, Guid fileId, string suffix) => new()
|
|
{
|
|
Id = Guid.CreateVersion7(),
|
|
TenantId = tenantId,
|
|
OwnerUserId = ownerUserId,
|
|
LineageJobId = jobId,
|
|
ManagedArtifactId = fileId,
|
|
Fingerprint = new string(suffix[0], 64),
|
|
PlanVersion = 1,
|
|
SourceRevisionsJson = "[]",
|
|
DecisionsJson = "[]",
|
|
TagsJson = "{}",
|
|
PathValuesJson = "{}",
|
|
CreatedAt = DateTimeOffset.UtcNow
|
|
};
|
|
|
|
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));
|
|
}
|
|
|
|
public async Task DisposeAsync()
|
|
{
|
|
await _database.DisposeAsync();
|
|
}
|
|
}
|