fix: close TOCTOU in CREATE path; add anti-forgery, loose-track, and case-sensitivity tests

FindOrCreateRelease now returns (ReleaseDto, bool WasCreated); the CREATE path in UploadAsync
rejects WasCreated=false as a duplicate rather than silently attaching on a lost race.
This commit is contained in:
daniel-c-harvey
2026-06-19 15:55:08 -04:00
parent bd85507308
commit 558ff4b4c6
5 changed files with 90 additions and 27 deletions
+9 -9
View File
@@ -60,9 +60,9 @@ public class MediumWritePathTests
"Live at the Vault", "Artist A", ReleaseData("Live at the Vault", "Artist A", ReleaseMedium.Session));
Assert.That(result.Success, Is.True);
Assert.That(result.Value!.Medium, Is.EqualTo(ReleaseMedium.Session));
Assert.That(result.Value.Release.Medium, Is.EqualTo(ReleaseMedium.Session));
var stored = await CreateRepository().GetReleaseByIdAsync(result.Value.Id);
var stored = await CreateRepository().GetReleaseByIdAsync(result.Value.Release.Id);
Assert.That(stored!.Medium, Is.EqualTo(ReleaseMedium.Session));
}
@@ -75,7 +75,7 @@ public class MediumWritePathTests
var result = await manager.FindOrCreateRelease(
"Sunset Set", "DJ B", ReleaseData("Sunset Set", "DJ B", ReleaseMedium.Mix));
Assert.That(result.Value!.Medium, Is.EqualTo(ReleaseMedium.Mix));
Assert.That(result.Value.Release.Medium, Is.EqualTo(ReleaseMedium.Mix));
}
// 9.5.A — a Cut upload (the default) creates a release carrying Medium == Cut.
@@ -87,7 +87,7 @@ public class MediumWritePathTests
var result = await manager.FindOrCreateRelease(
"Studio Album", "Artist C", ReleaseData("Studio Album", "Artist C", ReleaseMedium.Cut));
Assert.That(result.Value!.Medium, Is.EqualTo(ReleaseMedium.Cut));
Assert.That(result.Value.Release.Medium, Is.EqualTo(ReleaseMedium.Cut));
}
// 9.5.A — a second upload to an existing release does NOT mutate the stored medium. The first
@@ -105,10 +105,10 @@ public class MediumWritePathTests
var found = await manager.FindOrCreateRelease(
"Live at the Vault", "Artist A", ReleaseData("Live at the Vault", "Artist A", ReleaseMedium.Cut));
Assert.That(found.Value!.Id, Is.EqualTo(created.Value!.Id), "same release row is returned");
Assert.That(found.Value.Medium, Is.EqualTo(ReleaseMedium.Session), "medium stays as first set");
Assert.That(found.Value.Release.Id, Is.EqualTo(created.Value.Release.Id), "same release row is returned");
Assert.That(found.Value.Release.Medium, Is.EqualTo(ReleaseMedium.Session), "medium stays as first set");
var stored = await CreateRepository().GetReleaseByIdAsync(created.Value.Id);
var stored = await CreateRepository().GetReleaseByIdAsync(created.Value.Release.Id);
Assert.That(stored!.Medium, Is.EqualTo(ReleaseMedium.Session), "DB row unchanged");
}
@@ -207,9 +207,9 @@ public class MediumWritePathTests
var result = await manager.FindOrCreateRelease("Studio Album", "Artist C", data);
Assert.That(result.Success, Is.True);
Assert.That(result.Value!.Description, Is.EqualTo(prose));
Assert.That(result.Value.Release.Description, Is.EqualTo(prose));
var stored = await CreateRepository().GetReleaseByIdAsync(result.Value.Id);
var stored = await CreateRepository().GetReleaseByIdAsync(result.Value.Release.Id);
Assert.That(stored!.Description, Is.EqualTo(prose));
}
+50 -3
View File
@@ -179,11 +179,58 @@ public class UploadDuplicateDetectionTests
Assert.That(message, Does.StartWith(UnifiedTrackService.CardinalityViolationMarker));
}
// ATTACH anti-forgery guard: when the caller supplies a releaseId that does NOT match the release
// the natural key (title, artist) resolves to, the upload is rejected. Guards against a stale or
// forged releaseId pointing at a different (title, artist) than this row carries.
[Test]
public async Task UploadAsync_AttachWithMismatchedReleaseId_IsRejectedWithDuplicateMarker()
{
var manager = CreateManager();
var service = await CreateUnifiedServiceAsync(manager);
// Create two separate releases so we have two distinct ids.
var releaseA = await UploadAsync(
service, await WriteWavAsync(2.0), "Track One", "Artist A", "Release A", ReleaseMedium.Cut, releaseId: null);
Assert.That(releaseA.Success, Is.True, "release A must be created");
var idA = releaseA.Value!.ReleaseId!.Value;
var releaseB = await UploadAsync(
service, await WriteWavAsync(2.0), "Track One", "Artist B", "Release B", ReleaseMedium.Cut, releaseId: null);
Assert.That(releaseB.Success, Is.True, "release B must be created");
// Try to ATTACH to release A while carrying release B's (title, artist). The natural-key lookup
// resolves to B — id A ≠ B.Id → anti-forgery guard fires.
var forged = await UploadAsync(
service, await WriteWavAsync(2.0), "Track Two", "Artist B", "Release B", ReleaseMedium.Cut, releaseId: idA);
Assert.That(forged.Success, Is.False);
var message = forged.Messages.FirstOrDefault()?.Message ?? string.Empty;
Assert.That(message, Does.StartWith(UnifiedTrackService.DuplicateReleaseMarker));
}
// Loose-track success: an upload with null/whitespace album stays release-less (ReleaseId null).
// Confirms the duplicate guard is correctly bypassed for tracks that carry no album.
[Test]
public async Task UploadAsync_NullAlbum_SucceedsAsLooseTrack()
{
var service = await CreateUnifiedServiceAsync(CreateManager());
var result = await UploadAsync(
service, await WriteWavAsync(2.0), "Standalone Cut", "DJ Solo", album: null, ReleaseMedium.Cut, releaseId: null);
Assert.That(result.Success, Is.True, result.Messages.FirstOrDefault()?.Message);
Assert.That(result.Value!.ReleaseId, Is.Null, "a null-album track must stay a loose track with no release");
}
// Case-sensitivity caveat: the assertion below verifies ordinal == equality as implemented by the
// EF in-memory provider (which evaluates LINQ predicates in-process). The deployed PostgreSQL
// instance may use a different column collation (e.g. case-insensitive) — production case-sensitivity
// depends on the collation of the `release` table's `title` and `artist` columns, not on this test.
// Matching semantics: GetReleaseByTitleAndArtist (the read both the pre-flight and the create-path
// duplicate guard use) is exact — a case difference is NOT a match, so it does not trip the block.
// This asserts the pre-flight and the create path agree by using the one shared read.
[Test]
public async Task UploadAsync_CaseDifferentTitle_IsNotADuplicate()
public async Task UploadAsync_CaseDifferentTitle_IsNotADuplicateOnInMemoryProvider()
{
var service = await CreateUnifiedServiceAsync(CreateManager());
@@ -191,8 +238,8 @@ public class UploadDuplicateDetectionTests
service, await WriteWavAsync(2.0), "Studio Album", "Artist C", "Studio Album", ReleaseMedium.Cut, releaseId: null);
Assert.That(first.Success, Is.True);
// Different case → not the same natural key → admitted as a new release (matches the create
// path's exact == comparison; no normalization anywhere).
// Different case → not the same natural key under the in-memory provider's ordinal == →
// admitted as a new release. Production outcome depends on PostgreSQL column collation.
var differentCase = await UploadAsync(
service, await WriteWavAsync(2.0), "Studio Album", "Artist C", "STUDIO ALBUM", ReleaseMedium.Cut, releaseId: null);