Scope becomes youtube + yt-analytics.readonly, with include_granted_scopes=true so an existing session is asked for the NEW permission only. GrantedScopes records what Google says on the token response (both the code exchange and the refresh), and HasAnalyticsScope is the flag the scoreboard uses to offer a one-click re-consent instead of failing anonymously mid-stream. TokenStore is unchanged on purpose: a launch-time refresh carries the scope list, so old sessions report 'not granted' until the creator re-consents — the honest state. The old auth test asserted 'exactly one scope'; it now asserts the new DECISION (two scopes, no force-ssl, analytics read-only) rather than loosening.
156 lines
6.7 KiB
C#
156 lines
6.7 KiB
C#
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<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.
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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 <b>value</b> 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.
|
|
/// </summary>
|
|
[Fact]
|
|
public void TwoScopesAreRequested_TheBroadYouTubeOneAndReadOnlyAnalytics()
|
|
{
|
|
// Updated 2026-10-30: the scoreboard's two polled tiles need watch-time analytics, so the
|
|
// app asks for exactly one more scope. The DECISION worth locking is still "no redundant
|
|
// scopes": force-ssl stays gone, and analytics is read-only.
|
|
Assert.Equal(
|
|
new[]
|
|
{
|
|
"https://www.googleapis.com/auth/youtube",
|
|
"https://www.googleapis.com/auth/yt-analytics.readonly",
|
|
},
|
|
SourceAssert.Scopes("Scope", "Services", "YouTubeAuthService.cs"));
|
|
}
|
|
|
|
}
|