yavsc/src/PostIt.Tests/Auth/OidcStubAuthority.cs
Paul Schneider afac287a18 feat(postit): circles+ACL UI, blog fixture→SQLite, seed default user
Bundled end-of-branch commit on feat/postit-acl-members.

PostIt UI for circles + per-post ACL
- Reorganise PostIt.Tests into Auth/ and Blogs/ subfolders
  (Bearer/OIDC scope tests vs. blog API fakes live where they
  belong) and introduces PostItHeadlessCollection so the
  Avalonia.Headless tests share a single xUnit collection
  instead of contending with the EF-Core test host.
- Adds BlogAclApiTests (a brand-new behavioural layer over
  POST /api/v1/blogacl) and the fakes it relies on
  (BlogApiTestFakes, BlogPostAuthorDtoTests, AddCircleMember
  DialogTests); pulls UserId-through-OIDC-sub path into
  BearerScopeTests / FakeAuthorizingBrowser /
  OidcStubAuthority.
- App.axaml.cs gets a small PushPageAsync touch-up the new
  tests rely on.
- Drops UnitTest1.cs (xUnit scaffold, never used).

Yavsc.Blogs.Tests — SQLite instead of InMemory
- Bumps Yavsc.Blogs.Tests.csproj on
  Microsoft.EntityFrameworkCore.Sqlite and rewrites
  BlogsWebServerFixture to hold a single shared
  SqliteConnection (Cache=Shared) for the fixture lifetime,
  with a sync Dispose close to dodge async teardown hangs.
  Reason: the EF Core InMemory provider silently ignores FKs,
  which masked the kind of bug we are about to pin in the
  ACL tests. SQLite enforces them, so any future INSERT that
  forgets to seed its parent rows fails loudly here instead
  of passing the test and breaking prod.
- PublishEndpointTests and BlogApiSmokeTests get a one-line
  tweak to follow the new connection lifecycle.

Foreign-key fallout: seed the default user in the fixture
- Adds BlogsWebServerFixture.SeedUser(userName). Now that
  SQLite enforces BlogPost.AuthorId → AspNetUsers.Id, every
  test that POST/PUT/DELETE a BlogPost and sends AuthorId=
  'tester' in the payload needs an AspNetUsers row to satisfy
  the FK or it returns 500 with SQLite Error 19.
- BlogApiTests wraps the existing ResetDatabase with a
  ResetAndSeedDefaultUser helper for the six mutating tests;
  the four GET-only and ModelState-only tests keep the bare
  ResetDatabase.
- Side benefit: every test in Yavsc.Blogs.Tests now finishes
  cleanly instead of hanging at teardown — previously a stuck
  test held the shared SqliteConnection open and the next
  tests waited indefinitely.

Verified: dotnet test src/Yavsc.Blogs.Tests passes 25/25
green from a clean run, no fixture teardown hang.
2026-08-20 23:59:21 +01:00

254 lines
8.5 KiB
C#

