diff --git a/.gitignore b/.gitignore index bcd92b8..e73010c 100644 --- a/.gitignore +++ b/.gitignore @@ -14,3 +14,5 @@ ui/test-results/ ui/playwright-report/ src/EzOdata.Host/wwwroot/ .DS_Store +deploy/compose/specs/* +!deploy/compose/specs/.gitkeep diff --git a/deploy/compose/demo.sh b/deploy/compose/demo.sh new file mode 100755 index 0000000..2747f80 --- /dev/null +++ b/deploy/compose/demo.sh @@ -0,0 +1,100 @@ +#!/usr/bin/env bash +# Beat 2 of the demo: two unlike engines, one identical API contract. +# +# docker compose -f docker-compose.minimal.yml -f docker-compose.demo.yml up -d --build +# ./demo.sh +set -euo pipefail +API=http://localhost:8080 +EMAIL=admin@example.com +PASS='a-strong-password-1' + +say() { printf '\n\033[1;36m== %s\033[0m\n' "$*"; } + +say "Wait for the platform" +for i in $(seq 1 60); do curl -fsS "$API/health" >/dev/null 2>&1 && break || sleep 2; done + +say "First-run setup (ignore 409 if already done)" +curl -fsS -X POST "$API/system/setup" -H 'Content-Type: application/json' \ + -d "{\"email\":\"$EMAIL\",\"displayName\":\"Admin\",\"password\":\"$PASS\"}" >/dev/null 2>&1 || true + +say "Log in" +TOKEN=$(curl -fsS -X POST "$API/system/auth/login" -H 'Content-Type: application/json' \ + -d "{\"email\":\"$EMAIL\",\"password\":\"$PASS\"}" | jq -r .accessToken) +AUTH=(-H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json') + +say "Register service 1 of 2 -> PostgreSQL" +curl -fsS -X POST "$API/system/services" "${AUTH[@]}" -d '{ + "name":"sales-pg","label":"Sales (PostgreSQL)","connectorType":"postgresql", + "connection":{"host":"sampledb","port":5432,"database":"northwind", + "username":"demo","password":"demo-pw","tls":{"mode":"disable"}}}' \ + | jq -c '{name,connectorType,status}' 2>/dev/null || echo ' (already registered)' + +say "Register service 2 of 2 -> MySQL (different engine, same platform)" +curl -fsS -X POST "$API/system/services" "${AUTH[@]}" -d '{ + "name":"sales-my","label":"Sales (MySQL)","connectorType":"mysql", + "connection":{"host":"sampledb-mysql","port":3306,"database":"northwind", + "username":"demo","password":"demo-pw","tls":{"mode":"disable"}}}' \ + | jq -c '{name,connectorType,status}' 2>/dev/null || echo ' (already registered)' + +say "Refresh schema on both (introspection) — wait for Active" +for svc in sales-pg sales-my; do + ID=$(curl -fsS "$API/system/services" "${AUTH[@]}" | jq -r --arg n "$svc" '.[]|select(.name==$n)|.id') + curl -fsS -X POST "$API/system/services/$ID/refresh" "${AUTH[@]}" >/dev/null 2>&1 || true + ST="" + for i in $(seq 1 40); do + ST=$(curl -fsS "$API/system/services/$ID" "${AUTH[@]}" | jq -r .status) + case "$ST" in Active|Failed) break;; esac + sleep 2 + done + printf ' %-9s id=%-3s status=%s\n' "$svc" "$ID" "$ST" +done + +say "THE POINT: byte-for-byte identical OData query against both engines" +for svc in sales-pg sales-my; do + printf '\n--- %s ---\n' "$svc" + curl -fsS -G "$API/api/odata/$svc/customers" "${AUTH[@]}" \ + --data-urlencode '$filter=country eq '"'"'US'"'"'' \ + --data-urlencode '$select=id,name,country' \ + --data-urlencode '$orderby=id' \ + --data-urlencode '$top=3' | jq -c '.value' +done + +say "Same data over REST, and each service publishes OpenAPI 3.1" +for svc in sales-pg sales-my; do + printf ' %-9s rest: %s | openapi paths: %s\n' "$svc" \ + "$(curl -fsS -G "$API/api/rest/$svc/_table/customers" "${AUTH[@]}" --data-urlencode 'limit=1' 2>/dev/null | jq -c '.resource[0].name' 2>/dev/null || echo n/a)" \ + "$(curl -fsS "$API/api/odata/$svc/openapi.json" "${AUTH[@]}" 2>/dev/null | jq '.paths|length' 2>/dev/null || echo n/a)" +done + +say "Beat 3: interrogate the data — discovery endpoints" +SVC=sales-pg +printf ' tables : %s\n' "$(curl -fsS "$API/api/odata/$SVC/" "${AUTH[@]}" | jq -c '[.value[].name]')" +printf ' $metadata : %s bytes of CSDL\n' "$(curl -fsS "$API/api/odata/$SVC/\$metadata" "${AUTH[@]}" | wc -c | tr -d ' ')" +printf ' customers : %s\n' "$(curl -fsS "$API/api/rest/$SVC/_table/customers/_schema" "${AUTH[@]}" | jq -c '[.fields[]|"\(.name):\(.type)"]')" +printf ' PII flagged : %s\n' "$(curl -fsS "$API/api/rest/$SVC/_table/customers/_schema" "${AUTH[@]}" | jq -c '[.fields[]|select(.description!=null)|{name,description}]')" + +say "Beat 3b: the same data over MCP (JSON-RPC, for LLM clients)" +ROLE=$(curl -fsS "$API/system/roles" "${AUTH[@]}" | jq -r '.[0].id') +curl -fsS -X POST "$API/system/apps" "${AUTH[@]}" \ + -d "{\"name\":\"mcp-explorer\",\"description\":\"demo\",\"roleId\":$ROLE,\"isActive\":true,\"mcpEnabled\":true}" \ + >/dev/null 2>&1 || true +APPID=$(curl -fsS "$API/system/apps" "${AUTH[@]}" | jq -r '.[]|select(.name=="mcp-explorer")|.id') +# The full key is returned exactly once, so mint a fresh one each run. +KEY=$(curl -fsS -X POST "$API/system/apps/$APPID/keys" "${AUTH[@]}" -d '{"name":"demo"}' | jq -r .key) +MCP=(-H "X-API-Key: $KEY" -H 'Content-Type: application/json') +printf ' tools for key : %s\n' "$(curl -fsS "$API/mcp/health" -H "X-API-Key: $KEY" | jq -c .toolsAvailableForKey)" +printf ' tools : %s\n' "$(curl -fsS -X POST "$API/mcp" "${MCP[@]}" \ + -d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}' | jq -c '[.result.tools[].name]')" +printf ' sales-my_query: %s\n' "$(curl -fsS -X POST "$API/mcp" "${MCP[@]}" \ + -d '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"sales-my_query","arguments":{"table":"customers","filter":"country='"'"'US'"'"'","fields":["id","name"],"limit":3}}}' \ + | jq -c '.result.content[0].text|fromjson|.rows')" + +say "Beat 3c: browsable OpenAPI (Swagger UI on :8081)" +mkdir -p "$(dirname "$0")/specs" +for svc in sales-pg sales-my; do + for kind in odata rest; do + curl -fsS "$API/api/$kind/$svc/openapi.json" "${AUTH[@]}" > "$(dirname "$0")/specs/$kind-$svc.json" + done +done +printf ' exported %s specs -> ./specs/\n' "$(ls "$(dirname "$0")"/specs/*.json | wc -l | tr -d ' ')" +printf ' open: http://localhost:8081 ("Try it out" works — CORS allows :8081)\n' diff --git a/deploy/compose/docker-compose.demo.yml b/deploy/compose/docker-compose.demo.yml new file mode 100644 index 0000000..7e0be25 --- /dev/null +++ b/deploy/compose/docker-compose.demo.yml @@ -0,0 +1,77 @@ +# Demo overlay on the MINIMAL profile (SQLite system DB = the EF-migration path that +# is actually tested; the Postgres system-DB path uses EnsureCreated and currently +# fails on a SQLite-style filtered index). +# +# Adds BOTH sample engines as connectable services — that is the demo's point: +# two unlike backends, one identical API contract. +# +# docker compose -f docker-compose.minimal.yml -f docker-compose.demo.yml up -d --build +# ./demo.sh +services: + ez-odata-api: + # image runs as non-root ($APP_UID) but the named /data volume is root-owned, + # so SQLite cannot create its file. Demo-only workaround. + user: "root" + environment: + # base compose ships a 31-byte master key; the host requires exactly 32 + Encryption__MasterKey: "Y29tcG9zZS1kZXYtbWFzdGVyLWtleS0zMmJ5dGVzISE=" + Mcp__Enabled: "true" + # Preflight is anonymous (no X-API-Key), so Swagger UI's origin has to be on the + # instance default list for "Try it out" to work (spec 08 §7). + Cors__DefaultAllowedOrigins__0: "http://localhost:8081" + depends_on: + sampledb: + condition: service_healthy + sampledb-mysql: + condition: service_healthy + + sampledb: + image: postgres:16-alpine + environment: + POSTGRES_DB: northwind + POSTGRES_USER: demo + POSTGRES_PASSWORD: demo-pw + ports: + - "55432:5432" + volumes: + - ../../tests/EzOdata.ConformanceTests/Fixtures/northwind.sql:/docker-entrypoint-initdb.d/01-northwind.sql:ro + healthcheck: + test: ["CMD-SHELL", "pg_isready -U demo -d northwind"] + interval: 5s + timeout: 3s + retries: 20 + + sampledb-mysql: + image: mariadb:11 # MySQL 8.4 defaults to caching_sha2_password, which the + # connector cannot negotiate without AllowPublicKeyRetrieval + environment: + MYSQL_DATABASE: northwind + MYSQL_USER: demo + MYSQL_PASSWORD: demo-pw + MARIADB_ROOT_PASSWORD: root-pw + MYSQL_ROOT_PASSWORD: root-pw + ports: + - "53306:3306" + volumes: + - ../../js/test/conformance/fixtures/northwind-mysql.sql:/docker-entrypoint-initdb.d/01-northwind.sql:ro + healthcheck: + test: ["CMD-SHELL", "mariadb-admin ping -h 127.0.0.1 -uroot -proot-pw || mysqladmin ping -h 127.0.0.1 -uroot -proot-pw"] + interval: 5s + timeout: 3s + retries: 20 + + # Spec browser for the demo. The API requires auth on openapi.json, so Swagger UI + # cannot fetch it directly; ./demo.sh exports the four specs into ./specs and + # nginx serves them from there. Browsing works; "Try it out" needs CORS on the API. + swagger-ui: + image: swaggerapi/swagger-ui + ports: + - "8081:8080" + environment: + URLS: | + [{"url":"/specs/odata-sales-pg.json","name":"OData - sales-pg (PostgreSQL)"}, + {"url":"/specs/odata-sales-my.json","name":"OData - sales-my (MySQL)"}, + {"url":"/specs/rest-sales-pg.json","name":"REST - sales-pg (PostgreSQL)"}, + {"url":"/specs/rest-sales-my.json","name":"REST - sales-my (MySQL)"}] + volumes: + - ./specs:/usr/share/nginx/html/specs:ro diff --git a/deploy/compose/specs/.gitkeep b/deploy/compose/specs/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/src/EzOdata.Admin/Auth/AuthService.cs b/src/EzOdata.Admin/Auth/AuthService.cs index 9974fac..5faf119 100644 --- a/src/EzOdata.Admin/Auth/AuthService.cs +++ b/src/EzOdata.Admin/Auth/AuthService.cs @@ -170,6 +170,7 @@ private static string GenerateOpaqueToken() => Convert.ToBase64String(RandomNumberGenerator.GetBytes(48)) .Replace('+', '-').Replace('/', '_').TrimEnd('='); - internal static string Sha256(string value) => + /// Key/token hash format, shared with CORS origin resolution in the host. + public static string Sha256(string value) => Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(value))).ToLowerInvariant(); } diff --git a/src/EzOdata.Host/Middleware/CorsOptions.cs b/src/EzOdata.Host/Middleware/CorsOptions.cs new file mode 100644 index 0000000..88d44f7 --- /dev/null +++ b/src/EzOdata.Host/Middleware/CorsOptions.cs @@ -0,0 +1,13 @@ +namespace EzOdata.Host.Middleware; + +/// spec 08 §7 / spec 12 §4 — instance-level CORS defaults. +public sealed class CorsOptions +{ + public const string SectionPath = "Cors"; + + /// + /// Origins allowed when the request presents no API key — notably browser preflight, + /// which never carries custom headers (spec 08 §7). + /// + public string[] DefaultAllowedOrigins { get; set; } = []; +} diff --git a/src/EzOdata.Host/Middleware/DynamicCorsMiddleware.cs b/src/EzOdata.Host/Middleware/DynamicCorsMiddleware.cs new file mode 100644 index 0000000..0a348d9 --- /dev/null +++ b/src/EzOdata.Host/Middleware/DynamicCorsMiddleware.cs @@ -0,0 +1,181 @@ +using System.Text.Json; +using EzOdata.Admin.Auth; +using EzOdata.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Caching.Memory; +using Microsoft.Extensions.Options; + +namespace EzOdata.Host.Middleware; + +/// +/// Default-deny CORS (spec 08 §7). The allowed-origin list is dynamic — it comes from the +/// App row behind the presented API key — so this replaces the static UseCors policy. +/// Preflight carries no custom headers, so an unkeyed request falls back to the +/// instance-level Cors:DefaultAllowedOrigins list. +/// Runs before authentication: a preflight must succeed while still anonymous. +/// +public sealed class DynamicCorsMiddleware +{ + private static readonly TimeSpan CacheTtl = TimeSpan.FromSeconds(60); + + // OData + RateLimit headers must be readable by browser clients (spec 08 §7). + private const string ExposedHeaders = + "OData-Version,RateLimit-Limit,RateLimit-Remaining,RateLimit-Reset,Retry-After,X-Request-Id"; + + private const string AllowedMethods = "GET,POST,PATCH,PUT,DELETE,OPTIONS"; + private const string FallbackAllowedHeaders = "Authorization,Content-Type,X-API-Key,Prefer"; + + private readonly RequestDelegate _next; + private readonly IMemoryCache _cache; + private readonly IServiceScopeFactory _scopeFactory; + private readonly CorsOptions _options; + + public DynamicCorsMiddleware( + RequestDelegate next, IMemoryCache cache, IServiceScopeFactory scopeFactory, IOptions options) + { + _next = next; + _cache = cache; + _scopeFactory = scopeFactory; + _options = options.Value; + } + + public async Task InvokeAsync(HttpContext context) + { + var origin = context.Request.Headers.Origin.ToString(); + if (string.IsNullOrEmpty(origin)) + { + await _next(context); // same-origin or non-browser: nothing to negotiate + return; + } + + var allowed = await ResolveAllowedOriginsAsync(context); + var isPreflight = HttpMethods.IsOptions(context.Request.Method) && + !string.IsNullOrEmpty(context.Request.Headers.AccessControlRequestMethod); + + if (!Matches(origin, allowed)) + { + // Default deny: emit no CORS headers. The browser blocks it; a preflight + // is still answered 204 so the failure surfaces as CORS, not as a 401. + if (isPreflight) + { + context.Response.StatusCode = StatusCodes.Status204NoContent; + return; + } + + await _next(context); + return; + } + + var headers = context.Response.Headers; + headers.AccessControlAllowOrigin = origin; + headers.Append("Vary", "Origin"); + headers.AccessControlExposeHeaders = ExposedHeaders; + + if (isPreflight) + { + var requested = context.Request.Headers.AccessControlRequestHeaders.ToString(); + headers.AccessControlAllowMethods = AllowedMethods; + headers.AccessControlAllowHeaders = + string.IsNullOrEmpty(requested) ? FallbackAllowedHeaders : requested; + headers.AccessControlMaxAge = "600"; + context.Response.StatusCode = StatusCodes.Status204NoContent; + return; // never let a preflight reach authentication + } + + await _next(context); + } + + private async Task> ResolveAllowedOriginsAsync(HttpContext context) + { + var key = context.Request.Headers[ApiKeyAuthenticationOptions.HeaderName].ToString(); + if (string.IsNullOrEmpty(key)) + { + key = context.Request.Query[ApiKeyAuthenticationOptions.QueryName].ToString(); + } + + if (string.IsNullOrEmpty(key)) + { + return _options.DefaultAllowedOrigins; + } + + var hash = AuthService.Sha256(key); + var cacheKey = "ez:cors:" + hash; + if (_cache.TryGetValue(cacheKey, out string[]? cached) && cached is not null) + { + return cached; + } + + var origins = await LookupAsync(hash, context.RequestAborted); + _cache.Set(cacheKey, origins, CacheTtl); + return origins; + } + + private async Task LookupAsync(string hash, CancellationToken ct) + { + using var scope = _scopeFactory.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + + var json = await db.ApiKeys + .AsNoTracking() + .Where(k => k.KeyHash == hash && k.RevokedAt == null && k.App!.IsActive) + .Select(k => k.App!.AllowedOriginsJson) + .FirstOrDefaultAsync(ct); + + if (string.IsNullOrWhiteSpace(json)) + { + // Known key with no per-app list, or an unknown key: fall back to the + // instance default rather than leaking which keys exist. + return _options.DefaultAllowedOrigins; + } + + try + { + return JsonSerializer.Deserialize(json) ?? _options.DefaultAllowedOrigins; + } + catch (JsonException) + { + return _options.DefaultAllowedOrigins; + } + } + + /// Exact match, or a single leading wildcard label (https://*.example.com). + public static bool Matches(string origin, IReadOnlyList allowed) + { + foreach (var candidate in allowed) + { + if (string.IsNullOrWhiteSpace(candidate)) + { + continue; + } + + if (string.Equals(candidate, origin, StringComparison.OrdinalIgnoreCase)) + { + return true; + } + + var star = candidate.IndexOf("://*.", StringComparison.Ordinal); + if (star < 0) + { + continue; + } + + // "https://*.example.com" -> scheme "https://", suffix ".example.com" + var scheme = candidate[..(star + 3)]; + var suffix = candidate[(star + 4)..]; + if (!origin.StartsWith(scheme, StringComparison.OrdinalIgnoreCase) || + !origin.EndsWith(suffix, StringComparison.OrdinalIgnoreCase)) + { + continue; + } + + // Require a non-empty label in place of the wildcard, and no extra dots in it. + var label = origin[scheme.Length..^suffix.Length]; + if (label.Length > 0 && !label.Contains('.')) + { + return true; + } + } + + return false; + } +} diff --git a/src/EzOdata.Host/Program.cs b/src/EzOdata.Host/Program.cs index 7650a51..6a9b37c 100644 --- a/src/EzOdata.Host/Program.cs +++ b/src/EzOdata.Host/Program.cs @@ -31,6 +31,8 @@ builder.Services.Configure(builder.Configuration.GetSection(JwtOptions.SectionPath)); builder.Services.Configure(builder.Configuration.GetSection(LockoutOptions.SectionPath)); +builder.Services.Configure( + builder.Configuration.GetSection(EzOdata.Host.Middleware.CorsOptions.SectionPath)); // ---- System database (SQLite default; PostgreSQL planned for prod) ---- var systemDbProvider = builder.Configuration["SystemDatabase:Provider"] ?? "sqlite"; @@ -240,6 +242,7 @@ // ---- Pipeline (spec 02 §4 order) ---- app.UseMiddleware(); app.UseMiddleware(); +app.UseMiddleware(); // spec 02 §4 step 4 — before authn; preflight is anonymous app.UseMiddleware(); app.UseAuthentication(); app.UseAuthorization(); diff --git a/tests/EzOdata.IntegrationTests/CorsTests.cs b/tests/EzOdata.IntegrationTests/CorsTests.cs new file mode 100644 index 0000000..6f6a252 --- /dev/null +++ b/tests/EzOdata.IntegrationTests/CorsTests.cs @@ -0,0 +1,164 @@ +using System.Net; +using System.Net.Http.Json; +using EzOdata.Host.Middleware; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Mvc.Testing; +using Xunit; + +namespace EzOdata.IntegrationTests; + +/// spec 08 §7 — default-deny CORS with per-app origin whitelists. +public class CorsTests : IClassFixture +{ + private readonly CorsHostFixture _fixture; + + public CorsTests(CorsHostFixture fixture) => _fixture = fixture; + + /// + /// Standalone host fixture — HostFixture is sealed, and this one additionally needs + /// an instance-level default origin configured before the host boots. + /// + public sealed class CorsHostFixture : WebApplicationFactory + { + public const string DefaultOrigin = "http://localhost:8081"; + + private readonly string _dbPath = Path.Combine(Path.GetTempPath(), $"ez-cors-{Guid.NewGuid():N}.db"); + + protected override void ConfigureWebHost(IWebHostBuilder builder) + { + builder.UseEnvironment("Testing"); + builder.UseSetting("SystemDatabase:Provider", "sqlite"); + builder.UseSetting("SystemDatabase:ConnectionString", $"Data Source={_dbPath}"); + builder.UseSetting("Auth:Jwt:SigningKey", "integration-test-signing-key-32-chars!!"); + builder.UseSetting("Encryption:MasterKey", + Convert.ToBase64String("integration-test-master-key-32b!"u8.ToArray())); + builder.UseSetting("Cors:DefaultAllowedOrigins:0", DefaultOrigin); + } + + public async Task CreateAdminClientAsync() + { + const string email = "admin@example.com"; + const string password = "a-strong-test-password-1"; + var client = CreateClient(); + + var status = await client.GetFromJsonAsync("/system/setup"); + if (status!.Required) + { + var setup = await client.PostAsJsonAsync("/system/setup", + new { email, displayName = "Test Admin", password }); + setup.EnsureSuccessStatusCode(); + } + + var login = await client.PostAsJsonAsync("/system/auth/login", new { email, password }); + login.EnsureSuccessStatusCode(); + var auth = await login.Content.ReadFromJsonAsync(); + client.DefaultRequestHeaders.Authorization = + new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", auth!.AccessToken); + return client; + } + + protected override void Dispose(bool disposing) + { + base.Dispose(disposing); + if (File.Exists(_dbPath)) File.Delete(_dbPath); + } + + private sealed record SetupStatusDto(bool Required); + + private sealed record AuthDto(string AccessToken); + } + + private static HttpRequestMessage Preflight(string origin, string path = "/api/odata/x/customers") + { + var request = new HttpRequestMessage(HttpMethod.Options, path); + request.Headers.Add("Origin", origin); + request.Headers.Add("Access-Control-Request-Method", "GET"); + return request; + } + + [Fact] + public async Task Preflight_from_default_origin_is_allowed_while_anonymous() + { + var client = _fixture.CreateClient(); + + var response = await client.SendAsync(Preflight(CorsHostFixture.DefaultOrigin)); + + // Must not be 401: a browser preflight never carries credentials. + Assert.Equal(HttpStatusCode.NoContent, response.StatusCode); + Assert.Equal(CorsHostFixture.DefaultOrigin, + Assert.Single(response.Headers.GetValues("Access-Control-Allow-Origin"))); + Assert.Contains("OData-Version", + Assert.Single(response.Headers.GetValues("Access-Control-Expose-Headers"))); + } + + [Fact] + public async Task Preflight_from_unknown_origin_gets_no_cors_headers() + { + var client = _fixture.CreateClient(); + + var response = await client.SendAsync(Preflight("https://evil.example")); + + Assert.False(response.Headers.Contains("Access-Control-Allow-Origin")); + } + + [Fact] + public async Task Request_without_origin_is_untouched() + { + var client = _fixture.CreateClient(); + + var response = await client.GetAsync("/healthz/live"); + + Assert.False(response.Headers.Contains("Access-Control-Allow-Origin")); + } + + [Fact] + public async Task Per_app_origin_is_honored_for_keyed_request() + { + var admin = await _fixture.CreateAdminClientAsync(); + var roles = await admin.GetFromJsonAsync>("/system/roles"); + var created = await admin.PostAsJsonAsync("/system/apps", new + { + name = "cors-app", + roleId = roles![0].Id, + isActive = true, + allowedOrigins = new[] { "https://*.example.com" }, + }); + created.EnsureSuccessStatusCode(); + var app = await created.Content.ReadFromJsonAsync(); + var keyResponse = await admin.PostAsJsonAsync($"/system/apps/{app!.Id}/keys", new { name = "k" }); + var key = (await keyResponse.Content.ReadFromJsonAsync())!.Key; + + var client = _fixture.CreateClient(); + var request = new HttpRequestMessage(HttpMethod.Get, "/api/odata/x/customers"); + request.Headers.Add("Origin", "https://tenant.example.com"); + request.Headers.Add("X-API-Key", key); + + var response = await client.SendAsync(request); + + Assert.Equal("https://tenant.example.com", + Assert.Single(response.Headers.GetValues("Access-Control-Allow-Origin"))); + } + + [Theory] + // exact + [InlineData("https://app.example.com", "https://app.example.com", true)] + [InlineData("https://APP.example.com", "https://app.example.com", true)] // origin compare is case-insensitive + [InlineData("https://other.example.com", "https://app.example.com", false)] + // single-label wildcard + [InlineData("https://*.example.com", "https://tenant.example.com", true)] + [InlineData("https://*.example.com", "https://a.b.example.com", false)] // wildcard spans one label only + [InlineData("https://*.example.com", "http://tenant.example.com", false)] // scheme must match + [InlineData("https://*.example.com", "https://example.com", false)] // wildcard needs a label + [InlineData("https://*.example.com", "https://evilexample.com", false)] // suffix forgery + [InlineData("https://*.example.com", "https://evil.com/?x=.example.com", false)] + public void Matches_follows_spec_08_section_7(string allowed, string origin, bool expected) + => Assert.Equal(expected, DynamicCorsMiddleware.Matches(origin, new[] { allowed })); + + [Fact] + public void Empty_allow_list_denies_everything() + => Assert.False(DynamicCorsMiddleware.Matches("https://app.example.com", Array.Empty())); + + private sealed record RoleDto(long Id); + private sealed record AppDto(long Id); + private sealed record KeyDto(string Key); +}