Skip to content
Open
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
51 changes: 51 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
name: CI

on:
pull_request:

jobs:
# Job keys are the required-check contexts in the protect-main ruleset — do not rename.
api-tests:
runs-on: ubuntu-latest
services:
# Matches TestDatabase.cs's default connection string, so no VARDE_TEST_PG is needed.
# Throwaway credentials for a job-local container; they secure nothing.
postgres:
image: postgres:17
env:
POSTGRES_PASSWORD: postgres
ports:
- 5432:5432
options: >-
--health-cmd "pg_isready -U postgres"
--health-interval 5s
--health-timeout 5s
--health-retries 10
steps:
- uses: actions/checkout@v4
- uses: actions/setup-dotnet@v4
with:
dotnet-version: "10.0.x"
- name: Test
run: dotnet test api/Varde.slnx

web-tests:
runs-on: ubuntu-latest
defaults:
run:
working-directory: web
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
cache-dependency-path: web/package-lock.json
- name: Install
run: npm ci
- name: Biome
run: npx biome ci .
- name: Test
run: npm test
- name: Build
run: npm run build
61 changes: 61 additions & 0 deletions .github/workflows/deploy-api.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
name: Deploy API

on:
push:
branches: [main]
paths:
- "api/**"
- ".github/workflows/deploy-api.yml"
workflow_dispatch:

permissions:
contents: read

jobs:
test:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:17
env:
POSTGRES_PASSWORD: postgres
ports:
- 5432:5432
options: >-
--health-cmd "pg_isready -U postgres"
--health-interval 5s
--health-timeout 5s
--health-retries 10
steps:
- uses: actions/checkout@v4
- uses: actions/setup-dotnet@v4
with:
dotnet-version: "10.0.x"
- name: Test
run: dotnet test api/Varde.slnx

deploy:
needs: test
runs-on: ubuntu-latest
environment: production
permissions:
# OIDC: the job requests a GitHub-signed token; Azure trusts it via the federated
# credential on the app registration. No stored Azure secret exists.
id-token: write
contents: read
steps:
- uses: actions/checkout@v4
- uses: actions/setup-dotnet@v4
with:
dotnet-version: "10.0.x"
- name: Publish
run: dotnet publish api/Varde.Api/Varde.Api.csproj -c Release -o publish
- uses: azure/login@v2
with:
client-id: ${{ secrets.AZURE_CLIENT_ID }}
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
- uses: azure/webapps-deploy@v3
with:
app-name: ${{ vars.API_APP_NAME }}
package: publish
68 changes: 68 additions & 0 deletions .github/workflows/deploy-web.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
name: Deploy Web

on:
push:
branches: [main]
paths:
- "web/**"
- ".github/workflows/deploy-web.yml"
workflow_dispatch:

permissions:
contents: read

jobs:
test:
runs-on: ubuntu-latest
defaults:
run:
working-directory: web
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
cache-dependency-path: web/package-lock.json
- name: Install
run: npm ci
- name: Biome
run: npx biome ci .
- name: Test
run: npm test

deploy:
needs: test
runs-on: ubuntu-latest
environment: production
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
cache-dependency-path: web/package-lock.json
- name: Install
working-directory: web
run: npm ci
- name: Require API_URL
env:
API_URL: ${{ vars.API_URL }}
run: test -n "$API_URL" || { echo "vars.API_URL is not set - refusing to build a broken bundle"; exit 1; }
- name: Build
working-directory: web
env:
VITE_API_URL: ${{ vars.API_URL }}
run: npm run build
- name: Inject API origin into the CSP
# The repo holds only the __API_ORIGIN__ placeholder; the real hostname lives in
# the API_URL environment variable and lands in dist/ only, never in git.
env:
API_URL: ${{ vars.API_URL }}
run: sed -i "s|__API_ORIGIN__|$API_URL|g" web/dist/staticwebapp.config.json
- uses: Azure/static-web-apps-deploy@v1
with:
azure_static_web_apps_api_token: ${{ secrets.AZURE_STATIC_WEB_APPS_API_TOKEN }}
action: upload
app_location: web/dist
skip_app_build: true
26 changes: 26 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,3 +71,29 @@ Tests create disposable `varde_test_<guid>` databases. The connection defaults t
standard local development setup (`localhost`, `postgres`/`postgres`); override it with the
`VARDE_TEST_PG` environment variable. The web dev server expects the API at
`http://localhost:5005` by default (`VITE_API_URL` to override).

## Deployment

