2af0d8650b
Route normal play closes (end/switch/stop) and all shares through a same-origin HttpClient POST so privacy-hardened browsers stop blocking them; keep sendBeacon for the tab-unload edge. Rename the JS module off telemetry/beacon to session/ lifecycle so the retained fallback isn't name-matched. No new data or identifiers.
186 lines
7.8 KiB
C#
186 lines
7.8 KiB
C#
using System.Text.Json;
|
|
using DeepDrftModels.Enums;
|
|
using DeepDrftPublic.Client.Services;
|
|
using Microsoft.AspNetCore.Components;
|
|
using Microsoft.JSInterop;
|
|
|
|
namespace DeepDrftTests;
|
|
|
|
/// <summary>
|
|
/// Tests that the Phase 16 wave-16.3 anon id is threaded onto the event payloads emitted by
|
|
/// <see cref="BeaconPlayEventSink"/> and <see cref="ShareTracker"/>, and omitted when the provider has
|
|
/// no token. After the transport-resilience split, normal play closes and shares serialize and POST over
|
|
/// the first-party <see cref="IEventPoster"/>, so those assertions capture the JSON off a fake poster.
|
|
/// The play sink's unload arm still serializes the same bytes through <c>BeaconInterop</c> →
|
|
/// <c>DeepDrftLifecycle.send</c>, asserted off a fake JS runtime — proving both arms carry the id.
|
|
/// </summary>
|
|
[TestFixture]
|
|
public class AnonIdPayloadTests
|
|
{
|
|
// Captures the JSON body of the most recent first-party POST. The poster is fire-and-forget; the
|
|
// caller never reads its result.
|
|
private sealed class CapturingEventPoster : IEventPoster
|
|
{
|
|
public string? LastJson { get; private set; }
|
|
|
|
public Task PostAsync(string url, string json)
|
|
{
|
|
LastJson = json;
|
|
return Task.CompletedTask;
|
|
}
|
|
}
|
|
|
|
// Captures the JSON body of the most recent DeepDrftLifecycle.send(url, json) invocation (the unload
|
|
// arm). Other interop calls (unload registration) are tolerated and ignored.
|
|
private sealed class CapturingJsRuntime : IJSRuntime
|
|
{
|
|
public string? LastJson { get; private set; }
|
|
|
|
public ValueTask<TValue> InvokeAsync<TValue>(string identifier, object?[]? args)
|
|
{
|
|
if (identifier == "DeepDrftLifecycle.send" && args is { Length: 2 } && args[1] is string json)
|
|
LastJson = json;
|
|
return ValueTask.FromResult<TValue>(default!);
|
|
}
|
|
|
|
public ValueTask<TValue> InvokeAsync<TValue>(string identifier, CancellationToken cancellationToken, object?[]? args)
|
|
=> InvokeAsync<TValue>(identifier, args);
|
|
}
|
|
|
|
// A no-op poster for the unload-arm test, where the beacon (not the poster) is the asserted transport.
|
|
private sealed class NoopEventPoster : IEventPoster
|
|
{
|
|
public Task PostAsync(string url, string json) => Task.CompletedTask;
|
|
}
|
|
|
|
private sealed class StubAnonIdProvider : IAnonIdProvider
|
|
{
|
|
public StubAnonIdProvider(string? current) => Current = current;
|
|
public string? Current { get; }
|
|
public ValueTask EnsureLoadedAsync() => ValueTask.CompletedTask;
|
|
}
|
|
|
|
private sealed class TestNavigationManager : NavigationManager
|
|
{
|
|
public TestNavigationManager() => Initialize("https://deepdrft.test/", "https://deepdrft.test/");
|
|
protected override void NavigateToCore(string uri, bool forceLoad) { }
|
|
}
|
|
|
|
// The sinks serialize with default (PascalCase) property names; the API binds case-insensitively, so
|
|
// the wire field is "AnonId". Match it case-insensitively here so the test asserts the value, not the
|
|
// casing convention. Returns (present, value) while the document is still alive.
|
|
private static (bool Present, string? Value) FindAnonId(string json)
|
|
{
|
|
using var doc = JsonDocument.Parse(json);
|
|
foreach (var prop in doc.RootElement.EnumerateObject())
|
|
{
|
|
if (string.Equals(prop.Name, "anonId", StringComparison.OrdinalIgnoreCase))
|
|
return (true, prop.Value.GetString());
|
|
}
|
|
return (false, null);
|
|
}
|
|
|
|
private static string? ReadAnonId(string json) => FindAnonId(json).Value;
|
|
|
|
private static bool HasAnonIdProperty(string json) => FindAnonId(json).Present;
|
|
|
|
private static BeaconPlayEventSink PlaySink(IEventPoster poster, IJSRuntime js, string? anonId)
|
|
=> new(poster, new BeaconInterop(js), new StubAnonIdProvider(anonId), new TestNavigationManager());
|
|
|
|
// --- Play sink, first-party fetch arm (normal close) ---
|
|
|
|
// A play emitted while the provider holds a token carries that token in the fetch payload.
|
|
[Test]
|
|
public async Task PlaySink_FetchArm_WithAnonId_IncludesItInPayload()
|
|
{
|
|
var poster = new CapturingEventPoster();
|
|
var sink = PlaySink(poster, new CapturingJsRuntime(), "listener-42");
|
|
|
|
await sink.EmitPlayAsync("track-key", PlayBucket.Complete);
|
|
|
|
Assert.That(poster.LastJson, Is.Not.Null);
|
|
Assert.That(ReadAnonId(poster.LastJson!), Is.EqualTo("listener-42"));
|
|
}
|
|
|
|
// A play emitted when the provider has no token (storage unavailable / not warmed) omits anonId
|
|
// entirely rather than sending anonId:null.
|
|
[Test]
|
|
public async Task PlaySink_FetchArm_WithoutAnonId_OmitsItFromPayload()
|
|
{
|
|
var poster = new CapturingEventPoster();
|
|
var sink = PlaySink(poster, new CapturingJsRuntime(), null);
|
|
|
|
await sink.EmitPlayAsync("track-key", PlayBucket.Partial);
|
|
|
|
Assert.That(poster.LastJson, Is.Not.Null);
|
|
Assert.That(HasAnonIdProperty(poster.LastJson!), Is.False, "null anonId is omitted from the wire payload");
|
|
}
|
|
|
|
// --- Play sink, sendBeacon arm (page unload) ---
|
|
|
|
// The unload arm serializes the same payload through sendBeacon, carrying the token too.
|
|
[Test]
|
|
public void PlaySink_UnloadArm_WithAnonId_IncludesItInPayload()
|
|
{
|
|
var js = new CapturingJsRuntime();
|
|
var sink = PlaySink(new NoopEventPoster(), js, "listener-99");
|
|
|
|
sink.EmitPlayOnUnload("track-key", PlayBucket.Complete);
|
|
|
|
Assert.That(js.LastJson, Is.Not.Null);
|
|
Assert.That(ReadAnonId(js.LastJson!), Is.EqualTo("listener-99"));
|
|
}
|
|
|
|
// --- Share tracker (always first-party fetch) ---
|
|
|
|
// A share recorded while the provider holds a token carries it in the payload.
|
|
[Test]
|
|
public void ShareTracker_WithAnonId_IncludesItInPayload()
|
|
{
|
|
var poster = new CapturingEventPoster();
|
|
var tracker = new ShareTracker(poster, new StubAnonIdProvider("listener-7"), new TestNavigationManager());
|
|
|
|
tracker.RecordShare(ShareTargetType.Track, "k", ShareChannel.Link);
|
|
|
|
Assert.That(poster.LastJson, Is.Not.Null);
|
|
Assert.That(ReadAnonId(poster.LastJson!), Is.EqualTo("listener-7"));
|
|
}
|
|
|
|
// A share recorded with no token omits anonId from the payload.
|
|
[Test]
|
|
public void ShareTracker_WithoutAnonId_OmitsItFromPayload()
|
|
{
|
|
var poster = new CapturingEventPoster();
|
|
var tracker = new ShareTracker(poster, new StubAnonIdProvider(null), new TestNavigationManager());
|
|
|
|
tracker.RecordShare(ShareTargetType.Track, "k", ShareChannel.Link);
|
|
|
|
Assert.That(poster.LastJson, Is.Not.Null);
|
|
Assert.That(HasAnonIdProperty(poster.LastJson!), Is.False);
|
|
}
|
|
|
|
// A JS runtime that throws on every call — models localStorage interop being unavailable (private
|
|
// mode, blocked storage, or the module not yet imported during prerender).
|
|
private sealed class ThrowingJsRuntime : IJSRuntime
|
|
{
|
|
public ValueTask<TValue> InvokeAsync<TValue>(string identifier, object?[]? args)
|
|
=> throw new InvalidOperationException("interop unavailable");
|
|
|
|
public ValueTask<TValue> InvokeAsync<TValue>(string identifier, CancellationToken cancellationToken, object?[]? args)
|
|
=> throw new InvalidOperationException("interop unavailable");
|
|
}
|
|
|
|
// The real AnonIdProvider degrades to null (no throw) when the localStorage interop is unavailable —
|
|
// the acceptance-criterion "degrades safely if localStorage is unavailable". EnsureLoadedAsync
|
|
// swallows the interop failure and leaves Current null.
|
|
[Test]
|
|
public async Task AnonIdProvider_WhenInteropUnavailable_DegradesToNullWithoutThrowing()
|
|
{
|
|
var provider = new AnonIdProvider(new ThrowingJsRuntime());
|
|
|
|
await provider.EnsureLoadedAsync(); // must not throw
|
|
|
|
Assert.That(provider.Current, Is.Null);
|
|
}
|
|
}
|