using System.IO; using System.Linq; using System.Net; using System.Text; using System.Text.RegularExpressions; using Xunit; using ytLive.Models; using ytLive.Services; namespace ytLive.Tests; public class YouTubeAuthServiceTests { private sealed class FakeHandler : HttpMessageHandler { private readonly string _tokenResponse; private readonly string _channelResponse; public FakeHandler(string tokenResponse, string channelResponse) { _tokenResponse = tokenResponse; _channelResponse = channelResponse; } protected override Task SendAsync( HttpRequestMessage request, CancellationToken cancellationToken) { var isToken = request.RequestUri!.PathAndQuery.Contains("/token"); var body = isToken ? _tokenResponse : _channelResponse; return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent(body, Encoding.UTF8, "application/json"), }); } } private static YouTubeAuthService CreateService(string tokenResponse, string channelResponse) => new("test-id", "test-secret", new HttpClient(new FakeHandler(tokenResponse, channelResponse))); [Fact] public async Task ExchangeCodeForToken_MockServer_ParsesChannel() { var service = CreateService( """{"access_token":"acc-123","refresh_token":"ref-123","expires_in":3600}""", """{"items":[{"id":"UC123","snippet":{"title":"Test Channel","thumbnails":{"default":{"url":"http://example/thumb.jpg"}}}}]}"""); var channel = await service.ExchangeCodeForToken("auth-code", "http://localhost:8765/oauth2/callback"); Assert.NotNull(channel); Assert.Equal("UC123", channel!.ChannelId); Assert.Equal("Test Channel", channel.DisplayName); Assert.Equal("http://example/thumb.jpg", channel.ProfileImageUrl); Assert.Equal("acc-123", channel.AccessToken); Assert.Equal("ref-123", channel.RefreshToken); Assert.InRange(channel.TokenExpiry, DateTime.UtcNow.AddSeconds(3590), DateTime.UtcNow.AddSeconds(3610)); } [Fact] public async Task RefreshToken_MockServer_UpdatesAccessTokenAndExpiry() { var service = CreateService( """{"access_token":"new-acc","expires_in":3600}""", """{"items":[]}"""); service.SetSession(new YouTubeChannel { RefreshToken = "ref-123", TokenExpiry = DateTime.UtcNow.AddMinutes(-5), }); var ok = await service.RefreshToken(); Assert.True(ok); Assert.Equal("new-acc", service.CurrentChannel!.AccessToken); Assert.InRange(service.CurrentChannel.TokenExpiry, DateTime.UtcNow.AddSeconds(3590), DateTime.UtcNow.AddSeconds(3610)); } [Fact] public void ClearSession_DropsCurrentChannel() { var service = CreateService("""{"access_token":"a","refresh_token":"r","expires_in":3600}""", """{"items":[]}"""); service.SetSession(new YouTubeChannel { DisplayName = "Test Channel" }); service.ClearSession(); Assert.Null(service.CurrentChannel); } /// Creator report 2026-09-27: clicking "Change Account", cancelling in the /// Google account chooser, then clicking again failed with "cannot sign-in because /// the callback url was not released". Abandoning the browser gives the app no /// signal, so the first attempt sat on the loopback callback port for its full /// 5-minute timeout and the second attempt could not bind. /// Requires nothing else to be listening on port 8765 — close a running /// LlamaCasty that happens to be mid-sign-in, or this test sees a genuine /// "address already in use" from the host. [Fact] public async Task RetryingAfterAnAbandonedAttempt_ReleasesTheCallbackPort() { var service = new YouTubeAuthService( "test-id", "test-secret", new HttpClient(new FakeHandler("{}", "{}")), openBrowser: _ => { }); // never shell out to a real browser from a test var first = service.AuthenticateAsync(); // The creator gives up on the chooser and clicks "Change Account" again. var second = service.AuthenticateAsync(); // The abandoned attempt must be released promptly rather than left holding the // port for its whole 5-minute timeout. If it weren't, this await would time out. Assert.Null(await first.WaitAsync(TimeSpan.FromSeconds(15))); // Taking over again proves `second` really did bind the port: had it not, // awaiting it would throw HttpListenerException ("address already in use"). _ = service.AuthenticateAsync(); Assert.Null(await second.WaitAsync(TimeSpan.FromSeconds(15))); // The last attempt is deliberately left pending — nothing can cancel it and the // test host exits before its 5-minute timer fires. } /// /// The app requests exactly ONE scope, and it is the broad one. 2026-09-28: /// `auth/youtube.force-ssl` was dropped as redundant — its grant ("see, edit, and /// permanently delete your videos, ratings, comments and captions") is a strict subset of /// `auth/youtube` ("Manage your YouTube account"), which is also the only scope that can /// cover live broadcast create/transition, so it was never avoidable. /// /// Asserted from source because the const is private and the auth flow cannot be exercised /// without a browser. What is worth locking in is the DECISION — that the redundant scope /// does not come back — not merely today's string. /// /// The assertion reads the const's value out of the source rather than scanning the /// file. A file-wide `DoesNotContain("force-ssl")` fails on this very file's doc comment, /// which names force-ssl precisely to explain why it went. A test that fails on the prose /// documenting the guarantee gets deleted instead of obeyed. /// [Fact] public void ExactlyOneScopeIsRequested_AndItIsTheBroadOne() { var source = File.ReadAllText(RepoFile("Services", "YouTubeAuthService.cs")); var match = Regex.Match(source, @"private const string Scope = ""([^""]*)"";"); Assert.True(match.Success, "could not find the Scope const in YouTubeAuthService.cs"); var scopes = match.Groups[1].Value.Split(' ', StringSplitOptions.RemoveEmptyEntries); Assert.Equal(new[] { "https://www.googleapis.com/auth/youtube" }, scopes); } private static string RepoFile(params string[] parts) { var dir = new DirectoryInfo(AppContext.BaseDirectory); while (dir != null) { var candidate = Path.Combine(new[] { dir.FullName }.Concat(parts).ToArray()); if (File.Exists(candidate)) return candidate; dir = dir.Parent; } throw new FileNotFoundException( $"could not locate {Path.Combine(parts)} above {AppContext.BaseDirectory}"); } }