Varde deploys automatically on merge to `main`: the frontend to **Azure Static Web Apps**
(Free), the API to **Azure App Service** (F1, Linux, Germany West Central), the database on
**Neon** (PostgreSQL 17, Frankfurt, `nb-NO` ICU collation). Schema and seed data arrive via
EF Core migrations at API startup — nothing is hand-built in the database.

Three GitHub Actions workflows drive it:

| Workflow | Trigger | Does |
|---|---|---|
| `ci.yml` | every pull request | both test suites + web build — the required merge checks |
| `deploy-api.yml` | push to `main` touching `api/**` | re-test, then deploy to App Service via OIDC |
| `deploy-web.yml` | push to `main` touching `web/**` | re-test, build with the real API origin, deploy to SWA |

Deploy credentials live in the GitHub `production` environment: secrets `AZURE_CLIENT_ID`,
`AZURE_TENANT_ID`, `AZURE_SUBSCRIPTION_ID` (OIDC federated login — no stored password),
`AZURE_STATIC_WEB_APPS_API_TOKEN`, and variables `API_APP_NAME` and `API_URL`. The repo
itself contains no hostnames or secrets; `staticwebapp.config.json` carries an
`__API_ORIGIN__` placeholder replaced at deploy time.

By design there is no Application Insights and HTTP logging is off — see the privacy posture
in `docs/superpowers/specs/2026-08-12-varde-design.md`. The full deployment design, including
the first-deploy runbook and verification checklist, is
`docs/superpowers/specs/2026-08-19-varde-deploy-design.md`.
32 changes: 26 additions & 6 deletions api/Varde.Api/Program.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using System.Threading.RateLimiting;
using Microsoft.AspNetCore.HttpOverrides;
using Microsoft.AspNetCore.RateLimiting;
using Microsoft.EntityFrameworkCore;
using Varde.Core.Interfaces;
Expand Down Expand Up @@ -53,19 +54,38 @@

var app = builder.Build();

// First in the pipeline, in every environment. App Service terminates TLS and proxies plain
// HTTP to Kestrel, so X-Forwarded-Proto must be applied before UseHttpsRedirection (else
// production redirect-loops) and X-Forwarded-For before the rate limiter (else every visitor
// shares one bucket). KnownIPNetworks/KnownProxies are cleared because App Service's proxy
// addresses are not enumerable. ForwardLimit stays at 1: App Service APPENDS the real client
// IP, so the right-most entry is the trustworthy one — reading deeper into the chain would
// let clients choose their own rate-limit bucket. Enabled in dev too: there is no proxy
// there, so a spoofed header only mis-partitions a local limiter, and unconditional
// enablement keeps WebApplicationFactory tests in their default Development environment.
var forwardedHeaders = new ForwardedHeadersOptions
{
ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto,
};
forwardedHeaders.KnownIPNetworks.Clear();
forwardedHeaders.KnownProxies.Clear();
app.UseForwardedHeaders(forwardedHeaders);

app.UseExceptionHandler();

app.UseCors(CorsPolicy);
app.UseRateLimiter();

if (app.Environment.IsDevelopment())
// Schema comes from migrations, always — never EnsureCreated. Runs in every environment:
// production Neon fills itself at deploy (schema + seed rows live in the migrations), and
// a failed migration blocks startup, which is the safe failure.
using (var scope = app.Services.CreateScope())
{
// Schema comes from migrations, always — never EnsureCreated.
using (var scope = app.Services.CreateScope())
{
scope.ServiceProvider.GetRequiredService<VardeDbContext>().Database.Migrate();
}
scope.ServiceProvider.GetRequiredService<VardeDbContext>().Database.Migrate();
}

if (app.Environment.IsDevelopment())
{
app.MapOpenApi(); // JSON spec at /openapi/v1.json — dev only
}
else
Expand Down
3 changes: 2 additions & 1 deletion api/Varde.Api/appsettings.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,8 @@
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
"Microsoft.AspNetCore": "Warning",
"Microsoft.EntityFrameworkCore.Database.Command": "Warning"
}
},
"AllowedHosts": "*",
Expand Down
13 changes: 10 additions & 3 deletions api/Varde.Tests/Infrastructure/VardeApiFactory.cs
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ namespace Varde.Tests.Infrastructure;
/// <summary>
/// Boots the real app against a throwaway PostgreSQL database on the local/CI server. Each factory
/// instance creates its OWN empty database and drops it on dispose, so every test that news up a
/// factory gets full isolation. The app applies migrations on startup in Development.
/// factory gets full isolation. The app applies migrations on startup in every environment.
/// Create one per test — `using var factory = new VardeApiFactory();` — rather than sharing a
/// class fixture, or data from one test leaks into the next.
/// </summary>
Expand All @@ -34,13 +34,20 @@ public sealed class VardeApiFactory : WebApplicationFactory<Program>
/// </summary>
public bool KeepSeedData { get; init; }

