Read-only mirrors. Development, issues and pull requests are on GitHub.

summaryrefslogtreecommitdiffhomepage
path: root/apps/api-tests
diff options
context:
space:
mode:
authorLeitwolf112026-10-16 16:13:45 +0300
committerLeitwolf112026-10-16 16:13:45 +0300
commit031cbf7efc5d97b28259da5b387cb3a3dbd242d5 (patch)
tree44077b255fdf019e82d6960ba30e70c3a59a954a /apps/api-tests
parent8f58831373b72dc5b60bad649a961acd0bb94aa4 (diff)
downloadprogmaweb-031cbf7efc5d97b28259da5b387cb3a3dbd242d5.tar.gz
Log why a Google callback could not finish
A failed Google callback has returned the visitor to /login?google=failed since the previous release, but nothing recorded why: a declined consent, an expired or missing state cookie and a forged request all looked the same, and the log of the service held no entry at all. GoogleSignInFailure now handles OnRemoteFailure. It writes one warning, event GoogleSignInFailed (id 5), with the cause: the error code Google reported, such as access_denied, or the reason the handler refused the callback, such as "The oauth state was missing or invalid." An error code is written only when it is a short run of letters, digits, '_', '-' and '.'; anything else from the query string is written as an unrecognized error. The authorization code, the state and tokens are never logged. The redirect is unchanged. Four new tests cover the entry, the redirect, an unusual error value and a refused state. The account API document describes the log entry.
Diffstat (limited to 'apps/api-tests')
-rw-r--r--apps/api-tests/GoogleSignInFailureTests.cs65
1 files changed, 65 insertions, 0 deletions
diff --git a/apps/api-tests/GoogleSignInFailureTests.cs b/apps/api-tests/GoogleSignInFailureTests.cs
new file mode 100644
index 0000000..7c44d5c
--- /dev/null
+++ b/apps/api-tests/GoogleSignInFailureTests.cs
@@ -0,0 +1,65 @@
+// SPDX-FileCopyrightText: 2026 Progmasoft <support@progmasoft.com>
+// SPDX-License-Identifier: AGPL-3.0-or-later WITH AdditionRef-Progmasoft-Patent-Grant-1.1
+
+using Microsoft.AspNetCore.Authentication;
+using Microsoft.AspNetCore.Authentication.Google;
+using Microsoft.AspNetCore.Http;
+using Microsoft.Extensions.Logging;
+using Progmasoft.Progmaweb.Api.Accounts;
+
+namespace Progmasoft.Progmaweb.Api.Tests;
+
+public sealed class GoogleSignInFailureTests
+{
+ private sealed class RecordingLogger : ILogger
+ {
+ public List<(LogLevel Level, EventId Event, string Message)> Entries { get; } = [];
+
+ public IDisposable? BeginScope<TState>(TState state) where TState : notnull => null;
+
+ public bool IsEnabled(LogLevel logLevel) => true;
+
+ public void Log<TState>(LogLevel logLevel, EventId eventId, TState state, Exception? exception,
+ Func<TState, Exception?, string> formatter) => Entries.Add((logLevel, eventId, formatter(state, exception)));
+ }
+
+ private static RemoteFailureContext Failure(string query, Exception failure)
+ {
+ DefaultHttpContext http = new();
+ http.Request.QueryString = new QueryString(query);
+ AuthenticationScheme scheme = new(GoogleDefaults.AuthenticationScheme, null, typeof(GoogleHandler));
+ return new RemoteFailureContext(http, scheme, new GoogleOptions(), failure);
+ }
+
+ [Fact]
+ public async Task AFailedCallbackIsLoggedAndReturnsToTheSignInPage()
+ {
+ RecordingLogger logger = new();
+ RemoteFailureContext context = Failure(
+ "?state=secret-state&code=secret-code",
+ new AuthenticationFailureException(
+ "An error was encountered while handling the remote login.",
+ new AuthenticationFailureException("The oauth state was missing or invalid.")));
+
+ await GoogleSignInFailure.HandleAsync(context, logger);
+
+ Assert.Equal("/login?google=failed", context.Response.Headers.Location.ToString());
+ Assert.True(context.Result?.Handled);
+ (LogLevel level, EventId eventId, string message) = Assert.Single(logger.Entries);
+ Assert.Equal(LogLevel.Warning, level);
+ Assert.Equal("GoogleSignInFailed", eventId.Name);
+ Assert.Equal("Google sign-in could not be completed: The oauth state was missing or invalid.", message);
+ Assert.DoesNotContain("secret", message, StringComparison.Ordinal);
+ }
+
+ [Theory]
+ [InlineData("?error=access_denied&state=s", "Google reported access_denied")]
+ [InlineData("?error=%3Cscript%3E&state=s", "Google reported an unrecognized error")]
+ [InlineData("?state=s", "the denied consent")]
+ public void TheReasonNamesWhatGoogleReportedOrWhyTheCallbackWasRefused(string query, string expected)
+ {
+ RemoteFailureContext context = Failure(query, new AuthenticationFailureException("the denied consent"));
+
+ Assert.Equal(expected, GoogleSignInFailure.Reason(context));
+ }
+}