Files
LlamaCasty/ytLive.Tests/YouTubeAuthServiceTests.cs
T
gramps b3edc2e42e Drop the redundant force-ssl scope, and reject the playlistItems "fix"
force-ssl is gone from the requested scope. Its grant ("see, edit, and
permanently delete your videos, ratings, comments and captions") is a strict
subset of auth/youtube's ("Manage your YouTube account"), and Google's own
liveBroadcasts.insert reference lists auth/youtube as an accepted ALTERNATIVE
to force-ssl, so every endpoint this app calls — channels, liveBroadcasts,
liveStreams, liveChat, playlistItems — is covered by the one we keep. Two
consent lines became one, and one fewer scope to justify. Removing a scope
cannot narrow capability when the scope being kept is the broader of the two,
and there is no forced re-consent: a refresh token keeps the scopes it was
originally granted, so existing sessions are untouched.

The playlistItems change is NOT being made, and the map entry recommending it
was wrong. It came out of the verification hunt on the theory that public data
should not ride on user credentials. Walking the code kills it: ChannelStats-
Service is built with one HttpClient, and channels?mine=true on the same
service genuinely needs the session. Splitting it means a second unauthenticated
client PLUS a Google API key, since every YouTube Data API request needs a
credential and the call 403s without one. So the fix would ship a new
extractable, quota-abusable credential inside the binary to replace a token the
app already holds, buying zero verification-surface reduction (the data is
public, no scope involved) and zero correctness. Strictly worse. Recorded as a
do-not-do so it is not "helpfully" reintroduced later.

Test: ExactlyOneScopeIsRequested_AndItIsTheBroadOne asserts on the const's
parsed VALUE rather than scanning the file — a file-wide DoesNotContain
fails on the doc comment that names force-ssl to explain its removal, which is
the same trap I hit and documented earlier today in the locator test. Suite
380/380, 0 warnings.
2026-09-28 09:45:35 -07:00

165 lines
7.0 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 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}");
}
}