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

summaryrefslogtreecommitdiffhomepage
path: root/apps
diff options
context:
space:
mode:
authorLeitwolf2026-10-03 20:12:23 +0300
committerGitHub2026-10-03 20:12:23 +0300
commit933244cc0fa149427d22d06be4d226c0a67e8b80 (patch)
tree048ae623f49cc5f2f94234d747351f9e659f5267 /apps
parentcf444eb3bccf99c10f59ed5330b61c0397e0aeaa (diff)
parentb84a2216c26cc5392d0dc300b7a605ccb288d8c8 (diff)
downloadprogmaweb-933244cc0fa149427d22d06be4d226c0a67e8b80.tar.gz
Merge pull request #27 from Progmasoft/fix/api-without-google
Run the account API without a Google client
Diffstat (limited to 'apps')
-rw-r--r--apps/api-tests/AccountApiFactory.cs19
-rw-r--r--apps/api-tests/UnconfiguredGoogleTests.cs97
-rw-r--r--apps/api/Program.cs20
3 files changed, 127 insertions, 9 deletions
diff --git a/apps/api-tests/AccountApiFactory.cs b/apps/api-tests/AccountApiFactory.cs
index 373cd84..89faa3e 100644
--- a/apps/api-tests/AccountApiFactory.cs
+++ b/apps/api-tests/AccountApiFactory.cs
@@ -7,19 +7,30 @@ using Microsoft.Extensions.Configuration;
namespace Progmasoft.Progmaweb.Api.Tests;
-internal sealed class AccountApiFactory : WebApplicationFactory<Program>
+/// <summary>Starts the account API in memory for a test.</summary>
+/// <param name="configureGoogle">
+/// Whether the Google client is configured. Without it the service has to run and refuse only Google sign-in.
+/// </param>
+internal sealed class AccountApiFactory(bool configureGoogle = true) : WebApplicationFactory<Program>
{
protected override void ConfigureWebHost(IWebHostBuilder builder)
{
builder.UseEnvironment("Testing");
+
+ // The program decides while it registers services whether the Google scheme exists, so these two values
+ // have to be host settings: configuration sources added below are applied only when the host is built,
+ // after that decision. An empty value also overrides credentials the environment of the test run carries.
+ builder.UseSetting(
+ "Authentication:Google:ClientId",
+ configureGoogle ? "test-client.apps.googleusercontent.com" : string.Empty);
+ builder.UseSetting("Authentication:Google:ClientSecret", configureGoogle ? "test-secret" : string.Empty);
+
builder.ConfigureAppConfiguration((_, configuration) =>
{
configuration.AddInMemoryCollection(new Dictionary<string, string?>
{
["AccountSessions:LifetimeHours"] = "2",
- ["AccountSessions:CookieName"] = "__Host-ProgmasoftSession",
- ["Authentication:Google:ClientId"] = "test-client.apps.googleusercontent.com",
- ["Authentication:Google:ClientSecret"] = "test-secret"
+ ["AccountSessions:CookieName"] = "__Host-ProgmasoftSession"
});
});
}
diff --git a/apps/api-tests/UnconfiguredGoogleTests.cs b/apps/api-tests/UnconfiguredGoogleTests.cs
new file mode 100644
index 0000000..d4e8c53
--- /dev/null
+++ b/apps/api-tests/UnconfiguredGoogleTests.cs
@@ -0,0 +1,97 @@
+// SPDX-FileCopyrightText: 2026 Progmasoft <support@progmasoft.com>
+// SPDX-License-Identifier: AGPL-3.0-or-later WITH AdditionRef-Progmasoft-Patent-Grant-1.1
+
+using System.Net;
+using System.Net.Http.Json;
+using Progmasoft.Progmaweb.Api.Accounts;
+
+namespace Progmasoft.Progmaweb.Api.Tests;
+
+/// <summary>
+/// The service without a Google client. It used to answer every request with 500, because the Google scheme was
+/// registered with empty credentials and failed its option validation in the authentication middleware.
+/// </summary>
+public sealed class UnconfiguredGoogleTests
+{
+ [Theory]
+ [InlineData("/api/v1/status", HttpStatusCode.OK)]
+ [InlineData("/health", HttpStatusCode.OK)]
+ [InlineData("/api/v1/accounts/me", HttpStatusCode.Unauthorized)]
+ public async Task OrdinaryRoutesAnswerWithoutGoogle(string path, HttpStatusCode expected)
+ {
+ await using AccountApiFactory factory = new(configureGoogle: false);
+ using HttpClient client = factory.CreateAccountClient();
+
+ HttpResponseMessage response = await client.GetAsync(path, TestContext.Current.CancellationToken);
+
+ Assert.Equal(expected, response.StatusCode);
+ }
+
+ [Fact]
+ public async Task PasswordAccountsWorkWithoutGoogle()
+ {
+ await using AccountApiFactory factory = new(configureGoogle: false);
+ using HttpClient client = factory.CreateAccountClient();
+ const string password = "a sufficiently long passphrase";
+
+ HttpResponseMessage registration = await client.PostAsJsonAsync(
+ "/api/v1/accounts/register",
+ new RegisterAccountRequest("LocalUser1", "local@example.com", password),
+ TestContext.Current.CancellationToken);
+ Assert.Equal(HttpStatusCode.Created, registration.StatusCode);
+
+ // The registration set the session cookie, so the same client is now signed in.
+ HttpResponseMessage me = await client.GetAsync("/api/v1/accounts/me", TestContext.Current.CancellationToken);
+ Assert.Equal(HttpStatusCode.OK, me.StatusCode);
+
+ HttpResponseMessage logout = await client.PostAsync(
+ "/api/v1/accounts/logout", null, TestContext.Current.CancellationToken);
+ Assert.Equal(HttpStatusCode.NoContent, logout.StatusCode);
+
+ HttpResponseMessage login = await client.PostAsJsonAsync(
+ "/api/v1/accounts/login",
+ new LoginAccountRequest("local@example.com", password),
+ TestContext.Current.CancellationToken);
+ Assert.Equal(HttpStatusCode.OK, login.StatusCode);
+ }
+
+ [Fact]
+ public async Task GoogleStartReportsThatSignInIsUnavailable()
+ {
+ await using AccountApiFactory factory = new(configureGoogle: false);
+ using HttpClient client = factory.CreateAccountClient(handleCookies: false);
+
+ HttpResponseMessage response = await client.GetAsync(
+ "/api/v1/accounts/oauth/google/start?accountName=GoogleUser",
+ TestContext.Current.CancellationToken);
+
+ Assert.Equal(HttpStatusCode.ServiceUnavailable, response.StatusCode);
+ Assert.Null(response.Headers.Location);
+ }
+
+ [Fact]
+ public async Task GoogleCompleteFailsClosedWithoutGoogle()
+ {
+ await using AccountApiFactory factory = new(configureGoogle: false);
+ using HttpClient client = factory.CreateAccountClient(handleCookies: false);
+
+ HttpResponseMessage response = await client.GetAsync(
+ "/api/v1/accounts/oauth/google/complete", TestContext.Current.CancellationToken);
+
+ Assert.Equal(HttpStatusCode.Redirect, response.StatusCode);
+ Assert.Equal("/login?google=failed", response.Headers.Location?.OriginalString);
+ Assert.False(response.Headers.Contains("Set-Cookie"));
+ }
+
+ [Fact]
+ public async Task GoogleCallbackIsNotARouteWithoutGoogle()
+ {
+ await using AccountApiFactory factory = new(configureGoogle: false);
+ using HttpClient client = factory.CreateAccountClient(handleCookies: false);
+
+ HttpResponseMessage response = await client.GetAsync(
+ "/api/v1/accounts/oauth/google/callback", TestContext.Current.CancellationToken);
+
+ Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
+ }
+}
diff --git a/apps/api/Program.cs b/apps/api/Program.cs
index 5f6a7a5..b5c8708 100644
--- a/apps/api/Program.cs
+++ b/apps/api/Program.cs
@@ -2,6 +2,7 @@
// SPDX-License-Identifier: AGPL-3.0-or-later WITH AdditionRef-Progmasoft-Patent-Grant-1.1
using System.Threading.RateLimiting;
+using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.AspNetCore.Authentication.Google;
using Microsoft.AspNetCore.HttpOverrides;
@@ -15,7 +16,7 @@ builder.Services.AddSingleton(TimeProvider.System);
builder.Services.AddSingleton<IAccountStore, InMemoryAccountStore>();
builder.Services.AddSingleton<AccountService>();
builder.Services.AddSingleton<SessionService>();
-builder.Services
+AuthenticationBuilder authentication = builder.Services
.AddAuthentication()
.AddCookie(AccountEndpoints.ExternalCookieScheme, options =>
{
@@ -25,15 +26,24 @@ builder.Services
options.Cookie.SameSite = SameSiteMode.Lax;
options.Cookie.SecurePolicy = CookieSecurePolicy.Always;
options.ExpireTimeSpan = TimeSpan.FromMinutes(10);
- })
- .AddGoogle(GoogleDefaults.AuthenticationScheme, options =>
+ });
+
+// The Google scheme exists only when its client is configured. Registered with empty credentials, its option
+// validation fails inside the authentication middleware and every request, not only Google sign-in, ends in 500.
+// Without the scheme the service runs normally and the Google start endpoint answers that sign-in is unavailable.
+string? googleClientId = builder.Configuration["Authentication:Google:ClientId"];
+string? googleClientSecret = builder.Configuration["Authentication:Google:ClientSecret"];
+if (!string.IsNullOrWhiteSpace(googleClientId) && !string.IsNullOrWhiteSpace(googleClientSecret))
+{
+ authentication.AddGoogle(GoogleDefaults.AuthenticationScheme, options =>
{
- options.ClientId = builder.Configuration["Authentication:Google:ClientId"] ?? string.Empty;
- options.ClientSecret = builder.Configuration["Authentication:Google:ClientSecret"] ?? string.Empty;
+ options.ClientId = googleClientId;
+ options.ClientSecret = googleClientSecret;
options.CallbackPath = "/api/v1/accounts/oauth/google/callback";
options.SignInScheme = AccountEndpoints.ExternalCookieScheme;
options.SaveTokens = false;
});
+}
builder.Services.AddProblemDetails();
builder.Services.AddHealthChecks();
builder.Services.AddRateLimiter(options =>