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
+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);