534 lines
32 KiB
C#
534 lines
32 KiB
C#
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 startupExe = Path.Combine(AppContext.BaseDirectory, "MusicBridge.Agent.exe");
|
|
var startupRegistry = @"Software\MusicBridge\Tests\" + Guid.NewGuid().ToString("N");
|
|
try
|
|
{
|
|
var startup = new MusicBridge.Agent.Desktop.StartupSettings(startupExe, true, 18765, startupRegistry);
|
|
Check(startup.Command == $"\"{startupExe}\" --tray --minimized --lan --port 18765", "autostart preserves quoted path, mode and port");
|
|
Check(!startup.Enabled, "autostart starts disabled");
|
|
startup.SetEnabled(true);
|
|
Check(startup.Enabled, "autostart can be enabled in isolated registry key");
|
|
startup.SetEnabled(false);
|
|
Check(!startup.Enabled, "autostart can be disabled");
|
|
}
|
|
finally { Microsoft.Win32.Registry.CurrentUser.DeleteSubKeyTree(startupRegistry, false); }
|
|
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");
|
|
var qrPng = PairingQr.Render(invitation);
|
|
using (var qrStream = new MemoryStream(qrPng))
|
|
using (var bitmap = new Bitmap(qrStream))
|
|
{
|
|
var pixels = new byte[bitmap.Width * bitmap.Height * 3];
|
|
for (var y = 0; y < bitmap.Height; y++)
|
|
for (var x = 0; x < bitmap.Width; x++)
|
|
{
|
|
var color = bitmap.GetPixel(x, y);
|
|
var offset = (y * bitmap.Width + x) * 3;
|
|
pixels[offset] = color.R; pixels[offset + 1] = color.G; pixels[offset + 2] = color.B;
|
|
}
|
|
var reader = new ZXing.BarcodeReaderGeneric();
|
|
var decodedQr = reader.Decode(pixels, bitmap.Width, bitmap.Height, ZXing.RGBLuminanceSource.BitmapFormat.RGB24);
|
|
Check(decodedQr?.Text == invitation.ToQrPayload(), "generated QR decodes to exact pairing invitation");
|
|
Check(bitmap.Width == bitmap.Height && bitmap.GetPixel(0, 0).R == 255, "QR is square with white quiet zone");
|
|
}
|
|
var serviceProfile = MdnsPublisher.CreateProfile(new string('A', 64), 18765, [IPAddress.Parse("192.168.1.10")]);
|
|
Check(serviceProfile.Resources.OfType<Makaretu.Dns.SRVRecord>().Single().Port == 18765, "DNS-SD advertises actual HTTPS port");
|
|
Check(serviceProfile.Resources.OfType<Makaretu.Dns.ARecord>().Single().Address.Equals(IPAddress.Parse("192.168.1.10")), "DNS-SD contains selected LAN address");
|
|
Check(serviceProfile.Resources.OfType<Makaretu.Dns.TXTRecord>().Single().Strings.Contains("id=" + new string('A', 64)), "DNS-SD includes stable agent id");
|
|
var invalidProfileRejected = false;
|
|
try { MdnsPublisher.CreateProfile(new string('A', 64), 8765, [IPAddress.Loopback]); }
|
|
catch (ArgumentException) { invalidProfileRejected = true; }
|
|
Check(invalidProfileRejected, "mDNS cannot advertise loopback address");
|
|
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<byte>(), Encoding.UTF8.GetBytes("<svg></svg>"), 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<PairingResult>();
|
|
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<DiscoveryDescriptor>("/v1/info");
|
|
Check(info?.AgentId == fingerprint, "agent info matches pinned certificate");
|
|
var state = await client.GetFromJsonAsync<MediaState>("/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<CommandResult>();
|
|
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");
|
|
|
|
var recoveryMedia = new FakeMedia { FailRead = true };
|
|
var recoveryPairing = new PairingService();
|
|
var recoveryToken = recoveryPairing.Pair(recoveryPairing.Open().Code).Token;
|
|
await using (var recoveryServer = new RemoteServer(recoveryMedia, recoveryPairing, certificate, false, 0, TimeSpan.FromMilliseconds(50)))
|
|
{
|
|
await recoveryServer.StartAsync();
|
|
Check(recoveryServer.Latest.MediaError is not null, "initial media failure does not prevent server startup");
|
|
recoveryMedia.FailRead = false;
|
|
recoveryMedia.Notify();
|
|
using var recoveredTimeout = new CancellationTokenSource(TimeSpan.FromSeconds(5));
|
|
while (recoveryServer.Latest.MediaError is not null) await Task.Delay(20, recoveredTimeout.Token);
|
|
Check(recoveryServer.Latest.HasSession, "polling recovers after initial media failure");
|
|
recoveryMedia.FailRead = true;
|
|
recoveryMedia.Notify();
|
|
while (recoveryServer.Latest.MediaError is null) await Task.Delay(20, recoveredTimeout.Token);
|
|
Check(!recoveryServer.Latest.HasSession && recoveryServer.Latest.ArtworkId is null, "read failure clears stale playing state");
|
|
recoveryMedia.FailRead = false;
|
|
recoveryMedia.Title = "Recovered track";
|
|
recoveryMedia.Notify();
|
|
while (recoveryServer.Latest.Title != "Recovered track") await Task.Delay(20, recoveredTimeout.Token);
|
|
Check(recoveryServer.Latest.MediaError is null, "polling survives later transient exception");
|
|
var address = new UriBuilder(recoveryServer.Addresses.Single()) { Scheme = "wss", Path = "/v1/events" }.Uri;
|
|
for (var i = 0; i < 12; i++)
|
|
{
|
|
using var clientSocket = new ClientWebSocket();
|
|
clientSocket.Options.SetRequestHeader("Authorization", $"Bearer {recoveryToken}");
|
|
clientSocket.Options.RemoteCertificateValidationCallback = (_, cert, _, _) => cert?.GetCertHashString(HashAlgorithmName.SHA256) == fingerprint;
|
|
await clientSocket.ConnectAsync(address, recoveredTimeout.Token);
|
|
var data = new byte[8192];
|
|
await clientSocket.ReceiveAsync(data.AsMemory(), recoveredTimeout.Token);
|
|
clientSocket.Abort();
|
|
await Task.Delay(30, recoveredTimeout.Token);
|
|
}
|
|
Check(true, "abrupt WSS disconnects release slots for reconnection");
|
|
}
|
|
|
|
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");
|
|
}
|
|
}
|
|
if (args.Contains("--desktop-smoke"))
|
|
{
|
|
var desktopMedia = new FakeMedia();
|
|
var desktopPairing = new PairingService();
|
|
var desktopState = new MediaState
|
|
{
|
|
HasSession = true, Title = "Проверка MusicBridge", Artist = "Тестовый плеер", PlaybackStatus = "Playing",
|
|
Volume = 0.5f, Muted = false, PositionSeconds = 45, EndSeconds = 180, MaxSeekSeconds = 180,
|
|
Capabilities = new(true, true, true, true, true, true)
|
|
};
|
|
var exited = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
|
using (var host = new MusicBridge.Agent.Desktop.DesktopHost(() => desktopState, desktopMedia, desktopPairing,
|
|
() => "Домашняя сеть • https://192.168.1.10:8765", () => { }, () => exited.TrySetResult(), visible: false, minimized: true))
|
|
{
|
|
await host.StartAsync().WaitAsync(TimeSpan.FromSeconds(8));
|
|
var field = typeof(MusicBridge.Agent.Desktop.DesktopHost).GetField("window", System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic)!;
|
|
var view = (MusicBridge.Agent.Desktop.AgentWindow)field.GetValue(host)!;
|
|
view.Invoke(() =>
|
|
{
|
|
Check(!view.Visible, "minimized startup leaves main window hidden");
|
|
view.Show();
|
|
view.RefreshState();
|
|
using var frame = new Bitmap(view.Width, view.Height);
|
|
view.DrawToBitmap(frame, new Rectangle(0, 0, frame.Width, frame.Height));
|
|
frame.Save(Path.Combine(AppContext.BaseDirectory, "desktop-smoke.png"));
|
|
var toggle = (Button)view.Controls.Find("toggle", true).Single();
|
|
Check(toggle.Enabled, "desktop enables supported commands");
|
|
toggle.PerformClick();
|
|
Check(desktopMedia.Commands.Last().Type == "toggle", "desktop play button reaches shared media service");
|
|
var slider = (TrackBar)view.Controls.Find("volume", true).Single();
|
|
slider.Value = 80;
|
|
typeof(Control).GetMethod("OnKeyUp", System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic)!
|
|
.Invoke(slider, [new KeyEventArgs(Keys.Right)]);
|
|
Check(desktopMedia.Commands.Last() is { Type: "volume", Value: 0.8 }, "desktop volume slider sends absolute volume");
|
|
desktopState = new MediaState();
|
|
view.RefreshState();
|
|
Check(!toggle.Enabled && !slider.Enabled, "desktop disables unavailable player and volume controls");
|
|
view.Close();
|
|
Check(!view.IsDisposed && !view.Visible, "closing desktop window hides it without stopping agent");
|
|
});
|
|
Check(!exited.Task.IsCompleted, "tray host stays alive while window is hidden");
|
|
}
|
|
await exited.Task.WaitAsync(TimeSpan.FromSeconds(5));
|
|
Check(exited.Task.IsCompleted, "disposing tray host stops its message loop");
|
|
}
|
|
if (args.Contains("--qr-window-smoke"))
|
|
{
|
|
var uiClock = new TestClock(DateTimeOffset.UtcNow);
|
|
var uiPairing = new PairingService(uiClock);
|
|
uiPairing.Open();
|
|
using var host = new MusicBridge.Agent.PairingWindowHost(uiPairing, new string('A', 64),
|
|
() => [new Uri("https://192.168.1.10:8765")], visible: false);
|
|
host.Open();
|
|
var field = typeof(MusicBridge.Agent.PairingWindowHost).GetField("form", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance)!;
|
|
Form? uiForm = null;
|
|
using var uiTimeout = new CancellationTokenSource(TimeSpan.FromSeconds(8));
|
|
while (uiForm is null || !uiForm.IsHandleCreated)
|
|
{
|
|
await Task.Delay(50, uiTimeout.Token);
|
|
uiForm = field.GetValue(host) as Form;
|
|
}
|
|
var actualWindow = uiForm;
|
|
bool HasImage() => (bool)actualWindow.Invoke(() => actualWindow.Controls.OfType<TableLayoutPanel>().Single()
|
|
.Controls.OfType<PictureBox>().Single().Image is not null);
|
|
while (!HasImage()) await Task.Delay(50, uiTimeout.Token);
|
|
Check(HasImage(), "QR window renders active pairing image");
|
|
actualWindow.Invoke(() =>
|
|
{
|
|
using var frame = new Bitmap(actualWindow.Width, actualWindow.Height);
|
|
actualWindow.DrawToBitmap(frame, new Rectangle(0, 0, frame.Width, frame.Height));
|
|
frame.Save(Path.Combine(AppContext.BaseDirectory, "qr-window-smoke.png"));
|
|
});
|
|
uiClock.Now = uiClock.Now.AddMinutes(6);
|
|
while (HasImage()) await Task.Delay(50, uiTimeout.Token);
|
|
Check(!HasImage(), "QR window removes expired pairing image");
|
|
}
|
|
if (args.Contains("--mdns-smoke"))
|
|
{
|
|
var local = LocalAddresses.Read();
|
|
if (local.Length == 0) throw new InvalidOperationException("No private multicast-capable IPv4 interface for mDNS smoke test.");
|
|
var testFingerprint = Convert.ToHexString(SHA256.HashData(Guid.NewGuid().ToByteArray()));
|
|
var name = MdnsPublisher.CreateProfile(testFingerprint, 18799, local).FullyQualifiedName;
|
|
using var listener = new Makaretu.Dns.MulticastService { UseIpv4 = true, UseIpv6 = false };
|
|
var seen = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
|
|
var gone = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
|
|
listener.AnswerReceived += (_, e) =>
|
|
{
|
|
foreach (var ptr in e.Message.Answers.OfType<Makaretu.Dns.PTRRecord>().Where(r => r.DomainName == name))
|
|
{
|
|
if (ptr.TTL == TimeSpan.Zero) gone.TrySetResult(true);
|
|
else seen.TrySetResult(true);
|
|
}
|
|
};
|
|
listener.Start();
|
|
using (var publisher = new MdnsPublisher(testFingerprint, 18799))
|
|
{
|
|
publisher.Refresh(local);
|
|
listener.SendQuery("_musicbridge._tcp.local", type: Makaretu.Dns.DnsType.PTR);
|
|
Check(await seen.Task.WaitAsync(TimeSpan.FromSeconds(8)), "real multicast listener receives service announcement");
|
|
}
|
|
Check(await gone.Task.WaitAsync(TimeSpan.FromSeconds(8)), "real multicast listener receives goodbye on shutdown");
|
|
}
|
|
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 volatile bool FailRead;
|
|
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<MediaCommand> Commands { get; } = [];
|
|
public Task<MediaState> ReadAsync(CancellationToken cancellationToken)
|
|
{
|
|
Interlocked.Increment(ref ReadCount);
|
|
if (FailRead) throw new IOException("Simulated player disconnection");
|
|
return Task.FromResult(new MediaState
|
|
{
|
|
HasSession = true, Title = Title, ArtworkId = Artwork.Current?.Id, Volume = 0.5f, Timestamp = DateTimeOffset.UtcNow
|
|
});
|
|
}
|
|
public Task<CommandResult> ExecuteAsync(MediaCommand command, CancellationToken cancellationToken)
|
|
{
|
|
Commands.Add(command);
|
|
return Task.FromResult(new CommandResult(true, "ok", "Test command", command.Id));
|
|
}
|
|
}
|