Files
LlamaCasty/ytLive.Tests/YouTubeAuthServiceTests.cs
T
gramps 44344c9327 fix: Change Account signed out BEFORE the browser opened
Reported 2026-09-27: click avatar -> Change Account -> cancel in the Google
chooser -> back to a signed-out app, then "cannot sign-in because the callback
url was not released".

Two independent defects:

1. ChangeAccountAsync ran `await SignOutYouTubeAsync(); await SignInAsync();`.
   SignOutYouTubeAsync -> TokenStore.Clear() -> deletes ytLlive.auth, so the
   working account's token was destroyed before the browser even opened. It is
   now just `await SignInAsync()`: success overwrites the token via the
   sessionChanged hook, cancel/failure keeps the current session. The explicit
   sign-out was never needed.

2. Abandoning the browser gives the app no signal, so the OAuth attempt held
   localhost:8765 for its full 5-minute timeout and a retry could not bind. A
   new AuthenticateAsync now cancels and awaits the abandoned attempt first.
   That takeover is serialised by a SemaphoreSlim: the first version cleared
   the in-flight marker before awaiting, so a caller arriving in that window saw
   a free port and raced to bind it -- reproducing the very failure it was
   written to prevent. My own test caught that; reasoning had "proved" it fine.

Also adds an `openBrowser` ctor seam so tests never shell out to a real
browser, and corrects the ai.md line that documented "Logout / Change Account ->
SignOutYouTubeAsync" as intended behaviour.

Test: YouTubeAuthServiceTests.RetryingAfterAnAbandonedAttempt_ReleasesTheCallbackPort
Full suite 370/370, 0 warnings. Coverage gap noted in HANDOFF.md: the ordering
fix has no automated test because _youtubeAuth is hardcoded in MainViewModel
and ChangeAccountAsync is private.

The reported cross-instance effect is NOT this cause and stays open -- no code
path can sign out a second instance (per-instance TokenStore, local delete, no
server-side revoke).
2026-09-27 16:02:05 -07:00

121 lines
4.9 KiB
C#

using System.Net;
using System.Text;
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<HttpResponseMessage> 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);
}
/// <summary>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.
/// <para>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.</para></summary>
[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.
}
}