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:
@@ -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);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user