using System.Net; using System.Net.Http.Headers; using System.Net.Http.Json; using System.Net.WebSockets; using System.Security.Cryptography; using System.Text; using System.Text.Json; using MusicBridge.Agent.Media; using MusicBridge.Agent.Network; try { await RunAsync(args); } catch (Exception ex) { Console.Error.WriteLine($"TEST FAILED: {ex.GetType().Name}: {ex.Message}"); Environment.ExitCode = 1; } static async Task RunAsync(string[] args) { if (args.Contains("--test-failure")) throw new InvalidOperationException("Controlled failure to verify console-only reporting."); var checks = 0; void Check(bool condition, string name) { if (!condition) throw new InvalidOperationException($"FAIL: {name}"); checks++; Console.WriteLine($"PASS: {name}"); } var now = DateTimeOffset.UtcNow; var invitation = ConnectionInvitation.Create(new Uri("https://192.168.1.10:8765"), new string('A', 64), new("01234567", now.AddMinutes(5)), now); Check(invitation.Version == 1 && invitation.Code == "01234567", "invitation preserves leading zero in pairing code"); var encodedInvitation = invitation.ToQrPayload().Split("data=")[1].Replace('-', '+').Replace('_', '/'); encodedInvitation = encodedInvitation.PadRight((encodedInvitation.Length + 3) / 4 * 4, '='); using (var decoded = JsonDocument.Parse(Convert.FromBase64String(encodedInvitation))) Check(decoded.RootElement.GetProperty("endpoint").GetString() == "https://192.168.1.10:8765", "QR payload contains versioned endpoint JSON"); foreach (var endpoint in new[] { "https://127.0.0.1:8765", "http://192.168.1.2", "https://8.8.8.8", "https://192.168.1.2/?token=x", "https://user:pass@192.168.1.2" }) Check(!ConnectionInvitation.IsLocalEndpoint(new Uri(endpoint)), "invitation rejects unsafe or unusable endpoint"); var expiredInvitationRejected = false; try { ConnectionInvitation.Create(new Uri("https://10.0.0.1"), new string('A', 64), new("01234567", now), now); } catch (ArgumentException) { expiredInvitationRejected = true; } Check(expiredInvitationRejected, "expired pairing window cannot generate invitation"); var descriptor = DiscoveryDescriptor.Create(new string('a', 64)); Check(descriptor.AgentId == new string('A', 64) && descriptor.TxtRecords().Count == 2, "discovery descriptor stable and contains no pairing secrets"); Check(TimelineMath.Position(20, 0, 200, true, now.AddSeconds(-5), now, 2) == 30, "position uses elapsed time and rate"); Check(TimelineMath.Position(20, 0, 200, false, now.AddSeconds(-5), now, 1) == 20, "pause does not advance position"); Check(TimelineMath.Position(199, 0, 200, true, now.AddSeconds(-5), now, 1) == 200, "position clamps to end"); Check(TimelineMath.Position(-5, 3, 200, false, now, now, 1) == 3, "position clamps to start"); Check(TimelineMath.Position(20, 0, 200, true, now.AddSeconds(5), now, 1) == 20, "future update ignored"); Check(TimelineMath.Position(20, 0, 200, true, DateTimeOffset.UnixEpoch, now, 1) == 20, "missing update ignored"); Check(TimelineMath.SeekRange(10, 200, 20, 300) == (20, 200), "seek range intersects timeline"); Check(TimelineMath.SeekRange(0, 0, 20, 100) == (20, 100), "live seek range without duration"); foreach (var command in new[] { new MediaCommand("volume", -0.1), new("volume", 1.1), new("volume", double.NaN), new("seek", double.PositiveInfinity), new("seek", -1), new("seek"), new("next", 1), new("unknown"), new("pause", Id: new string('a', 65)) }) Check(CommandValidation.Validate(command) is not null, $"reject invalid {command.Type}/{command.Value}"); Check(CommandValidation.Validate(new("seek", 125.5)) is null, "absolute seek accepted"); Check(CommandValidation.Validate(new("volume", 0)) is null, "zero volume accepted"); Check(CommandValidation.Validate(new("volume", 1)) is null, "full volume accepted"); var clock = new TestClock(now); var trust = new PairingService(clock); Check(!trust.Pair("00000000").Success, "pairing closed initially"); var expired = trust.Open(); clock.Now = now.AddMinutes(6); Check(!trust.Pair(expired.Code).Success && trust.Current is null, "pairing code expires"); var locked = trust.Open(); for (var i = 0; i < 10; i++) trust.Pair("invalid"); Check(!trust.Pair(locked.Code).Success, "ten failed attempts close pairing"); var current = trust.Open(); var paired = trust.Pair(current.Code); Check(paired.Success && trust.Authorize(paired.Token), "valid pairing authorizes token"); Check(!trust.Pair(current.Code).Success, "pairing code is single use"); Check(!trust.Authorize(new string('0', 64)), "wrong token rejected"); Check(!new PairingService().Authorize(paired.Token), "isolated in-memory test services do not share trust"); var identityDirectory = Path.GetFullPath(Path.Combine(Path.GetTempPath(), "MusicBridge-Test-" + Guid.NewGuid().ToString("N"))); Directory.CreateDirectory(identityDirectory); try { string savedToken; string savedId; string savedFingerprint; var identityPath = Path.Combine(identityDirectory, "identity.dat"); using (var identity = new IdentityStore(identityDirectory)) { savedFingerprint = identity.Certificate.GetCertHashString(HashAlgorithmName.SHA256); var persistent = new PairingService(store: identity); var newDevice = persistent.Pair(persistent.Open().Code, "My iPhone"); Check(newDevice.Success && newDevice.DeviceId is not null, "persistent pairing returns stable device id"); savedToken = newDevice.Token!; savedId = newDevice.DeviceId!; var clear = UserProtection.Unprotect(File.ReadAllBytes(identityPath)); try { var text = Encoding.UTF8.GetString(clear); Check(!text.Contains(savedToken) && text.Contains(persistent.Devices[0].TokenHash), "disk identity stores token hash, not bearer token"); } finally { CryptographicOperations.ZeroMemory(clear); } var lockedOut = false; try { using var secondOwner = new IdentityStore(identityDirectory); } catch (IOException) { lockedOut = true; } Check(lockedOut, "second agent cannot overwrite active identity"); } using (var reopened = new IdentityStore(identityDirectory)) { Check(reopened.Certificate.GetCertHashString(HashAlgorithmName.SHA256) == savedFingerprint, "certificate fingerprint survives restart"); var persistent = new PairingService(store: reopened); Check(persistent.Authorize(savedToken) && persistent.Devices[0].Name == "My iPhone", "trusted token and device name survive restart"); Check(persistent.Current is null, "restored trust does not open pairing window"); await using (var restoredServer = new RemoteServer(new FakeMedia(), persistent, reopened.Certificate, false, 0)) { await restoredServer.StartAsync(); using var restoredHandler = new HttpClientHandler { ServerCertificateCustomValidationCallback = (_, cert, _, _) => cert?.GetCertHashString(HashAlgorithmName.SHA256) == savedFingerprint }; using var restoredClient = new HttpClient(restoredHandler) { BaseAddress = new Uri(restoredServer.Addresses.Single()), Timeout = TimeSpan.FromSeconds(5) }; restoredClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", savedToken); Check((await restoredClient.GetAsync("/v1/state")).IsSuccessStatusCode, "restored certificate and token work over real HTTPS"); } var badName = persistent.Pair(persistent.Open().Code, "bad\nname"); Check(!badName.Success && badName.Code == "invalid_name", "control characters in device name rejected"); // Force an atomic-write failure without touching the valid identity file. var blockedTemporary = identityPath + ".tmp"; Directory.CreateDirectory(blockedTemporary); try { var failedPair = persistent.Pair(persistent.Open().Code, "Cannot save"); Check(!failedPair.Success && failedPair.Code == "storage_failed" && persistent.Devices.Count == 1, "failed persistence never authorizes new device"); var revokeFailed = false; try { persistent.Revoke(savedId); } catch (IOException) { revokeFailed = true; } catch (UnauthorizedAccessException) { revokeFailed = true; } Check(revokeFailed && persistent.Authorize(savedToken), "failed revocation preserves consistent active trust"); } finally { Directory.Delete(blockedTemporary); } Check(persistent.Revoke(savedId) && !persistent.Authorize(savedToken), "revocation removes token authorization"); } using (var reopened = new IdentityStore(identityDirectory)) Check(!new PairingService(store: reopened).Authorize(savedToken), "revocation survives restart"); var corrupted = File.ReadAllBytes(identityPath); corrupted[^1] ^= 0xff; File.WriteAllBytes(identityPath, corrupted); var corruptionRejected = false; try { using var broken = new IdentityStore(identityDirectory); } catch (System.ComponentModel.Win32Exception) { corruptionRejected = true; } Check(corruptionRejected && File.ReadAllBytes(identityPath).SequenceEqual(corrupted), "corrupt identity is rejected without silent replacement"); } finally { // Only files owned by this test in its newly generated directory are removed. foreach (var name in new[] { "identity.dat", "identity.lock", "identity.dat.tmp" }) { var file = Path.Combine(identityDirectory, name); if (File.Exists(file)) File.Delete(file); } Directory.Delete(identityDirectory); } var png = Convert.FromBase64String("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+jRZkAAAAASUVORK5CYII="); var cache = new ArtworkCache(); var cover = await cache.LoadAsync(new MemoryStream(png), CancellationToken.None); Check(cover.ContentType == "image/png" && cover.Bytes.SequenceEqual(png), "cover preserves bytes and detects PNG"); var repeated = await cache.LoadAsync(new MemoryStream(png), CancellationToken.None); Check(cover.Id == repeated.Id, "identical cover keeps content id"); Check(cache.Get(cover.Id) is not null && cache.Get("unknown") is null, "cover lookup uses content id"); foreach (var invalidImage in new[] { Array.Empty(), Encoding.UTF8.GetBytes(""), new byte[ArtworkCache.MaxBytes + 1] }) { await cache.LoadAsync(new MemoryStream(png), CancellationToken.None); var rejected = false; try { await cache.LoadAsync(new MemoryStream(invalidImage), CancellationToken.None); } catch (InvalidDataException) { rejected = true; } Check(rejected && cache.Current is null, "invalid or oversized artwork clears old cover"); } using (var canceled = new CancellationTokenSource()) { canceled.Cancel(); var canceledRead = false; try { await cache.LoadAsync(new MemoryStream(png), canceled.Token); } catch (OperationCanceledException) { canceledRead = true; } Check(canceledRead && cache.Current is null, "artwork read supports cancellation"); } using var certificate = AgentCertificate.Create(); var fingerprint = certificate.GetCertHashString(HashAlgorithmName.SHA256); var fake = new FakeMedia(); await fake.Artwork.LoadAsync(new MemoryStream(png), CancellationToken.None); var pairing = new PairingService(); var window = pairing.Open(); await using var server = new RemoteServer(fake, pairing, certificate, false, 0); await server.StartAsync(); using var handler = new HttpClientHandler { ServerCertificateCustomValidationCallback = (_, cert, _, _) => cert?.GetCertHashString(HashAlgorithmName.SHA256) == fingerprint }; using var client = new HttpClient(handler) { BaseAddress = new Uri(server.Addresses.Single()), Timeout = TimeSpan.FromSeconds(5) }; Check((await client.GetAsync("/v1/state")).StatusCode == HttpStatusCode.Unauthorized, "state requires authorization"); Check((await client.GetAsync("/v1/info")).StatusCode == HttpStatusCode.Unauthorized, "agent info requires authorization"); Check((await client.GetAsync($"/v1/artwork/{cover.Id}")).StatusCode == HttpStatusCode.Unauthorized, "artwork requires authorization"); Check((await client.PostAsJsonAsync("/v1/command", new { type = "next" })).StatusCode == HttpStatusCode.Unauthorized && fake.Commands.Count == 0, "unauthorized command never reaches player"); var wsUri = new UriBuilder(client.BaseAddress) { Scheme = "wss", Path = "/v1/events" }.Uri; using (var anonymous = new ClientWebSocket()) { anonymous.Options.RemoteCertificateValidationCallback = (_, cert, _, _) => cert?.GetCertHashString(HashAlgorithmName.SHA256) == fingerprint; var denied = false; try { await anonymous.ConnectAsync(wsUri, CancellationToken.None); } catch (WebSocketException) { denied = true; } Check(denied, "WSS requires authorization"); } Check((await client.PostAsJsonAsync("/v1/pair", new { code = "invalid" })).StatusCode == HttpStatusCode.Forbidden, "invalid pairing code denied over HTTPS"); var response = await client.PostAsJsonAsync("/v1/pair", new { code = window.Code }); var result = await response.Content.ReadFromJsonAsync(); Check(response.IsSuccessStatusCode && result?.Token is not null, "pair over pinned TLS"); client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", result!.Token); var info = await client.GetFromJsonAsync("/v1/info"); Check(info?.AgentId == fingerprint, "agent info matches pinned certificate"); var state = await client.GetFromJsonAsync("/v1/state"); Check(state?.ProtocolVersion == 1 && state.Title == "Test track", "authenticated state is structured"); Check(state?.ArtworkId == cover.Id, "state references cover without embedding image bytes"); var imageResponse = await client.GetAsync($"/v1/artwork/{cover.Id}"); Check(imageResponse.IsSuccessStatusCode && imageResponse.Content.Headers.ContentType?.MediaType == "image/png" && (await imageResponse.Content.ReadAsByteArrayAsync()).SequenceEqual(png), "authenticated cover download preserves MIME and bytes"); Check((await client.GetAsync("/v1/artwork/missing")).StatusCode == HttpStatusCode.NotFound, "missing artwork returns 404"); fake.Artwork.Clear(); Check((await client.GetAsync($"/v1/artwork/{cover.Id}")).StatusCode == HttpStatusCode.NotFound, "old cover unavailable after track cache clears"); var bad = await client.PostAsJsonAsync("/v1/command", new { type = "volume", value = 4 }); Check(bad.StatusCode == HttpStatusCode.BadRequest && fake.Commands.Count == 0, "invalid remote volume cannot reach player"); var malformed = await client.PostAsync("/v1/command", new StringContent("{", Encoding.UTF8, "application/json")); Check(malformed.StatusCode == HttpStatusCode.BadRequest, "malformed JSON gets 400"); var tooLarge = await client.PostAsync("/v1/command", new StringContent(new string(' ', 5000), Encoding.UTF8, "application/json")); Check(tooLarge.StatusCode == HttpStatusCode.RequestEntityTooLarge, "oversized command rejected"); var commandResponse = await client.PostAsJsonAsync("/v1/command", new MediaCommand("seek", 42.5, "seek-1")); var commandResult = await commandResponse.Content.ReadFromJsonAsync(); Check(commandResult is { Success: true, Id: "seek-1" } && fake.Commands.Single().Value == 42.5, "absolute seek and request id reach service"); using (var origin = new HttpRequestMessage(HttpMethod.Get, "/v1/state")) { origin.Headers.Add("Origin", "https://example.com"); Check((await client.SendAsync(origin)).StatusCode == HttpStatusCode.Forbidden, "browser origin rejected"); } using (var socket = new ClientWebSocket()) { socket.Options.SetRequestHeader("Authorization", $"Bearer {result.Token}"); socket.Options.RemoteCertificateValidationCallback = (_, cert, _, _) => cert?.GetCertHashString(HashAlgorithmName.SHA256) == fingerprint; using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(5)); await socket.ConnectAsync(wsUri, timeout.Token); var buffer = new byte[8192]; var first = await socket.ReceiveAsync(buffer.AsMemory(), timeout.Token); using var document = JsonDocument.Parse(buffer.AsMemory(0, first.Count)); Check(first.EndOfMessage && document.RootElement.GetProperty("type").GetString() == "state", "WSS delivers initial state"); var second = await socket.ReceiveAsync(buffer.AsMemory(), timeout.Token); Check(second.Count > 0, "WSS delivers subsequent state"); await socket.CloseAsync(WebSocketCloseStatus.NormalClosure, "test complete", timeout.Token); Check(socket.State == WebSocketState.Closed, "WSS closes cleanly"); } // A long fallback interval proves delivery comes from the event, not the timer. var eventMedia = new FakeMedia(); var eventPairing = new PairingService(); var eventDevice = eventPairing.Pair(eventPairing.Open().Code); var eventToken = eventDevice.Token; await using (var eventServer = new RemoteServer(eventMedia, eventPairing, certificate, false, 0, TimeSpan.FromSeconds(30))) { await eventServer.StartAsync(); using var socket = new ClientWebSocket(); socket.Options.SetRequestHeader("Authorization", $"Bearer {eventToken}"); socket.Options.RemoteCertificateValidationCallback = (_, cert, _, _) => cert?.GetCertHashString(HashAlgorithmName.SHA256) == fingerprint; using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(5)); await socket.ConnectAsync(new UriBuilder(eventServer.Addresses.Single()) { Scheme = "wss", Path = "/v1/events" }.Uri, timeout.Token); var buffer = new byte[8192]; await socket.ReceiveAsync(buffer.AsMemory(), timeout.Token); eventMedia.Title = "Track after event"; for (var i = 0; i < 1000; i++) eventMedia.Notify(); var update = await socket.ReceiveAsync(buffer.AsMemory(), timeout.Token); using var document = JsonDocument.Parse(buffer.AsMemory(0, update.Count)); Check(document.RootElement.GetProperty("state").GetProperty("title").GetString() == "Track after event", "media event reaches WSS before 30-second fallback timer"); Check(eventMedia.ReadCount < 50, "event burst is coalesced"); eventPairing.Revoke(eventDevice.DeviceId!); var disconnected = false; try { var close = await socket.ReceiveAsync(buffer.AsMemory(), timeout.Token); disconnected = close.MessageType == WebSocketMessageType.Close; } catch (WebSocketException) { disconnected = true; } Check(disconnected, "revoking trust disconnects an existing WSS stream"); using var revokedHandler = new HttpClientHandler { ServerCertificateCustomValidationCallback = (_, cert, _, _) => cert?.GetCertHashString(HashAlgorithmName.SHA256) == fingerprint }; using var revokedClient = new HttpClient(revokedHandler) { BaseAddress = new Uri(eventServer.Addresses.Single()), Timeout = TimeSpan.FromSeconds(5) }; revokedClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", eventToken); Check((await revokedClient.PostAsJsonAsync("/v1/command", new { type = "next" })).StatusCode == HttpStatusCode.Unauthorized && eventMedia.Commands.Count == 0, "revoked token cannot send HTTP commands"); } Check(eventMedia.SubscriberCount == 0, "server unsubscribes from media events on shutdown"); if (args.Contains("--live-read")) { var manager = await Windows.Media.Control.GlobalSystemMediaTransportControlsSessionManager.RequestAsync(); using var live = new MediaService(manager); var snapshot = await live.ReadAsync(CancellationToken.None); Check(snapshot.MediaError is null, "real Windows media read succeeds"); Console.WriteLine($"Live read: session={snapshot.HasSession}, artwork={snapshot.ArtworkId is not null}, artworkError={snapshot.ArtworkError ?? "none"}"); if (snapshot.ArtworkId is { } id) { var liveCover = live.GetArtwork(id); Check(liveCover is { Bytes.Length: > 0 }, "real cover bytes are cached"); Console.WriteLine($"Live cover: {liveCover!.ContentType}, {liveCover.Bytes.Length} bytes."); var next = await live.ReadAsync(CancellationToken.None); Check(next.ArtworkId == id, "real cover cache survives progress refresh"); } } Console.WriteLine($"All {checks} checks passed. No real playback or volume was changed."); } sealed class TestClock(DateTimeOffset now) : TimeProvider { public DateTimeOffset Now { get; set; } = now; public override DateTimeOffset GetUtcNow() => Now; } sealed class FakeMedia : IMediaService { public event Action? Changed; public int SubscriberCount => Changed?.GetInvocationList().Length ?? 0; public int ReadCount; public readonly ArtworkCache Artwork = new(); public Artwork? GetArtwork(string id) => Artwork.Get(id); public string Title { get; set; } = "Test track"; public void Notify() => Changed?.Invoke(); public List Commands { get; } = []; public Task ReadAsync(CancellationToken cancellationToken) { Interlocked.Increment(ref ReadCount); return Task.FromResult(new MediaState { HasSession = true, Title = Title, ArtworkId = Artwork.Current?.Id, Volume = 0.5f, Timestamp = DateTimeOffset.UtcNow }); } public Task ExecuteAsync(MediaCommand command, CancellationToken cancellationToken) { Commands.Add(command); return Task.FromResult(new CommandResult(true, "ok", "Test command", command.Id)); } }