diff --git a/.config/dotnet-tools.json b/.config/dotnet-tools.json new file mode 100644 index 0000000..6704daa --- /dev/null +++ b/.config/dotnet-tools.json @@ -0,0 +1,13 @@ +{ + "version": 1, + "isRoot": true, + "tools": { + "dotnet-ef": { + "version": "10.0.9", + "commands": [ + "dotnet-ef" + ], + "rollForward": false + } + } +} diff --git a/.github/workflows/portal-validation.yml b/.github/workflows/portal-validation.yml index 9c00cee..d55bc72 100644 --- a/.github/workflows/portal-validation.yml +++ b/.github/workflows/portal-validation.yml @@ -47,5 +47,10 @@ jobs: - name: Restore solution run: dotnet restore WiSave.Portal.slnx --configfile NuGet.Config + # Compiles every project, not just what the test project happens to reference. + # Without this, a project outside the test graph can break without CI noticing. + - name: Build solution + run: dotnet build WiSave.Portal.slnx --configuration Release --no-restore + - name: Run portal unit tests run: dotnet test tests/WiSave.Portal.UnitTests/WiSave.Portal.UnitTests.csproj --configuration Release --no-restore diff --git a/.gitignore b/.gitignore index 6c47fd8..1422d7c 100644 --- a/.gitignore +++ b/.gitignore @@ -25,3 +25,6 @@ graphify-out/ ## OS .DS_Store Thumbs.db + +## Superpowers artifacts (specs/plans live in the Obsidian vault) +docs/superpowers/ diff --git a/AGENTS.md b/AGENTS.md index 28f21a3..4507da0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,29 +1,191 @@ # AGENTS.md -Guidance for coding agents working in this repository. +Single source of truth for coding agents working in this repository. `CLAUDE.md` +imports this file; do not duplicate guidance there. ## Project Overview -- `WiSave.Portal` is an ASP.NET Core portal/gateway service. -- It handles authentication, permission resolution, session management, SignalR hubs, messaging, and YARP-based proxying to downstream services. -- Keep changes focused and consistent with the existing architecture. Avoid broad refactors unless they directly support the requested work. +**WiSave Portal** is an API gateway/portal service on ASP.NET Core (.NET 10). It +authenticates users, resolves role-based permissions, hosts SignalR notification +hubs, consumes messaging events, and proxies requests to downstream microservices +through YARP. + +Keep changes focused and consistent with the existing architecture. Avoid broad +refactors unless they directly support the requested work. ## Repository Layout -- `src/WiSave.Portal` — main application code -- `src/WiSave.Portal/Auth` — identity setup and auth models -- `src/WiSave.Portal/Authorization` — permission resolution and authorization helpers -- `src/WiSave.Portal/Endpoints` — HTTP endpoint mappings -- `src/WiSave.Portal/Gateway` — YARP proxy configuration and transforms -- `src/WiSave.Portal/Infrastructure` — infrastructure wiring and database access -- `src/WiSave.Portal/Session` — session storage implementation -- `src/WiSave.Portal/Hubs` — SignalR hubs -- `src/WiSave.Portal/Messaging` — messaging-related integration code -- `src/WiSave.Portal.Migrations` — DbUp migration project -- `src/WiSave.Portal.EfTools` — EF Core tooling support -- `tests/WiSave.Portal.Tests` — integration and behavior tests -- `scripts` — helper scripts for local development and migration workflows -- `docs` — project and workflow documentation +- `src/WiSave.Portal.WebApi` — composition root and HTTP surface, and nothing else + - `Auth` — antiforgery and auth rate limiting; auth DTOs + - `Authorization` — authorization policies, requirements, handlers, permission middleware + - `Endpoints` — minimal API endpoint mappings + - `Filters` — endpoint filters (antiforgery validation) + - `Gateway` — YARP configuration, transforms, downstream availability + - `Hubs` — SignalR hub and the `IRealtimeNotifier` adapter over it + - `Infrastructure` — CORS, forwarded headers, OpenAPI, health-check endpoint mapping +- `src/WiSave.Portal.Core.Abstractions` — the vocabulary every layer agrees on, and the + innermost project: the `IPermissionResolver` and `IRealtimeNotifier` ports, the + `RealtimeEnvelope`/`RealtimeEventType`/`RealtimeDomain` wire contracts, `PortalRoles` + and `PortalClaimTypes`, the downstream-service options, and `PortalTelemetry`. It names + only BCL types and declares **no references of any kind** — no projects, no packages, + no shared framework — enforced by `ProjectDependencyDirectionTests`. +- `src/WiSave.Portal.Core.Application` — framework-free policy expressed in that + vocabulary: `AccessManagementPolicy`, the `RealtimeNotificationHandler` base, and the + event handlers that translate downstream events into realtime notifications. + Core.Abstractions is its only project reference and the three integration contracts are + its only packages; framework references stay banned. +- `src/WiSave.Portal.Core.Infrastructure` — driven adapters: Identity entities, + `PortalDbContext` and its EF migrations, permission resolution, the Redis ticket + store, Wolverine/RabbitMQ wiring, and the Postgres and Redis health checks +- `src/WiSave.Portal.AppHost` — Aspire AppHost describing the local dev stack +- `src/WiSave.Portal.Console` — operational CLI (`db-migrate`, `db-seed`) +- `src/WiSave.Portal.Contracts` — shared authorization/identity contracts +- `src/WiSave.Portal.Migrations` — DbUp migration runner, SQL in `Scripts/`, seed SQL in `Seeds/` +- `tests/WiSave.Portal.UnitTests` — the only test project (xUnit v3) + +## Build, Run, and Test + +```bash +# Build +dotnet build + +# Run the local stack: Aspire starts Postgres, Redis and RabbitMQ and runs the +# portal as a host process on the fixed port 5100, with the dashboard for logs +# and traces. No preconditions — see Running the stack. +aspire run + +# Run the portal alone against infrastructure you started yourself +dotnet run --project src/WiSave.Portal.WebApi + +# Run all tests +dotnet test + +# Run tests in a specific class +dotnet test --filter "FullyQualifiedName~WiSave.Portal.UnitTests.Authorization.PermissionHandlerTests" + +# Build the portal container image (there is no Dockerfile; the csproj is +# configured for SDK container publish). Only needed to produce an image for +# deployment — the local stack runs the portal as a host process. +dotnet publish src/WiSave.Portal.WebApi -c Release -t:PublishContainer + +# Restore local tools (pins dotnet-ef; run once per clone) +dotnet tool restore + +# Generate a DbUp SQL script from EF Core migrations +dotnet ef migrations script \ + --project src/WiSave.Portal.Core.Infrastructure \ + --idempotent \ + --output src/WiSave.Portal.Migrations/Scripts/_.sql +``` + +## Running the stack + +`aspire run` is the whole local stack. It starts PostgreSQL 17, Redis 7 and RabbitMQ +4 as containers it owns, runs the portal as a host process on the fixed port **5100**, +and opens a dashboard with logs, traces and resource health. There is no +`docker-compose.yml` in this repository any more — everything it used to host is +modelled in `src/WiSave.Portal.AppHost`. + +The portal endpoint sets `IsProxied = false` so SignalR WebSocket upgrades bypass +Aspire's reverse proxy and `wisave-ui/proxy.conf.json` keeps working unchanged. +`Properties/launchSettings.json` gives the AppHost an `http` profile, so it can also +be launched from an IDE with the debugger attached to `portal-api`. + +**RabbitMQ moved here, and that has a cross-repo consequence.** It is the only broker +in the WiSave ecosystem, and `wisave-incomes`, `wisave-expenses` and `wisave-stock` +resolve it by the literal hostname `rabbitmq` over the external `wisave-net` network. +Aspire gives a container exactly one network and offers no API to add a second, so the +AppHost attaches the broker to `wisave-net` itself once the container is ready, aliased +`rabbitmq` — `WithSharedNetworkAlias` in `SharedNetworkExtensions.cs`. Those three stacks +therefore need no override and no code change; the broker answers to the name their +committed Compose files already use. + +Do not "simplify" that into `WithContainerRuntimeArgs("--network", "wisave-net")`. It +compiles, and it kills the container: DCP already passes `--network bridge` on the +`docker create` line, and Docker refuses to combine a non-user-defined network mode with +a user-defined one, so the resource fails to start with exit 125. +`WithContainerNetworkAlias` is not an alternative either — it only registers DNS names +within Aspire's own network. `AppHostConfigurationTests` pins both. + +The vhosts `/`, `portal`, `expenses`, `incomes` and `stocks` come from +`infrastructure/rabbitmq/definitions.json`, which the AppHost imports at boot — +Wolverine auto-provisions exchanges and queues but never vhosts, so that file stays +load-bearing. + +**Schema is your job.** Migrations are never applied automatically — see +[Migrations and Data Changes](#migrations-and-data-changes). The stack starts against +whatever schema is already in the database, and `/health` reports Postgres reachable +either way, so a missing schema surfaces as failing requests rather than a failed boot. + +Ports on the host: portal **5100**, incomes 5300, stock 5301, expenses 5200, Postgres +5432, Redis 6379, RabbitMQ 5672, Aspire dashboard 15100. The management UI is **not** +published — the image carries the plugin, but only `WithManagementPlugin()` maps a host +port, and the AppHost does not call it. Reach it from a container on `wisave-net`, or add +that call if you want `localhost:15672`. + +## Health endpoints + +- `GET /alive` — liveness. Runs no checks. It answers "the process is up", so a failure + means restart. +- `GET /health` — readiness. Runs the `ready`-tagged Postgres and Redis checks. It can + report that the process is fine but a dependency is not, which means route traffic + away rather than restart. Aspire polls this one. + +Both are anonymous so an orchestrator can reach them before anyone authenticates. +Dependencies that are not configured are not registered as checks, which is why the +in-memory test host is legitimately ready. + +## Architecture + +### Identity + +`ApplicationUser` and `ApplicationRole` extend `IdentityUser` / +`IdentityRole` and assign UUIDv7 keys via `Guid.CreateVersion7()` in their +constructors. `PortalDbContext` is +`IdentityDbContext` on the `public` +schema — Identity tables only, no additional entities. + +### Permission Model + +Permissions come from **role claims**, not from plans. `RolePermissionResolver` +reads the user's roles, and for each role collects claims of type +`PortalClaimTypes.Permission` (e.g. `incomes:read`, `stocks:write`). Admin roles +listed in `PortalRoles.AdminRoles` short-circuit to the wildcard `*`. + +`PermissionResolutionMiddleware` runs after authentication, resolves the set once +per request for authenticated users, and stashes it in +`HttpContext.Items["UserPermissions"]`. `PermissionHandler` enforces +`PermissionRequirement` against it. + +### Gateway + +Routes and clusters are configured in `appsettings.json` under `ReverseProxy` and +loaded by `YarpConfiguration`: + +| Route | Path | Policy | +| --- | --- | --- | +| `incomes-route` | `/api/incomes/{**remainder}` | `require-incomes` | +| `stocks-route` | `/api/stocks/{**remainder}` | `require-stocks` | +| `expenses-route` | `/api/expenses/{**remainder}` | `require-expenses` | + +Each route strips the `/api` prefix before forwarding. `UserHeaderTransform` +**strips client-supplied identity headers** and injects the authenticated user's +`X-User-Id`, `X-User-Email`, `X-User-Roles`, and `X-User-Permissions`. Antiforgery +validation is enforced on unsafe methods (POST/PUT/DELETE/PATCH) in the proxy +pipeline. + +### Endpoints + +- `/api/auth` — register, login, logout, change-password, me, antiforgery-token +- `/api/admin/access-management` — roles listing/creation, user role assignment, role permission updates +- `/api/capabilities` — downstream service availability + +### Local Development + +Default downstream targets: incomes `localhost:5300`, stocks `localhost:5301`, +expenses `localhost:5200`. Override via `ReverseProxy:Clusters` config or +environment variables. CORS allows `http://localhost:4200` (Angular frontend). +API docs at `/scalar/v1` in Development. ## Working Style @@ -33,43 +195,66 @@ Guidance for coding agents working in this repository. - Avoid unrelated cleanup while implementing a task. - When modifying behavior, update or add the nearest relevant tests. -## Build, Run, and Test - -Use the existing documented commands: - -```bash -dotnet build -dotnet run --project src/WiSave.Portal -dotnet test -dotnet test --filter "FullyQualifiedName~WiSave.Portal.Tests.Auth.AuthEndpointsTests" -docker compose up -d -docker compose --profile portal up -d --build -./scripts/generate-dbup-script.sh -``` - ## Testing Expectations -- Prefer targeted test execution first, then broader validation if needed. -- Tests use the in-memory database setup unless the task explicitly requires infrastructure-backed validation. -- Do not claim a fix is complete without running the most relevant verification you can run. -- If you cannot run verification, say so clearly and explain what should be run. +- `tests/WiSave.Portal.UnitTests` is the only test project. The + `WebApplicationFactory`-based integration suite was removed deliberately — do + not recreate it or add a new integration project. +- Cover new behavior at unit level. If something genuinely cannot be covered + there, say so once in the handoff rather than reintroducing integration tests. +- Prefer targeted test execution first, then broader validation. +- Do not claim a fix is complete without running the most relevant verification + you can run. If you cannot run it, say so and explain what should be run. ## Portal-Specific Guidance -- Auth and authorization changes should preserve the current plan/permission model. -- Gateway changes must keep header handling and proxy safety in mind, especially identity and permission headers. -- Unsafe proxied HTTP methods should continue respecting antiforgery requirements unless the task explicitly changes that behavior. -- Session-related changes should consider Redis-backed behavior and in-memory fallback paths. -- Database-related changes should stay aligned between runtime code, migrations, and tests. +- Auth and authorization changes must preserve the role-claim permission model. +- Gateway changes must keep header handling and proxy safety in mind, especially + the stripping of client-supplied identity headers. +- Unsafe proxied HTTP methods should continue respecting antiforgery + requirements unless the task explicitly changes that behavior. +- Session changes should consider both Redis-backed behavior and the in-memory + fallback path. +- Keep runtime code, migrations, and tests aligned on the same schema. ## Migrations and Data Changes -- Put schema evolution in the appropriate migrations project instead of ad hoc runtime logic. -- Keep EF tooling and DbUp workflows compatible when changing persistence-related code. -- Backend agents must not create, edit, delete, regenerate, rename, or otherwise modify EF migration files or DbUp SQL scripts unless the user explicitly asks for that exact migration/script change. -- When working on backend changes, agents may read EF migrations and DbUp scripts for context only. Treat migration and DbUp script files as read-only by default. +- **Migrations are run manually and are never automated.** Not on application startup, + not as an orchestrated one-shot resource in the Aspire AppHost or Compose, not in CI. + Do not add a migrator resource, a `WaitForCompletion` on one, or a call that applies + migrations during boot. `AppHostConfigurationTests.AppHost_NeverAutomatesMigrations` + enforces this. +- Put schema evolution in the migrations project, not ad hoc runtime logic. +- Keep EF tooling and DbUp workflows compatible when changing persistence code. +- `dotnet-ef` is pinned in `.config/dotnet-tools.json`. Keep that version in step + with `Microsoft.EntityFrameworkCore.Design` in `Directory.Packages.props` — + generating scripts with a mismatched tool writes the wrong `ProductVersion` + into `__EFMigrationsHistory`. +- Agents must not create, edit, delete, regenerate, or rename EF migration files + or DbUp SQL scripts unless the user explicitly asks for that exact change. + Otherwise treat them as read-only context. +- Treat squashed or destructive migrations as reset baselines, not in-place + upgrade paths. State the fresh-database or rebuild requirement and verify that + EF history, DbUp journals, maintenance scripts, and tests agree on the baseline. - Mention any required migration or seed follow-up in the handoff. +## Commit Readiness + +- Do not stage changes or create commits unless the user explicitly asks. +- Before proposing a commit, inspect both the staged snapshot (`git diff --cached`) + and unstaged changes (`git diff`). Judge readiness from the exact staged + snapshot, not from a successful worktree build. +- Unless the user asks for one combined commit, split unrelated concerns into + separate atomic commits. Keep each behavior change together with its production + registration and its nearest relevant tests. +- Follow Conventional Commits. Write a concise subject and a body explaining the + behavioral change, why it is needed, and any rollout or compatibility + requirements. +- Confirm that added local package artifacts are referenced and that restore + resolves the intended version. Do not include unused `.nupkg` files. +- Keep local agent plans, scratch files, and generated artifacts out of product + commits. Adding a path to `.gitignore` does not remove an already-tracked file. + ## Agent Handoff When finishing work: diff --git a/CLAUDE.md b/CLAUDE.md index d13ecdd..a1cb3ca 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,67 +1,6 @@ # CLAUDE.md -This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. +All guidance for this repository lives in [AGENTS.md](AGENTS.md). It is the single +source of truth for every coding agent — do not add project guidance here. -## Build & Run Commands - -```bash -# Build -dotnet build - -# Run the portal (requires Postgres + Redis; see docker-compose for local infra) -dotnet run --project src/WiSave.Portal - -# Run all tests (uses in-memory database, no external deps needed) -dotnet test - -# Run a single test by fully-qualified name -dotnet test --filter "FullyQualifiedName~WiSave.Portal.Tests.Auth.AuthEndpointsTests.Register_ReturnsOk" - -# Run tests in a specific class -dotnet test --filter "FullyQualifiedName~WiSave.Portal.Tests.Auth.AuthEndpointsTests" - -# Start local infrastructure only; the portal container is behind an explicit profile. -docker compose up -d - -# Start local infrastructure plus the Dockerfile-built portal image -docker compose --profile portal up -d --build - -# Generate a DbUp migration SQL from EF Core migrations -./scripts/generate-dbup-script.sh -``` - -## Architecture - -**WiSave Portal** is an API gateway/portal service built on ASP.NET Core (.NET 10). It authenticates users, resolves plan-based permissions, and proxies requests to downstream microservices via YARP reverse proxy. - -### Solution Projects - -- **WiSave.Portal** — Main application: auth endpoints, YARP gateway, session management, authorization middleware -- **WiSave.Portal.Migrations** — DbUp-based PostgreSQL migrations (SQL scripts in `Scripts/`, run via `DbMigrator.Run()`) -- **WiSave.Portal.Tests** — xUnit v3 integration tests using `WebApplicationFactory` with in-memory database -- **WiSave.Portal.EfTools** — Design-time helper for EF Core CLI tooling (used by `generate-dbup-script.sh`) - -### Key Layers (within WiSave.Portal) - -- **Auth** (`/Auth`) — ASP.NET Core Identity configuration, `ApplicationUser` model (extends `IdentityUser` with `Name` and `PlanId`) -- **Authorization** (`/Authorization`) — `PermissionResolutionMiddleware` resolves user permissions from their plan, caches via `UserPlanCache` and `PlanPermissionCache` (1hr TTL, distributed cache) -- **Gateway** (`/Gateway`) — YARP config (`YarpConfiguration.cs`), `UserHeaderTransform` strips client identity headers and injects authenticated user info (X-User-Id, X-User-Email, X-User-Roles, X-User-Permissions) -- **Session** (`/Session`) — Redis-backed session storage (`RedisTicketStore`), falls back to in-memory cache -- **Endpoints** (`/Endpoints`) — Auth endpoint mappings at `/api/auth` (register, login, logout, me, antiforgery-token) -- **Infrastructure/Database** (`/Infrastructure/Database`) — `PortalDbContext` with Identity tables + Plans, Permissions, PlanPermissions - -### Permission Model - -Users belong to a **Plan** (free/standard/premium). Each plan maps to a set of **Permissions** (e.g., `incomes:read`, `stocks:write`). Admin roles (`superadmin`, `admin`) get wildcard `*` permissions. Resolved permissions are injected into proxy headers for downstream services. - -### Proxy Routes - -YARP routes `/api/incomes/{**remainder}` and `/api/stocks/{**remainder}` to downstream service clusters. Antiforgery validation is enforced on unsafe HTTP methods (POST/PUT/DELETE/PATCH) in the proxy pipeline. - -### Local Development - -Default downstream targets: incomes at `localhost:5114`, stocks at `localhost:5086`. Override via `ReverseProxy:Clusters` config or environment variables. CORS allows `http://localhost:4200` (Angular frontend). API docs available at `/scalar/v1` in Development mode. - -## Testing - -Tests use `WebApplicationFactory` with `UseInMemoryDatabase=true` — no external services required. Each test seeds its own roles and plans. `UserHeaderTransformTests` spins up a local echo server to validate proxy header injection. +@AGENTS.md diff --git a/Directory.Packages.props b/Directory.Packages.props index e9f6cc4..b48e506 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -1,31 +1,42 @@ true + true + + + + - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + - - - + + + diff --git a/WiSave.Portal.slnx b/WiSave.Portal.slnx index 7a61dfb..773e858 100644 --- a/WiSave.Portal.slnx +++ b/WiSave.Portal.slnx @@ -1,16 +1,20 @@ + + + + - + - + - + diff --git a/aspire.config.json b/aspire.config.json new file mode 100644 index 0000000..b401fb8 --- /dev/null +++ b/aspire.config.json @@ -0,0 +1,5 @@ +{ + "appHost": { + "path": "src/WiSave.Portal.AppHost/WiSave.Portal.AppHost.csproj" + } +} diff --git a/docker-compose.yml b/docker-compose.yml deleted file mode 100644 index 3735e52..0000000 --- a/docker-compose.yml +++ /dev/null @@ -1,94 +0,0 @@ -services: - portal: - profiles: - - portal - build: - context: . - dockerfile: src/WiSave.Portal/Dockerfile - additional_contexts: - wisave_expenses_contracts_package: ${HOME}/.nuget/packages/wisave.expenses.contracts - wisave_incomes_contracts_package: ${HOME}/.nuget/packages/wisave.incomes.contracts - args: - GITHUB_PACKAGES_USERNAME: ${GITHUB_PACKAGES_USERNAME:-JacobChwastek} - BUILD_CONFIGURATION: Debug - secrets: - - github_packages_token - ports: - - "5100:8080" - depends_on: - postgres: - condition: service_healthy - redis: - condition: service_started - rabbitmq: - condition: service_healthy - environment: - - ASPNETCORE_ENVIRONMENT=Development - - ConnectionStrings__Portal=Host=postgres;Database=wisave_portal;Username=wisave;Password=wisave_dev - - Migrations__AutoApplyOnStartup=true - - Redis__ConnectionString=redis:6379 - - Cors__Origins__0=http://localhost:4200 - - ReverseProxy__Clusters__incomes-cluster__Destinations__destination1__Address=http://wisave-incomes-webapi:8080 - - ReverseProxy__Clusters__stocks-cluster__Destinations__destination1__Address=http://wisave-stock-webapi:8080 - - ReverseProxy__Clusters__expenses-cluster__Destinations__destination1__Address=http://wisave-expenses-webapi:8080 - - RabbitMq__Host=rabbitmq - - RabbitMq__VirtualHost=portal - - RabbitMq__Username=guest - - RabbitMq__Password=guest - networks: - - default - - wisave-net - - postgres: - image: postgres:17 - ports: - - "5432:5432" - volumes: - - portal-db:/var/lib/postgresql/data - environment: - - POSTGRES_DB=wisave_portal - - POSTGRES_USER=wisave - - POSTGRES_PASSWORD=wisave_dev - healthcheck: - test: ["CMD-SHELL", "pg_isready -U wisave -d wisave_portal"] - interval: 5s - timeout: 5s - retries: 5 - - redis: - image: redis:7-alpine - ports: - - "6379:6379" - - rabbitmq: - image: rabbitmq:4-management-alpine - ports: - - "5672:5672" - - "15672:15672" - environment: - - RABBITMQ_SERVER_ADDITIONAL_ERL_ARGS=-rabbitmq_management load_definitions "/etc/rabbitmq/definitions.json" - volumes: - - rabbitmq-data:/var/lib/rabbitmq - - ./infrastructure/rabbitmq/definitions.json:/etc/rabbitmq/definitions.json:ro - healthcheck: - test: ["CMD-SHELL", "rabbitmq-diagnostics check_port_connectivity"] - interval: 5s - timeout: 5s - retries: 10 - networks: - - default - - wisave-net - -volumes: - portal-db: - rabbitmq-data: - -networks: - default: - wisave-net: - external: true - name: wisave-net - -secrets: - github_packages_token: - environment: GITHUB_PACKAGES_TOKEN diff --git a/docs/superpowers/plans/2026-04-06-portal-console-shell-plan.md b/docs/superpowers/plans/2026-04-06-portal-console-shell-plan.md deleted file mode 100644 index b028e28..0000000 --- a/docs/superpowers/plans/2026-04-06-portal-console-shell-plan.md +++ /dev/null @@ -1,227 +0,0 @@ -# WiSave Portal Console Shell Plan - -## Context - -`WiSave.Portal` is currently the main ASP.NET Core host for authentication, authorization, SignalR, messaging, and YARP proxying. The solution also contains `WiSave.Portal.Migrations`, which already exposes a small console-style entry point over reusable migration logic. The current solution file does not yet include a dedicated console shell application. - -The goal is to introduce `WiSave.Portal.Console` as a non-Dockerized operator tool that: - -- runs locally as a standalone console application -- lets the user choose commands interactively -- accepts parameters for commands -- executes one command at a time -- returns to the command menu after each command completes -- makes it easy to add new commands and reuse the same execution model - -The user asked to keep the design simple and not split the operational logic into a separate `WiSave.Portal.Operations` project. Because of that, the console solution should stay as a single project while still preserving internal separation of concerns through folders, namespaces, and interfaces. - -## Architectural Direction - -Create a new project: - -- `src/WiSave.Portal.Console` - -Add it to: - -- `WiSave.Portal.slnx` - -Do not add it to: - -- `docker-compose.yml` - -The console project should use a generic host so it can reuse standard .NET patterns for: - -- configuration -- dependency injection -- logging -- scoped command execution - -Inside the single project, keep the code organized into these internal areas: - -- `Shell` - - interactive loop - - menu rendering - - follow-up prompt after command completion -- `Commands` - - one class per command - - command metadata and validation -- `Execution` - - command catalog - - parser - - prompt flow - - runner -- `Operations` - - actual database, identity, and migration work -- `Infrastructure` - - registrations, configuration binding, shared setup - -This keeps the solution small without collapsing the implementation into `Program.cs`. - -## Core Design - -Use a metadata-driven command model so new commands can be added by registering a new class instead of editing a central switch statement. - -Suggested contracts: - -```csharp -public interface IPortalCommand -{ - string Name { get; } - string Description { get; } - IReadOnlyList Parameters { get; } - Task ExecuteAsync(CommandExecutionContext context, CancellationToken ct); -} -``` - -```csharp -public sealed record CommandParameter( - string Name, - string Description, - bool Required, - string? DefaultValue = null); -``` - -```csharp -public sealed record CommandResult( - bool Success, - string Message, - IReadOnlyList? Details = null); -``` - -Use supporting services: - -- `ICommandCatalog` - - discovers registered commands - - resolves a command by name -- `ICommandParser` - - parses direct CLI arguments into command name and parameter values -- `ICommandPrompter` - - interactively asks for missing values -- `ICommandRunner` - - creates a scope and executes the chosen command -- `IConsoleShell` - - drives the repeated choose-execute-repeat workflow - -Commands should stay thin. Each command should depend on one or more operation services rather than directly owning EF Core or Identity logic. - -## Execution Modes - -The same command definitions should support both modes: - -### Interactive mode - -Triggered when no command-line arguments are provided. - -Flow: - -1. Start host and resolve command catalog. -2. Display available commands. -3. Let the user choose a command by number or name. -4. Prompt for required parameters. -5. Execute the command. -6. Print the result. -7. Ask whether to run another command. -8. Return to the command list until the user exits. - -### Direct mode - -Triggered when command-line arguments are provided. - -Example: - -```bash -dotnet run --project src/WiSave.Portal.Console -- db-migrate -dotnet run --project src/WiSave.Portal.Console -- users-create --email admin@wisave.local --name Admin --plan premium -``` - -This mode is useful for scripted or repeatable operator workflows while still reusing the same command implementations. - -## Recommended First Commands - -Start with a small set that validates the pattern: - -1. `db-migrate` - - wraps existing `DbMigrator` - - reuses `ConnectionStrings__Portal` -2. `users-create` - - creates a portal user - - assigns a plan - - optionally assigns a role -3. `users-set-plan` - - changes an existing user plan -4. `plans-list` - - lists available plans - -These commands are enough to prove the shell, prompting, validation, and service structure. - -## Configuration Strategy - -The console should follow the same configuration conventions already used in the portal: - -- `appsettings.json` -- `appsettings.Development.json` -- environment variables -- `ConnectionStrings__Portal` - -This avoids introducing a second configuration model. The console should be runnable locally against the same Postgres instance as the portal without requiring Docker Compose changes. - -If configuration needs diverge later, add a dedicated `appsettings.json` inside `WiSave.Portal.Console`, but keep connection naming aligned with the portal. - -## Implementation Steps - -1. Create `src/WiSave.Portal.Console` targeting `net10.0`. -2. Add the project to `WiSave.Portal.slnx`. -3. Set up a generic host in `Program.cs` with configuration, logging, and DI. -4. Add the internal folder structure: `Shell`, `Commands`, `Execution`, `Operations`, `Infrastructure`. -5. Implement the core command contracts and the command catalog. -6. Implement direct CLI parsing for `command-name --param value`. -7. Implement the interactive shell loop with repeated execution. -8. Add a command prompter for missing required parameters. -9. Add a command runner that creates a DI scope per execution. -10. Implement `db-migrate` as the first command using existing migration logic. -11. Implement one user-management command to validate DB and Identity access patterns. -12. Add a short README or docs entry with usage examples. - -## Constraints and Guardrails - -- Do not add `WiSave.Portal.Console` to `docker-compose.yml`. -- Do not place business logic in `Program.cs`. -- Do not use a large switch statement for commands. -- Do not couple interactive prompting logic to specific commands. -- Keep commands small and delegate real work to services. -- Prefer registration through DI so new commands can be added with minimal friction. - -## Risks - -### Risk: console project references too much web-only code - -If the console directly depends on web host concerns, it will become harder to maintain and test. - -Mitigation: - -- reference only the pieces actually needed -- move reusable database or identity setup behind console-local services where necessary -- keep web middleware and HTTP concerns out of the console project - -### Risk: command implementations become inconsistent - -If each command invents its own parameter and prompt logic, the shell will become uneven. - -Mitigation: - -- standardize metadata via `CommandParameter` -- keep prompting and validation in shared runner services - -### Risk: interactive mode becomes hard to automate - -If the shell is the only entry mode, the tool will be inconvenient for scripts. - -Mitigation: - -- support both interactive mode and direct CLI mode from the start - -## Summary - -The recommended approach is to add a single new project, `WiSave.Portal.Console`, and keep it out of Docker Compose. The project should provide an interactive shell and direct CLI execution using a shared command model, DI registration, and scoped execution. Operational logic should stay in the same project but live behind services so commands remain thin and easy to add. - -This approach keeps the solution simple, avoids unnecessary project sprawl, and creates a maintainable path for adding future portal administration commands. diff --git a/docs/superpowers/plans/2026-04-11-auth-antiforgery-alignment.md b/docs/superpowers/plans/2026-04-11-auth-antiforgery-alignment.md deleted file mode 100644 index d860eba..0000000 --- a/docs/superpowers/plans/2026-04-11-auth-antiforgery-alignment.md +++ /dev/null @@ -1,347 +0,0 @@ -# Auth and Antiforgery Alignment Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Make the UI and portal consistently authenticate and authorize requests, and eliminate avoidable `400 Antiforgery token validation failed` responses in local development and authenticated app flows. - -**Architecture:** Keep the portal as the source of truth for authentication, antiforgery, and route authorization. Align the UI transport layer so mutating calls flow through the same-origin `/api` path in development, and proactively bootstrap antiforgery cookies from both guest and authenticated shells so Angular can attach the `X-XSRF-TOKEN` header automatically. The key root cause is that Angular’s built-in XSRF support skips absolute or cross-origin API URLs, so `http://localhost:5100/api` bypasses the automatic header injection that works with same-origin `/api` requests. - -**Tech Stack:** ASP.NET Core minimal APIs, ASP.NET Core Identity cookies, ASP.NET Core Antiforgery, YARP, Angular `HttpClient`, Angular XSRF configuration, xUnit. - ---- - -## File Map - -**Portal repository** -- Modify: `tests/WiSave.Portal.Tests/Auth/AuthEndpointsTests.cs` — extend auth endpoint coverage for antiforgery bootstrap behavior if needed. -- Modify: `tests/WiSave.Portal.Tests/Gateway/UserHeaderTransformTests.cs` — add or adjust an integration test proving proxied unsafe requests require a valid antiforgery token. -- Check: `src/WiSave.Portal/Program.cs` — keep antiforgery enforcement for unsafe proxied methods unchanged unless tests show a real server-side gap. - -**UI repository** -- Modify: `public/env.js` — switch local default API base to `/api` so dev traffic uses the Angular proxy instead of cross-origin absolute URLs, or remove the local override entirely and fall back to the existing `/api` default in `runtime-config.ts`. -- Modify: `src/app/core/services/auth.service.ts` — add a focused method for fetching antiforgery cookies that both shells can reuse. -- Modify: `src/app/layout/auth-layout.component.ts` — keep antiforgery bootstrap for guest flows explicit without relying on `ngOnInit`. -- Modify: `src/app/layout/main-layout.component.ts` — add antiforgery bootstrap for authenticated shell startup without relying on `ngOnInit`. -- Check: `src/app/core/interceptors/auth.interceptor.ts` — keep credentials forwarding minimal; do not manually synthesize the XSRF header unless same-origin proxying proves insufficient. -- Modify: `README.md` — document the local dev requirement that `/api` must be used with the Angular proxy for cookie auth + XSRF, and note that Angular only sends the XSRF header on mutating requests. - -## Constraints - -- Preserve backend-first authorization; do not move security decisions into the UI. -- Do not weaken antiforgery validation in the portal to accommodate the current UI bug. -- Prefer same-origin `/api` transport over custom client-side XSRF-header logic. -- Keep changes surgical and close to existing patterns. -- Respect the UI guidance that discourages `ngOnInit` for simple initialization. -- Validate with targeted tests first. - -### Task 1: Lock down current antiforgery behavior with backend gateway coverage - -**Files:** -- Modify: `tests/WiSave.Portal.Tests/Gateway/UserHeaderTransformTests.cs` -- Check: `src/WiSave.Portal/Program.cs` -- Check: `src/WiSave.Portal/Endpoints/AuthEndpoints.cs` - -- [ ] **Step 1: Add a gateway integration test that proves unsafe proxied requests fail without antiforgery** - -Use `/api/incomes` rather than `/api/expenses`, because the gateway test harness already points `incomes-cluster` at the downstream echo server and seeds `incomes:read` for the `free` plan. - -```csharp -[Fact] -public async Task UnsafeProxyRequest_WithoutAntiforgeryToken_Returns400() -{ - var client = CreateClientWithCookies(); - await RegisterAsync(client, "Proxy User", "proxy@example.com"); - - var response = await client.PostAsJsonAsync("/api/incomes", new { name = "Test" }, CancellationToken); - - Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); -} -``` - -- [ ] **Step 2: Run the new focused test and verify it fails only if the chosen downstream route shape is incompatible** - -Run: `dotnet test tests/WiSave.Portal.Tests/WiSave.Portal.Tests.csproj --filter "FullyQualifiedName~UnsafeProxyRequest_WithoutAntiforgeryToken_Returns400"` -Expected: PASS after targeting an unsafe `incomes` route that reaches the echo server. - -- [ ] **Step 3: Add the paired happy-path test proving the same proxied request forwards with antiforgery** - -```csharp -[Fact] -public async Task UnsafeProxyRequest_WithAntiforgeryToken_ForwardsRequest() -{ - var client = CreateClientWithCookies(); - await RegisterAsync(client, "Proxy User", "proxy-ok@example.com"); - - var token = await GetAntiforgeryTokenAsync(client); - var message = new HttpRequestMessage(HttpMethod.Post, "/api/incomes"); - message.Headers.Add("X-XSRF-TOKEN", token); - message.Content = JsonContent.Create(new { name = "Test" }); - - var response = await client.SendAsync(message, CancellationToken); - var forwarded = await response.Content.ReadFromJsonAsync(CancellationToken); - - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - Assert.NotNull(forwarded); - Assert.Equal("/incomes", forwarded.Path); -} -``` - -- [ ] **Step 4: Run the focused gateway antiforgery tests** - -Run: `dotnet test tests/WiSave.Portal.Tests/WiSave.Portal.Tests.csproj --filter "FullyQualifiedName~UnsafeProxyRequest_"` -Expected: PASS - -- [ ] **Step 5: Keep `Program.cs` unchanged unless the tests reveal a real server-side inconsistency** - -```csharp -string[] unsafeMethods = ["POST", "PUT", "DELETE", "PATCH"]; -if (unsafeMethods.Contains(context.Request.Method, StringComparer.OrdinalIgnoreCase)) -{ - var antiforgery = context.RequestServices.GetRequiredService(); - await antiforgery.ValidateRequestAsync(context); -} -``` - -### Task 2: Make UI local development use same-origin `/api` - -**Files:** -- Modify: `public/env.js` -- Check: `src/app/core/config/runtime-config.ts` -- Check: `proxy.conf.json` -- Check: `angular.json` -- Test: manual browser verification in local dev - -- [ ] **Step 1: Use a same-origin local API base by either setting `/api` explicitly or removing the override** - -Preferred minimal change: - -```js -window.__env = { - API_BASE_URL: '/api', -}; -``` - -Alternative acceptable change if you want the default to speak for itself: - -```js -window.__env = { -}; -``` - -- [ ] **Step 2: Document in code review notes or commit message why absolute URLs break XSRF** - -```text -Angular’s XSRF support automatically adds X-XSRF-TOKEN only for mutating same-origin requests. -When API_BASE_URL is http://localhost:5100/api in a browser served from http://localhost:4200, -requests become cross-origin and Angular skips the XSRF header. -``` - -- [ ] **Step 3: Verify Angular dev server is already configured to proxy `/api` to the portal** - -Run: `rg -n "proxyConfig|target\": \"http://localhost:5100\"" angular.json proxy.conf.json` -Expected: matches in `angular.json` and `proxy.conf.json` - -- [ ] **Step 4: Start the UI and confirm requests now target `/api/...` instead of `http://localhost:5100/api/...`** - -Run: `yarn start` -Expected: browser network panel shows request URLs beginning with `/api`, with the dev server proxy forwarding them to `http://localhost:5100` - -- [ ] **Step 5: Keep the interceptor focused on credentials only** - -```ts -export const authInterceptor: HttpInterceptorFn = (req, next) => { - const apiBase = getApiBaseUrl(); - - if (req.url.startsWith(apiBase) || req.url.startsWith('/api')) { - return next(req.clone({ withCredentials: true })); - } - - return next(req); -}; -``` - -### Task 3: Bootstrap antiforgery tokens from both UI shells - -**Files:** -- Modify: `src/app/core/services/auth.service.ts` -- Modify: `src/app/layout/auth-layout.component.ts` -- Modify: `src/app/layout/main-layout.component.ts` -- Test: UI manual verification for guest and authenticated flows - -- [ ] **Step 1: Add a reusable antiforgery bootstrap method to the auth service and include `withCredentials` explicitly** - -```ts -bootstrapAntiforgery(): Observable { - return this.#http - .get(`${this.#apiUrl}/antiforgery-token`, { - withCredentials: true, - responseType: 'text' as const, - }) - .pipe(map(() => void 0)); -} -``` - -- [ ] **Step 2: Replace the direct auth-layout HTTP call with a simple field-initializer bootstrap** - -```ts -export class AuthLayoutComponent { - readonly #authService = inject(AuthService); - - readonly #bootstrapAntiforgery = this.#authService.bootstrapAntiforgery().subscribe(); -} -``` - -- [ ] **Step 3: Add the same bootstrap pattern to the authenticated shell without `ngOnInit`** - -```ts -export class MainLayoutComponent { - readonly #authService = inject(AuthService); - - readonly #bootstrapAntiforgery = this.#authService.bootstrapAntiforgery().subscribe(); -} -``` - -- [ ] **Step 4: If the team prefers a lifecycle-safe cleanup path, switch both layouts to `takeUntilDestroyed()` instead of `OnInit`** - -```ts -readonly #destroyRef = inject(DestroyRef); - -constructor() { - this.#authService - .bootstrapAntiforgery() - .pipe(takeUntilDestroyed(this.#destroyRef)) - .subscribe(); -} -``` - -- [ ] **Step 5: Manually verify the cookie bootstrap sequence in both shells** - -Run: open the app in a browser, visit `/auth/login`, then log in and navigate to an authenticated page. -Expected: -- guest shell requests `GET /api/auth/antiforgery-token` -- authenticated shell also requests `GET /api/auth/antiforgery-token` -- response sets `XSRF-TOKEN` -- subsequent `POST`/`PUT`/`DELETE`/`PATCH` calls include `X-XSRF-TOKEN` -- `GET` requests do not include `X-XSRF-TOKEN` - -### Task 4: Add focused regression coverage for auth antiforgery flows - -**Files:** -- Modify: `tests/WiSave.Portal.Tests/Auth/AuthEndpointsTests.cs` -- Check: `src/WiSave.Portal/Endpoints/AuthEndpoints.cs` - -- [ ] **Step 1: Add a test that `GET /api/auth/antiforgery-token` sets the readable `XSRF-TOKEN` cookie** - -```csharp -[Fact] -public async Task AntiforgeryToken_SetsReadableXsrfCookie() -{ - var client = CreateClient(); - - var response = await client.GetAsync("/api/auth/antiforgery-token", CancellationToken); - - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - Assert.Contains(response.Headers.GetValues("Set-Cookie"), c => c.StartsWith("XSRF-TOKEN=")); -} -``` - -- [ ] **Step 2: Add a test that login refreshes antiforgery cookies after successful auth** - -```csharp -[Fact] -public async Task Login_ValidCredentials_RefreshesXsrfCookie() -{ - var client = CreateClient(); - await RegisterAsync(client, new RegisterRequest("Token User", "token@example.com", "Password123!", "free")); - await PostWithAntiforgeryAsync(client, "/api/auth/logout", new { }); - - var response = await PostWithAntiforgeryAsync(client, "/api/auth/login", new LoginRequest("token@example.com", "Password123!")); - - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - Assert.Contains(response.Headers.GetValues("Set-Cookie"), c => c.StartsWith("XSRF-TOKEN=")); -} -``` - -- [ ] **Step 3: Run the focused auth endpoint tests** - -Run: `dotnet test tests/WiSave.Portal.Tests/WiSave.Portal.Tests.csproj --filter "FullyQualifiedName~WiSave.Portal.Tests.Auth.AuthEndpointsTests"` -Expected: PASS - -- [ ] **Step 4: If a test fails, adjust only the closest endpoint behavior and rerun the same filter** - -```csharp -private static void SetXsrfTokenCookie(IAntiforgery antiforgery, HttpContext context) -{ - var tokens = antiforgery.GetAndStoreTokens(context); - context.Response.Cookies.Append("XSRF-TOKEN", tokens.RequestToken!, new CookieOptions - { - HttpOnly = false, - SameSite = SameSiteMode.Lax, - Secure = context.Request.IsHttps, - Path = "/", - }); -} -``` - -### Task 5: Document the supported local auth/XSRF model - -**Files:** -- Modify: `README.md` -- Check: `public/env.js` -- Check: `proxy.conf.json` - -- [ ] **Step 1: Update the local development section to say the frontend should use `/api` locally** - -```md -The Angular dev server should call the backend through `/api` and `proxy.conf.json`. -Do not use `http://localhost:5100/api` in local browser runtime config when relying on cookie auth and Angular XSRF support. -``` - -- [ ] **Step 2: Add a short troubleshooting note for `400 Antiforgery token validation failed` and explain the absolute-URL pitfall** - -```md -If mutating requests return `400 Antiforgery token validation failed`, verify: -- `window.__env.API_BASE_URL` is `/api` or omitted so the runtime default resolves to `/api` -- the Angular dev server proxy is active -- `GET /api/auth/antiforgery-token` sets `XSRF-TOKEN` -- the browser sends `X-XSRF-TOKEN` on `POST`/`PUT`/`DELETE`/`PATCH` - -Angular does not send `X-XSRF-TOKEN` for `GET` requests, and it also skips automatic XSRF headers for absolute/cross-origin API URLs. -``` - -- [ ] **Step 3: Run a quick docs sanity check** - -Run: `rg -n "localhost:5100/api|/api|Antiforgery token validation failed|X-XSRF-TOKEN" README.md public/env.js` -Expected: the README and runtime config now consistently describe `/api` for local browser usage and the mutating-method behavior of XSRF headers. - -## Final Verification - -- [ ] **Step 1: Run focused portal auth tests** - -Run: `dotnet test tests/WiSave.Portal.Tests/WiSave.Portal.Tests.csproj --filter "FullyQualifiedName~WiSave.Portal.Tests.Auth.AuthEndpointsTests|FullyQualifiedName~UnsafeProxyRequest_"` -Expected: PASS - -- [ ] **Step 2: Run the UI lint or targeted check only if the touched files are covered by existing checks** - -Run: `yarn eslint src/app/core/services/auth.service.ts src/app/layout/auth-layout.component.ts src/app/layout/main-layout.component.ts src/app/core/interceptors/auth.interceptor.ts` -Expected: PASS - -- [ ] **Step 3: Manually verify the browser behavior end to end** - -Run: -- start portal -- start UI -- load `/auth/login` -- log in -- trigger a mutating incomes or expenses call - -Expected: -- cookies are present -- `X-XSRF-TOKEN` header is present on unsafe calls -- no `400 Antiforgery token validation failed` response occurs for valid flows - -## Self-Review - -- Spec coverage: the plan covers auth transport, antiforgery rejection verification, UI bootstrap, backend regression tests, and docs updates. -- Placeholder scan: no `TODO`/`TBD` placeholders remain; each task names exact files and commands. -- Type consistency: `bootstrapAntiforgery()` is introduced once in the auth service and reused consistently from both layouts. diff --git a/docs/superpowers/plans/2026-04-12-auth-stability-hardening.md b/docs/superpowers/plans/2026-04-12-auth-stability-hardening.md deleted file mode 100644 index dbc90a7..0000000 --- a/docs/superpowers/plans/2026-04-12-auth-stability-hardening.md +++ /dev/null @@ -1,742 +0,0 @@ -# Auth Stability Hardening Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Make login, refresh, logout, registration, and protected-route restoration behave deterministically by hardening server-side session storage, removing frontend antiforgery races, and preserving navigation intent. - -**Architecture:** Keep the existing cookie/BFF model. The portal remains the source of truth for auth, permissions, and antiforgery. Fix instability at the boundaries: require an intentional ticket-store choice on the portal, make the Angular auth service own antiforgery readiness instead of layout constructors, and treat refresh/bootstrap failures differently from a confirmed `401`. - -**Tech Stack:** ASP.NET Core Identity cookie auth, ASP.NET Core Antiforgery, distributed cache / Redis ticket store, Angular 21 standalone app, Angular `HttpClient`, Angular router guards, Jasmine/Karma frontend tests, xUnit backend tests. - ---- - -## File Map - -**Portal** -- Create: `tests/WiSave.Portal.Tests/Session/SessionConfigurationTests.cs` — verifies startup/session-store configuration choices fail or pass intentionally. -- Modify: `src/WiSave.Portal/Session/Extensions.cs` — stop silently falling back to process-local ticket storage in runtime environments where that causes auth loss. -- Create: `src/WiSave.Portal/Session/PortalSessionOptions.cs` — explicit configuration contract for auth-ticket storage behavior. -- Modify: `src/WiSave.Portal/Endpoints/AuthEndpoints.cs` — align logout with antiforgery protection and rotate a fresh guest XSRF token after sign-out. -- Modify: `tests/WiSave.Portal.Tests/Auth/AuthEndpointsTests.cs` — cover logout XSRF behavior and refreshed token issuance. -- Modify: `src/WiSave.Portal/appsettings.Development.json` — keep local development explicit about session-store expectations if needed. - -**UI** -- Create: `../wisave-ui/src/app/core/services/auth.service.spec.ts` — regression coverage for antiforgery sequencing and `/me` bootstrap behavior. -- Create: `../wisave-ui/src/app/core/guards/auth.guard.spec.ts` — verifies redirect and error-route behavior with `returnUrl`. -- Modify: `../wisave-ui/src/app/core/services/auth.service.ts` — centralize antiforgery readiness, make initialization classify outcomes, and stop hiding transport errors as logout while preserving the existing public `logout(): void` API. -- Modify: `../wisave-ui/src/app/core/guards/auth.guard.ts` — preserve `returnUrl`, redirect only on confirmed unauthenticated state, and send transient bootstrap failures to a dedicated retry route. -- Modify: `../wisave-ui/src/app/core/guards/auth.guard.ts` — update `guestGuard` with the same bootstrap classification rules so logged-in users are not shown auth pages during backend outages. -- Modify: `../wisave-ui/src/app/features/auth/views/login.component.ts` — navigate to `returnUrl` after successful login and surface antiforgery/bootstrap failures clearly. -- Modify: `../wisave-ui/src/app/features/auth/views/register.component.ts` — same as login for post-registration flows. -- Modify: `../wisave-ui/src/app/layout/auth-layout.component.ts` — optional prewarm only; no correctness dependency on constructor timing. -- Modify: `../wisave-ui/src/app/layout/main-layout.component.ts` — same as auth layout. -- Create: `../wisave-ui/src/app/features/auth/views/session-unavailable.component.ts` — retry screen for transient bootstrap failures. -- Modify: `../wisave-ui/src/app/app.routes.ts` — add `session-unavailable` as a top-level unguarded route outside both guarded route trees. -- Check: `../wisave-ui/src/app/layout/sidebar.ts` — confirm no caller changes are needed after preserving `logout(): void`. -- Modify: `../wisave-ui/docs/features/auth.md` — document auth bootstrap states and `returnUrl` handling. -- Modify: `../wisave-ui/README.md` — document the local dependency on shared session storage + same-origin `/api`. - -**Reference / Check-Only** -- Check: `../wisave-ui/public/env.js` — already set to same-origin `/api`; keep unchanged unless drift is found. -- Check: `../wisave-ui/src/app/core/interceptors/auth.interceptor.ts` — keep credential forwarding minimal; do not move security decisions here. -- Check: `../wisave-ui/proxy.conf.json` — already proxies `/api` to the portal. - ---- - -### Task 1: Make Portal Session Storage an Explicit Runtime Choice - -**Files:** -- Create: `src/WiSave.Portal/Session/PortalSessionOptions.cs` -- Modify: `src/WiSave.Portal/Session/Extensions.cs` -- Create: `tests/WiSave.Portal.Tests/Session/SessionConfigurationTests.cs` - -- [ ] **Step 1: Write the failing configuration test for missing shared ticket storage** - -```csharp -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.DependencyInjection; -using WiSave.Portal.Session; -using Xunit; - -namespace WiSave.Portal.Tests.Session; - -public class SessionConfigurationTests -{ - [Fact] - public void AddPortalSession_WithoutRedisAndWithoutExplicitFallback_Throws() - { - var configuration = new ConfigurationBuilder() - .AddInMemoryCollection(new Dictionary - { - ["UseInMemoryDatabase"] = "false", - ["Redis:ConnectionString"] = "", - ["Session:AllowInMemoryTicketStoreFallback"] = "false", - }) - .Build(); - - var services = new ServiceCollection(); - - var ex = Assert.Throws(() => - services.AddPortalSession(configuration)); - - Assert.Contains("Redis:ConnectionString", ex.Message); - } -} -``` - -- [ ] **Step 2: Run the new test and verify it fails because the portal still silently falls back** - -Run: `dotnet test tests/WiSave.Portal.Tests/WiSave.Portal.Tests.csproj --filter "FullyQualifiedName~SessionConfigurationTests"` -Expected: FAIL because `AddPortalSession()` currently uses `AddDistributedMemoryCache()` whenever Redis is absent. - -- [ ] **Step 3: Add an explicit session-options contract** - -```csharp -namespace WiSave.Portal.Session; - -public sealed class PortalSessionOptions -{ - public bool AllowInMemoryTicketStoreFallback { get; set; } -} -``` - -- [ ] **Step 4: Update session registration to fail fast unless fallback is deliberately allowed** - -```csharp -public static IServiceCollection AddPortalSession(this IServiceCollection services, IConfiguration configuration) -{ - services.Configure(configuration.GetSection("Session")); - - var redisConnection = configuration["Redis:ConnectionString"]; - var allowFallback = - configuration.GetValue("UseInMemoryDatabase") || - configuration.GetValue("Session:AllowInMemoryTicketStoreFallback"); - - if (!string.IsNullOrWhiteSpace(redisConnection)) - { - services.AddStackExchangeRedisCache(options => - { - options.Configuration = redisConnection; - options.InstanceName = "WiSave:"; - }); - } - else if (allowFallback) - { - services.AddDistributedMemoryCache(); - } - else - { - throw new InvalidOperationException( - "Redis:ConnectionString is required for authentication ticket storage. " + - "Set Session:AllowInMemoryTicketStoreFallback=true only for local single-instance development."); - } - - services.AddSingleton(sp => new RedisTicketStore(sp.GetRequiredService())); - - services.AddOptions(IdentityConstants.ApplicationScheme) - .Configure((options, store) => options.SessionStore = store); - - return services; -} -``` - -- [ ] **Step 5: Add a positive test proving tests/local fallback still works when explicitly allowed** - -```csharp -[Fact] -public void AddPortalSession_WithExplicitFallback_RegistersTicketStore() -{ - var configuration = new ConfigurationBuilder() - .AddInMemoryCollection(new Dictionary - { - ["Redis:ConnectionString"] = "", - ["Session:AllowInMemoryTicketStoreFallback"] = "true", - }) - .Build(); - - var services = new ServiceCollection(); - services.AddPortalSession(configuration); - - using var provider = services.BuildServiceProvider(); - Assert.NotNull(provider.GetRequiredService()); -} -``` - -- [ ] **Step 6: Run the focused session configuration tests** - -Run: `dotnet test tests/WiSave.Portal.Tests/WiSave.Portal.Tests.csproj --filter "FullyQualifiedName~SessionConfigurationTests"` -Expected: PASS - -- [ ] **Step 7: Commit the portal session-store hardening** - -```bash -git add src/WiSave.Portal/Session/PortalSessionOptions.cs src/WiSave.Portal/Session/Extensions.cs tests/WiSave.Portal.Tests/Session/SessionConfigurationTests.cs -git commit -m "fix(auth): make portal ticket store configuration explicit" -``` - ---- - -### Task 2: Make Logout and Antiforgery Behavior Symmetric on the Portal - -**Files:** -- Modify: `src/WiSave.Portal/Endpoints/AuthEndpoints.cs` -- Modify: `tests/WiSave.Portal.Tests/Auth/AuthEndpointsTests.cs` - -- [ ] **Step 1: Write the failing logout antiforgery test** - -```csharp -[Fact] -public async Task Logout_WithoutAntiforgeryToken_Returns400() -{ - var client = CreateClient(); - await RegisterAsync(client, new RegisterRequest("Logout User", "logout-xsrf@example.com", "Password123!", "free")); - - var response = await client.PostAsJsonAsync("/api/auth/logout", new { }, CancellationToken); - - Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); -} -``` - -Use the existing cookie-backed `CreateClient()` and `RegisterAsync()` helper exactly as shown so the request is definitely authenticated and the only missing piece is the `X-XSRF-TOKEN` header. If this test returns `401` instead of `400`, stop and verify auth state propagation before changing endpoint behavior. - -- [ ] **Step 2: Write the failing test that logout rotates a fresh readable XSRF token** - -```csharp -[Fact] -public async Task Logout_WithAntiforgeryToken_RefreshesXsrfCookie() -{ - var client = CreateClient(); - await RegisterAsync(client, new RegisterRequest("Logout User", "logout-refresh@example.com", "Password123!", "free")); - - var response = await PostWithAntiforgeryAsync(client, "/api/auth/logout", new { }); - - Assert.Equal(HttpStatusCode.NoContent, response.StatusCode); - Assert.Contains(response.Headers.GetValues("Set-Cookie"), c => c.StartsWith("XSRF-TOKEN=")); -} -``` - -- [ ] **Step 3: Run the focused auth tests and verify logout is currently inconsistent** - -Run: `dotnet test tests/WiSave.Portal.Tests/WiSave.Portal.Tests.csproj --filter "FullyQualifiedName~Logout_WithoutAntiforgeryToken|FullyQualifiedName~Logout_WithAntiforgeryToken_RefreshesXsrfCookie"` -Expected: FAIL because `/api/auth/logout` is not behind `AntiforgeryValidationFilter` and does not issue a fresh guest token. - -- [ ] **Step 4: Protect logout with the same antiforgery filter and rotate a guest token after sign-out** - -```csharp -group.MapPost("/logout", Logout) - .AddEndpointFilter() - .RequireAuthorization() - .Produces(204) - .WithSummary("Clear session"); - -private static async Task Logout( - SignInManager signInManager, - IAntiforgery antiforgery, - HttpContext context) -{ - await signInManager.SignOutAsync(); - SetXsrfTokenCookie(antiforgery, context); - return Results.NoContent(); -} -``` - -- [ ] **Step 5: Run the focused auth endpoint tests** - -Run: `dotnet test tests/WiSave.Portal.Tests/WiSave.Portal.Tests.csproj --filter "FullyQualifiedName~Logout_WithoutAntiforgeryToken|FullyQualifiedName~Logout_WithAntiforgeryToken_RefreshesXsrfCookie|FullyQualifiedName~Logout_ClearsSession"` -Expected: PASS - -- [ ] **Step 6: Commit the logout/XSRF consistency change** - -```bash -git add src/WiSave.Portal/Endpoints/AuthEndpoints.cs tests/WiSave.Portal.Tests/Auth/AuthEndpointsTests.cs -git commit -m "fix(auth): align logout with antiforgery flow" -``` - ---- - -### Task 3: Move Antiforgery Readiness into the Angular Auth Service - -**Files:** -- Create: `../wisave-ui/src/app/core/services/auth.service.spec.ts` -- Modify: `../wisave-ui/src/app/core/services/auth.service.ts` -- Modify: `../wisave-ui/src/app/layout/auth-layout.component.ts` -- Modify: `../wisave-ui/src/app/layout/main-layout.component.ts` - -- [ ] **Step 1: Write the failing frontend test proving login waits for antiforgery bootstrap** - -```ts -import { TestBed } from '@angular/core/testing'; -import { provideHttpClient, withInterceptors, withXsrfConfiguration } from '@angular/common/http'; -import { provideHttpClientTesting, HttpTestingController } from '@angular/common/http/testing'; -import { provideRouter } from '@angular/router'; - -import { authInterceptor } from '@core/interceptors/auth.interceptor'; -import { AuthService } from './auth.service'; - -describe('AuthService', () => { - let service: AuthService; - let httpMock: HttpTestingController; - - beforeEach(() => { - TestBed.configureTestingModule({ - providers: [ - provideRouter([]), - provideHttpClient(withInterceptors([authInterceptor]), withXsrfConfiguration({ cookieName: 'XSRF-TOKEN', headerName: 'X-XSRF-TOKEN' })), - provideHttpClientTesting(), - ], - }); - - service = TestBed.inject(AuthService); - httpMock = TestBed.inject(HttpTestingController); - }); - - it('waits for antiforgery bootstrap before login', () => { - service.login({ email: 'user@example.com', password: 'Password123!' }).subscribe(); - - const xsrf = httpMock.expectOne('/api/auth/antiforgery-token'); - const login = httpMock.expectNone('/api/auth/login'); - - xsrf.flush(''); - httpMock.expectOne('/api/auth/login'); - }); -}); -``` - -- [ ] **Step 2: Run the focused frontend service test and verify it fails** - -Run: `yarn --cwd ../wisave-ui test --watch=false --include src/app/core/services/auth.service.spec.ts` -Expected: FAIL because `login()` and `register()` currently issue POST requests immediately. - -- [ ] **Step 3: Refactor `AuthService` so auth mutations depend on an internal antiforgery-ready observable** - -```ts -import { catchError, map, Observable, of, shareReplay, switchMap, tap } from 'rxjs'; - -type AuthBootstrapResult = - | { kind: 'authenticated'; user: IUser } - | { kind: 'unauthenticated' } - | { kind: 'unavailable'; status: number }; - -export class AuthService { - #antiforgeryReady$: Observable | null = null; - - #fetchAntiforgeryToken(): Observable { - return this.#http - .get(`${this.#apiUrl}/antiforgery-token`, { - withCredentials: true, - responseType: 'text', - }) - .pipe(map(() => void 0)); - } - - ensureAntiforgeryReady(forceRefresh = false): Observable { - if (forceRefresh || !this.#antiforgeryReady$) { - this.#antiforgeryReady$ = this.#fetchAntiforgeryToken().pipe(shareReplay({ bufferSize: 1, refCount: false })); - } - - return this.#antiforgeryReady$; - } - - login(credentials: ILoginRequest): Observable { - return this.ensureAntiforgeryReady().pipe( - switchMap(() => this.#http.post(`${this.#apiUrl}/login`, credentials)), - tap((res) => this.#user.set(res.user)), - ); - } - - register(data: IRegisterRequest): Observable { - return this.ensureAntiforgeryReady().pipe( - switchMap(() => this.#http.post(`${this.#apiUrl}/register`, data)), - tap((res) => this.#user.set(res.user)), - ); - } - - #logoutRequest(): Observable { - return this.ensureAntiforgeryReady().pipe( - switchMap(() => this.#http.post(`${this.#apiUrl}/logout`, {})), - tap(() => { - this.#user.set(null); - this.#antiforgeryReady$ = null; - }), - switchMap(() => this.ensureAntiforgeryReady(true)), - ); - } - - logout(): void { - this.#logoutRequest().subscribe({ - next: () => { - void this.#router.navigate(['/auth/login']); - }, - error: () => { - this.#user.set(null); - this.#antiforgeryReady$ = null; - void this.#router.navigate(['/auth/login']); - }, - }); - } -} -``` - -- [ ] **Step 4: Keep layout bootstrapping as an optional warm-up, not a correctness requirement** - -```ts -export class AuthLayoutComponent { - readonly #authService = inject(AuthService); - - constructor() { - this.#authService.ensureAntiforgeryReady().subscribe({ error: () => void 0 }); - } -} -``` - -```ts -export class MainLayoutComponent { - readonly #authService = inject(AuthService); - - constructor() { - this.#authService.ensureAntiforgeryReady().subscribe({ error: () => void 0 }); - } -} -``` - -- [ ] **Step 5: Add a second service test proving logout refreshes antiforgery readiness** - -```ts -it('refreshes antiforgery after logout completes', () => { - service.logout(); - - httpMock.expectOne('/api/auth/antiforgery-token').flush(''); - httpMock.expectOne('/api/auth/logout').flush({}); - httpMock.expectOne('/api/auth/antiforgery-token').flush(''); -}); -``` - -Assert this through the existing public `logout(): void` API. Do not test `#private` helpers directly. - -- [ ] **Step 6: Run the focused frontend auth service tests** - -Run: `yarn --cwd ../wisave-ui test --watch=false --include src/app/core/services/auth.service.spec.ts` -Expected: PASS - -- [ ] **Step 7: Commit the frontend antiforgery sequencing refactor** - -```bash -git -C ../wisave-ui add src/app/core/services/auth.service.ts src/app/core/services/auth.service.spec.ts src/app/layout/auth-layout.component.ts src/app/layout/main-layout.component.ts -git -C ../wisave-ui commit -m "fix(auth): serialize antiforgery bootstrap in auth service" -``` - ---- - -### Task 4: Distinguish Real Logout from Session Bootstrap Failure and Preserve Return URL - -**Files:** -- Create: `../wisave-ui/src/app/core/guards/auth.guard.spec.ts` -- Modify: `../wisave-ui/src/app/core/services/auth.service.ts` -- Modify: `../wisave-ui/src/app/core/guards/auth.guard.ts` -- Modify: `../wisave-ui/src/app/features/auth/views/login.component.ts` -- Modify: `../wisave-ui/src/app/features/auth/views/register.component.ts` -- Create: `../wisave-ui/src/app/features/auth/views/session-unavailable.component.ts` -- Modify: `../wisave-ui/src/app/app.routes.ts` - -- [ ] **Step 1: Write the failing guard test for `returnUrl` preservation** - -```ts -it('redirects unauthenticated users to login with returnUrl', async () => { - const result = await firstValueFrom( - authGuard({} as never, { url: '/expenses/budgets' } as never) as Observable, - ); - - expect(router.serializeUrl(result)).toBe('/auth/login?returnUrl=%2Fexpenses%2Fbudgets'); -}); -``` - -Add a paired failing test for the guest flow: - -```ts -it('redirects guest-guard bootstrap failures to the unguarded session-unavailable route', async () => { - const result = await firstValueFrom( - guestGuard({} as never, { url: '/auth/login' } as never) as Observable, - ); - - expect(router.serializeUrl(result)).toBe('/session-unavailable?returnUrl=%2Fauth%2Flogin'); -}); -``` - -- [ ] **Step 2: Write the failing service test that treats non-401 `/me` failures as unavailable, not logged out** - -```ts -it('classifies 500 from /me as unavailable', () => { - let result: AuthBootstrapResult | undefined; - - service.initialize().subscribe((value) => { - result = value; - }); - - httpMock.expectOne('/api/auth/me').flush('boom', { status: 500, statusText: 'Server Error' }); - - expect(result).toEqual({ kind: 'unavailable', status: 500 }); - expect(service.isInitialized()).toBeFalse(); -}); -``` - -- [ ] **Step 3: Run the focused guard/service tests and verify they fail** - -Run: `yarn --cwd ../wisave-ui test --watch=false --include src/app/core/services/auth.service.spec.ts --include src/app/core/guards/auth.guard.spec.ts` -Expected: FAIL because `initialize()` currently converts every error into logged-out state and guards discard the original destination. - -- [ ] **Step 4: Refactor `initialize()` to return an explicit bootstrap result** - -```ts -initialize(): Observable { - return this.#http.get(`${this.#apiUrl}/me`).pipe( - map((user) => ({ kind: 'authenticated', user }) as const), - tap(({ user }) => { - this.#user.set(user); - this.#initialized.set(true); - }), - catchError((err: HttpErrorResponse) => { - if (err.status === 401) { - this.#user.set(null); - this.#initialized.set(true); - return of({ kind: 'unauthenticated' } as const); - } - - this.#initialized.set(false); - return of({ kind: 'unavailable', status: err.status } as const); - }), - ); -} -``` - -- [ ] **Step 5: Update guards to preserve destination and route transient failures to a retry page** - -```ts -export const authGuard: CanActivateFn = (_route, state) => { - const authService = inject(AuthService); - const router = inject(Router); - - if (authService.isInitialized()) { - return authService.isAuthenticated() - ? true - : router.createUrlTree(['/auth/login'], { queryParams: { returnUrl: state.url } }); - } - - return authService.initialize().pipe( - map((result) => { - if (result.kind === 'authenticated') return true; - if (result.kind === 'unauthenticated') { - return router.createUrlTree(['/auth/login'], { queryParams: { returnUrl: state.url } }); - } - - return router.createUrlTree(['/session-unavailable'], { queryParams: { returnUrl: state.url } }); - }), - ); -}; - -export const guestGuard: CanActivateFn = (_route, state) => { - const authService = inject(AuthService); - const router = inject(Router); - - if (authService.isInitialized()) { - return authService.isAuthenticated() - ? router.createUrlTree(['/incomes']) - : true; - } - - return authService.initialize().pipe( - map((result) => { - if (result.kind === 'authenticated') { - return router.createUrlTree(['/incomes']); - } - if (result.kind === 'unauthenticated') { - return true; - } - - return router.createUrlTree(['/session-unavailable'], { queryParams: { returnUrl: state.url } }); - }), - ); -}; -``` - -- [ ] **Step 6: Add a minimal retry screen that keeps the original destination** - -```ts -@Component({ - selector: 'app-session-unavailable', - template: ` -
-

We couldn't restore your session

-

The server did not confirm whether you are signed in. Retry before logging in again.

-
- `, -}) -export class SessionUnavailableComponent { - readonly #route = inject(ActivatedRoute); - readonly #router = inject(Router); - - readonly returnUrl = signal(this.#route.snapshot.queryParamMap.get('returnUrl') ?? '/incomes'); - - retry(): void { - void this.#router.navigateByUrl(this.returnUrl()); - } -} -``` - -- [ ] **Step 7: Mount the retry screen as an unguarded top-level route** - -```ts -export const routes: Routes = [ - { - path: 'session-unavailable', - loadComponent: () => - import('./features/auth/views/session-unavailable.component').then((m) => m.SessionUnavailableComponent), - }, - { - path: 'auth', - loadComponent: () => import('./layout/auth-layout.component').then((m) => m.AuthLayoutComponent), - canActivate: [guestGuard], - loadChildren: () => import('./features/auth/auth.routes').then((m) => m.routes), - }, - { - path: '', - loadComponent: () => import('./layout/main-layout.component').then((m) => m.MainLayoutComponent), - canActivate: [authGuard], - loadChildren: () => import('./features/features.routing').then((m) => m.routes), - }, -]; -``` - -- [ ] **Step 8: Update login and register success paths to respect `returnUrl`** - -```ts -readonly #route = inject(ActivatedRoute); - -onLogin(credentials: { email: string; password: string }): void { - this.isLoading.set(true); - this.error.set(null); - - this.#authService.login(credentials).subscribe({ - next: () => { - this.isLoading.set(false); - const returnUrl = this.#route.snapshot.queryParamMap.get('returnUrl') ?? '/incomes'; - void this.#router.navigateByUrl(returnUrl); - }, - error: (err: HttpErrorResponse) => { - this.isLoading.set(false); - this.error.set(err.status === 400 - ? 'Security validation expired. Please try again.' - : err.status === 401 - ? 'Invalid email or password.' - : 'Login failed. Please try again.'); - }, - }); -} -``` - -- [ ] **Step 9: Run the focused auth guard and service tests** - -Run: `yarn --cwd ../wisave-ui test --watch=false --include src/app/core/services/auth.service.spec.ts --include src/app/core/guards/auth.guard.spec.ts` -Expected: PASS - -- [ ] **Step 10: Commit the auth-bootstrap and navigation fix** - -```bash -git -C ../wisave-ui add src/app/core/services/auth.service.ts src/app/core/guards/auth.guard.ts src/app/core/guards/auth.guard.spec.ts src/app/features/auth/views/login.component.ts src/app/features/auth/views/register.component.ts src/app/features/auth/views/session-unavailable.component.ts src/app/app.routes.ts -git -C ../wisave-ui commit -m "fix(auth): preserve return paths and separate auth loss from bootstrap failure" -``` - ---- - -### Task 5: Regression Coverage, Docs, and Full Verification - -**Files:** -- Modify: `tests/WiSave.Portal.Tests/Auth/AuthEndpointsTests.cs` -- Modify: `tests/WiSave.Portal.Tests/Session/SessionConfigurationTests.cs` -- Modify: `../wisave-ui/docs/features/auth.md` -- Modify: `../wisave-ui/README.md` - -- [ ] **Step 1: Re-run the focused backend auth/session slice** - -Run: `dotnet test tests/WiSave.Portal.Tests/WiSave.Portal.Tests.csproj --filter "FullyQualifiedName~AuthEndpointsTests|FullyQualifiedName~UserHeaderTransformTests|FullyQualifiedName~SessionConfigurationTests"` -Expected: PASS - -- [ ] **Step 2: Document the actual auth model and failure modes** - -```md -## Auth Bootstrap - -- The browser talks only to `/api` -- The portal owns cookies, session state, and antiforgery -- `GET /api/auth/antiforgery-token` is a bootstrap dependency for guest and authenticated shells -- `GET /api/auth/me` has three outcomes: - - authenticated - - unauthenticated (`401`) - - unavailable (transport/server failure; do not treat as logout) - -## Local Development - -- Run the portal with Redis unless `Session:AllowInMemoryTicketStoreFallback=true` is set intentionally -- In-memory ticket storage is for tests or single-process local debugging only -``` - -- [ ] **Step 3: Run frontend tests and lint** - -Run: `yarn --cwd ../wisave-ui test --watch=false` -Expected: PASS - -Run: `yarn --cwd ../wisave-ui lint` -Expected: PASS - -- [ ] **Step 4: Run the complete portal test suite** - -Run: `dotnet test` -Expected: PASS - -- [ ] **Step 5: Manual smoke test the exact scenarios the user reported** - -Run: -1. `docker compose up -d postgres redis` -2. `dotnet run --project src/WiSave.Portal` -3. `yarn --cwd ../wisave-ui start` - -Manual checks: -1. Open `http://localhost:4200/auth/login` and submit immediately after page load. -Expected: login succeeds; no intermittent `400 Antiforgery token validation failed`. - -2. Register a fresh account from `http://localhost:4200/auth/register`. -Expected: registration succeeds; app lands on the originally intended route or `/incomes`. - -3. Navigate to a protected deep link such as `http://localhost:4200/expenses/budgets`, refresh the page, and observe bootstrap. -Expected: user remains on the same page when `/me` returns `200`; no redirect to `/auth/login`. - -4. Stop Redis temporarily or force a `/me` server failure. -Expected: app shows the session-unavailable retry screen instead of pretending the user is logged out. - -5. Log out, then immediately log back in or register another account. -Expected: guest flow already has a fresh XSRF token; no stuck auth form. - -- [ ] **Step 6: Commit docs and verification fallout** - -```bash -git add tests/WiSave.Portal.Tests/Auth/AuthEndpointsTests.cs tests/WiSave.Portal.Tests/Session/SessionConfigurationTests.cs -git -C ../wisave-ui add README.md docs/features/auth.md -git commit -m "test(auth): add auth stability regression coverage" -git -C ../wisave-ui commit -m "docs(auth): document session bootstrap and local requirements" -``` - ---- - -## Self-Review - -**Spec coverage:** The plan covers the confirmed root causes from the review: -- silent process-local session fallback on the portal -- frontend XSRF/bootstrap race -- treating non-401 `/me` failures as logout -- loss of original destination after forced login -- logout/XSRF inconsistency - -**Placeholder scan:** No `TODO`, `TBD`, or “handle appropriately” placeholders remain. Each task names concrete files, commands, and intended code. - -**Type consistency:** `AuthBootstrapResult`, `ensureAntiforgeryReady()`, `SessionUnavailableComponent`, and `PortalSessionOptions` are referenced consistently across tasks. diff --git a/docs/superpowers/plans/2026-04-12-login-error-response-plan.md b/docs/superpowers/plans/2026-04-12-login-error-response-plan.md deleted file mode 100644 index 877f883..0000000 --- a/docs/superpowers/plans/2026-04-12-login-error-response-plan.md +++ /dev/null @@ -1,148 +0,0 @@ -# Login Error Response Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Return structured `401` payloads from `POST /api/auth/login` that explain whether the login failed because the user was missing, the password was wrong, the account was locked, or sign-in is not allowed. - -**Architecture:** Keep the existing auth endpoint shape and ASP.NET Identity flow. Add one typed auth-error DTO, drive the change from endpoint tests, then update the login handler to translate Identity outcomes into stable failure codes and messages while leaving successful responses unchanged. - -**Tech Stack:** ASP.NET Core minimal APIs, ASP.NET Identity, xUnit, `WebApplicationFactory` - ---- - -### Task 1: Add failing tests for detailed login failures - -**Files:** -- Modify: `tests/WiSave.Portal.Tests/Auth/AuthEndpointsTests.cs` -- Test: `tests/WiSave.Portal.Tests/Auth/AuthEndpointsTests.cs` - -- [ ] **Step 1: Write the failing tests** - -Add assertions that deserialize the `401` body into the new auth-error DTO and check both `code` and `message` for: - -```csharp -[Fact] -public async Task Login_UnknownEmail_Returns401WithUserNotFoundError() -{ - var client = CreateClient(); - - var response = await PostWithAntiforgeryAsync( - client, - "/api/auth/login", - new LoginRequest("missing@example.com", "Password123!")); - - Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); - - var error = await response.Content.ReadFromJsonAsync(CancellationToken); - Assert.NotNull(error); - Assert.Equal("USER_NOT_FOUND", error.Code); -} - -[Fact] -public async Task Login_InvalidPassword_Returns401WithInvalidPasswordError() -{ - var client = CreateClient(); - await RegisterAsync(client, new RegisterRequest("User", "wrong@example.com", "Password123!", "free")); - - var response = await PostWithAntiforgeryAsync( - client, - "/api/auth/login", - new LoginRequest("wrong@example.com", "WrongPassword!")); - - Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); - - var error = await response.Content.ReadFromJsonAsync(CancellationToken); - Assert.NotNull(error); - Assert.Equal("INVALID_PASSWORD", error.Code); -} -``` - -- [ ] **Step 2: Run test to verify it fails** - -Run: `dotnet test --filter "FullyQualifiedName~WiSave.Portal.Tests.Auth.AuthEndpointsTests.Login_UnknownEmail_Returns401WithUserNotFoundError|FullyQualifiedName~WiSave.Portal.Tests.Auth.AuthEndpointsTests.Login_InvalidPassword_Returns401WithInvalidPasswordError"` -Expected: FAIL because `AuthErrorResponse` does not exist and the endpoint returns an empty `401` body. - -- [ ] **Step 3: Add the lockout error test** - -Extend the existing lockout flow with: - -```csharp -var error = await stillLocked.Content.ReadFromJsonAsync(CancellationToken); -Assert.NotNull(error); -Assert.Equal("LOCKED_OUT", error.Code); -``` - -- [ ] **Step 4: Run the focused auth tests and confirm they still fail for the intended reason** - -Run: `dotnet test --filter "FullyQualifiedName~WiSave.Portal.Tests.Auth.AuthEndpointsTests.Login_"` -Expected: FAIL on missing typed error contract or missing response body assertions. - -### Task 2: Implement the typed auth failure contract - -**Files:** -- Modify: `src/WiSave.Portal/Auth/Models/AuthDtos.cs` -- Modify: `src/WiSave.Portal/Endpoints/AuthEndpoints.cs` -- Test: `tests/WiSave.Portal.Tests/Auth/AuthEndpointsTests.cs` - -- [ ] **Step 1: Add the DTO** - -Add: - -```csharp -public record AuthErrorResponse(string Code, string Message); -``` - -- [ ] **Step 2: Update endpoint metadata and login branching** - -Use typed `401` results from the login handler: - -```csharp -.Produces(401) -``` - -and: - -```csharp -if (user is null) -{ - return Results.Json( - new AuthErrorResponse("USER_NOT_FOUND", "No account exists for that email address."), - statusCode: StatusCodes.Status401Unauthorized); -} - -if (result.IsLockedOut) -{ - return Results.Json( - new AuthErrorResponse("LOCKED_OUT", "This account is locked out."), - statusCode: StatusCodes.Status401Unauthorized); -} - -if (result.IsNotAllowed) -{ - return Results.Json( - new AuthErrorResponse("NOT_ALLOWED", "Sign-in is not allowed for this account."), - statusCode: StatusCodes.Status401Unauthorized); -} - -if (!result.Succeeded) -{ - return Results.Json( - new AuthErrorResponse("INVALID_PASSWORD", "The password is incorrect."), - statusCode: StatusCodes.Status401Unauthorized); -} -``` - -- [ ] **Step 3: Run the focused tests to verify they pass** - -Run: `dotnet test --filter "FullyQualifiedName~WiSave.Portal.Tests.Auth.AuthEndpointsTests.Login_"` -Expected: PASS for the login response tests. - -### Task 3: Verify the broader auth surface - -**Files:** -- Test: `tests/WiSave.Portal.Tests/Auth/AuthEndpointsTests.cs` - -- [ ] **Step 1: Run the full auth endpoint test class** - -Run: `dotnet test --filter "FullyQualifiedName~WiSave.Portal.Tests.Auth.AuthEndpointsTests"` -Expected: PASS with no auth regressions. diff --git a/docs/superpowers/plans/2026-04-12-portal-contracts-github-actions-implementation.md b/docs/superpowers/plans/2026-04-12-portal-contracts-github-actions-implementation.md deleted file mode 100644 index d5b1814..0000000 --- a/docs/superpowers/plans/2026-04-12-portal-contracts-github-actions-implementation.md +++ /dev/null @@ -1,70 +0,0 @@ -# WiSave.Portal.Contracts GitHub Actions Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Add a GitHub Actions workflow that publishes `WiSave.Portal.Contracts` to GitHub Packages on every push to `master`. - -**Architecture:** Keep package versioning simple by storing `VersionPrefix` in `WiSave.Portal.Contracts.csproj` and letting the workflow append `${{ github.run_number }}` at pack time. Publish only the contracts package, but validate the repo by running the portal test project first. - -**Tech Stack:** GitHub Actions, .NET 10, NuGet, GitHub Packages - ---- - -### Task 1: Add Package Metadata - -**Files:** -- Modify: `src/WiSave.Portal.Contracts/WiSave.Portal.Contracts.csproj` -- Create: `src/WiSave.Portal.Contracts/README.md` - -- [ ] **Step 1: Add package metadata to the contracts project** - -Set `PackageId`, `VersionPrefix`, `Authors`, `Description`, `RepositoryUrl`, `PackageTags`, and `PackageReadmeFile` in `WiSave.Portal.Contracts.csproj`. - -- [ ] **Step 2: Add a package README** - -Create `src/WiSave.Portal.Contracts/README.md` with package purpose, versioning summary, and a minimal consumer example. - -- [ ] **Step 3: Run a local pack command** - -Run: `dotnet pack src/WiSave.Portal.Contracts/WiSave.Portal.Contracts.csproj -c Release -p:Version=0.1.1 -o /tmp/portal-contracts-pack` -Expected: PASS and produce a `.nupkg` that includes the README metadata. - -### Task 2: Add GitHub Packages Workflow - -**Files:** -- Create: `.github/workflows/publish-portal-contracts.yml` - -- [ ] **Step 1: Add the workflow** - -Create a workflow that: -- triggers on `push` to `master` -- uses current actions versions -- sets `contents: read` and `packages: write` -- restores, builds, tests, packs, and publishes only `WiSave.Portal.Contracts` -- computes package version from project `VersionPrefix` + `github.run_number` -- uploads the package as an artifact in addition to publishing - -- [ ] **Step 2: Inspect the workflow for consistency** - -Verify the project path, package source URL, and version computation match the package metadata. - -### Task 3: Verify Repository State - -**Files:** -- Modify: files from Tasks 1-2 - -- [ ] **Step 1: Run the portal test project** - -Run: `dotnet test tests/WiSave.Portal.Tests/WiSave.Portal.Tests.csproj` -Expected: PASS - -- [ ] **Step 2: Re-run local packing using the workflow version shape** - -Run: `dotnet pack src/WiSave.Portal.Contracts/WiSave.Portal.Contracts.csproj -c Release -p:Version=0.1.999 -o /tmp/portal-contracts-pack` -Expected: PASS - -- [ ] **Step 3: Summarize follow-up** - -Report: -- which secret or token assumptions the workflow uses -- how the downstream repo should configure GitHub Packages diff --git a/docs/superpowers/plans/2026-04-12-portal-contracts-implementation.md b/docs/superpowers/plans/2026-04-12-portal-contracts-implementation.md deleted file mode 100644 index c53352b..0000000 --- a/docs/superpowers/plans/2026-04-12-portal-contracts-implementation.md +++ /dev/null @@ -1,166 +0,0 @@ -# WiSave.Portal.Contracts Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Add a new `WiSave.Portal.Contracts` class library in this repo and switch portal auth-header forwarding and permission constants to use it. - -**Architecture:** Introduce a pure .NET contracts assembly with XML-documented boundary types and constants, then consume it from `WiSave.Portal` without changing runtime behavior. Verify behavior with tests-first updates around header forwarding and contract parsing. - -**Tech Stack:** .NET 10, xUnit v3, ASP.NET Core, YARP - ---- - -### Task 1: Add Contract Parsing Tests - -**Files:** -- Create: `tests/WiSave.Portal.Tests/Contracts/ForwardedUserContextTests.cs` -- Modify: `tests/WiSave.Portal.Tests/WiSave.Portal.Tests.csproj` -- Modify: `WiSave.Portal.slnx` -- Create: `src/WiSave.Portal.Contracts/WiSave.Portal.Contracts.csproj` - -- [ ] **Step 1: Write the failing test** - -```csharp -using WiSave.Portal.Contracts.Authorization; -using WiSave.Portal.Contracts.Identity; - -namespace WiSave.Portal.Tests.Contracts; - -public sealed class ForwardedUserContextTests -{ - [Fact] - public void Read_ReturnsContext_ForValidHeaders() - { - var headers = new Dictionary(StringComparer.OrdinalIgnoreCase) - { - [PortalHeaderNames.UserId] = ["user-1"], - [PortalHeaderNames.UserEmail] = ["user@example.com"], - [PortalHeaderNames.UserPermissions] = [$"{PortalPermissions.Expenses.Read}, {PortalPermissions.Expenses.Write}"], - [PortalHeaderNames.UserRoles] = ["admin, user"] - }; - - var context = ForwardedUserContextReader.Read(headers); - - Assert.NotNull(context); - Assert.Equal("user-1", context.UserId); - Assert.Equal("user@example.com", context.Email); - Assert.Contains(PortalPermissions.Expenses.Read, context.Permissions); - Assert.Contains("admin", context.Roles); - } -} -``` - -- [ ] **Step 2: Run test to verify it fails** - -Run: `dotnet test tests/WiSave.Portal.Tests/WiSave.Portal.Tests.csproj --filter FullyQualifiedName~ForwardedUserContextTests` -Expected: FAIL because `WiSave.Portal.Contracts` types do not exist yet. - -- [ ] **Step 3: Add empty project plumbing** - -Create the contracts project, add a project reference from tests, and add the project to `WiSave.Portal.slnx` so the test project can compile against it. - -- [ ] **Step 4: Run test to verify it still fails for missing implementation** - -Run: `dotnet test tests/WiSave.Portal.Tests/WiSave.Portal.Tests.csproj --filter FullyQualifiedName~ForwardedUserContextTests` -Expected: FAIL because reader/constants are not implemented yet. - -### Task 2: Implement Contracts Assembly - -**Files:** -- Create: `src/WiSave.Portal.Contracts/Authorization/PortalPermissions.cs` -- Create: `src/WiSave.Portal.Contracts/Identity/PortalHeaderNames.cs` -- Create: `src/WiSave.Portal.Contracts/Identity/ForwardedUserContext.cs` -- Create: `src/WiSave.Portal.Contracts/Identity/ForwardedUserContextReader.cs` -- Create: `src/WiSave.Portal.Contracts/Identity/ForwardedUserContextWriter.cs` -- Modify: `src/WiSave.Portal.Contracts/WiSave.Portal.Contracts.csproj` - -- [ ] **Step 1: Write minimal implementation** - -Implement the new contracts types with XML documentation on public types and members. Keep the API pure by using `IReadOnlyDictionary` and `Dictionary` rather than ASP.NET types. - -- [ ] **Step 2: Run the focused contract test** - -Run: `dotnet test tests/WiSave.Portal.Tests/WiSave.Portal.Tests.csproj --filter FullyQualifiedName~ForwardedUserContextTests` -Expected: PASS - -- [ ] **Step 3: Expand contract coverage** - -Add tests for: -- missing optional email -- missing required user id returns `null` -- comma-separated permissions/roles are trimmed and case-insensitive -- writer emits the expected header names and values - -- [ ] **Step 4: Run focused contract tests again** - -Run: `dotnet test tests/WiSave.Portal.Tests/WiSave.Portal.Tests.csproj --filter FullyQualifiedName~ForwardedUserContextTests` -Expected: PASS - -### Task 3: Switch Portal Header Forwarding To Contracts - -**Files:** -- Modify: `src/WiSave.Portal/Gateway/UserHeaderTransform.cs` -- Modify: `src/WiSave.Portal/WiSave.Portal.csproj` -- Modify: `tests/WiSave.Portal.Tests/Gateway/UserHeaderTransformTests.cs` - -- [ ] **Step 1: Write the failing regression assertion** - -Replace string literal header names in `UserHeaderTransformTests` with `PortalHeaderNames` and add an assertion that `X-User-Permissions` contains package-defined expenses permission constants when seeded through the new contract types. - -- [ ] **Step 2: Run the gateway tests to verify failure if any contract assumptions are wrong** - -Run: `dotnet test tests/WiSave.Portal.Tests/WiSave.Portal.Tests.csproj --filter FullyQualifiedName~UserHeaderTransformTests` -Expected: FAIL until `UserHeaderTransform` uses the new shared writer correctly. - -- [ ] **Step 3: Implement the portal wiring** - -Update `UserHeaderTransform` to: -- use `PortalHeaderNames` -- create a `ForwardedUserContext` -- use `ForwardedUserContextWriter` to emit proxy headers - -Add a project reference from `WiSave.Portal` to `WiSave.Portal.Contracts`. - -- [ ] **Step 4: Run gateway tests again** - -Run: `dotnet test tests/WiSave.Portal.Tests/WiSave.Portal.Tests.csproj --filter FullyQualifiedName~UserHeaderTransformTests` -Expected: PASS - -### Task 4: Replace Portal Permission String Literals In Code And Tests - -**Files:** -- Modify: `tests/WiSave.Portal.Tests/Authorization/PermissionHandlerTests.cs` -- Modify: `tests/WiSave.Portal.Tests/Auth/AuthEndpointsTests.cs` -- Modify: `tests/WiSave.Portal.Tests/Gateway/UserHeaderTransformTests.cs` - -- [ ] **Step 1: Write or adjust failing tests to use `PortalPermissions`** - -Replace duplicated permission literals in tests with shared constants. Keep SQL scripts unchanged in this task. - -- [ ] **Step 2: Run the targeted test set** - -Run: `dotnet test tests/WiSave.Portal.Tests/WiSave.Portal.Tests.csproj --filter "FullyQualifiedName~PermissionHandlerTests|FullyQualifiedName~AuthEndpointsTests|FullyQualifiedName~UserHeaderTransformTests"` -Expected: PASS if runtime behavior is unchanged. - -### Task 5: Final Verification - -**Files:** -- Modify: `.gitignore` -- Create/Modify: files from Tasks 1-4 - -- [ ] **Step 1: Run the contracts and gateway-focused tests** - -Run: `dotnet test tests/WiSave.Portal.Tests/WiSave.Portal.Tests.csproj --filter "FullyQualifiedName~ForwardedUserContextTests|FullyQualifiedName~UserHeaderTransformTests|FullyQualifiedName~PermissionHandlerTests"` -Expected: PASS - -- [ ] **Step 2: Run the full portal test project if the targeted set is green** - -Run: `dotnet test tests/WiSave.Portal.Tests/WiSave.Portal.Tests.csproj` -Expected: PASS, or a clear report of any unrelated pre-existing failure. - -- [ ] **Step 3: Summarize the implementation** - -Report: -- files changed -- verification run -- remaining follow-up for `wisave-expenses` diff --git a/docs/superpowers/plans/2026-04-12-test-layer-separation.md b/docs/superpowers/plans/2026-04-12-test-layer-separation.md deleted file mode 100644 index a29eb25..0000000 --- a/docs/superpowers/plans/2026-04-12-test-layer-separation.md +++ /dev/null @@ -1,466 +0,0 @@ -# Portal Test Layer Separation Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Split the mixed portal test project into explicit unit and integration test projects, keep the publish workflow limited to unit tests, and introduce a separate validation workflow that runs both layers. - -**Architecture:** The change is a mechanical repository refactor. Existing isolated tests move into a new unit test project, existing `WebApplicationFactory` tests move into a new integration test project, and CI is updated so publish stays fast while validation preserves integration coverage. The old mixed project is removed only after both new projects are green. - -**Tech Stack:** .NET 10, xUnit v3, ASP.NET Core `WebApplicationFactory`, GitHub Actions, solution file (`.slnx`) maintenance - ---- - -## File Map - -- Create: `tests/WiSave.Portal.UnitTests/WiSave.Portal.UnitTests.csproj` — package and project references for isolated tests only. -- Create: `tests/WiSave.Portal.UnitTests/Authorization/PermissionHandlerTests.cs` — moved unit test file with updated namespace. -- Create: `tests/WiSave.Portal.UnitTests/Contracts/ForwardedUserContextTests.cs` — moved unit test file with updated namespace. -- Create: `tests/WiSave.Portal.UnitTests/Session/SessionConfigurationTests.cs` — moved unit test file with updated namespace. -- Create: `tests/WiSave.Portal.IntegrationTests/WiSave.Portal.IntegrationTests.csproj` — package and project references for host-boot integration tests. -- Create: `tests/WiSave.Portal.IntegrationTests/Auth/AuthEndpointsTests.cs` — moved integration test file with updated namespace. -- Create: `tests/WiSave.Portal.IntegrationTests/Gateway/UserHeaderTransformTests.cs` — moved integration test file with updated namespace. -- Create: `tests/WiSave.Portal.IntegrationTests/Hubs/NotificationsHubTests.cs` — moved integration test file with updated namespace. -- Create: `tests/WiSave.Portal.IntegrationTests/Messaging/ConsumerSignalRTests.cs` — moved integration test file with updated namespace. -- Create: `.github/workflows/portal-validation.yml` — dedicated validation workflow for unit and integration suites. -- Modify: `WiSave.Portal.slnx` — include the new test projects and remove the old mixed project. -- Modify: `.github/workflows/publish-portal-contracts.yml` — point the publish workflow at the new unit test project. -- Delete: `tests/WiSave.Portal.Tests/WiSave.Portal.Tests.csproj` -- Delete: `tests/WiSave.Portal.Tests/Auth/AuthEndpointsTests.cs` -- Delete: `tests/WiSave.Portal.Tests/Authorization/PermissionHandlerTests.cs` -- Delete: `tests/WiSave.Portal.Tests/Contracts/ForwardedUserContextTests.cs` -- Delete: `tests/WiSave.Portal.Tests/Gateway/UserHeaderTransformTests.cs` -- Delete: `tests/WiSave.Portal.Tests/Hubs/NotificationsHubTests.cs` -- Delete: `tests/WiSave.Portal.Tests/Messaging/ConsumerSignalRTests.cs` -- Delete: `tests/WiSave.Portal.Tests/Session/SessionConfigurationTests.cs` - -### Task 1: Create the Unit Test Project - -**Files:** -- Create: `tests/WiSave.Portal.UnitTests/WiSave.Portal.UnitTests.csproj` -- Create: `tests/WiSave.Portal.UnitTests/Authorization/PermissionHandlerTests.cs` -- Create: `tests/WiSave.Portal.UnitTests/Contracts/ForwardedUserContextTests.cs` -- Create: `tests/WiSave.Portal.UnitTests/Session/SessionConfigurationTests.cs` -- Delete: `tests/WiSave.Portal.Tests/Authorization/PermissionHandlerTests.cs` -- Delete: `tests/WiSave.Portal.Tests/Contracts/ForwardedUserContextTests.cs` -- Delete: `tests/WiSave.Portal.Tests/Session/SessionConfigurationTests.cs` - -- [ ] **Step 1: Prove the unit test project does not exist yet** - -Run: - -```bash -dotnet test tests/WiSave.Portal.UnitTests/WiSave.Portal.UnitTests.csproj -``` - -Expected: FAIL with output indicating the project file does not exist. - -- [ ] **Step 2: Create the new unit test project file** - -Write `tests/WiSave.Portal.UnitTests/WiSave.Portal.UnitTests.csproj` with: - -```xml - - - net10.0 - enable - enable - Exe - false - - - - - - - - - - - - - -``` - -- [ ] **Step 3: Move the isolated tests into the new unit project** - -Run: - -```bash -mkdir -p tests/WiSave.Portal.UnitTests/Authorization tests/WiSave.Portal.UnitTests/Contracts tests/WiSave.Portal.UnitTests/Session -mv tests/WiSave.Portal.Tests/Authorization/PermissionHandlerTests.cs tests/WiSave.Portal.UnitTests/Authorization/PermissionHandlerTests.cs -mv tests/WiSave.Portal.Tests/Contracts/ForwardedUserContextTests.cs tests/WiSave.Portal.UnitTests/Contracts/ForwardedUserContextTests.cs -mv tests/WiSave.Portal.Tests/Session/SessionConfigurationTests.cs tests/WiSave.Portal.UnitTests/Session/SessionConfigurationTests.cs -``` - -- [ ] **Step 4: Update unit test namespaces to match the new project** - -Change the namespace declarations to: - -```csharp -namespace WiSave.Portal.UnitTests.Authorization; -``` - -```csharp -namespace WiSave.Portal.UnitTests.Contracts; -``` - -```csharp -namespace WiSave.Portal.UnitTests.Session; -``` - -- [ ] **Step 5: Run the unit-only suite to verify it passes** - -Run: - -```bash -dotnet test tests/WiSave.Portal.UnitTests/WiSave.Portal.UnitTests.csproj -``` - -Expected: PASS with only the moved isolated tests executing. - -- [ ] **Step 6: Commit the unit project split** - -Run: - -```bash -git add tests/WiSave.Portal.UnitTests tests/WiSave.Portal.Tests/Authorization/PermissionHandlerTests.cs tests/WiSave.Portal.Tests/Contracts/ForwardedUserContextTests.cs tests/WiSave.Portal.Tests/Session/SessionConfigurationTests.cs -git commit -m "test: split portal unit tests into dedicated project" -``` - -### Task 2: Create the Integration Test Project - -**Files:** -- Create: `tests/WiSave.Portal.IntegrationTests/WiSave.Portal.IntegrationTests.csproj` -- Create: `tests/WiSave.Portal.IntegrationTests/Auth/AuthEndpointsTests.cs` -- Create: `tests/WiSave.Portal.IntegrationTests/Gateway/UserHeaderTransformTests.cs` -- Create: `tests/WiSave.Portal.IntegrationTests/Hubs/NotificationsHubTests.cs` -- Create: `tests/WiSave.Portal.IntegrationTests/Messaging/ConsumerSignalRTests.cs` -- Delete: `tests/WiSave.Portal.Tests/Auth/AuthEndpointsTests.cs` -- Delete: `tests/WiSave.Portal.Tests/Gateway/UserHeaderTransformTests.cs` -- Delete: `tests/WiSave.Portal.Tests/Hubs/NotificationsHubTests.cs` -- Delete: `tests/WiSave.Portal.Tests/Messaging/ConsumerSignalRTests.cs` - -- [ ] **Step 1: Prove the integration test project does not exist yet** - -Run: - -```bash -dotnet test tests/WiSave.Portal.IntegrationTests/WiSave.Portal.IntegrationTests.csproj -``` - -Expected: FAIL with output indicating the project file does not exist. - -- [ ] **Step 2: Create the integration test project file with the current host-testing dependencies** - -Write `tests/WiSave.Portal.IntegrationTests/WiSave.Portal.IntegrationTests.csproj` with: - -```xml - - - net10.0 - enable - enable - Exe - false - - - - - - - - - - - - - - - - - - -``` - -- [ ] **Step 3: Move the `WebApplicationFactory` tests into the integration project** - -Run: - -```bash -mkdir -p tests/WiSave.Portal.IntegrationTests/Auth tests/WiSave.Portal.IntegrationTests/Gateway tests/WiSave.Portal.IntegrationTests/Hubs tests/WiSave.Portal.IntegrationTests/Messaging -mv tests/WiSave.Portal.Tests/Auth/AuthEndpointsTests.cs tests/WiSave.Portal.IntegrationTests/Auth/AuthEndpointsTests.cs -mv tests/WiSave.Portal.Tests/Gateway/UserHeaderTransformTests.cs tests/WiSave.Portal.IntegrationTests/Gateway/UserHeaderTransformTests.cs -mv tests/WiSave.Portal.Tests/Hubs/NotificationsHubTests.cs tests/WiSave.Portal.IntegrationTests/Hubs/NotificationsHubTests.cs -mv tests/WiSave.Portal.Tests/Messaging/ConsumerSignalRTests.cs tests/WiSave.Portal.IntegrationTests/Messaging/ConsumerSignalRTests.cs -``` - -- [ ] **Step 4: Update integration test namespaces to match the new project** - -Change the namespace declarations to: - -```csharp -namespace WiSave.Portal.IntegrationTests.Auth; -``` - -```csharp -namespace WiSave.Portal.IntegrationTests.Gateway; -``` - -```csharp -namespace WiSave.Portal.IntegrationTests.Hubs; -``` - -```csharp -namespace WiSave.Portal.IntegrationTests.Messaging; -``` - -- [ ] **Step 5: Run the integration-only suite to verify the moved host-based tests pass** - -Run: - -```bash -dotnet test tests/WiSave.Portal.IntegrationTests/WiSave.Portal.IntegrationTests.csproj -``` - -Expected: PASS with the `WebApplicationFactory`-based tests executing from the new project. - -- [ ] **Step 6: Commit the integration project split** - -Run: - -```bash -git add tests/WiSave.Portal.IntegrationTests tests/WiSave.Portal.Tests/Auth/AuthEndpointsTests.cs tests/WiSave.Portal.Tests/Gateway/UserHeaderTransformTests.cs tests/WiSave.Portal.Tests/Hubs/NotificationsHubTests.cs tests/WiSave.Portal.Tests/Messaging/ConsumerSignalRTests.cs -git commit -m "test: split portal integration tests into dedicated project" -``` - -### Task 3: Rewire the Solution and Remove the Mixed Test Project - -**Files:** -- Modify: `WiSave.Portal.slnx` -- Delete: `tests/WiSave.Portal.Tests/WiSave.Portal.Tests.csproj` - -- [ ] **Step 1: Update the solution to reference the two new test projects** - -Replace the current single test project entry in `WiSave.Portal.slnx`: - -```xml - - - - - - -``` - -with: - -```xml - - - - - - - - -``` - -- [ ] **Step 2: Remove the old mixed test project file** - -Run: - -```bash -rm tests/WiSave.Portal.Tests/WiSave.Portal.Tests.csproj -``` - -- [ ] **Step 3: Verify the solution sees the new test projects** - -Run: - -```bash -dotnet sln WiSave.Portal.slnx list -``` - -Expected: output includes: - -```text -tests/WiSave.Portal.UnitTests/WiSave.Portal.UnitTests.csproj -tests/WiSave.Portal.IntegrationTests/WiSave.Portal.IntegrationTests.csproj -``` - -- [ ] **Step 4: Run both suites through their new project boundaries** - -Run: - -```bash -dotnet test tests/WiSave.Portal.UnitTests/WiSave.Portal.UnitTests.csproj -dotnet test tests/WiSave.Portal.IntegrationTests/WiSave.Portal.IntegrationTests.csproj -``` - -Expected: both commands PASS and no test depends on the deleted mixed project. - -- [ ] **Step 5: Commit the solution rewire** - -Run: - -```bash -git add WiSave.Portal.slnx tests/WiSave.Portal.Tests/WiSave.Portal.Tests.csproj -git commit -m "build: replace mixed portal test project with explicit test layers" -``` - -### Task 4: Update CI for the New Test Layers - -**Files:** -- Modify: `.github/workflows/publish-portal-contracts.yml` -- Create: `.github/workflows/portal-validation.yml` - -- [ ] **Step 1: Prove the current publish workflow still points at the mixed test project** - -Run: - -```bash -rg -n "WiSave\\.Portal\\.Tests/WiSave\\.Portal\\.Tests\\.csproj" .github/workflows/publish-portal-contracts.yml -``` - -Expected: one match in the `Run portal tests` step. - -- [ ] **Step 2: Update the publish workflow to run only unit tests** - -Change the test step in `.github/workflows/publish-portal-contracts.yml` to: - -```yaml - - name: Run portal unit tests - run: dotnet test tests/WiSave.Portal.UnitTests/WiSave.Portal.UnitTests.csproj --configuration Release --no-restore -``` - -- [ ] **Step 3: Add the dedicated validation workflow for unit and integration suites** - -Write `.github/workflows/portal-validation.yml` with: - -```yaml -name: Validate WiSave.Portal - -on: - pull_request: - push: - -permissions: - contents: read - packages: read - -concurrency: - group: portal-validation-${{ github.ref }} - cancel-in-progress: true - -jobs: - validate: - runs-on: ubuntu-latest - env: - PACKAGES_USERNAME: ${{ github.repository_owner }} - PACKAGES_TOKEN: ${{ secrets.PACKAGES_READ_TOKEN }} - - steps: - - name: Checkout repository - uses: actions/checkout@v6 - - - name: Setup .NET SDK - uses: actions/setup-dotnet@v5 - with: - dotnet-version: 10.0.x - - - name: Validate GitHub Packages credentials - shell: bash - run: | - if [ -z "${PACKAGES_TOKEN}" ]; then - echo "::error title=Missing GitHub Packages token::Repository secret PACKAGES_READ_TOKEN is not set." - exit 1 - fi - - - name: Restore solution - env: - NuGetPackageSourceCredentials_github: Username=${{ env.PACKAGES_USERNAME }};Password=${{ env.PACKAGES_TOKEN }} - run: dotnet restore WiSave.Portal.slnx --configfile NuGet.Config - - - name: Run portal unit tests - run: dotnet test tests/WiSave.Portal.UnitTests/WiSave.Portal.UnitTests.csproj --configuration Release --no-restore - - - name: Run portal integration tests - run: dotnet test tests/WiSave.Portal.IntegrationTests/WiSave.Portal.IntegrationTests.csproj --configuration Release --no-restore -``` - -- [ ] **Step 4: Verify the workflow references are clean** - -Run: - -```bash -rg -n "WiSave\\.Portal\\.Tests/WiSave\\.Portal\\.Tests\\.csproj|WiSave\\.Portal\\.UnitTests|WiSave\\.Portal\\.IntegrationTests" .github/workflows -``` - -Expected: - -- `publish-portal-contracts.yml` references only `WiSave.Portal.UnitTests` -- `portal-validation.yml` references both `WiSave.Portal.UnitTests` and `WiSave.Portal.IntegrationTests` -- no workflow references the deleted mixed project - -- [ ] **Step 5: Commit the CI split** - -Run: - -```bash -git add .github/workflows/publish-portal-contracts.yml .github/workflows/portal-validation.yml -git commit -m "ci: separate portal unit and integration test workflows" -``` - -### Task 5: Final Verification and Cleanup - -**Files:** -- Modify: all files from Tasks 1-4 as needed for final corrections - -- [ ] **Step 1: Run the full validation sequence locally** - -Run: - -```bash -dotnet test tests/WiSave.Portal.UnitTests/WiSave.Portal.UnitTests.csproj -dotnet test tests/WiSave.Portal.IntegrationTests/WiSave.Portal.IntegrationTests.csproj -git diff --check -``` - -Expected: - -- both test commands PASS -- `git diff --check` prints no whitespace or conflict issues - -- [ ] **Step 2: Run a final search for stale mixed-project references** - -Run: - -```bash -rg -n "tests/WiSave\\.Portal\\.Tests|WiSave\\.Portal\\.Tests\\.csproj" WiSave.Portal.slnx .github tests docs -``` - -Expected: - -- no matches in `WiSave.Portal.slnx` or `.github/workflows` -- historical docs may still match; only update them if they are active guidance and still misleading - -- [ ] **Step 3: Stage and commit any final fixes** - -Run: - -```bash -git add WiSave.Portal.slnx .github/workflows tests -git commit -m "chore: finalize portal test layer separation" -``` - -- [ ] **Step 4: Prepare the branch handoff** - -Report: - -```text -Unit test project: tests/WiSave.Portal.UnitTests -Integration test project: tests/WiSave.Portal.IntegrationTests -Publish workflow: unit tests only -Validation workflow: unit + integration -Reserved future layer: tests/WiSave.Portal.E2E -``` diff --git a/docs/superpowers/plans/2026-04-14-portal-multibus-implementation.md b/docs/superpowers/plans/2026-04-14-portal-multibus-implementation.md deleted file mode 100644 index 0999023..0000000 --- a/docs/superpowers/plans/2026-04-14-portal-multibus-implementation.md +++ /dev/null @@ -1,359 +0,0 @@ -# Portal Multi-Bus Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Add full MassTransit multi-bus support to `WiSave.Portal` and align integration tests so they publish through the typed expenses bus path instead of a separate default harness bus. - -**Architecture:** Keep messaging registration centralized in `src/WiSave.Portal/Messaging/Extensions.cs`, add explicit configuration and registration for `IExpensesBus` and `IPortalBus`, and keep bus ownership explicit through `Bind` for any non-consumer publishing surface. Update the messaging integration tests to mirror the typed bus registration shape used by the app, using in-memory transport for test determinism. - -**Tech Stack:** ASP.NET Core, MassTransit, RabbitMQ, xUnit v3, WebApplicationFactory - ---- - -### Task 1: Write The Failing Multi-Bus Messaging Tests - -**Files:** -- Modify: `tests/WiSave.Portal.IntegrationTests/Messaging/ConsumerSignalRTests.cs` -- Test: `tests/WiSave.Portal.IntegrationTests/Messaging/ConsumerSignalRTests.cs` - -- [ ] **Step 1: Replace the default test harness dependency with typed-bus access in the test file** - -Update the test setup so it no longer assumes `ITestHarness` is the correct publishing surface. Add the portal bus contract namespace and prepare the test to resolve a typed publish endpoint. - -```csharp -using MassTransit; -using Microsoft.Extensions.DependencyInjection; -using WiSave.Expenses.Contracts.Bus; -using WiSave.Portal.Contracts.Bus; -``` - -Replace direct `ITestHarness` usage sites with a helper call that will resolve a bus-bound publish endpoint: - -```csharp -await PublishOnExpensesBus(new ExpenseRecorded( - ExpenseId: "exp-1", - UserId: userId, - AccountId: "acc-1", - CategoryId: "cat-1", - SubcategoryId: null, - Amount: 99.99m, - Currency: Currency.PLN, - Date: new DateOnly(2026, 4, 1), - Description: "Test expense", - Recurring: false, - Metadata: null, - Timestamp: DateTimeOffset.UtcNow -)); -``` - -Add the helper skeleton at the bottom of the file: - -```csharp -private async Task PublishOnExpensesBus(T message) - where T : class -{ - using var scope = _factory.Services.CreateScope(); - var publishEndpoint = scope.ServiceProvider - .GetRequiredService>(); - - await publishEndpoint.Value.Publish(message, CancellationToken); -} -``` - -- [ ] **Step 2: Add an explicit failing test that proves the typed expenses bus must be registered** - -Add this test near the top of the class: - -```csharp -[Fact] -public void Services_ExposeTypedExpensesBusPublishEndpoint() -{ - using var scope = _factory.Services.CreateScope(); - - var publishEndpoint = scope.ServiceProvider.GetService>(); - var portalBus = scope.ServiceProvider.GetService>(); - - Assert.NotNull(publishEndpoint); - Assert.NotNull(portalBus); -} -``` - -This test should fail until the app registers both typed buses and exposes the bound publish endpoint for the expenses bus. - -- [ ] **Step 3: Run the targeted test to verify RED** - -Run: - -```bash -dotnet test --filter "FullyQualifiedName~WiSave.Portal.IntegrationTests.Messaging.ConsumerSignalRTests.Services_ExposeTypedExpensesBusPublishEndpoint" -``` - -Expected: -- FAIL -- The failure should indicate that `Bind` and/or `IBusInstance` is not available from the container. - -- [ ] **Step 4: Run the existing messaging tests to capture the current mismatch** - -Run: - -```bash -dotnet test --filter "FullyQualifiedName~WiSave.Portal.IntegrationTests.Messaging.ConsumerSignalRTests" -``` - -Expected: -- FAIL -- Existing messaging tests should still fail because the test host publishes on the wrong bus shape and the auth/antiforgery path is currently unstable. - -- [ ] **Step 5: Commit the red tests** - -```bash -git add tests/WiSave.Portal.IntegrationTests/Messaging/ConsumerSignalRTests.cs -git commit -m "test: define typed bus expectations for messaging integration tests" -``` - -### Task 2: Implement Runtime Multi-Bus Registration - -**Files:** -- Modify: `src/WiSave.Portal/Messaging/Extensions.cs` -- Modify: `src/WiSave.Portal.Contracts/Bus/IPortalBus.cs` (only if XML docs or namespace cleanup is needed) -- Test: `tests/WiSave.Portal.IntegrationTests/Messaging/ConsumerSignalRTests.cs` - -- [ ] **Step 1: Expand messaging configuration to support per-bus settings while preserving current defaults** - -Replace the current flat setting reads in `src/WiSave.Portal/Messaging/Extensions.cs` with a helper record and loader: - -```csharp -using MassTransit; -using WiSave.Expenses.Contracts.Bus; -using WiSave.Portal.Contracts.Bus; - -namespace WiSave.Portal.Messaging; - -public static class Extensions -{ - public static IServiceCollection AddPortalMessaging( - this IServiceCollection services, - IConfiguration configuration) - { - var expensesSettings = GetBusSettings(configuration, "Expenses", "expenses"); - var portalSettings = GetBusSettings(configuration, "Portal", "portal"); - - services.AddMassTransit(x => - { - x.AddConsumer(); - x.SetEndpointNameFormatter(new DefaultEndpointNameFormatter(".", null, true)); - x.UsingRabbitMq((context, cfg) => - { - ConfigureRabbitMqHost(cfg, expensesSettings); - cfg.ConfigureEndpoints(context); - }); - }); - - services.AddMassTransit(x => - { - x.SetEndpointNameFormatter(new DefaultEndpointNameFormatter(".", null, true)); - x.UsingRabbitMq((context, cfg) => - { - ConfigureRabbitMqHost(cfg, portalSettings); - cfg.ConfigureEndpoints(context); - }); - }); - - return services; - } - - private static RabbitMqBusSettings GetBusSettings( - IConfiguration configuration, - string sectionName, - string defaultVirtualHost) - { - var section = configuration.GetSection($"RabbitMq:{sectionName}"); - - return new RabbitMqBusSettings( - Host: section["Host"] ?? configuration["RabbitMq:Host"] ?? "localhost", - VirtualHost: section["VirtualHost"] ?? configuration["RabbitMq:VirtualHost"] ?? defaultVirtualHost, - Username: section["Username"] ?? configuration["RabbitMq:Username"] ?? "guest", - Password: section["Password"] ?? configuration["RabbitMq:Password"] ?? "guest" - ); - } - - private static void ConfigureRabbitMqHost( - IRabbitMqBusFactoryConfigurator cfg, - RabbitMqBusSettings settings) - { - cfg.Host(settings.Host, settings.VirtualHost, h => - { - h.Username(settings.Username); - h.Password(settings.Password); - }); - } - - private sealed record RabbitMqBusSettings( - string Host, - string VirtualHost, - string Username, - string Password); -} -``` - -- [ ] **Step 2: Run the focused container test to verify the minimal implementation turns GREEN** - -Run: - -```bash -dotnet test --filter "FullyQualifiedName~WiSave.Portal.IntegrationTests.Messaging.ConsumerSignalRTests.Services_ExposeTypedExpensesBusPublishEndpoint" -``` - -Expected: -- PASS -- `IExpensesBus` publish binding and `IPortalBus` bus instance should now resolve from DI. - -- [ ] **Step 3: Refactor only if needed to keep the registration readable** - -If `Extensions.cs` is still noisy after the helper extraction, keep the file as-is unless there is clear duplication. Do not introduce additional abstractions beyond the settings helper record and host helper above. - -- [ ] **Step 4: Run the messaging integration tests again to see the remaining failures clearly** - -Run: - -```bash -dotnet test --filter "FullyQualifiedName~WiSave.Portal.IntegrationTests.Messaging.ConsumerSignalRTests" -``` - -Expected: -- Some failures may remain. -- The failure mode should shift away from missing typed bus registrations and toward either test-host transport wiring or the pre-existing auth/antiforgery issue. - -- [ ] **Step 5: Commit the runtime multi-bus registration** - -```bash -git add src/WiSave.Portal/Messaging/Extensions.cs src/WiSave.Portal.Contracts/Bus/IPortalBus.cs tests/WiSave.Portal.IntegrationTests/Messaging/ConsumerSignalRTests.cs -git commit -m "feat: register portal multibus messaging" -``` - -### Task 3: Align Integration Tests With The Typed Expenses Bus - -**Files:** -- Modify: `tests/WiSave.Portal.IntegrationTests/Messaging/ConsumerSignalRTests.cs` -- Test: `tests/WiSave.Portal.IntegrationTests/Messaging/ConsumerSignalRTests.cs` - -- [ ] **Step 1: Replace the default MassTransit test harness registration with typed-bus-aware in-memory registration** - -Inside the `WithWebHostBuilder` test setup, remove the default harness call and replace it with MassTransit registrations that mirror production bus identities: - -```csharp -builder.ConfigureServices(services => -{ - services.AddMassTransit(x => - { - x.AddConsumer(); - x.SetEndpointNameFormatter(new DefaultEndpointNameFormatter(".", null, true)); - x.UsingInMemory((context, cfg) => - { - cfg.ConfigureEndpoints(context); - }); - }); - - services.AddMassTransit(x => - { - x.SetEndpointNameFormatter(new DefaultEndpointNameFormatter(".", null, true)); - x.UsingInMemory((context, cfg) => - { - cfg.ConfigureEndpoints(context); - }); - }); -}); -``` - -If the existing app registration causes duplicate bus registration conflicts in the test host, remove the production MassTransit registrations from the service collection first, then add the in-memory typed-bus registrations back in the same builder block. - -- [ ] **Step 2: Update the message-publishing tests to use the helper consistently** - -Change all three event tests to publish through the helper rather than through `ITestHarness`: - -```csharp -await PublishOnExpensesBus(new CommandFailed( - CorrelationId: correlationId, - UserId: userId, - CommandType: "RecordExpense", - Reason: "Insufficient funds", - Timestamp: DateTimeOffset.UtcNow -)); -``` - -Remove the now-unused `MassTransit.Testing` dependency from the file if nothing else uses it. - -- [ ] **Step 3: Run the messaging integration tests to verify GREEN** - -Run: - -```bash -dotnet test --filter "FullyQualifiedName~WiSave.Portal.IntegrationTests.Messaging.ConsumerSignalRTests" -``` - -Expected: -- PASS for the typed-bus exposure test. -- The three SignalR messaging tests should pass if the remaining auth setup is healthy. -- If auth/antiforgery failures remain, capture them explicitly and do not claim full green until resolved. - -- [ ] **Step 4: Run broader verification** - -Run: - -```bash -dotnet build -dotnet test --filter "FullyQualifiedName~WiSave.Portal.IntegrationTests.Messaging" -``` - -Expected: -- `dotnet build` passes. -- Messaging-focused integration tests pass, or any remaining failures are isolated and documented with exact test names. - -- [ ] **Step 5: Commit the test-host alignment** - -```bash -git add tests/WiSave.Portal.IntegrationTests/Messaging/ConsumerSignalRTests.cs -git commit -m "test: align messaging integration tests with typed buses" -``` - -### Task 4: Final Verification And Handoff - -**Files:** -- Review only: `src/WiSave.Portal/Messaging/Extensions.cs` -- Review only: `tests/WiSave.Portal.IntegrationTests/Messaging/ConsumerSignalRTests.cs` - -- [ ] **Step 1: Run final verification commands** - -Run: - -```bash -dotnet build -dotnet test --filter "FullyQualifiedName~WiSave.Portal.IntegrationTests.Messaging.ConsumerSignalRTests" -``` - -Expected: -- Build succeeds. -- Messaging integration tests pass, or remaining failures are captured exactly. - -- [ ] **Step 2: Review the diff for scope control** - -Run: - -```bash -git diff --stat HEAD~3..HEAD -git diff -- src/WiSave.Portal/Messaging/Extensions.cs tests/WiSave.Portal.IntegrationTests/Messaging/ConsumerSignalRTests.cs -``` - -Expected: -- Only messaging registration and targeted messaging tests should have changed for this feature. - -- [ ] **Step 3: Prepare the handoff summary** - -The final handoff must state: - -```text -- what changed in runtime multi-bus registration -- what changed in typed-bus integration test wiring -- verification commands run and their actual results -- any remaining auth/antiforgery failures, if still present -``` diff --git a/docs/superpowers/plans/2026-04-19-portal-account-signalr-propagation.md b/docs/superpowers/plans/2026-04-19-portal-account-signalr-propagation.md deleted file mode 100644 index 952b808..0000000 --- a/docs/superpowers/plans/2026-04-19-portal-account-signalr-propagation.md +++ /dev/null @@ -1,895 +0,0 @@ -# Portal Account SignalR Propagation Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Make `wisave-portal` emit explicit FE-facing full account snapshots for `account.opened` and `account.updated`, then make `wisave-ui` consume those snapshots by replacing account entities instead of merging partial updates. - -**Architecture:** Keep the existing `RealtimeEnvelope` and existing `NotificationConsumer`, but carve out an explicit account-specific adapter path inside the portal. On the UI side, unify `account.opened` and `account.updated` around one full-snapshot payload shape and one mapper, then remove shape-sensitive merge logic from the account SignalR feature. - -**Tech Stack:** ASP.NET Core SignalR, MassTransit, xUnit, Angular 21, NGRX Signals, Vitest, Yarn 4 - -**Spec:** `docs/superpowers/specs/2026-04-19-portal-account-signalr-propagation-design.md` - -**Working tree note:** Both `wisave-portal` and `wisave-ui` already contain unrelated local changes. Every commit in this plan is path-limited on purpose. Do not use broad `git add .` or broad repo-wide commits while executing it. - -**Precondition:** `wisave-portal` must reference a `WiSave.Expenses.Contracts` package version that already includes `AccountOpened` and `AccountUpdated` with `Variant`, `PreviousCycleDebt`, and `CurrentCycleDebt`. If that package is not yet published or not yet consumed by `src/WiSave.Portal/WiSave.Portal.csproj`, stop and resolve that dependency first before starting implementation. - ---- - -### End-To-End Event Flow - -```mermaid -flowchart LR - A[wisave-expenses
AccountOpened / AccountUpdated] --> B[MassTransit expenses bus] - B --> C[wisave-portal
NotificationConsumer] - C --> D[Map to AccountPayload] - D --> E[Wrap in RealtimeEnvelope
domain=expenses] - E --> F[NotificationsHub.SendAsync
user SignalR group] - F --> G[wisave-ui
PortalSignalRService] - G --> H[ExpensesSignalRService
accountOpened$ / accountUpdated$] - H --> I[withAccountsSignalR
mapAccountFromSignalR] - I --> J[accounts store
accountUpsertedSignalR] - J --> K[account cards / totals / effective balance] - - L[account.closed] --> M[accountRemovedSignalR] - M --> J - - N[SignalR reconnect] --> O[accountsPageEvents.opened] - O --> J -``` - -1. `wisave-expenses` publishes a full-snapshot `AccountOpened` or `AccountUpdated` event on the expenses bus. -2. `wisave-portal` `NotificationConsumer` consumes that contracts event from MassTransit. -3. `NotificationConsumer` maps the contracts event into `AccountPayload` and wraps it in `RealtimeEnvelope` with `Domain: "expenses"` and `EventType: "account.opened"` or `"account.updated"`. -4. `NotificationsHub` pushes that envelope to the authenticated user SignalR group via `SendAsync("realtimeEvent", env, ...)`. -5. `wisave-ui` `PortalSignalRService` receives the envelope from the hub connection. -6. `ExpensesSignalRService` routes the envelope into `accountOpened$` or `accountUpdated$` based on `domain === 'expenses'` and the event type. -7. `withAccountsSignalR` maps `env.payload` through `mapAccountFromSignalR(...)` for both opened and updated events. -8. The accounts store emits `accountUpsertedSignalR` and replaces the account entity wholesale instead of merging a partial patch. -9. Account cards, totals, and effective-balance UI derive their display state from the replaced store entity; no normal-path refetch from the backend is required. -10. `account.closed` stays id-only and removes the entity, while reconnect recovery remains a backup path through `accountsPageEvents.opened()` rather than the primary propagation mechanism. - -This plan must preserve that flow end-to-end. If any step cannot provide the full snapshot needed by the next boundary, stop and fix that boundary instead of adding partial merge logic back into the FE. - ---- - -### File Map - -| File | Action | Responsibility | -| ---- | ------ | -------------- | -| `src/WiSave.Portal/Hubs/Realtime/AccountPayload.cs` | Create | FE-facing full snapshot DTO for account realtime events | -| `src/WiSave.Portal/WiSave.Portal.csproj` | Verify | Confirm the consumed `WiSave.Expenses.Contracts` package version contains the new account event shape before starting code changes | -| `src/WiSave.Portal/Messaging/NotificationConsumer.cs` | Modify | Explicit portal-side mapping and push path for `AccountOpened` / `AccountUpdated` | -| `tests/WiSave.Portal.UnitTests/Messaging/NotificationConsumerEnvelopeTests.cs` | Modify | Prove account envelopes carry FE-facing mapped payloads and full snapshots | -| `tests/WiSave.Portal.IntegrationTests/Messaging/ConsumerSignalRTests.cs` | Modify | Verify real SignalR clients receive full account snapshots from portal | -| `../wisave-ui/src/app/core/signalr/expenses-signalr.types.ts` | Modify | Make `IAccountUpdatedPayload` a full snapshot shape instead of `Partial<>` | -| `../wisave-ui/src/app/features/expense-accounts/+store/accounts/accounts.signalr.event-handlers.ts` | Modify | Remove account patch merge logic and replace updates via the full mapper | -| `../wisave-ui/src/app/features/expense-accounts/+store/accounts/accounts.signalr.event-handlers.spec.ts` | Create | Lock full-snapshot mapping and replacement semantics for account realtime | - ---- - -### Task 1: Add Portal Account Realtime DTO And Explicit Mapping - -**Files:** -- Create: `src/WiSave.Portal/Hubs/Realtime/AccountPayload.cs` -- Modify: `src/WiSave.Portal/Messaging/NotificationConsumer.cs` -- Test: `tests/WiSave.Portal.UnitTests/Messaging/NotificationConsumerEnvelopeTests.cs` - -- [ ] **Step 1: Write failing portal unit tests for FE-facing account payloads** - -Update `tests/WiSave.Portal.UnitTests/Messaging/NotificationConsumerEnvelopeTests.cs` by replacing the current account assertion with two explicit tests: - -```csharp -[Fact] -public async Task AccountOpened_sent_as_full_account_snapshot_payload() -{ - var (hub, _, group) = CreateHub(); - var consumer = new NotificationConsumer(hub); - - var userId = Guid.NewGuid().ToString(); - var accountId = Guid.NewGuid().ToString(); - - var msg = new AccountOpened( - AccountId: accountId, - UserId: userId, - Name: "Millennium", - Type: AccountType.CreditCard, - Variant: null, - Currency: Currency.PLN, - Balance: null, - LinkedBankAccountId: "bank-1", - CreditLimit: 5000m, - BillingCycleDay: 16, - PreviousCycleDebt: 1200m, - CurrentCycleDebt: 340m, - Color: "#f59e0b", - LastFourDigits: "4532", - Timestamp: DateTimeOffset.UtcNow); - - var ctx = Substitute.For>(); - ctx.Message.Returns(msg); - ctx.CancellationToken.Returns(CancellationToken.None); - - await consumer.Consume(ctx); - - var env = CaptureSentEnvelope(group); - Assert.Equal(RealtimeEventType.AccountOpened, env.EventType); - Assert.Equal(accountId, env.EntityId); - - var payload = Assert.IsType(env.Payload); - Assert.Equal("CreditCard", payload.Type); - Assert.Null(payload.Variant); - Assert.Null(payload.Balance); - Assert.Equal("bank-1", payload.LinkedBankAccountId); - Assert.Equal(1200m, payload.PreviousCycleDebt); - Assert.Equal(340m, payload.CurrentCycleDebt); -} - -[Fact] -public async Task AccountUpdated_sent_as_full_account_snapshot_payload_not_patch() -{ - var (hub, _, group) = CreateHub(); - var consumer = new NotificationConsumer(hub); - - var userId = Guid.NewGuid().ToString(); - var accountId = Guid.NewGuid().ToString(); - - var msg = new AccountUpdated( - AccountId: accountId, - UserId: userId, - Name: "Travel Card", - Type: AccountType.DebitCard, - Variant: DebitCardVariant.Standalone, - Currency: Currency.EUR, - Balance: 250m, - LinkedBankAccountId: null, - CreditLimit: null, - BillingCycleDay: null, - PreviousCycleDebt: null, - CurrentCycleDebt: null, - Color: null, - LastFourDigits: "8812", - Timestamp: DateTimeOffset.UtcNow); - - var ctx = Substitute.For>(); - ctx.Message.Returns(msg); - ctx.CancellationToken.Returns(CancellationToken.None); - - await consumer.Consume(ctx); - - var env = CaptureSentEnvelope(group); - Assert.Equal(RealtimeEventType.AccountUpdated, env.EventType); - Assert.Equal(accountId, env.EntityId); - - var payload = Assert.IsType(env.Payload); - Assert.Equal("DebitCard", payload.Type); - Assert.Equal("standalone", payload.Variant); - Assert.Equal(250m, payload.Balance); - Assert.Null(payload.LinkedBankAccountId); -} -``` - -- [ ] **Step 2: Run the portal unit tests to verify the DTO and mapper are missing** - -Run: - -```bash -dotnet test tests/WiSave.Portal.UnitTests/WiSave.Portal.UnitTests.csproj --filter "FullyQualifiedName~NotificationConsumerEnvelopeTests" -``` - -Expected: - -- FAIL because `AccountPayload` does not exist -- FAIL because `NotificationConsumer` still forwards raw `AccountOpened` / `AccountUpdated` payloads - -- [ ] **Step 3: Add the FE-facing account realtime DTO** - -Create `src/WiSave.Portal/Hubs/Realtime/AccountPayload.cs`: - -```csharp -namespace WiSave.Portal.Hubs.Realtime; - -public sealed record AccountPayload( - string AccountId, - string UserId, - string Name, - string Type, - string? Variant, - string Currency, - decimal? Balance, - string? LinkedBankAccountId, - decimal? CreditLimit, - int? BillingCycleDay, - decimal? PreviousCycleDebt, - decimal? CurrentCycleDebt, - string? Color, - string? LastFourDigits, - DateTimeOffset Timestamp); -``` - -- [ ] **Step 4: Implement explicit account mapping inside `NotificationConsumer`** - -Update `src/WiSave.Portal/Messaging/NotificationConsumer.cs` so account events use explicit DTO mapping while expense and budget events stay on the generic `Push(...)` path: - -```csharp -using MassTransit; -using Microsoft.AspNetCore.SignalR; -using WiSave.Expenses.Contracts.Events; -using WiSave.Expenses.Contracts.Events.Accounts; -using WiSave.Expenses.Contracts.Events.Budgets; -using WiSave.Expenses.Contracts.Events.Expenses; -using WiSave.Portal.Hubs; -using WiSave.Portal.Hubs.Realtime; - -namespace WiSave.Portal.Messaging; - -public class NotificationConsumer(IHubContext hub) : - IConsumer, - IConsumer, - IConsumer, - IConsumer, - IConsumer, - IConsumer, - IConsumer, - IConsumer, - IConsumer, - IConsumer, - IConsumer, - IConsumer -{ - public Task Consume(ConsumeContext ctx) => - PushAccount(ctx, RealtimeEventType.AccountOpened, MapAccountPayload(ctx.Message)); - - public Task Consume(ConsumeContext ctx) => - PushAccount(ctx, RealtimeEventType.AccountUpdated, MapAccountPayload(ctx.Message)); - - public Task Consume(ConsumeContext ctx) => - Push(ctx, RealtimeEventType.AccountClosed, ctx.Message.UserId, ctx.Message.AccountId); - - public Task Consume(ConsumeContext ctx) => - Push(ctx, RealtimeEventType.ExpenseRecorded, ctx.Message.UserId, ctx.Message.ExpenseId); - - public Task Consume(ConsumeContext ctx) => - Push(ctx, RealtimeEventType.ExpenseUpdated, ctx.Message.UserId, ctx.Message.ExpenseId); - - public Task Consume(ConsumeContext ctx) => - Push(ctx, RealtimeEventType.ExpenseDeleted, ctx.Message.UserId, ctx.Message.ExpenseId); - - public Task Consume(ConsumeContext ctx) => - Push(ctx, RealtimeEventType.BudgetCreated, ctx.Message.UserId, ctx.Message.BudgetId); - - public Task Consume(ConsumeContext ctx) => - Push(ctx, RealtimeEventType.BudgetCopiedFromPrevious, ctx.Message.UserId, ctx.Message.BudgetId); - - public Task Consume(ConsumeContext ctx) => - Push(ctx, RealtimeEventType.OverallLimitSet, ctx.Message.UserId, ctx.Message.BudgetId); - - public Task Consume(ConsumeContext ctx) => - Push(ctx, RealtimeEventType.CategoryLimitSet, ctx.Message.UserId, ctx.Message.BudgetId); - - public Task Consume(ConsumeContext ctx) => - Push(ctx, RealtimeEventType.CategoryLimitRemoved, ctx.Message.UserId, ctx.Message.BudgetId); - - public Task Consume(ConsumeContext ctx) => - Push(ctx, RealtimeEventType.CommandFailed, ctx.Message.UserId, entityId: null); - - private Task PushAccount( - ConsumeContext ctx, - string eventType, - AccountPayload payload) - where T : class - { - var env = new RealtimeEnvelope( - EventId: Guid.CreateVersion7(), - Domain: "expenses", - EventType: eventType, - OccurredAt: DateTime.UtcNow, - EntityId: payload.AccountId, - Payload: payload); - - return hub.Clients.Group(payload.UserId).SendAsync("realtimeEvent", env, ctx.CancellationToken); - } - - private static AccountPayload MapAccountPayload(AccountOpened message) => - new( - message.AccountId, - message.UserId, - message.Name, - message.Type.ToString(), - message.Variant switch - { - DebitCardVariant.Linked => "linked", - DebitCardVariant.Standalone => "standalone", - _ => null, - }, - message.Currency.ToString(), - message.Balance, - message.LinkedBankAccountId, - message.CreditLimit, - message.BillingCycleDay, - message.PreviousCycleDebt, - message.CurrentCycleDebt, - message.Color, - message.LastFourDigits, - message.Timestamp); - - private static AccountPayload MapAccountPayload(AccountUpdated message) => - new( - message.AccountId, - message.UserId, - message.Name, - message.Type.ToString(), - message.Variant switch - { - DebitCardVariant.Linked => "linked", - DebitCardVariant.Standalone => "standalone", - _ => null, - }, - message.Currency.ToString(), - message.Balance, - message.LinkedBankAccountId, - message.CreditLimit, - message.BillingCycleDay, - message.PreviousCycleDebt, - message.CurrentCycleDebt, - message.Color, - message.LastFourDigits, - message.Timestamp); - - private Task Push(ConsumeContext ctx, string eventType, string userId, string? entityId) - where T : class - { - var env = new RealtimeEnvelope( - EventId: Guid.CreateVersion7(), - Domain: "expenses", - EventType: eventType, - OccurredAt: DateTime.UtcNow, - EntityId: entityId, - Payload: ctx.Message!); - - return hub.Clients.Group(userId).SendAsync("realtimeEvent", env, ctx.CancellationToken); - } -} -``` - -- [ ] **Step 5: Run the portal unit tests again** - -Run: - -```bash -dotnet test tests/WiSave.Portal.UnitTests/WiSave.Portal.UnitTests.csproj --filter "FullyQualifiedName~NotificationConsumerEnvelopeTests" -``` - -Expected: - -- PASS - -- [ ] **Step 6: Commit the portal DTO and consumer changes** - -Use a path-limited commit: - -```bash -git -C /Users/jakubchwastek/Desktop/Projects/wisave_project/wisave-portal add \ - src/WiSave.Portal/Hubs/Realtime/AccountPayload.cs \ - src/WiSave.Portal/Messaging/NotificationConsumer.cs \ - tests/WiSave.Portal.UnitTests/Messaging/NotificationConsumerEnvelopeTests.cs - -git -C /Users/jakubchwastek/Desktop/Projects/wisave_project/wisave-portal commit -m "feat(portal): emit explicit account signalr payloads" -``` - ---- - -### Task 2: Prove The Portal Boundary With SignalR Integration Tests - -**Files:** -- Modify: `tests/WiSave.Portal.IntegrationTests/Messaging/ConsumerSignalRTests.cs` - -- [ ] **Step 1: Write failing SignalR integration assertions for opened and updated full snapshots** - -Extend `tests/WiSave.Portal.IntegrationTests/Messaging/ConsumerSignalRTests.cs`: - -1. Update the existing `AccountOpened_IsPushedToSignalRClient` test so it publishes the new account event shape and asserts FE-facing payload values: - -```csharp -[Fact] -public async Task AccountOpened_IsPushedToSignalRClient() -{ - var (connection, userId) = await CreateAuthenticatedHubConnection("account@example.com"); - - var tcs = new TaskCompletionSource(); - connection.On("realtimeEvent", envelope => - { - if (envelope.GetProperty("eventType").GetString() == "account.opened") - tcs.TrySetResult(envelope); - }); - - await connection.StartAsync(CancellationToken); - - await PublishOnExpensesBus(new AccountOpened( - AccountId: "acc-42", - UserId: userId, - Name: "Main Account", - Type: AccountType.CreditCard, - Variant: null, - Currency: Currency.PLN, - Balance: null, - LinkedBankAccountId: "bank-1", - CreditLimit: 5000m, - BillingCycleDay: 16, - PreviousCycleDebt: 1200m, - CurrentCycleDebt: 340m, - Color: "#FF0000", - LastFourDigits: "1234", - Timestamp: DateTimeOffset.UtcNow - )); - - var envelope = await tcs.Task.WaitAsync(TimeSpan.FromSeconds(5), CancellationToken); - var payload = envelope.GetProperty("payload"); - Assert.Equal("account.opened", envelope.GetProperty("eventType").GetString()); - Assert.Equal("CreditCard", payload.GetProperty("type").GetString()); - Assert.True(payload.GetProperty("variant").ValueKind == JsonValueKind.Null); - Assert.Equal(1200m, payload.GetProperty("previousCycleDebt").GetDecimal()); - Assert.Equal(340m, payload.GetProperty("currentCycleDebt").GetDecimal()); - Assert.Equal("bank-1", payload.GetProperty("linkedBankAccountId").GetString()); - - await connection.StopAsync(CancellationToken); - await connection.DisposeAsync(); -} -``` - -2. Add a new `AccountUpdated_IsPushedToSignalRClient_AsFullSnapshot` test: - -```csharp -[Fact] -public async Task AccountUpdated_IsPushedToSignalRClient_AsFullSnapshot() -{ - var (connection, userId) = await CreateAuthenticatedHubConnection("account-update@example.com"); - - var tcs = new TaskCompletionSource(); - connection.On("realtimeEvent", envelope => - { - if (envelope.GetProperty("eventType").GetString() == "account.updated") - tcs.TrySetResult(envelope); - }); - - await connection.StartAsync(CancellationToken); - - await PublishOnExpensesBus(new AccountUpdated( - AccountId: "acc-77", - UserId: userId, - Name: "Travel Card", - Type: AccountType.DebitCard, - Variant: DebitCardVariant.Standalone, - Currency: Currency.EUR, - Balance: 250m, - LinkedBankAccountId: null, - CreditLimit: null, - BillingCycleDay: null, - PreviousCycleDebt: null, - CurrentCycleDebt: null, - Color: null, - LastFourDigits: "8812", - Timestamp: DateTimeOffset.UtcNow - )); - - var envelope = await tcs.Task.WaitAsync(TimeSpan.FromSeconds(5), CancellationToken); - var payload = envelope.GetProperty("payload"); - Assert.Equal("account.updated", envelope.GetProperty("eventType").GetString()); - Assert.Equal("DebitCard", payload.GetProperty("type").GetString()); - Assert.Equal("standalone", payload.GetProperty("variant").GetString()); - Assert.Equal(250m, payload.GetProperty("balance").GetDecimal()); - Assert.True(payload.GetProperty("linkedBankAccountId").ValueKind == JsonValueKind.Null); - - await connection.StopAsync(CancellationToken); - await connection.DisposeAsync(); -} -``` - -- [ ] **Step 2: Run the portal integration tests to verify the boundary is still raw/generic** - -Run: - -```bash -dotnet test tests/WiSave.Portal.IntegrationTests/WiSave.Portal.IntegrationTests.csproj --filter "FullyQualifiedName~ConsumerSignalRTests" -``` - -Expected: - -- FAIL before the consumer mapping is implemented -- or FAIL before the new account event constructors are updated - -- [ ] **Step 3: Make the integration test constructors and assertions match the explicit snapshot boundary** - -After Task 1 code is in place, ensure both account event constructor calls in `ConsumerSignalRTests.cs` include: - -```csharp -Variant: null, -PreviousCycleDebt: ..., -CurrentCycleDebt: ..., -``` - -and the assertions check FE-facing strings in the SignalR payload rather than raw enum numbers. - -- [ ] **Step 4: Run the portal integration tests again** - -Run: - -```bash -dotnet test tests/WiSave.Portal.IntegrationTests/WiSave.Portal.IntegrationTests.csproj --filter "FullyQualifiedName~ConsumerSignalRTests" -``` - -Expected: - -- PASS - -- [ ] **Step 5: Commit the portal integration boundary tests** - -```bash -git -C /Users/jakubchwastek/Desktop/Projects/wisave_project/wisave-portal add \ - tests/WiSave.Portal.IntegrationTests/Messaging/ConsumerSignalRTests.cs - -git -C /Users/jakubchwastek/Desktop/Projects/wisave_project/wisave-portal commit -m "test(portal): verify account signalr snapshot payloads" -``` - ---- - -### Task 3: Switch UI Account SignalR Handling To Full Snapshot Replacement - -**Files:** -- Modify: `../wisave-ui/src/app/core/signalr/expenses-signalr.types.ts` -- Modify: `../wisave-ui/src/app/features/expense-accounts/+store/accounts/accounts.signalr.event-handlers.ts` -- Create: `../wisave-ui/src/app/features/expense-accounts/+store/accounts/accounts.signalr.event-handlers.spec.ts` - -- [ ] **Step 1: Write failing UI tests for full account snapshot updates** - -Create `../wisave-ui/src/app/features/expense-accounts/+store/accounts/accounts.signalr.event-handlers.spec.ts`: - -```ts -import { Currency } from '@core/types/currency.enum'; -import type { IAccountOpenedPayload, IAccountUpdatedPayload } from '@core/signalr/expenses-signalr.types'; -import { mapAccountFromSignalR } from './accounts.signalr.event-handlers'; - -describe('accounts.signalr.event-handlers', () => { - it('maps debit card update payloads as full standalone snapshots', () => { - const payload: IAccountUpdatedPayload = { - accountId: 'card-1', - userId: 'user-1', - name: 'Travel Card', - type: 'DebitCard', - variant: 'standalone', - currency: 'EUR', - balance: 250, - linkedBankAccountId: null, - creditLimit: null, - billingCycleDay: null, - previousCycleDebt: null, - currentCycleDebt: null, - color: null, - lastFourDigits: '8812', - timestamp: '2026-04-19T10:00:00Z', - }; - - const account = mapAccountFromSignalR(payload as IAccountOpenedPayload); - - expect(account).toEqual({ - id: 'card-1', - name: 'Travel Card', - type: 'debit_card', - variant: 'standalone', - currency: Currency.EUR, - balance: 250, - lastFourDigits: '8812', - }); - }); - - it('maps credit card update payloads as full snapshots with both debt buckets', () => { - const payload: IAccountUpdatedPayload = { - accountId: 'card-2', - userId: 'user-1', - name: 'Millennium', - type: 'CreditCard', - variant: null, - currency: 'PLN', - balance: null, - linkedBankAccountId: 'bank-1', - creditLimit: 5000, - billingCycleDay: 16, - previousCycleDebt: 1200, - currentCycleDebt: 340, - color: '#f59e0b', - lastFourDigits: '4532', - timestamp: '2026-04-19T10:00:00Z', - }; - - const account = mapAccountFromSignalR(payload as IAccountOpenedPayload); - - expect(account).toEqual({ - id: 'card-2', - name: 'Millennium', - type: 'credit_card', - currency: Currency.PLN, - originAccountUid: 'bank-1', - creditLimit: 5000, - billingCycleDay: 16, - previousCycleDebt: 1200, - currentCycleDebt: 340, - color: '#f59e0b', - lastFourDigits: '4532', - }); - }); -}); -``` - -- [ ] **Step 2: Run the focused UI tests to verify account updates are still patch-shaped** - -Run: - -```bash -cd /Users/jakubchwastek/Desktop/Projects/wisave_project/wisave-ui -yarn test --watch=false --include src/app/features/expense-accounts/+store/accounts/accounts.signalr.event-handlers.spec.ts -``` - -Expected: - -- FAIL because no dedicated spec exists yet -- or FAIL because `IAccountUpdatedPayload` is still `Partial` - -- [ ] **Step 3: Make account update payloads full snapshots** - -Update `../wisave-ui/src/app/core/signalr/expenses-signalr.types.ts`: - -```ts -export type IAccountUpdatedPayload = IAccountOpenedPayload; -``` - -Replace: - -```ts -export interface IAccountUpdatedPayload extends Partial { - accountId: string; - userId: string; - timestamp: string; -} -``` - -with the single full-snapshot alias above. - -- [ ] **Step 4: Remove account patch merge logic and replace via the full mapper** - -Update `../wisave-ui/src/app/features/expense-accounts/+store/accounts/accounts.signalr.event-handlers.ts`: - -1. Delete the entire `mergeAccountUpdate(...)` function. -2. Remove the now-unused `mapAccountType(...)` helper. -3. Simplify `accountUpdated$` to use `mapAccountFromSignalR(...)` directly. - -The relevant code should become: - -```ts -import { inject } from '@angular/core'; -import { toObservable } from '@angular/core/rxjs-interop'; -import { signalStoreFeature, withProps } from '@ngrx/signals'; -import { withEventHandlers } from '@ngrx/signals/events'; -import { filter, map, pairwise } from 'rxjs'; - -import { Currency } from '@core/types/currency.enum'; -import type { ExpenseAccountType, ExpenseAccountTypeApi, IExpenseAccount } from '@core/types/expense-account.interface'; -import { asExpenseAccountId } from '@core/types/expense-id.types'; - -import { ExpensesSignalRService } from '@core/signalr/expenses-signalr.service'; -import { PortalSignalRService } from '@core/signalr/portal-signalr.service'; -import type { IAccountOpenedPayload, IAccountUpdatedPayload } from '@core/signalr/expenses-signalr.types'; - -import { accountsPageEvents, accountsSignalREvents } from './accounts.events'; - -const ACCOUNT_TYPE_MAP: Record = { - BankAccount: 'bank_account', - DebitCard: 'debit_card', - CreditCard: 'credit_card', - Cash: 'cash', -}; - -function mapCurrency(value: string | null | undefined): Currency { - if (!value) return Currency.PLN; - return (Object.values(Currency) as string[]).includes(value) ? (value as Currency) : Currency.PLN; -} - -export function mapAccountFromSignalR(payload: IAccountOpenedPayload): IExpenseAccount { - const id = asExpenseAccountId(payload.accountId); - const name = payload.name; - const currency = mapCurrency(payload.currency); - const color = payload.color ?? undefined; - const lastFourDigits = payload.lastFourDigits ?? undefined; - - switch (payload.type) { - case 'BankAccount': - return { - id, - name, - type: 'bank_account', - currency, - balance: payload.balance ?? 0, - ...(color && { color }), - }; - case 'Cash': - return { - id, - name, - type: 'cash', - currency, - balance: payload.balance ?? 0, - ...(color && { color }), - }; - case 'DebitCard': - if (payload.variant === 'linked') { - return { - id, - name, - type: 'debit_card', - variant: 'linked', - currency, - originAccountUid: asExpenseAccountId(payload.linkedBankAccountId ?? ''), - ...(color && { color }), - ...(lastFourDigits && { lastFourDigits }), - }; - } - - return { - id, - name, - type: 'debit_card', - variant: 'standalone', - currency, - balance: payload.balance ?? 0, - ...(color && { color }), - ...(lastFourDigits && { lastFourDigits }), - }; - case 'CreditCard': - return { - id, - name, - type: 'credit_card', - currency, - originAccountUid: asExpenseAccountId(payload.linkedBankAccountId ?? ''), - creditLimit: payload.creditLimit ?? 0, - billingCycleDay: payload.billingCycleDay ?? 1, - previousCycleDebt: payload.previousCycleDebt ?? 0, - currentCycleDebt: payload.currentCycleDebt ?? 0, - ...(color && { color }), - ...(lastFourDigits && { lastFourDigits }), - }; - } -} - -export function withAccountsSignalR() { - return signalStoreFeature( - withProps(() => ({ - _realtime: inject(ExpensesSignalRService), - _portal: inject(PortalSignalRService), - })), - withEventHandlers((store) => ({ - accountOpened$: store._realtime.accountOpened$.pipe( - filter((env) => env.entityId !== null && env.payload !== null), - map((env) => accountsSignalREvents.accountUpsertedSignalR({ - account: mapAccountFromSignalR(env.payload as IAccountOpenedPayload), - })), - ), - accountUpdated$: store._realtime.accountUpdated$.pipe( - filter((env) => env.entityId !== null && env.payload !== null), - map((env) => accountsSignalREvents.accountUpsertedSignalR({ - account: mapAccountFromSignalR(env.payload as IAccountUpdatedPayload), - })), - ), - accountClosed$: store._realtime.accountClosed$.pipe( - filter((env) => env.entityId !== null), - map((env) => accountsSignalREvents.accountRemovedSignalR({ - id: asExpenseAccountId(env.entityId as string), - })), - ), - reconnectCatchUp$: toObservable(store._portal.status).pipe( - pairwise(), - filter(([prev, curr]) => (prev === 'reconnecting' || prev === 'disconnected') && curr === 'connected'), - map(() => accountsPageEvents.opened()), - ), - })), - ); -} -``` - -- [ ] **Step 5: Run the focused UI tests again** - -Run: - -```bash -cd /Users/jakubchwastek/Desktop/Projects/wisave_project/wisave-ui -yarn test --watch=false --include src/app/features/expense-accounts/+store/accounts/accounts.signalr.event-handlers.spec.ts -``` - -Expected: - -- PASS - -- [ ] **Step 6: Commit the UI full-snapshot account SignalR changes** - -```bash -git -C /Users/jakubchwastek/Desktop/Projects/wisave_project/wisave-ui add \ - src/app/core/signalr/expenses-signalr.types.ts \ - src/app/features/expense-accounts/+store/accounts/accounts.signalr.event-handlers.ts \ - src/app/features/expense-accounts/+store/accounts/accounts.signalr.event-handlers.spec.ts - -git -C /Users/jakubchwastek/Desktop/Projects/wisave_project/wisave-ui commit -m "feat(ui): replace account signalr entities from full snapshots" -``` - ---- - -### Task 4: Run Cross-Repo Verification - -**Files:** -- Modify: none - -- [ ] **Step 1: Run focused portal tests** - -Run: - -```bash -cd /Users/jakubchwastek/Desktop/Projects/wisave_project/wisave-portal -dotnet test tests/WiSave.Portal.UnitTests/WiSave.Portal.UnitTests.csproj --filter "FullyQualifiedName~NotificationConsumerEnvelopeTests" -dotnet test tests/WiSave.Portal.IntegrationTests/WiSave.Portal.IntegrationTests.csproj --filter "FullyQualifiedName~ConsumerSignalRTests" -``` - -Expected: - -- both commands PASS - -- [ ] **Step 2: Run focused UI tests** - -Run: - -```bash -cd /Users/jakubchwastek/Desktop/Projects/wisave_project/wisave-ui -yarn test --watch=false --include src/app/features/expense-accounts/+store/accounts/accounts.signalr.event-handlers.spec.ts -``` - -Expected: - -- PASS - -- [ ] **Step 3: Run broader portal and UI build checks** - -Run: - -```bash -cd /Users/jakubchwastek/Desktop/Projects/wisave_project/wisave-portal -dotnet build - -cd /Users/jakubchwastek/Desktop/Projects/wisave_project/wisave-ui -yarn build -``` - -Expected: - -- both builds PASS - -- [ ] **Step 4: Inspect both working trees before handoff** - -Run: - -```bash -git -C /Users/jakubchwastek/Desktop/Projects/wisave_project/wisave-portal status --short -git -C /Users/jakubchwastek/Desktop/Projects/wisave_project/wisave-portal diff --stat - -git -C /Users/jakubchwastek/Desktop/Projects/wisave_project/wisave-ui status --short -git -C /Users/jakubchwastek/Desktop/Projects/wisave_project/wisave-ui diff --stat -``` - -Expected: - -- only the intended portal account realtime files and UI account SignalR files are part of this feature’s changes -- any pre-existing unrelated local changes remain untouched - ---- - -### Self-Review - -- Spec coverage: the plan covers the portal contracts-package precondition, the explicit end-to-end account event flow, portal-side explicit account DTO mapping, portal SignalR boundary tests, UI full-snapshot account payload typing, removal of account patch merge logic, and cross-repo verification. -- Placeholder scan: no `TODO`, `TBD`, or “implement later” steps remain. -- Type consistency: `AccountPayload`, `IAccountUpdatedPayload`, `AccountOpened`, `AccountUpdated`, and the full-snapshot replacement semantics are consistent across portal and UI tasks. diff --git a/docs/superpowers/plans/2026-04-26-identity-plan-permissions.md b/docs/superpowers/plans/2026-04-26-identity-plan-permissions.md deleted file mode 100644 index c9693bc..0000000 --- a/docs/superpowers/plans/2026-04-26-identity-plan-permissions.md +++ /dev/null @@ -1,804 +0,0 @@ -# Identity Plan Permissions Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Replace custom plan-permission runtime resolution with ASP.NET Core Identity plan roles and permission role claims while preserving the gateway `X-User-Permissions` contract. - -**Architecture:** Plans become Identity roles named `plan:free`, `plan:standard`, and `plan:premium`; permissions become Identity role claims with claim type `permission`. Registration assigns exactly one plan role, login preserves it, and a focused resolver loads permissions from role claims for `PermissionResolutionMiddleware`. Existing custom plan tables remain in the database for now but are no longer used by runtime auth. - -**Tech Stack:** ASP.NET Core Identity, EF Core, PostgreSQL/DbUp migrations, YARP transforms, xUnit integration tests, `WebApplicationFactory`. - ---- - -## File Structure - -- Create `src/WiSave.Portal/Authorization/PortalClaimTypes.cs`: constants for Identity claim types used by authorization. -- Create `src/WiSave.Portal/Authorization/PortalRoles.cs`: constants and helpers for plan/admin role names. -- Create `src/WiSave.Portal/Authorization/RolePermissionResolver.cs`: resolves effective permissions from Identity roles and role claims. -- Modify `src/WiSave.Portal/Authorization/PermissionResolutionMiddleware.cs`: delegate permission calculation to `RolePermissionResolver`. -- Modify `src/WiSave.Portal/Program.cs`: register `RolePermissionResolver`, remove unused custom permission caches from DI after code no longer uses them. -- Modify `src/WiSave.Portal/Endpoints/AuthEndpoints.cs`: validate selected plan role and assign plan role during registration. -- Modify `src/WiSave.Portal/Auth/Models/ApplicationUser.cs`: remove runtime dependency on `PlanId` after EF model is updated. -- Modify `src/WiSave.Portal/Infrastructure/Database/PortalDbContext.cs`: remove custom plan DbSets/model configuration and `ApplicationUser.PlanId` mapping from runtime model. -- Modify `src/WiSave.Portal.Migrations/Scripts/003_SeedPlansAndPermissions.sql`: seed Identity plan roles and permission role claims. -- Modify `tests/WiSave.Portal.Tests/Auth/AuthEndpointsTests.cs`: seed plan roles, assert plan assignment, invalid plan behavior, and login preservation. -- Modify `tests/WiSave.Portal.Tests/Gateway/UserHeaderTransformTests.cs`: seed permission role claims and assert forwarded permission headers. - -## Constants - -Use these constants consistently: - -```csharp -namespace WiSave.Portal.Authorization; - -public static class PortalClaimTypes -{ - public const string Permission = "permission"; -} -``` - -```csharp -namespace WiSave.Portal.Authorization; - -public static class PortalRoles -{ - public const string FreePlan = "plan:free"; - public const string StandardPlan = "plan:standard"; - public const string PremiumPlan = "plan:premium"; - public const string Admin = "admin"; - public const string SuperAdmin = "superadmin"; - - public static readonly string[] PlanRoles = [FreePlan, StandardPlan, PremiumPlan]; - public static readonly string[] AdminRoles = [Admin, SuperAdmin]; - - public static string NormalizePlanInput(string? plan) - { - if (string.IsNullOrWhiteSpace(plan)) - return FreePlan; - - var trimmed = plan.Trim(); - return trimmed.StartsWith("plan:", StringComparison.OrdinalIgnoreCase) - ? trimmed.ToLowerInvariant() - : $"plan:{trimmed.ToLowerInvariant()}"; - } - - public static bool IsPlanRole(string role) => - PlanRoles.Contains(role, StringComparer.OrdinalIgnoreCase); -} -``` - -## Task 1: Add Role/Claim Constants - -**Files:** -- Create: `src/WiSave.Portal/Authorization/PortalClaimTypes.cs` -- Create: `src/WiSave.Portal/Authorization/PortalRoles.cs` - -- [ ] **Step 1: Add failing compile references in tests** - -Modify `tests/WiSave.Portal.Tests/Auth/AuthEndpointsTests.cs` by adding: - -```csharp -using WiSave.Portal.Authorization; -``` - -Then change the role seeding loop from: - -```csharp -foreach (var role in new[] { "superadmin", "admin", "user" }) -``` - -to: - -```csharp -foreach (var role in PortalRoles.AdminRoles.Concat(PortalRoles.PlanRoles)) -``` - -- [ ] **Step 2: Run compile to verify missing constants** - -Run: - -```bash -dotnet test --filter "FullyQualifiedName~WiSave.Portal.Tests.Auth.AuthEndpointsTests" -``` - -Expected: build fails because `PortalRoles` does not exist. - -- [ ] **Step 3: Create constants** - -Create `src/WiSave.Portal/Authorization/PortalClaimTypes.cs`: - -```csharp -namespace WiSave.Portal.Authorization; - -public static class PortalClaimTypes -{ - public const string Permission = "permission"; -} -``` - -Create `src/WiSave.Portal/Authorization/PortalRoles.cs`: - -```csharp -namespace WiSave.Portal.Authorization; - -public static class PortalRoles -{ - public const string FreePlan = "plan:free"; - public const string StandardPlan = "plan:standard"; - public const string PremiumPlan = "plan:premium"; - public const string Admin = "admin"; - public const string SuperAdmin = "superadmin"; - - public static readonly string[] PlanRoles = [FreePlan, StandardPlan, PremiumPlan]; - public static readonly string[] AdminRoles = [Admin, SuperAdmin]; - - public static string NormalizePlanInput(string? plan) - { - if (string.IsNullOrWhiteSpace(plan)) - return FreePlan; - - var trimmed = plan.Trim(); - return trimmed.StartsWith("plan:", StringComparison.OrdinalIgnoreCase) - ? trimmed.ToLowerInvariant() - : $"plan:{trimmed.ToLowerInvariant()}"; - } - - public static bool IsPlanRole(string role) => - PlanRoles.Contains(role, StringComparer.OrdinalIgnoreCase); -} -``` - -- [ ] **Step 4: Run targeted compile/tests** - -Run: - -```bash -dotnet test --filter "FullyQualifiedName~WiSave.Portal.Tests.Auth.AuthEndpointsTests" -``` - -Expected: tests compile. Some tests may still fail until later tasks update registration. - -- [ ] **Step 5: Commit** - -```bash -git add src/WiSave.Portal/Authorization/PortalClaimTypes.cs src/WiSave.Portal/Authorization/PortalRoles.cs tests/WiSave.Portal.Tests/Auth/AuthEndpointsTests.cs -git commit -m "feat: add portal role constants" -``` - -## Task 2: Assign Plan Roles During Registration - -**Files:** -- Modify: `src/WiSave.Portal/Endpoints/AuthEndpoints.cs` -- Modify: `tests/WiSave.Portal.Tests/Auth/AuthEndpointsTests.cs` - -- [ ] **Step 1: Add registration plan tests** - -Add these helpers to `AuthEndpointsTests`: - -```csharp -private async Task FindUserByEmailAsync(string email) -{ - using var scope = _factory.Services.CreateScope(); - var userManager = scope.ServiceProvider.GetRequiredService>(); - var user = await userManager.FindByEmailAsync(email); - return Assert.NotNull(user); -} - -private async Task> GetUserRolesAsync(ApplicationUser user) -{ - using var scope = _factory.Services.CreateScope(); - var userManager = scope.ServiceProvider.GetRequiredService>(); - return await userManager.GetRolesAsync(user); -} -``` - -Add these tests: - -```csharp -[Theory] -[InlineData("free", PortalRoles.FreePlan)] -[InlineData("standard", PortalRoles.StandardPlan)] -[InlineData("premium", PortalRoles.PremiumPlan)] -[InlineData("plan:standard", PortalRoles.StandardPlan)] -public async Task Register_ValidPlan_AssignsExactlyOnePlanRole(string requestedPlan, string expectedRole) -{ - var client = _factory.CreateClient(); - var email = $"plan-{Guid.NewGuid():N}@example.com"; - var request = new RegisterRequest("Plan User", email, "Password123!", requestedPlan); - - var response = await client.PostAsJsonAsync("/api/auth/register", request, CancellationToken); - - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - - var user = await FindUserByEmailAsync(email); - var roles = await GetUserRolesAsync(user); - Assert.Contains(expectedRole, roles); - Assert.Single(roles.Where(PortalRoles.IsPlanRole)); -} - -[Fact] -public async Task Register_BlankPlan_DefaultsToFreePlanRole() -{ - var client = _factory.CreateClient(); - var email = $"blank-plan-{Guid.NewGuid():N}@example.com"; - var request = new RegisterRequest("Blank Plan User", email, "Password123!", ""); - - var response = await client.PostAsJsonAsync("/api/auth/register", request, CancellationToken); - - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - - var user = await FindUserByEmailAsync(email); - var roles = await GetUserRolesAsync(user); - Assert.Contains(PortalRoles.FreePlan, roles); - Assert.Single(roles.Where(PortalRoles.IsPlanRole)); -} - -[Fact] -public async Task Register_InvalidPlan_Returns400() -{ - var client = _factory.CreateClient(); - var request = new RegisterRequest("Bad Plan User", $"bad-plan-{Guid.NewGuid():N}@example.com", "Password123!", "enterprise"); - - var response = await client.PostAsJsonAsync("/api/auth/register", request, CancellationToken); - - Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); -} -``` - -- [ ] **Step 2: Run tests to verify failure** - -Run: - -```bash -dotnet test --filter "FullyQualifiedName~WiSave.Portal.Tests.Auth.AuthEndpointsTests" -``` - -Expected: new plan role tests fail because registration still validates custom `Plans` and assigns `user`. - -- [ ] **Step 3: Update registration endpoint** - -In `src/WiSave.Portal/Endpoints/AuthEndpoints.cs`, add: - -```csharp -using WiSave.Portal.Authorization; -``` - -Change the `Register` signature from: - -```csharp -PortalDbContext db) -``` - -to: - -```csharp -RoleManager roleManager) -``` - -Replace the custom plan validation and user creation block with: - -```csharp -var planRole = PortalRoles.NormalizePlanInput(request.PlanId); -if (!PortalRoles.IsPlanRole(planRole) || !await roleManager.RoleExistsAsync(planRole)) -{ - return Results.BadRequest(new { errors = new[] { $"Plan '{request.PlanId}' does not exist." } }); -} - -var user = new ApplicationUser -{ - Name = request.Name, - Email = request.Email, - UserName = request.Email -}; -``` - -Replace: - -```csharp -await userManager.AddToRoleAsync(user, "user"); -``` - -with: - -```csharp -var roleResult = await userManager.AddToRoleAsync(user, planRole); -if (!roleResult.Succeeded) -{ - return Results.BadRequest(new { errors = roleResult.Errors.Select(e => e.Description) }); -} -``` - -Remove these unused usings if present: - -```csharp -using Microsoft.EntityFrameworkCore; -using WiSave.Portal.Infrastructure.Database; -``` - -- [ ] **Step 4: Run targeted auth tests** - -Run: - -```bash -dotnet test --filter "FullyQualifiedName~WiSave.Portal.Tests.Auth.AuthEndpointsTests" -``` - -Expected: auth endpoint tests pass. - -- [ ] **Step 5: Commit** - -```bash -git add src/WiSave.Portal/Endpoints/AuthEndpoints.cs tests/WiSave.Portal.Tests/Auth/AuthEndpointsTests.cs -git commit -m "feat: assign identity plan roles on registration" -``` - -## Task 3: Resolve Permissions From Identity Role Claims - -**Files:** -- Create: `src/WiSave.Portal/Authorization/RolePermissionResolver.cs` -- Modify: `src/WiSave.Portal/Authorization/PermissionResolutionMiddleware.cs` -- Modify: `src/WiSave.Portal/Program.cs` -- Modify: `tests/WiSave.Portal.Tests/Gateway/UserHeaderTransformTests.cs` - -- [ ] **Step 1: Seed test role claims** - -In `UserHeaderTransformTests`, add: - -```csharp -using System.Security.Claims; -using Microsoft.AspNetCore.Identity; -using WiSave.Portal.Authorization; -``` - -Replace `SeedRolesAsync` with: - -```csharp -private async Task SeedRolesAsync() -{ - using var scope = _factory.Services.CreateScope(); - var roleManager = scope.ServiceProvider.GetRequiredService>(); - foreach (var role in PortalRoles.AdminRoles.Concat(PortalRoles.PlanRoles)) - { - if (!await roleManager.RoleExistsAsync(role)) - await roleManager.CreateAsync(new IdentityRole(role)); - } - - await EnsurePermissionClaimAsync(roleManager, PortalRoles.FreePlan, "incomes:read"); - await EnsurePermissionClaimAsync(roleManager, PortalRoles.StandardPlan, "incomes:read"); - await EnsurePermissionClaimAsync(roleManager, PortalRoles.StandardPlan, "incomes:write"); - await EnsurePermissionClaimAsync(roleManager, PortalRoles.PremiumPlan, "incomes:read"); - await EnsurePermissionClaimAsync(roleManager, PortalRoles.PremiumPlan, "incomes:write"); - await EnsurePermissionClaimAsync(roleManager, PortalRoles.PremiumPlan, "incomes:delete"); -} - -private static async Task EnsurePermissionClaimAsync(RoleManager roleManager, string roleName, string permission) -{ - var role = await roleManager.FindByNameAsync(roleName); - Assert.NotNull(role); - - var claims = await roleManager.GetClaimsAsync(role); - if (!claims.Any(c => c.Type == PortalClaimTypes.Permission && c.Value == permission)) - await roleManager.AddClaimAsync(role, new Claim(PortalClaimTypes.Permission, permission)); -} -``` - -Add this test: - -```csharp -[Fact] -public async Task ProxiedRequest_Authenticated_ForwardsPlanPermissions() -{ - var client = CreateClient(handleCookies: true); - await RegisterAsync(client, "Permission User", "permissions@example.com", "standard"); - - var response = await client.GetAsync("/api/incomes", CancellationToken); - var forwarded = await response.Content.ReadFromJsonAsync(CancellationToken); - - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - Assert.NotNull(forwarded); - var permissions = GetHeaderValue(forwarded, "X-User-Permissions").Split(','); - Assert.Contains("incomes:read", permissions); - Assert.Contains("incomes:write", permissions); -} -``` - -Change `RegisterAsync` signature and body in this test file: - -```csharp -private static async Task RegisterAsync(HttpClient client, string name, string email, string plan = "free") -{ - var request = new RegisterRequest(name, email, "Password123!", plan); - var response = await client.PostAsJsonAsync("/api/auth/register", request, CancellationToken); - response.EnsureSuccessStatusCode(); - - return (await response.Content.ReadFromJsonAsync(CancellationToken))!; -} -``` - -- [ ] **Step 2: Run gateway test to verify failure** - -Run: - -```bash -dotnet test --filter "FullyQualifiedName~WiSave.Portal.Tests.Gateway.UserHeaderTransformTests.ProxiedRequest_Authenticated_ForwardsPlanPermissions" -``` - -Expected: fails because middleware still reads custom plan caches. - -- [ ] **Step 3: Add resolver** - -Create `src/WiSave.Portal/Authorization/RolePermissionResolver.cs`: - -```csharp -using Microsoft.AspNetCore.Identity; -using WiSave.Portal.Auth.Models; - -namespace WiSave.Portal.Authorization; - -public class RolePermissionResolver( - UserManager userManager, - RoleManager roleManager) -{ - public async Task> GetPermissionsAsync(ApplicationUser user) - { - var roles = await userManager.GetRolesAsync(user); - if (roles.Any(role => PortalRoles.AdminRoles.Contains(role, StringComparer.OrdinalIgnoreCase))) - return new HashSet { "*" }; - - var permissions = new HashSet(StringComparer.OrdinalIgnoreCase); - foreach (var roleName in roles) - { - var role = await roleManager.FindByNameAsync(roleName); - if (role is null) - continue; - - var claims = await roleManager.GetClaimsAsync(role); - foreach (var claim in claims.Where(c => c.Type == PortalClaimTypes.Permission && !string.IsNullOrWhiteSpace(c.Value))) - { - permissions.Add(claim.Value); - } - } - - return permissions; - } -} -``` - -- [ ] **Step 4: Update middleware and DI** - -Replace `PermissionResolutionMiddleware.cs` with: - -```csharp -using System.Security.Claims; -using Microsoft.AspNetCore.Identity; -using WiSave.Portal.Auth.Models; - -namespace WiSave.Portal.Authorization; - -public class PermissionResolutionMiddleware(RequestDelegate next) -{ - public async Task InvokeAsync( - HttpContext context, - UserManager userManager, - RolePermissionResolver rolePermissionResolver) - { - if (context.User.Identity?.IsAuthenticated != true) - { - await next(context); - return; - } - - var userId = context.User.FindFirstValue(ClaimTypes.NameIdentifier); - if (userId is null) - { - await next(context); - return; - } - - var user = await userManager.FindByIdAsync(userId); - if (user is null) - { - await next(context); - return; - } - - context.Items["UserPermissions"] = await rolePermissionResolver.GetPermissionsAsync(user); - - await next(context); - } -} -``` - -In `Program.cs`, replace: - -```csharp -builder.Services.AddSingleton(); -builder.Services.AddSingleton(); -``` - -with: - -```csharp -builder.Services.AddScoped(); -``` - -- [ ] **Step 5: Run targeted gateway tests** - -Run: - -```bash -dotnet test --filter "FullyQualifiedName~WiSave.Portal.Tests.Gateway.UserHeaderTransformTests" -``` - -Expected: gateway tests pass. - -- [ ] **Step 6: Commit** - -```bash -git add src/WiSave.Portal/Authorization/RolePermissionResolver.cs src/WiSave.Portal/Authorization/PermissionResolutionMiddleware.cs src/WiSave.Portal/Program.cs tests/WiSave.Portal.Tests/Gateway/UserHeaderTransformTests.cs -git commit -m "feat: resolve permissions from identity role claims" -``` - -## Task 4: Remove Runtime Custom Plan Model Usage - -**Files:** -- Modify: `src/WiSave.Portal/Auth/Models/ApplicationUser.cs` -- Modify: `src/WiSave.Portal/Infrastructure/Database/PortalDbContext.cs` -- Delete: `src/WiSave.Portal/Auth/Models/Plan.cs` -- Delete: `src/WiSave.Portal/Auth/Models/Permission.cs` -- Delete: `src/WiSave.Portal/Auth/Models/PlanPermission.cs` -- Delete: `src/WiSave.Portal/Authorization/UserPlanCache.cs` -- Delete: `src/WiSave.Portal/Authorization/PlanPermissionCache.cs` -- Modify tests if any compile references remain. - -- [ ] **Step 1: Search current references** - -Run: - -```bash -rg "PlanId|DbSet|DbSet|DbSet|UserPlanCache|PlanPermissionCache|new Plan|PlanPermissions|Permissions" src tests -``` - -Expected: only the old model/config/cache files and test seeding references remain. - -- [ ] **Step 2: Remove `PlanId` from `ApplicationUser`** - -Update `src/WiSave.Portal/Auth/Models/ApplicationUser.cs`: - -```csharp -using Microsoft.AspNetCore.Identity; - -namespace WiSave.Portal.Auth.Models; - -public class ApplicationUser : IdentityUser -{ - public required string Name { get; set; } -} -``` - -- [ ] **Step 3: Remove custom DbSets/model configuration** - -Update `src/WiSave.Portal/Infrastructure/Database/PortalDbContext.cs`: - -```csharp -using Microsoft.AspNetCore.Identity.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore; -using WiSave.Portal.Auth.Models; - -namespace WiSave.Portal.Infrastructure.Database; - -public class PortalDbContext(DbContextOptions options) : IdentityDbContext(options) -{ - protected override void OnModelCreating(ModelBuilder builder) - { - base.OnModelCreating(builder); - } -} -``` - -- [ ] **Step 4: Delete unused custom model/cache files** - -Delete: - -```text -src/WiSave.Portal/Auth/Models/Plan.cs -src/WiSave.Portal/Auth/Models/Permission.cs -src/WiSave.Portal/Auth/Models/PlanPermission.cs -src/WiSave.Portal/Authorization/UserPlanCache.cs -src/WiSave.Portal/Authorization/PlanPermissionCache.cs -``` - -- [ ] **Step 5: Remove old test plan seeding** - -In `AuthEndpointsTests.InitializeAsync`, remove the block that resolves `PortalDbContext` and inserts `Plan` entities. The method should only seed roles: - -```csharp -public async ValueTask InitializeAsync() -{ - using var scope = _factory.Services.CreateScope(); - var roleManager = scope.ServiceProvider.GetRequiredService>(); - foreach (var role in PortalRoles.AdminRoles.Concat(PortalRoles.PlanRoles)) - { - if (!await roleManager.RoleExistsAsync(role)) - await roleManager.CreateAsync(new IdentityRole(role)); - } -} -``` - -- [ ] **Step 6: Run build/test** - -Run: - -```bash -dotnet test --filter "FullyQualifiedName~WiSave.Portal.Tests.Auth.AuthEndpointsTests|FullyQualifiedName~WiSave.Portal.Tests.Gateway.UserHeaderTransformTests" -``` - -Expected: targeted auth and gateway tests pass. - -- [ ] **Step 7: Commit** - -```bash -git add src/WiSave.Portal/Auth/Models/ApplicationUser.cs src/WiSave.Portal/Infrastructure/Database/PortalDbContext.cs tests/WiSave.Portal.Tests/Auth/AuthEndpointsTests.cs -git rm src/WiSave.Portal/Auth/Models/Plan.cs src/WiSave.Portal/Auth/Models/Permission.cs src/WiSave.Portal/Auth/Models/PlanPermission.cs src/WiSave.Portal/Authorization/UserPlanCache.cs src/WiSave.Portal/Authorization/PlanPermissionCache.cs -git commit -m "refactor: remove runtime custom plan model" -``` - -## Task 5: Seed Identity Plan Roles and Permission Claims - -**Files:** -- Modify: `src/WiSave.Portal.Migrations/Scripts/003_SeedPlansAndPermissions.sql` - -- [ ] **Step 1: Replace custom seed script with Identity seeds** - -Replace `src/WiSave.Portal.Migrations/Scripts/003_SeedPlansAndPermissions.sql` with: - -```sql --- Seed administrative roles and plan roles. -INSERT INTO "AspNetRoles" ("Id", "Name", "NormalizedName", "ConcurrencyStamp") VALUES - ('role-superadmin', 'superadmin', 'SUPERADMIN', gen_random_uuid()::text), - ('role-admin', 'admin', 'ADMIN', gen_random_uuid()::text), - ('role-plan-free', 'plan:free', 'PLAN:FREE', gen_random_uuid()::text), - ('role-plan-standard', 'plan:standard', 'PLAN:STANDARD', gen_random_uuid()::text), - ('role-plan-premium', 'plan:premium', 'PLAN:PREMIUM', gen_random_uuid()::text) -ON CONFLICT ("Id") DO NOTHING; - --- Add permission claims to plan roles. Duplicate claims are avoided by the NOT EXISTS predicate. -WITH role_permissions("RoleId", "ClaimValue") AS ( - VALUES - ('role-plan-free', 'incomes:read'), - - ('role-plan-standard', 'incomes:read'), - ('role-plan-standard', 'incomes:write'), - ('role-plan-standard', 'stocks:read'), - ('role-plan-standard', 'expenses:read'), - ('role-plan-standard', 'expenses:write'), - - ('role-plan-premium', 'incomes:read'), - ('role-plan-premium', 'incomes:write'), - ('role-plan-premium', 'incomes:delete'), - ('role-plan-premium', 'incomes:import'), - ('role-plan-premium', 'stocks:read'), - ('role-plan-premium', 'stocks:write'), - ('role-plan-premium', 'stocks:portfolio:manage'), - ('role-plan-premium', 'stocks:watchlist:manage'), - ('role-plan-premium', 'expenses:read'), - ('role-plan-premium', 'expenses:write'), - ('role-plan-premium', 'expenses:delete') -) -INSERT INTO "AspNetRoleClaims" ("RoleId", "ClaimType", "ClaimValue") -SELECT rp."RoleId", 'permission', rp."ClaimValue" -FROM role_permissions rp -WHERE NOT EXISTS ( - SELECT 1 - FROM "AspNetRoleClaims" arc - WHERE arc."RoleId" = rp."RoleId" - AND arc."ClaimType" = 'permission' - AND arc."ClaimValue" = rp."ClaimValue" -); -``` - -- [ ] **Step 2: Verify migration SQL is syntactically consistent** - -Run: - -```bash -dotnet build -``` - -Expected: build passes. SQL script is not compiled, so also manually check table/column names match `001_InitialIdentity.sql`. - -- [ ] **Step 3: Commit** - -```bash -git add src/WiSave.Portal.Migrations/Scripts/003_SeedPlansAndPermissions.sql -git commit -m "chore: seed identity plan permission claims" -``` - -## Task 6: Add Admin Permission Coverage and Full Verification - -**Files:** -- Modify: `tests/WiSave.Portal.Tests/Gateway/UserHeaderTransformTests.cs` - -- [ ] **Step 1: Add helper for admin user** - -Add to `UserHeaderTransformTests`: - -```csharp -private async Task AddUserToRoleAsync(string email, string role) -{ - using var scope = _factory.Services.CreateScope(); - var userManager = scope.ServiceProvider.GetRequiredService>(); - var user = await userManager.FindByEmailAsync(email); - Assert.NotNull(user); - - var result = await userManager.AddToRoleAsync(user, role); - Assert.True(result.Succeeded, string.Join(", ", result.Errors.Select(e => e.Description))); -} -``` - -Make sure the file has: - -```csharp -using Microsoft.AspNetCore.Identity; -using WiSave.Portal.Auth.Models; -``` - -- [ ] **Step 2: Add admin wildcard test** - -Add: - -```csharp -[Fact] -public async Task ProxiedRequest_AdminUser_ForwardsWildcardPermissions() -{ - var client = CreateClient(handleCookies: true); - await RegisterAsync(client, "Admin User", "admin-user@example.com", "free"); - await AddUserToRoleAsync("admin-user@example.com", PortalRoles.Admin); - - var response = await client.GetAsync("/api/incomes", CancellationToken); - var forwarded = await response.Content.ReadFromJsonAsync(CancellationToken); - - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - Assert.NotNull(forwarded); - Assert.Equal("*", GetHeaderValue(forwarded, "X-User-Permissions")); -} -``` - -- [ ] **Step 3: Run all tests** - -Run: - -```bash -dotnet test -``` - -Expected: all tests pass. - -- [ ] **Step 4: Final search for old runtime references** - -Run: - -```bash -rg "PlanId|UserPlanCache|PlanPermissionCache|PlanPermissions|DbSet|DbSet|DbSet" src tests -``` - -Expected: no runtime references remain. Migration scripts may still mention old tables only in historical scripts. - -- [ ] **Step 5: Commit** - -```bash -git add tests/WiSave.Portal.Tests/Gateway/UserHeaderTransformTests.cs -git commit -m "test: cover identity permission forwarding" -``` - -## Final Verification - -- [ ] Run: - -```bash -dotnet build -dotnet test -``` - -- [ ] Confirm `git status --short` is clean except for intentional uncommitted files. -- [ ] Summarize touched files and note that old custom plan tables remain in historical migrations/database until a later cleanup migration. diff --git a/docs/superpowers/specs/2026-04-12-login-error-response-design.md b/docs/superpowers/specs/2026-04-12-login-error-response-design.md deleted file mode 100644 index 3e72364..0000000 --- a/docs/superpowers/specs/2026-04-12-login-error-response-design.md +++ /dev/null @@ -1,47 +0,0 @@ -# Login Error Response Design - -## Goal - -Expand `POST /api/auth/login` so failed authentication responses explain why the request was rejected, while preserving the current success payload and status code. - -## Approved Direction - -- Keep successful logins unchanged: `200 OK` with `AuthResponse`. -- Keep failed logins on `401 Unauthorized`. -- Return a structured JSON body for `401` responses with: - - a stable machine-readable `code` - - a human-readable `message` -- Distinguish these failure cases: - - `USER_NOT_FOUND` - - `INVALID_PASSWORD` - - `LOCKED_OUT` - - `NOT_ALLOWED` when ASP.NET Identity reports that state - -## API Shape - -Use a small typed DTO instead of anonymous objects so endpoint metadata, tests, and callers share one contract. - -Example: - -```json -{ - "code": "INVALID_PASSWORD", - "message": "The password is incorrect." -} -``` - -## Implementation Notes - -- Add the DTO next to the existing auth DTOs in `src/WiSave.Portal/Auth/Models/AuthDtos.cs`. -- In `AuthEndpoints.Login`, branch on `SignInResult` so each failure case maps to the correct code/message pair. -- Unknown user should return the same typed `401` body instead of bare `Results.Unauthorized()`. -- Update minimal API metadata so Swagger documents the `401` payload type. - -## Testing - -- Extend `tests/WiSave.Portal.Tests/Auth/AuthEndpointsTests.cs`. -- Cover at least: - - unknown email returns `401` with `USER_NOT_FOUND` - - bad password returns `401` with `INVALID_PASSWORD` - - locked out account returns `401` with `LOCKED_OUT` -- Keep existing success-path tests intact. diff --git a/docs/superpowers/specs/2026-04-12-portal-contracts-design.md b/docs/superpowers/specs/2026-04-12-portal-contracts-design.md deleted file mode 100644 index e5d5ad7..0000000 --- a/docs/superpowers/specs/2026-04-12-portal-contracts-design.md +++ /dev/null @@ -1,267 +0,0 @@ -# WiSave.Portal.Contracts Design - -Date: 2026-04-12 - -## Summary - -Create a small NuGet package named `WiSave.Portal.Contracts` to define the authentication and authorization transport contract between `WiSave.Portal` and downstream services such as `WiSave.Expenses`. - -The package will be owned by the portal, published independently, and consumed by child services. It will centralize: - -- forwarded auth header names -- forwarded user context types -- header parsing and serialization helpers -- shared permission constants used across portal and child services - -The package will remain a pure .NET class library with no ASP.NET Core dependency. - -## Current State - -Today the portal authenticates the user, resolves plan permissions, and forwards identity and permission data to downstream services through proxy headers. - -Current portal behavior: - -- service access is gated through reverse-proxy authorization policies such as `require-expenses` -- plan permissions are resolved in portal middleware -- downstream requests receive `X-User-Id`, `X-User-Email`, `X-User-Roles`, and `X-User-Permissions` - -Current expenses behavior: - -- the API does not authenticate independently -- it trusts the forwarded headers from the portal -- it parses the forwarded headers locally -- it defines its own local permission constants for the same values the portal seeds and checks - -This creates drift risk because both repos currently duplicate: - -- header names -- forwarded auth parsing rules -- permission string values - -## Goals - -- define one source of truth for portal-to-service auth transport -- let child services consume a stable NuGet package instead of re-creating local header contracts -- centralize permission constants used by both portal and downstream services -- keep the package narrow and safe to version independently - -## Non-Goals - -- moving portal EF, Identity, session, or database models into the package -- introducing ASP.NET Core framework dependencies into the package -- redesigning the auth protocol to use JWTs or mTLS in this change -- replacing service business contracts unrelated to auth transport - -## Package Scope - -`WiSave.Portal.Contracts` is a boundary-contract package. It represents what the portal sends to downstream services, not how the portal stores or authenticates users internally. - -Allowed contents: - -- constants for forwarded auth header names -- immutable records/classes for forwarded user context -- pure helper methods for reading and writing header dictionaries -- shared permission constants -- optional validation/parsing result helpers - -Disallowed contents: - -- `ApplicationUser`, plans, plan-permission persistence types -- cookie, session, or antiforgery types -- auth endpoint request/response DTOs -- gateway configuration or YARP-specific code -- service-specific domain contracts that are unrelated to forwarded auth - -## Proposed Package Structure - -Suggested namespace layout: - -- `WiSave.Portal.Contracts.Identity` -- `WiSave.Portal.Contracts.Authorization` - -Suggested files: - -- `Identity/PortalHeaderNames.cs` -- `Identity/ForwardedUserContext.cs` -- `Identity/ForwardedUserContextReader.cs` -- `Identity/ForwardedUserContextWriter.cs` -- `Authorization/PortalPermissions.cs` - -Optional later additions: - -- `Identity/ForwardedUserContextParseResult.cs` -- `Identity/ForwardedUserContextValidation.cs` - -## API Shape - -The concrete names can change slightly, but the package should look approximately like this: - -```csharp -namespace WiSave.Portal.Contracts.Identity; - -public static class PortalHeaderNames -{ - public const string UserId = "X-User-Id"; - public const string UserEmail = "X-User-Email"; - public const string UserPermissions = "X-User-Permissions"; - public const string UserRoles = "X-User-Roles"; -} - -public sealed record ForwardedUserContext( - string UserId, - string? Email, - IReadOnlySet Permissions, - IReadOnlySet Roles); - -public static class ForwardedUserContextReader -{ - public static ForwardedUserContext? Read(IReadOnlyDictionary headers); -} - -public static class ForwardedUserContextWriter -{ - public static IReadOnlyDictionary Write(ForwardedUserContext context); -} -``` - -```csharp -namespace WiSave.Portal.Contracts.Authorization; - -public static class PortalPermissions -{ - public static class Expenses - { - public const string Read = "expenses:read"; - public const string Write = "expenses:write"; - public const string Delete = "expenses:delete"; - } - - public static class Incomes - { - public const string Read = "incomes:read"; - public const string Write = "incomes:write"; - public const string Delete = "incomes:delete"; - public const string Import = "incomes:import"; - } - - public static class Stocks - { - public const string Read = "stocks:read"; - public const string Write = "stocks:write"; - public const string PortfolioManage = "stocks:portfolio:manage"; - public const string WatchlistManage = "stocks:watchlist:manage"; - } -} -``` - -## Design Notes - -### Pure class library - -The package must not depend on ASP.NET Core types such as `HttpContext`, `IHeaderDictionary`, or endpoint metadata. This keeps the package reusable in: - -- ASP.NET services -- worker processes -- tests -- future non-web consumers - -The integration layer in each service can adapt between framework-specific types and the pure contract helpers. - -### Roles - -`X-User-Roles` is currently forwarded by the portal but not used by expenses. The contract may still include it because it is already part of the boundary. If the team decides to trim unused data later, roles can be deprecated in the package and removed in a coordinated version bump. - -### Missing identity behavior - -The contracts package should not decide HTTP status codes. It should only parse and expose context. Each downstream service remains responsible for deciding whether missing or invalid forwarded identity maps to `401`, `403`, or another result. - -### Serialization rules - -The package should define the wire format explicitly: - -- `UserId` and `Email` are forwarded as single header values -- `Permissions` are forwarded as a comma-separated list in `X-User-Permissions` -- `Roles` are forwarded as a comma-separated list in `X-User-Roles` -- permission and role parsing should trim whitespace and use case-insensitive set semantics - -This preserves compatibility with the current portal behavior while making the parsing rules owned by one package. - -## Integration Plan - -### Portal changes - -Portal will consume `WiSave.Portal.Contracts` and replace hardcoded header strings with package constants. The user-header transform should construct a `ForwardedUserContext` and serialize it through the shared writer. - -Expected portal updates: - -- replace raw header name literals with `PortalHeaderNames` -- replace permission string literals in application code and tests with `PortalPermissions` -- keep SQL seed scripts as literal values unless the team introduces SQL generation later - -### Expenses changes - -Expenses will consume `WiSave.Portal.Contracts` and replace local header parsing with the shared reader. - -Expected expenses updates: - -- replace local header name literals with `PortalHeaderNames` -- replace local permission constants with `PortalPermissions.Expenses` -- adapt `HeaderCurrentUser` and `PermissionContext` to use a parsed `ForwardedUserContext` - -### Migration order - -1. Create and publish `WiSave.Portal.Contracts` -2. Update portal to consume the package first -3. Update expenses to consume the package -4. remove now-redundant local constants and parsing helpers from expenses - -Portal-first rollout is preferred because the portal owns the boundary definition. - -## Testing Strategy - -Package tests: - -- header constant coverage -- parse valid forwarded context -- parse missing optional values -- parse empty permission/role lists -- serialize and parse round-trip -- case-insensitive permission handling - -Portal tests: - -- continue verifying spoofed inbound headers are stripped -- verify forwarded header names come from package constants -- verify forwarded permissions match shared constants - -Expenses tests: - -- verify permission checks still succeed with package-defined permission constants -- verify missing forwarded identity is handled consistently -- verify the shared reader drives both current-user resolution and permission evaluation - -Cross-repo validation: - -- at minimum, run portal and expenses test suites that cover proxy forwarding and downstream auth -- ideally add a shared contract test fixture later to prevent behavioral drift - -## Risks - -- versioning discipline becomes important because downstream services depend on a published package -- if the contract package grows beyond auth transport, it will become a dumping ground and lose clarity -- raw trusted headers are still a security boundary assumption; this package reduces drift but does not harden the protocol by itself - -## Future Work - -The next likely hardening step is replacing raw forwarded headers with a signed short-lived internal token. That is intentionally out of scope for this package introduction. The current proposal focuses on making the existing protocol explicit, shared, and easier to evolve safely. - -## Recommendation - -Proceed with `WiSave.Portal.Contracts` as a small, pure, portal-owned NuGet package that contains: - -- header name constants -- forwarded user context transport types -- pure parsing/serialization helpers -- shared permission constants - -Do not place portal internals in the package. Keep it strictly focused on the portal-to-child auth boundary. diff --git a/docs/superpowers/specs/2026-04-12-portal-contracts-github-actions-design.md b/docs/superpowers/specs/2026-04-12-portal-contracts-github-actions-design.md deleted file mode 100644 index 77d92ae..0000000 --- a/docs/superpowers/specs/2026-04-12-portal-contracts-github-actions-design.md +++ /dev/null @@ -1,108 +0,0 @@ -# WiSave.Portal.Contracts GitHub Actions Design - -Date: 2026-04-12 - -## Summary - -Add a GitHub Actions workflow that builds, tests, packs, and publishes `WiSave.Portal.Contracts` to GitHub Packages whenever code is merged into `master`. - -The package version will be generated automatically from a fixed `VersionPrefix` in the project and the GitHub Actions run number: - -- project `VersionPrefix`: for example `0.1` -- published package version: `0.1.` - -This produces a stable, monotonically increasing internal package version on every merge to `master`. - -## Goals - -- publish `WiSave.Portal.Contracts` automatically on every push to `master` -- make the package consumable from other GitHub repositories through GitHub Packages -- keep versioning automatic and simple -- validate the repository before publishing - -## Non-Goals - -- semantic version inference from commit messages -- tag-based release publishing -- publishing the whole solution as NuGet packages -- publishing prerelease packages from feature branches - -## Workflow Behavior - -Trigger: - -- `push` to `master` - -Steps: - -1. checkout code -2. install .NET SDK -3. restore dependencies -4. run tests -5. compute package version from `VersionPrefix` + `${{ github.run_number }}` -6. pack `src/WiSave.Portal.Contracts/WiSave.Portal.Contracts.csproj` -7. publish the generated package to GitHub Packages -8. optionally upload the `.nupkg` as a workflow artifact for inspection - -## Project Changes - -`WiSave.Portal.Contracts.csproj` should include package metadata: - -- `PackageId` -- `VersionPrefix` -- `Authors` -- `Description` -- `RepositoryUrl` -- `PackageReadmeFile` -- `PackageTags` -- `PackageLicenseExpression` if desired - -A `README.md` should be added under the contracts project and included in the package so the GitHub Packages page is usable. - -## Versioning - -Use fixed `VersionPrefix` in the project file, for example: - -```xml -0.1 -``` - -The workflow computes: - -```text -0.1.${GITHUB_RUN_NUMBER} -``` - -Examples: - -- run `15` => `0.1.15` -- run `16` => `0.1.16` - -This is intentionally simple and stable for internal package consumption. - -## Publishing Target - -Publish to GitHub Packages using the repository owner namespace. Authentication will use the built-in `GITHUB_TOKEN`. - -The workflow should grant: - -- `contents: read` -- `packages: write` - -## Consumer Expectations - -Other repositories that want to consume `WiSave.Portal.Contracts` will need: - -- GitHub Packages configured as a NuGet source -- credentials that can read packages from the account or organization feed -- a package reference to the published version - -## Risks - -- every merge to `master` creates a stable package, so package history will grow quickly -- `github.run_number` is repository-wide, not package-specific -- if package metadata is incomplete, the resulting package page will be poor even if publishing succeeds - -## Recommendation - -Implement a dedicated workflow for `WiSave.Portal.Contracts` publishing on `master`, with package metadata added to the project and version generation based on `VersionPrefix + run number`. diff --git a/docs/superpowers/specs/2026-04-12-test-layer-separation-design.md b/docs/superpowers/specs/2026-04-12-test-layer-separation-design.md deleted file mode 100644 index 050ea85..0000000 --- a/docs/superpowers/specs/2026-04-12-test-layer-separation-design.md +++ /dev/null @@ -1,180 +0,0 @@ -# Portal Test Layer Separation Design - -## Goal - -Split the current mixed `tests/WiSave.Portal.Tests` project into clear testing layers so the repository has an explicit boundary between unit tests, integration tests, and future end-to-end tests. - -## Current State - -The repository currently has a single test project, `tests/WiSave.Portal.Tests/WiSave.Portal.Tests.csproj`, and the solution and CI workflow reference that project directly. - -That project mixes two different kinds of tests: - -- Unit-style tests that execute isolated logic or configuration without booting the application host. -- Integration-style tests that use `WebApplicationFactory` to boot the portal in-process and exercise multiple application components together. - -The integration tests are lightweight because they use in-memory substitutes for infrastructure, but they are still integration tests by definition because they execute the real ASP.NET Core host, DI graph, routing, middleware, auth, hubs, and endpoint wiring. - -## Definitions - -### Unit Tests - -Unit tests verify a class, method, or configuration rule in isolation. - -Rules: - -- Must not boot `Program` or use `WebApplicationFactory`. -- Must not require live infrastructure or real network connections. -- May use simple DI setup when the subject under test is still isolated. - -Initial examples: - -- `tests/WiSave.Portal.Tests/Authorization/PermissionHandlerTests.cs` -- `tests/WiSave.Portal.Tests/Contracts/ForwardedUserContextTests.cs` -- `tests/WiSave.Portal.Tests/Session/SessionConfigurationTests.cs` - -### Integration Tests - -Integration tests verify that application components work together inside the real portal host. - -Rules: - -- May use `WebApplicationFactory`. -- May boot the real ASP.NET Core app and exercise routing, middleware, auth, SignalR, and endpoint behavior. -- May use in-memory or fake infrastructure for determinism. -- Must not be labeled as unit tests even if they are fast. - -Initial examples: - -- `tests/WiSave.Portal.Tests/Auth/AuthEndpointsTests.cs` -- `tests/WiSave.Portal.Tests/Gateway/UserHeaderTransformTests.cs` -- `tests/WiSave.Portal.Tests/Hubs/NotificationsHubTests.cs` -- `tests/WiSave.Portal.Tests/Messaging/ConsumerSignalRTests.cs` - -### End-to-End Tests - -End-to-end tests execute the system from outside the application boundary. - -Rules: - -- Must not use in-process host shortcuts such as `WebApplicationFactory`. -- Should target a running stack through HTTP, browser automation, or deployed environment entry points. -- Are intentionally slower and operationally separate from routine validation. - -There are no current E2E tests in this repository. The design reserves a clear place for them so they can be added later without mixing them into the unit or integration layers. - -## Target Repository Structure - -The repository should move to this layout: - -- `tests/WiSave.Portal.UnitTests` -- `tests/WiSave.Portal.IntegrationTests` -- `tests/WiSave.Portal.E2E` reserved for future use, but not created until there is at least one real E2E scenario - -Only two projects should be created now: - -- `tests/WiSave.Portal.UnitTests/WiSave.Portal.UnitTests.csproj` -- `tests/WiSave.Portal.IntegrationTests/WiSave.Portal.IntegrationTests.csproj` - -The existing mixed project should be retired after its files are redistributed. - -## Naming and Namespace Conventions - -Namespaces should follow the project boundary so the layer is visible from the code itself: - -- `WiSave.Portal.UnitTests.*` -- `WiSave.Portal.IntegrationTests.*` -- Future: `WiSave.Portal.E2E.*` or `WiSave.Portal.E2ETests.*` - -Files should be grouped by behavior, not by historical location. A test file belongs in the project that matches how it exercises the system. - -## CI Strategy - -### Publish Workflow - -The package publishing workflow should run unit tests only. - -Reasoning: - -- Publishing contracts should stay fast and deterministic. -- That workflow should not depend on application-host boot unless contract packaging itself requires it. -- Unit coverage is enough there to protect isolated logic used by packaging-adjacent changes. - -Target command: - -```bash -dotnet test tests/WiSave.Portal.UnitTests/WiSave.Portal.UnitTests.csproj --configuration Release --no-restore -``` - -### Main Validation Workflow - -A separate validation workflow should run both unit and integration tests on pushes and pull requests. - -Target commands: - -```bash -dotnet test tests/WiSave.Portal.UnitTests/WiSave.Portal.UnitTests.csproj --configuration Release -dotnet test tests/WiSave.Portal.IntegrationTests/WiSave.Portal.IntegrationTests.csproj --configuration Release -``` - -This workflow must be added immediately if integration tests are removed from the publish workflow so coverage is not silently lost. - -### Future E2E Workflow - -E2E should have its own workflow with slower triggers such as: - -- manual dispatch -- nightly schedule -- release or pre-release validation - -The E2E workflow is intentionally separate so operational flakiness or browser/runtime costs do not contaminate normal application validation. - -## Migration Plan Shape - -The implementation should proceed in this order: - -1. Create the `UnitTests` and `IntegrationTests` projects. -2. Move existing test files into the correct project based on behavior. -3. Update namespaces and project references. -4. Update `WiSave.Portal.slnx` to include the new projects and remove the old mixed project. -5. Update workflows to run the new projects in the correct lanes. -6. Update any active documentation that points contributors to the old mixed test project. - -This keeps the refactor mechanical and lowers the risk of mixing classification changes with unrelated test rewrites. - -## Non-Goals - -- Adding Testcontainers in this change. -- Introducing browser tooling in this change. -- Rewriting existing tests unless a move requires minor namespace or helper adjustments. -- Broad cleanup of all historical docs that mention `WiSave.Portal.Tests`. - -## Risks and Controls - -### Risk: Misclassifying fast host-based tests as unit tests - -Control: - -Treat any `WebApplicationFactory` test as integration by definition. - -### Risk: Losing CI coverage by narrowing the publish workflow - -Control: - -Add or update a dedicated validation workflow in the same implementation batch. - -### Risk: Shared helpers blur layer boundaries again - -Control: - -Keep host-boot and app-fixture helpers in the integration project only. Keep unit helpers local to the unit project. - -## Success Criteria - -The design is complete when: - -- unit and integration tests live in separate test projects -- no `WebApplicationFactory` test remains in the unit test project -- the publish workflow runs unit tests only -- a validation workflow runs both unit and integration suites -- the repository has an explicit reserved path for future E2E tests diff --git a/docs/superpowers/specs/2026-04-14-portal-multibus-design.md b/docs/superpowers/specs/2026-04-14-portal-multibus-design.md deleted file mode 100644 index 92fa083..0000000 --- a/docs/superpowers/specs/2026-04-14-portal-multibus-design.md +++ /dev/null @@ -1,160 +0,0 @@ -# Portal Multi-Bus Design - -## Goal - -Add full MassTransit multi-bus support to `WiSave.Portal` so the application can register and use multiple typed buses explicitly, and so integration tests exercise the same typed bus topology shape as production. - -## Current State - -- `src/WiSave.Portal/Messaging/Extensions.cs` registers a single typed bus via `AddMassTransit(...)`. -- `src/WiSave.Portal.Contracts/Bus/IPortalBus.cs` already exists as a second bus marker, but it is not wired into the application. -- Integration tests in `tests/WiSave.Portal.IntegrationTests/Messaging/ConsumerSignalRTests.cs` add `AddMassTransitTestHarness()` as a default harness, which does not match the production typed-bus registration. -- This mismatch makes tests ambiguous: the app consumes through a typed bus, while the tests publish through the default harness bus. - -## Requirements - -### Functional - -1. The portal must support registering more than one typed MassTransit bus. -2. Each bus must have explicit ownership via its marker interface. -3. Non-consumer application code must be able to publish and send through a specific bus explicitly. -4. Integration tests must publish through the same typed bus shape used by the application. -5. Existing notification consumer behavior must continue to work on the expenses bus. - -### Non-Functional - -1. Keep the change aligned with existing extension-based service registration patterns. -2. Keep the public shape simple: one registration method for messaging, focused helper types where needed, and no broad refactor outside the messaging/test boundary. -3. Prefer explicitness over convention magic. Bus selection should be obvious in DI. - -## Recommended Approach - -Use true MassTransit multi-bus registration. - -- Register one bus per marker interface with `AddMassTransit()`. -- Keep expenses events on `IExpensesBus`. -- Introduce full runtime wiring for `IPortalBus`, even if it does not immediately host consumers. -- Use `Bind` and `Bind` in non-consumer code so bus selection remains explicit. -- In integration tests, replace the default harness registration with test DI that mirrors the typed bus being exercised. - -This is preferred over a test-only workaround because it keeps production and test configuration aligned and makes future bus additions straightforward. - -## Architecture - -### Messaging Registration - -`src/WiSave.Portal/Messaging/Extensions.cs` will become the single entry point for multi-bus registration. - -- Read a bus-specific configuration section for each bus. -- Register `IExpensesBus` with the current `NotificationConsumer` endpoint configuration. -- Register `IPortalBus` with its own host settings and endpoint configuration. -- Keep endpoint naming deterministic and consistent with current formatter usage. - -### Bus-Specific Publish/Send Access - -Where application code needs to publish or send outside a consumer scope, it should resolve the bus-bound endpoint via `Bind`. - -This avoids relying on the default `IPublishEndpoint` or `ISendEndpointProvider`, which is ambiguous in multi-bus setups outside consume scopes. - -### Test Wiring - -Integration tests that currently rely on `ITestHarness` plus the default bus registration will be updated to target the typed bus explicitly. - -The key point is not to bolt a separate default harness onto the container and assume it drives the typed bus consumers. Test registration must mirror the production bus identity: - -- the same bus marker interface, -- the same consumers under test, -- an in-memory transport for deterministic testing. - -## Configuration Shape - -The messaging configuration should support one section per bus. The simplest maintainable shape is: - -```json -{ - "RabbitMq": { - "Expenses": { - "Host": "localhost", - "VirtualHost": "expenses", - "Username": "guest", - "Password": "guest" - }, - "Portal": { - "Host": "localhost", - "VirtualHost": "portal", - "Username": "guest", - "Password": "guest" - } - } -} -``` - -Backward compatibility may be preserved for the existing flat settings by treating them as the default for `Expenses` when the nested section is absent. - -## File-Level Design - -### `src/WiSave.Portal/Messaging/Extensions.cs` - -- Expand from single-bus setup to multi-bus registration. -- Extract repeated broker-setting reads into a small private helper record or method if repetition becomes noisy. -- Keep `NotificationConsumer` attached to the expenses bus only. - -### `src/WiSave.Portal.Contracts/Bus/IPortalBus.cs` - -- Keep as the portal bus marker. -- No functional changes required unless namespace or XML docs need cleanup. - -### Additional Messaging Helper File - -If the application does not already have a clean place for explicit bus publishing, add a focused helper file under `src/WiSave.Portal/Messaging/` for bus-bound publisher abstractions or helper methods. - -This file should exist only if needed by current usage. Do not add wrappers without a concrete call site. - -### `tests/WiSave.Portal.IntegrationTests/Messaging/ConsumerSignalRTests.cs` - -- Replace default harness assumptions with typed-bus-aware test setup. -- Publish through the expenses bus registration used by the app under test. -- Keep test intent unchanged: validate SignalR notifications from consumed events. - -## Error Handling - -- Missing bus-specific configuration should fall back to safe defaults where that already exists today for the expenses bus. -- The portal bus should fail in a normal, visible MassTransit startup way if explicitly configured incorrectly; no custom error layer is needed. -- Tests should fail clearly when the typed bus is not registered, rather than silently publishing on an unrelated default bus. - -## Testing Strategy - -### Integration Tests - -Primary validation should focus on the nearest relevant tests: - -- `tests/WiSave.Portal.IntegrationTests/Messaging/ConsumerSignalRTests.cs` - -These tests should verify that messages published through the expenses typed bus are consumed and forwarded to SignalR clients. - -### Broader Verification - -After targeted integration tests pass, run: - -- `dotnet build` -- targeted integration tests for messaging -- broader `dotnet test` if the local environment allows it - -## Risks - -1. MassTransit test harness APIs around typed buses are easier to misconfigure than the default single-bus harness. -2. Accidentally leaving code on plain `IPublishEndpoint` outside consumers would make bus routing ambiguous. -3. Changing configuration shape without a backward-compatibility path could break existing local environments. - -## Mitigations - -1. Keep typed bus ownership explicit in DI and in tests. -2. Preserve current expenses defaults while adding nested per-bus configuration. -3. Limit the first implementation to the expenses and portal buses only. - -## Success Criteria - -1. `WiSave.Portal` registers both `IExpensesBus` and `IPortalBus`. -2. Expenses notifications still reach SignalR clients. -3. Messaging tests publish through the typed expenses bus path rather than a separate default test bus. -4. The configuration and DI setup make bus ownership obvious to future contributors. diff --git a/docs/superpowers/specs/2026-04-19-portal-account-signalr-propagation-design.md b/docs/superpowers/specs/2026-04-19-portal-account-signalr-propagation-design.md deleted file mode 100644 index e9a2e0b..0000000 --- a/docs/superpowers/specs/2026-04-19-portal-account-signalr-propagation-design.md +++ /dev/null @@ -1,283 +0,0 @@ -# Portal Account SignalR Propagation Design - -## Goal - -Adjust the `wisave-portal` -> `wisave-ui` realtime boundary for expenses account events so the frontend receives explicit, FE-oriented full account snapshots instead of raw expenses contracts. - -The scope of this change is limited to: - -- `account.opened` -- `account.updated` -- `account.closed` - -Expense and budget realtime events stay on the current generic pass-through path for now. - -## Context - -Current flow: - -1. `wisave-expenses` publishes MassTransit events such as `AccountOpened` and `AccountUpdated`. -2. `wisave-portal` consumes those events in `NotificationConsumer`. -3. The portal wraps the raw message in a generic `RealtimeEnvelope`. -4. `wisave-ui` filters portal SignalR envelopes by `domain === 'expenses'` and interprets the payload as a frontend account event. - -Current frontend behavior for accounts is fragile: - -- `account.updated` is treated like a partial patch. -- The store merges updates into the existing entity shape. -- Type and debit-card variant transitions are risky because the merge logic depends on the current local shape. -- The portal boundary does not explicitly control enum/string shape for frontend-facing account payloads. - -This is no longer a good fit after the account model changed to discriminated account variants and credit-card debt buckets. - -## Problem - -The realtime boundary is too generic for account events. - -The frontend now depends on: - -- full account-kind shape -- explicit debit-card variant -- nullable `balance` -- `previousCycleDebt` -- `currentCycleDebt` - -If account realtime stays generic and patch-oriented: - -- `linked` -> `standalone` debit-card changes can be merged incorrectly -- cross-type transitions rely on brittle FE logic -- the portal has no explicit contract for what the UI is supposed to receive -- enum serialization remains an implicit transport detail instead of a controlled boundary - -## Recommended Approach - -Use an explicit, non-generic portal-side adapter for account realtime events. - -For account events only: - -1. `wisave-expenses` remains the source of domain events. -2. `wisave-portal` maps `AccountOpened` and `AccountUpdated` to a dedicated FE-facing realtime payload. -3. `wisave-portal` pushes that payload inside the existing `RealtimeEnvelope`. -4. `wisave-ui` treats both `account.opened` and `account.updated` as full snapshots and replaces the account entity wholesale. -5. `account.closed` remains id-based and removes the entity. - -This keeps full control over: - -- payload shape -- enum/string values -- nullability -- what the FE may rely on as a complete account snapshot - -## Rejected Approaches - -### 1. Keep generic raw-contract forwarding - -Rejected because the FE still has to interpret domain contracts directly and keep shape-sensitive merge logic. - -### 2. Fetch the account from expenses on every account update - -Rejected because the portal can receive the event before the expenses read model is caught up. Immediate refreshes can return stale state and introduce extra coupling, latency, and failure modes. - -### 3. Hydrate only when fields are missing - -Rejected because it keeps two code paths: - -- direct payload usage -- refresh/recompute fallback - -That adds complexity without solving the core issue that account updates should already be full snapshots. - -## Portal Realtime DTO - -Add an explicit FE-facing account realtime payload used only by the portal realtime layer. - -Recommended shape for both `account.opened` and `account.updated`: - -```csharp -public sealed record AccountPayload( - string AccountId, - string UserId, - string Name, - string Type, - string? Variant, - string Currency, - decimal? Balance, - string? LinkedBankAccountId, - decimal? CreditLimit, - int? BillingCycleDay, - decimal? PreviousCycleDebt, - decimal? CurrentCycleDebt, - string? Color, - string? LastFourDigits, - DateTimeOffset Timestamp); -``` - -Notes: - -- `Type` is FE-facing string output such as `BankAccount`, `DebitCard`, `CreditCard`, `Cash` -- `Variant` is FE-facing string output such as `linked`, `standalone`, or `null` -- `Balance` stays nullable -- both debt buckets stay nullable for non-credit-card accounts -- this DTO is a portal adapter contract, not a domain event contract - -## Portal Mapping Rules - -The portal should explicitly map account events instead of forwarding the raw message object. - -### `AccountOpened` - -Map the event to `AccountPayload` and publish it as: - -- `domain: "expenses"` -- `eventType: "account.opened"` -- `entityId: message.AccountId` -- `payload: mapped full snapshot` - -### `AccountUpdated` - -Map the event to the same `AccountPayload` shape and publish it as: - -- `domain: "expenses"` -- `eventType: "account.updated"` -- `entityId: message.AccountId` -- `payload: mapped full snapshot` - -This is intentionally not a patch payload. The frontend should receive a complete account snapshot every time. - -### `AccountClosed` - -Keep the current lightweight event: - -- `domain: "expenses"` -- `eventType: "account.closed"` -- `entityId: message.AccountId` -- `payload: raw close payload is acceptable` - -No special DTO is required for close at this stage. - -## Implementation Shape In Portal - -Do not redesign the whole notification pipeline. Keep the existing `NotificationConsumer`, but make account propagation explicit inside it. - -Recommended implementation: - -- keep generic `Push(...)` for expense and budget events -- add account-specific push paths for `AccountOpened` and `AccountUpdated` -- add a small private mapper or a nearby static mapper dedicated to account realtime payloads - -Example direction: - -```csharp -public Task Consume(ConsumeContext ctx) => - PushAccountOpened(ctx); - -public Task Consume(ConsumeContext ctx) => - PushAccountUpdated(ctx); -``` - -with: - -- `MapAccountRealtimePayload(AccountOpened message)` -- `MapAccountRealtimePayload(AccountUpdated message)` - -No generic “map every expenses contract to FE DTO” abstraction should be introduced in this change. - -## Frontend Consumption Rules - -The frontend should stop treating `account.updated` as a partial patch. - -### UI SignalR Types - -In `wisave-ui`, `IAccountUpdatedPayload` should become the same full snapshot shape as `IAccountOpenedPayload`. - -That means: - -- remove `Partial` semantics for account updates -- keep one FE assumption: opened and updated carry full account state - -### UI Store Handling - -For account realtime: - -- `account.opened` -> map payload -> upsert/replace -- `account.updated` -> map payload -> upsert/replace -- `account.closed` -> remove entity - -The account store should not merge partial updates for account events anymore. - -### UI Derived Data - -The frontend may still compute presentation-only derived values locally, for example: - -- settlement date labels -- effective-balance breakdown -- due/pending presentation - -But it should not reconstruct missing domain state from partial account update payloads. - -## Reconnect Strategy - -Keep the existing reconnect catch-up in the UI as a safety net: - -- after disconnect/reconnect, the accounts page may still trigger an HTTP resync - -But after this change, reconnect catch-up becomes a backup consistency path, not the normal mechanism required to repair incomplete realtime account payloads. - -## Testing - -### Portal - -Add or update tests so the portal proves it emits FE-facing account payloads: - -- `AccountOpened` SignalR envelope contains: - - `eventType = "account.opened"` - - full payload - - string `type` - - string `variant` - - `previousCycleDebt` - - `currentCycleDebt` -- `AccountUpdated` SignalR envelope contains: - - `eventType = "account.updated"` - - full payload, not patch semantics - -The existing integration path in `ConsumerSignalRTests` is the right place for boundary verification. - -### UI - -Update or add tests so the FE proves account updates replace entities safely: - -- linked debit -> standalone debit update replaces shape correctly -- standalone debit -> linked debit update replaces shape correctly -- bank account -> credit card update replaces shape correctly -- account update path no longer depends on partial merge logic - -## File Impact - -### Portal - -- `src/WiSave.Portal/Messaging/NotificationConsumer.cs` -- create a small account realtime DTO file near the portal realtime/messaging code -- `tests/WiSave.Portal.IntegrationTests/Messaging/ConsumerSignalRTests.cs` - -### UI - -- `src/app/core/signalr/expenses-signalr.types.ts` -- `src/app/features/expense-accounts/+store/accounts/accounts.signalr.event-handlers.ts` -- related FE specs for account SignalR handling - -## Out Of Scope - -- expense realtime payload redesign -- budget realtime payload redesign -- replacing the existing `RealtimeEnvelope` -- generic portal-side DTO mapping for all downstream services -- portal-side HTTP hydration from expenses on account updates - -## Result - -After this change: - -- account realtime becomes explicit and frontend-oriented -- portal owns the account SignalR contract intentionally -- UI account updates become full-snapshot replacement, not patch merging -- type and debit-card variant transitions stop depending on brittle FE merge logic diff --git a/docs/superpowers/specs/2026-04-26-identity-plan-permissions-design.md b/docs/superpowers/specs/2026-04-26-identity-plan-permissions-design.md deleted file mode 100644 index fe596d4..0000000 --- a/docs/superpowers/specs/2026-04-26-identity-plan-permissions-design.md +++ /dev/null @@ -1,142 +0,0 @@ -# Identity Plan Permissions Design - -## Context - -WiSave.Portal currently uses ASP.NET Core Identity for users, passwords, cookies, and broad roles, but uses custom `Plans`, `Permissions`, and `PlanPermissions` tables to resolve plan-based permissions. `PermissionResolutionMiddleware` reads the current user's `PlanId`, loads permissions through custom caches, stores them in `HttpContext.Items["UserPermissions"]`, and `UserHeaderTransform` forwards them to downstream services as `X-User-Permissions`. - -For the current requirement, plans are fixed account tiers: free, standard, and premium. A normal user should have exactly one plan. Admin roles remain separate from plans. - -## Goal - -Simplify plan and permission handling by relying on built-in ASP.NET Core Identity structures as much as possible: - -- Identity users remain the account source of truth. -- Identity roles represent plan tiers and admin roles. -- Identity role claims represent permissions. -- The existing gateway contract continues forwarding `X-User-Permissions`. - -## Role Model - -Use Identity roles for both account plans and administrative roles. - -Plan roles: - -- `plan:free` -- `plan:standard` -- `plan:premium` - -Administrative roles: - -- `admin` -- `superadmin` - -Plan roles are mutually exclusive for normal users. A user may also have administrative roles. `admin` and `superadmin` continue to grant all permissions. - -The existing generic `user` role becomes unnecessary for permission resolution. It can either be retained temporarily for compatibility or removed after confirming no downstream service depends on `X-User-Roles` containing `user`. - -## Permission Model - -Store permissions as Identity role claims: - -- claim type: `permission` -- claim value: permission name, for example `incomes:read` - -The stable permission names are the authorization contract. The existing GUID permission IDs are not needed if permissions move into role claims. - -Initial permission set: - -- `incomes:read` -- `incomes:write` -- `incomes:delete` -- `incomes:import` -- `stocks:read` -- `stocks:write` -- `stocks:portfolio:manage` -- `stocks:watchlist:manage` -- `expenses:read` -- `expenses:write` -- `expenses:delete` - -Initial plan mapping: - -- `plan:free`: `incomes:read` -- `plan:standard`: `incomes:read`, `incomes:write`, `stocks:read`, `expenses:read`, `expenses:write` -- `plan:premium`: all listed permissions - -This mapping is the implementation default. Changes to plan capabilities should be made in the seed migration and reflected in tests. - -## Authentication Flow - -Registration should accept a selected plan. If no plan is provided, default to `plan:free`. - -On registration: - -1. Validate the selected plan role is one of the supported plan roles. -2. Create the Identity user. -3. Assign exactly one plan role to the user. -4. Sign the user in. - -Login should not change the user's plan. Login authenticates the existing account. Registration or a dedicated plan-change endpoint controls plan assignment. If the product later requires choosing a plan during login, that should be designed as a separate change because it affects billing, auditability, and downgrade behavior. - -## Permission Resolution - -Replace custom plan permission resolution with Identity role claim resolution. - -Request-time behavior: - -1. If the user is unauthenticated, continue without permissions. -2. If the user has `admin` or `superadmin`, set permissions to `*`. -3. Otherwise, read the user's roles. -4. Load role claims for those roles. -5. Collect all claim values where claim type is `permission`. -6. Store the resulting set in `HttpContext.Items["UserPermissions"]`. - -`UserHeaderTransform` can continue forwarding `X-User-Permissions` without changing the downstream contract. - -Caching can be simpler than the current user-plan and plan-permission caches. Role claims are small and mostly static, so the first implementation can resolve them directly through Identity. If needed later, add one cache keyed by role name or role ID for role permission claims. - -## Database Changes - -New seed migration should ensure: - -- plan roles exist -- admin roles exist -- permission claims exist on plan roles -- obsolete or duplicate role claims are not inserted twice - -The custom tables can be deprecated: - -- `Plans` -- `Permissions` -- `PlanPermissions` -- `AspNetUsers.PlanId` - -For a minimal first migration, keep old columns/tables unused to avoid destructive schema changes. A later cleanup migration can drop them after the new flow is verified and no production code reads them. - -## Code Changes - -Expected implementation scope: - -- Update registration DTO and endpoint logic to use plan roles instead of `ApplicationUser.PlanId`. -- Remove runtime dependency on `PortalDbContext.Plans` during registration. -- Replace `UserPlanCache` and `PlanPermissionCache` usage in `PermissionResolutionMiddleware`. -- Add a small role permission resolver service if the middleware should stay thin. -- Keep `UserHeaderTransform` behavior intact. -- Update tests to seed Identity roles and role claims instead of custom plan rows. - -## Testing - -Targeted tests should cover: - -- registering with `free`, `standard`, and `premium` assigns the correct plan role -- invalid plan selection returns `400` -- users receive `X-User-Permissions` based on plan role claims -- spoofed permission headers are stripped and replaced -- admin users receive `*` -- login preserves the assigned plan role - -Run targeted auth and gateway tests first, then `dotnet test` if the targeted suite passes. - -## Deferred Decisions - -No implementation blocker remains for the initial simplification. A future billing/subscription design should decide whether plan roles are changed by users directly, an admin workflow, or an external billing webhook. diff --git a/local-packages/WiSave.Expenses.Contracts.0.1.0.nupkg b/local-packages/WiSave.Expenses.Contracts.0.1.0.nupkg deleted file mode 100644 index 6e54a0c..0000000 Binary files a/local-packages/WiSave.Expenses.Contracts.0.1.0.nupkg and /dev/null differ diff --git a/scripts/generate-dbup-script.sh b/scripts/generate-dbup-script.sh deleted file mode 100755 index d8679fb..0000000 --- a/scripts/generate-dbup-script.sh +++ /dev/null @@ -1,27 +0,0 @@ -#!/usr/bin/env bash - -set -euo pipefail - -if [[ $# -ne 3 ]]; then - echo "Usage: $0 " >&2 - exit 1 -fi - -FROM_MIGRATION="$1" -TO_MIGRATION="$2" -OUTPUT_PATH="$3" - -ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -PROJECT_PATH="$ROOT_DIR/src/WiSave.Portal/WiSave.Portal.csproj" -STARTUP_PROJECT_PATH="$ROOT_DIR/src/WiSave.Portal.EfTools/WiSave.Portal.EfTools.csproj" - -mkdir -p "$(dirname "$OUTPUT_PATH")" - -dotnet ef migrations script \ - "$FROM_MIGRATION" \ - "$TO_MIGRATION" \ - --project "$PROJECT_PATH" \ - --startup-project "$STARTUP_PROJECT_PATH" \ - --output "$OUTPUT_PATH" - -echo "Generated DbUp script: $OUTPUT_PATH" diff --git a/src/WiSave.Portal.AppHost/AppHost.cs b/src/WiSave.Portal.AppHost/AppHost.cs new file mode 100644 index 0000000..7473674 --- /dev/null +++ b/src/WiSave.Portal.AppHost/AppHost.cs @@ -0,0 +1,64 @@ +using WiSave.Portal.AppHost; + +var builder = DistributedApplication.CreateBuilder(args); + +var postgres = builder.AddPostgres( + "portal-postgres", + userName: builder.AddParameter("pg-user", "wisave"), + password: builder.AddParameter("pg-password", "wisave_dev", secret: true)) + .WithImageTag("17") + .WithDataVolume("portal-db") + .WithHostPort(5432); + +var db = postgres.AddDatabase("portal", "wisave_portal"); + +var redis = builder.AddRedis("redis") + .WithImage("redis", "7-alpine") + .WithHostPort(6379) + .WithDataVolume("portal-redis"); + +var rabbitMqUsername = builder.AddParameter("rabbitmq-username", "guest"); +var rabbitMqPassword = builder.AddParameter("rabbitmq-password", "guest", secret: true); + +var rabbitmq = builder.AddRabbitMQ("rabbitmq", rabbitMqUsername, rabbitMqPassword) + .WithImageTag("4-management-alpine") + .WithBindMount( + "../../infrastructure/rabbitmq/definitions.json", + "/etc/rabbitmq/definitions.json", + isReadOnly: true) + .WithEnvironment( + "RABBITMQ_SERVER_ADDITIONAL_ERL_ARGS", + "-rabbitmq_management load_definitions \"/etc/rabbitmq/definitions.json\"") + .WithEndpoint("tcp", endpoint => endpoint.Port = 5672) + .WithDataVolume("rabbitmq-data") + .WithSharedNetworkAlias("wisave-net", containerName: "wisave-rabbitmq", alias: "rabbitmq"); + +var rabbitMqHost = builder.AddParameter("rabbitmq-host", "localhost"); + +builder.AddExternalService("incomes", "http://localhost:5300"); +builder.AddExternalService("stocks", "http://localhost:5301"); +builder.AddExternalService("expenses", "http://localhost:5200"); + +builder.AddProject("portal-api") + .WithEndpoint("http", endpoint => + { + endpoint.Port = 5100; + endpoint.IsProxied = false; + }) + .WithEnvironment("ASPNETCORE_ENVIRONMENT", "Development") + .WithEnvironment("ConnectionStrings__Portal", db) + .WithEnvironment("Redis__ConnectionString", redis) + .WithEnvironment("RabbitMq__Host", rabbitMqHost) + .WithEnvironment("RabbitMq__VirtualHost", "portal") + .WithEnvironment("RabbitMq__Username", rabbitMqUsername) + .WithEnvironment("RabbitMq__Password", rabbitMqPassword) + .WithEnvironment("Cors__Origins__0", "http://localhost:4200") + .WithEnvironment("ReverseProxy__Clusters__incomes-cluster__Destinations__destination1__Address", "http://localhost:5300") + .WithEnvironment("ReverseProxy__Clusters__stocks-cluster__Destinations__destination1__Address", "http://localhost:5301") + .WithEnvironment("ReverseProxy__Clusters__expenses-cluster__Destinations__destination1__Address", "http://localhost:5200") + .WaitFor(db) + .WaitFor(redis) + .WaitFor(rabbitmq) + .WithHttpHealthCheck("/health"); + +builder.Build().Run(); diff --git a/src/WiSave.Portal.AppHost/Properties/launchSettings.json b/src/WiSave.Portal.AppHost/Properties/launchSettings.json new file mode 100644 index 0000000..1736c28 --- /dev/null +++ b/src/WiSave.Portal.AppHost/Properties/launchSettings.json @@ -0,0 +1,30 @@ +{ + "$schema": "https://json.schemastore.org/launchsettings.json", + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "http://localhost:15100", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development", + "DOTNET_ENVIRONMENT": "Development", + "ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL": "http://localhost:19100", + "ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL": "http://localhost:20100", + "ASPIRE_ALLOW_UNSECURED_TRANSPORT": "true" + } + }, + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "https://localhost:17100;http://localhost:15100", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development", + "DOTNET_ENVIRONMENT": "Development", + "ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL": "https://localhost:21100", + "ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL": "https://localhost:22100" + } + } + } +} diff --git a/src/WiSave.Portal.AppHost/SharedNetworkExtensions.cs b/src/WiSave.Portal.AppHost/SharedNetworkExtensions.cs new file mode 100644 index 0000000..d5ce060 --- /dev/null +++ b/src/WiSave.Portal.AppHost/SharedNetworkExtensions.cs @@ -0,0 +1,135 @@ +using System.Diagnostics; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; + +namespace WiSave.Portal.AppHost; + +/// +/// Joins an Aspire-owned container to a Docker network the AppHost does not own, so stacks +/// outside this repository can reach it by a stable hostname. +/// +/// +/// Aspire gives a container exactly one network and no API to add a second: +/// WithContainerRuntimeArgs("--network", ...) collides with DCP's own +/// --network bridge and kills the container at create time (exit 125), and +/// WithContainerNetworkAlias only names the container within Aspire's own network. +/// DCP itself attaches with docker network connect after create, so do the same one +/// step later, once the resource is ready. +/// +internal static class SharedNetworkExtensions +{ + /// + /// Pins — DCP otherwise appends a random suffix, leaving + /// no handle to attach or docker exec against — then attaches the container to + /// under once it is ready. + /// + internal static IResourceBuilder WithSharedNetworkAlias( + this IResourceBuilder builder, + string network, + string containerName, + string alias) + where T : ContainerResource => + builder + .WithContainerName(containerName) + .OnResourceReady(async (resource, readyEvent, cancellationToken) => + { + var logger = readyEvent.Services + .GetRequiredService() + .GetLogger(resource); + + await AttachAsync(logger, network, containerName, alias, cancellationToken); + }); + + private static async Task AttachAsync( + ILogger logger, + string network, + string containerName, + string alias, + CancellationToken cancellationToken) + { + try + { + // Sibling stacks declare the network as external, so nothing there creates it. + var inspect = await DockerAsync(["network", "inspect", network], cancellationToken); + if (inspect.ExitCode != 0) + { + var create = await DockerAsync(["network", "create", network], cancellationToken); + if (create.ExitCode != 0) + { + logger.LogWarning( + "Could not create the shared Docker network {Network}: {Error}", + network, + create.Error); + return; + } + + logger.LogInformation("Created shared Docker network {Network}", network); + } + + var connect = await DockerAsync( + ["network", "connect", "--alias", alias, network, containerName], + cancellationToken); + + if (connect.ExitCode == 0) + { + logger.LogInformation( + "Attached {Container} to {Network} as {Alias}", + containerName, + network, + alias); + return; + } + + // Docker's wording for "already on that network". + if (connect.Error.Contains("already exists", StringComparison.OrdinalIgnoreCase)) + { + logger.LogInformation( + "{Container} already attached to {Network}", + containerName, + network); + return; + } + + logger.LogWarning( + "Could not attach {Container} to {Network}: {Error}", + containerName, + network, + connect.Error); + } + catch (Exception exception) when (exception is not OperationCanceledException) + { + // Only cross-stack messaging degrades; failing the resource would take the + // whole local stack down with it. + logger.LogWarning( + exception, + "Could not attach {Container} to {Network}", + containerName, + network); + } + } + + private static async Task<(int ExitCode, string Error)> DockerAsync( + string[] arguments, + CancellationToken cancellationToken) + { + var startInfo = new ProcessStartInfo("docker") + { + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false + }; + + foreach (var argument in arguments) + { + startInfo.ArgumentList.Add(argument); + } + + using var process = Process.Start(startInfo) + ?? throw new InvalidOperationException("Could not start the docker CLI."); + + var error = await process.StandardError.ReadToEndAsync(cancellationToken); + await process.WaitForExitAsync(cancellationToken); + + return (process.ExitCode, error.Trim()); + } +} \ No newline at end of file diff --git a/src/WiSave.Portal.AppHost/WiSave.Portal.AppHost.csproj b/src/WiSave.Portal.AppHost/WiSave.Portal.AppHost.csproj new file mode 100644 index 0000000..05c6f84 --- /dev/null +++ b/src/WiSave.Portal.AppHost/WiSave.Portal.AppHost.csproj @@ -0,0 +1,29 @@ + + + + Exe + net10.0 + enable + enable + false + wisave-portal-apphost + + + + + + + + + + + + + + + + + diff --git a/src/WiSave.Portal.Console/Properties/AssemblyInfo.cs b/src/WiSave.Portal.Console/Properties/AssemblyInfo.cs new file mode 100644 index 0000000..f7c4002 --- /dev/null +++ b/src/WiSave.Portal.Console/Properties/AssemblyInfo.cs @@ -0,0 +1,3 @@ +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("WiSave.Portal.UnitTests")] diff --git a/src/WiSave.Portal.Core.Abstractions/Authorization/IPermissionResolver.cs b/src/WiSave.Portal.Core.Abstractions/Authorization/IPermissionResolver.cs new file mode 100644 index 0000000..90139cf --- /dev/null +++ b/src/WiSave.Portal.Core.Abstractions/Authorization/IPermissionResolver.cs @@ -0,0 +1,17 @@ +namespace WiSave.Portal.Core.Abstractions.Authorization; + +/// +/// Resolves the effective permission set for a user. Declared here and implemented +/// further out; the signature names only BCL types so this project keeps its +/// zero-dependency guarantee. +/// +public interface IPermissionResolver +{ + /// + /// Returns the permissions granted to , or an empty set when + /// no such user exists. Administrative roles resolve to the wildcard "*". + /// + Task> GetPermissionsAsync( + Guid userId, + CancellationToken cancellationToken = default); +} diff --git a/src/WiSave.Portal/Authorization/PortalClaimTypes.cs b/src/WiSave.Portal.Core.Abstractions/Authorization/PortalClaimTypes.cs similarity index 62% rename from src/WiSave.Portal/Authorization/PortalClaimTypes.cs rename to src/WiSave.Portal.Core.Abstractions/Authorization/PortalClaimTypes.cs index 3cb9d2c..68c7d17 100644 --- a/src/WiSave.Portal/Authorization/PortalClaimTypes.cs +++ b/src/WiSave.Portal.Core.Abstractions/Authorization/PortalClaimTypes.cs @@ -1,4 +1,4 @@ -namespace WiSave.Portal.Authorization; +namespace WiSave.Portal.Core.Abstractions.Authorization; public static class PortalClaimTypes { diff --git a/src/WiSave.Portal/Authorization/PortalRoles.cs b/src/WiSave.Portal.Core.Abstractions/Authorization/PortalRoles.cs similarity index 94% rename from src/WiSave.Portal/Authorization/PortalRoles.cs rename to src/WiSave.Portal.Core.Abstractions/Authorization/PortalRoles.cs index b16b454..33fbd81 100644 --- a/src/WiSave.Portal/Authorization/PortalRoles.cs +++ b/src/WiSave.Portal.Core.Abstractions/Authorization/PortalRoles.cs @@ -1,4 +1,4 @@ -namespace WiSave.Portal.Authorization; +namespace WiSave.Portal.Core.Abstractions.Authorization; public static class PortalRoles { diff --git a/src/WiSave.Portal.Core.Abstractions/Gateway/DownstreamServiceNames.cs b/src/WiSave.Portal.Core.Abstractions/Gateway/DownstreamServiceNames.cs new file mode 100644 index 0000000..275ee04 --- /dev/null +++ b/src/WiSave.Portal.Core.Abstractions/Gateway/DownstreamServiceNames.cs @@ -0,0 +1,8 @@ +namespace WiSave.Portal.Core.Abstractions.Gateway; + +public static class DownstreamServiceNames +{ + public const string Incomes = "incomes"; + public const string Stocks = "stocks"; + public const string Expenses = "expenses"; +} \ No newline at end of file diff --git a/src/WiSave.Portal.Core.Abstractions/Gateway/DownstreamServiceOptions.cs b/src/WiSave.Portal.Core.Abstractions/Gateway/DownstreamServiceOptions.cs new file mode 100644 index 0000000..f963dd0 --- /dev/null +++ b/src/WiSave.Portal.Core.Abstractions/Gateway/DownstreamServiceOptions.cs @@ -0,0 +1,6 @@ +namespace WiSave.Portal.Core.Abstractions.Gateway; + +public sealed class DownstreamServiceOptions +{ + public bool Enabled { get; init; } = true; +} \ No newline at end of file diff --git a/src/WiSave.Portal/Gateway/DownstreamServicesOptions.cs b/src/WiSave.Portal.Core.Abstractions/Gateway/DownstreamServicesOptions.cs similarity index 78% rename from src/WiSave.Portal/Gateway/DownstreamServicesOptions.cs rename to src/WiSave.Portal.Core.Abstractions/Gateway/DownstreamServicesOptions.cs index 836977b..c00c822 100644 --- a/src/WiSave.Portal/Gateway/DownstreamServicesOptions.cs +++ b/src/WiSave.Portal.Core.Abstractions/Gateway/DownstreamServicesOptions.cs @@ -1,4 +1,4 @@ -namespace WiSave.Portal.Gateway; +namespace WiSave.Portal.Core.Abstractions.Gateway; public sealed class DownstreamServicesOptions { @@ -34,16 +34,4 @@ public bool IsEnabled(string serviceName) [DownstreamServiceNames.Stocks] = Stocks.Enabled, [DownstreamServiceNames.Expenses] = Expenses.Enabled }; -} - -public sealed class DownstreamServiceOptions -{ - public bool Enabled { get; init; } = true; -} - -public static class DownstreamServiceNames -{ - public const string Incomes = "incomes"; - public const string Stocks = "stocks"; - public const string Expenses = "expenses"; -} +} \ No newline at end of file diff --git a/src/WiSave.Portal.Core.Abstractions/Observability/PortalTelemetry.cs b/src/WiSave.Portal.Core.Abstractions/Observability/PortalTelemetry.cs new file mode 100644 index 0000000..23d4942 --- /dev/null +++ b/src/WiSave.Portal.Core.Abstractions/Observability/PortalTelemetry.cs @@ -0,0 +1,62 @@ +using System.Diagnostics; +using System.Diagnostics.Metrics; + +namespace WiSave.Portal.Core.Abstractions.Observability; + +/// +/// The portal's own and , covering the +/// work between the edges that automatic instrumentation already spans. Both types ship in +/// the BCL, so this costs Core.Abstractions none of its zero-reference guarantee. +/// +public static class PortalTelemetry +{ + public const string SourceName = "WiSave.Portal"; + + public static readonly ActivitySource Source = new(SourceName); + + public static readonly Meter Meter = new(SourceName); + + public static readonly Counter RealtimeNotifications = + Meter.CreateCounter( + "wisave.portal.realtime.notifications", + unit: "{notification}", + description: "Realtime notifications published to a user."); + + /// + /// Events that carried no user and were dropped. A rising count means an upstream + /// service is emitting events the portal cannot route to anyone. + /// + public static readonly Counter RealtimeNotificationsDropped = + Meter.CreateCounter( + "wisave.portal.realtime.notifications_dropped", + unit: "{notification}", + description: "Realtime notifications dropped because the event carried no user."); + + /// + /// Authentication attempts by operation and outcome. Login returns 401 for an unknown + /// email, a lockout, a disallowed sign-in and a wrong password alike, so the request + /// span cannot tell them apart — and a rising lockout rate is what credential stuffing + /// looks like. + /// + public static readonly Counter AuthAttempts = + Meter.CreateCounter( + "wisave.portal.auth.attempts", + unit: "{attempt}", + description: "Authentication attempts by operation and outcome."); + + public static readonly Counter PermissionResolutions = + Meter.CreateCounter( + "wisave.portal.authorization.resolutions", + unit: "{resolution}", + description: "Permission sets resolved for a user."); + + /// + /// Records an authentication attempt. Tags carry the outcome only — never the email or + /// user id, which are personal data and would make cardinality unbounded. + /// + public static void RecordAuthAttempt(string operation, string outcome) => + AuthAttempts.Add( + 1, + new KeyValuePair("wisave.auth.operation", operation), + new KeyValuePair("wisave.auth.outcome", outcome)); +} diff --git a/src/WiSave.Portal.Core.Abstractions/Realtime/IRealtimeNotifier.cs b/src/WiSave.Portal.Core.Abstractions/Realtime/IRealtimeNotifier.cs new file mode 100644 index 0000000..88df8c0 --- /dev/null +++ b/src/WiSave.Portal.Core.Abstractions/Realtime/IRealtimeNotifier.cs @@ -0,0 +1,17 @@ +namespace WiSave.Portal.Core.Abstractions.Realtime; + +/// +/// Delivers a realtime envelope to a single user's connections. +/// +/// +/// The transport (SignalR, its hub and its backplane) is an outer-layer concern. +/// Callers translate an inbound event into a and hand it +/// here; how it reaches the browser is not their problem. +/// +public interface IRealtimeNotifier +{ + Task NotifyUserAsync( + string userId, + RealtimeEnvelope envelope, + CancellationToken cancellationToken = default); +} diff --git a/src/WiSave.Portal.Core.Abstractions/Realtime/RealtimeDomain.cs b/src/WiSave.Portal.Core.Abstractions/Realtime/RealtimeDomain.cs new file mode 100644 index 0000000..9ce9b78 --- /dev/null +++ b/src/WiSave.Portal.Core.Abstractions/Realtime/RealtimeDomain.cs @@ -0,0 +1,13 @@ +namespace WiSave.Portal.Core.Abstractions.Realtime; + +/// +/// The domain discriminator on every . Part of the +/// wire contract: the Angular client filters on it before dispatching to a feature +/// service, so changing a value silently kills that client stream. +/// +public static class RealtimeDomain +{ + public const string Incomes = "incomes"; + public const string Expenses = "expenses"; + public const string Stocks = "stocks"; +} diff --git a/src/WiSave.Portal/Hubs/Realtime/RealtimeEnvelope.cs b/src/WiSave.Portal.Core.Abstractions/Realtime/RealtimeEnvelope.cs similarity index 75% rename from src/WiSave.Portal/Hubs/Realtime/RealtimeEnvelope.cs rename to src/WiSave.Portal.Core.Abstractions/Realtime/RealtimeEnvelope.cs index b084388..d06c958 100644 --- a/src/WiSave.Portal/Hubs/Realtime/RealtimeEnvelope.cs +++ b/src/WiSave.Portal.Core.Abstractions/Realtime/RealtimeEnvelope.cs @@ -1,4 +1,4 @@ -namespace WiSave.Portal.Hubs.Realtime; +namespace WiSave.Portal.Core.Abstractions.Realtime; public record RealtimeEnvelope( Guid EventId, diff --git a/src/WiSave.Portal/Hubs/Realtime/RealtimeEventType.cs b/src/WiSave.Portal.Core.Abstractions/Realtime/RealtimeEventType.cs similarity index 95% rename from src/WiSave.Portal/Hubs/Realtime/RealtimeEventType.cs rename to src/WiSave.Portal.Core.Abstractions/Realtime/RealtimeEventType.cs index 368d992..6c47943 100644 --- a/src/WiSave.Portal/Hubs/Realtime/RealtimeEventType.cs +++ b/src/WiSave.Portal.Core.Abstractions/Realtime/RealtimeEventType.cs @@ -1,4 +1,4 @@ -namespace WiSave.Portal.Hubs.Realtime; +namespace WiSave.Portal.Core.Abstractions.Realtime; public static class RealtimeEventType { diff --git a/src/WiSave.Portal.Core.Abstractions/WiSave.Portal.Core.Abstractions.csproj b/src/WiSave.Portal.Core.Abstractions/WiSave.Portal.Core.Abstractions.csproj new file mode 100644 index 0000000..8e91a61 --- /dev/null +++ b/src/WiSave.Portal.Core.Abstractions/WiSave.Portal.Core.Abstractions.csproj @@ -0,0 +1,13 @@ + + + net10.0 + enable + enable + + + + diff --git a/src/WiSave.Portal.Core.Application/Authorization/AccessManagementPolicy.cs b/src/WiSave.Portal.Core.Application/Authorization/AccessManagementPolicy.cs new file mode 100644 index 0000000..ce3a20c --- /dev/null +++ b/src/WiSave.Portal.Core.Application/Authorization/AccessManagementPolicy.cs @@ -0,0 +1,23 @@ +using WiSave.Portal.Core.Abstractions.Authorization; + +namespace WiSave.Portal.Core.Application.Authorization; + +public static class AccessManagementPolicy +{ + public static bool IsPrivilegedRole(string? role) => + string.Equals(role, PortalRoles.Admin, StringComparison.OrdinalIgnoreCase) + || string.Equals(role, PortalRoles.SuperAdmin, StringComparison.OrdinalIgnoreCase); + + public static bool CanReadAccessManagement(IEnumerable roles) => + roles.Any(IsPrivilegedRole); + + public static bool CanManagePrivilegedRoles(IEnumerable roles) => + roles.Contains(PortalRoles.SuperAdmin, StringComparer.OrdinalIgnoreCase); + + public static bool ContainsPrivilegedRole(IEnumerable roles) => + roles.Any(IsPrivilegedRole); + + public static bool IsReservedRoleName(string role) => + role.StartsWith("plan:", StringComparison.OrdinalIgnoreCase) + || IsPrivilegedRole(role); +} diff --git a/src/WiSave.Portal.Core.Application/EventHandlers/RealtimeNotificationsEventHandler.cs b/src/WiSave.Portal.Core.Application/EventHandlers/RealtimeNotificationsEventHandler.cs new file mode 100644 index 0000000..3d05bdf --- /dev/null +++ b/src/WiSave.Portal.Core.Application/EventHandlers/RealtimeNotificationsEventHandler.cs @@ -0,0 +1,82 @@ +using WiSave.Expenses.Contracts.Events; +using WiSave.Incomes.Contracts.Events; +using WiSave.Portal.Core.Abstractions.Realtime; +using WiSave.Portal.Core.Application.Realtime; +using WiSave.Stock.Contracts.Events.Portfolios; +using WiSave.Stock.Contracts.Events.Positions; + +namespace WiSave.Portal.Core.Application.EventHandlers; + +/// +/// Translates every downstream integration event the portal relays into a realtime +/// notification for the user it belongs to. +/// +/// +/// One class rather than one per source service: every handler did the same thing, and +/// splitting them by upstream domain only duplicated the translation. The transport is +/// deliberately absent — this pushes a at +/// and does not know SignalR exists. +/// +public class RealtimeNotificationsEventHandler(IRealtimeNotifier notifier) + : RealtimeNotificationHandler(notifier) +{ + // ── stocks ──────────────────────────────────────────────────────────────────── + + public Task Handle(PositionOpened message, CancellationToken cancellationToken = default) => + PushAsync(RealtimeDomain.Stocks, RealtimeEventType.PositionOpened, message.UserId, message.PositionId, message, cancellationToken); + + public Task Handle(PositionBuyOrderPlaced message, CancellationToken cancellationToken = default) => + PushAsync(RealtimeDomain.Stocks, RealtimeEventType.PositionBuyOrderPlaced, message.UserId, message.PositionId, message, cancellationToken); + + public Task Handle(PositionSellOrderPlaced message, CancellationToken cancellationToken = default) => + PushAsync(RealtimeDomain.Stocks, RealtimeEventType.PositionSellOrderPlaced, message.UserId, message.PositionId, message, cancellationToken); + + public Task Handle(PositionClosed message, CancellationToken cancellationToken = default) => + PushAsync(RealtimeDomain.Stocks, RealtimeEventType.PositionClosed, message.UserId, message.PositionId, message, cancellationToken); + + public Task Handle(PositionReopened message, CancellationToken cancellationToken = default) => + PushAsync(RealtimeDomain.Stocks, RealtimeEventType.PositionReopened, message.UserId, message.PositionId, message, cancellationToken); + + public Task Handle(PortfolioCreated message, CancellationToken cancellationToken = default) => + PushAsync(RealtimeDomain.Stocks, RealtimeEventType.PortfolioCreated, message.UserId, message.PortfolioId, message, cancellationToken); + + // ── incomes ─────────────────────────────────────────────────────────────────── + + public Task Handle(IncomeCreated message, CancellationToken cancellationToken = default) => + PushAsync(RealtimeDomain.Incomes, RealtimeEventType.IncomeCreated, message.UserId, message.Id.Value, message, cancellationToken); + + public Task Handle(IncomeUpdated message, CancellationToken cancellationToken = default) => + PushAsync(RealtimeDomain.Incomes, RealtimeEventType.IncomeUpdated, message.UserId, message.Id.Value, message, cancellationToken); + + public Task Handle(IncomeDeleted message, CancellationToken cancellationToken = default) => + PushAsync(RealtimeDomain.Incomes, RealtimeEventType.IncomeDeleted, message.UserId, message.Id.Value, message, cancellationToken); + + public Task Handle(CategoryCreated message, CancellationToken cancellationToken = default) => + PushAsync(RealtimeDomain.Incomes, RealtimeEventType.CategoryCreated, message.UserId, message.Id, message, cancellationToken); + + public Task Handle(CategoryUpdated message, CancellationToken cancellationToken = default) => + PushAsync(RealtimeDomain.Incomes, RealtimeEventType.CategoryUpdated, message.UserId, message.Id, message, cancellationToken); + + public Task Handle(CategoryDeleted message, CancellationToken cancellationToken = default) => + PushAsync(RealtimeDomain.Incomes, RealtimeEventType.CategoryDeleted, message.UserId, message.Id, message, cancellationToken); + + public Task Handle(SubcategoryCreated message, CancellationToken cancellationToken = default) => + PushAsync(RealtimeDomain.Incomes, RealtimeEventType.SubcategoryCreated, message.UserId, message.Id, message, cancellationToken); + + public Task Handle(SubcategoryUpdated message, CancellationToken cancellationToken = default) => + PushAsync(RealtimeDomain.Incomes, RealtimeEventType.SubcategoryUpdated, message.UserId, message.Id, message, cancellationToken); + + public Task Handle(SubcategoryDeleted message, CancellationToken cancellationToken = default) => + PushAsync(RealtimeDomain.Incomes, RealtimeEventType.SubcategoryDeleted, message.UserId, message.Id, message, cancellationToken); + + // ── expenses ────────────────────────────────────────────────────────────────── + + public Task Handle(ExpenseCreated message, CancellationToken cancellationToken = default) => + PushAsync( + RealtimeDomain.Expenses, + RealtimeEventType.ExpenseCreated, + message.UserId.ToString(), + message.Id.Value.ToString(), + message, + cancellationToken); +} diff --git a/src/WiSave.Portal.Core.Application/Realtime/RealtimeNotificationHandler.cs b/src/WiSave.Portal.Core.Application/Realtime/RealtimeNotificationHandler.cs new file mode 100644 index 0000000..b3dd4cf --- /dev/null +++ b/src/WiSave.Portal.Core.Application/Realtime/RealtimeNotificationHandler.cs @@ -0,0 +1,82 @@ +using System.Diagnostics; +using WiSave.Portal.Core.Abstractions.Observability; +using WiSave.Portal.Core.Abstractions.Realtime; + +namespace WiSave.Portal.Core.Application.Realtime; + +/// +/// Turns a downstream integration event into a realtime notification for the user it +/// belongs to. +/// +public abstract class RealtimeNotificationHandler(IRealtimeNotifier notifier) +{ + /// + /// Publishes to . Events carrying + /// no user are dropped — broadcasting instead would leak one user's data to everyone. + /// + protected async Task PushAsync( + string domain, + string eventType, + string? userId, + string? entityId, + object payload, + CancellationToken cancellationToken) + { + if (string.IsNullOrWhiteSpace(userId)) + { + PortalTelemetry.RealtimeNotificationsDropped.Add( + 1, + new KeyValuePair("wisave.domain", domain), + new KeyValuePair("wisave.event_type", eventType)); + return; + } + + // Nests under the Wolverine consume span, so a trace reads + // downstream event -> handler -> notification. + using var activity = PortalTelemetry.Source.StartActivity( + $"realtime publish {eventType}", + ActivityKind.Producer); + activity?.SetTag("wisave.domain", domain); + activity?.SetTag("wisave.event_type", eventType); + activity?.SetTag("wisave.entity_id", entityId); + + var envelope = new RealtimeEnvelope( + EventId: Guid.CreateVersion7(), + Domain: domain, + EventType: eventType, + OccurredAt: DateTime.UtcNow, + EntityId: entityId, + Payload: payload); + + try + { + await notifier.NotifyUserAsync(userId, envelope, cancellationToken); + } + catch (Exception exception) + { + activity?.AddException(exception); + activity?.SetStatus(ActivityStatusCode.Error, exception.Message); + throw; + } + + PortalTelemetry.RealtimeNotifications.Add( + 1, + new KeyValuePair("wisave.domain", domain), + new KeyValuePair("wisave.event_type", eventType)); + } + + protected Task PushAsync( + string domain, + string eventType, + Guid userId, + Guid entityId, + object payload, + CancellationToken cancellationToken) => + PushAsync( + domain, + eventType, + userId == Guid.Empty ? null : userId.ToString(), + entityId.ToString(), + payload, + cancellationToken); +} diff --git a/src/WiSave.Portal.Core.Application/WiSave.Portal.Core.Application.csproj b/src/WiSave.Portal.Core.Application/WiSave.Portal.Core.Application.csproj new file mode 100644 index 0000000..486adad --- /dev/null +++ b/src/WiSave.Portal.Core.Application/WiSave.Portal.Core.Application.csproj @@ -0,0 +1,23 @@ + + + net10.0 + enable + enable + + + + + + + + + + + + + + diff --git a/src/WiSave.Portal/Infrastructure/Database/DesignTimeDbContextFactory.cs b/src/WiSave.Portal.Core.Infrastructure/Database/DesignTimeDbContextFactory.cs similarity index 89% rename from src/WiSave.Portal/Infrastructure/Database/DesignTimeDbContextFactory.cs rename to src/WiSave.Portal.Core.Infrastructure/Database/DesignTimeDbContextFactory.cs index 2f6385a..4f4aebf 100644 --- a/src/WiSave.Portal/Infrastructure/Database/DesignTimeDbContextFactory.cs +++ b/src/WiSave.Portal.Core.Infrastructure/Database/DesignTimeDbContextFactory.cs @@ -1,7 +1,7 @@ using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Design; -namespace WiSave.Portal.Infrastructure.Database; +namespace WiSave.Portal.Core.Infrastructure.Database; public class DesignTimeDbContextFactory : IDesignTimeDbContextFactory { diff --git a/src/WiSave.Portal/Infrastructure/Database/Migrations/20260426212559_Initial.Designer.cs b/src/WiSave.Portal.Core.Infrastructure/Database/Migrations/20260801231030_Initial.Designer.cs similarity index 78% rename from src/WiSave.Portal/Infrastructure/Database/Migrations/20260426212559_Initial.Designer.cs rename to src/WiSave.Portal.Core.Infrastructure/Database/Migrations/20260801231030_Initial.Designer.cs index ae25279..2c9b712 100644 --- a/src/WiSave.Portal/Infrastructure/Database/Migrations/20260426212559_Initial.Designer.cs +++ b/src/WiSave.Portal.Core.Infrastructure/Database/Migrations/20260801231030_Initial.Designer.cs @@ -5,14 +5,14 @@ using Microsoft.EntityFrameworkCore.Migrations; using Microsoft.EntityFrameworkCore.Storage.ValueConversion; using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; -using WiSave.Portal.Infrastructure.Database; +using WiSave.Portal.Core.Infrastructure.Database; #nullable disable -namespace WiSave.Portal.Infrastructure.Database.Migrations +namespace WiSave.Portal.Core.Infrastructure.Database.Migrations { [DbContext(typeof(PortalDbContext))] - [Migration("20260426212559_Initial")] + [Migration("20260801231030_Initial")] partial class Initial { /// @@ -21,38 +21,12 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) #pragma warning disable 612, 618 modelBuilder .HasDefaultSchema("public") - .HasAnnotation("ProductVersion", "10.0.7") + .HasAnnotation("ProductVersion", "10.0.10") .HasAnnotation("Relational:MaxIdentifierLength", 63); NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); - modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b => - { - b.Property("Id") - .HasColumnType("text"); - - b.Property("ConcurrencyStamp") - .IsConcurrencyToken() - .HasColumnType("text"); - - b.Property("Name") - .HasMaxLength(256) - .HasColumnType("character varying(256)"); - - b.Property("NormalizedName") - .HasMaxLength(256) - .HasColumnType("character varying(256)"); - - b.HasKey("Id"); - - b.HasIndex("NormalizedName") - .IsUnique() - .HasDatabaseName("RoleNameIndex"); - - b.ToTable("AspNetRoles", "public"); - }); - - modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => { b.Property("Id") .ValueGeneratedOnAdd() @@ -66,9 +40,8 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) b.Property("ClaimValue") .HasColumnType("text"); - b.Property("RoleId") - .IsRequired() - .HasColumnType("text"); + b.Property("RoleId") + .HasColumnType("uuid"); b.HasKey("Id"); @@ -77,7 +50,7 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) b.ToTable("AspNetRoleClaims", "public"); }); - modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => { b.Property("Id") .ValueGeneratedOnAdd() @@ -91,9 +64,8 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) b.Property("ClaimValue") .HasColumnType("text"); - b.Property("UserId") - .IsRequired() - .HasColumnType("text"); + b.Property("UserId") + .HasColumnType("uuid"); b.HasKey("Id"); @@ -102,7 +74,7 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) b.ToTable("AspNetUserClaims", "public"); }); - modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => { b.Property("LoginProvider") .HasColumnType("text"); @@ -113,9 +85,8 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) b.Property("ProviderDisplayName") .HasColumnType("text"); - b.Property("UserId") - .IsRequired() - .HasColumnType("text"); + b.Property("UserId") + .HasColumnType("uuid"); b.HasKey("LoginProvider", "ProviderKey"); @@ -124,13 +95,13 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) b.ToTable("AspNetUserLogins", "public"); }); - modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => { - b.Property("UserId") - .HasColumnType("text"); + b.Property("UserId") + .HasColumnType("uuid"); - b.Property("RoleId") - .HasColumnType("text"); + b.Property("RoleId") + .HasColumnType("uuid"); b.HasKey("UserId", "RoleId"); @@ -139,10 +110,10 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) b.ToTable("AspNetUserRoles", "public"); }); - modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => { - b.Property("UserId") - .HasColumnType("text"); + b.Property("UserId") + .HasColumnType("uuid"); b.Property("LoginProvider") .HasColumnType("text"); @@ -158,11 +129,39 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) b.ToTable("AspNetUserTokens", "public"); }); - modelBuilder.Entity("WiSave.Portal.Auth.Models.ApplicationUser", b => + modelBuilder.Entity("WiSave.Portal.Core.Infrastructure.Identity.ApplicationRole", b => { - b.Property("Id") + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() .HasColumnType("text"); + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex"); + + b.ToTable("AspNetRoles", "public"); + }); + + modelBuilder.Entity("WiSave.Portal.Core.Infrastructure.Identity.ApplicationUser", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + b.Property("AccessFailedCount") .HasColumnType("integer"); @@ -226,51 +225,51 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) b.ToTable("AspNetUsers", "public"); }); - modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => { - b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + b.HasOne("WiSave.Portal.Core.Infrastructure.Identity.ApplicationRole", null) .WithMany() .HasForeignKey("RoleId") .OnDelete(DeleteBehavior.Cascade) .IsRequired(); }); - modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => { - b.HasOne("WiSave.Portal.Auth.Models.ApplicationUser", null) + b.HasOne("WiSave.Portal.Core.Infrastructure.Identity.ApplicationUser", null) .WithMany() .HasForeignKey("UserId") .OnDelete(DeleteBehavior.Cascade) .IsRequired(); }); - modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => { - b.HasOne("WiSave.Portal.Auth.Models.ApplicationUser", null) + b.HasOne("WiSave.Portal.Core.Infrastructure.Identity.ApplicationUser", null) .WithMany() .HasForeignKey("UserId") .OnDelete(DeleteBehavior.Cascade) .IsRequired(); }); - modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => { - b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + b.HasOne("WiSave.Portal.Core.Infrastructure.Identity.ApplicationRole", null) .WithMany() .HasForeignKey("RoleId") .OnDelete(DeleteBehavior.Cascade) .IsRequired(); - b.HasOne("WiSave.Portal.Auth.Models.ApplicationUser", null) + b.HasOne("WiSave.Portal.Core.Infrastructure.Identity.ApplicationUser", null) .WithMany() .HasForeignKey("UserId") .OnDelete(DeleteBehavior.Cascade) .IsRequired(); }); - modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => { - b.HasOne("WiSave.Portal.Auth.Models.ApplicationUser", null) + b.HasOne("WiSave.Portal.Core.Infrastructure.Identity.ApplicationUser", null) .WithMany() .HasForeignKey("UserId") .OnDelete(DeleteBehavior.Cascade) diff --git a/src/WiSave.Portal/Infrastructure/Database/Migrations/20260426212559_Initial.cs b/src/WiSave.Portal.Core.Infrastructure/Database/Migrations/20260801231030_Initial.cs similarity index 93% rename from src/WiSave.Portal/Infrastructure/Database/Migrations/20260426212559_Initial.cs rename to src/WiSave.Portal.Core.Infrastructure/Database/Migrations/20260801231030_Initial.cs index 9e3c1c6..1484dda 100644 --- a/src/WiSave.Portal/Infrastructure/Database/Migrations/20260426212559_Initial.cs +++ b/src/WiSave.Portal.Core.Infrastructure/Database/Migrations/20260801231030_Initial.cs @@ -4,7 +4,7 @@ #nullable disable -namespace WiSave.Portal.Infrastructure.Database.Migrations +namespace WiSave.Portal.Core.Infrastructure.Database.Migrations { /// public partial class Initial : Migration @@ -20,7 +20,7 @@ protected override void Up(MigrationBuilder migrationBuilder) schema: "public", columns: table => new { - Id = table.Column(type: "text", nullable: false), + Id = table.Column(type: "uuid", nullable: false), Name = table.Column(type: "character varying(256)", maxLength: 256, nullable: true), NormalizedName = table.Column(type: "character varying(256)", maxLength: 256, nullable: true), ConcurrencyStamp = table.Column(type: "text", nullable: true) @@ -35,7 +35,7 @@ protected override void Up(MigrationBuilder migrationBuilder) schema: "public", columns: table => new { - Id = table.Column(type: "text", nullable: false), + Id = table.Column(type: "uuid", nullable: false), Name = table.Column(type: "text", nullable: false), UserName = table.Column(type: "character varying(256)", maxLength: 256, nullable: true), NormalizedUserName = table.Column(type: "character varying(256)", maxLength: 256, nullable: true), @@ -64,7 +64,7 @@ protected override void Up(MigrationBuilder migrationBuilder) { Id = table.Column(type: "integer", nullable: false) .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), - RoleId = table.Column(type: "text", nullable: false), + RoleId = table.Column(type: "uuid", nullable: false), ClaimType = table.Column(type: "text", nullable: true), ClaimValue = table.Column(type: "text", nullable: true) }, @@ -87,7 +87,7 @@ protected override void Up(MigrationBuilder migrationBuilder) { Id = table.Column(type: "integer", nullable: false) .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), - UserId = table.Column(type: "text", nullable: false), + UserId = table.Column(type: "uuid", nullable: false), ClaimType = table.Column(type: "text", nullable: true), ClaimValue = table.Column(type: "text", nullable: true) }, @@ -111,7 +111,7 @@ protected override void Up(MigrationBuilder migrationBuilder) LoginProvider = table.Column(type: "text", nullable: false), ProviderKey = table.Column(type: "text", nullable: false), ProviderDisplayName = table.Column(type: "text", nullable: true), - UserId = table.Column(type: "text", nullable: false) + UserId = table.Column(type: "uuid", nullable: false) }, constraints: table => { @@ -130,8 +130,8 @@ protected override void Up(MigrationBuilder migrationBuilder) schema: "public", columns: table => new { - UserId = table.Column(type: "text", nullable: false), - RoleId = table.Column(type: "text", nullable: false) + UserId = table.Column(type: "uuid", nullable: false), + RoleId = table.Column(type: "uuid", nullable: false) }, constraints: table => { @@ -157,7 +157,7 @@ protected override void Up(MigrationBuilder migrationBuilder) schema: "public", columns: table => new { - UserId = table.Column(type: "text", nullable: false), + UserId = table.Column(type: "uuid", nullable: false), LoginProvider = table.Column(type: "text", nullable: false), Name = table.Column(type: "text", nullable: false), Value = table.Column(type: "text", nullable: true) diff --git a/src/WiSave.Portal/Infrastructure/Database/Migrations/PortalDbContextModelSnapshot.cs b/src/WiSave.Portal.Core.Infrastructure/Database/Migrations/PortalDbContextModelSnapshot.cs similarity index 79% rename from src/WiSave.Portal/Infrastructure/Database/Migrations/PortalDbContextModelSnapshot.cs rename to src/WiSave.Portal.Core.Infrastructure/Database/Migrations/PortalDbContextModelSnapshot.cs index 4052030..4a60ac8 100644 --- a/src/WiSave.Portal/Infrastructure/Database/Migrations/PortalDbContextModelSnapshot.cs +++ b/src/WiSave.Portal.Core.Infrastructure/Database/Migrations/PortalDbContextModelSnapshot.cs @@ -4,11 +4,11 @@ using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.EntityFrameworkCore.Storage.ValueConversion; using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; -using WiSave.Portal.Infrastructure.Database; +using WiSave.Portal.Core.Infrastructure.Database; #nullable disable -namespace WiSave.Portal.Infrastructure.Database.Migrations +namespace WiSave.Portal.Core.Infrastructure.Database.Migrations { [DbContext(typeof(PortalDbContext))] partial class PortalDbContextModelSnapshot : ModelSnapshot @@ -18,38 +18,12 @@ protected override void BuildModel(ModelBuilder modelBuilder) #pragma warning disable 612, 618 modelBuilder .HasDefaultSchema("public") - .HasAnnotation("ProductVersion", "10.0.7") + .HasAnnotation("ProductVersion", "10.0.10") .HasAnnotation("Relational:MaxIdentifierLength", 63); NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); - modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b => - { - b.Property("Id") - .HasColumnType("text"); - - b.Property("ConcurrencyStamp") - .IsConcurrencyToken() - .HasColumnType("text"); - - b.Property("Name") - .HasMaxLength(256) - .HasColumnType("character varying(256)"); - - b.Property("NormalizedName") - .HasMaxLength(256) - .HasColumnType("character varying(256)"); - - b.HasKey("Id"); - - b.HasIndex("NormalizedName") - .IsUnique() - .HasDatabaseName("RoleNameIndex"); - - b.ToTable("AspNetRoles", "public"); - }); - - modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => { b.Property("Id") .ValueGeneratedOnAdd() @@ -63,9 +37,8 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("ClaimValue") .HasColumnType("text"); - b.Property("RoleId") - .IsRequired() - .HasColumnType("text"); + b.Property("RoleId") + .HasColumnType("uuid"); b.HasKey("Id"); @@ -74,7 +47,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("AspNetRoleClaims", "public"); }); - modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => { b.Property("Id") .ValueGeneratedOnAdd() @@ -88,9 +61,8 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("ClaimValue") .HasColumnType("text"); - b.Property("UserId") - .IsRequired() - .HasColumnType("text"); + b.Property("UserId") + .HasColumnType("uuid"); b.HasKey("Id"); @@ -99,7 +71,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("AspNetUserClaims", "public"); }); - modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => { b.Property("LoginProvider") .HasColumnType("text"); @@ -110,9 +82,8 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("ProviderDisplayName") .HasColumnType("text"); - b.Property("UserId") - .IsRequired() - .HasColumnType("text"); + b.Property("UserId") + .HasColumnType("uuid"); b.HasKey("LoginProvider", "ProviderKey"); @@ -121,13 +92,13 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("AspNetUserLogins", "public"); }); - modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => { - b.Property("UserId") - .HasColumnType("text"); + b.Property("UserId") + .HasColumnType("uuid"); - b.Property("RoleId") - .HasColumnType("text"); + b.Property("RoleId") + .HasColumnType("uuid"); b.HasKey("UserId", "RoleId"); @@ -136,10 +107,10 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("AspNetUserRoles", "public"); }); - modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => { - b.Property("UserId") - .HasColumnType("text"); + b.Property("UserId") + .HasColumnType("uuid"); b.Property("LoginProvider") .HasColumnType("text"); @@ -155,11 +126,39 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("AspNetUserTokens", "public"); }); - modelBuilder.Entity("WiSave.Portal.Auth.Models.ApplicationUser", b => + modelBuilder.Entity("WiSave.Portal.Core.Infrastructure.Identity.ApplicationRole", b => { - b.Property("Id") + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() .HasColumnType("text"); + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex"); + + b.ToTable("AspNetRoles", "public"); + }); + + modelBuilder.Entity("WiSave.Portal.Core.Infrastructure.Identity.ApplicationUser", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + b.Property("AccessFailedCount") .HasColumnType("integer"); @@ -223,51 +222,51 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("AspNetUsers", "public"); }); - modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => { - b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + b.HasOne("WiSave.Portal.Core.Infrastructure.Identity.ApplicationRole", null) .WithMany() .HasForeignKey("RoleId") .OnDelete(DeleteBehavior.Cascade) .IsRequired(); }); - modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => { - b.HasOne("WiSave.Portal.Auth.Models.ApplicationUser", null) + b.HasOne("WiSave.Portal.Core.Infrastructure.Identity.ApplicationUser", null) .WithMany() .HasForeignKey("UserId") .OnDelete(DeleteBehavior.Cascade) .IsRequired(); }); - modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => { - b.HasOne("WiSave.Portal.Auth.Models.ApplicationUser", null) + b.HasOne("WiSave.Portal.Core.Infrastructure.Identity.ApplicationUser", null) .WithMany() .HasForeignKey("UserId") .OnDelete(DeleteBehavior.Cascade) .IsRequired(); }); - modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => { - b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + b.HasOne("WiSave.Portal.Core.Infrastructure.Identity.ApplicationRole", null) .WithMany() .HasForeignKey("RoleId") .OnDelete(DeleteBehavior.Cascade) .IsRequired(); - b.HasOne("WiSave.Portal.Auth.Models.ApplicationUser", null) + b.HasOne("WiSave.Portal.Core.Infrastructure.Identity.ApplicationUser", null) .WithMany() .HasForeignKey("UserId") .OnDelete(DeleteBehavior.Cascade) .IsRequired(); }); - modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => { - b.HasOne("WiSave.Portal.Auth.Models.ApplicationUser", null) + b.HasOne("WiSave.Portal.Core.Infrastructure.Identity.ApplicationUser", null) .WithMany() .HasForeignKey("UserId") .OnDelete(DeleteBehavior.Cascade) diff --git a/src/WiSave.Portal/Infrastructure/Database/PortalDbContext.cs b/src/WiSave.Portal.Core.Infrastructure/Database/PortalDbContext.cs similarity index 63% rename from src/WiSave.Portal/Infrastructure/Database/PortalDbContext.cs rename to src/WiSave.Portal.Core.Infrastructure/Database/PortalDbContext.cs index 98db8bf..ab4e024 100644 --- a/src/WiSave.Portal/Infrastructure/Database/PortalDbContext.cs +++ b/src/WiSave.Portal.Core.Infrastructure/Database/PortalDbContext.cs @@ -1,10 +1,11 @@ using Microsoft.AspNetCore.Identity.EntityFrameworkCore; using Microsoft.EntityFrameworkCore; -using WiSave.Portal.Auth.Models; +using WiSave.Portal.Core.Infrastructure.Identity; -namespace WiSave.Portal.Infrastructure.Database; +namespace WiSave.Portal.Core.Infrastructure.Database; -public class PortalDbContext(DbContextOptions options) : IdentityDbContext(options) +public class PortalDbContext(DbContextOptions options) + : IdentityDbContext(options) { protected override void OnModelCreating(ModelBuilder builder) { diff --git a/src/WiSave.Portal.Core.Infrastructure/HealthChecks/PortalHealthChecks.cs b/src/WiSave.Portal.Core.Infrastructure/HealthChecks/PortalHealthChecks.cs new file mode 100644 index 0000000..47bee4c --- /dev/null +++ b/src/WiSave.Portal.Core.Infrastructure/HealthChecks/PortalHealthChecks.cs @@ -0,0 +1,50 @@ +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Diagnostics.HealthChecks; + +namespace WiSave.Portal.Core.Infrastructure.HealthChecks; + +public static class PortalHealthChecks +{ + /// Tag marking a check as a readiness dependency rather than a liveness one. + public const string ReadyTag = "ready"; + + /// + /// Registers a readiness check per configured dependency. + /// + /// + /// Dependencies that are not configured are not registered, rather than registered + /// and reported unhealthy: the in-memory test host runs without Postgres or Redis and + /// is legitimately ready. Registration performs no I/O — each check connects lazily on + /// its first probe — so an unreachable dependency delays readiness instead of + /// preventing startup. + /// + public static IServiceCollection AddPortalHealthChecks( + this IServiceCollection services, + IConfiguration configuration) + { + var builder = services.AddHealthChecks(); + + var portalConnectionString = configuration.GetConnectionString("Portal"); + if (!string.IsNullOrWhiteSpace(portalConnectionString)) + { + builder.Add(new HealthCheckRegistration( + "postgres", + _ => new PostgresHealthCheck(portalConnectionString), + HealthStatus.Unhealthy, + [ReadyTag])); + } + + var redisConnectionString = configuration["Redis:ConnectionString"]; + if (!string.IsNullOrWhiteSpace(redisConnectionString)) + { + builder.Add(new HealthCheckRegistration( + "redis", + _ => new RedisHealthCheck(redisConnectionString), + HealthStatus.Unhealthy, + [ReadyTag])); + } + + return services; + } +} diff --git a/src/WiSave.Portal.Core.Infrastructure/HealthChecks/PostgresHealthCheck.cs b/src/WiSave.Portal.Core.Infrastructure/HealthChecks/PostgresHealthCheck.cs new file mode 100644 index 0000000..95ddcd8 --- /dev/null +++ b/src/WiSave.Portal.Core.Infrastructure/HealthChecks/PostgresHealthCheck.cs @@ -0,0 +1,39 @@ +using Microsoft.Extensions.Diagnostics.HealthChecks; +using Npgsql; + +namespace WiSave.Portal.Core.Infrastructure.HealthChecks; + +/// +/// Reports whether the portal's PostgreSQL database accepts a connection and answers a +/// trivial query. +/// +/// +/// The connection string is captured rather than resolved from DI so that registration +/// never opens a socket — see . +/// +public sealed class PostgresHealthCheck(string connectionString) : IHealthCheck +{ + public async Task CheckHealthAsync( + HealthCheckContext context, + CancellationToken cancellationToken = default) + { + try + { + await using var connection = new NpgsqlConnection(connectionString); + await connection.OpenAsync(cancellationToken); + + await using var command = connection.CreateCommand(); + command.CommandText = "SELECT 1;"; + await command.ExecuteScalarAsync(cancellationToken); + + return HealthCheckResult.Healthy(); + } + catch (Exception exception) + { + return new HealthCheckResult( + context.Registration.FailureStatus, + "The portal database is not reachable.", + exception); + } + } +} diff --git a/src/WiSave.Portal.Core.Infrastructure/HealthChecks/RedisHealthCheck.cs b/src/WiSave.Portal.Core.Infrastructure/HealthChecks/RedisHealthCheck.cs new file mode 100644 index 0000000..95ff50e --- /dev/null +++ b/src/WiSave.Portal.Core.Infrastructure/HealthChecks/RedisHealthCheck.cs @@ -0,0 +1,66 @@ +using Microsoft.Extensions.Diagnostics.HealthChecks; +using StackExchange.Redis; + +namespace WiSave.Portal.Core.Infrastructure.HealthChecks; + +/// +/// Reports whether Redis answers a PING. +/// +/// +/// Redis backs session tickets, data-protection keys and the SignalR backplane, so an +/// unreachable Redis means the portal cannot authenticate anyone — a readiness failure, +/// not a liveness one. The multiplexer is created on first probe and reused; creating it +/// during registration would make service configuration perform I/O. +/// +public sealed class RedisHealthCheck(string connectionString) : IHealthCheck, IDisposable +{ + private readonly SemaphoreSlim _gate = new(1, 1); + private IConnectionMultiplexer? _multiplexer; + + public async Task CheckHealthAsync( + HealthCheckContext context, + CancellationToken cancellationToken = default) + { + try + { + var multiplexer = await GetMultiplexerAsync(cancellationToken); + await multiplexer.GetDatabase().PingAsync(); + + return HealthCheckResult.Healthy(); + } + catch (Exception exception) + { + return new HealthCheckResult( + context.Registration.FailureStatus, + "Redis is not reachable.", + exception); + } + } + + private async Task GetMultiplexerAsync(CancellationToken cancellationToken) + { + if (_multiplexer is { IsConnected: true }) + return _multiplexer; + + await _gate.WaitAsync(cancellationToken); + try + { + if (_multiplexer is { IsConnected: true }) + return _multiplexer; + + _multiplexer?.Dispose(); + _multiplexer = await ConnectionMultiplexer.ConnectAsync(connectionString); + return _multiplexer; + } + finally + { + _gate.Release(); + } + } + + public void Dispose() + { + _multiplexer?.Dispose(); + _gate.Dispose(); + } +} diff --git a/src/WiSave.Portal.Core.Infrastructure/Identity/ApplicationRole.cs b/src/WiSave.Portal.Core.Infrastructure/Identity/ApplicationRole.cs new file mode 100644 index 0000000..bd63557 --- /dev/null +++ b/src/WiSave.Portal.Core.Infrastructure/Identity/ApplicationRole.cs @@ -0,0 +1,16 @@ +using Microsoft.AspNetCore.Identity; + +namespace WiSave.Portal.Core.Infrastructure.Identity; + +public sealed class ApplicationRole : IdentityRole +{ + public ApplicationRole() + { + Id = Guid.CreateVersion7(); + } + + public ApplicationRole(string roleName) : base(roleName) + { + Id = Guid.CreateVersion7(); + } +} diff --git a/src/WiSave.Portal.Core.Infrastructure/Identity/ApplicationUser.cs b/src/WiSave.Portal.Core.Infrastructure/Identity/ApplicationUser.cs new file mode 100644 index 0000000..c3b9dd0 --- /dev/null +++ b/src/WiSave.Portal.Core.Infrastructure/Identity/ApplicationUser.cs @@ -0,0 +1,13 @@ +using Microsoft.AspNetCore.Identity; + +namespace WiSave.Portal.Core.Infrastructure.Identity; + +public class ApplicationUser : IdentityUser +{ + public ApplicationUser() + { + Id = Guid.CreateVersion7(); + } + + public required string Name { get; set; } +} diff --git a/src/WiSave.Portal.Core.Infrastructure/Identity/IdentityServiceCollectionExtensions.cs b/src/WiSave.Portal.Core.Infrastructure/Identity/IdentityServiceCollectionExtensions.cs new file mode 100644 index 0000000..c2b1fe1 --- /dev/null +++ b/src/WiSave.Portal.Core.Infrastructure/Identity/IdentityServiceCollectionExtensions.cs @@ -0,0 +1,66 @@ +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Identity; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using WiSave.Portal.Core.Infrastructure.Database; + +namespace WiSave.Portal.Core.Infrastructure.Identity; + +public static class IdentityServiceCollectionExtensions +{ + public static IServiceCollection AddPortalIdentity( + this IServiceCollection services, + IConfiguration configuration, + IHostEnvironment environment) + { + var useInMemory = configuration.GetValue("UseInMemoryDatabase"); + if (useInMemory) + { + var dbName = configuration["InMemoryDatabaseName"] ?? "WiSave_Test"; + services.AddDbContext(options => options.UseInMemoryDatabase(dbName)); + } + else + { + services.AddDbContext(options => + options.UseNpgsql(configuration.GetConnectionString("Portal"))); + } + + services.AddIdentity(options => + { + options.User.RequireUniqueEmail = true; + options.Password.RequiredLength = 8; + options.SignIn.RequireConfirmedAccount = false; + options.Lockout.MaxFailedAccessAttempts = 5; + options.Lockout.DefaultLockoutTimeSpan = TimeSpan.FromMinutes(15); + }) + .AddEntityFrameworkStores() + .AddDefaultTokenProviders(); + + services.ConfigureApplicationCookie(options => + { + options.Cookie.Name = "WiSave.Session"; + options.Cookie.HttpOnly = true; + options.Cookie.SameSite = SameSiteMode.Lax; + options.Cookie.SecurePolicy = environment.IsDevelopment() + ? CookieSecurePolicy.SameAsRequest + : CookieSecurePolicy.Always; + options.ExpireTimeSpan = TimeSpan.FromDays(14); + options.SlidingExpiration = true; + + options.Events.OnRedirectToLogin = context => + { + context.Response.StatusCode = StatusCodes.Status401Unauthorized; + return Task.CompletedTask; + }; + options.Events.OnRedirectToAccessDenied = context => + { + context.Response.StatusCode = StatusCodes.Status403Forbidden; + return Task.CompletedTask; + }; + }); + + return services; + } +} diff --git a/src/WiSave.Portal.Core.Infrastructure/Identity/RolePermissionResolver.cs b/src/WiSave.Portal.Core.Infrastructure/Identity/RolePermissionResolver.cs new file mode 100644 index 0000000..6ffdcbd --- /dev/null +++ b/src/WiSave.Portal.Core.Infrastructure/Identity/RolePermissionResolver.cs @@ -0,0 +1,62 @@ +using System.Diagnostics; +using Microsoft.AspNetCore.Identity; +using WiSave.Portal.Core.Abstractions.Authorization; +using WiSave.Portal.Core.Abstractions.Observability; + +namespace WiSave.Portal.Core.Infrastructure.Identity; + +public class RolePermissionResolver( + UserManager userManager, + RoleManager roleManager) : IPermissionResolver +{ + private static readonly IReadOnlySet None = + new HashSet(StringComparer.OrdinalIgnoreCase); + + public async Task> GetPermissionsAsync( + Guid userId, + CancellationToken cancellationToken = default) + { + // This runs on every authenticated request and issues several round trips to the + // identity store, so it is the most likely hidden cost inside a slow request. + using var activity = PortalTelemetry.Source.StartActivity( + "resolve permissions", + ActivityKind.Internal); + + var user = await userManager.FindByIdAsync(userId.ToString()); + if (user is null) + { + activity?.SetTag("wisave.permissions.outcome", "unknown-user"); + PortalTelemetry.PermissionResolutions.Add( + 1, new KeyValuePair("wisave.permissions.outcome", "unknown-user")); + return None; + } + + var roles = await userManager.GetRolesAsync(user); + activity?.SetTag("wisave.permissions.role_count", roles.Count); + + if (roles.Any(role => PortalRoles.AdminRoles.Contains(role, StringComparer.OrdinalIgnoreCase))) + { + activity?.SetTag("wisave.permissions.outcome", "wildcard"); + PortalTelemetry.PermissionResolutions.Add( + 1, new KeyValuePair("wisave.permissions.outcome", "wildcard")); + return new HashSet { "*" }; + } + + var permissions = new HashSet(StringComparer.OrdinalIgnoreCase); + foreach (var roleName in roles) + { + var role = await roleManager.FindByNameAsync(roleName); + if (role is null) + continue; + + var claims = await roleManager.GetClaimsAsync(role); + foreach (var claim in claims.Where(c => + c.Type == PortalClaimTypes.Permission && !string.IsNullOrWhiteSpace(c.Value))) + { + permissions.Add(claim.Value); + } + } + + return permissions; + } +} diff --git a/src/WiSave.Portal/Messaging/Extensions.cs b/src/WiSave.Portal.Core.Infrastructure/Messaging/MessagingHostBuilderExtensions.cs similarity index 52% rename from src/WiSave.Portal/Messaging/Extensions.cs rename to src/WiSave.Portal.Core.Infrastructure/Messaging/MessagingHostBuilderExtensions.cs index 9b52fbf..120310d 100644 --- a/src/WiSave.Portal/Messaging/Extensions.cs +++ b/src/WiSave.Portal.Core.Infrastructure/Messaging/MessagingHostBuilderExtensions.cs @@ -1,19 +1,26 @@ +using Microsoft.Extensions.Hosting; using Wolverine; using Wolverine.RabbitMQ; using WiSave.Expenses.Contracts.Events; using WiSave.Framework.Messaging.Wolverine; using WiSave.Incomes.Contracts.Events; -using WiSave.Portal.EventHandlers; +using WiSave.Portal.Core.Application.EventHandlers; using WiSave.Stock.Contracts.Events.Portfolios; using WiSave.Stock.Contracts.Events.Positions; -namespace WiSave.Portal.Messaging; +namespace WiSave.Portal.Core.Infrastructure.Messaging; -public static class Extensions +public static class MessagingHostBuilderExtensions { private static readonly BrokerName IncomesBroker = new("incomes"); private static readonly BrokerName ExpensesBroker = new("expenses"); + private static readonly BrokerName StocksBroker = new("stocks"); + + // A durable RabbitMQ queue name, not a type reference. It happens to look like an + // old .NET namespace because Wolverine derived it from one, but the queue exists on + // the broker with this exact name and sibling services bind to it. It must NOT be + // renamed when C# namespaces move. private const string LegacyNotificationsQueueName = "WiSave.Portal.EventHandlers.NotificationsEventHandler"; public static IHostApplicationBuilder AddPortalMessaging(this IHostApplicationBuilder builder) @@ -27,8 +34,7 @@ public static IHostApplicationBuilder AddPortalMessaging(this IHostApplicationBu builder.UseWolverine(options => { options.MultipleHandlerBehavior = MultipleHandlerBehavior.Separated; - options.Discovery.IncludeType(); - options.Discovery.IncludeType(); + options.Discovery.IncludeType(); options.UseRabbitMq(rabbit => { rabbit.ConfigureRabbitMq(rabbitMqSettings); }) .EnableEnhancedDeadLettering() @@ -38,47 +44,34 @@ public static IHostApplicationBuilder AddPortalMessaging(this IHostApplicationBu .EnableEnhancedDeadLettering() .AutoProvision(); - options.ListenToEventOnNamedBroker(IncomesBroker); - options.ListenToEventOnNamedBroker(IncomesBroker); - options.ListenToEventOnNamedBroker(IncomesBroker); - options.ListenToLegacyNotificationEventOnNamedBroker(IncomesBroker); - options.ListenToLegacyNotificationEventOnNamedBroker(IncomesBroker); - options.ListenToLegacyNotificationEventOnNamedBroker(IncomesBroker); - options.ListenToLegacyNotificationEventOnNamedBroker(IncomesBroker); - options.ListenToLegacyNotificationEventOnNamedBroker(IncomesBroker); - options.ListenToLegacyNotificationEventOnNamedBroker(IncomesBroker); + options.ListenToEventOnNamedBroker(IncomesBroker); + options.ListenToEventOnNamedBroker(IncomesBroker); + options.ListenToEventOnNamedBroker(IncomesBroker); + options.ListenToEventOnNamedBroker(IncomesBroker); + options.ListenToEventOnNamedBroker(IncomesBroker); + options.ListenToEventOnNamedBroker(IncomesBroker); + options.ListenToEventOnNamedBroker(IncomesBroker); + options.ListenToEventOnNamedBroker(IncomesBroker); + options.ListenToEventOnNamedBroker(IncomesBroker); options.AddNamedRabbitMqBroker(ExpensesBroker, rabbit => { rabbit.ConfigureRabbitMq(expensesRabbitMqSettings); }) .EnableEnhancedDeadLettering() .AutoProvision(); - options.ListenToLegacyNotificationEventOnNamedBroker(ExpensesBroker); + options.ListenToEventOnNamedBroker(ExpensesBroker); options.AddNamedRabbitMqBroker(StocksBroker, rabbit => { rabbit.ConfigureRabbitMq(stocksRabbitMqSettings); }) .EnableEnhancedDeadLettering() .AutoProvision(); - options.ListenToEventOnNamedBroker(StocksBroker); - options.ListenToEventOnNamedBroker(StocksBroker); - options.ListenToEventOnNamedBroker(StocksBroker); - options.ListenToEventOnNamedBroker(StocksBroker); - options.ListenToEventOnNamedBroker(StocksBroker); - options.ListenToEventOnNamedBroker(StocksBroker); + options.ListenToEventOnNamedBroker(StocksBroker); + options.ListenToEventOnNamedBroker(StocksBroker); + options.ListenToEventOnNamedBroker(StocksBroker); + options.ListenToEventOnNamedBroker(StocksBroker); + options.ListenToEventOnNamedBroker(StocksBroker); + options.ListenToEventOnNamedBroker(StocksBroker); }); return builder; } - - private static void ListenToLegacyNotificationEventOnNamedBroker( - this WolverineOptions options, - BrokerName brokerName) - { - var exchangeName = typeof(TMessage).FullName - ?? throw new InvalidOperationException($"Message type '{typeof(TMessage).Name}' does not have a full name."); - - options.ListenToRabbitQueueOnNamedBroker(brokerName, LegacyNotificationsQueueName, queue => - { - queue.BindExchange(exchangeName); - }); - } -} +} \ No newline at end of file diff --git a/src/WiSave.Portal.Core.Infrastructure/Observability/PortalObservability.cs b/src/WiSave.Portal.Core.Infrastructure/Observability/PortalObservability.cs new file mode 100644 index 0000000..aa5fb4e --- /dev/null +++ b/src/WiSave.Portal.Core.Infrastructure/Observability/PortalObservability.cs @@ -0,0 +1,49 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using OpenTelemetry; +using OpenTelemetry.Metrics; +using OpenTelemetry.Trace; +using WiSave.Portal.Core.Abstractions.Observability; + +namespace WiSave.Portal.Core.Infrastructure.Observability; + +public static class PortalObservability +{ + /// + /// Emits traces, metrics and structured logs over OTLP. The HTTP client + /// instrumentation is the one that matters most for a gateway: without it there is no + /// way to tell whether latency came from the portal or from the service it proxied to. + /// + public static IHostApplicationBuilder AddPortalObservability(this IHostApplicationBuilder builder) + { + builder.Logging.AddOpenTelemetry(logging => + { + logging.IncludeFormattedMessage = true; + logging.IncludeScopes = true; + }); + + builder.Services.AddOpenTelemetry() + .WithMetrics(metrics => metrics + .AddAspNetCoreInstrumentation() + .AddHttpClientInstrumentation() + .AddRuntimeInstrumentation() + .AddMeter(WolverineTelemetry.Meters) + .AddMeter(PortalTelemetry.SourceName)) + .WithTracing(tracing => tracing + .AddAspNetCoreInstrumentation(options => + options.Filter = context => + !context.Request.Path.StartsWithSegments("/health") + && !context.Request.Path.StartsWithSegments("/alive")) + .AddHttpClientInstrumentation() + .AddSource(WolverineTelemetry.ActivitySource) + .AddSource(PortalTelemetry.SourceName)); + + if (!string.IsNullOrWhiteSpace(builder.Configuration["OTEL_EXPORTER_OTLP_ENDPOINT"])) + { + builder.Services.AddOpenTelemetry().UseOtlpExporter(); + } + + return builder; + } +} diff --git a/src/WiSave.Portal.Core.Infrastructure/Observability/WolverineTelemetry.cs b/src/WiSave.Portal.Core.Infrastructure/Observability/WolverineTelemetry.cs new file mode 100644 index 0000000..b12444a --- /dev/null +++ b/src/WiSave.Portal.Core.Infrastructure/Observability/WolverineTelemetry.cs @@ -0,0 +1,20 @@ +namespace WiSave.Portal.Core.Infrastructure.Observability; + +/// +/// The telemetry identifiers Wolverine publishes under. +/// +public static class WolverineTelemetry +{ + /// + /// Wolverine's ActivitySource. Registering it puts the RabbitMQ consume path in + /// the same trace as whatever triggered it. + /// + public const string ActivitySource = "Wolverine"; + + /// + /// Wolverine names its meter Wolverine:{ApplicationName}, so the wildcard is + /// required — a bare "Wolverine" matches nothing and the messaging metrics + /// silently never arrive. + /// + public const string Meters = "Wolverine*"; +} diff --git a/src/WiSave.Portal/Session/RedisTicketStore.cs b/src/WiSave.Portal.Core.Infrastructure/Session/RedisTicketStore.cs similarity index 95% rename from src/WiSave.Portal/Session/RedisTicketStore.cs rename to src/WiSave.Portal.Core.Infrastructure/Session/RedisTicketStore.cs index 564a637..5d19763 100644 --- a/src/WiSave.Portal/Session/RedisTicketStore.cs +++ b/src/WiSave.Portal.Core.Infrastructure/Session/RedisTicketStore.cs @@ -2,7 +2,7 @@ using Microsoft.AspNetCore.Authentication.Cookies; using Microsoft.Extensions.Caching.Distributed; -namespace WiSave.Portal.Session; +namespace WiSave.Portal.Core.Infrastructure.Session; public class RedisTicketStore(IDistributedCache cache) : ITicketStore { diff --git a/src/WiSave.Portal/Session/Extensions.cs b/src/WiSave.Portal.Core.Infrastructure/Session/SessionServiceCollectionExtensions.cs similarity index 93% rename from src/WiSave.Portal/Session/Extensions.cs rename to src/WiSave.Portal.Core.Infrastructure/Session/SessionServiceCollectionExtensions.cs index fb8ed1f..61d3d78 100644 --- a/src/WiSave.Portal/Session/Extensions.cs +++ b/src/WiSave.Portal.Core.Infrastructure/Session/SessionServiceCollectionExtensions.cs @@ -1,18 +1,18 @@ using Microsoft.AspNetCore.Authentication.Cookies; using Microsoft.AspNetCore.DataProtection; using Microsoft.Extensions.Caching.Distributed; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; using Microsoft.AspNetCore.Identity; using StackExchange.Redis; -namespace WiSave.Portal.Session; +namespace WiSave.Portal.Core.Infrastructure.Session; -public static class Extensions +public static class SessionServiceCollectionExtensions { public static IServiceCollection AddPortalSession(this IServiceCollection services, IConfiguration configuration) { - services.Configure(configuration.GetSection("Session")); - var redisConnection = configuration["Redis:ConnectionString"]; var allowInMemoryFallback = configuration.GetValue("UseInMemoryDatabase") || diff --git a/src/WiSave.Portal/WiSave.Portal.csproj b/src/WiSave.Portal.Core.Infrastructure/WiSave.Portal.Core.Infrastructure.csproj similarity index 61% rename from src/WiSave.Portal/WiSave.Portal.csproj rename to src/WiSave.Portal.Core.Infrastructure/WiSave.Portal.Core.Infrastructure.csproj index 1e124ea..dc25aad 100644 --- a/src/WiSave.Portal/WiSave.Portal.csproj +++ b/src/WiSave.Portal.Core.Infrastructure/WiSave.Portal.Core.Infrastructure.csproj @@ -1,47 +1,41 @@ - + net10.0 enable enable - wisave-portal - latest - - + - + + - + all runtime; build; native; contentfiles; analyzers; buildtransitive - - - - - - - + + + + + + - - - - + diff --git a/src/WiSave.Portal.Migrations/Scripts/002_20260426212559_Initial.sql b/src/WiSave.Portal.Migrations/Scripts/002_20260801231030_Initial.sql similarity index 84% rename from src/WiSave.Portal.Migrations/Scripts/002_20260426212559_Initial.sql rename to src/WiSave.Portal.Migrations/Scripts/002_20260801231030_Initial.sql index 2050edb..da28c5c 100644 --- a/src/WiSave.Portal.Migrations/Scripts/002_20260426212559_Initial.sql +++ b/src/WiSave.Portal.Migrations/Scripts/002_20260801231030_Initial.sql @@ -8,9 +8,9 @@ START TRANSACTION; DO $EF$ BEGIN - IF NOT EXISTS(SELECT 1 FROM "__EFMigrationsHistory" WHERE "MigrationId" = '20260426212559_Initial') THEN + IF NOT EXISTS(SELECT 1 FROM "__EFMigrationsHistory" WHERE "MigrationId" = '20260801231030_Initial') THEN CREATE TABLE public."AspNetRoles" ( - "Id" text NOT NULL, + "Id" uuid NOT NULL, "Name" character varying(256), "NormalizedName" character varying(256), "ConcurrencyStamp" text, @@ -21,9 +21,9 @@ END $EF$; DO $EF$ BEGIN - IF NOT EXISTS(SELECT 1 FROM "__EFMigrationsHistory" WHERE "MigrationId" = '20260426212559_Initial') THEN + IF NOT EXISTS(SELECT 1 FROM "__EFMigrationsHistory" WHERE "MigrationId" = '20260801231030_Initial') THEN CREATE TABLE public."AspNetUsers" ( - "Id" text NOT NULL, + "Id" uuid NOT NULL, "Name" text NOT NULL, "UserName" character varying(256), "NormalizedUserName" character varying(256), @@ -46,10 +46,10 @@ END $EF$; DO $EF$ BEGIN - IF NOT EXISTS(SELECT 1 FROM "__EFMigrationsHistory" WHERE "MigrationId" = '20260426212559_Initial') THEN + IF NOT EXISTS(SELECT 1 FROM "__EFMigrationsHistory" WHERE "MigrationId" = '20260801231030_Initial') THEN CREATE TABLE public."AspNetRoleClaims" ( "Id" integer GENERATED BY DEFAULT AS IDENTITY, - "RoleId" text NOT NULL, + "RoleId" uuid NOT NULL, "ClaimType" text, "ClaimValue" text, CONSTRAINT "PK_AspNetRoleClaims" PRIMARY KEY ("Id"), @@ -60,10 +60,10 @@ END $EF$; DO $EF$ BEGIN - IF NOT EXISTS(SELECT 1 FROM "__EFMigrationsHistory" WHERE "MigrationId" = '20260426212559_Initial') THEN + IF NOT EXISTS(SELECT 1 FROM "__EFMigrationsHistory" WHERE "MigrationId" = '20260801231030_Initial') THEN CREATE TABLE public."AspNetUserClaims" ( "Id" integer GENERATED BY DEFAULT AS IDENTITY, - "UserId" text NOT NULL, + "UserId" uuid NOT NULL, "ClaimType" text, "ClaimValue" text, CONSTRAINT "PK_AspNetUserClaims" PRIMARY KEY ("Id"), @@ -74,12 +74,12 @@ END $EF$; DO $EF$ BEGIN - IF NOT EXISTS(SELECT 1 FROM "__EFMigrationsHistory" WHERE "MigrationId" = '20260426212559_Initial') THEN + IF NOT EXISTS(SELECT 1 FROM "__EFMigrationsHistory" WHERE "MigrationId" = '20260801231030_Initial') THEN CREATE TABLE public."AspNetUserLogins" ( "LoginProvider" text NOT NULL, "ProviderKey" text NOT NULL, "ProviderDisplayName" text, - "UserId" text NOT NULL, + "UserId" uuid NOT NULL, CONSTRAINT "PK_AspNetUserLogins" PRIMARY KEY ("LoginProvider", "ProviderKey"), CONSTRAINT "FK_AspNetUserLogins_AspNetUsers_UserId" FOREIGN KEY ("UserId") REFERENCES public."AspNetUsers" ("Id") ON DELETE CASCADE ); @@ -88,10 +88,10 @@ END $EF$; DO $EF$ BEGIN - IF NOT EXISTS(SELECT 1 FROM "__EFMigrationsHistory" WHERE "MigrationId" = '20260426212559_Initial') THEN + IF NOT EXISTS(SELECT 1 FROM "__EFMigrationsHistory" WHERE "MigrationId" = '20260801231030_Initial') THEN CREATE TABLE public."AspNetUserRoles" ( - "UserId" text NOT NULL, - "RoleId" text NOT NULL, + "UserId" uuid NOT NULL, + "RoleId" uuid NOT NULL, CONSTRAINT "PK_AspNetUserRoles" PRIMARY KEY ("UserId", "RoleId"), CONSTRAINT "FK_AspNetUserRoles_AspNetRoles_RoleId" FOREIGN KEY ("RoleId") REFERENCES public."AspNetRoles" ("Id") ON DELETE CASCADE, CONSTRAINT "FK_AspNetUserRoles_AspNetUsers_UserId" FOREIGN KEY ("UserId") REFERENCES public."AspNetUsers" ("Id") ON DELETE CASCADE @@ -101,9 +101,9 @@ END $EF$; DO $EF$ BEGIN - IF NOT EXISTS(SELECT 1 FROM "__EFMigrationsHistory" WHERE "MigrationId" = '20260426212559_Initial') THEN + IF NOT EXISTS(SELECT 1 FROM "__EFMigrationsHistory" WHERE "MigrationId" = '20260801231030_Initial') THEN CREATE TABLE public."AspNetUserTokens" ( - "UserId" text NOT NULL, + "UserId" uuid NOT NULL, "LoginProvider" text NOT NULL, "Name" text NOT NULL, "Value" text, @@ -115,58 +115,58 @@ END $EF$; DO $EF$ BEGIN - IF NOT EXISTS(SELECT 1 FROM "__EFMigrationsHistory" WHERE "MigrationId" = '20260426212559_Initial') THEN + IF NOT EXISTS(SELECT 1 FROM "__EFMigrationsHistory" WHERE "MigrationId" = '20260801231030_Initial') THEN CREATE INDEX "IX_AspNetRoleClaims_RoleId" ON public."AspNetRoleClaims" ("RoleId"); END IF; END $EF$; DO $EF$ BEGIN - IF NOT EXISTS(SELECT 1 FROM "__EFMigrationsHistory" WHERE "MigrationId" = '20260426212559_Initial') THEN + IF NOT EXISTS(SELECT 1 FROM "__EFMigrationsHistory" WHERE "MigrationId" = '20260801231030_Initial') THEN CREATE UNIQUE INDEX "RoleNameIndex" ON public."AspNetRoles" ("NormalizedName"); END IF; END $EF$; DO $EF$ BEGIN - IF NOT EXISTS(SELECT 1 FROM "__EFMigrationsHistory" WHERE "MigrationId" = '20260426212559_Initial') THEN + IF NOT EXISTS(SELECT 1 FROM "__EFMigrationsHistory" WHERE "MigrationId" = '20260801231030_Initial') THEN CREATE INDEX "IX_AspNetUserClaims_UserId" ON public."AspNetUserClaims" ("UserId"); END IF; END $EF$; DO $EF$ BEGIN - IF NOT EXISTS(SELECT 1 FROM "__EFMigrationsHistory" WHERE "MigrationId" = '20260426212559_Initial') THEN + IF NOT EXISTS(SELECT 1 FROM "__EFMigrationsHistory" WHERE "MigrationId" = '20260801231030_Initial') THEN CREATE INDEX "IX_AspNetUserLogins_UserId" ON public."AspNetUserLogins" ("UserId"); END IF; END $EF$; DO $EF$ BEGIN - IF NOT EXISTS(SELECT 1 FROM "__EFMigrationsHistory" WHERE "MigrationId" = '20260426212559_Initial') THEN + IF NOT EXISTS(SELECT 1 FROM "__EFMigrationsHistory" WHERE "MigrationId" = '20260801231030_Initial') THEN CREATE INDEX "IX_AspNetUserRoles_RoleId" ON public."AspNetUserRoles" ("RoleId"); END IF; END $EF$; DO $EF$ BEGIN - IF NOT EXISTS(SELECT 1 FROM "__EFMigrationsHistory" WHERE "MigrationId" = '20260426212559_Initial') THEN + IF NOT EXISTS(SELECT 1 FROM "__EFMigrationsHistory" WHERE "MigrationId" = '20260801231030_Initial') THEN CREATE INDEX "EmailIndex" ON public."AspNetUsers" ("NormalizedEmail"); END IF; END $EF$; DO $EF$ BEGIN - IF NOT EXISTS(SELECT 1 FROM "__EFMigrationsHistory" WHERE "MigrationId" = '20260426212559_Initial') THEN + IF NOT EXISTS(SELECT 1 FROM "__EFMigrationsHistory" WHERE "MigrationId" = '20260801231030_Initial') THEN CREATE UNIQUE INDEX "UserNameIndex" ON public."AspNetUsers" ("NormalizedUserName"); END IF; END $EF$; DO $EF$ BEGIN - IF NOT EXISTS(SELECT 1 FROM "__EFMigrationsHistory" WHERE "MigrationId" = '20260426212559_Initial') THEN + IF NOT EXISTS(SELECT 1 FROM "__EFMigrationsHistory" WHERE "MigrationId" = '20260801231030_Initial') THEN INSERT INTO "__EFMigrationsHistory" ("MigrationId", "ProductVersion") - VALUES ('20260426212559_Initial', '10.0.7'); + VALUES ('20260801231030_Initial', '10.0.10'); END IF; END $EF$; COMMIT; diff --git a/src/WiSave.Portal.Migrations/Seeds/001_SeedIdentityPlanPermissions.sql b/src/WiSave.Portal.Migrations/Seeds/001_SeedIdentityPlanPermissions.sql index fffcc56..a8c7ae0 100644 --- a/src/WiSave.Portal.Migrations/Seeds/001_SeedIdentityPlanPermissions.sql +++ b/src/WiSave.Portal.Migrations/Seeds/001_SeedIdentityPlanPermissions.sql @@ -1,13 +1,13 @@ WITH roles("Id", "Name", "NormalizedName") AS ( VALUES - ('role-superadmin', 'superadmin', 'SUPERADMIN'), - ('role-admin', 'admin', 'ADMIN'), - ('role-plan-free', 'plan:free', 'PLAN:FREE'), - ('role-plan-standard', 'plan:standard', 'PLAN:STANDARD'), - ('role-plan-premium', 'plan:premium', 'PLAN:PREMIUM') + ('019f52ff-5046-7935-9ff8-84958bdb3945', 'superadmin', 'SUPERADMIN'), + ('019f52ff-5047-7391-baf5-fb5e84cfb2c0', 'admin', 'ADMIN'), + ('019f52ff-5048-7bff-ba46-6b304c7816eb', 'plan:free', 'PLAN:FREE'), + ('019f52ff-5049-7209-9821-fbbc42da9f9d', 'plan:standard', 'PLAN:STANDARD'), + ('019f52ff-504a-7dd8-9412-94ab987fba29', 'plan:premium', 'PLAN:PREMIUM') ) INSERT INTO public."AspNetRoles" ("Id", "Name", "NormalizedName", "ConcurrencyStamp") -SELECT roles."Id", roles."Name", roles."NormalizedName", gen_random_uuid()::text +SELECT roles."Id"::uuid, roles."Name", roles."NormalizedName", gen_random_uuid()::text FROM roles WHERE NOT EXISTS ( SELECT 1 diff --git a/src/WiSave.Portal.WebApi/Auth/Extensions.cs b/src/WiSave.Portal.WebApi/Auth/Extensions.cs new file mode 100644 index 0000000..c4eaa9f --- /dev/null +++ b/src/WiSave.Portal.WebApi/Auth/Extensions.cs @@ -0,0 +1,49 @@ +using Microsoft.AspNetCore.RateLimiting; + +namespace WiSave.Portal.Auth; + +public static class Extensions +{ + public static IServiceCollection AddPortalAntiforgery(this IServiceCollection services, IHostEnvironment environment) + { + services.AddAntiforgery(options => + { + options.HeaderName = "X-XSRF-TOKEN"; + // The system's own cookie stores the cookie token (HttpOnly). + // A separate XSRF-TOKEN cookie with the request token is set manually + // after GetAndStoreTokens() for Angular's XSRF interceptor to read. + options.Cookie.Name = ".AspNetCore.Antiforgery"; + options.Cookie.HttpOnly = true; + options.Cookie.SameSite = SameSiteMode.Lax; + options.Cookie.SecurePolicy = environment.IsDevelopment() + ? CookieSecurePolicy.SameAsRequest + : CookieSecurePolicy.Always; + }); + + return services; + } + + public static IServiceCollection AddPortalAuthRateLimiting(this IServiceCollection services) + { + services.AddRateLimiter(options => + { + options.RejectionStatusCode = StatusCodes.Status429TooManyRequests; + + options.AddFixedWindowLimiter("auth-login", opt => + { + opt.PermitLimit = 10; + opt.Window = TimeSpan.FromMinutes(1); + opt.QueueLimit = 0; + }); + + options.AddFixedWindowLimiter("auth-register", opt => + { + opt.PermitLimit = 5; + opt.Window = TimeSpan.FromMinutes(1); + opt.QueueLimit = 0; + }); + }); + + return services; + } +} diff --git a/src/WiSave.Portal/Auth/Models/AuthDtos.cs b/src/WiSave.Portal.WebApi/Auth/Models/AuthDtos.cs similarity index 100% rename from src/WiSave.Portal/Auth/Models/AuthDtos.cs rename to src/WiSave.Portal.WebApi/Auth/Models/AuthDtos.cs diff --git a/src/WiSave.Portal/Authorization/AuthorizationExtensions.cs b/src/WiSave.Portal.WebApi/Authorization/AuthorizationExtensions.cs similarity index 88% rename from src/WiSave.Portal/Authorization/AuthorizationExtensions.cs rename to src/WiSave.Portal.WebApi/Authorization/AuthorizationExtensions.cs index f5aa511..7b66733 100644 --- a/src/WiSave.Portal/Authorization/AuthorizationExtensions.cs +++ b/src/WiSave.Portal.WebApi/Authorization/AuthorizationExtensions.cs @@ -1,4 +1,6 @@ using Microsoft.AspNetCore.Authorization; +using WiSave.Portal.Core.Abstractions.Authorization; +using WiSave.Portal.Core.Infrastructure.Identity; namespace WiSave.Portal.Authorization; @@ -6,7 +8,7 @@ public static class AuthorizationExtensions { public static IServiceCollection AddPortalAuthorization(this IServiceCollection services) { - services.AddScoped(); + services.AddScoped(); return services.AddPermissionPolicies(); } diff --git a/src/WiSave.Portal/Authorization/PermissionHandler.cs b/src/WiSave.Portal.WebApi/Authorization/PermissionHandler.cs similarity index 100% rename from src/WiSave.Portal/Authorization/PermissionHandler.cs rename to src/WiSave.Portal.WebApi/Authorization/PermissionHandler.cs diff --git a/src/WiSave.Portal/Authorization/PermissionRequirement.cs b/src/WiSave.Portal.WebApi/Authorization/PermissionRequirement.cs similarity index 100% rename from src/WiSave.Portal/Authorization/PermissionRequirement.cs rename to src/WiSave.Portal.WebApi/Authorization/PermissionRequirement.cs diff --git a/src/WiSave.Portal.WebApi/Authorization/PermissionResolutionMiddleware.cs b/src/WiSave.Portal.WebApi/Authorization/PermissionResolutionMiddleware.cs new file mode 100644 index 0000000..8909516 --- /dev/null +++ b/src/WiSave.Portal.WebApi/Authorization/PermissionResolutionMiddleware.cs @@ -0,0 +1,28 @@ +using System.Security.Claims; +using WiSave.Portal.Core.Abstractions.Authorization; + +namespace WiSave.Portal.Authorization; + +public class PermissionResolutionMiddleware(RequestDelegate next) +{ + public async Task InvokeAsync(HttpContext context, IPermissionResolver permissionResolver) + { + if (context.User.Identity?.IsAuthenticated != true) + { + await next(context); + return; + } + + var claim = context.User.FindFirstValue(ClaimTypes.NameIdentifier); + if (!Guid.TryParse(claim, out var userId)) + { + await next(context); + return; + } + + context.Items["UserPermissions"] = + await permissionResolver.GetPermissionsAsync(userId, context.RequestAborted); + + await next(context); + } +} diff --git a/src/WiSave.Portal/Endpoints/AdminAccessManagementEndpoints.cs b/src/WiSave.Portal.WebApi/Endpoints/AdminAccessManagementEndpoints.cs similarity index 85% rename from src/WiSave.Portal/Endpoints/AdminAccessManagementEndpoints.cs rename to src/WiSave.Portal.WebApi/Endpoints/AdminAccessManagementEndpoints.cs index 23dd204..14e0243 100644 --- a/src/WiSave.Portal/Endpoints/AdminAccessManagementEndpoints.cs +++ b/src/WiSave.Portal.WebApi/Endpoints/AdminAccessManagementEndpoints.cs @@ -1,8 +1,10 @@ using System.Security.Claims; using Microsoft.AspNetCore.Identity; using Microsoft.EntityFrameworkCore; -using WiSave.Portal.Auth.Models; +using WiSave.Portal.Core.Infrastructure.Identity; using WiSave.Portal.Authorization; +using WiSave.Portal.Core.Abstractions.Authorization; +using WiSave.Portal.Core.Application.Authorization; using WiSave.Portal.Contracts.Authorization; using WiSave.Portal.Filters; @@ -52,19 +54,19 @@ public static void MapAdminAccessManagementEndpoints(this WebApplication app) private static async Task GetAccessManagement( HttpContext context, UserManager userManager, - RoleManager roleManager, - RolePermissionResolver rolePermissionResolver) + RoleManager roleManager, + IPermissionResolver rolePermissionResolver) { var currentUser = await GetCurrentUserAsync(context, userManager); if (currentUser is null) return Results.Unauthorized(); var currentUserRoles = await userManager.GetRolesAsync(currentUser); - var canReadAccessManagement = CanReadAccessManagement(currentUserRoles); + var canReadAccessManagement = AccessManagementPolicy.CanReadAccessManagement(currentUserRoles); if (!canReadAccessManagement) return Results.Forbid(); - var canManagePrivilegedRoles = CanManagePrivilegedRoles(currentUserRoles); + var canManagePrivilegedRoles = AccessManagementPolicy.CanManagePrivilegedRoles(currentUserRoles); var roles = await GetRoleResponsesAsync(roleManager); var roleByName = roles.ToDictionary(role => role.Name, StringComparer.OrdinalIgnoreCase); var users = await userManager.Users.OrderBy(user => user.Email).ToListAsync(); @@ -88,17 +90,20 @@ private static async Task UpdateUserRoles( UpdateUserRolesRequest request, HttpContext context, UserManager userManager, - RoleManager roleManager, - RolePermissionResolver rolePermissionResolver) + RoleManager roleManager, + IPermissionResolver rolePermissionResolver) { var currentUser = await GetCurrentUserAsync(context, userManager); if (currentUser is null) return Results.Unauthorized(); var currentUserRoles = await userManager.GetRolesAsync(currentUser); - if (!CanReadAccessManagement(currentUserRoles)) + if (!AccessManagementPolicy.CanReadAccessManagement(currentUserRoles)) return Results.Forbid(); + if (!Guid.TryParse(userId, out _)) + return Results.NotFound(); + var targetUser = await userManager.FindByIdAsync(userId); if (targetUser is null) return Results.NotFound(); @@ -109,8 +114,9 @@ private static async Task UpdateUserRoles( .ToArray(); var allRoles = await roleManager.Roles.ToListAsync(); - var roleById = allRoles.ToDictionary(role => role.Id, StringComparer.OrdinalIgnoreCase); - var missingRoleId = requestedRoleIds.FirstOrDefault(roleId => !roleById.ContainsKey(roleId)); + var roleById = allRoles.ToDictionary(role => role.Id); + var missingRoleId = requestedRoleIds.FirstOrDefault(roleId => + !Guid.TryParse(roleId, out var parsedRoleId) || !roleById.ContainsKey(parsedRoleId)); if (missingRoleId is not null) return Results.ValidationProblem(new Dictionary { @@ -125,14 +131,14 @@ private static async Task UpdateUserRoles( }); } - var requestedRole = roleById[requestedRoleIds[0]]; - var canManagePrivilegedRoles = CanManagePrivilegedRoles(currentUserRoles); + var requestedRole = roleById[Guid.Parse(requestedRoleIds[0])]; + var canManagePrivilegedRoles = AccessManagementPolicy.CanManagePrivilegedRoles(currentUserRoles); var targetRoles = await userManager.GetRolesAsync(targetUser); - if (!canManagePrivilegedRoles && ContainsPrivilegedRole(targetRoles)) + if (!canManagePrivilegedRoles && AccessManagementPolicy.ContainsPrivilegedRole(targetRoles)) return Results.Forbid(); - if (!canManagePrivilegedRoles && IsPrivilegedRole(requestedRole.Name)) + if (!canManagePrivilegedRoles && AccessManagementPolicy.IsPrivilegedRole(requestedRole.Name)) return Results.Forbid(); var removeResult = await userManager.RemoveFromRolesAsync(targetUser, targetRoles); @@ -159,21 +165,21 @@ private static async Task CreateRole( CreateRoleRequest request, HttpContext context, UserManager userManager, - RoleManager roleManager) + RoleManager roleManager) { var currentUser = await GetCurrentUserAsync(context, userManager); if (currentUser is null) return Results.Unauthorized(); var currentUserRoles = await userManager.GetRolesAsync(currentUser); - if (!CanReadAccessManagement(currentUserRoles)) + if (!AccessManagementPolicy.CanReadAccessManagement(currentUserRoles)) return Results.Forbid(); var roleName = request.Name.Trim(); if (string.IsNullOrWhiteSpace(roleName)) return Results.ValidationProblem(new Dictionary { [nameof(CreateRoleRequest.Name)] = ["Role name is required."] }); - if (IsReservedRoleName(roleName)) + if (AccessManagementPolicy.IsReservedRoleName(roleName)) return Results.ValidationProblem(new Dictionary { [nameof(CreateRoleRequest.Name)] = ["This role name is reserved."] }); if (await roleManager.RoleExistsAsync(roleName)) @@ -189,7 +195,7 @@ private static async Task CreateRole( }); } - var role = new IdentityRole(roleName); + var role = new ApplicationRole(roleName); var createResult = await roleManager.CreateAsync(role); if (!createResult.Succeeded) return Results.BadRequest(new { errors = createResult.Errors.Select(error => error.Description) }); @@ -210,16 +216,19 @@ private static async Task UpdateRolePermissions( UpdateRolePermissionsRequest request, HttpContext context, UserManager userManager, - RoleManager roleManager) + RoleManager roleManager) { var currentUser = await GetCurrentUserAsync(context, userManager); if (currentUser is null) return Results.Unauthorized(); var currentUserRoles = await userManager.GetRolesAsync(currentUser); - if (!CanReadAccessManagement(currentUserRoles)) + if (!AccessManagementPolicy.CanReadAccessManagement(currentUserRoles)) return Results.Forbid(); + if (!Guid.TryParse(roleId, out _)) + return Results.NotFound(); + var role = await roleManager.FindByIdAsync(roleId); if (role is null) return Results.NotFound(); @@ -258,7 +267,7 @@ private static async Task UpdateRolePermissions( return userId is null ? null : await userManager.FindByIdAsync(userId); } - private static async Task> GetRoleResponsesAsync(RoleManager roleManager) + private static async Task> GetRoleResponsesAsync(RoleManager roleManager) { var roles = await roleManager.Roles.OrderBy(role => role.Name).ToListAsync(); var responses = new List(roles.Count); @@ -272,8 +281,8 @@ private static async Task> GetRoleResponsesAsync(RoleMa } private static async Task BuildRoleResponseAsync( - RoleManager roleManager, - IdentityRole role) + RoleManager roleManager, + ApplicationRole role) { var claims = await roleManager.GetClaimsAsync(role); var permissions = claims @@ -284,7 +293,7 @@ private static async Task BuildRoleResponseAsync( .ToArray(); return new AccessRoleResponse( - role.Id, + role.Id.ToString(), role.Name!, role.NormalizedName!, role.ConcurrencyStamp, @@ -294,7 +303,7 @@ private static async Task BuildRoleResponseAsync( private static async Task BuildUserResponseAsync( ApplicationUser user, UserManager userManager, - RolePermissionResolver rolePermissionResolver, + IPermissionResolver rolePermissionResolver, IReadOnlyDictionary roleByName, bool canManagePrivilegedRoles) { @@ -305,11 +314,11 @@ private static async Task BuildUserResponseAsync( .Cast() .Order(StringComparer.OrdinalIgnoreCase) .ToArray(); - var permissions = await rolePermissionResolver.GetPermissionsAsync(user); - var canEditRoles = canManagePrivilegedRoles || !ContainsPrivilegedRole(userRoles); + var permissions = await rolePermissionResolver.GetPermissionsAsync(user.Id); + var canEditRoles = canManagePrivilegedRoles || !AccessManagementPolicy.ContainsPrivilegedRole(userRoles); return new AccessUserResponse( - user.Id, + user.Id.ToString(), user.Name, user.Email!, roleIds, @@ -317,23 +326,6 @@ [.. permissions.Order(StringComparer.OrdinalIgnoreCase)], canEditRoles); } - private static bool CanReadAccessManagement(IEnumerable roles) => - roles.Any(IsPrivilegedRole); - - private static bool CanManagePrivilegedRoles(IEnumerable roles) => - roles.Contains(PortalRoles.SuperAdmin, StringComparer.OrdinalIgnoreCase); - - private static bool ContainsPrivilegedRole(IEnumerable roles) => - roles.Any(IsPrivilegedRole); - - private static bool IsPrivilegedRole(string? role) => - string.Equals(role, PortalRoles.Admin, StringComparison.OrdinalIgnoreCase) - || string.Equals(role, PortalRoles.SuperAdmin, StringComparison.OrdinalIgnoreCase); - - private static bool IsReservedRoleName(string role) => - role.StartsWith("plan:", StringComparison.OrdinalIgnoreCase) - || IsPrivilegedRole(role); - private static string[] NormalizePermissionRequest(IEnumerable permissions) => permissions .Where(permission => !string.IsNullOrWhiteSpace(permission)) diff --git a/src/WiSave.Portal/Endpoints/AuthEndpoints.cs b/src/WiSave.Portal.WebApi/Endpoints/AuthEndpoints.cs similarity index 85% rename from src/WiSave.Portal/Endpoints/AuthEndpoints.cs rename to src/WiSave.Portal.WebApi/Endpoints/AuthEndpoints.cs index f6d83fb..88a6aa4 100644 --- a/src/WiSave.Portal/Endpoints/AuthEndpoints.cs +++ b/src/WiSave.Portal.WebApi/Endpoints/AuthEndpoints.cs @@ -2,7 +2,10 @@ using Microsoft.AspNetCore.Identity; using System.Security.Claims; using WiSave.Portal.Auth.Models; +using WiSave.Portal.Core.Infrastructure.Identity; using WiSave.Portal.Authorization; +using WiSave.Portal.Core.Abstractions.Authorization; +using WiSave.Portal.Core.Abstractions.Observability; using WiSave.Portal.Filters; namespace WiSave.Portal.Endpoints; @@ -64,12 +67,13 @@ private static async Task Register( SignInManager signInManager, IAntiforgery antiforgery, HttpContext context, - RolePermissionResolver rolePermissionResolver, - RoleManager roleManager) + IPermissionResolver rolePermissionResolver, + RoleManager roleManager) { var planRole = PortalRoles.NormalizePlanInput(request.PlanId); if (!PortalRoles.IsPlanRole(planRole) || !await roleManager.RoleExistsAsync(planRole)) { + PortalTelemetry.RecordAuthAttempt("register", "UNKNOWN_PLAN"); return Results.BadRequest(new { errors = new[] { $"Plan '{request.PlanId}' does not exist." } }); } @@ -84,21 +88,24 @@ private static async Task Register( if (!result.Succeeded) { + PortalTelemetry.RecordAuthAttempt("register", "REJECTED"); return Results.BadRequest(new { errors = result.Errors.Select(e => e.Description) }); } var roleResult = await userManager.AddToRoleAsync(user, planRole); if (!roleResult.Succeeded) { + PortalTelemetry.RecordAuthAttempt("register", "ROLE_ASSIGNMENT_FAILED"); return Results.BadRequest(new { errors = roleResult.Errors.Select(e => e.Description) }); } + PortalTelemetry.RecordAuthAttempt("register", "SUCCEEDED"); await signInManager.SignInAsync(user, isPersistent: true); SetXsrfTokenCookie(antiforgery, context); - var permissions = await rolePermissionResolver.GetPermissionsAsync(user); - var response = new AuthResponse(new UserResponse(user.Id, user.Name, user.Email!, [.. permissions])); + var permissions = await rolePermissionResolver.GetPermissionsAsync(user.Id); + var response = new AuthResponse(new UserResponse(user.Id.ToString(), user.Name, user.Email!, [.. permissions])); return Results.Ok(response); } @@ -108,13 +115,14 @@ private static async Task Login( SignInManager signInManager, IAntiforgery antiforgery, HttpContext context, - RolePermissionResolver rolePermissionResolver) + IPermissionResolver rolePermissionResolver) { var normalized = userManager.NormalizeEmail(request.Email); var user = await userManager.FindByEmailAsync(normalized); if (user is null) { + PortalTelemetry.RecordAuthAttempt("login", "USER_NOT_FOUND"); return UnauthorizedError( "USER_NOT_FOUND", "No account exists for that email address."); @@ -125,11 +133,13 @@ private static async Task Login( if (result.IsLockedOut) { + PortalTelemetry.RecordAuthAttempt("login", "LOCKED_OUT"); return UnauthorizedError("LOCKED_OUT", "This account is locked out."); } if (result.IsNotAllowed) { + PortalTelemetry.RecordAuthAttempt("login", "NOT_ALLOWED"); return UnauthorizedError( "NOT_ALLOWED", "Sign-in is not allowed for this account."); @@ -137,15 +147,17 @@ private static async Task Login( if (!result.Succeeded) { + PortalTelemetry.RecordAuthAttempt("login", "INVALID_PASSWORD"); return UnauthorizedError( "INVALID_PASSWORD", "The password is incorrect."); } + PortalTelemetry.RecordAuthAttempt("login", "SUCCEEDED"); SetXsrfTokenCookie(antiforgery, context); - var permissions = await rolePermissionResolver.GetPermissionsAsync(user); - var response = new AuthResponse(new UserResponse(user.Id, user.Name, user.Email!, [.. permissions])); + var permissions = await rolePermissionResolver.GetPermissionsAsync(user.Id); + var response = new AuthResponse(new UserResponse(user.Id.ToString(), user.Name, user.Email!, [.. permissions])); return Results.Ok(response); } @@ -182,7 +194,7 @@ private static async Task ChangePassword( private static async Task Me( HttpContext context, UserManager userManager, - RolePermissionResolver rolePermissionResolver) + IPermissionResolver rolePermissionResolver) { var userId = context.User.FindFirstValue(ClaimTypes.NameIdentifier); @@ -198,8 +210,8 @@ private static async Task Me( return Results.Unauthorized(); } - var permissions = await rolePermissionResolver.GetPermissionsAsync(user); - var response = new UserResponse(user.Id, user.Name, user.Email!, [.. permissions]); + var permissions = await rolePermissionResolver.GetPermissionsAsync(user.Id); + var response = new UserResponse(user.Id.ToString(), user.Name, user.Email!, [.. permissions]); return Results.Ok(response); } diff --git a/src/WiSave.Portal/Endpoints/CapabilitiesEndpoints.cs b/src/WiSave.Portal.WebApi/Endpoints/CapabilitiesEndpoints.cs similarity index 93% rename from src/WiSave.Portal/Endpoints/CapabilitiesEndpoints.cs rename to src/WiSave.Portal.WebApi/Endpoints/CapabilitiesEndpoints.cs index 38e30fa..67bb99f 100644 --- a/src/WiSave.Portal/Endpoints/CapabilitiesEndpoints.cs +++ b/src/WiSave.Portal.WebApi/Endpoints/CapabilitiesEndpoints.cs @@ -1,5 +1,5 @@ using Microsoft.Extensions.Options; -using WiSave.Portal.Gateway; +using WiSave.Portal.Core.Abstractions.Gateway; namespace WiSave.Portal.Endpoints; diff --git a/src/WiSave.Portal/Filters/AntiforgeryValidationFilter.cs b/src/WiSave.Portal.WebApi/Filters/AntiforgeryValidationFilter.cs similarity index 100% rename from src/WiSave.Portal/Filters/AntiforgeryValidationFilter.cs rename to src/WiSave.Portal.WebApi/Filters/AntiforgeryValidationFilter.cs diff --git a/src/WiSave.Portal.WebApi/Gateway/DownstreamProxyErrorMiddleware.cs b/src/WiSave.Portal.WebApi/Gateway/DownstreamProxyErrorMiddleware.cs new file mode 100644 index 0000000..e5b4eec --- /dev/null +++ b/src/WiSave.Portal.WebApi/Gateway/DownstreamProxyErrorMiddleware.cs @@ -0,0 +1,107 @@ +using Yarp.ReverseProxy.Forwarder; + +namespace WiSave.Portal.Gateway; + +public sealed class DownstreamProxyErrorMiddleware( + RequestDelegate next, + ILogger logger) +{ + public async Task InvokeAsync(HttpContext context) + { + var serviceName = GetServiceName(context); + + await next(context); + + var errorFeature = context.GetForwarderErrorFeature(); + if (errorFeature?.Error is ForwarderError.RequestTimedOut) + { + logger.LogWarning( + "Downstream service {DownstreamService} timed out for {Method} {Path}. " + + "Forwarder error: {ForwarderError}. Trace identifier: {TraceIdentifier}.", + serviceName, + context.Request.Method, + context.Request.Path, + errorFeature.Error, + context.TraceIdentifier); + + await WriteProblemAsync( + context, + serviceName, + StatusCodes.Status504GatewayTimeout, + "Downstream service timed out", + $"The '{serviceName}' service did not respond in time. Please try again later.", + "downstream_service_timeout"); + return; + } + + if (errorFeature?.Error is not (ForwarderError.Request or ForwarderError.NoAvailableDestinations)) + { + if (errorFeature is not null) + { + logger.LogError( + errorFeature.Exception, + "Unexpected forwarding failure for downstream service {DownstreamService} on {Method} {Path}. " + + "Forwarder error: {ForwarderError}. Trace identifier: {TraceIdentifier}.", + serviceName, + context.Request.Method, + context.Request.Path, + errorFeature.Error, + context.TraceIdentifier); + } + + return; + } + + logger.LogWarning( + "Downstream service {DownstreamService} is unavailable for {Method} {Path}. " + + "Forwarder error: {ForwarderError}. Trace identifier: {TraceIdentifier}.", + serviceName, + context.Request.Method, + context.Request.Path, + errorFeature.Error, + context.TraceIdentifier); + + await WriteProblemAsync( + context, + serviceName, + StatusCodes.Status503ServiceUnavailable, + "Downstream service unavailable", + $"The '{serviceName}' service is temporarily unavailable. Please try again later.", + "downstream_service_unavailable"); + } + + private static string GetServiceName(HttpContext context) + { + var route = context.GetReverseProxyFeature().Route.Config; + return route.Metadata?.TryGetValue( + DownstreamServiceAvailabilityMiddleware.MetadataKey, + out var serviceName) == true + ? serviceName + : "unknown"; + } + + private static async Task WriteProblemAsync( + HttpContext context, + string serviceName, + int statusCode, + string title, + string detail, + string code) + { + if (context.Response.HasStarted) + { + return; + } + + context.Response.Clear(); + await Results.Problem( + statusCode: statusCode, + title: title, + detail: detail, + extensions: new Dictionary + { + ["code"] = code, + ["service"] = serviceName + }).ExecuteAsync(context); + } +} diff --git a/src/WiSave.Portal/Gateway/DownstreamServiceAvailabilityMiddleware.cs b/src/WiSave.Portal.WebApi/Gateway/DownstreamServiceAvailabilityMiddleware.cs similarity index 96% rename from src/WiSave.Portal/Gateway/DownstreamServiceAvailabilityMiddleware.cs rename to src/WiSave.Portal.WebApi/Gateway/DownstreamServiceAvailabilityMiddleware.cs index ff33787..fe68adf 100644 --- a/src/WiSave.Portal/Gateway/DownstreamServiceAvailabilityMiddleware.cs +++ b/src/WiSave.Portal.WebApi/Gateway/DownstreamServiceAvailabilityMiddleware.cs @@ -1,4 +1,5 @@ using Microsoft.Extensions.Options; +using WiSave.Portal.Core.Abstractions.Gateway; using Yarp.ReverseProxy.Model; namespace WiSave.Portal.Gateway; diff --git a/src/WiSave.Portal/Gateway/Extensions.cs b/src/WiSave.Portal.WebApi/Gateway/Extensions.cs similarity index 94% rename from src/WiSave.Portal/Gateway/Extensions.cs rename to src/WiSave.Portal.WebApi/Gateway/Extensions.cs index 830556e..d931deb 100644 --- a/src/WiSave.Portal/Gateway/Extensions.cs +++ b/src/WiSave.Portal.WebApi/Gateway/Extensions.cs @@ -30,6 +30,7 @@ public static WebApplication MapPortalReverseProxy(this WebApplication app) await next(); }); proxyPipeline.UseMiddleware(); + proxyPipeline.UseMiddleware(); proxyPipeline.UseSessionAffinity(); proxyPipeline.UseLoadBalancing(); proxyPipeline.UsePassiveHealthChecks(); diff --git a/src/WiSave.Portal.WebApi/Gateway/UserHeaderTransform.cs b/src/WiSave.Portal.WebApi/Gateway/UserHeaderTransform.cs new file mode 100644 index 0000000..a6abd3b --- /dev/null +++ b/src/WiSave.Portal.WebApi/Gateway/UserHeaderTransform.cs @@ -0,0 +1,56 @@ +using System.Security.Claims; +using WiSave.Portal.Contracts.Identity; +using Yarp.ReverseProxy.Transforms; +using Yarp.ReverseProxy.Transforms.Builder; + +namespace WiSave.Portal.Gateway; + +public class UserHeaderTransformProvider : ITransformProvider +{ + public void ValidateRoute(TransformRouteValidationContext context) { } + + public void ValidateCluster(TransformClusterValidationContext context) { } + + public void Apply(TransformBuilderContext context) + { + context.AddRequestTransform(transformContext => + { + ApplyUserHeaders(transformContext.ProxyRequest, transformContext.HttpContext); + return ValueTask.CompletedTask; + }); + } + + internal static void ApplyUserHeaders(HttpRequestMessage proxyRequest, HttpContext httpContext) + { + proxyRequest.Headers.Remove(PortalHeaderNames.UserId); + proxyRequest.Headers.Remove(PortalHeaderNames.UserEmail); + proxyRequest.Headers.Remove(PortalHeaderNames.UserRoles); + proxyRequest.Headers.Remove(PortalHeaderNames.UserPermissions); + + var user = httpContext.User; + if (user.Identity?.IsAuthenticated != true) + { + return; + } + + var userId = user.FindFirstValue(ClaimTypes.NameIdentifier); + if (userId is null) + { + return; + } + + var forwardedContext = new ForwardedUserContext( + userId, + user.FindFirstValue(ClaimTypes.Email), + httpContext.Items["UserPermissions"] as IReadOnlySet + ?? new HashSet(StringComparer.OrdinalIgnoreCase), + user.FindAll(ClaimTypes.Role) + .Select(static claim => claim.Value) + .ToHashSet(StringComparer.OrdinalIgnoreCase)); + + foreach (var header in ForwardedUserContextWriter.Write(forwardedContext)) + { + proxyRequest.Headers.TryAddWithoutValidation(header.Key, header.Value); + } + } +} diff --git a/src/WiSave.Portal/Gateway/YarpConfiguration.cs b/src/WiSave.Portal.WebApi/Gateway/YarpConfiguration.cs similarity index 71% rename from src/WiSave.Portal/Gateway/YarpConfiguration.cs rename to src/WiSave.Portal.WebApi/Gateway/YarpConfiguration.cs index 3d1c450..25549e3 100644 --- a/src/WiSave.Portal/Gateway/YarpConfiguration.cs +++ b/src/WiSave.Portal.WebApi/Gateway/YarpConfiguration.cs @@ -1,9 +1,16 @@ +using WiSave.Portal.Core.Abstractions.Gateway; + namespace WiSave.Portal.Gateway; public static class YarpConfiguration { public static IServiceCollection AddPortalGateway(this IServiceCollection services, IConfiguration configuration) { + services.AddLogging(logging => + logging.AddFilter( + "Yarp.ReverseProxy.Forwarder.HttpForwarder", + LogLevel.Error)); + services.Configure( configuration.GetSection(DownstreamServicesOptions.SectionName)); diff --git a/src/WiSave.Portal/Hubs/Extensions.cs b/src/WiSave.Portal.WebApi/Hubs/Extensions.cs similarity index 87% rename from src/WiSave.Portal/Hubs/Extensions.cs rename to src/WiSave.Portal.WebApi/Hubs/Extensions.cs index bd6f47d..a51c4b4 100644 --- a/src/WiSave.Portal/Hubs/Extensions.cs +++ b/src/WiSave.Portal.WebApi/Hubs/Extensions.cs @@ -1,5 +1,6 @@ using Microsoft.Extensions.Configuration; using StackExchange.Redis; +using WiSave.Portal.Core.Abstractions.Realtime; namespace WiSave.Portal.Hubs; @@ -9,6 +10,8 @@ public static IServiceCollection AddPortalSignalR(this IServiceCollection servic { var builder = services.AddSignalR(); + services.AddSingleton(); + var redisConnection = configuration["Redis:ConnectionString"]; if (!string.IsNullOrWhiteSpace(redisConnection)) { diff --git a/src/WiSave.Portal/Hubs/NotificationsHub.cs b/src/WiSave.Portal.WebApi/Hubs/NotificationsHub.cs similarity index 100% rename from src/WiSave.Portal/Hubs/NotificationsHub.cs rename to src/WiSave.Portal.WebApi/Hubs/NotificationsHub.cs diff --git a/src/WiSave.Portal.WebApi/Hubs/SignalRRealtimeNotifier.cs b/src/WiSave.Portal.WebApi/Hubs/SignalRRealtimeNotifier.cs new file mode 100644 index 0000000..c95a30c --- /dev/null +++ b/src/WiSave.Portal.WebApi/Hubs/SignalRRealtimeNotifier.cs @@ -0,0 +1,19 @@ +using Microsoft.AspNetCore.SignalR; +using WiSave.Portal.Core.Abstractions.Realtime; + +namespace WiSave.Portal.Hubs; + +/// +/// Delivers realtime envelopes over the SignalR notifications hub. Connections join a +/// group named after the user id on connect, so the group name is the user id. +/// +public sealed class SignalRRealtimeNotifier(IHubContext hub) : IRealtimeNotifier +{ + public const string ClientMethod = "realtimeEvent"; + + public Task NotifyUserAsync( + string userId, + RealtimeEnvelope envelope, + CancellationToken cancellationToken = default) => + hub.Clients.Group(userId).SendAsync(ClientMethod, envelope, cancellationToken); +} diff --git a/src/WiSave.Portal/Infrastructure/Extensions.cs b/src/WiSave.Portal.WebApi/Infrastructure/Extensions.cs similarity index 63% rename from src/WiSave.Portal/Infrastructure/Extensions.cs rename to src/WiSave.Portal.WebApi/Infrastructure/Extensions.cs index c00cef2..0f35d12 100644 --- a/src/WiSave.Portal/Infrastructure/Extensions.cs +++ b/src/WiSave.Portal.WebApi/Infrastructure/Extensions.cs @@ -1,11 +1,39 @@ using Microsoft.AspNetCore.HttpOverrides; +using Microsoft.AspNetCore.Diagnostics.HealthChecks; using Scalar.AspNetCore; -using WiSave.Portal.Migrations; +using WiSave.Portal.Core.Infrastructure.HealthChecks; namespace WiSave.Portal.Infrastructure; public static class Extensions { + /// + /// Maps readiness at /health and liveness at /alive. + /// + /// + /// They answer different questions. /alive runs no checks: it says the process + /// is up, so restarting it would help nothing. /health runs the + /// checks, so it can + /// report that the process is fine but Postgres or Redis is not — traffic should go + /// elsewhere, not to a restart. Both are anonymous so an orchestrator can reach them + /// before anyone has authenticated. + /// + public static WebApplication MapPortalHealthChecks(this WebApplication app) + { + app.MapHealthChecks("/health", new HealthCheckOptions + { + Predicate = registration => + registration.Tags.Contains(PortalHealthChecks.ReadyTag) + }).AllowAnonymous(); + + app.MapHealthChecks("/alive", new HealthCheckOptions + { + Predicate = _ => false + }).AllowAnonymous(); + + return app; + } + public static IServiceCollection AddPortalOpenApi(this IServiceCollection services) { services.AddOpenApi(); @@ -38,20 +66,6 @@ public static IServiceCollection AddPortalCors(this IServiceCollection services, return services; } - public static WebApplication ApplyPortalMigrations(this WebApplication app) - { - var autoApplyMigrations = app.Configuration.GetValue("Migrations:AutoApplyOnStartup"); - var useInMemoryDatabase = app.Configuration.GetValue("UseInMemoryDatabase"); - var connectionString = app.Configuration.GetConnectionString("Portal"); - - if (autoApplyMigrations && !useInMemoryDatabase && !string.IsNullOrWhiteSpace(connectionString)) - { - DbMigrator.Run(connectionString); - } - - return app; - } - public static WebApplication MapPortalApiDocs(this WebApplication app) { if (!app.Environment.IsDevelopment()) diff --git a/src/WiSave.Portal/Program.cs b/src/WiSave.Portal.WebApi/Program.cs similarity index 75% rename from src/WiSave.Portal/Program.cs rename to src/WiSave.Portal.WebApi/Program.cs index dd8218f..5b371f1 100644 --- a/src/WiSave.Portal/Program.cs +++ b/src/WiSave.Portal.WebApi/Program.cs @@ -1,14 +1,18 @@ using WiSave.Portal.Auth; +using WiSave.Portal.Core.Infrastructure.HealthChecks; +using WiSave.Portal.Core.Infrastructure.Identity; using WiSave.Portal.Authorization; using WiSave.Portal.Endpoints; using WiSave.Portal.Gateway; using WiSave.Portal.Hubs; using WiSave.Portal.Infrastructure; -using WiSave.Portal.Messaging; -using WiSave.Portal.Session; +using WiSave.Portal.Core.Infrastructure.Messaging; +using WiSave.Portal.Core.Infrastructure.Observability; +using WiSave.Portal.Core.Infrastructure.Session; var builder = WebApplication.CreateBuilder(args); +builder.AddPortalObservability(); builder.AddPortalMessaging(); builder.Services.AddPortalIdentity(builder.Configuration, builder.Environment); @@ -19,6 +23,7 @@ builder.Services.AddPortalGateway(builder.Configuration); builder.Services.AddPortalSignalR(builder.Configuration); builder.Services.AddPortalOpenApi(); +builder.Services.AddPortalHealthChecks(builder.Configuration); var corsOrigins = builder.Configuration.GetCorsOrigins(); @@ -27,6 +32,7 @@ var app = builder.Build(); app.MapPortalApiDocs(); +app.MapPortalHealthChecks(); app.UsePortalCors(corsOrigins); app.UsePortalForwarding(); app.UseRateLimiter(); @@ -41,5 +47,3 @@ app.MapPortalReverseProxy(); app.Run(); - -public partial class Program { } diff --git a/src/WiSave.Portal.WebApi/Properties/AssemblyInfo.cs b/src/WiSave.Portal.WebApi/Properties/AssemblyInfo.cs new file mode 100644 index 0000000..f7c4002 --- /dev/null +++ b/src/WiSave.Portal.WebApi/Properties/AssemblyInfo.cs @@ -0,0 +1,3 @@ +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("WiSave.Portal.UnitTests")] diff --git a/src/WiSave.Portal/Properties/launchSettings.json b/src/WiSave.Portal.WebApi/Properties/launchSettings.json similarity index 100% rename from src/WiSave.Portal/Properties/launchSettings.json rename to src/WiSave.Portal.WebApi/Properties/launchSettings.json diff --git a/src/WiSave.Portal.WebApi/WiSave.Portal.WebApi.csproj b/src/WiSave.Portal.WebApi/WiSave.Portal.WebApi.csproj new file mode 100644 index 0000000..dcb0c3e --- /dev/null +++ b/src/WiSave.Portal.WebApi/WiSave.Portal.WebApi.csproj @@ -0,0 +1,34 @@ + + + net10.0 + WiSave.Portal + enable + enable + wisave-portal + latest + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/WiSave.Portal/appsettings.Development.json b/src/WiSave.Portal.WebApi/appsettings.Development.json similarity index 91% rename from src/WiSave.Portal/appsettings.Development.json rename to src/WiSave.Portal.WebApi/appsettings.Development.json index 418df81..e7c0d7e 100644 --- a/src/WiSave.Portal/appsettings.Development.json +++ b/src/WiSave.Portal.WebApi/appsettings.Development.json @@ -7,7 +7,7 @@ }, "DownstreamServices": { "Incomes": { - "Enabled": false + "Enabled": true }, "Stocks": { "Enabled": true @@ -19,9 +19,6 @@ "ConnectionStrings": { "Portal": "Host=postgres;Database=wisave_portal;Username=wisave;Password=wisave_dev" }, - "Migrations": { - "AutoApplyOnStartup": true - }, "RabbitMq": { "Host": "localhost", "VirtualHost": "portal", diff --git a/src/WiSave.Portal/appsettings.json b/src/WiSave.Portal.WebApi/appsettings.json similarity index 93% rename from src/WiSave.Portal/appsettings.json rename to src/WiSave.Portal.WebApi/appsettings.json index f0ea013..ce79380 100644 --- a/src/WiSave.Portal/appsettings.json +++ b/src/WiSave.Portal.WebApi/appsettings.json @@ -5,9 +5,6 @@ "Microsoft.AspNetCore": "Warning" } }, - "Migrations": { - "AutoApplyOnStartup": false - }, "RabbitMq": { "Host": "localhost", "VirtualHost": "portal", @@ -30,10 +27,10 @@ "Enabled": true }, "Stocks": { - "Enabled": true + "Enabled": false }, "Expenses": { - "Enabled": true + "Enabled": false } }, "AllowedHosts": "*", @@ -83,14 +80,14 @@ "incomes-cluster": { "Destinations": { "destination1": { - "Address": "http://localhost:5114" + "Address": "http://localhost:5300" } } }, "stocks-cluster": { "Destinations": { "destination1": { - "Address": "http://localhost:5300" + "Address": "http://localhost:5301" } } }, diff --git a/src/WiSave.Portal/Auth/Extensions.cs b/src/WiSave.Portal/Auth/Extensions.cs deleted file mode 100644 index 04847a0..0000000 --- a/src/WiSave.Portal/Auth/Extensions.cs +++ /dev/null @@ -1,104 +0,0 @@ -using Microsoft.AspNetCore.Identity; -using Microsoft.AspNetCore.RateLimiting; -using Microsoft.EntityFrameworkCore; -using WiSave.Portal.Auth.Models; -using WiSave.Portal.Infrastructure.Database; - -namespace WiSave.Portal.Auth; - -public static class Extensions -{ - public static IServiceCollection AddPortalIdentity(this IServiceCollection services, IConfiguration configuration, IHostEnvironment environment) - { - var useInMemory = configuration.GetValue("UseInMemoryDatabase"); - if (useInMemory) - { - var dbName = configuration["InMemoryDatabaseName"] ?? "WiSave_Test"; - services.AddDbContext(options => options.UseInMemoryDatabase(dbName)); - } - else - { - services.AddDbContext(options => - options.UseNpgsql(configuration.GetConnectionString("Portal"))); - } - - services.AddIdentity(options => - { - options.User.RequireUniqueEmail = true; - options.Password.RequiredLength = 8; - options.SignIn.RequireConfirmedAccount = false; - options.Lockout.MaxFailedAccessAttempts = 5; - options.Lockout.DefaultLockoutTimeSpan = TimeSpan.FromMinutes(15); - }) - .AddEntityFrameworkStores() - .AddDefaultTokenProviders(); - - services.ConfigureApplicationCookie(options => - { - options.Cookie.Name = "WiSave.Session"; - options.Cookie.HttpOnly = true; - options.Cookie.SameSite = SameSiteMode.Lax; - options.Cookie.SecurePolicy = environment.IsDevelopment() - ? CookieSecurePolicy.SameAsRequest - : CookieSecurePolicy.Always; - options.ExpireTimeSpan = TimeSpan.FromDays(14); - options.SlidingExpiration = true; - - options.Events.OnRedirectToLogin = context => - { - context.Response.StatusCode = StatusCodes.Status401Unauthorized; - return Task.CompletedTask; - }; - options.Events.OnRedirectToAccessDenied = context => - { - context.Response.StatusCode = StatusCodes.Status403Forbidden; - return Task.CompletedTask; - }; - }); - - return services; - } - - public static IServiceCollection AddPortalAntiforgery(this IServiceCollection services, IHostEnvironment environment) - { - services.AddAntiforgery(options => - { - options.HeaderName = "X-XSRF-TOKEN"; - // The system's own cookie stores the cookie token (HttpOnly). - // A separate XSRF-TOKEN cookie with the request token is set manually - // after GetAndStoreTokens() for Angular's XSRF interceptor to read. - options.Cookie.Name = ".AspNetCore.Antiforgery"; - options.Cookie.HttpOnly = true; - options.Cookie.SameSite = SameSiteMode.Lax; - options.Cookie.SecurePolicy = environment.IsDevelopment() - ? CookieSecurePolicy.SameAsRequest - : CookieSecurePolicy.Always; - }); - - return services; - } - - public static IServiceCollection AddPortalAuthRateLimiting(this IServiceCollection services) - { - services.AddRateLimiter(options => - { - options.RejectionStatusCode = StatusCodes.Status429TooManyRequests; - - options.AddFixedWindowLimiter("auth-login", opt => - { - opt.PermitLimit = 10; - opt.Window = TimeSpan.FromMinutes(1); - opt.QueueLimit = 0; - }); - - options.AddFixedWindowLimiter("auth-register", opt => - { - opt.PermitLimit = 5; - opt.Window = TimeSpan.FromMinutes(1); - opt.QueueLimit = 0; - }); - }); - - return services; - } -} diff --git a/src/WiSave.Portal/Auth/Models/ApplicationUser.cs b/src/WiSave.Portal/Auth/Models/ApplicationUser.cs deleted file mode 100644 index 3bfd689..0000000 --- a/src/WiSave.Portal/Auth/Models/ApplicationUser.cs +++ /dev/null @@ -1,8 +0,0 @@ -using Microsoft.AspNetCore.Identity; - -namespace WiSave.Portal.Auth.Models; - -public class ApplicationUser : IdentityUser -{ - public required string Name { get; set; } -} diff --git a/src/WiSave.Portal/Authorization/PermissionResolutionMiddleware.cs b/src/WiSave.Portal/Authorization/PermissionResolutionMiddleware.cs deleted file mode 100644 index 2bdc268..0000000 --- a/src/WiSave.Portal/Authorization/PermissionResolutionMiddleware.cs +++ /dev/null @@ -1,38 +0,0 @@ -using System.Security.Claims; -using Microsoft.AspNetCore.Identity; -using WiSave.Portal.Auth.Models; - -namespace WiSave.Portal.Authorization; - -public class PermissionResolutionMiddleware(RequestDelegate next) -{ - public async Task InvokeAsync( - HttpContext context, - UserManager userManager, - RolePermissionResolver rolePermissionResolver) - { - if (context.User.Identity?.IsAuthenticated != true) - { - await next(context); - return; - } - - var userId = context.User.FindFirstValue(ClaimTypes.NameIdentifier); - if (userId is null) - { - await next(context); - return; - } - - var user = await userManager.FindByIdAsync(userId); - if (user is null) - { - await next(context); - return; - } - - context.Items["UserPermissions"] = await rolePermissionResolver.GetPermissionsAsync(user); - - await next(context); - } -} diff --git a/src/WiSave.Portal/Authorization/RolePermissionResolver.cs b/src/WiSave.Portal/Authorization/RolePermissionResolver.cs deleted file mode 100644 index c9e9986..0000000 --- a/src/WiSave.Portal/Authorization/RolePermissionResolver.cs +++ /dev/null @@ -1,33 +0,0 @@ -using Microsoft.AspNetCore.Identity; -using WiSave.Portal.Auth.Models; - -namespace WiSave.Portal.Authorization; - -public class RolePermissionResolver( - UserManager userManager, - RoleManager roleManager) -{ - public async Task> GetPermissionsAsync(ApplicationUser user) - { - var roles = await userManager.GetRolesAsync(user); - if (roles.Any(role => PortalRoles.AdminRoles.Contains(role, StringComparer.OrdinalIgnoreCase))) - return new HashSet { "*" }; - - var permissions = new HashSet(StringComparer.OrdinalIgnoreCase); - foreach (var roleName in roles) - { - var role = await roleManager.FindByNameAsync(roleName); - if (role is null) - continue; - - var claims = await roleManager.GetClaimsAsync(role); - foreach (var claim in claims.Where(c => - c.Type == PortalClaimTypes.Permission && !string.IsNullOrWhiteSpace(c.Value))) - { - permissions.Add(claim.Value); - } - } - - return permissions; - } -} diff --git a/src/WiSave.Portal/Dockerfile b/src/WiSave.Portal/Dockerfile deleted file mode 100644 index 44257ae..0000000 --- a/src/WiSave.Portal/Dockerfile +++ /dev/null @@ -1,42 +0,0 @@ -## syntax=docker/dockerfile:1.7 -FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build -WORKDIR /src - -ARG GITHUB_PACKAGES_USERNAME=JacobChwastek -ARG BUILD_CONFIGURATION=Debug - -COPY NuGet.Config ./ -COPY Directory.Packages.props ./ -COPY src/WiSave.Portal/WiSave.Portal.csproj src/WiSave.Portal/ -COPY src/WiSave.Portal.Contracts/WiSave.Portal.Contracts.csproj src/WiSave.Portal.Contracts/ -COPY src/WiSave.Portal.Migrations/WiSave.Portal.Migrations.csproj src/WiSave.Portal.Migrations/ - -RUN --mount=type=bind,from=wisave_expenses_contracts_package,source=.,target=/host-wisave-expenses-contracts,readonly \ - mkdir -p /root/.nuget/packages/wisave.expenses.contracts && \ - cp -a /host-wisave-expenses-contracts/. /root/.nuget/packages/wisave.expenses.contracts/ - -RUN --mount=type=bind,from=wisave_incomes_contracts_package,source=.,target=/host-wisave-incomes-contracts,readonly \ - mkdir -p /root/.nuget/packages/wisave.incomes.contracts && \ - cp -a /host-wisave-incomes-contracts/. /root/.nuget/packages/wisave.incomes.contracts/ - -RUN --mount=type=secret,id=github_packages_token,required=false \ - if [ -s /run/secrets/github_packages_token ]; then \ - dotnet nuget update source wisave \ - --username "${GITHUB_PACKAGES_USERNAME}" \ - --password "$(cat /run/secrets/github_packages_token)" \ - --store-password-in-clear-text \ - --configfile NuGet.Config; \ - fi && \ - dotnet restore src/WiSave.Portal/WiSave.Portal.csproj --configfile NuGet.Config - -COPY src/ src/ -RUN dotnet publish src/WiSave.Portal/WiSave.Portal.csproj -c "${BUILD_CONFIGURATION}" -o /app/publish --no-restore - -FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS runtime -WORKDIR /app -COPY --from=build /app/publish . - -EXPOSE 8080 -ENV ASPNETCORE_URLS=http://+:8080 - -ENTRYPOINT ["dotnet", "WiSave.Portal.dll"] diff --git a/src/WiSave.Portal/EventHandlers/IncomesNotificationsEventHandler.cs b/src/WiSave.Portal/EventHandlers/IncomesNotificationsEventHandler.cs deleted file mode 100644 index 951b077..0000000 --- a/src/WiSave.Portal/EventHandlers/IncomesNotificationsEventHandler.cs +++ /dev/null @@ -1,102 +0,0 @@ -using Microsoft.AspNetCore.SignalR; -using WiSave.Expenses.Contracts.Events; -using WiSave.Incomes.Contracts.Events; -using WiSave.Portal.Hubs; -using WiSave.Portal.Hubs.Realtime; - -namespace WiSave.Portal.EventHandlers; - -public class IncomesNotificationsEventHandler(IHubContext hub) -{ - public Task Handle(ExpenseCreated message, CancellationToken cancellationToken = default) - { - return Push( - RealtimeEventType.ExpenseCreated, - message.UserId.ToString(), - message.Id.Value.ToString(), - message, - cancellationToken); - } - - public Task Handle(IncomeCreated message, CancellationToken cancellationToken = default) - { - return PushIncome(RealtimeEventType.IncomeCreated, message.UserId, message.Id.Value, message, cancellationToken); - } - - public Task Handle(IncomeUpdated message, CancellationToken cancellationToken = default) - { - return PushIncome(RealtimeEventType.IncomeUpdated, message.UserId, message.Id.Value, message, cancellationToken); - } - - public Task Handle(IncomeDeleted message, CancellationToken cancellationToken = default) - { - return PushIncome(RealtimeEventType.IncomeDeleted, message.UserId, message.Id.Value, message, cancellationToken); - } - - public Task Handle(CategoryCreated message, CancellationToken cancellationToken = default) - { - return PushIncome(RealtimeEventType.CategoryCreated, message.UserId, message.Id, message, cancellationToken); - } - - public Task Handle(CategoryUpdated message, CancellationToken cancellationToken = default) - { - return PushIncome(RealtimeEventType.CategoryUpdated, message.UserId, message.Id, message, cancellationToken); - } - - public Task Handle(CategoryDeleted message, CancellationToken cancellationToken = default) - { - return PushIncome(RealtimeEventType.CategoryDeleted, message.UserId, message.Id, message, cancellationToken); - } - - public Task Handle(SubcategoryCreated message, CancellationToken cancellationToken = default) - { - return PushIncome(RealtimeEventType.SubcategoryCreated, message.UserId, message.Id, message, cancellationToken); - } - - public Task Handle(SubcategoryUpdated message, CancellationToken cancellationToken = default) - { - return PushIncome(RealtimeEventType.SubcategoryUpdated, message.UserId, message.Id, message, cancellationToken); - } - - public Task Handle(SubcategoryDeleted message, CancellationToken cancellationToken = default) - { - return PushIncome(RealtimeEventType.SubcategoryDeleted, message.UserId, message.Id, message, cancellationToken); - } - - private Task PushIncome( - string eventType, - Guid userId, - Guid entityId, - object payload, - CancellationToken cancellationToken) - { - return Push( - eventType, - userId.ToString(), - entityId.ToString(), - payload, - cancellationToken, - domain: "incomes"); - } - - private Task Push( - string eventType, - string? userId, - string? entityId, - object payload, - CancellationToken cancellationToken, - string domain = "expenses") - { - if (string.IsNullOrWhiteSpace(userId)) - return Task.CompletedTask; - - var env = new RealtimeEnvelope( - EventId: Guid.CreateVersion7(), - Domain: domain, - EventType: eventType, - OccurredAt: DateTime.UtcNow, - EntityId: entityId, - Payload: payload); - return hub.Clients.Group(userId).SendAsync("realtimeEvent", env, cancellationToken); - } -} diff --git a/src/WiSave.Portal/EventHandlers/StockNotificationsEventHandler.cs b/src/WiSave.Portal/EventHandlers/StockNotificationsEventHandler.cs deleted file mode 100644 index 3875a3b..0000000 --- a/src/WiSave.Portal/EventHandlers/StockNotificationsEventHandler.cs +++ /dev/null @@ -1,47 +0,0 @@ -using Microsoft.AspNetCore.SignalR; -using WiSave.Portal.Hubs; -using WiSave.Portal.Hubs.Realtime; -using WiSave.Stock.Contracts.Events.Portfolios; -using WiSave.Stock.Contracts.Events.Positions; - -namespace WiSave.Portal.EventHandlers; - -public class StockNotificationsEventHandler(IHubContext hub) -{ - public Task Handle(PositionOpened message, CancellationToken cancellationToken = default) => - Push(RealtimeEventType.PositionOpened, message.UserId, message.PositionId, message, cancellationToken); - - public Task Handle(PositionBuyOrderPlaced message, CancellationToken cancellationToken = default) => - Push(RealtimeEventType.PositionBuyOrderPlaced, message.UserId, message.PositionId, message, cancellationToken); - - public Task Handle(PositionSellOrderPlaced message, CancellationToken cancellationToken = default) => - Push(RealtimeEventType.PositionSellOrderPlaced, message.UserId, message.PositionId, message, cancellationToken); - - public Task Handle(PositionClosed message, CancellationToken cancellationToken = default) => - Push(RealtimeEventType.PositionClosed, message.UserId, message.PositionId, message, cancellationToken); - - public Task Handle(PositionReopened message, CancellationToken cancellationToken = default) => - Push(RealtimeEventType.PositionReopened, message.UserId, message.PositionId, message, cancellationToken); - - public Task Handle(PortfolioCreated message, CancellationToken cancellationToken = default) => - Push(RealtimeEventType.PortfolioCreated, message.UserId, message.PortfolioId, message, cancellationToken); - - private Task Push(string eventType, Guid userId, Guid entityId, object payload, CancellationToken cancellationToken) => - Push(eventType, userId == Guid.Empty ? null : userId.ToString(), entityId.ToString(), payload, cancellationToken); - - private Task Push(string eventType, string? userId, string? entityId, object payload, CancellationToken cancellationToken) - { - if (string.IsNullOrWhiteSpace(userId)) - return Task.CompletedTask; - - var env = new RealtimeEnvelope( - EventId: Guid.CreateVersion7(), - Domain: "stocks", - EventType: eventType, - OccurredAt: DateTime.UtcNow, - EntityId: entityId, - Payload: payload); - - return hub.Clients.Group(userId).SendAsync("realtimeEvent", env, cancellationToken); - } -} diff --git a/src/WiSave.Portal/Gateway/UserHeaderTransform.cs b/src/WiSave.Portal/Gateway/UserHeaderTransform.cs deleted file mode 100644 index 13a2828..0000000 --- a/src/WiSave.Portal/Gateway/UserHeaderTransform.cs +++ /dev/null @@ -1,49 +0,0 @@ -using System.Security.Claims; -using WiSave.Portal.Contracts.Identity; -using Yarp.ReverseProxy.Transforms; -using Yarp.ReverseProxy.Transforms.Builder; - -namespace WiSave.Portal.Gateway; - -public class UserHeaderTransformProvider : ITransformProvider -{ - public void ValidateRoute(TransformRouteValidationContext context) { } - - public void ValidateCluster(TransformClusterValidationContext context) { } - - public void Apply(TransformBuilderContext context) - { - context.AddRequestTransform(transformContext => - { - transformContext.ProxyRequest.Headers.Remove(PortalHeaderNames.UserId); - transformContext.ProxyRequest.Headers.Remove(PortalHeaderNames.UserEmail); - transformContext.ProxyRequest.Headers.Remove(PortalHeaderNames.UserRoles); - transformContext.ProxyRequest.Headers.Remove(PortalHeaderNames.UserPermissions); - - var user = transformContext.HttpContext.User; - - if (user.Identity?.IsAuthenticated == true) - { - var userId = user.FindFirstValue(ClaimTypes.NameIdentifier); - if (userId is not null) - { - var forwardedContext = new ForwardedUserContext( - userId, - user.FindFirstValue(ClaimTypes.Email), - transformContext.HttpContext.Items["UserPermissions"] as IReadOnlySet - ?? new HashSet(StringComparer.OrdinalIgnoreCase), - user.FindAll(ClaimTypes.Role) - .Select(static claim => claim.Value) - .ToHashSet(StringComparer.OrdinalIgnoreCase)); - - foreach (var header in ForwardedUserContextWriter.Write(forwardedContext)) - { - transformContext.ProxyRequest.Headers.TryAddWithoutValidation(header.Key, header.Value); - } - } - } - - return ValueTask.CompletedTask; - }); - } -} diff --git a/src/WiSave.Portal/Hubs/Realtime/ExpensesAccountPayloads.cs b/src/WiSave.Portal/Hubs/Realtime/ExpensesAccountPayloads.cs deleted file mode 100644 index 3fca1ad..0000000 --- a/src/WiSave.Portal/Hubs/Realtime/ExpensesAccountPayloads.cs +++ /dev/null @@ -1,22 +0,0 @@ -namespace WiSave.Portal.Hubs.Realtime; - -public sealed record FundingAccountPayload( - string FundingAccountId, - string UserId, - string Name, - string Kind, - string Currency, - decimal Balance, - string? Color, - DateTimeOffset Timestamp); - -public sealed record FundingPaymentInstrumentPayload( - string PaymentInstrumentId, - string FundingAccountId, - string UserId, - string Name, - string Kind, - string? LastFourDigits, - string? Network, - string? Color, - DateTimeOffset Timestamp); diff --git a/src/WiSave.Portal/Session/PortalSessionOptions.cs b/src/WiSave.Portal/Session/PortalSessionOptions.cs deleted file mode 100644 index b2153a5..0000000 --- a/src/WiSave.Portal/Session/PortalSessionOptions.cs +++ /dev/null @@ -1,6 +0,0 @@ -namespace WiSave.Portal.Session; - -public sealed class PortalSessionOptions -{ - public bool AllowInMemoryTicketStoreFallback { get; set; } -} diff --git a/tests/WiSave.Portal.IntegrationTests/Auth/AdminAccessManagementEndpointsTests.cs b/tests/WiSave.Portal.IntegrationTests/Auth/AdminAccessManagementEndpointsTests.cs deleted file mode 100644 index 66edf1c..0000000 --- a/tests/WiSave.Portal.IntegrationTests/Auth/AdminAccessManagementEndpointsTests.cs +++ /dev/null @@ -1,433 +0,0 @@ -using System.Net; -using System.Net.Http.Json; -using System.Security.Claims; -using Microsoft.AspNetCore.Identity; -using Microsoft.AspNetCore.Mvc.Testing; -using Microsoft.Extensions.DependencyInjection; -using WiSave.Portal.Auth.Models; -using WiSave.Portal.Authorization; -using WiSave.Portal.Contracts.Authorization; -using Xunit; - -namespace WiSave.Portal.IntegrationTests.Auth; - -public class AdminAccessManagementEndpointsTests : IClassFixture>, IAsyncLifetime -{ - private readonly WebApplicationFactory _factory; - private static CancellationToken CancellationToken => TestContext.Current.CancellationToken; - - public AdminAccessManagementEndpointsTests(WebApplicationFactory factory) - { - _factory = factory.WithWebHostBuilder(builder => - { - builder.UseSetting("UseInMemoryDatabase", "true"); - builder.UseSetting("InMemoryDatabaseName", "AdminAccessTests_" + Guid.NewGuid()); - builder.UseSetting("Redis:ConnectionString", ""); - }); - } - - public async ValueTask InitializeAsync() - { - await SeedIdentityDataAsync(_factory); - } - - public ValueTask DisposeAsync() => ValueTask.CompletedTask; - - [Fact] - public async Task AccessManagement_NormalUser_ReturnsForbidden() - { - var client = CreateClient(); - await RegisterAsync(client, new RegisterRequest("Normal User", "normal@example.com", "Password123!", "free")); - - var response = await client.GetAsync("/api/admin/access-management", CancellationToken); - - Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode); - } - - [Fact] - public async Task AccessManagement_Admin_ReturnsRolesUsersAndCapabilities() - { - var client = CreateClient(); - await RegisterAsync(client, new RegisterRequest("Admin User", "admin@example.com", "Password123!", "free")); - await AddUserToRoleAsync("admin@example.com", PortalRoles.Admin); - - await CreateUserAsync("Target User", "target@example.com", PortalRoles.StandardPlan); - - var response = await client.GetAsync("/api/admin/access-management", CancellationToken); - - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - var access = await response.Content.ReadFromJsonAsync(CancellationToken); - Assert.NotNull(access); - Assert.False(access.CanManagePrivilegedRoles); - Assert.Contains(PortalPermissions.Stocks.Read, access.AvailablePermissions); - Assert.Contains(access.Roles, role => role.Name == PortalRoles.Admin); - Assert.Contains(access.Roles, role => role.Permissions.Contains(PortalPermissions.Incomes.Read)); - - var targetUser = Assert.Single(access.Users, user => user.Email == "target@example.com"); - Assert.True(targetUser.CanEditRoles); - Assert.Contains(access.Roles.Single(role => role.Name == PortalRoles.StandardPlan).Id, targetUser.Roles); - Assert.Contains(PortalPermissions.Expenses.Read, targetUser.Permissions); - } - - [Fact] - public async Task UpdateUserRoles_Admin_CanUpdateNormalUserPlanRole() - { - var client = CreateClient(); - await RegisterAsync(client, new RegisterRequest("Admin User", "plan-admin@example.com", "Password123!", "free")); - await AddUserToRoleAsync("plan-admin@example.com", PortalRoles.Admin); - - var target = await CreateUserAsync("Target User", "plan-target@example.com", PortalRoles.FreePlan); - var standardRole = await FindRoleByNameAsync(PortalRoles.StandardPlan); - - var response = await PutWithAntiforgeryAsync( - client, - $"/api/admin/access-management/users/{target.Id}/roles", - new UpdateUserRolesRequest([standardRole.Id])); - - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - var updated = await response.Content.ReadFromJsonAsync(CancellationToken); - Assert.NotNull(updated); - Assert.Equal([standardRole.Id], updated.Roles); - Assert.Contains(PortalPermissions.Expenses.Read, updated.Permissions); - } - - [Fact] - public async Task UpdateUserRoles_Admin_CannotAssignPrivilegedRole() - { - var client = CreateClient(); - await RegisterAsync(client, new RegisterRequest("Admin User", "limited-admin@example.com", "Password123!", "free")); - await AddUserToRoleAsync("limited-admin@example.com", PortalRoles.Admin); - - var target = await CreateUserAsync("Target User", "limited-target@example.com", PortalRoles.FreePlan); - var adminRole = await FindRoleByNameAsync(PortalRoles.Admin); - - var response = await PutWithAntiforgeryAsync( - client, - $"/api/admin/access-management/users/{target.Id}/roles", - new UpdateUserRolesRequest([adminRole.Id])); - - Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode); - } - - [Fact] - public async Task UpdateUserRoles_Admin_CannotEditPrivilegedUsers() - { - var client = CreateClient(); - await RegisterAsync(client, new RegisterRequest("Admin User", "peer-admin@example.com", "Password123!", "free")); - await AddUserToRoleAsync("peer-admin@example.com", PortalRoles.Admin); - - var target = await CreateUserAsync("Target Admin", "target-admin@example.com", PortalRoles.FreePlan); - await AddUserToRoleAsync("target-admin@example.com", PortalRoles.Admin); - var standardRole = await FindRoleByNameAsync(PortalRoles.StandardPlan); - - var response = await PutWithAntiforgeryAsync( - client, - $"/api/admin/access-management/users/{target.Id}/roles", - new UpdateUserRolesRequest([standardRole.Id])); - - Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode); - } - - [Fact] - public async Task UpdateUserRoles_SuperAdmin_CanAssignPrivilegedRole() - { - var client = CreateClient(); - await RegisterAsync(client, new RegisterRequest("Super Admin", "super@example.com", "Password123!", "free")); - await AddUserToRoleAsync("super@example.com", PortalRoles.SuperAdmin); - - var target = await CreateUserAsync("Target User", "promote-target@example.com", PortalRoles.FreePlan); - var adminRole = await FindRoleByNameAsync(PortalRoles.Admin); - - var response = await PutWithAntiforgeryAsync( - client, - $"/api/admin/access-management/users/{target.Id}/roles", - new UpdateUserRolesRequest([adminRole.Id])); - - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - var updated = await response.Content.ReadFromJsonAsync(CancellationToken); - Assert.NotNull(updated); - Assert.Equal([adminRole.Id], updated.Roles); - Assert.Contains("*", updated.Permissions); - } - - [Fact] - public async Task UpdateUserRoles_RequiresExactlyOneRole() - { - var client = CreateClient(); - await RegisterAsync(client, new RegisterRequest("Super Admin", "plan-super@example.com", "Password123!", "free")); - await AddUserToRoleAsync("plan-super@example.com", PortalRoles.SuperAdmin); - - var target = await CreateUserAsync("Target User", "invalid-plan-target@example.com", PortalRoles.FreePlan); - var freeRole = await FindRoleByNameAsync(PortalRoles.FreePlan); - var standardRole = await FindRoleByNameAsync(PortalRoles.StandardPlan); - - var response = await PutWithAntiforgeryAsync( - client, - $"/api/admin/access-management/users/{target.Id}/roles", - new UpdateUserRolesRequest([freeRole.Id, standardRole.Id])); - - Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); - } - - [Fact] - public async Task UpdateRolePermissions_Admin_CanReplaceRolePermissionClaims() - { - var client = CreateClient(); - await RegisterAsync(client, new RegisterRequest("Admin User", "permission-admin@example.com", "Password123!", "free")); - await AddUserToRoleAsync("permission-admin@example.com", PortalRoles.Admin); - var freeRole = await FindRoleByNameAsync(PortalRoles.FreePlan); - - var response = await PutWithAntiforgeryAsync( - client, - $"/api/admin/access-management/roles/{freeRole.Id}/permissions", - new UpdateRolePermissionsRequest([PortalPermissions.Expenses.Read, PortalPermissions.Stocks.Read])); - - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - var updated = await response.Content.ReadFromJsonAsync(CancellationToken); - Assert.NotNull(updated); - Assert.Equal([PortalPermissions.Expenses.Read, PortalPermissions.Stocks.Read], updated.Permissions); - } - - [Fact] - public async Task UpdateRolePermissions_NormalUser_ReturnsForbidden() - { - var client = CreateClient(); - await RegisterAsync(client, new RegisterRequest("Normal User", "permission-normal@example.com", "Password123!", "free")); - var freeRole = await FindRoleByNameAsync(PortalRoles.FreePlan); - - var response = await PutWithAntiforgeryAsync( - client, - $"/api/admin/access-management/roles/{freeRole.Id}/permissions", - new UpdateRolePermissionsRequest([PortalPermissions.Expenses.Read])); - - Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode); - } - - [Fact] - public async Task UpdateRolePermissions_InvalidPermission_Returns400() - { - var client = CreateClient(); - await RegisterAsync(client, new RegisterRequest("Admin User", "permission-invalid@example.com", "Password123!", "free")); - await AddUserToRoleAsync("permission-invalid@example.com", PortalRoles.Admin); - var freeRole = await FindRoleByNameAsync(PortalRoles.FreePlan); - - var response = await PutWithAntiforgeryAsync( - client, - $"/api/admin/access-management/roles/{freeRole.Id}/permissions", - new UpdateRolePermissionsRequest(["nope:invalid"])); - - Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); - } - - [Fact] - public async Task CreateRole_Admin_CanCreateCustomRole() - { - var client = CreateClient(); - await RegisterAsync(client, new RegisterRequest("Admin User", "role-create@example.com", "Password123!", "free")); - await AddUserToRoleAsync("role-create@example.com", PortalRoles.Admin); - - var response = await PostWithAntiforgeryAsync( - client, - "/api/admin/access-management/roles", - new CreateRoleRequest("auditor", [PortalPermissions.Incomes.Read])); - - Assert.Equal(HttpStatusCode.Created, response.StatusCode); - var created = await response.Content.ReadFromJsonAsync(CancellationToken); - Assert.NotNull(created); - Assert.Equal("auditor", created.Name); - Assert.Equal(["incomes:read"], created.Permissions); - } - - [Theory] - [InlineData("admin")] - [InlineData("superadmin")] - [InlineData("plan:enterprise")] - public async Task CreateRole_ReservedRoleName_Returns400(string roleName) - { - var client = CreateClient(); - await RegisterAsync(client, new RegisterRequest("Admin User", $"role-reserved-{Guid.NewGuid():N}@example.com", "Password123!", "free")); - await AddUserToRoleAsync(client, PortalRoles.Admin); - - var response = await PostWithAntiforgeryAsync( - client, - "/api/admin/access-management/roles", - new CreateRoleRequest(roleName, [])); - - Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); - } - - private static async Task SeedIdentityDataAsync(WebApplicationFactory factory) - { - using var scope = factory.Services.CreateScope(); - var roleManager = scope.ServiceProvider.GetRequiredService>(); - foreach (var role in PortalRoles.AdminRoles.Concat(PortalRoles.PlanRoles)) - { - if (!await roleManager.RoleExistsAsync(role)) - await roleManager.CreateAsync(new IdentityRole(role)); - } - - await EnsurePermissionClaimAsync(roleManager, PortalRoles.FreePlan, PortalPermissions.Incomes.Read); - await EnsurePermissionClaimAsync(roleManager, PortalRoles.StandardPlan, PortalPermissions.Expenses.Read); - await EnsurePermissionClaimAsync(roleManager, PortalRoles.StandardPlan, PortalPermissions.Expenses.Write); - await EnsurePermissionClaimAsync(roleManager, PortalRoles.PremiumPlan, PortalPermissions.Expenses.Read); - await EnsurePermissionClaimAsync(roleManager, PortalRoles.PremiumPlan, PortalPermissions.Expenses.Write); - await EnsurePermissionClaimAsync(roleManager, PortalRoles.PremiumPlan, PortalPermissions.Expenses.Delete); - } - - private static async Task EnsurePermissionClaimAsync(RoleManager roleManager, string roleName, string permission) - { - var role = await roleManager.FindByNameAsync(roleName); - Assert.NotNull(role); - - var claims = await roleManager.GetClaimsAsync(role); - if (!claims.Any(c => c.Type == PortalClaimTypes.Permission && c.Value == permission)) - await roleManager.AddClaimAsync(role, new Claim(PortalClaimTypes.Permission, permission)); - } - - private HttpClient CreateClient() - { - var cookieContainer = new CookieContainer(); - var handler = new CookieDelegatingHandler(cookieContainer, _factory.Server.CreateHandler()); - return new HttpClient(handler) - { - BaseAddress = new Uri("https://localhost"), - }; - } - - private static async Task GetAntiforgeryTokenAsync(HttpClient client) - { - var response = await client.GetAsync("/api/auth/antiforgery-token", CancellationToken); - response.EnsureSuccessStatusCode(); - - var xsrfCookie = response.Headers.GetValues("Set-Cookie") - .First(c => c.StartsWith("XSRF-TOKEN=")); - return Uri.UnescapeDataString(xsrfCookie.Split('=', 2)[1].Split(';')[0]); - } - - private static async Task PutWithAntiforgeryAsync(HttpClient client, string url, T body) - { - var token = await GetAntiforgeryTokenAsync(client); - var message = new HttpRequestMessage(HttpMethod.Put, url); - message.Headers.Add("X-XSRF-TOKEN", token); - message.Content = JsonContent.Create(body); - return await client.SendAsync(message, CancellationToken); - } - - private static Task RegisterAsync(HttpClient client, RegisterRequest request) => - PostWithAntiforgeryAsync(client, "/api/auth/register", request); - - private static async Task PostWithAntiforgeryAsync(HttpClient client, string url, T body) - { - var token = await GetAntiforgeryTokenAsync(client); - var message = new HttpRequestMessage(HttpMethod.Post, url); - message.Headers.Add("X-XSRF-TOKEN", token); - message.Content = JsonContent.Create(body); - return await client.SendAsync(message, CancellationToken); - } - - private async Task CreateUserAsync(string name, string email, string planRole) - { - using var scope = _factory.Services.CreateScope(); - var userManager = scope.ServiceProvider.GetRequiredService>(); - var user = new ApplicationUser - { - Name = name, - Email = email, - UserName = email - }; - - var createResult = await userManager.CreateAsync(user, "Password123!"); - Assert.True(createResult.Succeeded); - var roleResult = await userManager.AddToRoleAsync(user, planRole); - Assert.True(roleResult.Succeeded); - return user; - } - - private async Task AddUserToRoleAsync(string email, string roleName) - { - using var scope = _factory.Services.CreateScope(); - var userManager = scope.ServiceProvider.GetRequiredService>(); - var user = await userManager.FindByEmailAsync(email); - Assert.NotNull(user); - - var result = await userManager.AddToRoleAsync(user, roleName); - Assert.True(result.Succeeded); - } - - private async Task AddUserToRoleAsync(HttpClient client, string roleName) - { - var me = await client.GetFromJsonAsync("/api/auth/me", CancellationToken); - Assert.NotNull(me); - - using var scope = _factory.Services.CreateScope(); - var userManager = scope.ServiceProvider.GetRequiredService>(); - var user = await userManager.FindByIdAsync(me.Id); - Assert.NotNull(user); - - var result = await userManager.AddToRoleAsync(user, roleName); - Assert.True(result.Succeeded); - } - - private async Task FindRoleByNameAsync(string roleName) - { - using var scope = _factory.Services.CreateScope(); - var roleManager = scope.ServiceProvider.GetRequiredService>(); - var role = await roleManager.FindByNameAsync(roleName); - Assert.NotNull(role); - return role; - } - - private sealed class CookieDelegatingHandler(CookieContainer cookieContainer, HttpMessageHandler inner) - : DelegatingHandler(inner) - { - protected override async Task SendAsync( - HttpRequestMessage request, CancellationToken cancellationToken) - { - var cookieHeader = cookieContainer.GetCookieHeader(request.RequestUri!); - if (!string.IsNullOrEmpty(cookieHeader)) - request.Headers.TryAddWithoutValidation("Cookie", cookieHeader); - - var response = await base.SendAsync(request, cancellationToken); - - if (response.Headers.TryGetValues("Set-Cookie", out var setCookieHeaders)) - { - foreach (var setCookie in setCookieHeaders) - { - try { cookieContainer.SetCookies(request.RequestUri!, setCookie); } - catch (CookieException) { /* ignore malformed cookies */ } - } - } - - return response; - } - } - - private sealed record AccessManagementResponse( - bool CanManagePrivilegedRoles, - string[] AvailablePermissions, - AccessRoleResponse[] Roles, - AccessUserResponse[] Users); - - private sealed record AccessRoleResponse( - string Id, - string Name, - string NormalizedName, - string? ConcurrencyStamp, - string[] Permissions); - - private sealed record AccessUserResponse( - string Id, - string Name, - string Email, - string[] Roles, - string[] Permissions, - bool CanEditRoles); - - private sealed record UpdateUserRolesRequest(string[] RoleIds); - - private sealed record UpdateRolePermissionsRequest(string[] Permissions); - - private sealed record CreateRoleRequest(string Name, string[] Permissions); - - private sealed record UserResponse(string Id, string Name, string Email, string[] Permissions); -} diff --git a/tests/WiSave.Portal.IntegrationTests/Auth/AuthEndpointsTests.cs b/tests/WiSave.Portal.IntegrationTests/Auth/AuthEndpointsTests.cs deleted file mode 100644 index 6162b74..0000000 --- a/tests/WiSave.Portal.IntegrationTests/Auth/AuthEndpointsTests.cs +++ /dev/null @@ -1,595 +0,0 @@ -using System.Net; -using System.Net.Http.Json; -using System.Security.Claims; -using Microsoft.AspNetCore.Identity; -using Microsoft.AspNetCore.Mvc.Testing; -using Microsoft.AspNetCore.Hosting; -using Microsoft.AspNetCore.TestHost; -using Microsoft.Extensions.DependencyInjection; -using WiSave.Portal.Auth.Models; -using WiSave.Portal.Authorization; -using WiSave.Portal.Contracts.Authorization; -using Xunit; - -namespace WiSave.Portal.IntegrationTests.Auth; - -public class AuthEndpointsTests : IClassFixture>, IAsyncLifetime -{ - private readonly WebApplicationFactory _factory; - private static CancellationToken CancellationToken => TestContext.Current.CancellationToken; - - public AuthEndpointsTests(WebApplicationFactory factory) - { - _factory = factory.WithWebHostBuilder(builder => - { - builder.UseSetting("UseInMemoryDatabase", "true"); - builder.UseSetting("InMemoryDatabaseName", "AuthTests_" + Guid.NewGuid()); - builder.UseSetting("Redis:ConnectionString", ""); - }); - } - - public async ValueTask InitializeAsync() - { - await SeedIdentityDataAsync(_factory); - } - - public ValueTask DisposeAsync() => ValueTask.CompletedTask; - - private static async Task SeedIdentityDataAsync(WebApplicationFactory factory) - { - using var scope = factory.Services.CreateScope(); - var roleManager = scope.ServiceProvider.GetRequiredService>(); - foreach (var role in PortalRoles.AdminRoles.Concat(PortalRoles.PlanRoles)) - { - if (!await roleManager.RoleExistsAsync(role)) - await roleManager.CreateAsync(new IdentityRole(role)); - } - - await EnsurePermissionClaimAsync(roleManager, PortalRoles.FreePlan, PortalPermissions.Incomes.Read); - await EnsurePermissionClaimAsync(roleManager, PortalRoles.StandardPlan, PortalPermissions.Expenses.Read); - await EnsurePermissionClaimAsync(roleManager, PortalRoles.StandardPlan, PortalPermissions.Expenses.Write); - await EnsurePermissionClaimAsync(roleManager, PortalRoles.PremiumPlan, PortalPermissions.Expenses.Read); - await EnsurePermissionClaimAsync(roleManager, PortalRoles.PremiumPlan, PortalPermissions.Expenses.Write); - await EnsurePermissionClaimAsync(roleManager, PortalRoles.PremiumPlan, PortalPermissions.Expenses.Delete); - } - - private static async Task EnsurePermissionClaimAsync(RoleManager roleManager, string roleName, string permission) - { - var role = await roleManager.FindByNameAsync(roleName); - Assert.NotNull(role); - - var claims = await roleManager.GetClaimsAsync(role); - if (!claims.Any(c => c.Type == PortalClaimTypes.Permission && c.Value == permission)) - await roleManager.AddClaimAsync(role, new Claim(PortalClaimTypes.Permission, permission)); - } - - [Theory] - [InlineData("free", PortalRoles.FreePlan)] - [InlineData("standard", PortalRoles.StandardPlan)] - [InlineData("premium", PortalRoles.PremiumPlan)] - [InlineData("plan:standard", PortalRoles.StandardPlan)] - public async Task Register_ValidPlan_AssignsExactlyOnePlanRole(string requestedPlan, string expectedRole) - { - var client = CreateClient(); - var email = $"plan-{Guid.NewGuid():N}@example.com"; - var request = new RegisterRequest("Plan User", email, "Password123!", requestedPlan); - - var response = await RegisterAsync(client, request); - - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - - var user = await FindUserByEmailAsync(email); - var roles = await GetUserRolesAsync(user); - Assert.Contains(expectedRole, roles); - Assert.Single(roles, PortalRoles.IsPlanRole); - } - - [Fact] - public async Task Register_BlankPlan_DefaultsToFreePlanRole() - { - var client = CreateClient(); - var email = $"blank-plan-{Guid.NewGuid():N}@example.com"; - var request = new RegisterRequest("Blank Plan User", email, "Password123!", ""); - - var response = await RegisterAsync(client, request); - - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - - var user = await FindUserByEmailAsync(email); - var roles = await GetUserRolesAsync(user); - Assert.Contains(PortalRoles.FreePlan, roles); - Assert.Single(roles, PortalRoles.IsPlanRole); - } - - [Fact] - public async Task Register_InvalidPlan_Returns400() - { - var client = CreateClient(); - var request = new RegisterRequest("Bad Plan User", $"bad-plan-{Guid.NewGuid():N}@example.com", "Password123!", "enterprise"); - - var response = await RegisterAsync(client, request); - - Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); - } - - [Fact] - public async Task Register_ValidData_ReturnsUserAndSetsCookie() - { - var client = CreateClient(); - var request = new RegisterRequest("Test User", "test@example.com", "Password123!", "free"); - - var response = await RegisterAsync(client, request); - - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - - var auth = await response.Content.ReadFromJsonAsync(CancellationToken); - Assert.NotNull(auth); - Assert.Equal("Test User", auth.User.Name); - Assert.Equal("test@example.com", auth.User.Email); - Assert.NotEmpty(auth.User.Id); - Assert.Contains(response.Headers.GetValues("Set-Cookie"), - c => c.Contains("WiSave.Session")); - } - - [Fact] - public async Task Register_DuplicateEmail_Returns400() - { - var client = CreateClient(); - var request = new RegisterRequest("User", "dupe@example.com", "Password123!", "free"); - - await RegisterAsync(client, request); - var response = await PostWithAntiforgeryAsync(client, "/api/auth/register", request); - - Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); - } - - [Fact] - public async Task Login_ValidCredentials_ReturnsUserAndSetsCookie() - { - var client = CreateClient(); - var register = new RegisterRequest("Login User", "login@example.com", "Password123!", "free"); - var login = new LoginRequest("login@example.com", "Password123!"); - - await RegisterAsync(client, register); - var response = await PostWithAntiforgeryAsync(client, "/api/auth/login", login); - - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - - var auth = await response.Content.ReadFromJsonAsync(CancellationToken); - Assert.NotNull(auth); - Assert.Equal("Login User", auth.User.Name); - Assert.Equal("login@example.com", auth.User.Email); - } - - [Fact] - public async Task Login_UnknownEmail_Returns401WithUserNotFoundError() - { - var client = CreateClient(); - - var response = await PostWithAntiforgeryAsync( - client, - "/api/auth/login", - new LoginRequest("missing@example.com", "Password123!")); - - Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); - - var error = await response.Content.ReadFromJsonAsync(CancellationToken); - Assert.NotNull(error); - Assert.Equal("USER_NOT_FOUND", error.Code); - Assert.Equal("No account exists for that email address.", error.Message); - } - - [Fact] - public async Task Login_InvalidPassword_Returns401WithInvalidPasswordError() - { - var client = CreateClient(); - - var register = new RegisterRequest("User", "wrong@example.com", "Password123!", "free"); - await RegisterAsync(client, register); - - var login = new LoginRequest("wrong@example.com", "WrongPassword!"); - var response = await PostWithAntiforgeryAsync(client, "/api/auth/login", login); - - Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); - - var error = await response.Content.ReadFromJsonAsync(CancellationToken); - Assert.NotNull(error); - Assert.Equal("INVALID_PASSWORD", error.Code); - Assert.Equal("The password is incorrect.", error.Message); - } - - [Fact] - public async Task Me_Authenticated_ReturnsUser() - { - var client = CreateClient(); - - var register = new RegisterRequest("Me User", "me@example.com", "Password123!", "free"); - await RegisterAsync(client, register); - var response = await client.GetAsync("/api/auth/me", CancellationToken); - - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - - var user = await response.Content.ReadFromJsonAsync(CancellationToken); - Assert.NotNull(user); - Assert.Equal("Me User", user.Name); - Assert.Equal("me@example.com", user.Email); - } - - [Fact] - public async Task Me_Authenticated_ReturnsPermissions() - { - var client = CreateClient(handleCookies: true); - await RegisterAsync(client, new RegisterRequest("Perm User", "perm@example.com", "Password123!", "free")); - - var response = await client.GetAsync("/api/auth/me", CancellationToken); - - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - var user = await response.Content.ReadFromJsonAsync(CancellationToken); - Assert.NotNull(user); - Assert.NotNull(user.Permissions); - Assert.Contains(PortalPermissions.Incomes.Read, user.Permissions); - } - - [Fact] - public async Task Me_Unauthenticated_Returns401() - { - var client = _factory.CreateClient(new WebApplicationFactoryClientOptions - { - BaseAddress = new Uri("https://localhost"), - }); - - var response = await client.GetAsync("/api/auth/me", CancellationToken); - - Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); - } - - [Fact] - public async Task Logout_ClearsSession() - { - var client = CreateClient(); - - var register = new RegisterRequest("Logout User", "logout@example.com", "Password123!", "free"); - await RegisterAsync(client, register); - - var logoutResponse = await PostWithAntiforgeryAsync(client, "/api/auth/logout", new { }); - Assert.Equal(HttpStatusCode.NoContent, logoutResponse.StatusCode); - - var meResponse = await client.GetAsync("/api/auth/me", CancellationToken); - Assert.Equal(HttpStatusCode.Unauthorized, meResponse.StatusCode); - } - - [Fact] - public async Task Logout_WithoutAntiforgeryToken_Returns400() - { - var client = CreateClient(); - await RegisterAsync(client, new RegisterRequest("Logout User", "logout-xsrf@example.com", "Password123!", "free")); - - var response = await client.PostAsJsonAsync("/api/auth/logout", new { }, CancellationToken); - - Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); - } - - [Fact] - public async Task Logout_WithAntiforgeryToken_RefreshesXsrfCookie() - { - var client = CreateClient(); - await RegisterAsync(client, new RegisterRequest("Logout User", "logout-refresh@example.com", "Password123!", "free")); - - var response = await PostWithAntiforgeryAsync(client, "/api/auth/logout", new { }); - - Assert.Equal(HttpStatusCode.NoContent, response.StatusCode); - Assert.Contains(response.Headers.GetValues("Set-Cookie"), c => c.StartsWith("XSRF-TOKEN=")); - } - - [Fact] - public async Task ChangePassword_Authenticated_ChangesPassword() - { - var client = CreateClient(); - await RegisterAsync(client, new RegisterRequest("Password User", "password-change@example.com", "Password123!", "free")); - - var response = await PostWithAntiforgeryAsync( - client, - "/api/auth/change-password", - new ChangePasswordRequest("Password123!", "NewPassword123!")); - - Assert.Equal(HttpStatusCode.NoContent, response.StatusCode); - - await PostWithAntiforgeryAsync(client, "/api/auth/logout", new { }); - var oldPasswordLogin = await PostWithAntiforgeryAsync( - client, - "/api/auth/login", - new LoginRequest("password-change@example.com", "Password123!")); - Assert.Equal(HttpStatusCode.Unauthorized, oldPasswordLogin.StatusCode); - - var newPasswordLogin = await PostWithAntiforgeryAsync( - client, - "/api/auth/login", - new LoginRequest("password-change@example.com", "NewPassword123!")); - Assert.Equal(HttpStatusCode.OK, newPasswordLogin.StatusCode); - } - - [Fact] - public async Task ChangePassword_WrongCurrentPassword_Returns400() - { - var client = CreateClient(); - await RegisterAsync(client, new RegisterRequest("Password User", "password-wrong@example.com", "Password123!", "free")); - - var response = await PostWithAntiforgeryAsync( - client, - "/api/auth/change-password", - new ChangePasswordRequest("WrongPassword123!", "NewPassword123!")); - - Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); - } - - [Fact] - public async Task ChangePassword_Unauthenticated_Returns401() - { - var client = _factory.CreateClient(new WebApplicationFactoryClientOptions - { - BaseAddress = new Uri("https://localhost"), - }); - - var response = await PostWithAntiforgeryAsync( - client, - "/api/auth/change-password", - new ChangePasswordRequest("Password123!", "NewPassword123!")); - - Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); - } - - [Fact] - public async Task ChangePassword_WithoutAntiforgeryToken_Returns400() - { - var client = CreateClient(); - await RegisterAsync(client, new RegisterRequest("Password User", "password-xsrf@example.com", "Password123!", "free")); - - var response = await client.PostAsJsonAsync( - "/api/auth/change-password", - new ChangePasswordRequest("Password123!", "NewPassword123!"), - CancellationToken); - - Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); - } - - [Fact] - public async Task Register_WithAntiforgeryTokenIssuedBeforeRestart_SucceedsWhenKeyRingIsShared() - { - var databaseName = "AuthRestart_" + Guid.NewGuid(); - var keyRingPath = Path.Combine(Path.GetTempPath(), "wisave-portal-tests", Guid.NewGuid().ToString("N")); - var sharedCookies = new CookieContainer(); - - await using var issuingFactory = CreateConfiguredFactory(databaseName, keyRingPath); - await SeedIdentityDataAsync(issuingFactory); - - var issuingClient = CreateClient(issuingFactory, sharedCookies); - var token = await GetAntiforgeryTokenAsync(issuingClient); - - await using var restartedFactory = CreateConfiguredFactory(databaseName, keyRingPath); - await SeedIdentityDataAsync(restartedFactory); - - var restartedClient = CreateClient(restartedFactory, sharedCookies); - var message = new HttpRequestMessage(HttpMethod.Post, "/api/auth/register"); - message.Headers.Add("X-XSRF-TOKEN", token); - message.Content = JsonContent.Create( - new RegisterRequest("Restart User", "restart@example.com", "Password123!", "free")); - - var response = await restartedClient.SendAsync(message, CancellationToken); - - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - } - - [Fact] - public async Task Register_SetsCookieWithSecureFlag() - { - // Tests run over HTTPS (BaseAddress = https://localhost) and with - // SameAsRequest policy, so the cookie should have the Secure flag. - var client = CreateClient(); - var request = new RegisterRequest("Secure User", "secure@example.com", "Password123!", "free"); - - var response = await RegisterAsync(client, request); - - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - var setCookie = response.Headers.GetValues("Set-Cookie").First(c => c.Contains("WiSave.Session")); - Assert.Contains("secure", setCookie, StringComparison.OrdinalIgnoreCase); - } - - [Fact] - public async Task Login_FiveFailedAttempts_LocksOutAccount() - { - var client = CreateClient(); - var register = new RegisterRequest("Lockout User", "lockout@example.com", "Password123!", "free"); - await RegisterAsync(client, register); - - var badLogin = new LoginRequest("lockout@example.com", "WrongPassword!"); - - for (var i = 0; i < 5; i++) - { - var attempt = await PostWithAntiforgeryAsync(client, "/api/auth/login", badLogin); - Assert.Equal(HttpStatusCode.Unauthorized, attempt.StatusCode); - } - - // 6th attempt should also return 401 (not 429, to avoid account-state oracle) - var lockedOut = await PostWithAntiforgeryAsync(client, "/api/auth/login", badLogin); - Assert.Equal(HttpStatusCode.Unauthorized, lockedOut.StatusCode); - - // Even correct password should return 401 while locked out - var correctLogin = new LoginRequest("lockout@example.com", "Password123!"); - var stillLocked = await PostWithAntiforgeryAsync(client, "/api/auth/login", correctLogin); - Assert.Equal(HttpStatusCode.Unauthorized, stillLocked.StatusCode); - var error = await stillLocked.Content.ReadFromJsonAsync(CancellationToken); - Assert.NotNull(error); - Assert.Equal("LOCKED_OUT", error.Code); - Assert.Equal("This account is locked out.", error.Message); - } - - [Fact] - public async Task Login_ExceedsRateLimit_Returns429() - { - var client = CreateClient(); - - HttpResponseMessage? lastResponse = null; - for (var i = 0; i < 11; i++) - { - var login = new LoginRequest($"ratelimit{i}@example.com", "Password123!"); - lastResponse = await PostWithAntiforgeryAsync(client, "/api/auth/login", login); - } - - Assert.Equal((HttpStatusCode)429, lastResponse!.StatusCode); - } - - [Fact] - public async Task Login_WithoutAntiforgeryToken_Returns400() - { - var client = CreateClient(); - var register = new RegisterRequest("Csrf User", "csrf@example.com", "Password123!", "free"); - await RegisterAsync(client, register); - - // Logout first - var logoutResponse = await PostWithAntiforgeryAsync(client, "/api/auth/logout", new { }); - Assert.Equal(HttpStatusCode.NoContent, logoutResponse.StatusCode); - - // Try login WITHOUT antiforgery token - var login = new LoginRequest("csrf@example.com", "Password123!"); - var response = await client.PostAsJsonAsync("/api/auth/login", login, CancellationToken); - - Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); - } - - [Fact] - public async Task AntiforgeryToken_Anonymous_Returns200() - { - var client = _factory.CreateClient(new WebApplicationFactoryClientOptions - { - BaseAddress = new Uri("https://localhost"), - }); - - var response = await client.GetAsync("/api/auth/antiforgery-token", CancellationToken); - - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - } - - [Fact] - public async Task AntiforgeryToken_SetsReadableXsrfCookie() - { - var client = CreateClient(); - - var response = await client.GetAsync("/api/auth/antiforgery-token", CancellationToken); - - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - Assert.Contains(response.Headers.GetValues("Set-Cookie"), c => c.StartsWith("XSRF-TOKEN=")); - } - - [Fact] - public async Task Login_ValidCredentials_RefreshesXsrfCookie() - { - var client = CreateClient(); - await RegisterAsync(client, new RegisterRequest("Token User", "token@example.com", "Password123!", "free")); - - var logoutResponse = await PostWithAntiforgeryAsync(client, "/api/auth/logout", new { }); - Assert.Equal(HttpStatusCode.NoContent, logoutResponse.StatusCode); - - var response = await PostWithAntiforgeryAsync( - client, - "/api/auth/login", - new LoginRequest("token@example.com", "Password123!")); - - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - Assert.Contains(response.Headers.GetValues("Set-Cookie"), c => c.StartsWith("XSRF-TOKEN=")); - } - - // Creates an HttpClient backed by a CookieContainer (via CookieDelegatingHandler) so - // that session cookies are sent on every request for authenticated flows. - private HttpClient CreateClient(bool handleCookies = true) - { - var cookieContainer = new CookieContainer(); - return CreateClient(_factory, cookieContainer); - } - - private static HttpClient CreateClient(WebApplicationFactory factory, CookieContainer cookieContainer) - { - var handler = new CookieDelegatingHandler(cookieContainer, factory.Server.CreateHandler()); - return new HttpClient(handler) - { - BaseAddress = new Uri("https://localhost"), - }; - } - - private WebApplicationFactory CreateConfiguredFactory(string databaseName, string keyRingPath) - { - return _factory.WithWebHostBuilder(builder => - { - builder.UseSetting("UseInMemoryDatabase", "true"); - builder.UseSetting("InMemoryDatabaseName", databaseName); - builder.UseSetting("Redis:ConnectionString", ""); - builder.UseSetting("DataProtection:KeyRingPath", keyRingPath); - }); - } - - private static async Task GetAntiforgeryTokenAsync(HttpClient client) - { - var response = await client.GetAsync("/api/auth/antiforgery-token", CancellationToken); - response.EnsureSuccessStatusCode(); - - // The endpoint sets a non-HttpOnly XSRF-TOKEN cookie with the request token. - var xsrfCookie = response.Headers.GetValues("Set-Cookie") - .First(c => c.StartsWith("XSRF-TOKEN=")); - return Uri.UnescapeDataString(xsrfCookie.Split('=', 2)[1].Split(';')[0]); - } - - private static async Task PostWithAntiforgeryAsync( - HttpClient client, string url, T body) - { - var token = await GetAntiforgeryTokenAsync(client); - var message = new HttpRequestMessage(HttpMethod.Post, url); - message.Headers.Add("X-XSRF-TOKEN", token); - message.Content = JsonContent.Create(body); - return await client.SendAsync(message, CancellationToken); - } - - private static Task RegisterAsync(HttpClient client, RegisterRequest request) => - PostWithAntiforgeryAsync(client, "/api/auth/register", request); - - private async Task FindUserByEmailAsync(string email) - { - using var scope = _factory.Services.CreateScope(); - var userManager = scope.ServiceProvider.GetRequiredService>(); - var user = await userManager.FindByEmailAsync(email); - Assert.NotNull(user); - return user; - } - - private async Task> GetUserRolesAsync(ApplicationUser user) - { - using var scope = _factory.Services.CreateScope(); - var userManager = scope.ServiceProvider.GetRequiredService>(); - return await userManager.GetRolesAsync(user); - } - - // Delegating handler that manages a cookie container, forwarding cookies on requests - // and storing cookies from responses — while leaving Set-Cookie headers visible in the response. - private sealed class CookieDelegatingHandler(CookieContainer cookieContainer, HttpMessageHandler inner) - : DelegatingHandler(inner) - { - protected override async Task SendAsync( - HttpRequestMessage request, CancellationToken cancellationToken) - { - var cookieHeader = cookieContainer.GetCookieHeader(request.RequestUri!); - if (!string.IsNullOrEmpty(cookieHeader)) - request.Headers.TryAddWithoutValidation("Cookie", cookieHeader); - - var response = await base.SendAsync(request, cancellationToken); - - if (response.Headers.TryGetValues("Set-Cookie", out var setCookieHeaders)) - { - foreach (var setCookie in setCookieHeaders) - { - try { cookieContainer.SetCookies(request.RequestUri!, setCookie); } - catch (CookieException) { /* ignore malformed cookies */ } - } - } - - return response; - } - } -} diff --git a/tests/WiSave.Portal.IntegrationTests/Gateway/DownstreamServiceAvailabilityTests.cs b/tests/WiSave.Portal.IntegrationTests/Gateway/DownstreamServiceAvailabilityTests.cs deleted file mode 100644 index a4a722d..0000000 --- a/tests/WiSave.Portal.IntegrationTests/Gateway/DownstreamServiceAvailabilityTests.cs +++ /dev/null @@ -1,64 +0,0 @@ -using System.Net; -using System.Net.Http.Json; -using Microsoft.AspNetCore.Hosting; -using Microsoft.AspNetCore.Mvc.Testing; -using Microsoft.Extensions.Configuration; -using Xunit; - -namespace WiSave.Portal.IntegrationTests.Gateway; - -public sealed class DownstreamServiceAvailabilityTests -{ - [Fact] - public async Task Capabilities_ReturnsConfiguredServiceAvailability() - { - await using var factory = CreateFactory(incomesEnabled: false); - using var client = factory.CreateClient(); - - var response = await client.GetAsync("/api/capabilities", TestContext.Current.CancellationToken); - var capabilities = await response.Content.ReadFromJsonAsync(TestContext.Current.CancellationToken); - - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - Assert.NotNull(capabilities); - Assert.False(capabilities.Services["incomes"]); - Assert.True(capabilities.Services["stocks"]); - Assert.True(capabilities.Services["expenses"]); - } - - [Fact] - public async Task DisabledService_RequestReturns503BeforeForwarding() - { - await using var factory = CreateFactory(incomesEnabled: false); - using var client = factory.CreateClient(); - - var response = await client.GetAsync("/api/incomes", TestContext.Current.CancellationToken); - var problem = await response.Content.ReadFromJsonAsync(TestContext.Current.CancellationToken); - - Assert.Equal(HttpStatusCode.ServiceUnavailable, response.StatusCode); - Assert.NotNull(problem); - Assert.Equal("downstream_service_disabled", problem.Code); - Assert.Equal("incomes", problem.Service); - } - - private static WebApplicationFactory CreateFactory(bool incomesEnabled) => - new WebApplicationFactory().WithWebHostBuilder(builder => - { - builder.UseSetting("UseInMemoryDatabase", "true"); - builder.UseSetting("InMemoryDatabaseName", "DownstreamAvailabilityTests_" + Guid.NewGuid()); - builder.UseSetting("Redis:ConnectionString", ""); - builder.ConfigureAppConfiguration((_, config) => - { - config.AddInMemoryCollection(new Dictionary - { - ["DownstreamServices:Incomes:Enabled"] = incomesEnabled.ToString(), - ["DownstreamServices:Stocks:Enabled"] = "true", - ["DownstreamServices:Expenses:Enabled"] = "true", - ["ReverseProxy:Routes:incomes-route:AuthorizationPolicy"] = "Anonymous", - ["ReverseProxy:Clusters:incomes-cluster:Destinations:destination1:Address"] = "http://127.0.0.1:1" - }); - }); - }); - - private sealed record CapabilitiesResponse(Dictionary Services); - private sealed record DisabledServiceProblem(string Code, string Service); -} diff --git a/tests/WiSave.Portal.IntegrationTests/Gateway/UserHeaderTransformTests.cs b/tests/WiSave.Portal.IntegrationTests/Gateway/UserHeaderTransformTests.cs deleted file mode 100644 index 938b15f..0000000 --- a/tests/WiSave.Portal.IntegrationTests/Gateway/UserHeaderTransformTests.cs +++ /dev/null @@ -1,360 +0,0 @@ -using System.Net; -using System.Net.Http.Json; -using System.Security.Claims; -using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Hosting; -using Microsoft.AspNetCore.Hosting.Server; -using Microsoft.AspNetCore.Hosting.Server.Features; -using Microsoft.AspNetCore.Identity; -using Microsoft.AspNetCore.Mvc.Testing; -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.DependencyInjection; -using WiSave.Portal.Auth.Models; -using WiSave.Portal.Authorization; -using WiSave.Portal.Contracts.Authorization; -using WiSave.Portal.Contracts.Identity; -using Xunit; - -namespace WiSave.Portal.IntegrationTests.Gateway; - -public class UserHeaderTransformTests(WebApplicationFactory factory) : IClassFixture>, IAsyncLifetime -{ - private WebApplicationFactory _factory = null!; - private DownstreamEchoServer _downstream = null!; - private static CancellationToken CancellationToken => TestContext.Current.CancellationToken; - - public async ValueTask InitializeAsync() - { - _downstream = await DownstreamEchoServer.StartAsync(CancellationToken); - _factory = factory.WithWebHostBuilder(builder => - { - builder.UseSetting("UseInMemoryDatabase", "true"); - builder.UseSetting("InMemoryDatabaseName", "GatewayTests_" + Guid.NewGuid()); - builder.UseSetting("Redis:ConnectionString", ""); - builder.ConfigureAppConfiguration((_, config) => - { - config.AddInMemoryCollection(new Dictionary - { - ["DownstreamServices:Incomes:Enabled"] = "true", - ["DownstreamServices:Stocks:Enabled"] = "true", - ["ReverseProxy:Clusters:incomes-cluster:Destinations:destination1:Address"] = _downstream.BaseAddress, - ["ReverseProxy:Clusters:stocks-cluster:Destinations:destination1:Address"] = _downstream.BaseAddress - }); - }); - }); - await SeedRolesAsync(); - } - - public async ValueTask DisposeAsync() - { - await _factory.DisposeAsync(); - await _downstream.DisposeAsync(); - } - - private async Task SeedRolesAsync() - { - using var scope = _factory.Services.CreateScope(); - var roleManager = scope.ServiceProvider.GetRequiredService>(); - foreach (var role in PortalRoles.AdminRoles.Concat(PortalRoles.PlanRoles)) - { - if (!await roleManager.RoleExistsAsync(role)) - await roleManager.CreateAsync(new IdentityRole(role)); - } - - await EnsurePermissionClaimAsync(roleManager, PortalRoles.FreePlan, PortalPermissions.Incomes.Read); - await EnsurePermissionClaimAsync(roleManager, PortalRoles.StandardPlan, PortalPermissions.Incomes.Read); - await EnsurePermissionClaimAsync(roleManager, PortalRoles.StandardPlan, PortalPermissions.Incomes.Write); - await EnsurePermissionClaimAsync(roleManager, PortalRoles.PremiumPlan, PortalPermissions.Incomes.Read); - await EnsurePermissionClaimAsync(roleManager, PortalRoles.PremiumPlan, PortalPermissions.Incomes.Write); - await EnsurePermissionClaimAsync(roleManager, PortalRoles.PremiumPlan, PortalPermissions.Incomes.Delete); - await EnsurePermissionClaimAsync(roleManager, PortalRoles.FreePlan, PortalPermissions.Stocks.Read); - await EnsurePermissionClaimAsync(roleManager, PortalRoles.StandardPlan, PortalPermissions.Stocks.Read); - await EnsurePermissionClaimAsync(roleManager, PortalRoles.StandardPlan, PortalPermissions.Stocks.Write); - await EnsurePermissionClaimAsync(roleManager, PortalRoles.PremiumPlan, PortalPermissions.Stocks.Read); - await EnsurePermissionClaimAsync(roleManager, PortalRoles.PremiumPlan, PortalPermissions.Stocks.Write); - await EnsurePermissionClaimAsync(roleManager, PortalRoles.PremiumPlan, PortalPermissions.Stocks.PortfolioManage); - } - - private static async Task EnsurePermissionClaimAsync(RoleManager roleManager, string roleName, string permission) - { - var role = await roleManager.FindByNameAsync(roleName); - Assert.NotNull(role); - - var claims = await roleManager.GetClaimsAsync(role); - if (!claims.Any(c => c.Type == PortalClaimTypes.Permission && c.Value == permission)) - await roleManager.AddClaimAsync(role, new Claim(PortalClaimTypes.Permission, permission)); - } - - [Fact] - public async Task ProxiedRequest_Authenticated_ForwardsIdentityHeaders() - { - var client = CreateClientWithCookies(); - var auth = await RegisterAsync(client, "Proxy User", "proxy@example.com"); - - var response = await client.GetAsync("/api/incomes", TestContext.Current.CancellationToken); - var forwarded = await response.Content.ReadFromJsonAsync(CancellationToken); - - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - Assert.NotNull(forwarded); - Assert.Equal("/incomes", forwarded.Path); - Assert.Equal(auth.User.Id, GetHeaderValue(forwarded, PortalHeaderNames.UserId)); - Assert.Equal(auth.User.Email, GetHeaderValue(forwarded, PortalHeaderNames.UserEmail)); - Assert.Contains( - PortalPermissions.Incomes.Read, - GetHeaderValue(forwarded, PortalHeaderNames.UserPermissions).Split(',')); - } - - [Fact] - public async Task IncomeByIdProxyRequest_Authenticated_ForwardsToIncomesService() - { - var client = CreateClientWithCookies(); - var auth = await RegisterAsync(client, "Income By Id User", "income-by-id@example.com"); - var incomeId = Guid.Parse("418f7e8d-7b41-7c3a-9f0d-0b5e6a8c1234"); - - var response = await client.GetAsync($"/api/incomes/{incomeId}", CancellationToken); - var forwarded = await response.Content.ReadFromJsonAsync(CancellationToken); - - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - Assert.NotNull(forwarded); - Assert.Equal($"/incomes/{incomeId}", forwarded.Path); - Assert.Equal(auth.User.Id, GetHeaderValue(forwarded, PortalHeaderNames.UserId)); - Assert.Contains( - PortalPermissions.Incomes.Read, - GetHeaderValue(forwarded, PortalHeaderNames.UserPermissions).Split(',')); - } - - [Fact] - public async Task StocksProxiedRequest_Authenticated_ForwardsIdentityHeaders() - { - var client = CreateClientWithCookies(); - var auth = await RegisterAsync(client, "Stock Proxy User", "stock-proxy@example.com"); - - var response = await client.GetAsync("/api/stocks/brokers", CancellationToken); - var forwarded = await response.Content.ReadFromJsonAsync(CancellationToken); - - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - Assert.NotNull(forwarded); - Assert.Equal("/stocks/brokers", forwarded.Path); - Assert.Equal(auth.User.Id, GetHeaderValue(forwarded, PortalHeaderNames.UserId)); - Assert.Equal(auth.User.Email, GetHeaderValue(forwarded, PortalHeaderNames.UserEmail)); - Assert.Contains( - PortalPermissions.Stocks.Read, - GetHeaderValue(forwarded, PortalHeaderNames.UserPermissions).Split(',')); - } - - [Fact] - public async Task ProxiedRequest_Unauthenticated_Returns401() - { - var client = _factory.CreateClient(); - - var response = await client.GetAsync("/api/incomes", TestContext.Current.CancellationToken); - - Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); - } - - [Fact] - public async Task ProxiedRequest_ClientSpoofedHeaders_AreOverwritten() - { - var client = CreateClientWithCookies(); - var auth = await RegisterAsync(client, "Spoof User", "spoof@example.com"); - - var request = new HttpRequestMessage(HttpMethod.Get, "/api/incomes"); - request.Headers.TryAddWithoutValidation(PortalHeaderNames.UserId, "spoofed-id"); - request.Headers.TryAddWithoutValidation(PortalHeaderNames.UserEmail, "evil@attacker.com"); - request.Headers.TryAddWithoutValidation(PortalHeaderNames.UserRoles, "admin"); - - var response = await client.SendAsync(request, CancellationToken); - var forwarded = await response.Content.ReadFromJsonAsync(CancellationToken); - - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - Assert.NotNull(forwarded); - Assert.Equal(auth.User.Id, GetHeaderValue(forwarded, PortalHeaderNames.UserId)); - Assert.Equal(auth.User.Email, GetHeaderValue(forwarded, PortalHeaderNames.UserEmail)); - Assert.Equal(PortalRoles.FreePlan, GetHeaderValue(forwarded, PortalHeaderNames.UserRoles)); - } - - [Fact] - public async Task ProxiedRequest_Authenticated_ForwardsPlanPermissions() - { - var client = CreateClientWithCookies(); - await RegisterAsync(client, "Permission User", "permissions@example.com", "standard"); - - var response = await client.GetAsync("/api/incomes", CancellationToken); - var forwarded = await response.Content.ReadFromJsonAsync(CancellationToken); - - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - Assert.NotNull(forwarded); - var permissions = GetHeaderValue(forwarded, PortalHeaderNames.UserPermissions).Split(','); - Assert.Contains(PortalPermissions.Incomes.Read, permissions); - Assert.Contains(PortalPermissions.Incomes.Write, permissions); - } - - [Fact] - public async Task ProxiedRequest_AdminUser_ForwardsWildcardPermissions() - { - var client = CreateClientWithCookies(); - await RegisterAsync(client, "Admin User", "admin-user@example.com", "free"); - await AddUserToRoleAsync("admin-user@example.com", PortalRoles.Admin); - - var response = await client.GetAsync("/api/incomes", CancellationToken); - var forwarded = await response.Content.ReadFromJsonAsync(CancellationToken); - - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - Assert.NotNull(forwarded); - Assert.Equal("*", GetHeaderValue(forwarded, PortalHeaderNames.UserPermissions)); - } - - [Fact] - public async Task UnsafeProxyRequest_WithoutAntiforgeryToken_Returns400() - { - var client = CreateClientWithCookies(); - await RegisterAsync(client, "Proxy Post User", "proxy-post@example.com"); - - var response = await client.PostAsJsonAsync("/api/incomes", new { name = "Test" }, CancellationToken); - - Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); - } - - [Fact] - public async Task UnsafeProxyRequest_WithAntiforgeryToken_ForwardsRequest() - { - var client = CreateClientWithCookies(); - await RegisterAsync(client, "Proxy Post User", "proxy-post-ok@example.com"); - - var token = await GetAntiforgeryTokenAsync(client); - var request = new HttpRequestMessage(HttpMethod.Post, "/api/incomes") - { - Content = JsonContent.Create(new { name = "Test" }) - }; - request.Headers.Add("X-XSRF-TOKEN", token); - - var response = await client.SendAsync(request, CancellationToken); - var forwarded = await response.Content.ReadFromJsonAsync(CancellationToken); - - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - Assert.NotNull(forwarded); - Assert.Equal("/incomes", forwarded.Path); - } - - private HttpClient CreateClientWithCookies() - { - var cookieContainer = new CookieContainer(); - var handler = new CookieDelegatingHandler(cookieContainer, _factory.Server.CreateHandler()); - return new HttpClient(handler) - { - BaseAddress = new Uri("https://localhost"), - }; - } - - private static async Task GetAntiforgeryTokenAsync(HttpClient client) - { - var response = await client.GetAsync("/api/auth/antiforgery-token", CancellationToken); - response.EnsureSuccessStatusCode(); - - var xsrfCookie = response.Headers.GetValues("Set-Cookie") - .First(c => c.StartsWith("XSRF-TOKEN=")); - return Uri.UnescapeDataString(xsrfCookie.Split('=', 2)[1].Split(';')[0]); - } - - private static async Task RegisterAsync(HttpClient client, string name, string email, string plan = "free") - { - var token = await GetAntiforgeryTokenAsync(client); - var request = new RegisterRequest(name, email, "Password123!", plan); - var message = new HttpRequestMessage(HttpMethod.Post, "/api/auth/register"); - message.Headers.Add("X-XSRF-TOKEN", token); - message.Content = JsonContent.Create(request); - var response = await client.SendAsync(message, CancellationToken); - response.EnsureSuccessStatusCode(); - - return (await response.Content.ReadFromJsonAsync(CancellationToken))!; - } - - private async Task AddUserToRoleAsync(string email, string role) - { - using var scope = _factory.Services.CreateScope(); - var userManager = scope.ServiceProvider.GetRequiredService>(); - var user = await userManager.FindByEmailAsync(email); - Assert.NotNull(user); - - var result = await userManager.AddToRoleAsync(user, role); - Assert.True(result.Succeeded, string.Join(", ", result.Errors.Select(e => e.Description))); - } - - private static string GetHeaderValue(ForwardedRequest forwarded, string headerName) - { - Assert.True(forwarded.Headers.TryGetValue(headerName, out var values)); - return Assert.Single(values); - } - - private sealed record ForwardedRequest(string Path, Dictionary Headers); - - private sealed class CookieDelegatingHandler(CookieContainer cookieContainer, HttpMessageHandler inner) - : DelegatingHandler(inner) - { - protected override async Task SendAsync( - HttpRequestMessage request, CancellationToken cancellationToken) - { - var cookieHeader = cookieContainer.GetCookieHeader(request.RequestUri!); - if (!string.IsNullOrEmpty(cookieHeader)) - request.Headers.TryAddWithoutValidation("Cookie", cookieHeader); - - var response = await base.SendAsync(request, cancellationToken); - - if (response.Headers.TryGetValues("Set-Cookie", out var setCookieHeaders)) - { - foreach (var setCookie in setCookieHeaders) - { - try { cookieContainer.SetCookies(request.RequestUri!, setCookie); } - catch (CookieException) { /* ignore malformed cookies */ } - } - } - - return response; - } - } - - private sealed class DownstreamEchoServer : IAsyncDisposable - { - private readonly WebApplication _app; - - private DownstreamEchoServer(WebApplication app, string baseAddress) - { - _app = app; - BaseAddress = baseAddress; - } - - public string BaseAddress { get; } - - public static async Task StartAsync(CancellationToken cancellationToken) - { - var builder = WebApplication.CreateBuilder(); - builder.WebHost.UseUrls("http://127.0.0.1:0"); - - var app = builder.Build(); - app.Map("/{**remainder}", (HttpContext context) => - { - var headers = context.Request.Headers.ToDictionary( - header => header.Key, - header => header.Value.Select(static value => value ?? string.Empty).ToArray(), - StringComparer.OrdinalIgnoreCase); - - return Results.Json(new ForwardedRequest(context.Request.Path.Value ?? string.Empty, headers)); - }); - - await app.StartAsync(cancellationToken); - - var addresses = app.Services.GetRequiredService() - .Features - .Get()? - .Addresses; - - var baseAddress = addresses?.SingleOrDefault() - ?? throw new InvalidOperationException("Unable to determine downstream server address."); - - return new DownstreamEchoServer(app, baseAddress); - } - - public ValueTask DisposeAsync() => _app.DisposeAsync(); - } -} diff --git a/tests/WiSave.Portal.IntegrationTests/Hubs/NotificationsHubTests.cs b/tests/WiSave.Portal.IntegrationTests/Hubs/NotificationsHubTests.cs deleted file mode 100644 index 4ea8869..0000000 --- a/tests/WiSave.Portal.IntegrationTests/Hubs/NotificationsHubTests.cs +++ /dev/null @@ -1,139 +0,0 @@ -using System.Net; -using System.Net.Http.Json; -using Microsoft.AspNetCore.Identity; -using Microsoft.AspNetCore.Mvc.Testing; -using Microsoft.AspNetCore.SignalR.Client; -using Microsoft.AspNetCore.TestHost; -using Microsoft.Extensions.DependencyInjection; -using WiSave.Portal.Auth.Models; -using WiSave.Portal.Authorization; -using Xunit; - -namespace WiSave.Portal.IntegrationTests.Hubs; - -public class NotificationsHubTests : IClassFixture>, IAsyncLifetime -{ - private readonly WebApplicationFactory _factory; - private static CancellationToken CancellationToken => TestContext.Current.CancellationToken; - - public NotificationsHubTests(WebApplicationFactory factory) - { - _factory = factory.WithWebHostBuilder(builder => - { - builder.UseSetting("UseInMemoryDatabase", "true"); - builder.UseSetting("InMemoryDatabaseName", "HubTests_" + Guid.NewGuid()); - builder.UseSetting("Redis:ConnectionString", ""); - }); - } - - public async ValueTask InitializeAsync() - { - using var scope = _factory.Services.CreateScope(); - var roleManager = scope.ServiceProvider.GetRequiredService>(); - foreach (var role in PortalRoles.AdminRoles.Concat(PortalRoles.PlanRoles)) - { - if (!await roleManager.RoleExistsAsync(role)) - await roleManager.CreateAsync(new IdentityRole(role)); - } - } - - public ValueTask DisposeAsync() => ValueTask.CompletedTask; - - [Fact] - public async Task AuthenticatedClient_CanConnectToHub() - { - // Register a user using CSRF-protected endpoint, capturing session cookie - var cookieContainer = new System.Net.CookieContainer(); - var handler = new CookieDelegatingHandler(cookieContainer, _factory.Server.CreateHandler()); - var client = new HttpClient(handler) { BaseAddress = new Uri("https://localhost") }; - - var afToken = await GetAntiforgeryTokenAsync(client); - var request = new RegisterRequest("Hub User", "hub@example.com", "Password123!", "free"); - var msg = new HttpRequestMessage(HttpMethod.Post, "/api/auth/register"); - msg.Headers.Add("X-XSRF-TOKEN", afToken); - msg.Content = JsonContent.Create(request); - var registerResponse = await client.SendAsync(msg, CancellationToken); - Assert.Equal(HttpStatusCode.OK, registerResponse.StatusCode); - - var cookies = registerResponse.Headers - .Where(h => h.Key.Equals("Set-Cookie", StringComparison.OrdinalIgnoreCase)) - .SelectMany(h => h.Value) - .ToList(); - - Assert.NotEmpty(cookies); - - var cookieHeader = string.Join("; ", - cookies.Select(c => c.Split(';')[0])); - - // Build SignalR connection using the test server's handler - var connection = new HubConnectionBuilder() - .WithUrl("http://localhost/hubs/notifications", options => - { - options.HttpMessageHandlerFactory = _ => _factory.Server.CreateHandler(); - options.Headers.Add("Cookie", cookieHeader); - }) - .Build(); - - await connection.StartAsync(CancellationToken); - - Assert.Equal(HubConnectionState.Connected, connection.State); - - await connection.StopAsync(CancellationToken); - await connection.DisposeAsync(); - } - - [Fact] - public async Task UnauthenticatedClient_GetsRejected() - { - var connection = new HubConnectionBuilder() - .WithUrl("http://localhost/hubs/notifications", options => - { - options.HttpMessageHandlerFactory = _ => _factory.Server.CreateHandler(); - }) - .Build(); - - var ex = await Assert.ThrowsAsync( - () => connection.StartAsync(CancellationToken)); - - Assert.Contains("401", ex.Message); - - await connection.DisposeAsync(); - } - - private static async Task GetAntiforgeryTokenAsync(HttpClient client) - { - var response = await client.GetAsync("/api/auth/antiforgery-token", CancellationToken); - response.EnsureSuccessStatusCode(); - - var xsrfCookie = response.Headers.GetValues("Set-Cookie") - .First(c => c.StartsWith("XSRF-TOKEN=")); - return Uri.UnescapeDataString(xsrfCookie.Split('=', 2)[1].Split(';')[0]); - } - - // Delegating handler that manages a cookie container, forwarding cookies on requests - // and storing cookies from responses — while leaving Set-Cookie headers visible in the response. - private sealed class CookieDelegatingHandler(System.Net.CookieContainer cookieContainer, HttpMessageHandler inner) - : DelegatingHandler(inner) - { - protected override async Task SendAsync( - HttpRequestMessage request, CancellationToken cancellationToken) - { - var cookieHeader = cookieContainer.GetCookieHeader(request.RequestUri!); - if (!string.IsNullOrEmpty(cookieHeader)) - request.Headers.TryAddWithoutValidation("Cookie", cookieHeader); - - var response = await base.SendAsync(request, cancellationToken); - - if (response.Headers.TryGetValues("Set-Cookie", out var setCookieHeaders)) - { - foreach (var setCookie in setCookieHeaders) - { - try { cookieContainer.SetCookies(request.RequestUri!, setCookie); } - catch (System.Net.CookieException) { /* ignore malformed cookies */ } - } - } - - return response; - } - } -} diff --git a/tests/WiSave.Portal.IntegrationTests/Hubs/RedisBackplaneCrossInstanceTests.cs b/tests/WiSave.Portal.IntegrationTests/Hubs/RedisBackplaneCrossInstanceTests.cs deleted file mode 100644 index bbe8736..0000000 --- a/tests/WiSave.Portal.IntegrationTests/Hubs/RedisBackplaneCrossInstanceTests.cs +++ /dev/null @@ -1,181 +0,0 @@ -using System.Net; -using System.Net.Http.Json; -using System.Text.Json; -using Microsoft.AspNetCore.Identity; -using Microsoft.AspNetCore.Mvc.Testing; -using Microsoft.AspNetCore.SignalR; -using Microsoft.AspNetCore.SignalR.Client; -using Microsoft.Extensions.DependencyInjection; -using StackExchange.Redis; -using WiSave.Portal.Auth.Models; -using WiSave.Portal.Authorization; -using WiSave.Portal.Hubs; -using WiSave.Portal.Hubs.Realtime; -using Xunit; - -namespace WiSave.Portal.IntegrationTests.Hubs; - -public class RedisBackplaneCrossInstanceTests : IAsyncLifetime -{ - private const string RedisConnectionString = "localhost:6379"; - private static CancellationToken CancellationToken => TestContext.Current.CancellationToken; - - private WebApplicationFactory? _instanceA; - private WebApplicationFactory? _instanceB; - - public async ValueTask InitializeAsync() - { - // Skip when Redis is unavailable (local dev without docker up). - try - { - using var probe = await ConnectionMultiplexer.ConnectAsync(RedisConnectionString); - Assert.True(probe.IsConnected); - } - catch - { - Assert.Skip($"Redis not reachable at {RedisConnectionString}; start with docker compose up -d redis."); - } - - var dbName = "CrossInstanceTests_" + Guid.NewGuid(); - _instanceA = CreateFactory(dbName); - _instanceB = CreateFactory(dbName); - - await SeedAsync(_instanceA); - } - - public async ValueTask DisposeAsync() - { - if (_instanceA is not null) await _instanceA.DisposeAsync(); - if (_instanceB is not null) await _instanceB.DisposeAsync(); - } - - [Fact(Skip = "Pending fix to the existing portal integration-test auth setup: /api/auth/register currently returns BadRequest in-process (reproducible on the pre-existing NotificationsHubTests and ConsumerSignalRTests as well). Unblock by fixing auth in WebApplicationFactory, then remove this Skip.")] - public async Task Event_pushed_from_instance_A_reaches_client_connected_to_instance_B() - { - // Register user via instance A, connect client to instance B. - var (registerClient, _) = await RegisterUserAsync(_instanceA!, "cross@example.com"); - var userId = await FetchUserIdAsync(_instanceA!, registerClient); - var cookieHeader = ExtractCookieHeader(registerClient); - - var connection = new HubConnectionBuilder() - .WithUrl("http://localhost/hubs/notifications", options => - { - options.HttpMessageHandlerFactory = _ => _instanceB!.Server.CreateHandler(); - options.Headers.Add("Cookie", cookieHeader); - }) - .Build(); - - var tcs = new TaskCompletionSource(); - connection.On("realtimeEvent", envelope => - { - if (envelope.GetProperty("eventType").GetString() == "expense.recorded") - tcs.TrySetResult(envelope); - }); - - await connection.StartAsync(CancellationToken); - - // Push envelope from instance A's hub context directly — bypasses MT, tests the backplane. - using var scope = _instanceA!.Services.CreateScope(); - var hub = scope.ServiceProvider.GetRequiredService>(); - var env = new RealtimeEnvelope( - EventId: Guid.CreateVersion7(), - Domain: "expenses", - EventType: RealtimeEventType.ExpenseRecorded, - OccurredAt: DateTime.UtcNow, - EntityId: "exp-cross-1", - Payload: new { expenseId = "exp-cross-1", userId }); - await hub.Clients.Group(userId).SendAsync("realtimeEvent", env, CancellationToken); - - var received = await tcs.Task.WaitAsync(TimeSpan.FromSeconds(10), CancellationToken); - Assert.Equal("expense.recorded", received.GetProperty("eventType").GetString()); - Assert.Equal("exp-cross-1", received.GetProperty("entityId").GetString()); - - await connection.StopAsync(CancellationToken); - await connection.DisposeAsync(); - } - - private static WebApplicationFactory CreateFactory(string dbName) - { - return new WebApplicationFactory().WithWebHostBuilder(builder => - { - builder.UseSetting("UseInMemoryDatabase", "true"); - builder.UseSetting("InMemoryDatabaseName", dbName); - builder.UseSetting("Messaging:Transport", "InMemory"); - builder.UseSetting("Redis:ConnectionString", RedisConnectionString); - }); - } - - private static async Task SeedAsync(WebApplicationFactory factory) - { - using var scope = factory.Services.CreateScope(); - var roleManager = scope.ServiceProvider.GetRequiredService>(); - foreach (var role in PortalRoles.AdminRoles.Concat(PortalRoles.PlanRoles)) - { - if (!await roleManager.RoleExistsAsync(role)) - await roleManager.CreateAsync(new IdentityRole(role)); - } - } - - private static async Task<(HttpResponseMessage Response, System.Net.CookieContainer Container)> RegisterUserAsync( - WebApplicationFactory factory, string email) - { - var cookieContainer = new System.Net.CookieContainer(); - var handler = new CookieDelegatingHandler(cookieContainer, factory.Server.CreateHandler()); - var client = new HttpClient(handler); - client.BaseAddress = new Uri("https://localhost"); - - var afResp = await client.GetAsync("/api/auth/antiforgery-token", CancellationToken); - afResp.EnsureSuccessStatusCode(); - var xsrfCookie = afResp.Headers.GetValues("Set-Cookie").First(c => c.StartsWith("XSRF-TOKEN=")); - var afToken = Uri.UnescapeDataString(xsrfCookie.Split('=', 2)[1].Split(';')[0]); - - var request = new RegisterRequest("Cross User", email, "Password123!", "free"); - var msg = new HttpRequestMessage(HttpMethod.Post, "/api/auth/register"); - msg.Headers.Add("X-XSRF-TOKEN", afToken); - msg.Content = JsonContent.Create(request); - var registerResponse = await client.SendAsync(msg, CancellationToken); - Assert.Equal(HttpStatusCode.OK, registerResponse.StatusCode); - - return (registerResponse, cookieContainer); - } - - private static async Task FetchUserIdAsync(WebApplicationFactory factory, HttpResponseMessage registerResponse) - { - var auth = await registerResponse.Content.ReadFromJsonAsync(CancellationToken); - return auth!.User.Id; - } - - private static string ExtractCookieHeader(HttpResponseMessage registerResponse) - { - var cookies = registerResponse.Headers - .Where(h => h.Key.Equals("Set-Cookie", StringComparison.OrdinalIgnoreCase)) - .SelectMany(h => h.Value) - .ToList(); - return string.Join("; ", cookies.Select(c => c.Split(';')[0])); - } - - private sealed class CookieDelegatingHandler(System.Net.CookieContainer cookieContainer, HttpMessageHandler inner) - : DelegatingHandler(inner) - { - protected override async Task SendAsync( - HttpRequestMessage request, CancellationToken cancellationToken) - { - var cookieHeader = cookieContainer.GetCookieHeader(request.RequestUri!); - if (!string.IsNullOrEmpty(cookieHeader)) - request.Headers.TryAddWithoutValidation("Cookie", cookieHeader); - - var response = await base.SendAsync(request, cancellationToken); - - if (response.Headers.TryGetValues("Set-Cookie", out var setCookieHeaders)) - { - foreach (var setCookie in setCookieHeaders) - { - try { cookieContainer.SetCookies(request.RequestUri!, setCookie); } - catch (System.Net.CookieException) { /* ignore */ } - } - } - - return response; - } - } -} diff --git a/tests/WiSave.Portal.IntegrationTests/Messaging/ConsumerSignalRTests.cs b/tests/WiSave.Portal.IntegrationTests/Messaging/ConsumerSignalRTests.cs deleted file mode 100644 index 1f9179d..0000000 --- a/tests/WiSave.Portal.IntegrationTests/Messaging/ConsumerSignalRTests.cs +++ /dev/null @@ -1,179 +0,0 @@ -using System.Net; -using System.Net.Http.Json; -using System.Text.Json; -using Microsoft.AspNetCore.Identity; -using Microsoft.AspNetCore.Mvc.Testing; -using Microsoft.AspNetCore.SignalR.Client; -using Microsoft.Extensions.DependencyInjection; -using WiSave.Expenses.Contracts.Events; -using WiSave.Expenses.Contracts.Models; -using WiSave.Portal.Auth.Models; -using WiSave.Portal.Authorization; -using Wolverine; -using Xunit; - -namespace WiSave.Portal.IntegrationTests.Messaging; - -public class ConsumerSignalRTests : IClassFixture>, IAsyncLifetime -{ - private readonly WebApplicationFactory _factory; - private static CancellationToken CancellationToken => TestContext.Current.CancellationToken; - - public ConsumerSignalRTests(WebApplicationFactory factory) - { - _factory = factory.WithWebHostBuilder(builder => - { - builder.UseSetting("UseInMemoryDatabase", "true"); - builder.UseSetting("InMemoryDatabaseName", "ConsumerTests_" + Guid.NewGuid()); - builder.UseSetting("Messaging:Transport", "InMemory"); - builder.UseSetting("Redis:ConnectionString", ""); - }); - } - - public async ValueTask InitializeAsync() - { - using var scope = _factory.Services.CreateScope(); - var roleManager = scope.ServiceProvider.GetRequiredService>(); - foreach (var role in PortalRoles.AdminRoles.Concat(PortalRoles.PlanRoles)) - { - if (!await roleManager.RoleExistsAsync(role)) - await roleManager.CreateAsync(new IdentityRole(role)); - } - } - - public ValueTask DisposeAsync() => ValueTask.CompletedTask; - - [Fact] - public void Services_ExposeWolverineMessageBus() - { - using var scope = _factory.Services.CreateScope(); - - var bus = scope.ServiceProvider.GetService(); - - Assert.NotNull(bus); - } - - [Fact] - public async Task ExpenseCreated_IsPushedToSignalRClient() - { - var (connection, userIdText) = await CreateAuthenticatedHubConnection("expense@example.com"); - var userId = Guid.Parse(userIdText); - var expenseId = Guid.NewGuid(); - - var tcs = new TaskCompletionSource(); - connection.On("realtimeEvent", envelope => - { - if (envelope.GetProperty("eventType").GetString() == "expense.created") - tcs.TrySetResult(envelope); - }); - - await connection.StartAsync(CancellationToken); - - await PublishExpensesEvent(new ExpenseCreated( - Id: new ExpenseId(expenseId), - Amount: new Money(99.99m, Currency.PLN), - ExpenseDate: new DateOnly(2026, 4, 1), - Name: "Lunch", - Description: "Test expense", - UserId: userId, - Tags: ["food"])); - - var envelope = await tcs.Task.WaitAsync(TimeSpan.FromSeconds(5), CancellationToken); - Assert.Equal("expenses", envelope.GetProperty("domain").GetString()); - Assert.Equal("expense.created", envelope.GetProperty("eventType").GetString()); - Assert.Equal(expenseId.ToString(), envelope.GetProperty("entityId").GetString()); - var payload = envelope.GetProperty("payload"); - Assert.Equal(expenseId.ToString(), payload.GetProperty("id").GetProperty("value").GetString()); - Assert.Equal(userId, payload.GetProperty("userId").GetGuid()); - Assert.Equal("Lunch", payload.GetProperty("name").GetString()); - - await connection.StopAsync(CancellationToken); - await connection.DisposeAsync(); - } - - private async Task<(HubConnection Connection, string UserId)> CreateAuthenticatedHubConnection(string email) - { - var cookieContainer = new System.Net.CookieContainer(); - var handler = new CookieDelegatingHandler(cookieContainer, _factory.Server.CreateHandler()); - var client = new HttpClient(handler) { BaseAddress = new Uri("https://localhost") }; - - var afToken = await GetAntiforgeryTokenAsync(client); - var request = new RegisterRequest("Test User", email, "Password123!", "free"); - var msg = new HttpRequestMessage(HttpMethod.Post, "/api/auth/register"); - msg.Headers.Add("X-XSRF-TOKEN", afToken); - msg.Content = JsonContent.Create(request); - var registerResponse = await client.SendAsync(msg, CancellationToken); - var registerBody = await registerResponse.Content.ReadAsStringAsync(CancellationToken); - Assert.True( - registerResponse.StatusCode == HttpStatusCode.OK, - $"Expected 200 from /api/auth/register but got {(int)registerResponse.StatusCode} {registerResponse.StatusCode}. Body: {registerBody}"); - - var auth = await registerResponse.Content.ReadFromJsonAsync(CancellationToken); - var userId = auth!.User.Id; - - var cookies = registerResponse.Headers - .Where(h => h.Key.Equals("Set-Cookie", StringComparison.OrdinalIgnoreCase)) - .SelectMany(h => h.Value) - .ToList(); - - var cookieHeader = string.Join("; ", - cookies.Select(c => c.Split(';')[0])); - - var connection = new HubConnectionBuilder() - .WithUrl("http://localhost/hubs/notifications", options => - { - options.HttpMessageHandlerFactory = _ => _factory.Server.CreateHandler(); - options.Headers.Add("Cookie", cookieHeader); - }) - .Build(); - - return (connection, userId); - } - - private static async Task GetAntiforgeryTokenAsync(HttpClient client) - { - var response = await client.GetAsync("/api/auth/antiforgery-token", CancellationToken); - response.EnsureSuccessStatusCode(); - - var xsrfCookie = response.Headers.GetValues("Set-Cookie") - .First(c => c.StartsWith("XSRF-TOKEN=")); - return Uri.UnescapeDataString(xsrfCookie.Split('=', 2)[1].Split(';')[0]); - } - - private async Task PublishExpensesEvent(T message) - where T : class - { - using var scope = _factory.Services.CreateScope(); - var bus = scope.ServiceProvider.GetRequiredService(); - - await bus.PublishAsync(message); - } - - // Delegating handler that manages a cookie container, forwarding cookies on requests - // and storing cookies from responses — while leaving Set-Cookie headers visible in the response. - private sealed class CookieDelegatingHandler(System.Net.CookieContainer cookieContainer, HttpMessageHandler inner) - : DelegatingHandler(inner) - { - protected override async Task SendAsync( - HttpRequestMessage request, CancellationToken cancellationToken) - { - var cookieHeader = cookieContainer.GetCookieHeader(request.RequestUri!); - if (!string.IsNullOrEmpty(cookieHeader)) - request.Headers.TryAddWithoutValidation("Cookie", cookieHeader); - - var response = await base.SendAsync(request, cancellationToken); - - if (response.Headers.TryGetValues("Set-Cookie", out var setCookieHeaders)) - { - foreach (var setCookie in setCookieHeaders) - { - try { cookieContainer.SetCookies(request.RequestUri!, setCookie); } - catch (System.Net.CookieException) { /* ignore malformed cookies */ } - } - } - - return response; - } - } - -} diff --git a/tests/WiSave.Portal.IntegrationTests/WiSave.Portal.IntegrationTests.csproj b/tests/WiSave.Portal.IntegrationTests/WiSave.Portal.IntegrationTests.csproj deleted file mode 100644 index 7aa339a..0000000 --- a/tests/WiSave.Portal.IntegrationTests/WiSave.Portal.IntegrationTests.csproj +++ /dev/null @@ -1,27 +0,0 @@ - - - net10.0 - enable - enable - Exe - false - - - - - - - - - - - all - runtime; build; native; contentfiles; analyzers; buildtransitive - - - - - - - - diff --git a/tests/WiSave.Portal.UnitTests/Architecture/ProjectDependencyDirectionTests.cs b/tests/WiSave.Portal.UnitTests/Architecture/ProjectDependencyDirectionTests.cs new file mode 100644 index 0000000..3b26b93 --- /dev/null +++ b/tests/WiSave.Portal.UnitTests/Architecture/ProjectDependencyDirectionTests.cs @@ -0,0 +1,172 @@ +using System.Xml.Linq; +using WiSave.Portal.Core.Abstractions.Authorization; +using WiSave.Portal.Core.Application.Authorization; +using WiSave.Portal.Core.Infrastructure.Database; +using Xunit; + +namespace WiSave.Portal.UnitTests.Architecture; + +public sealed class ProjectDependencyDirectionTests +{ + private const string CoreAbstractionsProject = + "src/WiSave.Portal.Core.Abstractions/WiSave.Portal.Core.Abstractions.csproj"; + + private const string CoreApplicationProject = + "src/WiSave.Portal.Core.Application/WiSave.Portal.Core.Application.csproj"; + + private const string CoreInfrastructureProject = + "src/WiSave.Portal.Core.Infrastructure/WiSave.Portal.Core.Infrastructure.csproj"; + + private const string WebApiProject = + "src/WiSave.Portal.WebApi/WiSave.Portal.WebApi.csproj"; + + /// + /// The integration-contract packages the application layer is allowed to know. + /// + /// + /// Core.Application holds the handlers that translate downstream events into realtime + /// notifications, so it necessarily names those event types. That is the only reason it + /// may reference a package at all. Adding a fourth entry here should be a deliberate + /// decision, not a side effect — which is why this is an allowlist rather than a + /// pattern match, and why framework references stay banned outright and the only + /// permitted project reference is Core.Abstractions. + /// + private static readonly string[] AllowedPackages = + [ + "WiSave.Expenses.Contracts", + "WiSave.Incomes.Contracts", + "WiSave.Stock.Contracts" + ]; + + /// + /// The innermost project depends on nothing at all — no projects, no packages, no + /// shared framework. That is what makes it safe for every other layer to depend on, + /// and it is the reason the ports and wire contracts live there rather than beside + /// the policy that uses them. + /// + [Fact] + public void CoreAbstractions_DeclaresNoReferencesOfAnyKind() + { + var project = XDocument.Load(RepoPath(CoreAbstractionsProject)); + + Assert.Empty(IncludeValues(project, "ProjectReference")); + Assert.Empty(IncludeValues(project, "FrameworkReference")); + Assert.Empty(IncludeValues(project, "PackageReference")); + } + + [Fact] + public void CoreAbstractionsAssembly_ReferencesNoOtherWiSaveAssembly() + { + var wiSaveReferences = typeof(PortalRoles).Assembly + .GetReferencedAssemblies() + .Select(assembly => assembly.Name ?? string.Empty) + .Where(name => name.StartsWith("WiSave.", StringComparison.Ordinal)) + .Order(StringComparer.Ordinal) + .ToArray(); + + Assert.Empty(wiSaveReferences); + } + + [Fact] + public void CoreApplication_ReferencesOnlyCoreAbstractions() + { + var project = XDocument.Load(RepoPath(CoreApplicationProject)); + + Assert.Equal(["WiSave.Portal.Core.Abstractions"], ProjectReferenceNames(project)); + Assert.Empty(IncludeValues(project, "FrameworkReference")); + } + + [Fact] + public void CoreApplication_ReferencesOnlyIntegrationContractPackages() + { + var project = XDocument.Load(RepoPath(CoreApplicationProject)); + + Assert.Equal( + AllowedPackages.Order(StringComparer.Ordinal), + IncludeValues(project, "PackageReference").Order(StringComparer.Ordinal)); + } + + [Fact] + public void CoreApplicationAssembly_ReferencesNoWiSaveAssemblyBeyondContractsAndAbstractions() + { + var wiSaveReferences = typeof(AccessManagementPolicy).Assembly + .GetReferencedAssemblies() + .Select(assembly => assembly.Name ?? string.Empty) + .Where(name => name.StartsWith("WiSave.", StringComparison.Ordinal)) + .Except(AllowedPackages, StringComparer.Ordinal) + .Except(["WiSave.Portal.Core.Abstractions"], StringComparer.Ordinal) + .Order(StringComparer.Ordinal) + .ToArray(); + + Assert.Empty(wiSaveReferences); + } + + [Fact] + public void CoreInfrastructure_ReferencesOnlyContractsAndTheCoreProjects() + { + var project = XDocument.Load(RepoPath(CoreInfrastructureProject)); + + Assert.Equal( + [ + "WiSave.Portal.Contracts", + "WiSave.Portal.Core.Abstractions", + "WiSave.Portal.Core.Application" + ], + ProjectReferenceNames(project)); + } + + [Fact] + public void CoreInfrastructureAssembly_DoesNotReferenceTheWebApi() + { + var referencedNames = typeof(PortalDbContext).Assembly + .GetReferencedAssemblies() + .Select(assembly => assembly.Name ?? string.Empty) + .ToArray(); + + Assert.DoesNotContain("WiSave.Portal.WebApi", referencedNames); + } + + [Fact] + public void WebApi_ReferencesContractsTheCoreProjectsAndMigrations() + { + var project = XDocument.Load(RepoPath(WebApiProject)); + + Assert.Equal( + [ + "WiSave.Portal.Contracts", + "WiSave.Portal.Core.Abstractions", + "WiSave.Portal.Core.Application", + "WiSave.Portal.Core.Infrastructure", + "WiSave.Portal.Migrations" + ], + ProjectReferenceNames(project)); + } + + private static string[] ProjectReferenceNames(XDocument project) => + IncludeValues(project, "ProjectReference") + .Select(Path.GetFileNameWithoutExtension) + .Select(name => name ?? string.Empty) + .Order(StringComparer.Ordinal) + .ToArray(); + + private static string[] IncludeValues(XDocument project, string itemName) => + project.Descendants(itemName) + .Select(element => element.Attribute("Include")?.Value ?? string.Empty) + .ToArray(); + + private static string RepoPath(string relativePath) + { + var directory = new DirectoryInfo(AppContext.BaseDirectory); + while (directory is not null && !File.Exists(Path.Combine(directory.FullName, "WiSave.Portal.slnx"))) + { + directory = directory.Parent; + } + + if (directory is null) + { + throw new DirectoryNotFoundException("Could not locate WiSave.Portal repository root."); + } + + return Path.Combine(directory.FullName, relativePath); + } +} diff --git a/tests/WiSave.Portal.UnitTests/Aspire/AppHostConfigurationTests.cs b/tests/WiSave.Portal.UnitTests/Aspire/AppHostConfigurationTests.cs new file mode 100644 index 0000000..23f1db6 --- /dev/null +++ b/tests/WiSave.Portal.UnitTests/Aspire/AppHostConfigurationTests.cs @@ -0,0 +1,192 @@ +using System.Text.Json; +using Xunit; + +namespace WiSave.Portal.UnitTests.Aspire; + +public sealed class AppHostConfigurationTests +{ + private const string AppHostProjectPath = "src/WiSave.Portal.AppHost/WiSave.Portal.AppHost.csproj"; + + [Fact] + public void Solution_ContainsTheAppHostProject() + { + var solution = File.ReadAllText(RepoPath("WiSave.Portal.slnx")); + + Assert.Contains(AppHostProjectPath, solution); + } + + [Fact] + public void AspireConfig_PointsAtTheAppHostProject() + { + using var config = JsonDocument.Parse(File.ReadAllText(RepoPath("aspire.config.json"))); + + var path = config.RootElement + .GetProperty("appHost") + .GetProperty("path") + .GetString(); + + Assert.Equal(AppHostProjectPath, path); + } + + [Fact] + public void AppHostProject_ReferencesEveryProjectItModelsAsAResource() + { + var project = File.ReadAllText(RepoPath(AppHostProjectPath)); + + // Attribute form rather than a nested element: Rider fails to evaluate the + // project's TargetFramework when the NuGet-based MSBuild SDK is declared as a child + // element, which leaves its run configuration unusable. Both forms are valid MSBuild + // and produce identical output; the version stays pinned to the Aspire CLI's. + Assert.Contains("Sdk=\"Microsoft.NET.Sdk;Aspire.AppHost.Sdk/13.4.6\"", project); + Assert.Contains("../WiSave.Portal.WebApi/WiSave.Portal.WebApi.csproj", project); + Assert.Contains("../WiSave.Portal.Migrations/WiSave.Portal.Migrations.csproj", project); + Assert.Contains("../WiSave.Portal.Console/WiSave.Portal.Console.csproj", project); + } + + [Fact] + public void AppHost_AvoidsTheKnownAspireStartupFailures() + { + var appHost = File.ReadAllText(RepoPath("src/WiSave.Portal.AppHost/AppHost.cs")); + + // Resource names are globally unique and case-insensitive across resource + // types: the database is "portal", so the project cannot also be "portal". + Assert.Contains("AddProject(\"portal-api\")", appHost); + Assert.DoesNotContain("AddProject(\"portal\")", appHost); + + // 13.4.6 defaults to postgres:18.3 and redis:8.6; both must be pinned. + Assert.Contains(".WithImageTag(\"17\")", appHost); + Assert.Contains(".WithImage(\"redis\", \"7-alpine\")", appHost); + + // AddProject has no args: parameter - passing one silently binds + // launchProfileName instead. + Assert.DoesNotContain("args:", appHost); + + // The migrator and the web project both read ConnectionStrings__Portal; + // WithReference would inject the lowercase resource name instead. + Assert.Contains(".WithEnvironment(\"ConnectionStrings__Portal\", db)", appHost); + Assert.DoesNotContain(".WithReference(db)", appHost); + + // WithHttpHealthCheck throws at AppHost startup for non-http-scheme + // resources, so only the portal may carry it. + Assert.Equal(1, Occurrences(appHost, "WithHttpHealthCheck")); + + // The Angular dev server proxies /api and /hubs to a fixed localhost:5100. + Assert.Contains("endpoint.Port = 5100", appHost); + Assert.Contains("endpoint.IsProxied = false", appHost); + } + + [Fact] + public void AppHost_TiesContainersToTheApplicationButKeepsTheirData() + { + var appHost = File.ReadAllText(RepoPath("src/WiSave.Portal.AppHost/AppHost.cs")); + + // Containers stop with the AppHost. ContainerLifetime.Persistent would outlive it, + // leaving 5432, 6379 and 5672 held after shutdown and colliding with the next run. + Assert.DoesNotContain("ContainerLifetime.Persistent", appHost); + + // Lifetime and storage are independent: every stateful resource keeps a named + // volume so stopping the stack costs no data. Redis matters as much as Postgres + // here — it holds the data-protection key ring, and a fresh one signs everyone out. + Assert.Contains(".WithDataVolume(\"portal-db\")", appHost); + Assert.Contains(".WithDataVolume(\"portal-redis\")", appHost); + Assert.Contains(".WithDataVolume(\"rabbitmq-data\")", appHost); + } + + [Fact] + public void AppHost_JoinsTheBrokerToTheSharedNetworkSiblingStacksResolveItOn() + { + var appHost = File.ReadAllText(RepoPath("src/WiSave.Portal.AppHost/AppHost.cs")); + + // Cross-repo contract: incomes, expenses and stock resolve the broker by these + // strings, so renaming one breaks stacks outside this repository. + Assert.Contains( + ".WithSharedNetworkAlias(\"wisave-net\", containerName: \"wisave-rabbitmq\", alias: \"rabbitmq\")", + appHost); + + // Postgres and Redis stay portal-private; sharing either widens the blast radius. + Assert.Equal(1, Occurrences(appHost, ".WithSharedNetworkAlias(")); + + // Reads like the obvious fix and compiles, but collides with DCP's own + // "--network bridge" and kills the container at create time with exit 125. + Assert.DoesNotContain("WithContainerRuntimeArgs", appHost); + } + + [Fact] + public void SharedNetworkAttach_RunsAfterStartupRatherThanAsAContainerArgument() + { + var extensions = File.ReadAllText( + RepoPath("src/WiSave.Portal.AppHost/SharedNetworkExtensions.cs")); + + // Aspire attaches its own network only after create, so this attach waits too. + Assert.Contains(".OnResourceReady(", extensions); + + // A random DCP suffix would leave nothing to attach to, or docker exec into. + Assert.Contains(".WithContainerName(containerName)", extensions); + + // A failed attach must warn, not take the local stack down. + Assert.Contains("LogWarning", extensions); + } + + [Fact] + public void HttpLaunchProfile_OptsInToUnsecuredTransport() + { + using var profiles = JsonDocument.Parse( + File.ReadAllText(RepoPath("src/WiSave.Portal.AppHost/Properties/launchSettings.json"))); + + var http = profiles.RootElement.GetProperty("profiles").GetProperty("http"); + + // aspire run takes the first profile, and this one is plain HTTP throughout — + // dashboard, OTLP and resource service. Without the opt-in the AppHost throws + // OptionsValidationException before starting a single resource. + Assert.StartsWith("http://", http.GetProperty("applicationUrl").GetString()); + Assert.Equal( + "true", + http.GetProperty("environmentVariables") + .GetProperty("ASPIRE_ALLOW_UNSECURED_TRANSPORT") + .GetString()); + } + + [Fact] + public void AppHost_NeverAutomatesMigrations() + { + var appHost = File.ReadAllText(RepoPath("src/WiSave.Portal.AppHost/AppHost.cs")); + + // Policy, not a temporary state: migrations are run manually and are never + // automated. The AppHost models no migrator and no seeder, and the portal is + // gated on neither. Beyond the policy, WaitForCompletion on a resource that + // exits non-zero would block the portal from ever starting, so an automated + // migrator also turns any migration failure into a silent hang. + Assert.DoesNotContain("WaitForCompletion", appHost); + Assert.DoesNotContain("Projects.WiSave_Portal_Migrations", appHost); + Assert.DoesNotContain("db-seed", appHost); + } + + private static int Occurrences(string value, string search) + { + var count = 0; + var startIndex = 0; + while ((startIndex = value.IndexOf(search, startIndex, StringComparison.Ordinal)) >= 0) + { + count++; + startIndex += search.Length; + } + + return count; + } + + private static string RepoPath(string relativePath) + { + var directory = new DirectoryInfo(AppContext.BaseDirectory); + while (directory is not null && !File.Exists(Path.Combine(directory.FullName, "WiSave.Portal.slnx"))) + { + directory = directory.Parent; + } + + if (directory is null) + { + throw new DirectoryNotFoundException("Could not locate WiSave.Portal repository root."); + } + + return Path.Combine(directory.FullName, relativePath); + } +} diff --git a/tests/WiSave.Portal.UnitTests/Auth/IdentityModelTests.cs b/tests/WiSave.Portal.UnitTests/Auth/IdentityModelTests.cs new file mode 100644 index 0000000..3aecf25 --- /dev/null +++ b/tests/WiSave.Portal.UnitTests/Auth/IdentityModelTests.cs @@ -0,0 +1,53 @@ +using Microsoft.EntityFrameworkCore; +using WiSave.Portal.Core.Infrastructure.Identity; +using WiSave.Portal.Core.Infrastructure.Database; +using Xunit; + +namespace WiSave.Portal.UnitTests.Auth; + +public class IdentityModelTests +{ + [Fact] + public void ApplicationUser_initializes_a_version_7_id() + { + var user = new ApplicationUser { Name = "Test User" }; + + var id = Guid.Parse(user.Id.ToString()!); + + Assert.NotEqual(Guid.Empty, id); + Assert.Equal(7, id.Version); + } + + [Fact] + public void ApplicationRole_initializes_a_version_7_id_and_preserves_its_name() + { + var role = new ApplicationRole("auditor"); + + Assert.NotEqual(Guid.Empty, role.Id); + Assert.Equal(7, role.Id.Version); + Assert.Equal("auditor", role.Name); + } + + [Fact] + public void PortalDbContext_uses_Guid_identity_keys_and_relationships() + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(nameof(PortalDbContext_uses_Guid_identity_keys_and_relationships)) + .Options; + using var context = new PortalDbContext(options); + + var identityEntities = context.Model.GetEntityTypes().ToArray(); + var rootKeyProperties = identityEntities + .Where(entity => entity.GetTableName() is "AspNetUsers" or "AspNetRoles") + .SelectMany(entity => entity.FindPrimaryKey()!.Properties); + var relationshipProperties = identityEntities + .SelectMany(entity => entity.GetProperties()) + .Where(property => property.Name is "UserId" or "RoleId"); + var identityIdProperties = rootKeyProperties + .Concat(relationshipProperties) + .ToArray(); + + Assert.NotEmpty(identityIdProperties); + Assert.All(identityIdProperties, property => Assert.Equal(typeof(Guid), property.ClrType)); + } +} diff --git a/tests/WiSave.Portal.UnitTests/Authorization/AccessManagementPolicyTests.cs b/tests/WiSave.Portal.UnitTests/Authorization/AccessManagementPolicyTests.cs new file mode 100644 index 0000000..7eaf52c --- /dev/null +++ b/tests/WiSave.Portal.UnitTests/Authorization/AccessManagementPolicyTests.cs @@ -0,0 +1,53 @@ +using WiSave.Portal.Core.Application.Authorization; +using Xunit; + +namespace WiSave.Portal.UnitTests.Authorization; + +public sealed class AccessManagementPolicyTests +{ + [Theory] + [InlineData("admin", true)] + [InlineData("ADMIN", true)] + [InlineData("superadmin", true)] + [InlineData("SuperAdmin", true)] + [InlineData("plan:free", false)] + [InlineData("auditor", false)] + [InlineData("", false)] + [InlineData(null, false)] + public void IsPrivilegedRole_MatchesAdminRolesCaseInsensitively(string? role, bool expected) + { + Assert.Equal(expected, AccessManagementPolicy.IsPrivilegedRole(role)); + } + + [Theory] + [InlineData(new[] { "plan:free" }, false)] + [InlineData(new[] { "plan:free", "admin" }, true)] + [InlineData(new[] { "superadmin" }, true)] + [InlineData(new string[0], false)] + public void CanReadAccessManagement_RequiresAtLeastOnePrivilegedRole(string[] roles, bool expected) + { + Assert.Equal(expected, AccessManagementPolicy.CanReadAccessManagement(roles)); + Assert.Equal(expected, AccessManagementPolicy.ContainsPrivilegedRole(roles)); + } + + [Theory] + [InlineData(new[] { "admin" }, false)] + [InlineData(new[] { "superadmin" }, true)] + [InlineData(new[] { "SUPERADMIN" }, true)] + [InlineData(new[] { "plan:premium" }, false)] + public void CanManagePrivilegedRoles_RequiresSuperAdmin(string[] roles, bool expected) + { + Assert.Equal(expected, AccessManagementPolicy.CanManagePrivilegedRoles(roles)); + } + + [Theory] + [InlineData("plan:free", true)] + [InlineData("PLAN:custom", true)] + [InlineData("admin", true)] + [InlineData("superadmin", true)] + [InlineData("auditor", false)] + public void IsReservedRoleName_BlocksPlanAndPrivilegedNames(string role, bool expected) + { + Assert.Equal(expected, AccessManagementPolicy.IsReservedRoleName(role)); + } +} diff --git a/tests/WiSave.Portal.UnitTests/Authorization/RolePermissionResolverTests.cs b/tests/WiSave.Portal.UnitTests/Authorization/RolePermissionResolverTests.cs new file mode 100644 index 0000000..a1efece --- /dev/null +++ b/tests/WiSave.Portal.UnitTests/Authorization/RolePermissionResolverTests.cs @@ -0,0 +1,139 @@ +using System.Security.Claims; +using Microsoft.AspNetCore.Identity; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using WiSave.Portal.Core.Infrastructure.Identity; +using WiSave.Portal.Authorization; +using WiSave.Portal.Core.Abstractions.Authorization; +using WiSave.Portal.Contracts.Authorization; +using WiSave.Portal.Core.Infrastructure.Database; +using Xunit; + +namespace WiSave.Portal.UnitTests.Authorization; + +public sealed class RolePermissionResolverTests +{ + [Fact] + public async Task GetPermissionsAsync_AdminRole_ReturnsWildcardOnly() + { + using var harness = new IdentityHarness(nameof(GetPermissionsAsync_AdminRole_ReturnsWildcardOnly)); + await harness.CreateRoleAsync(PortalRoles.Admin, PortalPermissions.Incomes.Read); + var user = await harness.CreateUserAsync("admin@example.com", PortalRoles.Admin); + IPermissionResolver resolver = new RolePermissionResolver(harness.UserManager, harness.RoleManager); + + var permissions = await resolver.GetPermissionsAsync(user.Id, TestContext.Current.CancellationToken); + + Assert.Equal(["*"], permissions.Order(StringComparer.Ordinal)); + } + + [Fact] + public async Task GetPermissionsAsync_SuperAdminRole_ReturnsWildcardOnly() + { + using var harness = new IdentityHarness(nameof(GetPermissionsAsync_SuperAdminRole_ReturnsWildcardOnly)); + await harness.CreateRoleAsync(PortalRoles.SuperAdmin); + var user = await harness.CreateUserAsync("root@example.com", PortalRoles.SuperAdmin); + IPermissionResolver resolver = new RolePermissionResolver(harness.UserManager, harness.RoleManager); + + var permissions = await resolver.GetPermissionsAsync(user.Id, TestContext.Current.CancellationToken); + + Assert.Equal(["*"], permissions.Order(StringComparer.Ordinal)); + } + + [Fact] + public async Task GetPermissionsAsync_PlanRole_ReturnsPermissionClaimsOnly() + { + using var harness = new IdentityHarness(nameof(GetPermissionsAsync_PlanRole_ReturnsPermissionClaimsOnly)); + await harness.CreateRoleAsync( + PortalRoles.FreePlan, + PortalPermissions.Incomes.Read, + PortalPermissions.Expenses.Read); + var role = await harness.RoleManager.FindByNameAsync(PortalRoles.FreePlan); + Assert.NotNull(role); + Assert.True((await harness.RoleManager.AddClaimAsync(role, new Claim("unrelated", "ignored"))).Succeeded); + var user = await harness.CreateUserAsync("free@example.com", PortalRoles.FreePlan); + IPermissionResolver resolver = new RolePermissionResolver(harness.UserManager, harness.RoleManager); + + var permissions = await resolver.GetPermissionsAsync(user.Id, TestContext.Current.CancellationToken); + + Assert.Equal( + [PortalPermissions.Expenses.Read, PortalPermissions.Incomes.Read], + permissions.Order(StringComparer.Ordinal)); + } + + [Fact] + public async Task GetPermissionsAsync_UnknownUser_ReturnsEmptySet() + { + using var harness = new IdentityHarness(nameof(GetPermissionsAsync_UnknownUser_ReturnsEmptySet)); + IPermissionResolver resolver = new RolePermissionResolver(harness.UserManager, harness.RoleManager); + + var permissions = await resolver.GetPermissionsAsync( + Guid.CreateVersion7(), + TestContext.Current.CancellationToken); + + Assert.Empty(permissions); + } + + [Fact] + public void AddPortalAuthorization_RegistersRolePermissionResolverBehindIPermissionResolver() + { + var services = new ServiceCollection(); + + services.AddPortalAuthorization(); + + var descriptor = Assert.Single(services, service => service.ServiceType == typeof(IPermissionResolver)); + Assert.Equal(typeof(RolePermissionResolver), descriptor.ImplementationType); + Assert.Equal(ServiceLifetime.Scoped, descriptor.Lifetime); + } + + private sealed class IdentityHarness : IDisposable + { + private readonly ServiceProvider _provider; + private readonly IServiceScope _scope; + + public IdentityHarness(string databaseName) + { + var services = new ServiceCollection(); + services.AddLogging(); + services.AddDbContext(options => options.UseInMemoryDatabase(databaseName)); + services.AddIdentityCore(options => options.User.RequireUniqueEmail = true) + .AddRoles() + .AddEntityFrameworkStores(); + + _provider = services.BuildServiceProvider(); + _scope = _provider.CreateScope(); + UserManager = _scope.ServiceProvider.GetRequiredService>(); + RoleManager = _scope.ServiceProvider.GetRequiredService>(); + } + + public UserManager UserManager { get; } + + public RoleManager RoleManager { get; } + + public async Task CreateRoleAsync(string roleName, params string[] permissions) + { + var role = new ApplicationRole(roleName); + Assert.True((await RoleManager.CreateAsync(role)).Succeeded); + + foreach (var permission in permissions) + { + Assert.True((await RoleManager.AddClaimAsync( + role, + new Claim(PortalClaimTypes.Permission, permission))).Succeeded); + } + } + + public async Task CreateUserAsync(string email, string roleName) + { + var user = new ApplicationUser { Name = "Test User", UserName = email, Email = email }; + Assert.True((await UserManager.CreateAsync(user)).Succeeded); + Assert.True((await UserManager.AddToRoleAsync(user, roleName)).Succeeded); + return user; + } + + public void Dispose() + { + _scope.Dispose(); + _provider.Dispose(); + } + } +} diff --git a/tests/WiSave.Portal.UnitTests/Ci/WorkflowConfigurationTests.cs b/tests/WiSave.Portal.UnitTests/Ci/WorkflowConfigurationTests.cs new file mode 100644 index 0000000..e45ec51 --- /dev/null +++ b/tests/WiSave.Portal.UnitTests/Ci/WorkflowConfigurationTests.cs @@ -0,0 +1,42 @@ +using Xunit; + +namespace WiSave.Portal.UnitTests.Ci; + +public sealed class WorkflowConfigurationTests +{ + [Fact] + public void PortalValidation_BuildsTheSolutionBetweenRestoreAndTest() + { + var workflow = File.ReadAllText(RepoPath(".github/workflows/portal-validation.yml")); + + var restore = workflow.IndexOf( + "dotnet restore WiSave.Portal.slnx", + StringComparison.Ordinal); + var build = workflow.IndexOf( + "dotnet build WiSave.Portal.slnx --configuration Release --no-restore", + StringComparison.Ordinal); + var test = workflow.IndexOf( + "dotnet test tests/WiSave.Portal.UnitTests/WiSave.Portal.UnitTests.csproj", + StringComparison.Ordinal); + + Assert.True(restore >= 0, "The workflow must restore the solution."); + Assert.True(build > restore, "The workflow must build the solution after restore."); + Assert.True(test > build, "The workflow must run tests after the solution build."); + } + + private static string RepoPath(string relativePath) + { + var directory = new DirectoryInfo(AppContext.BaseDirectory); + while (directory is not null && !File.Exists(Path.Combine(directory.FullName, "WiSave.Portal.slnx"))) + { + directory = directory.Parent; + } + + if (directory is null) + { + throw new DirectoryNotFoundException("Could not locate WiSave.Portal repository root."); + } + + return Path.Combine(directory.FullName, relativePath); + } +} diff --git a/tests/WiSave.Portal.UnitTests/EventHandlers/IncomesNotificationsEventHandlerTests.cs b/tests/WiSave.Portal.UnitTests/EventHandlers/IncomeEventNotificationTests.cs similarity index 85% rename from tests/WiSave.Portal.UnitTests/EventHandlers/IncomesNotificationsEventHandlerTests.cs rename to tests/WiSave.Portal.UnitTests/EventHandlers/IncomeEventNotificationTests.cs index 15b7aa8..015a2b4 100644 --- a/tests/WiSave.Portal.UnitTests/EventHandlers/IncomesNotificationsEventHandlerTests.cs +++ b/tests/WiSave.Portal.UnitTests/EventHandlers/IncomeEventNotificationTests.cs @@ -1,9 +1,7 @@ -using Microsoft.AspNetCore.SignalR; using NSubstitute; using WiSave.Incomes.Contracts.Events; -using WiSave.Portal.EventHandlers; -using WiSave.Portal.Hubs; -using WiSave.Portal.Hubs.Realtime; +using WiSave.Portal.Core.Abstractions.Realtime; +using WiSave.Portal.Core.Application.EventHandlers; using Xunit; using ExpenseCreated = WiSave.Expenses.Contracts.Events.ExpenseCreated; using ExpenseCurrency = WiSave.Expenses.Contracts.Models.Currency; @@ -15,7 +13,7 @@ namespace WiSave.Portal.UnitTests.EventHandlers; -public sealed class IncomesNotificationsEventHandlerTests +public sealed class IncomeEventNotificationTests { private static readonly Guid UserId = Guid.Parse("11111111-1111-1111-1111-111111111111"); private static readonly Guid CreatedIncomeId = Guid.Parse("12121212-1212-1212-1212-121212121212"); @@ -126,23 +124,19 @@ public async Task Handle_PublishesRealtimeEventToUserGroup( Guid expectedEntityId) { var cancellationToken = new CancellationTokenSource().Token; - var clientProxy = Substitute.For(); - var hubClients = Substitute.For(); - var hub = Substitute.For>(); - hub.Clients.Returns(hubClients); - hubClients.Group(UserId.ToString()).Returns(clientProxy); + var notifier = Substitute.For(); - var handler = new IncomesNotificationsEventHandler(hub); + var handler = new RealtimeNotificationsEventHandler(notifier); await Handle(handler, message, cancellationToken); - await clientProxy.Received(1).SendCoreAsync( - "realtimeEvent", - Arg.Is(arguments => ContainsExpectedEnvelope(arguments, expectedDomain, expectedEventType, expectedEntityId, message)), + await notifier.Received(1).NotifyUserAsync( + UserId.ToString(), + Arg.Is(envelope => IsExpectedEnvelope(envelope, expectedDomain, expectedEventType, expectedEntityId, message)), cancellationToken); } - private static Task Handle(IncomesNotificationsEventHandler handler, object message, CancellationToken cancellationToken) + private static Task Handle(RealtimeNotificationsEventHandler handler, object message, CancellationToken cancellationToken) { return message switch { @@ -160,16 +154,15 @@ private static Task Handle(IncomesNotificationsEventHandler handler, object mess }; } - private static bool ContainsExpectedEnvelope( - object[] arguments, + private static bool IsExpectedEnvelope( + RealtimeEnvelope? envelope, string expectedDomain, string expectedEventType, Guid expectedEntityId, object expectedPayload) { - Assert.Single(arguments); - var envelope = Assert.IsType(arguments[0]); - + Assert.NotNull(envelope); + Assert.NotNull(envelope); Assert.Equal(expectedDomain, envelope.Domain); Assert.Equal(expectedEventType, envelope.EventType); Assert.Equal(expectedEntityId.ToString(), envelope.EntityId); diff --git a/tests/WiSave.Portal.UnitTests/EventHandlers/RealtimePayloadContractTests.cs b/tests/WiSave.Portal.UnitTests/EventHandlers/RealtimePayloadContractTests.cs new file mode 100644 index 0000000..384f1d9 --- /dev/null +++ b/tests/WiSave.Portal.UnitTests/EventHandlers/RealtimePayloadContractTests.cs @@ -0,0 +1,148 @@ +using System.Text.Json; +using System.Text.Json.Nodes; +using WiSave.Portal.Core.Abstractions.Realtime; +using WiSave.Portal.Core.Application.EventHandlers; +using WiSave.Stock.Contracts.Events.Portfolios; +using WiSave.Stock.Contracts.Events.Positions; +using WiSave.Stock.Contracts.Models; +using Xunit; + +namespace WiSave.Portal.UnitTests.EventHandlers; + +/// +/// Pins the wire shape of the realtime payloads the portal forwards. +/// +/// +/// The portal never reads these payloads — it wraps a downstream integration event and +/// relays it — but the Angular client models them field for field in +/// wisave-ui/src/app/core/signalr/stocks-signalr.types.ts. A rename or removal in +/// WiSave.Stock.Contracts would therefore reach the browser silently and break the +/// SPA at runtime with nothing failing in either build. +/// +/// Each case below lists the fields that file declares. Extra fields are fine — additive +/// downstream changes do not break the client — so these assert presence, not equality. +/// Property names are checked after camelCase serialization, which is what SignalR's JSON +/// protocol emits and what the client's types assume. +/// +public sealed class RealtimePayloadContractTests +{ + private static readonly JsonSerializerOptions SignalRLike = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase + }; + + private static readonly Guid UserId = Guid.Parse("11111111-1111-1111-1111-111111111111"); + private static readonly Guid PositionId = Guid.Parse("22222222-2222-2222-2222-222222222222"); + private static readonly Guid OrderId = Guid.Parse("33333333-3333-3333-3333-333333333333"); + private static readonly Guid PortfolioId = Guid.Parse("44444444-4444-4444-4444-444444444444"); + private static readonly Guid BrokerId = Guid.Parse("55555555-5555-5555-5555-555555555555"); + private static readonly DateTimeOffset At = new(2026, 7, 7, 12, 0, 0, TimeSpan.Zero); + + private static Instrument Apple() => + new("AAPL", "US0378331005", "Apple Inc.", new Currency("USD"), "XNAS"); + + public static TheoryData ClientDeclaredFields() => new() + { + { + new PositionOpened( + PositionId, UserId, PortfolioId, BrokerId, Apple(), null, OrderId, At, + 10m, 5m, 1m, 50m, 51m, null, At), + ["positionId", "userId", "portfolioId", "brokerId", "instrument"] + }, + { + new PositionBuyOrderPlaced( + PositionId, UserId, PortfolioId, OrderId, At, 10m, 5m, 1m, 50m, 51m, null, 10m, At), + ["positionId", "userId", "portfolioId", "orderId"] + }, + { + new PositionSellOrderPlaced( + PositionId, UserId, PortfolioId, OrderId, At, 4m, 6m, 1m, 24m, 23m, null, 6m, At), + ["positionId", "userId", "portfolioId", "orderId"] + }, + { + new PositionClosed(PositionId, UserId, PortfolioId, At, At), + ["positionId", "userId", "portfolioId"] + }, + { + new PositionReopened(PositionId, UserId, PortfolioId, At, At), + ["positionId", "userId", "portfolioId"] + }, + { + new PortfolioCreated(PortfolioId, UserId, "Growth", "PLN", BrokerId, At), + ["portfolioId", "name", "currency", "brokerId"] + }, + }; + + [Theory] + [MemberData(nameof(ClientDeclaredFields))] + public async Task ForwardedPayload_KeepsTheFieldsTheAngularClientDeclares( + object message, + string[] requiredFields) + { + var payload = await ForwardAndCapturePayloadAsync(message); + + foreach (var field in requiredFields) + { + Assert.True( + payload.ContainsKey(field), + $"wisave-ui declares '{field}' on this payload, but the forwarded event no longer " + + $"carries it. Present fields: {string.Join(", ", payload.Select(p => p.Key))}"); + } + } + + [Fact] + public async Task ForwardedPositionOpened_KeepsTheInstrumentFieldsTheClientDeclares() + { + var payload = await ForwardAndCapturePayloadAsync( + new PositionOpened( + PositionId, UserId, PortfolioId, BrokerId, Apple(), null, OrderId, At, + 10m, 5m, 1m, 50m, 51m, null, At)); + + var instrument = Assert.IsType(payload["instrument"]); + + foreach (var field in new[] { "ticker", "isin", "name", "currency", "marketMic" }) + { + Assert.True( + instrument.ContainsKey(field), + $"wisave-ui declares instrument.{field}. Present: " + + string.Join(", ", instrument.Select(p => p.Key))); + } + + Assert.Equal("XNAS", instrument["marketMic"]!.GetValue()); + } + + private static async Task ForwardAndCapturePayloadAsync(object message) + { + var notifier = new CapturingNotifier(); + var handler = new RealtimeNotificationsEventHandler(notifier); + + await (message switch + { + PositionOpened e => handler.Handle(e, TestContext.Current.CancellationToken), + PositionBuyOrderPlaced e => handler.Handle(e, TestContext.Current.CancellationToken), + PositionSellOrderPlaced e => handler.Handle(e, TestContext.Current.CancellationToken), + PositionClosed e => handler.Handle(e, TestContext.Current.CancellationToken), + PositionReopened e => handler.Handle(e, TestContext.Current.CancellationToken), + PortfolioCreated e => handler.Handle(e, TestContext.Current.CancellationToken), + _ => throw new ArgumentOutOfRangeException(nameof(message), message, "Unsupported event.") + }); + + Assert.NotNull(notifier.Envelope); + var node = JsonSerializer.SerializeToNode(notifier.Envelope.Payload, SignalRLike); + return Assert.IsType(node); + } + + private sealed class CapturingNotifier : IRealtimeNotifier + { + public RealtimeEnvelope? Envelope { get; private set; } + + public Task NotifyUserAsync( + string userId, + RealtimeEnvelope envelope, + CancellationToken cancellationToken = default) + { + Envelope = envelope; + return Task.CompletedTask; + } + } +} diff --git a/tests/WiSave.Portal.UnitTests/EventHandlers/StockEventNotificationTests.cs b/tests/WiSave.Portal.UnitTests/EventHandlers/StockEventNotificationTests.cs new file mode 100644 index 0000000..f77ec95 --- /dev/null +++ b/tests/WiSave.Portal.UnitTests/EventHandlers/StockEventNotificationTests.cs @@ -0,0 +1,82 @@ +using NSubstitute; +using WiSave.Portal.Core.Abstractions.Realtime; +using WiSave.Portal.Core.Application.EventHandlers; +using WiSave.Stock.Contracts.Events.Portfolios; +using WiSave.Stock.Contracts.Events.Positions; +using WiSave.Stock.Contracts.Models; +using Xunit; + +namespace WiSave.Portal.UnitTests.EventHandlers; + +public sealed class StockEventNotificationTests +{ + private static readonly Guid UserId = Guid.Parse("11111111-1111-1111-1111-111111111111"); + private static readonly Guid PositionId = Guid.Parse("22222222-2222-2222-2222-222222222222"); + private static readonly Guid OrderId = Guid.Parse("33333333-3333-3333-3333-333333333333"); + private static readonly Guid PortfolioId = Guid.Parse("44444444-4444-4444-4444-444444444444"); + private static readonly Guid BrokerId = Guid.Parse("55555555-5555-5555-5555-555555555555"); + private static readonly DateTimeOffset At = new(2026, 7, 7, 12, 0, 0, TimeSpan.Zero); + + public static TheoryData NotificationEvents() => new() + { + { new PositionOpened(PositionId, UserId, PortfolioId, BrokerId, new Instrument("AAPL", "US0378331005", "Apple Inc.", new Currency("USD"), "XNAS"), null, OrderId, At, 10m, 5m, 1m, 50m, 51m, null, At), "position.opened", "22222222-2222-2222-2222-222222222222" }, + { new PositionBuyOrderPlaced(PositionId, UserId, PortfolioId, OrderId, At, 10m, 5m, 1m, 50m, 51m, null, 10m, At), "position.buy_order_placed", "22222222-2222-2222-2222-222222222222" }, + { new PositionSellOrderPlaced(PositionId, UserId, PortfolioId, OrderId, At, 4m, 6m, 1m, 24m, 23m, null, 6m, At), "position.sell_order_placed", "22222222-2222-2222-2222-222222222222" }, + { new PositionClosed(PositionId, UserId, PortfolioId, At, At), "position.closed", "22222222-2222-2222-2222-222222222222" }, + { new PositionReopened(PositionId, UserId, PortfolioId, At, At), "position.reopened", "22222222-2222-2222-2222-222222222222" }, + { new PortfolioCreated(PortfolioId, UserId, "Growth", "PLN", BrokerId, At), "portfolio.created", "44444444-4444-4444-4444-444444444444" }, + }; + + [Theory] + [MemberData(nameof(NotificationEvents))] + public async Task Handle_PushesRealtimeEventToUserGroup(object message, string expectedEventType, string expectedEntityId) + { + var cancellationToken = new CancellationTokenSource().Token; + var notifier = Substitute.For(); + + var handler = new RealtimeNotificationsEventHandler(notifier); + + await Handle(handler, message, cancellationToken); + + await notifier.Received(1).NotifyUserAsync( + UserId.ToString(), + Arg.Is(envelope => IsExpectedEnvelope(envelope, expectedEventType, expectedEntityId, message)), + cancellationToken); + } + + private static Task Handle(RealtimeNotificationsEventHandler handler, object message, CancellationToken cancellationToken) => + message switch + { + PositionOpened e => handler.Handle(e, cancellationToken), + PositionBuyOrderPlaced e => handler.Handle(e, cancellationToken), + PositionSellOrderPlaced e => handler.Handle(e, cancellationToken), + PositionClosed e => handler.Handle(e, cancellationToken), + PositionReopened e => handler.Handle(e, cancellationToken), + PortfolioCreated e => handler.Handle(e, cancellationToken), + _ => throw new ArgumentOutOfRangeException(nameof(message), message, "Unsupported message type.") + }; + + private static bool IsExpectedEnvelope( + RealtimeEnvelope? envelope, + string expectedEventType, + string expectedEntityId, + object expectedPayload) + { + Assert.NotNull(envelope); + Assert.NotNull(envelope); + Assert.Equal("stocks", envelope.Domain); + Assert.Equal(expectedEventType, envelope.EventType); + Assert.Equal(expectedEntityId, envelope.EntityId); + Assert.Same(expectedPayload, envelope.Payload); + + if (envelope.Payload is PositionOpened positionOpened) + { + Assert.Equal(PositionId, positionOpened.PositionId); + Assert.Equal(PortfolioId, positionOpened.PortfolioId); + Assert.Equal(BrokerId, positionOpened.BrokerId); + Assert.Equal("XNAS", positionOpened.Instrument.MarketMic); + } + + return true; + } +} diff --git a/tests/WiSave.Portal.UnitTests/EventHandlers/StockNotificationsEventHandlerTests.cs b/tests/WiSave.Portal.UnitTests/EventHandlers/StockNotificationsEventHandlerTests.cs deleted file mode 100644 index ca36c71..0000000 --- a/tests/WiSave.Portal.UnitTests/EventHandlers/StockNotificationsEventHandlerTests.cs +++ /dev/null @@ -1,77 +0,0 @@ -using Microsoft.AspNetCore.SignalR; -using NSubstitute; -using WiSave.Portal.EventHandlers; -using WiSave.Portal.Hubs; -using WiSave.Portal.Hubs.Realtime; -using WiSave.Stock.Contracts.Events.Portfolios; -using WiSave.Stock.Contracts.Events.Positions; -using Xunit; - -namespace WiSave.Portal.UnitTests.EventHandlers; - -public sealed class StockNotificationsEventHandlerTests -{ - private static readonly Guid UserId = Guid.Parse("11111111-1111-1111-1111-111111111111"); - private static readonly Guid PositionId = Guid.Parse("22222222-2222-2222-2222-222222222222"); - private static readonly Guid OrderId = Guid.Parse("33333333-3333-3333-3333-333333333333"); - private static readonly Guid PortfolioId = Guid.Parse("44444444-4444-4444-4444-444444444444"); - private static readonly DateTimeOffset At = new(2026, 7, 7, 12, 0, 0, TimeSpan.Zero); - - public static TheoryData NotificationEvents() => new() - { - { new PositionBuyOrderPlaced(PositionId, UserId, OrderId, At, 10m, 5m, 1m, 50m, 51m, null, 10m, At), "position.buy_order_placed", "22222222-2222-2222-2222-222222222222" }, - { new PositionSellOrderPlaced(PositionId, UserId, OrderId, At, 4m, 6m, 1m, 24m, 23m, null, 6m, At), "position.sell_order_placed", "22222222-2222-2222-2222-222222222222" }, - { new PositionClosed(PositionId, UserId, At, At), "position.closed", "22222222-2222-2222-2222-222222222222" }, - { new PositionReopened(PositionId, UserId, At, At), "position.reopened", "22222222-2222-2222-2222-222222222222" }, - { new PortfolioCreated(PortfolioId, UserId, "Growth", "PLN", At), "portfolio.created", "44444444-4444-4444-4444-444444444444" }, - }; - - [Theory] - [MemberData(nameof(NotificationEvents))] - public async Task Handle_PushesRealtimeEventToUserGroup(object message, string expectedEventType, string expectedEntityId) - { - var cancellationToken = new CancellationTokenSource().Token; - var clientProxy = Substitute.For(); - var hubClients = Substitute.For(); - var hub = Substitute.For>(); - hub.Clients.Returns(hubClients); - hubClients.Group(UserId.ToString()).Returns(clientProxy); - - var handler = new StockNotificationsEventHandler(hub); - - await Handle(handler, message, cancellationToken); - - await clientProxy.Received(1).SendCoreAsync( - "realtimeEvent", - Arg.Is(arguments => ContainsExpectedEnvelope(arguments, expectedEventType, expectedEntityId, message)), - cancellationToken); - } - - private static Task Handle(StockNotificationsEventHandler handler, object message, CancellationToken cancellationToken) => - message switch - { - PositionBuyOrderPlaced e => handler.Handle(e, cancellationToken), - PositionSellOrderPlaced e => handler.Handle(e, cancellationToken), - PositionClosed e => handler.Handle(e, cancellationToken), - PositionReopened e => handler.Handle(e, cancellationToken), - PortfolioCreated e => handler.Handle(e, cancellationToken), - _ => throw new ArgumentOutOfRangeException(nameof(message), message, "Unsupported message type.") - }; - - private static bool ContainsExpectedEnvelope( - object[] arguments, - string expectedEventType, - string expectedEntityId, - object expectedPayload) - { - Assert.Single(arguments); - var envelope = Assert.IsType(arguments[0]); - - Assert.Equal("stocks", envelope.Domain); - Assert.Equal(expectedEventType, envelope.EventType); - Assert.Equal(expectedEntityId, envelope.EntityId); - Assert.Same(expectedPayload, envelope.Payload); - - return true; - } -} diff --git a/tests/WiSave.Portal.UnitTests/Gateway/DownstreamProxyErrorMiddlewareTests.cs b/tests/WiSave.Portal.UnitTests/Gateway/DownstreamProxyErrorMiddlewareTests.cs new file mode 100644 index 0000000..00dc4e2 --- /dev/null +++ b/tests/WiSave.Portal.UnitTests/Gateway/DownstreamProxyErrorMiddlewareTests.cs @@ -0,0 +1,253 @@ +using System.Text.Json; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http.Features; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using NSubstitute; +using WiSave.Portal.Gateway; +using Yarp.ReverseProxy.Configuration; +using Yarp.ReverseProxy.Forwarder; +using Yarp.ReverseProxy.Model; +using Xunit; + +namespace WiSave.Portal.UnitTests.Gateway; + +public sealed class DownstreamProxyErrorMiddlewareTests +{ + private static readonly IServiceProvider ProblemDetailsServices = new ServiceCollection() + .AddLogging() + .BuildServiceProvider(); + + [Theory] + [InlineData( + ForwarderError.Request, + StatusCodes.Status502BadGateway, + StatusCodes.Status503ServiceUnavailable, + "Downstream service unavailable", + "The 'incomes' service is temporarily unavailable. Please try again later.", + "downstream_service_unavailable")] + [InlineData( + ForwarderError.NoAvailableDestinations, + StatusCodes.Status503ServiceUnavailable, + StatusCodes.Status503ServiceUnavailable, + "Downstream service unavailable", + "The 'incomes' service is temporarily unavailable. Please try again later.", + "downstream_service_unavailable")] + [InlineData( + ForwarderError.RequestTimedOut, + StatusCodes.Status504GatewayTimeout, + StatusCodes.Status504GatewayTimeout, + "Downstream service timed out", + "The 'incomes' service did not respond in time. Please try again later.", + "downstream_service_timeout")] + public async Task InvokeAsync_ReturnsHumanReadableProblem_WhenExpectedForwardingFails( + ForwarderError error, + int yarpStatusCode, + int expectedStatusCode, + string expectedTitle, + string expectedDetail, + string expectedCode) + { + var context = CreateContext(); + var middleware = CreateErrorMiddleware( + error, + yarpStatusCode, + new HttpRequestException("Sensitive downstream error at localhost:5300")); + + await middleware.InvokeAsync(context); + + Assert.Equal(expectedStatusCode, context.Response.StatusCode); + Assert.Equal("application/problem+json", context.Response.ContentType); + + var problem = await ReadProblemAsync(context); + Assert.Equal(expectedTitle, problem.GetProperty("title").GetString()); + Assert.Equal(expectedDetail, problem.GetProperty("detail").GetString()); + Assert.Equal(expectedCode, problem.GetProperty("code").GetString()); + Assert.Equal("incomes", problem.GetProperty("service").GetString()); + Assert.DoesNotContain("localhost:5300", problem.ToString(), StringComparison.OrdinalIgnoreCase); + Assert.DoesNotContain("sensitive downstream error", problem.ToString(), StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public async Task InvokeAsync_LogsConciseWarningWithoutException_WhenDownstreamRequestFails() + { + var context = CreateContext(); + var logger = new TestLogger(); + var middleware = CreateErrorMiddleware( + ForwarderError.Request, + StatusCodes.Status502BadGateway, + new HttpRequestException("Connection refused (localhost:5300)"), + logger); + + await middleware.InvokeAsync(context); + + var entry = Assert.Single(logger.Entries); + Assert.Equal(LogLevel.Warning, entry.Level); + Assert.Null(entry.Exception); + Assert.Contains("incomes", entry.Message, StringComparison.OrdinalIgnoreCase); + Assert.Contains(HttpMethods.Get, entry.Message, StringComparison.Ordinal); + Assert.Contains("/api/incomes/categories", entry.Message, StringComparison.Ordinal); + Assert.DoesNotContain("localhost:5300", entry.Message, StringComparison.OrdinalIgnoreCase); + Assert.DoesNotContain("connection refused", entry.Message, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public async Task InvokeAsync_PreservesDownstreamResponse_WhenForwardingSucceeds() + { + var context = CreateContext(); + var middleware = new DownstreamProxyErrorMiddleware( + next: async httpContext => + { + httpContext.Response.StatusCode = StatusCodes.Status418ImATeapot; + await httpContext.Response.WriteAsync( + "downstream response", + TestContext.Current.CancellationToken); + }, + NullLogger.Instance); + + await middleware.InvokeAsync(context); + + Assert.Equal(StatusCodes.Status418ImATeapot, context.Response.StatusCode); + Assert.Equal("downstream response", await ReadResponseBodyAsync(context)); + } + + [Fact] + public async Task InvokeAsync_DoesNotRewriteResponse_WhenResponseHasStarted() + { + var context = CreateContext(); + await context.Response.Body.WriteAsync( + "partial response"u8.ToArray(), + TestContext.Current.CancellationToken); + var startedResponse = Substitute.For(); + startedResponse.StatusCode.Returns(StatusCodes.Status200OK); + startedResponse.HasStarted.Returns(true); + context.Features.Set(startedResponse); + var errorFeature = new TestForwarderErrorFeature( + ForwarderError.Request, + new HttpRequestException("The downstream connection failed after response start.")); + var middleware = new DownstreamProxyErrorMiddleware( + next: httpContext => + { + httpContext.Features.Set(errorFeature); + return Task.CompletedTask; + }, + NullLogger.Instance); + + await middleware.InvokeAsync(context); + + Assert.Equal(StatusCodes.Status200OK, context.Response.StatusCode); + Assert.Equal("partial response", await ReadResponseBodyAsync(context)); + } + + [Fact] + public async Task InvokeAsync_LogsExceptionAtError_WhenForwarderFailureIsUnexpected() + { + var context = CreateContext(); + var exception = new IOException("The downstream response ended unexpectedly."); + var logger = new TestLogger(); + var middleware = CreateErrorMiddleware( + ForwarderError.ResponseBodyDestination, + StatusCodes.Status502BadGateway, + exception, + logger); + + await middleware.InvokeAsync(context); + + Assert.Equal(StatusCodes.Status502BadGateway, context.Response.StatusCode); + Assert.Equal(0, context.Response.Body.Length); + var entry = Assert.Single(logger.Entries); + Assert.Equal(LogLevel.Error, entry.Level); + Assert.Same(exception, entry.Exception); + Assert.Contains("incomes", entry.Message, StringComparison.OrdinalIgnoreCase); + Assert.Contains("ResponseBodyDestination", entry.Message, StringComparison.Ordinal); + } + + private static DefaultHttpContext CreateContext() + { + var context = new DefaultHttpContext + { + RequestServices = ProblemDetailsServices + }; + context.Request.Method = HttpMethods.Get; + context.Request.Path = "/api/incomes/categories"; + context.Response.Body = new MemoryStream(); + + var proxyFeature = Substitute.For(); + proxyFeature.Route.Returns(new RouteModel( + new RouteConfig + { + RouteId = "test-route", + ClusterId = "test-cluster", + Match = new RouteMatch { Path = "/{**catch-all}" }, + Metadata = new Dictionary + { + [DownstreamServiceAvailabilityMiddleware.MetadataKey] = "incomes" + } + }, + cluster: null, + HttpTransformer.Default)); + context.Features.Set(proxyFeature); + + return context; + } + + private static DownstreamProxyErrorMiddleware CreateErrorMiddleware( + ForwarderError error, + int yarpStatusCode, + Exception? exception = null, + ILogger? logger = null) + { + var errorFeature = new TestForwarderErrorFeature(error, exception); + return new DownstreamProxyErrorMiddleware( + next: httpContext => + { + httpContext.Response.StatusCode = yarpStatusCode; + httpContext.Features.Set(errorFeature); + return Task.CompletedTask; + }, + logger ?? NullLogger.Instance); + } + + private static async Task ReadProblemAsync(HttpContext context) + { + context.Response.Body.Position = 0; + using var problem = await JsonDocument.ParseAsync( + context.Response.Body, + cancellationToken: TestContext.Current.CancellationToken); + return problem.RootElement.Clone(); + } + + private static async Task ReadResponseBodyAsync(HttpContext context) + { + context.Response.Body.Position = 0; + using var reader = new StreamReader(context.Response.Body); + return await reader.ReadToEndAsync(TestContext.Current.CancellationToken); + } + + private sealed class TestLogger : ILogger + { + public List Entries { get; } = []; + + public IDisposable? BeginScope(TState state) + where TState : notnull => null; + + public bool IsEnabled(LogLevel logLevel) => true; + + public void Log( + LogLevel logLevel, + EventId eventId, + TState state, + Exception? exception, + Func formatter) + { + Entries.Add(new LogEntry(logLevel, formatter(state, exception), exception)); + } + } + + private sealed record LogEntry(LogLevel Level, string Message, Exception? Exception); + + private sealed record TestForwarderErrorFeature( + ForwarderError Error, + Exception? Exception) : IForwarderErrorFeature; +} diff --git a/tests/WiSave.Portal.UnitTests/Gateway/ProxyConfigurationTests.cs b/tests/WiSave.Portal.UnitTests/Gateway/ProxyConfigurationTests.cs index 2469817..88a330b 100644 --- a/tests/WiSave.Portal.UnitTests/Gateway/ProxyConfigurationTests.cs +++ b/tests/WiSave.Portal.UnitTests/Gateway/ProxyConfigurationTests.cs @@ -1,4 +1,10 @@ using System.Text.Json; +using System.Text.RegularExpressions; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using WiSave.Portal.Gateway; using Xunit; namespace WiSave.Portal.UnitTests.Gateway; @@ -11,7 +17,7 @@ public sealed class ProxyConfigurationTests [InlineData("expenses-route", "expenses")] public void Appsettings_RouteDeclaresDownstreamServiceMetadata(string routeId, string service) { - var appsettings = JsonDocument.Parse(File.ReadAllText(RepoPath("src/WiSave.Portal/appsettings.json"))); + var appsettings = JsonDocument.Parse(File.ReadAllText(RepoPath("src/WiSave.Portal.WebApi/appsettings.json"))); var configuredService = appsettings.RootElement .GetProperty("ReverseProxy") @@ -27,87 +33,43 @@ public void Appsettings_RouteDeclaresDownstreamServiceMetadata(string routeId, s [Fact] public void DevelopmentAppsettings_ConfiguresLocalDownstreamAvailability() { - var appsettings = JsonDocument.Parse(File.ReadAllText(RepoPath("src/WiSave.Portal/appsettings.Development.json"))); + var appsettings = JsonDocument.Parse(File.ReadAllText(RepoPath("src/WiSave.Portal.WebApi/appsettings.Development.json"))); var services = appsettings.RootElement.GetProperty("DownstreamServices"); - Assert.False(services.GetProperty("Incomes").GetProperty("Enabled").GetBoolean()); + // All three enabled locally. Incomes was previously disabled here, which made its + // proxy route inert regardless of the configured address. This test also fails when + // the file is not valid JSON at all, which is how a stray character in it surfaces + // before the portal refuses to start. + Assert.True(services.GetProperty("Incomes").GetProperty("Enabled").GetBoolean()); Assert.True(services.GetProperty("Stocks").GetProperty("Enabled").GetBoolean()); Assert.True(services.GetProperty("Expenses").GetProperty("Enabled").GetBoolean()); } - [Fact] - public void DockerCompose_DoesNotOverrideDownstreamAvailability() - { - var compose = File.ReadAllText(RepoPath("docker-compose.yml")); - - Assert.DoesNotContain("DownstreamServices__", compose); - } - - [Fact] - public void Appsettings_StocksCluster_TargetsCurrentLocalStockPort() + [Theory] + [InlineData("incomes-cluster", "http://localhost:5300")] + [InlineData("stocks-cluster", "http://localhost:5301")] + [InlineData("expenses-cluster", "http://localhost:5200")] + public void Appsettings_ClusterTargetsCurrentLocalDownstreamPort(string clusterId, string expectedAddress) { - var appsettings = JsonDocument.Parse(File.ReadAllText(RepoPath("src/WiSave.Portal/appsettings.json"))); + var appsettings = JsonDocument.Parse(File.ReadAllText(RepoPath("src/WiSave.Portal.WebApi/appsettings.json"))); var address = appsettings.RootElement .GetProperty("ReverseProxy") .GetProperty("Clusters") - .GetProperty("stocks-cluster") + .GetProperty(clusterId) .GetProperty("Destinations") .GetProperty("destination1") .GetProperty("Address") .GetString(); - Assert.Equal("http://localhost:5300", address); - } - - [Fact] - public void DockerCompose_StocksCluster_TargetsStockWebApiService() - { - var compose = File.ReadAllText(RepoPath("docker-compose.yml")); - - Assert.Contains( - "ReverseProxy__Clusters__stocks-cluster__Destinations__destination1__Address=http://wisave-stock-webapi:8080", - compose); - Assert.DoesNotContain( - "ReverseProxy__Clusters__stocks-cluster__Destinations__destination1__Address=http://wisave-stocks:8080", - compose); - } - - [Fact] - public void DockerCompose_IncomesCluster_TargetsIncomesWebApiService() - { - var compose = File.ReadAllText(RepoPath("docker-compose.yml")); - - Assert.Contains( - "ReverseProxy__Clusters__incomes-cluster__Destinations__destination1__Address=http://wisave-incomes-webapi:8080", - compose); - Assert.DoesNotContain( - "ReverseProxy__Clusters__incomes-cluster__Destinations__destination1__Address=http://wisave-incomes:8080", - compose); - } - - [Fact] - public void DockerCompose_Portal_UsesDockerfileBuildForRiderDebugging() - { - var compose = File.ReadAllText(RepoPath("docker-compose.yml")); - var dockerfilePath = RepoPath("src/WiSave.Portal/Dockerfile"); - - Assert.True(File.Exists(dockerfilePath)); - Assert.Contains("build:", compose); - Assert.Contains("dockerfile: src/WiSave.Portal/Dockerfile", compose); - Assert.Contains("wisave_expenses_contracts_package: ${HOME}/.nuget/packages/wisave.expenses.contracts", compose); - Assert.Contains("wisave_incomes_contracts_package: ${HOME}/.nuget/packages/wisave.incomes.contracts", compose); - Assert.Contains("github_packages_token", compose); - Assert.DoesNotContain("pull_policy: never", compose); + Assert.Equal(expectedAddress, address); } [Fact] - public void DockerCompose_Portal_UsesPortalRabbitMqVirtualHost() + public void Portal_HasNoHandWrittenDockerfile() { - var compose = File.ReadAllText(RepoPath("docker-compose.yml")); - - Assert.Contains("RabbitMq__VirtualHost=portal", compose); - Assert.DoesNotContain("RabbitMq__VirtualHost=expenses", compose); + Assert.False(File.Exists(RepoPath("src/WiSave.Portal.WebApi/Dockerfile"))); + Assert.False(File.Exists(RepoPath("src/WiSave.Portal/Dockerfile"))); } [Fact] @@ -134,20 +96,73 @@ public void RabbitMqDefinitions_CreatePortalVirtualHost() } [Fact] - public void DockerCompose_Portal_IsProfileGatedForLocalDebugging() + public void Portal_HasNoComposeStack() { - var compose = File.ReadAllText(RepoPath("docker-compose.yml")); - - Assert.Contains( - """ - portal: - profiles: - - portal - build: - context: . - dockerfile: src/WiSave.Portal/Dockerfile - """, - compose); + // Every service Compose used to host — the portal, Postgres, Redis and the + // RabbitMQ broker — is now modelled in src/WiSave.Portal.AppHost. Two + // orchestrators declaring the same broker would collide on 5672. + Assert.False(File.Exists(RepoPath("docker-compose.yml"))); + } + + [Fact] + public void AppHost_TargetsTheCurrentLocalDownstreamPorts() + { + // Collapsed so the assertions pin the key/value pairing rather than whether the + // call happens to be wrapped across lines. + var appHost = Regex.Replace( + File.ReadAllText(RepoPath("src/WiSave.Portal.AppHost/AppHost.cs")), + @"\s+", + " "); + + // Environment variables win over appsettings, so these are what the running + // portal actually proxies to. + Assert.Contains("incomes-cluster__Destinations__destination1__Address\", \"http://localhost:5300", appHost); + Assert.Contains("stocks-cluster__Destinations__destination1__Address\", \"http://localhost:5301", appHost); + Assert.Contains("expenses-cluster__Destinations__destination1__Address\", \"http://localhost:5200", appHost); + } + + [Fact] + public void AppHost_CarriesThePortalRabbitMqVirtualHost() + { + var appHost = File.ReadAllText(RepoPath("src/WiSave.Portal.AppHost/AppHost.cs")); + + Assert.Contains("\"RabbitMq__VirtualHost\", \"portal\"", appHost); + Assert.DoesNotContain("\"RabbitMq__VirtualHost\", \"expenses\"", appHost); + } + + [Fact] + public void PortalReverseProxyPipeline_HandlesConfiguredDisabledBeforeForwardingErrors() + { + var gatewayExtensions = File.ReadAllText( + RepoPath("src/WiSave.Portal.WebApi/Gateway/Extensions.cs")); + + var availabilityIndex = gatewayExtensions.IndexOf( + "proxyPipeline.UseMiddleware();", + StringComparison.Ordinal); + var proxyErrorIndex = gatewayExtensions.IndexOf( + "proxyPipeline.UseMiddleware();", + StringComparison.Ordinal); + var sessionAffinityIndex = gatewayExtensions.IndexOf( + "proxyPipeline.UseSessionAffinity();", + StringComparison.Ordinal); + + Assert.True(availabilityIndex >= 0, "The configured-availability middleware is not registered."); + Assert.True(proxyErrorIndex > availabilityIndex, "Proxy errors must wrap the forwarding stages."); + Assert.True(sessionAffinityIndex > proxyErrorIndex, "Proxy error handling must run before forwarding stages."); + } + + [Fact] + public void PortalGateway_SuppressesYarpHttpForwarderWarningStackTraces() + { + var services = new ServiceCollection(); + services.AddPortalGateway(new ConfigurationBuilder().Build()); + using var provider = services.BuildServiceProvider(); + + var filterOptions = provider.GetRequiredService>().Value; + + Assert.Contains(filterOptions.Rules, rule => + rule.CategoryName == "Yarp.ReverseProxy.Forwarder.HttpForwarder" + && rule.LogLevel == LogLevel.Error); } private static string RepoPath(string relativePath) diff --git a/tests/WiSave.Portal.UnitTests/Gateway/UserHeaderTransformTests.cs b/tests/WiSave.Portal.UnitTests/Gateway/UserHeaderTransformTests.cs new file mode 100644 index 0000000..6f09df2 --- /dev/null +++ b/tests/WiSave.Portal.UnitTests/Gateway/UserHeaderTransformTests.cs @@ -0,0 +1,86 @@ +using System.Net.Http; +using System.Security.Claims; +using Microsoft.AspNetCore.Http; +using WiSave.Portal.Contracts.Identity; +using WiSave.Portal.Gateway; +using Xunit; + +namespace WiSave.Portal.UnitTests.Gateway; + +public sealed class UserHeaderTransformTests +{ + [Fact] + public void ApplyUserHeaders_StripsClientSuppliedIdentityHeaders_ForAnonymousRequests() + { + var proxyRequest = CreateSpoofedRequest(); + + UserHeaderTransformProvider.ApplyUserHeaders(proxyRequest, new DefaultHttpContext()); + + Assert.False(proxyRequest.Headers.Contains(PortalHeaderNames.UserId)); + Assert.False(proxyRequest.Headers.Contains(PortalHeaderNames.UserEmail)); + Assert.False(proxyRequest.Headers.Contains(PortalHeaderNames.UserRoles)); + Assert.False(proxyRequest.Headers.Contains(PortalHeaderNames.UserPermissions)); + } + + [Fact] + public void ApplyUserHeaders_ReplacesClientSuppliedHeadersWithAuthenticatedUser() + { + var proxyRequest = CreateSpoofedRequest(); + var httpContext = new DefaultHttpContext + { + User = new ClaimsPrincipal(new ClaimsIdentity( + [ + new Claim(ClaimTypes.NameIdentifier, "11111111-1111-1111-1111-111111111111"), + new Claim(ClaimTypes.Email, "real@example.com"), + new Claim(ClaimTypes.Role, "plan:free") + ], + "cookies")) + }; + httpContext.Items["UserPermissions"] = + new HashSet(StringComparer.OrdinalIgnoreCase) { "incomes:read" }; + + UserHeaderTransformProvider.ApplyUserHeaders(proxyRequest, httpContext); + + Assert.Equal( + "11111111-1111-1111-1111-111111111111", + Assert.Single(proxyRequest.Headers.GetValues(PortalHeaderNames.UserId))); + Assert.Equal( + "real@example.com", + Assert.Single(proxyRequest.Headers.GetValues(PortalHeaderNames.UserEmail))); + Assert.Equal( + "plan:free", + Assert.Single(proxyRequest.Headers.GetValues(PortalHeaderNames.UserRoles))); + Assert.Equal( + "incomes:read", + Assert.Single(proxyRequest.Headers.GetValues(PortalHeaderNames.UserPermissions))); + } + + [Fact] + public void ApplyUserHeaders_StripsClientSuppliedHeaders_WhenAuthenticatedUserHasNoNameIdentifier() + { + var proxyRequest = CreateSpoofedRequest(); + var httpContext = new DefaultHttpContext + { + User = new ClaimsPrincipal(new ClaimsIdentity( + [new Claim(ClaimTypes.Email, "real@example.com")], + "cookies")) + }; + + UserHeaderTransformProvider.ApplyUserHeaders(proxyRequest, httpContext); + + Assert.False(proxyRequest.Headers.Contains(PortalHeaderNames.UserId)); + Assert.False(proxyRequest.Headers.Contains(PortalHeaderNames.UserEmail)); + Assert.False(proxyRequest.Headers.Contains(PortalHeaderNames.UserRoles)); + Assert.False(proxyRequest.Headers.Contains(PortalHeaderNames.UserPermissions)); + } + + private static HttpRequestMessage CreateSpoofedRequest() + { + var request = new HttpRequestMessage(); + request.Headers.TryAddWithoutValidation(PortalHeaderNames.UserId, "spoofed-user"); + request.Headers.TryAddWithoutValidation(PortalHeaderNames.UserEmail, "spoofed@example.com"); + request.Headers.TryAddWithoutValidation(PortalHeaderNames.UserRoles, "superadmin"); + request.Headers.TryAddWithoutValidation(PortalHeaderNames.UserPermissions, "*"); + return request; + } +} diff --git a/tests/WiSave.Portal.UnitTests/Hubs/Realtime/RealtimeEnvelopeTests.cs b/tests/WiSave.Portal.UnitTests/Hubs/Realtime/RealtimeEnvelopeTests.cs index 94fe8bf..e668209 100644 --- a/tests/WiSave.Portal.UnitTests/Hubs/Realtime/RealtimeEnvelopeTests.cs +++ b/tests/WiSave.Portal.UnitTests/Hubs/Realtime/RealtimeEnvelopeTests.cs @@ -1,5 +1,5 @@ using System.Text.Json; -using WiSave.Portal.Hubs.Realtime; +using WiSave.Portal.Core.Abstractions.Realtime; using Xunit; namespace WiSave.Portal.UnitTests.Hubs.Realtime; diff --git a/tests/WiSave.Portal.UnitTests/Infrastructure/HealthCheckRegistrationTests.cs b/tests/WiSave.Portal.UnitTests/Infrastructure/HealthCheckRegistrationTests.cs new file mode 100644 index 0000000..4d6c1c9 --- /dev/null +++ b/tests/WiSave.Portal.UnitTests/Infrastructure/HealthCheckRegistrationTests.cs @@ -0,0 +1,81 @@ +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Diagnostics.HealthChecks; +using Microsoft.Extensions.Options; +using WiSave.Portal.Core.Infrastructure.HealthChecks; +using Xunit; + +namespace WiSave.Portal.UnitTests.Infrastructure; + +public sealed class HealthCheckRegistrationTests +{ + private const string PortalConnectionString = + "Host=localhost;Database=wisave_portal;Username=wisave;Password=wisave_dev"; + + [Fact] + public void AddPortalHealthChecks_WithPostgresAndRedis_RegistersBothAsReadinessChecks() + { + var registrations = Register(new Dictionary + { + ["ConnectionStrings:Portal"] = PortalConnectionString, + ["Redis:ConnectionString"] = "localhost:6379", + }); + + Assert.Collection( + registrations.OrderBy(registration => registration.Name), + registration => Assert.Equal("postgres", registration.Name), + registration => Assert.Equal("redis", registration.Name)); + Assert.All(registrations, registration => Assert.Contains("ready", registration.Tags)); + Assert.All(registrations, registration => Assert.Equal(HealthStatus.Unhealthy, registration.FailureStatus)); + } + + [Fact] + public void AddPortalHealthChecks_WithoutRedis_RegistersOnlyPostgres() + { + var registrations = Register(new Dictionary + { + ["ConnectionStrings:Portal"] = PortalConnectionString, + ["Redis:ConnectionString"] = "", + }); + + var registration = Assert.Single(registrations); + Assert.Equal("postgres", registration.Name); + } + + [Fact] + public void AddPortalHealthChecks_WithNothingConfigured_RegistersNothingAndDoesNotThrow() + { + Assert.Empty(Register(new Dictionary())); + } + + [Fact] + public void AddPortalHealthChecks_WithUnavailableDependencies_DoesNotConnectDuringRegistration() + { + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + ["ConnectionStrings:Portal"] = "Host=127.0.0.1;Port=5499;Database=nope;Username=nope;Password=nope", + ["Redis:ConnectionString"] = "127.0.0.1:6399,connectTimeout=100,connectRetry=0", + }) + .Build(); + + var services = new ServiceCollection(); + services.AddLogging(); + + var exception = Record.Exception(() => services.AddPortalHealthChecks(configuration)); + + Assert.Null(exception); + } + + private static IReadOnlyList Register(Dictionary settings) + { + var configuration = new ConfigurationBuilder().AddInMemoryCollection(settings).Build(); + + var services = new ServiceCollection(); + services.AddLogging(); + services.AddPortalHealthChecks(configuration); + + using var provider = services.BuildServiceProvider(); + return provider.GetRequiredService>().Value.Registrations.ToArray(); + } +} diff --git a/tests/WiSave.Portal.UnitTests/Infrastructure/IdentityMigrationSqlTests.cs b/tests/WiSave.Portal.UnitTests/Infrastructure/IdentityMigrationSqlTests.cs new file mode 100644 index 0000000..62040b2 --- /dev/null +++ b/tests/WiSave.Portal.UnitTests/Infrastructure/IdentityMigrationSqlTests.cs @@ -0,0 +1,40 @@ +using System.Text.RegularExpressions; +using WiSave.Portal.Migrations; +using Xunit; + +namespace WiSave.Portal.UnitTests.Infrastructure; + +public class IdentityMigrationSqlTests +{ + [Fact] + public void Identity_seed_uses_five_deterministic_uuid_v7_role_ids() + { + var sql = ReadEmbeddedSql("Seeds.001_SeedIdentityPlanPermissions.sql"); + var seededRoleIds = Regex.Matches( + sql, + @"\('(?[^']+)', '(?:superadmin|admin|plan:[^']+)',") + .Select(match => match.Groups["id"].Value) + .ToArray(); + + Assert.Equal(5, seededRoleIds.Length); + Assert.Equal(5, seededRoleIds.Distinct(StringComparer.Ordinal).Count()); + Assert.Contains("SELECT roles.\"Id\"::uuid", sql); + Assert.All(seededRoleIds, value => + { + Assert.True(Guid.TryParse(value, out var id), $"'{value}' is not a UUID."); + Assert.Equal(7, id.Version); + }); + } + + private static string ReadEmbeddedSql(string resourceSuffix) + { + var assembly = typeof(DbMigrator).Assembly; + var resourceName = Assert.Single( + assembly.GetManifestResourceNames(), + name => name.EndsWith(resourceSuffix, StringComparison.Ordinal)); + using var stream = assembly.GetManifestResourceStream(resourceName); + Assert.NotNull(stream); + using var reader = new StreamReader(stream); + return reader.ReadToEnd(); + } +} diff --git a/tests/WiSave.Portal.UnitTests/Messaging/RabbitMqConfigurationTests.cs b/tests/WiSave.Portal.UnitTests/Messaging/RabbitMqConfigurationTests.cs index 44246c0..02da7bb 100644 --- a/tests/WiSave.Portal.UnitTests/Messaging/RabbitMqConfigurationTests.cs +++ b/tests/WiSave.Portal.UnitTests/Messaging/RabbitMqConfigurationTests.cs @@ -9,7 +9,7 @@ public sealed class RabbitMqConfigurationTests public void AppSettings_UsesPortalRabbitMqVirtualHostByDefault() { var configuration = new ConfigurationBuilder() - .AddJsonFile(RepoPath("src/WiSave.Portal/appsettings.json")) + .AddJsonFile(RepoPath("src/WiSave.Portal.WebApi/appsettings.json")) .Build(); Assert.Equal("portal", configuration["RabbitMq:VirtualHost"]); @@ -19,7 +19,7 @@ public void AppSettings_UsesPortalRabbitMqVirtualHostByDefault() public void AppSettings_ConfiguresServiceNamedRabbitMqVirtualHosts() { var configuration = new ConfigurationBuilder() - .AddJsonFile(RepoPath("src/WiSave.Portal/appsettings.json")) + .AddJsonFile(RepoPath("src/WiSave.Portal.WebApi/appsettings.json")) .Build(); Assert.Equal("incomes", configuration["RabbitMq:NamedBrokers:Incomes:VirtualHost"]); @@ -27,6 +27,23 @@ public void AppSettings_ConfiguresServiceNamedRabbitMqVirtualHosts() Assert.Equal("stocks", configuration["RabbitMq:NamedBrokers:Stocks:VirtualHost"]); } + [Fact] + public void LegacyNotificationsQueueName_IsNotRenamedWhenNamespacesMove() + { + var source = File.ReadAllText(RepoPath( + "src/WiSave.Portal.Core.Infrastructure/Messaging/MessagingHostBuilderExtensions.cs")); + + // This is a durable queue that already exists on the broker. It looks like a .NET + // namespace because Wolverine derived it from one, which makes it a standing trap + // for bulk namespace rewrites — one such rewrite did silently change it. Renaming + // it makes the portal bind a different queue and stop consuming the legacy income, + // category and expense notifications, with nothing failing at build time. + Assert.Contains( + "private const string LegacyNotificationsQueueName = " + + "\"WiSave.Portal.EventHandlers.NotificationsEventHandler\";", + source); + } + private static string RepoPath(string relativePath) { var directory = new DirectoryInfo(AppContext.BaseDirectory); diff --git a/tests/WiSave.Portal.UnitTests/Observability/RealtimeInstrumentationTests.cs b/tests/WiSave.Portal.UnitTests/Observability/RealtimeInstrumentationTests.cs new file mode 100644 index 0000000..194d6ef --- /dev/null +++ b/tests/WiSave.Portal.UnitTests/Observability/RealtimeInstrumentationTests.cs @@ -0,0 +1,90 @@ +using System.Diagnostics; +using NSubstitute; +using WiSave.Portal.Core.Application.EventHandlers; +using WiSave.Portal.Core.Abstractions.Observability; +using WiSave.Portal.Core.Abstractions.Realtime; +using WiSave.Stock.Contracts.Events.Positions; +using Xunit; + +namespace WiSave.Portal.UnitTests.Observability; + +/// +/// Pins the spans the portal emits for its own work. +/// +/// +/// Automatic instrumentation covers the request and the proxied call; these cover what +/// happens in between. A span that stops being recorded fails nothing at build time and +/// simply disappears from the dashboard, so it is worth asserting. +/// +/// ActivitySource listeners are process-global, so this listener also sees spans from +/// every other test running in parallel. Each case therefore identifies its own span by +/// the entity id it generated rather than assuming it recorded only one. +/// +public sealed class RealtimeInstrumentationTests : IDisposable +{ + private readonly List _recorded = []; + private readonly ActivityListener _listener; + + public RealtimeInstrumentationTests() + { + _listener = new ActivityListener + { + ShouldListenTo = source => source.Name == PortalTelemetry.SourceName, + Sample = (ref ActivityCreationOptions _) => ActivitySamplingResult.AllData, + ActivityStopped = _recorded.Add + }; + ActivitySource.AddActivityListener(_listener); + } + + [Fact] + public async Task PublishingANotification_RecordsASpanTaggedWithDomainAndEventType() + { + var positionId = Guid.CreateVersion7(); + var handler = new RealtimeNotificationsEventHandler(Substitute.For()); + + await handler.Handle( + new PositionClosed( + positionId, + Guid.CreateVersion7(), + Guid.CreateVersion7(), + DateTimeOffset.UtcNow, + DateTimeOffset.UtcNow), + TestContext.Current.CancellationToken); + + var activity = Assert.Single(SpansFor(positionId)); + Assert.Equal($"realtime publish {RealtimeEventType.PositionClosed}", activity.OperationName); + Assert.Equal(RealtimeDomain.Stocks, activity.GetTagItem("wisave.domain")); + Assert.Equal(RealtimeEventType.PositionClosed, activity.GetTagItem("wisave.event_type")); + } + + [Fact] + public async Task DroppingAnEventWithNoUser_RecordsNoSpan() + { + var positionId = Guid.CreateVersion7(); + var notifier = Substitute.For(); + var handler = new RealtimeNotificationsEventHandler(notifier); + + // Guid.Empty means the event carries no user, so there is nobody to notify. + await handler.Handle( + new PositionClosed( + positionId, + Guid.Empty, + Guid.CreateVersion7(), + DateTimeOffset.UtcNow, + DateTimeOffset.UtcNow), + TestContext.Current.CancellationToken); + + Assert.Empty(SpansFor(positionId)); + await notifier.DidNotReceive().NotifyUserAsync( + Arg.Any(), + Arg.Any(), + Arg.Any()); + } + + private Activity[] SpansFor(Guid entityId) => + _recorded + .Where(activity => (string?)activity.GetTagItem("wisave.entity_id") == entityId.ToString()) + .ToArray(); + + public void Dispose() => _listener.Dispose(); +} diff --git a/tests/WiSave.Portal.UnitTests/Session/SessionConfigurationTests.cs b/tests/WiSave.Portal.UnitTests/Session/SessionConfigurationTests.cs index 58a3076..79d80c3 100644 --- a/tests/WiSave.Portal.UnitTests/Session/SessionConfigurationTests.cs +++ b/tests/WiSave.Portal.UnitTests/Session/SessionConfigurationTests.cs @@ -3,7 +3,7 @@ using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Options; -using WiSave.Portal.Session; +using WiSave.Portal.Core.Infrastructure.Session; using Xunit; namespace WiSave.Portal.UnitTests.Session; diff --git a/tests/WiSave.Portal.UnitTests/WiSave.Portal.UnitTests.csproj b/tests/WiSave.Portal.UnitTests/WiSave.Portal.UnitTests.csproj index 9d785de..af98529 100644 --- a/tests/WiSave.Portal.UnitTests/WiSave.Portal.UnitTests.csproj +++ b/tests/WiSave.Portal.UnitTests/WiSave.Portal.UnitTests.csproj @@ -21,7 +21,11 @@ + - + + + +