using System;
using System.Collections.Generic;
using System.IO;
using System.Net;
using System.Net.Sockets;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
namespace PostIt.Tests;
/// <summary>
/// Minimal in-process OIDC authority used by LoginPageViewModelTests.
/// It serves the discovery document, jwks, and a token endpoint that
/// accepts any authorization code and returns a signed RS256 JWT.
///
/// Designed to be used together with <see cref="FakeAuthorizingBrowser"/>:
/// the browser intercepts the authorize redirect, the server completes
/// the token exchange.
/// </summary>
public sealed class OIDCStubAuthority : IAsyncDisposable, IDisposable
{
private readonly HttpListener _listener;
private readonly RSA _rsa;
private readonly string _kid;
private readonly CancellationTokenSource _cts = new();
public string Issuer { get; }
public string LoopbackRedirectUri { get; }
private OIDCStubAuthority(HttpListener listener, RSA rsa, string kid, string issuer, string loopback)
{
_listener = listener;
_rsa = rsa;
_kid = kid;
Issuer = issuer;
LoopbackRedirectUri = loopback;
}
public static async Task<OIDCStubAuthority> StartAsync()
{
// Pick a free loopback port.
var port = GetFreePort();
var prefix = $"http://127.0.0.1:{port}/";
var loopback = "postit://callback"; // matches PostIt.Settings.DefaultLoopbackRedirectUri
var listener = new HttpListener();
listener.Prefixes.Add(prefix);
listener.Start();
var rsa = RSA.Create(2048);
var kid = "test-key-1";
var authority = new OIDCStubAuthority(listener, rsa, kid, prefix.TrimEnd('/'), loopback);
_ = Task.Run(() => authority.AcceptLoopAsync(authority._cts.Token));
return authority;
}
private async Task AcceptLoopAsync(CancellationToken ct)
{
while (!ct.IsCancellationRequested)
{
HttpListenerContext ctx;
try { ctx = await _listener.GetContextAsync().WaitAsync(ct); }
catch (OperationCanceledException) { return; }
catch (HttpListenerException) { return; }
try { await DispatchAsync(ctx); }
catch { /* swallow per-request */ }
}
}
private async Task DispatchAsync(HttpListenerContext ctx)
{
var path = ctx.Request.Url?.AbsolutePath ?? "/";
switch (path)
{
case "/.well-known/openid-configuration":
await WriteJsonAsync(ctx.Response, BuildDiscovery());
break;
case "/.well-known/jwks":
await WriteJsonAsync(ctx.Response, BuildJwks());
break;
case "/connect/token":
await HandleTokenAsync(ctx);
break;
case "/connect/userinfo":
await WriteJsonAsync(ctx.Response, new { sub = "test-user" });
break;
default:
ctx.Response.StatusCode = 404;
ctx.Response.Close();
break;
}
}
private Dictionary<string, object> BuildDiscovery() => new()
{
["issuer"] = Issuer,
["authorization_endpoint"] = $"{Issuer}/connect/authorize",
["token_endpoint"] = $"{Issuer}/connect/token",
["userinfo_endpoint"] = $"{Issuer}/connect/userinfo",
["jwks_uri"] = $"{Issuer}/.well-known/jwks",
["response_types_supported"] = new[] { "code" },
["subject_types_supported"] = new[] { "public" },
["id_token_signing_alg_values_supported"] = new[] { "RS256" },
["grant_types_supported"] = new[] { "authorization_code" },
["code_challenge_methods_supported"] = new[] { "S256" },
};
private Dictionary<string, object> BuildJwks()
{
var p = _rsa.ExportParameters(false);
return new Dictionary<string, object>
{
["keys"] = new[]
{
new Dictionary<string, object>
{
["kty"] = "RSA",
["use"] = "sig",
["alg"] = "RS256",
["kid"] = _kid,
["n"] = Base64UrlEncoder.Encode(p.Modulus!),
["e"] = Base64UrlEncoder.Encode(p.Exponent!),
}
}
};
}
private async Task HandleTokenAsync(HttpListenerContext ctx)
{
// Read form-encoded body.
string body;
using (var reader = new StreamReader(ctx.Request.InputStream, Encoding.UTF8))
body = await reader.ReadToEndAsync();
var form = ParseForm(body);
// We accept any code and don't validate PKCE on the stub side;
// the OidcClient itself validates the redirect_uri match.
var now = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
var claims = new Dictionary<string, object>
{
["iss"] = Issuer,
["sub"] = "test-user",
["aud"] = form.TryGetValue("client_id", out var cid) ? cid : "postit-tests",
["exp"] = now + 600,
["iat"] = now,
};
var accessToken = SignJwt(claims);
var refreshToken = Convert.ToBase64String(System.Security.Cryptography.RandomNumberGenerator.GetBytes(32))
.TrimEnd('=').Replace('+', '-').Replace('/', '_');
var response = new
{
access_token = accessToken,
id_token = accessToken,
refresh_token = refreshToken,
token_type = "Bearer",
expires_in = 600,
scope = form.TryGetValue("scope", out var s) ? s : "openid",
};
await WriteJsonAsync(ctx.Response, response);
}
private string SignJwt(Dictionary<string, object> claims)
{
var header = new Dictionary<string, object>
{
["alg"] = "RS256",
["typ"] = "JWT",
["kid"] = _kid,
};
var headerJson = JsonSerializer.Serialize(header);
var payloadJson = JsonSerializer.Serialize(claims);
var headerB64 = Base64UrlEncoder.Encode(Encoding.UTF8.GetBytes(headerJson));
var payloadB64 = Base64UrlEncoder.Encode(Encoding.UTF8.GetBytes(payloadJson));
var signingInput = $"{headerB64}.{payloadB64}";
var signature = _rsa.SignData(
Encoding.UTF8.GetBytes(signingInput),
HashAlgorithmName.SHA256,
RSASignaturePadding.Pkcs1);
return $"{signingInput}.{Base64UrlEncoder.Encode(signature)}";
}
private static Dictionary<string, string> ParseForm(string body)
{
var dict = new Dictionary<string, string>(StringComparer.Ordinal);
foreach (var pair in body.Split('&', StringSplitOptions.RemoveEmptyEntries))
{
var eq = pair.IndexOf('=');
if (eq < 0) continue;
var key = Uri.UnescapeDataString(pair[..eq]);
var val = Uri.UnescapeDataString(pair[(eq + 1)..]);
dict[key] = val;
}
return dict;
}
private static async Task WriteJsonAsync(HttpListenerResponse response, object payload)
{
response.ContentType = "application/json";
response.StatusCode = 200;
var bytes = Encoding.UTF8.GetBytes(JsonSerializer.Serialize(payload));
await response.OutputStream.WriteAsync(bytes);
response.Close();
}
private static int GetFreePort()
{
var l = new TcpListener(IPAddress.Loopback, 0);
l.Start();
var port = ((IPEndPoint)l.LocalEndpoint).Port;
l.Stop();
return port;
}
public async ValueTask DisposeAsync()
{
_cts.Cancel();
try { _listener.Stop(); } catch { }
_listener.Close();
_rsa.Dispose();
_cts.Dispose();
await Task.CompletedTask;
}
public void Dispose()
{
// Synchronous dispose: cancels the accept loop and tears down
// resources. The accept task will exit on its own once the
// listener is closed.
try { _cts.Cancel(); } catch { }
try { _listener.Stop(); } catch { }
try { _listener.Close(); } catch { }
try { _rsa.Dispose(); } catch { }
try { _cts.Dispose(); } catch { }
}
}
/// <summary>
/// Minimal base64url encoder (no padding). RFC 7515 §2.
/// </summary>
internal static class Base64UrlEncoder
{
public static string Encode(byte[] data)
{
return Convert.ToBase64String(data)
.TrimEnd('=')
.Replace('+', '-')
.Replace('/', '_');
}
}