Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
using BuildingBlocks.Helper;
using Marten;
using Microsoft.Extensions.DependencyInjection;
using Modgud.Api.Features.Admin.Apps;
using Modgud.Api.Tests.Infrastructure;
using Modgud.Application.DTOs.Applications;
using Modgud.Authorization.Apps;
Expand All @@ -13,25 +14,68 @@
namespace Modgud.Api.Tests.Authorization;

/// <summary>
/// ADR-0011 — admin API for per-Application settings overrides
/// (<c>GET</c>/<c>PATCH /api/app/{id}/settings</c>): patch→get roundtrip across
/// sections, the Origin write also populating the global host→App routing map,
/// validation, and cross-app subdomain uniqueness.
/// ADR-0011 — an App is ONE resource: the per-App settings override is carried on the
/// unified App endpoint (<c>POST</c>/<c>PUT</c>/<c>GET /api/app</c>), there is no separate
/// <c>/settings</c> endpoint. Covers the atomic create (App + settings in one transaction,
/// and an invalid settings section rejecting the whole create), the update roundtrip across
/// sections, the Origin write also populating the global host→App routing map, validation,
/// and cross-app subdomain uniqueness.
/// </summary>
[Collection(IntegrationTestCollection.Name)]
public class ApplicationSettingsAdminTests : IntegrationTestBase
{
public ApplicationSettingsAdminTests(SharedPostgresFixture fixture) : base(fixture) { }

private sealed record AppRead(string Id, string Slug, ApplicationSettingsDto? Settings);

[Fact]
public async Task Create_With_Settings_PersistsBoth_Atomically()
{
var ct = TestContext.Current.CancellationToken;
var settings = new ApplicationSettingsDto
{
NativeGrants = new ApplicationNativeGrantsDto { Enabled = true, AccessTokenLifetimeMinutes = 10 },
Branding = new ApplicationBrandingDto { ProductName = "Created Together" },
};

var resp = await Client.PostAsJsonAsync("/api/app",
new CreateAppDto("as-create-app", "As Create App", null, [], settings), JsonOptions, ct);
Assert.True(resp.IsSuccessStatusCode, $"POST failed ({(int)resp.StatusCode}): {await resp.Content.ReadAsStringAsync(ct)}");

var created = await resp.Content.ReadFromJsonAsync<AppRead>(JsonOptions, ct);
Assert.True(created!.Settings!.NativeGrants!.Enabled);
Assert.Equal("Created Together", created.Settings.Branding!.ProductName);

// Re-read confirms the override was committed alongside the App.
var got = await GetAppAsync(created.Id, ct);
Assert.Equal(10, got.Settings!.NativeGrants!.AccessTokenLifetimeMinutes);
}

[Fact]
public async Task Create_With_Invalid_Settings_RejectsWholeCreate()
{
var ct = TestContext.Current.CancellationToken;

// The App itself is valid, but the settings section is not → the whole create is
// rejected and NO App is left behind (one atomic transaction).
var resp = await Client.PostAsJsonAsync("/api/app",
new CreateAppDto("as-atomic-app", "As Atomic App", null, [],
new ApplicationSettingsDto { SelfRegistration = new ApplicationSelfRegistrationDto { Posture = "Nope" } }),
JsonOptions, ct);

Assert.Equal(HttpStatusCode.BadRequest, resp.StatusCode);
Assert.False(await AppExistsAsync("as-atomic-app"), "the App must not be persisted when its settings are invalid");
}

[Fact]
public async Task Patch_Then_Get_Roundtrips_And_Writes_The_Routing_Map()
public async Task Update_Settings_Roundtrips_And_Writes_The_Routing_Map()
{
var ct = TestContext.Current.CancellationToken;
var app = await CreateAppAsync("as-admin-app");
var app = await SeedAppAsync("as-admin-app");
var appShort = new ShortGuid(app.Id).ToString();
var host = $"as-admin.{await SystemPrimaryDomainAsync()}";

var patch = new ApplicationSettingsDto
var settings = new ApplicationSettingsDto
{
SelfRegistration = new ApplicationSelfRegistrationDto { Posture = "Off", Enabled = true },
NativeGrants = new ApplicationNativeGrantsDto { Enabled = true, AccessTokenLifetimeMinutes = 10 },
Expand All @@ -41,12 +85,12 @@ public async Task Patch_Then_Get_Roundtrips_And_Writes_The_Routing_Map()
RegistrationFields = new ApplicationRegistrationFieldsDto { Firstname = "Required", Lastname = "Off" },
Origin = new ApplicationOriginDto { Subdomain = host },
};
var patchResp = await Client.PatchAsJsonAsync($"/api/app/{appShort}/settings", patch, JsonOptions, ct);
Assert.True(patchResp.IsSuccessStatusCode,
$"PATCH failed ({(int)patchResp.StatusCode}): {await patchResp.Content.ReadAsStringAsync(ct)}");
var putResp = await Client.PutAsJsonAsync($"/api/app/{appShort}",
new UpdateAppDto("As Admin App", null, [], settings), JsonOptions, ct);
Assert.True(putResp.IsSuccessStatusCode,
$"PUT failed ({(int)putResp.StatusCode}): {await putResp.Content.ReadAsStringAsync(ct)}");

var got = await (await Client.GetAsync($"/api/app/{appShort}/settings", ct))
.Content.ReadFromJsonAsync<ApplicationSettingsDto>(JsonOptions, ct);
var got = (await GetAppAsync(appShort, ct)).Settings;
Assert.Equal("Off", got!.SelfRegistration!.Posture);
Assert.True(got.SelfRegistration.Enabled);
Assert.True(got.NativeGrants!.Enabled);
Expand All @@ -69,48 +113,51 @@ public async Task Patch_Then_Get_Roundtrips_And_Writes_The_Routing_Map()
}

[Fact]
public async Task Patch_Rejects_Invalid_Values()
public async Task Update_Rejects_Invalid_Values()
{
var ct = TestContext.Current.CancellationToken;
var app = await CreateAppAsync("as-invalid-app");
var app = await SeedAppAsync("as-invalid-app");
var appShort = new ShortGuid(app.Id).ToString();

// Bad posture.
Assert.Equal(HttpStatusCode.BadRequest, (await Client.PatchAsJsonAsync($"/api/app/{appShort}/settings",
new ApplicationSettingsDto { SelfRegistration = new ApplicationSelfRegistrationDto { Posture = "Nope" } },
JsonOptions, ct)).StatusCode);
Assert.Equal(HttpStatusCode.BadRequest, (await PutSettingsAsync(appShort,
new ApplicationSettingsDto { SelfRegistration = new ApplicationSelfRegistrationDto { Posture = "Nope" } }, ct)).StatusCode);

// Subdomain not under the realm's primary domain.
Assert.Equal(HttpStatusCode.BadRequest, (await Client.PatchAsJsonAsync($"/api/app/{appShort}/settings",
new ApplicationSettingsDto { Origin = new ApplicationOriginDto { Subdomain = "evil.example.com" } },
JsonOptions, ct)).StatusCode);
Assert.Equal(HttpStatusCode.BadRequest, (await PutSettingsAsync(appShort,
new ApplicationSettingsDto { Origin = new ApplicationOriginDto { Subdomain = "evil.example.com" } }, ct)).StatusCode);

// Out-of-bounds token lifetime.
Assert.Equal(HttpStatusCode.BadRequest, (await Client.PatchAsJsonAsync($"/api/app/{appShort}/settings",
new ApplicationSettingsDto { NativeGrants = new ApplicationNativeGrantsDto { AccessTokenLifetimeMinutes = 9999 } },
JsonOptions, ct)).StatusCode);
Assert.Equal(HttpStatusCode.BadRequest, (await PutSettingsAsync(appShort,
new ApplicationSettingsDto { NativeGrants = new ApplicationNativeGrantsDto { AccessTokenLifetimeMinutes = 9999 } }, ct)).StatusCode);
}

[Fact]
public async Task Subdomain_Is_Unique_Across_Apps()
{
var ct = TestContext.Current.CancellationToken;
var app1 = await CreateAppAsync("as-uniq-1");
var app2 = await CreateAppAsync("as-uniq-2");
var app1 = await SeedAppAsync("as-uniq-1");
var app2 = await SeedAppAsync("as-uniq-2");
var host = $"as-uniq.{await SystemPrimaryDomainAsync()}";

var first = await Client.PatchAsJsonAsync($"/api/app/{new ShortGuid(app1.Id)}/settings",
new ApplicationSettingsDto { Origin = new ApplicationOriginDto { Subdomain = host } }, JsonOptions, ct);
var first = await PutSettingsAsync(new ShortGuid(app1.Id).ToString(),
new ApplicationSettingsDto { Origin = new ApplicationOriginDto { Subdomain = host } }, ct);
Assert.True(first.IsSuccessStatusCode);

var second = await Client.PatchAsJsonAsync($"/api/app/{new ShortGuid(app2.Id)}/settings",
new ApplicationSettingsDto { Origin = new ApplicationOriginDto { Subdomain = host } }, JsonOptions, ct);
var second = await PutSettingsAsync(new ShortGuid(app2.Id).ToString(),
new ApplicationSettingsDto { Origin = new ApplicationOriginDto { Subdomain = host } }, ct);
Assert.Equal(HttpStatusCode.Conflict, second.StatusCode);
}

// ── Helpers ──────────────────────────────────────────────────────────────

private async Task<App> CreateAppAsync(string slug)
private Task<HttpResponseMessage> PutSettingsAsync(string appShort, ApplicationSettingsDto settings, CancellationToken ct) =>
Client.PutAsJsonAsync($"/api/app/{appShort}", new UpdateAppDto(appShort, null, [], settings), JsonOptions, ct);

private async Task<AppRead> GetAppAsync(string appShort, CancellationToken ct) =>
(await (await Client.GetAsync($"/api/app/{appShort}", ct)).Content.ReadFromJsonAsync<AppRead>(JsonOptions, ct))!;

private async Task<App> SeedAppAsync(string slug)
{
using var scope = Factory.Services.CreateScope();
var session = scope.ServiceProvider.GetRequiredService<IDocumentSession>();
Expand All @@ -121,6 +168,14 @@ private async Task<App> CreateAppAsync(string slug)
return (await session.LoadAsync<App>(id, TestContext.Current.CancellationToken))!;
}

private async Task<bool> AppExistsAsync(string slug)
{
using var scope = Factory.Services.CreateScope();
var session = scope.ServiceProvider.GetRequiredService<IDocumentSession>();
return await session.Query<App>().Where(a => a.Slug == slug && !a.IsDeleted)
.AnyAsync(TestContext.Current.CancellationToken);
}

private async Task<string> SystemPrimaryDomainAsync()
{
var globalStore = Factory.Services.GetRequiredService<IGlobalStore>();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -166,21 +166,28 @@ public async Task InviteCode_ConfirmedUser_With_Code_Is_Plain_Login_Code_Untouch
}

[Fact]
public async Task InviteCode_Posture_RoundTrips_Through_Settings_Endpoint()
public async Task InviteCode_Posture_RoundTrips_Through_App_Resource()
{
// Gate 1 — the new posture value survives PATCH → GET on the per-App
// settings surface (sparse, zero-migration).
// Gate 1 — the new posture value survives a unified App update → GET on the
// single App resource (settings carried inline; sparse, zero-migration).
var ct = TestContext.Current.CancellationToken;
var app = await CreateAppAsync("adr12-posture");
var appShort = new ShortGuid(app.Id).ToString();

var patch = await Client.PatchAsJsonAsync(
$"/api/app/{appShort}/settings",
new { SelfRegistration = new { Posture = "InviteCode" } }, ct);
Assert.Equal(HttpStatusCode.OK, patch.StatusCode);

var got = await Client.GetFromJsonAsync<JsonElement>($"/api/app/{appShort}/settings", JsonOptions, ct);
Assert.Equal("InviteCode", got.GetProperty("SelfRegistration").GetProperty("Posture").GetString());
var put = await Client.PutAsJsonAsync(
$"/api/app/{appShort}",
new
{
DisplayName = "adr12-posture",
Description = (string?)null,
Permissions = Array.Empty<object>(),
Settings = new { SelfRegistration = new { Posture = "InviteCode" } },
}, ct);
Assert.Equal(HttpStatusCode.OK, put.StatusCode);

var got = await Client.GetFromJsonAsync<JsonElement>($"/api/app/{appShort}", JsonOptions, ct);
Assert.Equal("InviteCode",
got.GetProperty("Settings").GetProperty("SelfRegistration").GetProperty("Posture").GetString());
}

[Fact]
Expand Down
64 changes: 63 additions & 1 deletion src/dotnet/Modgud.Api/Features/Admin/Apps/AppAdminService.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
using ErrorOr;
using Marten;
using Modgud.Application.DTOs.Applications;
using Modgud.Authentication.Applications;
using Modgud.Authorization.Apps;
using Modgud.Authorization.Events;
using Modgud.Domain.OAuth.Apis;
Expand All @@ -14,8 +16,16 @@ namespace Modgud.Api.Features.Admin.Apps;
/// endpoint maps it to HTTP while the applier consumes it directly. The injected
/// <see cref="IDocumentSession"/> is tenant-scoped, so a call lands in whatever realm
/// the ambient <c>TenantContext</c> selects.
///
/// <para>An App is ONE resource: when <see cref="CreateAppDto.Settings"/> /
/// <see cref="UpdateAppDto.Settings"/> is supplied, the per-App ADR-0011 settings override
/// is written in the SAME tenant transaction as the App aggregate (atomic). The only piece
/// that cannot be — the <c>Origin</c> subdomain, which drives the GLOBAL host→App routing
/// map in a different database — is validated up-front (so an invalid subdomain rejects the
/// whole call before any commit) and its routing applied right after the atomic commit. The
/// applier passes no settings, so its behaviour is unchanged.</para>
/// </summary>
public sealed class AppAdminService(IDocumentSession session)
public sealed class AppAdminService(IDocumentSession session, IApplicationSettingsService settingsSvc)
{
public async Task<ErrorOr<App>> CreateAppAsync(CreateAppDto dto, CancellationToken ct = default)
{
Expand Down Expand Up @@ -46,8 +56,19 @@ public async Task<ErrorOr<App>> CreateAppAsync(CreateAppDto dto, CancellationTok
Description: dto.Description,
Permissions: permissions.Value,
IsSystem: false));

// App + its settings override commit together (atomic) — see class summary.
if (dto.Settings is not null)
{
var staged = await StageSettingsAsync(id, dto.Settings, ct);
if (staged.IsError) return staged.Errors;
}

await session.SaveChangesAsync(ct);

var originApplied = await ApplyOriginIfAnyAsync(id, dto.Settings, ct);
if (originApplied.IsError) return originApplied.Errors;

return (await session.LoadAsync<App>(id, ct))!;
}

Expand Down Expand Up @@ -97,11 +118,52 @@ public async Task<ErrorOr<App>> UpdateAppAsync(Guid id, UpdateAppDto dto, Cancel

session.Events.Append(id, new AppUpdatedEvent(
id, dto.DisplayName, dto.Description, permissions.Value));

// App + its settings override commit together (atomic) — see class summary.
if (dto.Settings is not null)
{
var staged = await StageSettingsAsync(id, dto.Settings, ct);
if (staged.IsError) return staged.Errors;
}

await session.SaveChangesAsync(ct);

var originApplied = await ApplyOriginIfAnyAsync(id, dto.Settings, ct);
if (originApplied.IsError) return originApplied.Errors;

return (await session.LoadAsync<App>(id, ct))!;
}

/// <summary>
/// Validates the Origin subdomain up-front (so an invalid one rejects the whole
/// create/update before any commit) and stages every other settings section onto the
/// shared session — committed atomically with the App by the caller's SaveChangesAsync.
/// </summary>
private async Task<ErrorOr<Success>> StageSettingsAsync(Guid appId, ApplicationSettingsDto settings, CancellationToken ct)
{
if (settings.Origin is not null)
{
var validOrigin = await settingsSvc.ValidateOriginAsync(appId, settings.Origin.Subdomain, ct);
if (validOrigin.IsError) return validOrigin.Errors;
}
var staged = await settingsSvc.StageNonOriginAsync(appId, settings, ct);
return staged.IsError ? staged.Errors : Result.Success;
}

/// <summary>
/// Applies the Origin subdomain → GLOBAL host routing AFTER the atomic tenant commit
/// (the routing map lives in a different database, so it can't be in the tenant
/// transaction). Already validated in <see cref="StageSettingsAsync"/>, so this only
/// fails on a race or infrastructure error — and then the App + its other settings are
/// already persisted (a valid state; only the subdomain route is missing).
/// </summary>
private async Task<ErrorOr<Success>> ApplyOriginIfAnyAsync(Guid appId, ApplicationSettingsDto? settings, CancellationToken ct)
{
if (settings?.Origin is null) return Result.Success;
var r = await settingsSvc.PatchAsync(appId, new ApplicationSettingsDto { Origin = settings.Origin }, ct);
return r.IsError ? r.Errors : Result.Success;
}

/// <summary>
/// The single canonical delete path for an <see cref="App"/>, shared by
/// <see cref="AppsEndpoints"/> and the realm-provisioning applier's prune. Refuses the
Expand Down

This file was deleted.

Loading
Loading