/// <summary>
/// Host environment for this test's app instance. Production hides OpenAPI and enables
/// HTTPS redirection (inert under TestServer — no https port is configured, so the
/// middleware skips redirecting); migrations run in every environment.
/// </summary>
public string Environment { get; init; } = "Development";

/// <summary>Every log message the app wrote during this test.</summary>
public CapturingLoggerProvider Logs { get; } = new();

protected override void ConfigureWebHost(IWebHostBuilder builder)
{
// Program.cs's Development branch applies migrations and maps OpenAPI; tests need the former.
builder.UseEnvironment("Development");
// Program.cs applies migrations at startup in every environment; OpenAPI stays dev-only.
builder.UseEnvironment(Environment);

// Touching TestDatabase runs its static constructor (stale-database cleanup) exactly once.
using (var admin = new NpgsqlConnection(TestDatabase.AdminConnectionString))
Expand Down
58 changes: 58 additions & 0 deletions api/Varde.Tests/Integration/ForwardedHeadersTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
using System.Net;
using Varde.Tests.Infrastructure;

namespace Varde.Tests.Integration;

public class ForwardedHeadersTests
{
private static HttpRequestMessage Get(string forwardedFor)
{
var request = new HttpRequestMessage(HttpMethod.Get, "/api/resources");
request.Headers.Add("X-Forwarded-For", forwardedFor);
return request;
}

[Fact]
public async Task Rate_limit_buckets_partition_by_forwarded_client_ip()
{
using var factory = new VardeApiFactory { RateLimitPermitLimit = 3 };
var client = factory.CreateClient();

for (var i = 0; i < 3; i++)
{
var allowed = await client.SendAsync(Get("203.0.113.10"));
Assert.Equal(HttpStatusCode.OK, allowed.StatusCode);
}

var exhausted = await client.SendAsync(Get("203.0.113.10"));
Assert.Equal(HttpStatusCode.TooManyRequests, exhausted.StatusCode);

// A different forwarded identity gets its own bucket — this is the assert that fails
// today, because without the middleware every request shares the "unknown" partition.
var otherIdentity = await client.SendAsync(Get("203.0.113.99"));
Assert.Equal(HttpStatusCode.OK, otherIdentity.StatusCode);
}

[Fact]
public async Task Only_the_rightmost_forwarded_entry_names_the_bucket()
{
// App Service APPENDS the real client IP to any client-supplied X-Forwarded-For, so
// with ForwardLimit = 1 the right-most entry wins and spoofed prefixes are ignored.
using var factory = new VardeApiFactory { RateLimitPermitLimit = 3 };
var client = factory.CreateClient();

for (var i = 0; i < 3; i++)
{
var allowed = await client.SendAsync(Get($"198.51.100.{i}, 203.0.113.10"));
Assert.Equal(HttpStatusCode.OK, allowed.StatusCode);
}

// Same spoofed prefix style, different right-most hop: different bucket, still 200.
var realOther = await client.SendAsync(Get("203.0.113.10, 198.51.100.77"));
Assert.Equal(HttpStatusCode.OK, realOther.StatusCode);

// Right-most hop 203.0.113.10 again: that bucket is exhausted regardless of prefix.
var exhausted = await client.SendAsync(Get("198.51.100.200, 203.0.113.10"));
Assert.Equal(HttpStatusCode.TooManyRequests, exhausted.StatusCode);
}
}
31 changes: 31 additions & 0 deletions api/Varde.Tests/Integration/ProductionStartupTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
using System.Net;
using Varde.Tests.Infrastructure;

namespace Varde.Tests.Integration;

public class ProductionStartupTests
{
[Fact]
public async Task Production_startup_applies_migrations_and_seed()
{
// KeepSeedData: this test asserts the migrated seed is queryable, so don't truncate.
using var factory = new VardeApiFactory { Environment = "Production", KeepSeedData = true };
var client = factory.CreateClient();

var response = await client.GetAsync("/api/categories");

Assert.Equal(HttpStatusCode.OK, response.StatusCode);
}

[Fact]
public async Task Production_does_not_expose_openapi()
{
// Guard, not new behavior: MapOpenApi stays inside the Development branch.
using var factory = new VardeApiFactory { Environment = "Production", KeepSeedData = true };
var client = factory.CreateClient();

var response = await client.GetAsync("/openapi/v1.json");

Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
}
}
Loading
Loading