using System; using System.Net; using System.Net.Http; using System.Threading; using System.Threading.Tasks; using Xunit; using ytLive.Models; using ytLive.Services; namespace ytLive.Tests; public class SocialValidatorTests { private sealed class StubHandler : HttpMessageHandler { private readonly Func _respond; public StubHandler(HttpResponseMessage response) : this(_ => response) { } public StubHandler(Func respond) => _respond = respond; protected override Task SendAsync(HttpRequestMessage request, CancellationToken ct) { if (ct.IsCancellationRequested) return Task.FromCanceled(ct); return Task.FromResult(_respond(request)); } } private static HttpSocialValidator Validator(HttpMessageHandler handler) => new(new HttpClient(handler)); [Fact] public async Task FediverseHandle_BuildsHostUrl() { var validator = Validator(new StubHandler(new HttpResponseMessage(HttpStatusCode.OK))); var result = await validator.LookupAsync(SocialService.Fediverse, "@user@site.tld", CancellationToken.None); Assert.True(result.Success); Assert.Equal("https://site.tld/@user", result.ProfileUrl); Assert.Equal("@user@site.tld", result.Handle); } [Fact] public async Task FediverseHandle_FetchesInstanceSoftware() { var validator = Validator(new StubHandler(RespondFediverse("peertube"))); var result = await validator.LookupAsync(SocialService.Fediverse, "@gramps@llamachile.tube", CancellationToken.None); Assert.True(result.Success); Assert.Equal("peertube", result.FediverseSoftware); } [Fact] public async Task FediverseHandle_RootRedirectToSubdomain_ResolvesSoftware() { var validator = Validator(new StubHandler(request => { var uri = request.RequestUri!; if (uri.Host == "site.tld") { if (uri.AbsolutePath == "/") { // Identity domain's default app redirects to the real instance. return new HttpResponseMessage(HttpStatusCode.OK) { RequestMessage = new HttpRequestMessage(HttpMethod.Get, "https://social.site.tld/"), }; } if (uri.AbsolutePath.StartsWith("/@")) return new HttpResponseMessage(HttpStatusCode.OK); // profile validates return new HttpResponseMessage(HttpStatusCode.NotFound); // nodeinfo SSO-blocked } if (uri.Host == "social.site.tld") { if (uri.AbsolutePath.StartsWith("/.well-known")) return Json(new { links = new[] { new { rel = "http://nodeinfo.diaspora.software/ns/schema/2.0", href = "https://social.site.tld/nodeinfo/2.0" } } }); if (uri.AbsolutePath.StartsWith("/nodeinfo")) return Json(new { software = new { name = "mastodon", version = "4.6.3" } }); } return new HttpResponseMessage(HttpStatusCode.OK); })); var result = await validator.LookupAsync(SocialService.Fediverse, "@user@site.tld", CancellationToken.None); Assert.True(result.Success); Assert.Equal("mastodon", result.FediverseSoftware); } [Fact] public async Task FediverseHandle_NodeInfoUnavailable_StillSucceeds() { var validator = Validator(new StubHandler(request => request.RequestUri!.AbsolutePath.StartsWith("/.well-known") ? new HttpResponseMessage(HttpStatusCode.NotFound) : new HttpResponseMessage(HttpStatusCode.OK))); var result = await validator.LookupAsync(SocialService.Fediverse, "@user@site.tld", CancellationToken.None); Assert.True(result.Success); Assert.Null(result.FediverseSoftware); } [Fact] public async Task FediverseSoftware_IdentityDomainSilent_ProbesWellKnownSubdomains() { // The identity domain is a silent landing page (no nodeinfo, no redirect); // the real instance lives on mastodon. — the probe must find it. var validator = Validator(new StubHandler(request => { var host = request.RequestUri!.Host; if (host == "mastodon.llamachile.tube") { if (request.RequestUri.AbsolutePath.StartsWith("/.well-known")) return Json(new { links = new[] { new { rel = "http://nodeinfo.diaspora.software/ns/schema/2.0", href = "https://mastodon.llamachile.tube/nodeinfo/2.0" } } }); if (request.RequestUri.AbsolutePath.StartsWith("/nodeinfo")) return Json(new { software = new { name = "mastodon", version = "4.6.3" } }); } return new HttpResponseMessage(HttpStatusCode.NotFound); })); var software = await validator.ResolveFediverseSoftwareAsync("llamachile.tube", CancellationToken.None); Assert.Equal("mastodon", software); } [Fact] public async Task FediverseSoftware_NoSubdomainAnswers_ReturnsNull() { var validator = Validator(new StubHandler(new HttpResponseMessage(HttpStatusCode.NotFound))); var software = await validator.ResolveFediverseSoftwareAsync("silent.example", CancellationToken.None); Assert.Null(software); } [Fact] public async Task FediverseHandle_CanceledDuringNodeInfo_IsCanceled() { using var cts = new CancellationTokenSource(); var request = 0; var validator = Validator(new StubHandler(_ => { if (++request >= 2) cts.Cancel(); return new HttpResponseMessage(HttpStatusCode.OK); })); var result = await validator.LookupAsync(SocialService.Fediverse, "@user@site.tld", cts.Token); Assert.True(result.Canceled); Assert.False(result.Success); } private static Func RespondFediverse(string software) { return request => { if (request.RequestUri!.AbsolutePath.StartsWith("/.well-known")) { return Json(new { links = new[] { new { rel = "http://nodeinfo.diaspora.software/ns/schema/2.0", href = $"https://{request.RequestUri.Host}/nodeinfo/2.0", }, }, }); } if (request.RequestUri!.AbsolutePath.StartsWith("/nodeinfo")) { return Json(new { software = new { name = software, version = "1.0.0" } }); } return new HttpResponseMessage(HttpStatusCode.OK); }; } private static HttpResponseMessage Json(object payload) => new(HttpStatusCode.OK) { Content = new StringContent(System.Text.Json.JsonSerializer.Serialize(payload)) }; [Fact] public async Task KnownServiceHandle_CanonicalUrl() { var validator = Validator(new StubHandler(new HttpResponseMessage(HttpStatusCode.OK))); var result = await validator.LookupAsync(SocialService.X, "creator", CancellationToken.None); Assert.True(result.Success); Assert.Equal("https://x.com/creator", result.ProfileUrl); Assert.Equal("creator", result.Handle); } [Fact] public async Task NotFound_IsRejected() { var validator = Validator(new StubHandler(new HttpResponseMessage(HttpStatusCode.NotFound))); var result = await validator.LookupAsync(SocialService.X, "doesnotexist", CancellationToken.None); Assert.False(result.Success); Assert.Contains("NotFound", result.Error); } [Fact] public async Task Redirect_CountsAsExists() { var validator = Validator(new StubHandler(new HttpResponseMessage(HttpStatusCode.Found))); var result = await validator.LookupAsync(SocialService.GitHub, "dev", CancellationToken.None); Assert.True(result.Success); } [Fact] public async Task ConnectionFailure_IsFriendlyError() { var validator = Validator(new StubHandler(_ => throw new HttpRequestException("boom"))); var result = await validator.LookupAsync(SocialService.X, "user", CancellationToken.None); Assert.False(result.Success); Assert.Contains("Couldn't reach", result.Error); } [Fact] public async Task CanceledToken_AbortsRequest() { using var cts = new CancellationTokenSource(); cts.Cancel(); var validator = Validator(new StubHandler(new HttpResponseMessage(HttpStatusCode.OK))); var result = await validator.LookupAsync(SocialService.X, "creator", cts.Token); Assert.True(result.Canceled); Assert.False(result.Success); } }