diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..31b2267 --- /dev/null +++ b/.env.example @@ -0,0 +1,4 @@ +# Copy to .env and fill in values. Never commit .env. + +# PostgreSQL password used by compose.yaml +POSTGRES_PASSWORD=change-me diff --git a/README.md b/README.md index 248b8ac..9669647 100644 --- a/README.md +++ b/README.md @@ -1,396 +1,241 @@ -# DistroFlow: MVP Documentation +# DistroFlow -**Document type:** Product and technical MVP specification -**Status:** Initial scope -**Product:** DistroFlow, a B2B ordering and credit-control system for small distributors -**Primary goal:** Build a credible, working business application that demonstrates practical C# and ASP.NET Core skills (concurrency, transactions, rule engines, state machines). +Order, stock, credit and payment management for small distributors, built as a multi-tenant SaaS with ASP.NET Core and PostgreSQL. ---- - -## 1. Product Overview - -DistroFlow lets a distributor (FMCG, hardware, pharma, stationery) take orders from its retailers in one place. It applies each retailer's price list and schemes, checks the retailer's credit limit against outstanding balance, reserves stock safely under concurrent orders, and tracks each order through dispatch, invoicing and payment. - -### Problem - -Small distributors often take orders by phone, WhatsApp or salesmen's notebooks. This leads to: - -- Pricing and scheme errors -- Orders accepted for stock that is already gone -- Credit limits exceeded without anyone noticing -- No clear history of who changed or approved what - -### Proposed MVP solution - -One workflow connecting: - -1. Product, price list and scheme setup -2. Retailer management with credit limits -3. Order placement with server-side pricing -4. Credit-limit check with a hold-for-approval path -5. Safe stock reservation under concurrency -6. Order lifecycle (confirm, pick, dispatch, deliver, cancel) -7. Invoice generation and outstanding balance tracking -8. Payment recording -9. Audit history +![CI](https://github.com/navaneeth06/DistroFlow/actions/workflows/dotnet.yml/badge.svg) -The MVP supports operational tracking. It does not move real money, provide accounting advice, or replace a full accounting system. +> **Status:** in development. Planning is complete; implementation follows the module order in [Roadmap](#roadmap). Sections marked *(from Module N)* describe what will work once that module is finished. --- -## 2. Goals and Non-Goals - -### Goals - -- Deliver an end-to-end order-to-payment workflow. -- Demonstrate sound C# and ASP.NET Core implementation. -- Prove correct stock reservation under concurrent orders with an automated test. -- Use a relational database with constraints and transactional consistency. -- Enforce authorization on the server. -- Include meaningful unit and integration tests and run instructions. -- Stay small enough to finish in about 6 weeks and explain in 10 minutes. - -### Non-Goals (first MVP) - -- WhatsApp ordering or notification integration -- Online payment gateway integration -- Full multi-tenant isolation for multiple customer companies -- Full accounting ledger, GST filing or e-invoicing -- Multi-warehouse stock transfers and batch/expiry tracking -- Mobile apps, salesman route planning, GPS tracking -- Microservices, Kubernetes, message brokers -- Complex promotion stacking (only two scheme types in the MVP) - ---- - -## 3. Users and Roles - -| Role | Main permissions | -|---|---| -| Administrator | Manage users, roles, products, price lists, schemes, retailers, credit limits | -| Retailer | Browse catalog (own prices), place and cancel own orders, view own orders and outstanding | -| Sales / Dispatch Staff | Confirm orders, pick, dispatch, mark delivered, cancel orders | -| Approver | Approve or reject orders on credit hold, record overrides with a reason | -| Accounts Staff | Record payments, view invoices and outstanding reports | - -- One user may hold multiple roles. -- Permissions are enforced on the API. Hiding buttons in the UI is not authorization. -- A retailer user can only access records belonging to their own retailer account. +## Contents + +- [The problem](#the-problem) +- [Features](#features) +- [How an order flows](#how-an-order-flows) +- [Design highlights](#design-highlights) +- [Tech stack](#tech-stack) +- [Project structure](#project-structure) +- [Getting started](#getting-started) +- [Configuration](#configuration) +- [Testing](#testing) +- [Documentation](#documentation) +- [Roadmap](#roadmap) +- [Out of scope](#out-of-scope) --- -## 4. MVP Modules - -### 4.1 Authentication and Access Control - -- Sign in / sign out using ASP.NET Core Identity with JWT (or cookie) authentication. -- Role- and policy-based endpoint authorization. -- Consistent 401 (unauthenticated) and 403 (forbidden) responses. - -**Acceptance criteria** -- Unauthenticated requests cannot reach protected endpoints. -- A retailer cannot view another retailer's orders. -- A user without the Approver role cannot release a held order. - -### 4.2 Product and Stock Management - -- Product: SKU, name, unit of measure, active flag. -- Stock: on-hand quantity and reserved quantity per product (single warehouse in the MVP). -- Available quantity = on-hand minus reserved. It is derived, not stored independently. -- Stock adjustments are recorded as movements (receipt, adjustment, reservation, release, dispatch) rather than silent overwrites. - -### 4.3 Price Lists - -- Named price lists (for example "Retail A", "Retail B"). -- Each price list holds a price per product. -- Each retailer is assigned exactly one price list. -- The price used on an order line is **snapshotted** on the line. Later price changes must not alter existing orders. - -### 4.4 Schemes (Promotions) - -The MVP supports two scheme types: - -1. **Free-quantity scheme:** buy N, get M free (for example buy 10, get 1 free). -2. **Volume discount:** percentage discount at or above a quantity threshold. - -Rules: -- A scheme has a product, validity dates, and an active flag. -- At most one scheme applies per order line (deterministic; the best benefit for the retailer wins, and ties are broken by lowest scheme ID). -- The applied scheme is recorded on the order line for auditability. - -### 4.5 Retailer Management - -- Retailer: name, code, contact details, price list, credit limit, active flag. -- Retailer code is unique. -- Inactive retailers cannot place orders. - -### 4.6 Order Placement - -- Retailer submits product IDs and quantities only. -- The **server** determines price, scheme benefits, line totals and order total. Client-supplied amounts are ignored. -- Order lines record: product, ordered quantity, free quantity, unit price (snapshot), discount, line total, applied scheme. -- Order totals use `decimal`, with the currency precision and rounding rule documented (2 decimal places, round half away from zero, applied per line). - -### 4.7 Credit Check - -On submit: +## The problem -``` -Outstanding balance + new order total <= credit limit → continue to stock reservation -Otherwise → order status = OnHold (no stock reserved) -``` - -- Outstanding balance = sum of unpaid invoice amounts, minus recorded payments. -- An Approver may release a held order with a recorded reason, which then proceeds to stock reservation. -- The credit decision (values used and result) is stored with the order for reproducibility. +Small distributors take orders by phone and WhatsApp. That leads to wrong prices and schemes, orders accepted for stock that is already gone, retailers exceeding their credit limit, and no record of who did what. -### 4.8 Stock Reservation (core technical feature) +DistroFlow gives the distributor one system that applies each retailer's price list and schemes, checks credit, reserves stock safely, and tracks the order through dispatch, invoicing and payment. -- Performed inside a single database transaction with the order creation. -- For each line, reserve `ordered quantity + free quantity`. -- If any line lacks available stock, the whole order fails with a clear conflict response and nothing is reserved. -- Concurrent orders for the same product must never over-reserve. +**The customer is the distributor.** Each tenant is one distributor business. Retailers are records the distributor manages; they have no accounts. A retailer can still send an order from a distributor-generated link on a phone. -Chosen strategy (decided and documented in the README): -- Conditional atomic update, for example `UPDATE Stock SET Reserved = Reserved + @qty WHERE ProductId = @id AND OnHand - Reserved >= @qty`, checking the affected row count; **or** -- A concurrency token (`rowversion`) with retry on `DbUpdateConcurrencyException`. +## Features -### 4.9 Order Lifecycle - -``` -Placed → Confirmed → Picked → Dispatched → Delivered - │ │ - ├── OnHold ─┴─→ (released by Approver) → Placed/Confirmed - └── Cancelled (allowed before Dispatched) +| Area | What it does | +|---|---| +| **Access** | Employee sign-in, five roles with fixed permissions, tenant isolation | +| **Products and stock** | Catalog, on-hand / reserved / available stock, full stock movement history | +| **Pricing** | Per-retailer price lists; schemes: Buy N Get M Free and Volume Discount | +| **Retailers** | Retailer records with credit limit, price list, payment terms | +| **Orders** | Server-side pricing, credit check, atomic stock reservation, hold and release, lifecycle, cancellation | +| **Invoicing** | Invoice created at dispatch, numbered per fiscal year, tax-exclusive, PDF | +| **Payments** | Partial payments, outstanding balance, available credit | +| **Reports** | Pending orders, credit holds, outstanding and ageing, low stock, scheme usage | +| **Audit** | Who did what and when, written in the same transaction as the action | +| **Ordering link** | Retailers submit orders from a secure shared link, no login | + +### Roles + +| Role | Responsibilities | +|---|---| +| Administrator | Employees, products, price lists, schemes, retailers, audit log | +| Sales Staff | Create and confirm orders, cancel orders, retailer profiles, ordering links | +| Dispatch Staff | Stock receipts and adjustments, pick, dispatch, deliver | +| Approver | Release orders held for credit, change credit limits, cancel orders | +| Accounts Staff | Record payments, invoices, outstanding reports | + +## How an order flows + +```mermaid +flowchart TD + A[Order submitted by staff or retailer link] --> B[Server prices lines and applies schemes] + B --> C{Exposure + order total over credit limit?} + C -- Yes --> D[OnHold: no stock reserved, Approver reviews] + C -- No --> E{Stock available for every line?} + E -- No --> F[Rejected: nothing reserved] + E -- Yes --> G[Stock reserved: Placed] + D -- Approver releases with reason --> G + G --> H[Confirmed] --> I[Picked] --> J[Dispatched: stock deducted, invoice created] --> K[Delivered] + J --> L[Payments recorded against the invoice] ``` -| From | To | Who | Effect | -|---|---|---|---| -| (new) | Placed | Retailer / Staff | Stock reserved | -| (new) | OnHold | System | Credit limit exceeded, no reservation | -| OnHold | Placed | Approver | Reason required; reservation attempted | -| OnHold | Cancelled | Approver / Retailer | No stock effect | -| Placed | Confirmed | Staff | None | -| Confirmed | Picked | Staff | None | -| Picked | Dispatched | Staff | Reserved stock converted to a permanent deduction; invoice generated | -| Dispatched | Delivered | Staff | None | -| Placed / Confirmed / Picked | Cancelled | Staff / Retailer (own, before Confirmed) | Reserved stock released | - -- Allowed transitions are defined in the backend. Arbitrary status updates are rejected. -- Cancelling after dispatch is not allowed in the MVP (a returns/credit-note process is out of scope). - -### 4.10 Invoice and Outstanding +An order can be cancelled any time before dispatch; reserved stock is released exactly once. -- Dispatch generates one invoice per order (amounts copied from the order lines). -- Invoice number is sequential and unique, with a documented format. -- Outstanding balance per retailer = sum of invoice totals minus sum of payments. -- The retailer's available credit = credit limit minus outstanding balance. +## Design highlights -### 4.11 Payment Recording +These are the parts the project is built to get right. -- Record payment amount, date, reference and method label (cash, UPI, cheque, bank transfer). -- Support partial payments, allocated to a specific invoice. -- A payment cannot exceed the invoice's outstanding amount. -- Invoice is marked Paid only when its outstanding amount reaches zero. -- The MVP records payments only. It does not initiate them. +- **Server decides every number.** Prices, discounts, schemes and totals are calculated on the server; client values are ignored. Price and scheme are snapshotted on each order line, so later price changes never alter existing orders. +- **No over-reservation under concurrency.** Stock is reserved with a single conditional `UPDATE` per line, in a fixed order to avoid deadlocks, inside one transaction. Database constraints back it up (`on_hand >= 0`, `reserved <= on_hand`). +- **No credit-limit bypass.** The retailer row is locked during the credit check, so simultaneous orders from one retailer are evaluated one after the other against current exposure. +- **Safe retries.** Dispatch, cancellation, payments and link submissions are idempotent: no second invoice, no second stock deduction, no second release. +- **Tenant isolation.** The tenant comes from the signed-in employee or the ordering-link record, never from client input. Queries are tenant-scoped, constraints prevent cross-tenant references, and tests prove it. +- **Secure ordering links.** Random 256-bit token, only the hash stored, expiry, revocation, rate limiting, one generic response for invalid links, and the retailer never sees credit, invoices or other retailers. +- **Explainable.** Every order stores its credit decision (limit, exposure, result); every important action is audited with actor and reason. -### 4.12 Audit and Reporting +## Tech stack -**Audit entry** contains: actor, timestamp, entity, entity ID, action, and before/after values or a safe change summary. Passwords and tokens are never logged. - -Audited actions: order placement, hold, release (with reason), confirmation, dispatch, cancellation, stock adjustments, price list and scheme changes, credit limit changes, payments. - -**Basic reports** -- Pending orders (Placed / Confirmed / Picked) -- Orders on credit hold -- Low stock (available quantity below a threshold) -- Retailer outstanding and ageing (0–30, 31–60, 60+ days) -- Scheme usage (times applied, free quantity given) - ---- - -## 5. Core Business Rules - -1. All monetary and quantity calculations are done server-side using `decimal`. -2. Price and scheme benefits are snapshotted on the order line at placement time. -3. Stock can never be reserved beyond available quantity, even under concurrent requests. -4. Reservation and order creation happen in one transaction. -5. An order on credit hold holds no stock. -6. Order status transitions are validated on the server. -7. Dispatch converts reservation into a permanent deduction and generates the invoice in one transaction. -8. Cancelling an order before dispatch releases its reserved stock exactly once (idempotent). -9. Manual overrides (credit release, stock adjustments) require a reason and are audited. -10. Concurrent edits must not silently overwrite each other. Return a clear 409 Conflict. -11. Payments cannot exceed the invoice's outstanding amount. -12. Critical rules are reinforced by database constraints (unique codes, non-negative stock, foreign keys), not only by application validation. - ---- - -## 6. Technical Architecture +| Layer | Technology | +|---|---| +| Backend | ASP.NET Core Web API, C# 14, .NET 10 | +| Data | Entity Framework Core 10, PostgreSQL (GUID/UUID keys) | +| Auth | ASP.NET Core Identity, JWT, role and permission policies | +| Frontend | React + TypeScript (staff app and mobile order page) | +| Testing | xUnit, Testcontainers (real PostgreSQL) | +| Delivery | Docker Compose, GitHub Actions, Serilog, OpenAPI/Swagger | -### Stack +A modular monolith with Domain, Application, Infrastructure and API layers. Domain has no outward dependencies, and business rules are testable without a database. -| Concern | Choice | -|---|---| -| Backend | C# 12 / .NET 8, ASP.NET Core Web API | -| Data access | Entity Framework Core (with raw SQL only where justified, such as the atomic stock update) | -| Database | SQL Server (PostgreSQL is acceptable if you can explain the differences) | -| Auth | ASP.NET Core Identity + JWT | -| Validation | FluentValidation or data annotations | -| Background jobs | Hangfire (low-stock alerts, nightly outstanding/ageing refresh) | -| Frontend | React + TypeScript (minimal screens) | -| API docs | OpenAPI / Swagger | -| Testing | xUnit, ASP.NET Core integration tests, Testcontainers (real database) | -| Logging | Serilog (structured) | -| Deployment | Docker, docker-compose; CI via GitHub Actions | - -### Architecture style - -Modular monolith. Keep business rules testable and separate from HTTP and persistence concerns. Add abstractions only where they provide a real boundary; EF Core's `DbContext` is already a unit of work. +## Project structure ``` DistroFlow/ +├── .github/workflows/dotnet.yml CI: build and test ├── src/ -│ ├── DistroFlow.Api/ (controllers, middleware, auth policies, Program.cs) -│ ├── DistroFlow.Application/ (use cases: Catalog, Pricing, Orders, Inventory, Billing, Payments) -│ ├── DistroFlow.Domain/ (entities, enums, rules, exceptions) -│ └── DistroFlow.Infrastructure/ (EF Core, configurations, migrations, audit) +│ ├── DistroFlow.Domain/ Entities/ (rules: pricing, credit, state machine) +│ ├── DistroFlow.Application/ DTOs/, Interfaces/, Services/, Validators/ +│ ├── DistroFlow.Infrastructure/ Persistence/ (DbContext, configurations, migrations), +│ │ Repositories/, Identity/ +│ └── DistroFlow.Api/ Controllers/, Program.cs, appsettings ├── tests/ -│ ├── DistroFlow.UnitTests/ -│ └── DistroFlow.IntegrationTests/ -├── frontend/ -├── docker-compose.yml +│ ├── DistroFlow.UnitTests/ domain and application rules +│ └── DistroFlow.IntegrationTests/ API and database tests (Testcontainers) +├── frontend/ React + TypeScript (planned) +├── docs/ MVP and design documents (planned) +├── compose.yaml local PostgreSQL +├── .env.example template for .env +├── DistroFlow.slnx +├── .editorconfig └── README.md ``` -### Tenancy decision +## Getting started -The MVP runs for **one distributor**. Records carry a single `TenantId` (seeded, never client-supplied) so the model is ready for multiple distributors later. This is not tenant isolation. Before enabling multiple customers, trusted tenant resolution, query filters, cross-tenant constraints and isolation tests must be added. +*(from Module 1)* -### API conventions +### Prerequisites +- .NET 10 SDK +- Docker Desktop +- Node.js 20 or later +- Git -- Versioned routes, for example `/api/v1/orders`. -- DTOs only; never expose entities directly. -- Request validation and Problem Details error responses. -- Pagination on list endpoints. -- `CancellationToken` on async request and data paths. -- Secrets in environment configuration or a secret manager, never in source control. +### Run locally ---- +```bash +git clone https://github.com/navaneeth06/DistroFlow.git +cd DistroFlow -## 7. Initial Data Model +# 1. Configure +cp .env.example .env # then set POSTGRES_PASSWORD -| Entity | Notes | -|---|---| -| ApplicationUser, Role | ASP.NET Core Identity | -| Retailer | Linked to a user account, price list, credit limit | -| Product | SKU unique per tenant | -| PriceList, PriceListItem | One price per product per list | -| Scheme | Type, product, parameters, validity dates, active flag | -| StockItem | Product, OnHand, Reserved, RowVersion (CHECK: OnHand >= 0, Reserved >= 0, Reserved <= OnHand) | -| StockMovement | Type, quantity, reference (order or adjustment), reason, timestamp | -| Order | Retailer, status, totals, credit check snapshot, RowVersion | -| OrderLine | Product, quantity, free quantity, unit price snapshot, discount, line total, applied scheme | -| Invoice, InvoiceLine | Generated at dispatch | -| Payment | Invoice, amount, date, reference, method | -| AuditEntry | Actor, entity, action, before/after summary, timestamp | - -Guidelines: -- Use explicit foreign keys, unique constraints and indexes (for example on `Order(RetailerId, Status)` and `StockItem(ProductId)`). -- Do not store derived values (available stock, outstanding balance) redundantly without a documented reason. -- Store currency code on monetary documents; exchange-rate handling is out of scope. +# 2. Start PostgreSQL (listens on localhost:5433) +docker compose up -d postgres ---- +# 3. Run the API (applies migrations and seeds demo data in Development) +dotnet run --project src/DistroFlow.Api -## 8. API Outline (examples) +# 4. Run the frontend +cd frontend +npm install +npm run dev +``` -| Method | Route | Purpose | -|---|---|---| -| POST | `/api/v1/auth/login` | Sign in | -| GET | `/api/v1/catalog` | Products with prices for the logged-in retailer | -| POST | `/api/v1/orders` | Place an order (product IDs and quantities only) | -| GET | `/api/v1/orders` | List orders (paged, filtered by role) | -| POST | `/api/v1/orders/{id}/release` | Approver releases a held order (reason required) | -| POST | `/api/v1/orders/{id}/confirm` | Confirm | -| POST | `/api/v1/orders/{id}/dispatch` | Dispatch and generate invoice | -| POST | `/api/v1/orders/{id}/cancel` | Cancel and release stock | -| POST | `/api/v1/invoices/{id}/payments` | Record a payment | -| GET | `/api/v1/reports/outstanding` | Retailer outstanding and ageing | +Open `http://localhost:5173`. Swagger is at `http://localhost:/swagger` in Development. ---- +### Demo accounts -## 9. Testing Requirements +Seeded in Development only. Passwords come from your `.env`; they are never committed. -The MVP is not complete just because the screens work. +| Role | Email | +|---|---| +| Administrator | `admin@demo.local` | +| Sales Staff | `sales@demo.local` | +| Dispatch Staff | `dispatch@demo.local` | +| Approver | `approver@demo.local` | +| Accounts Staff | `accounts@demo.local` | -### Unit tests -- Price list lookup and snapshotting -- Scheme selection and benefit calculation (including boundary quantities) -- Order total and rounding calculations -- Credit-limit decision -- Allowed and disallowed order status transitions -- Outstanding balance and ageing calculation +The demo data matches the scenario used in the end-to-end test: one distributor, a price list, two products with schemes, and a retailer with a 50,000 credit limit. -### Integration tests (real database via Testcontainers) -- Authentication and authorization, including a retailer accessing another retailer's order -- Order placement persists correct totals and snapshots -- **Concurrency:** N parallel orders for limited stock result in no over-reservation and clear failures for the rest -- Cancellation releases stock exactly once, even if called twice -- Dispatch deducts stock and creates the invoice in one transaction (and rolls back on failure) -- Payment cannot exceed outstanding; partial payments update the balance -- Concurrent edit returns 409 Conflict -- Audit entries are written for key actions +## Configuration -Avoid relying only on EF Core's in-memory provider for relational behaviour. +Configuration is read from environment variables (see `.env.example`). ---- +| Variable | Purpose | +|---|---| +| `ConnectionStrings__Default` | PostgreSQL connection string | +| `Jwt__Issuer`, `Jwt__Audience` | Token issuer and audience | +| `Jwt__SigningKey` | Token signing key (at least 32 random bytes) | +| `Cors__AllowedOrigin` | Frontend origin | +| `Seed__AdminEmail`, `Seed__AdminPassword` | First administrator (created at first start) | -## 10. MVP Acceptance Criteria +Never commit secrets. Use `dotnet user-secrets` for local development. -Ready for demonstration when: +## Testing -1. An admin can create products, a price list, a scheme and a retailer with a credit limit. -2. A retailer can place an order and see server-calculated prices and scheme benefits. -3. An order exceeding the credit limit goes on hold, and an Approver can release it with a reason. -4. Stock is reserved safely, and the concurrency test passes. -5. Staff can move an order through confirm, pick, dispatch and deliver, and cancellation releases stock. -6. Dispatch generates an invoice and updates the retailer's outstanding balance. -7. A payment can be recorded in full or in part, and available credit updates. -8. Important state changes appear in the audit log. -9. Core rules and critical persistence workflows are covered by automated tests. -10. The API and frontend can be started from documented instructions (Docker preferred). +```bash +dotnet test +``` ---- +Integration tests start a real PostgreSQL container with Testcontainers, so Docker must be running. They cover: -## 11. Suggested Build Sequence (about 6 weeks) +- Authentication, permissions and tenant isolation +- Pricing and scheme calculations, including boundary quantities +- Credit decisions and hold/release +- **Concurrent stock reservation** (for example, stock of 20 with ten parallel orders of 5: exactly 4 succeed) +- **Concurrent credit checks** (parallel orders cannot jointly exceed the limit) +- Cancellation, dispatch and payment idempotency +- Ordering-link expiry, revocation and cross-tenant access -| Week | Focus | +## Documentation + +| Document | Contents | |---|---| -| 1 | Solution setup, EF Core, migrations, Identity, API conventions, seed data | -| 2 | Products, stock, price lists, retailers, schemes, with unit tests for pricing | -| 3 | Order placement with server-side calculation and credit check | -| 4 | Concurrent stock reservation, lifecycle transitions, concurrency integration test | -| 5 | Dispatch and invoice, payments, outstanding and ageing, audit trail | -| 6 | Minimal React UI, Docker, CI, README and architecture write-up | +| `docs/MVP.md` | Scope, modules, development order, rules, "done when" checks (to be added) | +| `docs/requirements.md` | Detailed requirements with IDs and acceptance criteria (to be added) | +| `docs/technical-design.md` | Data model, API contracts, concurrency design, security (to be added) | -Build one vertical slice at a time, and test the full flow before adding the next feature. +## Roadmap ---- +Built one module at a time; each module ships with its tests and screens. -## 12. Deferred for Later +| # | Module | Status | +|---|---|---| +| 1 | Foundation | ☐ | +| 2 | Identity, Tenant and Access | ☐ | +| 3 | Products and Inventory | ☐ | +| 4 | Price Lists and Schemes | ☐ | +| 5 | Retailers | ☐ | +| 6 | Orders | ☐ | +| 7 | Dispatch and Invoicing | ☐ | +| 8 | Payments | ☐ | +| 9 | Reports and Audit | ☐ | +| 10 | Retailer Ordering Link | ☐ | +| 11 | Release | ☐ | -- Multi-tenant isolation and onboarding -- Multiple warehouses, batches and expiry tracking -- WhatsApp ordering and notifications -- Online payment links and webhooks -- Returns, credit notes and GST e-invoicing -- Salesman app and route planning -- Message-based processing +## Out of scope ---- +Retailer accounts or dashboards, marketplace features, payment gateways, WhatsApp API integration, GST filing and e-invoicing, multiple warehouses, promotion stacking, returns and credit notes, and self-service tenant sign-up and billing. + +DistroFlow is a portfolio project. It does not claim production scale, commercial adoption or complete multi-tenant hardening beyond what its tests demonstrate. + +## License -## 13. Success Definition +To be decided. -DistroFlow succeeds as an MVP when it completes the order-to-payment workflow reliably, never over-reserves stock under concurrency, and applies pricing, schemes and credit rules in a way that can be explained and tested. +## Author -Its portfolio value comes from correct business rules, tests, reliability and clear engineering decisions, not from the number of technologies used. Do not claim commercial adoption, production scale or measured business savings unless they have been independently demonstrated. Validate demand by speaking with real distributors before investing further. +Navaneeth T \ No newline at end of file diff --git a/src/DistroFlow.Api/Controllers/AuthController.cs b/src/DistroFlow.Api/Controllers/AuthController.cs new file mode 100644 index 0000000..8702c48 --- /dev/null +++ b/src/DistroFlow.Api/Controllers/AuthController.cs @@ -0,0 +1,78 @@ +using DistroFlow.Application.DTOs.Auth; +using DistroFlow.Application.DTOs.Common; +using DistroFlow.Application.Interfaces.Identity; +using DistroFlow.Application.Interfaces.Services; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; + +namespace DistroFlow.Api.Controllers; + +[ApiController] +[Route("api/v1/auth")] +public class AuthController : ControllerBase +{ + private readonly IAuthService _authService; + + public AuthController(IAuthService authService) + { + _authService = authService; + } + + /// Self-signup for a new distributor and its owner (first Administrator). + [AllowAnonymous] + [HttpPost("register")] + public async Task Register( + [FromBody] RegisterDistributorRequest request, + CancellationToken cancellationToken) + { + var result = await _authService.RegisterDistributorAsync(request, cancellationToken); + + return result.Error switch + { + CreateUserError.None => StatusCode( + StatusCodes.Status201Created, + ApiResponse.SuccessResponse( + result.Response, + "Distributor registered successfully.")), + + CreateUserError.EmailInUse => Conflict( + ApiResponse.FailureResponse(result.Messages[0])), + + CreateUserError.ValidationFailed => BadRequest( + ApiResponse.FailureResponse( + "Validation failed.", + new Dictionary + { + [nameof(RegisterDistributorRequest.Password)] = result.Messages.ToArray() + })), + + _ => throw new InvalidOperationException($"Unhandled registration error: {result.Error}") + }; + } + + /// Exchanges email + password for a JWT access token. + [AllowAnonymous] + [HttpPost("login")] + public async Task Login( + [FromBody] LoginRequest request, + CancellationToken cancellationToken) + { + var result = await _authService.LoginAsync(request, cancellationToken); + + return result.Error switch + { + LoginError.None => Ok( + ApiResponse.SuccessResponse(result.Response, "Login successful.")), + + LoginError.InvalidCredentials => Unauthorized( + ApiResponse.FailureResponse("Invalid email or password.")), + + LoginError.TenantSuspended => StatusCode( + StatusCodes.Status403Forbidden, + ApiResponse.FailureResponse( + "This distributor account is suspended. Contact support.")), + + _ => throw new InvalidOperationException($"Unhandled login error: {result.Error}") + }; + } +} diff --git a/src/DistroFlow.Api/Controllers/MeController.cs b/src/DistroFlow.Api/Controllers/MeController.cs new file mode 100644 index 0000000..4249969 --- /dev/null +++ b/src/DistroFlow.Api/Controllers/MeController.cs @@ -0,0 +1,30 @@ +using DistroFlow.Application.Common; +using DistroFlow.Application.DTOs.Common; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; + +namespace DistroFlow.Api.Controllers; + +/// +/// The signed-in employee. For now returns what the token says; profile data +/// (name, phone) is added with employee management. +/// +[ApiController] +[Authorize] +[Route("api/v1/me")] +public class MeController : ControllerBase +{ + [HttpGet] + public IActionResult Get() + { + var me = new + { + UserId = Guid.Parse(User.FindFirst(AppClaimTypes.UserId)!.Value), + EmployeeId = Guid.Parse(User.FindFirst(AppClaimTypes.EmployeeId)!.Value), + TenantId = Guid.Parse(User.FindFirst(AppClaimTypes.TenantId)!.Value), + Roles = User.FindAll(AppClaimTypes.Role).Select(c => c.Value).ToArray() + }; + + return Ok(ApiResponse.SuccessResponse(me)); + } +} diff --git a/src/DistroFlow.Api/Controllers/TenantController.cs b/src/DistroFlow.Api/Controllers/TenantController.cs new file mode 100644 index 0000000..88ed921 --- /dev/null +++ b/src/DistroFlow.Api/Controllers/TenantController.cs @@ -0,0 +1,57 @@ +using DistroFlow.Application.DTOs.Common; +using DistroFlow.Application.DTOs.Tenants; +using DistroFlow.Application.Interfaces.Services; +using DistroFlow.Domain.Constants; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; + +namespace DistroFlow.Api.Controllers; + +/// +/// The caller's own distributor. There is no id in the route: the tenant comes from the +/// signed token, so a distributor cannot even address another distributor's record. +/// Tenants are created via POST /api/v1/auth/register. +/// +[ApiController] +[Authorize] +[Route("api/v1/tenant")] +public class TenantController : ControllerBase +{ + private readonly ITenantService _tenantService; + + public TenantController(ITenantService tenantService) + { + _tenantService = tenantService; + } + + /// Any employee may read their distributor's settings (currency, time zone...). + [HttpGet] + public async Task Get(CancellationToken cancellationToken) + { + var tenant = await _tenantService.GetCurrentAsync(cancellationToken); + + if (tenant is null) + { + return NotFound(ApiResponse.FailureResponse("Tenant not found.")); + } + + return Ok(ApiResponse.SuccessResponse(tenant, "Tenant retrieved successfully.")); + } + + /// Only an Administrator may change the distributor's settings. + [HttpPut] + [Authorize(Roles = EmployeeRoles.Administrator)] + public async Task Update( + [FromBody] UpdateTenantRequest request, + CancellationToken cancellationToken) + { + var tenant = await _tenantService.UpdateCurrentAsync(request, cancellationToken); + + if (tenant is null) + { + return NotFound(ApiResponse.FailureResponse("Tenant not found.")); + } + + return Ok(ApiResponse.SuccessResponse(tenant, "Tenant updated successfully.")); + } +} diff --git a/src/DistroFlow.Api/Controllers/TenantsController.cs b/src/DistroFlow.Api/Controllers/TenantsController.cs deleted file mode 100644 index 0bd82ed..0000000 --- a/src/DistroFlow.Api/Controllers/TenantsController.cs +++ /dev/null @@ -1,109 +0,0 @@ -using DistroFlow.Application.DTOs.Common; -using DistroFlow.Application.DTOs.Tenants; -using DistroFlow.Application.Interfaces.Services; -using Microsoft.AspNetCore.Mvc; - -namespace DistroFlow.Api.Controllers; - -[ApiController] -[Route("api/v1/tenants")] -public class TenantsController : ControllerBase -{ - private readonly ITenantService _tenantService; - - public TenantsController(ITenantService tenantService) - { - _tenantService = tenantService; - } - - [HttpPost] - public async Task Create( - [FromBody] CreateTenantRequest request, - CancellationToken cancellationToken) - { - var tenant = await _tenantService.CreateAsync( - request, cancellationToken); - - return StatusCode( - StatusCodes.Status201Created, - ApiResponse.SuccessResponse( - tenant, - "Tenant created successfully.")); - } - - [HttpGet] - public async Task GetAll( - CancellationToken cancellationToken) - { - var tenants = await _tenantService.GetAllAsync(cancellationToken); - - return Ok( - ApiResponse>.SuccessResponse( - tenants, - "Tenants retrieved successfully.")); - } - - [HttpGet("{id:guid}")] - public async Task GetById( - Guid id, - CancellationToken cancellationToken) - { - var tenant = await _tenantService.GetByIdAsync( - id, cancellationToken); - - if (tenant is null) - { - return NotFound( - ApiResponse.FailureResponse( - "Tenant not found.")); - } - - return Ok( - ApiResponse.SuccessResponse( - tenant, - "Tenant retrieved successfully.")); - } - - [HttpPut("{id:guid}")] - public async Task Update( - Guid id, - [FromBody] UpdateTenantRequest request, - CancellationToken cancellationToken) - { - var tenant = await _tenantService.UpdateAsync( - id, request, cancellationToken); - - if (tenant is null) - { - return NotFound( - ApiResponse.FailureResponse( - "Tenant not found.")); - } - - return Ok( - ApiResponse.SuccessResponse( - tenant, - "Tenant updated successfully.")); - } - - [HttpPatch("{id:guid}/deactivate")] - public async Task Deactivate( - Guid id, - CancellationToken cancellationToken) - { - var deactivated = await _tenantService.DeactivateAsync( - id, cancellationToken); - - if (!deactivated) - { - return NotFound( - ApiResponse.FailureResponse( - "Tenant not found.")); - } - - return Ok( - ApiResponse.SuccessResponse( - null, - "Tenant deactivated successfully.")); - } -} \ No newline at end of file diff --git a/src/DistroFlow.Api/DistroFlow.Api.csproj b/src/DistroFlow.Api/DistroFlow.Api.csproj index 047b5d6..7198e73 100644 --- a/src/DistroFlow.Api/DistroFlow.Api.csproj +++ b/src/DistroFlow.Api/DistroFlow.Api.csproj @@ -8,6 +8,7 @@ + runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/src/DistroFlow.Api/Extensions/ServiceCollectionExtensions.cs b/src/DistroFlow.Api/Extensions/ServiceCollectionExtensions.cs new file mode 100644 index 0000000..92fad09 --- /dev/null +++ b/src/DistroFlow.Api/Extensions/ServiceCollectionExtensions.cs @@ -0,0 +1,101 @@ +using System.Text; +using DistroFlow.Application.Common; +using DistroFlow.Api.Services; +using DistroFlow.Application.DTOs.Common; +using DistroFlow.Application.Interfaces.Identity; +using DistroFlow.Infrastructure.Identity; +using Microsoft.AspNetCore.Authentication.JwtBearer; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Options; +using Microsoft.IdentityModel.Tokens; + +namespace DistroFlow.Api.Extensions; + +/// HTTP-layer registrations: controllers and how incoming requests are authenticated. +public static class ServiceCollectionExtensions +{ + public static IServiceCollection AddApiControllers(this IServiceCollection services) + { + services + .AddControllers() + .ConfigureApiBehaviorOptions(options => + { + // Return DTO validation failures ([Required], [EmailAddress], ...) in the ApiResponse shape. + options.InvalidModelStateResponseFactory = context => + { + var errors = context.ModelState + .Where(x => x.Value?.Errors.Count > 0) + .ToDictionary( + x => x.Key, + x => x.Value!.Errors.Select(e => e.ErrorMessage).ToArray()); + + return new BadRequestObjectResult( + ApiResponse.FailureResponse("Validation failed.", errors)); + }; + }); + + return services; + } + + /// Who is calling: the current tenant, read from the request's JWT. + public static IServiceCollection AddCurrentTenant(this IServiceCollection services) + { + // Makes IHttpContextAccessor injectable, so services can reach the current request. + services.AddHttpContextAccessor(); + + // Scoped = one per HTTP request, matching the request it reads from. + services.AddScoped(); + + return services; + } + + /// + /// Validates incoming bearer tokens with the same validated + /// used to issue them, so issuing and validation can never drift apart. + /// + public static IServiceCollection AddJwtAuthentication(this IServiceCollection services) + { + services + .AddAuthentication(JwtBearerDefaults.AuthenticationScheme) + .AddJwtBearer(); + + services + .AddOptions(JwtBearerDefaults.AuthenticationScheme) + .Configure>((bearer, jwtOptions) => + { + var jwt = jwtOptions.Value; + + // Keep claim names exactly as issued ("sub", "role", "tenant_id") instead of + // remapping them to long XML-namespace URIs. + bearer.MapInboundClaims = false; + + bearer.TokenValidationParameters = new TokenValidationParameters + { + ValidateIssuer = true, + ValidIssuer = jwt.Issuer, + ValidateAudience = true, + ValidAudience = jwt.Audience, + ValidateIssuerSigningKey = true, + IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwt.SigningKey)), + // Only accept the algorithm we sign with (blocks algorithm-confusion attacks). + ValidAlgorithms = [SecurityAlgorithms.HmacSha256], + ValidateLifetime = true, + // Default tolerance is 5 minutes; 30 seconds is enough for clock drift. + ClockSkew = TimeSpan.FromSeconds(30), + NameClaimType = AppClaimTypes.UserId, + RoleClaimType = AppClaimTypes.Role + }; + }); + + // Secure by default: every endpoint requires an authenticated user unless it + // explicitly opts out with [AllowAnonymous] / .AllowAnonymous(). + // Forgetting [Authorize] on a new endpoint therefore cannot expose it. + services.AddAuthorizationBuilder() + .SetFallbackPolicy(new AuthorizationPolicyBuilder() + .RequireAuthenticatedUser() + .Build()); + + return services; + } +} diff --git a/src/DistroFlow.Api/Program.cs b/src/DistroFlow.Api/Program.cs index d3e9863..24365d9 100644 --- a/src/DistroFlow.Api/Program.cs +++ b/src/DistroFlow.Api/Program.cs @@ -1,73 +1,48 @@ -using DistroFlow.Application.DTOs.Common; -using Microsoft.AspNetCore.Mvc; -using DistroFlow.Infrastructure.Persistence; -using DistroFlow.Application.Services; -using DistroFlow.Application.Interfaces.Repositories; -using DistroFlow.Application.Interfaces.Services; -using DistroFlow.Infrastructure.Repositories; -using Microsoft.EntityFrameworkCore; - -var builder = WebApplication.CreateBuilder(args); - -builder.Services.AddOpenApi(); -builder.Services - .AddControllers() - .ConfigureApiBehaviorOptions(options => - { - options.InvalidModelStateResponseFactory = context => - { - var errors = context.ModelState - .Where(x => x.Value?.Errors.Count > 0) - .ToDictionary( - x => x.Key, - x => x.Value!.Errors - .Select(e => e.ErrorMessage) - .ToArray() - ); - - var response = ApiResponse.FailureResponse( - "Validation failed.", - errors - ); - - return new BadRequestObjectResult(response); - }; - }); - -var connectionString = builder.Configuration - .GetConnectionString("DefaultConnection") - ?? throw new InvalidOperationException( - "Database connection string 'DefaultConnection' is missing."); - -builder.Services.AddDbContext(options => - options.UseNpgsql(connectionString)); - -builder.Services.AddScoped(); -builder.Services.AddScoped(); - -var app = builder.Build(); - -app.MapGet("/health/database", async (AppDbContext db) => -{ - var canConnect = await db.Database.CanConnectAsync(); - - return canConnect - ? Results.Ok(new - { - status = "Connected", - database = "distroflow_dev" - }) - : Results.Problem("Unable to connect to PostgreSQL."); -}); - -if (app.Environment.IsDevelopment()) -{ - app.MapOpenApi(); -} - -app.UseHttpsRedirection(); - -app.MapControllers(); - -app.Run(); -public partial class Program { } \ No newline at end of file +using DistroFlow.Api.Extensions; +using DistroFlow.Application; +using DistroFlow.Infrastructure; +using DistroFlow.Infrastructure.Persistence; + +var builder = WebApplication.CreateBuilder(args); + +// ---- Services (what the app is made of) ---- +builder.Services.AddOpenApi(); +builder.Services.AddApiControllers(); +builder.Services.AddApplication(); +builder.Services.AddInfrastructure(builder.Configuration); +builder.Services.AddJwtAuthentication(); +builder.Services.AddCurrentTenant(); + +var app = builder.Build(); + +// ---- Request pipeline (what happens to each request, in order) ---- +app.MapGet("/health/database", async (AppDbContext db) => +{ + var canConnect = await db.Database.CanConnectAsync(); + + return canConnect + ? Results.Ok(new + { + status = "Connected", + database = "distroflow_dev" + }) + : Results.Problem("Unable to connect to PostgreSQL."); +}) +.AllowAnonymous(); // probed by Docker/monitoring, which have no token + +if (app.Environment.IsDevelopment()) +{ + app.MapOpenApi().AllowAnonymous(); +} + +app.UseHttpsRedirection(); + +app.UseAuthentication(); +app.UseAuthorization(); + +app.MapControllers(); + +app.Run(); + +// Lets the integration tests reference Program in WebApplicationFactory. +public partial class Program { } diff --git a/src/DistroFlow.Api/Services/HttpCurrentTenant.cs b/src/DistroFlow.Api/Services/HttpCurrentTenant.cs new file mode 100644 index 0000000..8b6c7b4 --- /dev/null +++ b/src/DistroFlow.Api/Services/HttpCurrentTenant.cs @@ -0,0 +1,18 @@ +using DistroFlow.Application.Common; +using DistroFlow.Application.Interfaces.Identity; + +namespace DistroFlow.Api.Services; + +public class HttpCurrentTenant(IHttpContextAccessor httpContextAccessor): ICurrentTenant +{ + private readonly IHttpContextAccessor _httpContextAccessor = httpContextAccessor; + + public Guid? TenantId + { + get + { + var value = _httpContextAccessor.HttpContext?.User.FindFirst(AppClaimTypes.TenantId)?.Value; + return Guid.TryParse(value ,out var tenantId) ? tenantId : null; + } + } +} \ No newline at end of file diff --git a/src/DistroFlow.Api/appsettings.Development.json b/src/DistroFlow.Api/appsettings.Development.json index d419df9..843b77b 100644 --- a/src/DistroFlow.Api/appsettings.Development.json +++ b/src/DistroFlow.Api/appsettings.Development.json @@ -6,6 +6,6 @@ } }, "ConnectionStrings": { - "DefaultConnection": "Host=localhost;Port=5433;Database=distroflow_dev;Username=distroflow;Password=password" + "DefaultConnection": "Host=localhost;Port=5433;Database=distroflow_dev;Username=distroflow" } } diff --git a/src/DistroFlow.Api/appsettings.json b/src/DistroFlow.Api/appsettings.json index 4d56694..f856aa3 100644 --- a/src/DistroFlow.Api/appsettings.json +++ b/src/DistroFlow.Api/appsettings.json @@ -1,9 +1,14 @@ -{ - "Logging": { - "LogLevel": { - "Default": "Information", - "Microsoft.AspNetCore": "Warning" - } - }, - "AllowedHosts": "*" -} +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*", + "Jwt": { + "Issuer": "DistroFlow.Api", + "Audience": "DistroFlow.Web", + "AccessTokenMinutes": 30 + } +} diff --git a/src/DistroFlow.Application/Common/AppClaimTypes.cs b/src/DistroFlow.Application/Common/AppClaimTypes.cs new file mode 100644 index 0000000..c876717 --- /dev/null +++ b/src/DistroFlow.Application/Common/AppClaimTypes.cs @@ -0,0 +1,18 @@ +namespace DistroFlow.Application.Common; + +/// +/// Claim names in DistroFlow access tokens. Short JWT-style names are used as-is +/// (inbound claim mapping is disabled), so what the token contains is what the code reads. +/// +public static class AppClaimTypes +{ + /// Login account id (standard JWT "sub"). + public const string UserId = "sub"; + + public const string EmployeeId = "employee_id"; + + /// The only trusted source of the current tenant for a request. + public const string TenantId = "tenant_id"; + + public const string Role = "role"; +} diff --git a/src/DistroFlow.Application/DTOs/Auth/LoginRequest.cs b/src/DistroFlow.Application/DTOs/Auth/LoginRequest.cs new file mode 100644 index 0000000..a52a86d --- /dev/null +++ b/src/DistroFlow.Application/DTOs/Auth/LoginRequest.cs @@ -0,0 +1,15 @@ +using System.ComponentModel.DataAnnotations; + +namespace DistroFlow.Application.DTOs.Auth; + +public class LoginRequest +{ + [Required] + [EmailAddress] + [StringLength(256)] + public string Email { get; set; } = string.Empty; + + [Required] + [StringLength(128)] + public string Password { get; set; } = string.Empty; +} diff --git a/src/DistroFlow.Application/DTOs/Auth/LoginResponse.cs b/src/DistroFlow.Application/DTOs/Auth/LoginResponse.cs new file mode 100644 index 0000000..4abf977 --- /dev/null +++ b/src/DistroFlow.Application/DTOs/Auth/LoginResponse.cs @@ -0,0 +1,10 @@ +namespace DistroFlow.Application.DTOs.Auth; + +public class LoginResponse +{ + public string AccessToken { get; set; } = string.Empty; + + public string TokenType { get; set; } = "Bearer"; + + public DateTime ExpiresAtUtc { get; set; } +} diff --git a/src/DistroFlow.Application/DTOs/Auth/LoginResult.cs b/src/DistroFlow.Application/DTOs/Auth/LoginResult.cs new file mode 100644 index 0000000..ca55fd2 --- /dev/null +++ b/src/DistroFlow.Application/DTOs/Auth/LoginResult.cs @@ -0,0 +1,28 @@ +namespace DistroFlow.Application.DTOs.Auth; + +public enum LoginError +{ + None = 0, + + /// + /// Unknown email, wrong password, inactive or locked account. Deliberately one error, + /// so the response never reveals whether an email has an account. + /// + InvalidCredentials = 1, + + /// Correct credentials, but the distributor is suspended. + TenantSuspended = 2 +} + +public sealed record LoginResult +{ + public LoginResponse? Response { get; private init; } + + public LoginError Error { get; private init; } + + public bool Succeeded => Error == LoginError.None; + + public static LoginResult Success(LoginResponse response) => new() { Response = response }; + + public static LoginResult Failure(LoginError error) => new() { Error = error }; +} diff --git a/src/DistroFlow.Application/DTOs/Auth/RegisterDistributorRequest.cs b/src/DistroFlow.Application/DTOs/Auth/RegisterDistributorRequest.cs new file mode 100644 index 0000000..002fc3c --- /dev/null +++ b/src/DistroFlow.Application/DTOs/Auth/RegisterDistributorRequest.cs @@ -0,0 +1,38 @@ +using System.ComponentModel.DataAnnotations; +using DistroFlow.Application.Validators; + +namespace DistroFlow.Application.DTOs.Auth; + +/// +/// Self-signup: creates a distributor (tenant) and its first Administrator. +/// Currency, invoice prefix and fiscal year use defaults and can be edited later. +/// +public class RegisterDistributorRequest +{ + [Required] + [StringLength(200, MinimumLength = 2)] + public string BusinessName { get; set; } = string.Empty; + + [Required] + [StringLength(64)] + [ValidTimeZone] + public string TimeZoneId { get; set; } = "Asia/Kolkata"; + + [Required] + [StringLength(150, MinimumLength = 2)] + public string OwnerFullName { get; set; } = string.Empty; + + [RegularExpression(@"^\+[1-9]\d{7,14}$", + ErrorMessage = "Phone must be in E.164 format, e.g. +919876543210.")] + public string? OwnerPhone { get; set; } + + [Required] + [EmailAddress] + [StringLength(256)] + public string Email { get; set; } = string.Empty; + + /// Strength rules are enforced by Identity's password policy. + [Required] + [StringLength(128, MinimumLength = 8)] + public string Password { get; set; } = string.Empty; +} diff --git a/src/DistroFlow.Application/DTOs/Auth/RegisterDistributorResponse.cs b/src/DistroFlow.Application/DTOs/Auth/RegisterDistributorResponse.cs new file mode 100644 index 0000000..25b7d19 --- /dev/null +++ b/src/DistroFlow.Application/DTOs/Auth/RegisterDistributorResponse.cs @@ -0,0 +1,10 @@ +namespace DistroFlow.Application.DTOs.Auth; + +public class RegisterDistributorResponse +{ + public Guid TenantId { get; set; } + + public Guid EmployeeId { get; set; } + + public Guid UserId { get; set; } +} diff --git a/src/DistroFlow.Application/DTOs/Auth/RegisterDistributorResult.cs b/src/DistroFlow.Application/DTOs/Auth/RegisterDistributorResult.cs new file mode 100644 index 0000000..08e06e2 --- /dev/null +++ b/src/DistroFlow.Application/DTOs/Auth/RegisterDistributorResult.cs @@ -0,0 +1,24 @@ +using DistroFlow.Application.Interfaces.Identity; + +namespace DistroFlow.Application.DTOs.Auth; + +/// +/// Registration can only fail on the login part (email in use, weak password); +/// other input is validated at the API boundary. So it reuses . +/// +public sealed record RegisterDistributorResult +{ + public RegisterDistributorResponse? Response { get; private init; } + + public CreateUserError Error { get; private init; } + + public IReadOnlyList Messages { get; private init; } = []; + + public bool Succeeded => Error == CreateUserError.None; + + public static RegisterDistributorResult Success(RegisterDistributorResponse response) => + new() { Response = response }; + + public static RegisterDistributorResult Failure(CreateUserError error, IReadOnlyList messages) => + new() { Error = error, Messages = messages }; +} diff --git a/src/DistroFlow.Application/DTOs/Tenants/CreateTenantRequest.cs b/src/DistroFlow.Application/DTOs/Tenants/CreateTenantRequest.cs deleted file mode 100644 index 0f96cca..0000000 --- a/src/DistroFlow.Application/DTOs/Tenants/CreateTenantRequest.cs +++ /dev/null @@ -1,36 +0,0 @@ -using System.ComponentModel.DataAnnotations; -using DistroFlow.Application.Validators; - -namespace DistroFlow.Application.DTOs.Tenants; - -public class CreateTenantRequest -{ - [Required] - [StringLength(200, MinimumLength = 2)] - public string Name { get; set; } = string.Empty; - - [EmailAddress] - [StringLength(255)] - public string? Email { get; set; } - - [Phone] - [StringLength(20)] - public string? Phone { get; set; } - - [Required] - [RegularExpression(@"^[A-Z]{3}$", - ErrorMessage = "Currency code must contain exactly 3 uppercase letters.")] - public string CurrencyCode { get; set; } = "INR"; - - [Required] - [StringLength(10, MinimumLength = 2)] - public string InvoicePrefix { get; set; } = "INV"; - - [Range(1, 12)] - public int FiscalYearStartMonth { get; set; } = 4; - - [Required] - [StringLength(64)] - [ValidTimeZone] - public string TimeZoneId { get; set; } = "Asia/Kolkata"; -} \ No newline at end of file diff --git a/src/DistroFlow.Application/DTOs/Tenants/UpdateTenantRequest.cs b/src/DistroFlow.Application/DTOs/Tenants/UpdateTenantRequest.cs index 71ea7eb..b1825a0 100644 --- a/src/DistroFlow.Application/DTOs/Tenants/UpdateTenantRequest.cs +++ b/src/DistroFlow.Application/DTOs/Tenants/UpdateTenantRequest.cs @@ -33,5 +33,6 @@ public class UpdateTenantRequest [ValidTimeZone] public string TimeZoneId { get; set; } = "Asia/Kolkata"; - public bool IsActive { get; set; } + // No IsActive: suspending/reactivating a distributor is a platform decision, + // not something a distributor's own administrator may change. } \ No newline at end of file diff --git a/src/DistroFlow.Application/DependencyInjection.cs b/src/DistroFlow.Application/DependencyInjection.cs new file mode 100644 index 0000000..7cae76e --- /dev/null +++ b/src/DistroFlow.Application/DependencyInjection.cs @@ -0,0 +1,17 @@ +using DistroFlow.Application.Interfaces.Services; +using DistroFlow.Application.Services; +using Microsoft.Extensions.DependencyInjection; + +namespace DistroFlow.Application; + +public static class DependencyInjection +{ + /// Registers the Application layer's use-case services. + public static IServiceCollection AddApplication(this IServiceCollection services) + { + services.AddScoped(); + services.AddScoped(); + + return services; + } +} diff --git a/src/DistroFlow.Application/DistroFlow.Application.csproj b/src/DistroFlow.Application/DistroFlow.Application.csproj index ed9dc64..a94d94e 100644 --- a/src/DistroFlow.Application/DistroFlow.Application.csproj +++ b/src/DistroFlow.Application/DistroFlow.Application.csproj @@ -4,6 +4,10 @@ + + + + net10.0 enable diff --git a/src/DistroFlow.Application/Interfaces/Identity/CreateUserResult.cs b/src/DistroFlow.Application/Interfaces/Identity/CreateUserResult.cs new file mode 100644 index 0000000..cfbe43c --- /dev/null +++ b/src/DistroFlow.Application/Interfaces/Identity/CreateUserResult.cs @@ -0,0 +1,32 @@ +namespace DistroFlow.Application.Interfaces.Identity; + +public enum CreateUserError +{ + None = 0, + + /// The email already belongs to an active account (in any tenant). + EmailInUse = 1, + + /// Identity rejected the input, e.g. the password does not meet the policy. + ValidationFailed = 2 +} + +/// +/// Outcome of creating a login. Expected failures (email in use, weak password) are results, +/// not exceptions, so callers can map them to 409/400 without try/catch. +/// +public sealed record CreateUserResult +{ + public Guid? UserId { get; private init; } + + public CreateUserError Error { get; private init; } + + public IReadOnlyList Messages { get; private init; } = []; + + public bool Succeeded => Error == CreateUserError.None; + + public static CreateUserResult Success(Guid userId) => new() { UserId = userId }; + + public static CreateUserResult Failure(CreateUserError error, IReadOnlyList messages) => + new() { Error = error, Messages = messages }; +} diff --git a/src/DistroFlow.Application/Interfaces/Identity/CredentialCheckResult.cs b/src/DistroFlow.Application/Interfaces/Identity/CredentialCheckResult.cs new file mode 100644 index 0000000..ad175ec --- /dev/null +++ b/src/DistroFlow.Application/Interfaces/Identity/CredentialCheckResult.cs @@ -0,0 +1,30 @@ +namespace DistroFlow.Application.Interfaces.Identity; + +public enum CredentialCheckError +{ + None = 0, + + /// No active account with this email, or wrong password. + InvalidCredentials = 1, + + /// Too many failed attempts; the account is temporarily locked. + LockedOut = 2 +} + +public sealed record CredentialCheckResult +{ + public Guid UserId { get; private init; } + + public Guid TenantId { get; private init; } + + public IReadOnlyList Roles { get; private init; } = []; + + public CredentialCheckError Error { get; private init; } + + public bool Succeeded => Error == CredentialCheckError.None; + + public static CredentialCheckResult Success(Guid userId, Guid tenantId, IReadOnlyList roles) => + new() { UserId = userId, TenantId = tenantId, Roles = roles }; + + public static CredentialCheckResult Failure(CredentialCheckError error) => new() { Error = error }; +} diff --git a/src/DistroFlow.Application/Interfaces/Identity/ICurrentTenant.cs b/src/DistroFlow.Application/Interfaces/Identity/ICurrentTenant.cs new file mode 100644 index 0000000..bef4cf8 --- /dev/null +++ b/src/DistroFlow.Application/Interfaces/Identity/ICurrentTenant.cs @@ -0,0 +1,6 @@ +namespace DistroFlow.Application.Interfaces.Identity; + +public interface ICurrentTenant +{ + Guid? TenantId { get; } +} \ No newline at end of file diff --git a/src/DistroFlow.Application/Interfaces/Identity/IIdentityService.cs b/src/DistroFlow.Application/Interfaces/Identity/IIdentityService.cs new file mode 100644 index 0000000..e1ef274 --- /dev/null +++ b/src/DistroFlow.Application/Interfaces/Identity/IIdentityService.cs @@ -0,0 +1,28 @@ +namespace DistroFlow.Application.Interfaces.Identity; + +/// +/// Creates and manages employee login accounts. Hides ASP.NET Core Identity from the +/// Application layer, which must not depend on Identity types such as ApplicationUser. +/// +public interface IIdentityService +{ + /// + /// Creates a login account in with a hashed password and the given role. + /// Saves immediately; wrap in a transaction when other rows must be saved with it. + /// + Task CreateUserAsync( + Guid tenantId, + string email, + string password, + string role, + CancellationToken cancellationToken = default); + + /// + /// Checks email + password against the single active account with that email. + /// Counts failed attempts and enforces lockout. + /// + Task CheckCredentialsAsync( + string email, + string password, + CancellationToken cancellationToken = default); +} diff --git a/src/DistroFlow.Application/Interfaces/Identity/ITokenService.cs b/src/DistroFlow.Application/Interfaces/Identity/ITokenService.cs new file mode 100644 index 0000000..8127f8f --- /dev/null +++ b/src/DistroFlow.Application/Interfaces/Identity/ITokenService.cs @@ -0,0 +1,15 @@ +namespace DistroFlow.Application.Interfaces.Identity; + +public interface ITokenService +{ + AccessToken CreateAccessToken(TokenSubject subject); +} + +/// Who the token is for. Everything here ends up as claims in the token. +public sealed record TokenSubject( + Guid UserId, + Guid EmployeeId, + Guid TenantId, + IReadOnlyCollection Roles); + +public sealed record AccessToken(string Token, DateTime ExpiresAtUtc); diff --git a/src/DistroFlow.Application/Interfaces/Persistence/IUnitOfWork.cs b/src/DistroFlow.Application/Interfaces/Persistence/IUnitOfWork.cs new file mode 100644 index 0000000..5cdd4fc --- /dev/null +++ b/src/DistroFlow.Application/Interfaces/Persistence/IUnitOfWork.cs @@ -0,0 +1,21 @@ +namespace DistroFlow.Application.Interfaces.Persistence; + +/// +/// Saves pending changes and groups several saves into one database transaction. +/// All repositories in a request share the same DbContext, so one transaction covers them all. +/// +public interface IUnitOfWork +{ + Task SaveChangesAsync(CancellationToken cancellationToken = default); + + /// + /// Starts a transaction. Call to keep the changes; + /// disposing without committing rolls everything back. + /// + Task BeginTransactionAsync(CancellationToken cancellationToken = default); +} + +public interface ITransaction : IAsyncDisposable +{ + Task CommitAsync(CancellationToken cancellationToken = default); +} diff --git a/src/DistroFlow.Application/Interfaces/Repositories/IEmployeeRepository.cs b/src/DistroFlow.Application/Interfaces/Repositories/IEmployeeRepository.cs new file mode 100644 index 0000000..4c905dd --- /dev/null +++ b/src/DistroFlow.Application/Interfaces/Repositories/IEmployeeRepository.cs @@ -0,0 +1,16 @@ +using DistroFlow.Domain.Entities; + +namespace DistroFlow.Application.Interfaces.Repositories; + +public interface IEmployeeRepository +{ + void Add(Employee employee); + + /// + /// Login only: finds the employee for an authenticated login across all tenants + /// (no tenant is known before login). Do not use for tenant-facing queries. + /// + Task GetForLoginByUserIdAsync( + Guid userId, + CancellationToken cancellationToken = default); +} diff --git a/src/DistroFlow.Application/Interfaces/Repositories/ITenantRepository.cs b/src/DistroFlow.Application/Interfaces/Repositories/ITenantRepository.cs index 9b5816c..8f39fc1 100644 --- a/src/DistroFlow.Application/Interfaces/Repositories/ITenantRepository.cs +++ b/src/DistroFlow.Application/Interfaces/Repositories/ITenantRepository.cs @@ -14,9 +14,6 @@ public interface ITenantRepository Guid id, CancellationToken cancellationToken = default); - Task> GetAllAsync( - CancellationToken cancellationToken = default); - Task SaveChangesAsync( CancellationToken cancellationToken = default); } \ No newline at end of file diff --git a/src/DistroFlow.Application/Interfaces/Services/IAuthService.cs b/src/DistroFlow.Application/Interfaces/Services/IAuthService.cs new file mode 100644 index 0000000..923e032 --- /dev/null +++ b/src/DistroFlow.Application/Interfaces/Services/IAuthService.cs @@ -0,0 +1,14 @@ +using DistroFlow.Application.DTOs.Auth; + +namespace DistroFlow.Application.Interfaces.Services; + +public interface IAuthService +{ + Task RegisterDistributorAsync( + RegisterDistributorRequest request, + CancellationToken cancellationToken = default); + + Task LoginAsync( + LoginRequest request, + CancellationToken cancellationToken = default); +} diff --git a/src/DistroFlow.Application/Interfaces/Services/ITenantService.cs b/src/DistroFlow.Application/Interfaces/Services/ITenantService.cs index f7aee0b..8a8f3ca 100644 --- a/src/DistroFlow.Application/Interfaces/Services/ITenantService.cs +++ b/src/DistroFlow.Application/Interfaces/Services/ITenantService.cs @@ -2,25 +2,16 @@ namespace DistroFlow.Application.Interfaces.Services; +/// +/// The caller's own distributor. The tenant always comes from +/// (the JWT), never from a route or body, so one distributor can never address another. +/// public interface ITenantService { - Task CreateAsync( - CreateTenantRequest request, + Task GetCurrentAsync( CancellationToken cancellationToken = default); - Task GetByIdAsync( - Guid id, - CancellationToken cancellationToken = default); - - Task> GetAllAsync( - CancellationToken cancellationToken = default); - - Task UpdateAsync( - Guid id, + Task UpdateCurrentAsync( UpdateTenantRequest request, CancellationToken cancellationToken = default); - - Task DeactivateAsync( - Guid id, - CancellationToken cancellationToken = default); -} \ No newline at end of file +} diff --git a/src/DistroFlow.Application/Services/AuthService.cs b/src/DistroFlow.Application/Services/AuthService.cs new file mode 100644 index 0000000..84c3d49 --- /dev/null +++ b/src/DistroFlow.Application/Services/AuthService.cs @@ -0,0 +1,130 @@ +using DistroFlow.Application.DTOs.Auth; +using DistroFlow.Application.Interfaces.Identity; +using DistroFlow.Application.Interfaces.Persistence; +using DistroFlow.Application.Interfaces.Repositories; +using DistroFlow.Application.Interfaces.Services; +using DistroFlow.Domain.Constants; +using DistroFlow.Domain.Entities; + +namespace DistroFlow.Application.Services; + +public class AuthService : IAuthService +{ + private readonly ITenantRepository _tenantRepository; + private readonly IEmployeeRepository _employeeRepository; + private readonly IIdentityService _identityService; + private readonly ITokenService _tokenService; + private readonly IUnitOfWork _unitOfWork; + + public AuthService( + ITenantRepository tenantRepository, + IEmployeeRepository employeeRepository, + IIdentityService identityService, + ITokenService tokenService, + IUnitOfWork unitOfWork) + { + _tenantRepository = tenantRepository; + _employeeRepository = employeeRepository; + _identityService = identityService; + _tokenService = tokenService; + _unitOfWork = unitOfWork; + } + + public async Task LoginAsync( + LoginRequest request, + CancellationToken cancellationToken = default) + { + var credentials = await _identityService.CheckCredentialsAsync( + request.Email, request.Password, cancellationToken); + + if (!credentials.Succeeded) + { + // Locked-out is reported the same as a wrong password: only an existing + // account can be locked, so a distinct message would reveal the email exists. + return LoginResult.Failure(LoginError.InvalidCredentials); + } + + var tenant = await _tenantRepository.GetByIdReadOnlyAsync(credentials.TenantId, cancellationToken); + if (tenant is null || !tenant.IsActive) + { + // Safe to say: the caller has already proven they own the account. + return LoginResult.Failure(LoginError.TenantSuspended); + } + + var employee = await _employeeRepository.GetForLoginByUserIdAsync(credentials.UserId, cancellationToken); + if (employee is null || !employee.IsActive) + { + // Login and employee status are updated together, so this is a safety net. + return LoginResult.Failure(LoginError.InvalidCredentials); + } + + var token = _tokenService.CreateAccessToken(new TokenSubject( + credentials.UserId, + employee.Id, + tenant.Id, + credentials.Roles)); + + return LoginResult.Success(new LoginResponse + { + AccessToken = token.Token, + ExpiresAtUtc = token.ExpiresAtUtc + }); + } + + /// + /// Creates tenant → login → employee in one transaction, so a failure at any step + /// (e.g. email already in use) leaves no half-registered distributor behind. + /// + public async Task RegisterDistributorAsync( + RegisterDistributorRequest request, + CancellationToken cancellationToken = default) + { + var now = DateTime.UtcNow; + + await using var transaction = await _unitOfWork.BeginTransactionAsync(cancellationToken); + + var tenant = new Tenant + { + Name = request.BusinessName.Trim(), + TimeZoneId = request.TimeZoneId.Trim(), + CreatedAtUtc = now + }; + _tenantRepository.Add(tenant); + + // The login row has a foreign key to the tenant, so the tenant is saved first. + await _unitOfWork.SaveChangesAsync(cancellationToken); + + var login = await _identityService.CreateUserAsync( + tenant.Id, + request.Email, + request.Password, + EmployeeRoles.Administrator, + cancellationToken); + + if (!login.Succeeded) + { + // Returning without commit: disposing the transaction rolls back the tenant insert. + return RegisterDistributorResult.Failure(login.Error, login.Messages); + } + + var employee = new Employee + { + TenantId = tenant.Id, + UserId = login.UserId, + FullName = request.OwnerFullName.Trim(), + Phone = string.IsNullOrWhiteSpace(request.OwnerPhone) ? null : request.OwnerPhone.Trim(), + CreatedAtUtc = now + }; + _employeeRepository.Add(employee); + + await _unitOfWork.SaveChangesAsync(cancellationToken); + await transaction.CommitAsync(cancellationToken); + + return RegisterDistributorResult.Success(new RegisterDistributorResponse + { + TenantId = tenant.Id, + EmployeeId = employee.Id, + UserId = login.UserId!.Value + }); + } +} diff --git a/src/DistroFlow.Application/Services/TenantService.cs b/src/DistroFlow.Application/Services/TenantService.cs index 121aef5..27b541f 100644 --- a/src/DistroFlow.Application/Services/TenantService.cs +++ b/src/DistroFlow.Application/Services/TenantService.cs @@ -1,5 +1,5 @@ - using DistroFlow.Application.DTOs.Tenants; +using DistroFlow.Application.Interfaces.Identity; using DistroFlow.Application.Interfaces.Repositories; using DistroFlow.Application.Interfaces.Services; using DistroFlow.Domain.Entities; @@ -9,60 +9,29 @@ namespace DistroFlow.Application.Services; public class TenantService : ITenantService { private readonly ITenantRepository _tenantRepository; + private readonly ICurrentTenant _currentTenant; - public TenantService(ITenantRepository tenantRepository) + public TenantService(ITenantRepository tenantRepository, ICurrentTenant currentTenant) { _tenantRepository = tenantRepository; + _currentTenant = currentTenant; } - public async Task CreateAsync( - CreateTenantRequest request, - CancellationToken cancellationToken = default) - { - var tenant = new Tenant - { - Name = request.Name.Trim(), - Email = request.Email?.Trim(), - Phone = request.Phone?.Trim(), - CurrencyCode = request.CurrencyCode.Trim().ToUpperInvariant(), - InvoicePrefix = request.InvoicePrefix.Trim().ToUpperInvariant(), - FiscalYearStartMonth = request.FiscalYearStartMonth, - TimeZoneId = request.TimeZoneId.Trim(), - CreatedAtUtc = DateTime.UtcNow - }; - - _tenantRepository.Add(tenant); - - await _tenantRepository.SaveChangesAsync(cancellationToken); - - return MapToResponse(tenant); - } - - public async Task GetByIdAsync( - Guid id, + public async Task GetCurrentAsync( CancellationToken cancellationToken = default) { var tenant = await _tenantRepository.GetByIdReadOnlyAsync( - id, cancellationToken); + CurrentTenantId(), cancellationToken); return tenant is null ? null : MapToResponse(tenant); } - public async Task> GetAllAsync( - CancellationToken cancellationToken = default) - { - var tenants = await _tenantRepository.GetAllAsync(cancellationToken); - - return tenants.Select(MapToResponse).ToList(); - } - - public async Task UpdateAsync( - Guid id, + public async Task UpdateCurrentAsync( UpdateTenantRequest request, CancellationToken cancellationToken = default) { var tenant = await _tenantRepository.GetByIdAsync( - id, cancellationToken); + CurrentTenantId(), cancellationToken); if (tenant is null) return null; @@ -74,7 +43,6 @@ public async Task> GetAllAsync( tenant.InvoicePrefix = request.InvoicePrefix.Trim().ToUpperInvariant(); tenant.FiscalYearStartMonth = request.FiscalYearStartMonth; tenant.TimeZoneId = request.TimeZoneId.Trim(); - tenant.IsActive = request.IsActive; tenant.UpdatedAtUtc = DateTime.UtcNow; await _tenantRepository.SaveChangesAsync(cancellationToken); @@ -82,26 +50,14 @@ public async Task> GetAllAsync( return MapToResponse(tenant); } - public async Task DeactivateAsync( - Guid id, - CancellationToken cancellationToken = default) - { - var tenant = await _tenantRepository.GetByIdAsync( - id, cancellationToken); - - if (tenant is null) - return false; - - if (!tenant.IsActive) - return true; - - tenant.IsActive = false; - tenant.UpdatedAtUtc = DateTime.UtcNow; - - await _tenantRepository.SaveChangesAsync(cancellationToken); - - return true; - } + /// + /// These endpoints require authentication, so a tenant is always present. + /// A missing one means an endpoint was exposed anonymously by mistake: fail loudly. + /// + private Guid CurrentTenantId() => + _currentTenant.TenantId + ?? throw new InvalidOperationException( + "No current tenant. Tenant endpoints must require an authenticated user."); private static TenantResponse MapToResponse(Tenant tenant) { diff --git a/src/DistroFlow.Domain/Constants/EmployeeRoles.cs b/src/DistroFlow.Domain/Constants/EmployeeRoles.cs new file mode 100644 index 0000000..9f3a3cc --- /dev/null +++ b/src/DistroFlow.Domain/Constants/EmployeeRoles.cs @@ -0,0 +1,23 @@ +namespace DistroFlow.Domain.Constants; + +/// +/// The fixed set of employee roles. Authorization policies refer to these names, +/// and they are seeded into the database by a migration. +/// +public static class EmployeeRoles +{ + public const string Administrator = "Administrator"; + public const string SalesStaff = "Sales Staff"; + public const string DispatchStaff = "Dispatch Staff"; + public const string Approver = "Approver"; + public const string AccountsStaff = "Accounts Staff"; + + public static IReadOnlyList All { get; } = + [ + Administrator, + SalesStaff, + DispatchStaff, + Approver, + AccountsStaff + ]; +} diff --git a/src/DistroFlow.Domain/Entities/Employee.cs b/src/DistroFlow.Domain/Entities/Employee.cs new file mode 100644 index 0000000..980a321 --- /dev/null +++ b/src/DistroFlow.Domain/Entities/Employee.cs @@ -0,0 +1,100 @@ +namespace DistroFlow.Domain.Entities; + +/// +/// A person who works for a distributor (tenant) and uses DistroFlow. +/// +/// +/// Privacy (DPDP Act, 2023): +/// - Data minimisation: only what is needed to identify the actor on orders, approvals and audit +/// entries. Do not add Aadhaar, PAN, date of birth, home address, bank details or photos; +/// DistroFlow does not run payroll or HR. +/// - The login email lives only on the Identity user. It is not copied here. +/// - Erasure: removes personal data but keeps the row and its Id, so +/// records that must be retained (invoices, audit) still point to "a former employee". +/// +public class Employee +{ + public const string AnonymisedName = "Former employee"; + + public Guid Id { get; set; } = Guid.CreateVersion7(); + + public Guid TenantId { get; set; } + + public Tenant Tenant { get; set; } = null!; + + /// + /// Login account (Identity user). Null after anonymisation, when the account is deleted. + /// + public Guid? UserId { get; set; } + + /// Optional internal code assigned by the distributor, unique per tenant. + public string? EmployeeCode { get; set; } + + public string FullName { get; set; } = string.Empty; + + /// Work phone in E.164 format, optional. + public string? Phone { get; set; } + + public EmployeeStatus Status { get; private set; } = EmployeeStatus.Active; + + public DateTime? DeactivatedAtUtc { get; private set; } + + public DateTime? AnonymisedAtUtc { get; private set; } + + public DateTime CreatedAtUtc { get; set; } = DateTime.UtcNow; + + public DateTime? UpdatedAtUtc { get; set; } + + public bool IsActive => Status == EmployeeStatus.Active; + + public void Deactivate(DateTime utcNow) + { + if (Status != EmployeeStatus.Active) + { + return; + } + + Status = EmployeeStatus.Deactivated; + DeactivatedAtUtc = utcNow; + UpdatedAtUtc = utcNow; + } + + public void Reactivate(DateTime utcNow) + { + if (Status == EmployeeStatus.Anonymised) + { + throw new InvalidOperationException("An anonymised employee cannot be reactivated."); + } + + if (Status == EmployeeStatus.Active) + { + return; + } + + Status = EmployeeStatus.Active; + DeactivatedAtUtc = null; + UpdatedAtUtc = utcNow; + } + + /// + /// Erases personal data (right to erasure, or the end of the retention period). + /// The caller must also delete the Identity user in the same transaction. + /// + public void Anonymise(DateTime utcNow) + { + if (Status == EmployeeStatus.Anonymised) + { + return; + } + + DeactivatedAtUtc ??= utcNow; + Status = EmployeeStatus.Anonymised; + AnonymisedAtUtc = utcNow; + UpdatedAtUtc = utcNow; + + FullName = AnonymisedName; + Phone = null; + EmployeeCode = null; + UserId = null; + } +} diff --git a/src/DistroFlow.Domain/Entities/EmployeeStatus.cs b/src/DistroFlow.Domain/Entities/EmployeeStatus.cs new file mode 100644 index 0000000..1afedbc --- /dev/null +++ b/src/DistroFlow.Domain/Entities/EmployeeStatus.cs @@ -0,0 +1,13 @@ +namespace DistroFlow.Domain.Entities; + +public enum EmployeeStatus +{ + /// Can sign in and act in the system. + Active = 1, + + /// Left or suspended. Personal data is kept only until the retention period ends. + Deactivated = 2, + + /// Personal data erased. The row remains so past orders and audit entries still resolve. + Anonymised = 3 +} diff --git a/src/DistroFlow.Infrastructure/DependencyInjection.cs b/src/DistroFlow.Infrastructure/DependencyInjection.cs new file mode 100644 index 0000000..c2dad46 --- /dev/null +++ b/src/DistroFlow.Infrastructure/DependencyInjection.cs @@ -0,0 +1,78 @@ +using DistroFlow.Application.Interfaces.Identity; +using DistroFlow.Application.Interfaces.Persistence; +using DistroFlow.Application.Interfaces.Repositories; +using DistroFlow.Infrastructure.Identity; +using DistroFlow.Infrastructure.Persistence; +using DistroFlow.Infrastructure.Repositories; +using Microsoft.AspNetCore.Identity; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; + +namespace DistroFlow.Infrastructure; + +public static class DependencyInjection +{ + /// + /// Registers everything that talks to the outside world: database, Identity, token issuing. + /// + public static IServiceCollection AddInfrastructure( + this IServiceCollection services, + IConfiguration configuration) + { + services.AddPersistence(configuration); + services.AddIdentityServices(); + services.AddTokenIssuing(configuration); + + return services; + } + + private static void AddPersistence(this IServiceCollection services, IConfiguration configuration) + { + var connectionString = configuration.GetConnectionString("DefaultConnection") + ?? throw new InvalidOperationException( + "Database connection string 'DefaultConnection' is missing."); + + services.AddDbContext(options => options.UseNpgsql(connectionString)); + + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + } + + private static void AddIdentityServices(this IServiceCollection services) + { + // UserManager/RoleManager backed by EF Core. AddIdentityCore (not AddIdentity) + // skips cookie/UI defaults; the API authenticates with JWT bearer tokens instead. + services + .AddIdentityCore(options => + { + // Password policy for every path that sets a password (register, invite, change). + options.Password.RequiredLength = 8; + + // Email is the login. Uniqueness among *active* accounts is enforced by a + // partial unique index in the database, so Identity's global check stays off. + options.User.RequireUniqueEmail = false; + + options.Lockout.AllowedForNewUsers = true; + options.Lockout.MaxFailedAccessAttempts = 5; + options.Lockout.DefaultLockoutTimeSpan = TimeSpan.FromMinutes(15); + }) + .AddRoles>() + .AddEntityFrameworkStores(); + + services.AddScoped(); + } + + private static void AddTokenIssuing(this IServiceCollection services, IConfiguration configuration) + { + // The signing key is a secret: user-secrets in dev, env var Jwt__SigningKey in prod. + services.AddOptions() + .Bind(configuration.GetSection(JwtOptions.SectionName)) + .ValidateDataAnnotations() + .ValidateOnStart(); + + services.AddSingleton(TimeProvider.System); + services.AddSingleton(); + } +} diff --git a/src/DistroFlow.Infrastructure/DistroFlow.Infrastructure.csproj b/src/DistroFlow.Infrastructure/DistroFlow.Infrastructure.csproj index 19ade93..9587d82 100644 --- a/src/DistroFlow.Infrastructure/DistroFlow.Infrastructure.csproj +++ b/src/DistroFlow.Infrastructure/DistroFlow.Infrastructure.csproj @@ -6,11 +6,14 @@ + runtime; build; native; contentfiles; analyzers; buildtransitive all + + diff --git a/src/DistroFlow.Infrastructure/Identity/ApplicationUser.cs b/src/DistroFlow.Infrastructure/Identity/ApplicationUser.cs new file mode 100644 index 0000000..3db7f66 --- /dev/null +++ b/src/DistroFlow.Infrastructure/Identity/ApplicationUser.cs @@ -0,0 +1,23 @@ +using Microsoft.AspNetCore.Identity; +using DistroFlow.Domain.Entities; + +namespace DistroFlow.Infrastructure.Identity; + +public class ApplicationUser : IdentityUser +{ + public ApplicationUser() + { + Id = Guid.CreateVersion7(); + } + + public Guid TenantId { get; set; } + + public Tenant Tenant { get; set; } = null!; + + /// + /// Mirrors == Active. Kept on the login row so the database + /// can enforce "an email belongs to at most one active account" with a partial unique index + /// (an index cannot look at the Employees table). Updated in the same transaction as the employee. + /// + public bool IsActive { get; set; } = true; +} \ No newline at end of file diff --git a/src/DistroFlow.Infrastructure/Identity/IdentityService.cs b/src/DistroFlow.Infrastructure/Identity/IdentityService.cs new file mode 100644 index 0000000..47397ec --- /dev/null +++ b/src/DistroFlow.Infrastructure/Identity/IdentityService.cs @@ -0,0 +1,133 @@ +using DistroFlow.Application.Interfaces.Identity; +using DistroFlow.Infrastructure.Persistence; +using Microsoft.AspNetCore.Identity; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; +using Npgsql; + +namespace DistroFlow.Infrastructure.Identity; + +public class IdentityService : IIdentityService +{ + private const string ActiveEmailIndex = "EmailIndex"; + + private readonly UserManager _userManager; + private readonly AppDbContext _dbContext; + private readonly ILogger _logger; + + public IdentityService( + UserManager userManager, + AppDbContext dbContext, + ILogger logger) + { + _userManager = userManager; + _dbContext = dbContext; + _logger = logger; + } + + public async Task CreateUserAsync( + Guid tenantId, + string email, + string password, + string role, + CancellationToken cancellationToken = default) + { + var user = new ApplicationUser + { + TenantId = tenantId, + Email = email.Trim() + }; + + // Email is the login; UserName is internal. Using the id keeps Identity's + // global UserName uniqueness from blocking email reuse across tenants. + user.UserName = user.Id.ToString(); + + IdentityResult created; + try + { + created = await _userManager.CreateAsync(user, password); + } + catch (DbUpdateException ex) when (IsActiveEmailViolation(ex)) + { + // The database is the source of truth for "one active account per email", + // so this also covers two registrations racing with the same email. + // Email is not logged (personal data). + _logger.LogWarning( + "Login creation rejected for tenant {TenantId}: email already active on another account", + tenantId); + + // The failed insert is still tracked; detach it so a later SaveChanges + // on this request's DbContext does not retry it. + _dbContext.Entry(user).State = EntityState.Detached; + + return CreateUserResult.Failure( + CreateUserError.EmailInUse, + ["This email can't be used right now."]); + } + + if (!created.Succeeded) + { + return CreateUserResult.Failure( + CreateUserError.ValidationFailed, + created.Errors.Select(e => e.Description).ToList()); + } + + var assigned = await _userManager.AddToRoleAsync(user, role); + if (!assigned.Succeeded) + { + // Roles are seeded by migration; a missing role is a bug, not user error. + throw new InvalidOperationException( + $"Could not assign role '{role}': {string.Join("; ", assigned.Errors.Select(e => e.Description))}"); + } + + return CreateUserResult.Success(user.Id); + } + + public async Task CheckCredentialsAsync( + string email, + string password, + CancellationToken cancellationToken = default) + { + var normalizedEmail = _userManager.NormalizeEmail(email.Trim()); + + // The partial unique index guarantees at most one active account per email, + // so login needs no "which distributor?" input. + var user = await _userManager.Users.SingleOrDefaultAsync( + u => u.NormalizedEmail == normalizedEmail && u.IsActive, + cancellationToken); + + if (user is null) + { + // Hash anyway so "unknown email" takes about as long as "wrong password"; + // otherwise response time reveals which emails have accounts. + _userManager.PasswordHasher.HashPassword(new ApplicationUser(), password); + return CredentialCheckResult.Failure(CredentialCheckError.InvalidCredentials); + } + + // Checked before the password so a locked account cannot keep being brute-forced. + if (await _userManager.IsLockedOutAsync(user)) + { + _logger.LogWarning("Login rejected for locked-out user {UserId}", user.Id); + return CredentialCheckResult.Failure(CredentialCheckError.LockedOut); + } + + if (!await _userManager.CheckPasswordAsync(user, password)) + { + // Increments the failed count and locks the account once it reaches the limit. + await _userManager.AccessFailedAsync(user); + return CredentialCheckResult.Failure(CredentialCheckError.InvalidCredentials); + } + + await _userManager.ResetAccessFailedCountAsync(user); + + var roles = await _userManager.GetRolesAsync(user); + return CredentialCheckResult.Success(user.Id, user.TenantId, roles.ToList()); + } + + private static bool IsActiveEmailViolation(DbUpdateException ex) => + ex.InnerException is PostgresException + { + SqlState: PostgresErrorCodes.UniqueViolation, + ConstraintName: ActiveEmailIndex + }; +} diff --git a/src/DistroFlow.Infrastructure/Identity/JwtOptions.cs b/src/DistroFlow.Infrastructure/Identity/JwtOptions.cs new file mode 100644 index 0000000..9dfba30 --- /dev/null +++ b/src/DistroFlow.Infrastructure/Identity/JwtOptions.cs @@ -0,0 +1,28 @@ +using System.ComponentModel.DataAnnotations; + +namespace DistroFlow.Infrastructure.Identity; + +/// +/// Bound from the "Jwt" configuration section. Issuer/audience/lifetime live in appsettings.json; +/// the signing key is a secret and comes from user-secrets (dev) or environment variables (prod). +/// Validated at startup so a missing or weak key fails fast instead of on the first login. +/// +public sealed class JwtOptions +{ + public const string SectionName = "Jwt"; + + [Required] + public string Issuer { get; init; } = string.Empty; + + [Required] + public string Audience { get; init; } = string.Empty; + + /// HMAC-SHA256 key; at least 32 characters (256 bits). + [Required] + [MinLength(32)] + public string SigningKey { get; init; } = string.Empty; + + /// Kept short: a JWT stays valid until it expires, even if the employee is deactivated. + [Range(1, 1440)] + public int AccessTokenMinutes { get; init; } = 30; +} diff --git a/src/DistroFlow.Infrastructure/Identity/JwtTokenService.cs b/src/DistroFlow.Infrastructure/Identity/JwtTokenService.cs new file mode 100644 index 0000000..9ccc97f --- /dev/null +++ b/src/DistroFlow.Infrastructure/Identity/JwtTokenService.cs @@ -0,0 +1,55 @@ +using System.Security.Claims; +using System.Text; +using DistroFlow.Application.Common; +using DistroFlow.Application.Interfaces.Identity; +using Microsoft.Extensions.Options; +using Microsoft.IdentityModel.JsonWebTokens; +using Microsoft.IdentityModel.Tokens; + +namespace DistroFlow.Infrastructure.Identity; + +public class JwtTokenService : ITokenService +{ + private readonly JwtOptions _options; + private readonly TimeProvider _timeProvider; + private readonly SigningCredentials _signingCredentials; + private readonly JsonWebTokenHandler _handler = new(); + + public JwtTokenService(IOptions options, TimeProvider timeProvider) + { + _options = options.Value; + _timeProvider = timeProvider; + _signingCredentials = new SigningCredentials( + new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_options.SigningKey)), + SecurityAlgorithms.HmacSha256); + } + + public AccessToken CreateAccessToken(TokenSubject subject) + { + var now = _timeProvider.GetUtcNow().UtcDateTime; + var expiresAt = now.AddMinutes(_options.AccessTokenMinutes); + + var claims = new List + { + new(AppClaimTypes.UserId, subject.UserId.ToString()), + new(AppClaimTypes.EmployeeId, subject.EmployeeId.ToString()), + new(AppClaimTypes.TenantId, subject.TenantId.ToString()), + // Unique token id: lets a specific token be identified/revoked later if needed. + new(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()) + }; + claims.AddRange(subject.Roles.Select(role => new Claim(AppClaimTypes.Role, role))); + + var descriptor = new SecurityTokenDescriptor + { + Issuer = _options.Issuer, + Audience = _options.Audience, + Subject = new ClaimsIdentity(claims), + IssuedAt = now, + NotBefore = now, + Expires = expiresAt, + SigningCredentials = _signingCredentials + }; + + return new AccessToken(_handler.CreateToken(descriptor), expiresAt); + } +} diff --git a/src/DistroFlow.Infrastructure/Persistence/AppDbContext.cs b/src/DistroFlow.Infrastructure/Persistence/AppDbContext.cs index 09a864f..5ab33d8 100644 --- a/src/DistroFlow.Infrastructure/Persistence/AppDbContext.cs +++ b/src/DistroFlow.Infrastructure/Persistence/AppDbContext.cs @@ -1,23 +1,44 @@ +using DistroFlow.Application.Interfaces.Identity; using DistroFlow.Domain.Entities; +using DistroFlow.Infrastructure.Identity; +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Identity.EntityFrameworkCore; using Microsoft.EntityFrameworkCore; namespace DistroFlow.Infrastructure.Persistence; -public class AppDbContext : DbContext +public class AppDbContext : IdentityDbContext, Guid> { public DbSet Tenants => Set(); - public AppDbContext(DbContextOptions options) + public DbSet Employees => Set(); + + private readonly ICurrentTenant _currentTenant; + + public AppDbContext(DbContextOptions options, ICurrentTenant currentTenant) : base(options) { + _currentTenant = currentTenant; } + /// + /// Read by the tenant query filters on every query. Must be a member of the context + /// (not a captured local) so EF evaluates it per query instead of baking in one value. + /// Null for anonymous requests, which makes filtered queries return nothing. + /// + private Guid? CurrentTenantId => _currentTenant.TenantId; + protected override void OnModelCreating(ModelBuilder builder) { base.OnModelCreating(builder); builder.ApplyConfigurationsFromAssembly( typeof(AppDbContext).Assembly); + + // Tenant isolation: EF adds this WHERE to every Employee query automatically. + // ApplicationUser is deliberately not filtered (see CLAUDE.md, Tenancy). + builder.Entity() + .HasQueryFilter(e => e.TenantId == CurrentTenantId); } } diff --git a/src/DistroFlow.Infrastructure/Persistence/Configurations/ApplicationUserConfiguration.cs b/src/DistroFlow.Infrastructure/Persistence/Configurations/ApplicationUserConfiguration.cs new file mode 100644 index 0000000..41dbfb7 --- /dev/null +++ b/src/DistroFlow.Infrastructure/Persistence/Configurations/ApplicationUserConfiguration.cs @@ -0,0 +1,27 @@ +using DistroFlow.Infrastructure.Identity; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace DistroFlow.Infrastructure.Persistence.Configurations; + +public class ApplicationUserConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.HasOne(u => u.Tenant) + .WithMany() + .HasForeignKey(u => u.TenantId) + .OnDelete(DeleteBehavior.Restrict); + + builder.HasIndex(u => u.TenantId); + + // An email may be reused after the person leaves a distributor (account deactivated), + // but at any moment it belongs to at most one active account. Enforced by the database + // so two distributors adding the same email at the same time cannot both succeed. + // Overrides Identity's default non-unique "EmailIndex". + builder.HasIndex(u => u.NormalizedEmail) + .HasDatabaseName("EmailIndex") + .IsUnique() + .HasFilter("\"IsActive\""); + } +} diff --git a/src/DistroFlow.Infrastructure/Persistence/Configurations/EmployeeConfiguration.cs b/src/DistroFlow.Infrastructure/Persistence/Configurations/EmployeeConfiguration.cs new file mode 100644 index 0000000..23a7af4 --- /dev/null +++ b/src/DistroFlow.Infrastructure/Persistence/Configurations/EmployeeConfiguration.cs @@ -0,0 +1,80 @@ +using DistroFlow.Domain.Entities; +using DistroFlow.Infrastructure.Identity; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace DistroFlow.Infrastructure.Persistence.Configurations; + +public class EmployeeConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.HasKey(e => e.Id); + + // Lets future tables use composite (TenantId, EmployeeId) foreign keys, + // so a row can never reference another tenant's employee. + builder.HasAlternateKey(e => new { e.TenantId, e.Id }); + + builder.Property(e => e.FullName) + .HasMaxLength(150) + .IsRequired(); + + builder.Property(e => e.EmployeeCode) + .HasMaxLength(20); + + builder.Property(e => e.Phone) + .HasMaxLength(20); + + builder.Property(e => e.Status) + .HasConversion() + .HasMaxLength(20) + .IsRequired(); + + builder.Property(e => e.CreatedAtUtc) + .IsRequired(); + + builder.Ignore(e => e.IsActive); + + builder.HasOne(e => e.Tenant) + .WithMany() + .HasForeignKey(e => e.TenantId) + .OnDelete(DeleteBehavior.Restrict); + + // Deleting the login account (erasure) keeps the employee row for history. + builder.HasOne() + .WithOne() + .HasForeignKey(e => e.UserId) + .OnDelete(DeleteBehavior.SetNull); + + builder.HasIndex(e => e.UserId) + .IsUnique() + .HasFilter("\"UserId\" IS NOT NULL"); + + builder.HasIndex(e => new { e.TenantId, e.EmployeeCode }) + .IsUnique() + .HasFilter("\"EmployeeCode\" IS NOT NULL"); + + builder.HasIndex(e => new { e.TenantId, e.Status }); + + builder.ToTable(table => + { + table.HasCheckConstraint( + "CK_Employees_Status", + "\"Status\" IN ('Active', 'Deactivated', 'Anonymised')"); + + table.HasCheckConstraint( + "CK_Employees_ActiveHasLogin", + "\"Status\" <> 'Active' OR (\"UserId\" IS NOT NULL AND \"DeactivatedAtUtc\" IS NULL)"); + + table.HasCheckConstraint( + "CK_Employees_DeactivatedAt", + "\"Status\" = 'Active' OR \"DeactivatedAtUtc\" IS NOT NULL"); + + // Guarantees anonymised rows hold no personal data. + table.HasCheckConstraint( + "CK_Employees_AnonymisedHasNoPersonalData", + "\"Status\" <> 'Anonymised' OR (\"AnonymisedAtUtc\" IS NOT NULL AND \"UserId\" IS NULL " + + "AND \"Phone\" IS NULL AND \"EmployeeCode\" IS NULL AND \"FullName\" = 'Former employee')"); + }); + } +} diff --git a/src/DistroFlow.Infrastructure/Persistence/Configurations/RoleConfiguration.cs b/src/DistroFlow.Infrastructure/Persistence/Configurations/RoleConfiguration.cs new file mode 100644 index 0000000..8790ff3 --- /dev/null +++ b/src/DistroFlow.Infrastructure/Persistence/Configurations/RoleConfiguration.cs @@ -0,0 +1,32 @@ +using DistroFlow.Domain.Constants; +using Microsoft.AspNetCore.Identity; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace DistroFlow.Infrastructure.Persistence.Configurations; + +/// +/// Seeds the fixed employee roles as reference data in a migration. +/// Ids and concurrency stamps are constants so the migration is deterministic +/// (EF would otherwise generate a new migration every time). +/// +public class RoleConfiguration : IEntityTypeConfiguration> +{ + public void Configure(EntityTypeBuilder> builder) + { + builder.HasData( + Role("c2281575-f1a9-41f8-810d-f53174c2b4d1", EmployeeRoles.Administrator), + Role("b4f5e138-6741-45bb-a395-c971dc8e7962", EmployeeRoles.SalesStaff), + Role("a3a51997-11f6-480c-8c9d-28210ab7e385", EmployeeRoles.DispatchStaff), + Role("42e5faac-8ca2-41e8-98bc-f842f205e9aa", EmployeeRoles.Approver), + Role("295f85df-e212-4cbc-8ee1-48b3c6ec2fee", EmployeeRoles.AccountsStaff)); + } + + private static IdentityRole Role(string id, string name) => new() + { + Id = Guid.Parse(id), + Name = name, + NormalizedName = name.ToUpperInvariant(), + ConcurrencyStamp = id + }; +} diff --git a/src/DistroFlow.Infrastructure/Persistence/Migrations/20261106152937_AddIdentityAndEmployees.Designer.cs b/src/DistroFlow.Infrastructure/Persistence/Migrations/20261106152937_AddIdentityAndEmployees.Designer.cs new file mode 100644 index 0000000..ce4f0c0 --- /dev/null +++ b/src/DistroFlow.Infrastructure/Persistence/Migrations/20261106152937_AddIdentityAndEmployees.Designer.cs @@ -0,0 +1,435 @@ +// +using System; +using DistroFlow.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace DistroFlow.Infrastructure.Persistence.Migrations +{ + [DbContext(typeof(AppDbContext))] + [Migration("20261106152937_AddIdentityAndEmployees")] + partial class AddIdentityAndEmployees + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.14") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("DistroFlow.Domain.Entities.Employee", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AnonymisedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("DeactivatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("EmployeeCode") + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("FullName") + .IsRequired() + .HasMaxLength(150) + .HasColumnType("character varying(150)"); + + b.Property("Phone") + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasAlternateKey("TenantId", "Id"); + + b.HasIndex("UserId") + .IsUnique() + .HasFilter("\"UserId\" IS NOT NULL"); + + b.HasIndex("TenantId", "EmployeeCode") + .IsUnique() + .HasFilter("\"EmployeeCode\" IS NOT NULL"); + + b.HasIndex("TenantId", "Status"); + + b.ToTable("Employees", t => + { + t.HasCheckConstraint("CK_Employees_ActiveHasLogin", "\"Status\" <> 'Active' OR (\"UserId\" IS NOT NULL AND \"DeactivatedAtUtc\" IS NULL)"); + + t.HasCheckConstraint("CK_Employees_AnonymisedHasNoPersonalData", "\"Status\" <> 'Anonymised' OR (\"AnonymisedAtUtc\" IS NOT NULL AND \"UserId\" IS NULL AND \"Phone\" IS NULL AND \"EmployeeCode\" IS NULL AND \"FullName\" = 'Former employee')"); + + t.HasCheckConstraint("CK_Employees_DeactivatedAt", "\"Status\" = 'Active' OR \"DeactivatedAtUtc\" IS NOT NULL"); + + t.HasCheckConstraint("CK_Employees_Status", "\"Status\" IN ('Active', 'Deactivated', 'Anonymised')"); + }); + }); + + modelBuilder.Entity("DistroFlow.Domain.Entities.Tenant", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CurrencyCode") + .IsRequired() + .HasMaxLength(3) + .HasColumnType("character varying(3)"); + + b.Property("Email") + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("FiscalYearStartMonth") + .HasColumnType("integer"); + + b.Property("InvoicePrefix") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("character varying(10)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Phone") + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("TimeZoneId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.ToTable("Tenants", t => + { + t.HasCheckConstraint("CK_Tenants_FiscalYearStartMonth", "\"FiscalYearStartMonth\" BETWEEN 1 AND 12"); + }); + }); + + modelBuilder.Entity("DistroFlow.Infrastructure.Identity.ApplicationUser", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AccessFailedCount") + .HasColumnType("integer"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("EmailConfirmed") + .HasColumnType("boolean"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("LockoutEnabled") + .HasColumnType("boolean"); + + b.Property("LockoutEnd") + .HasColumnType("timestamp with time zone"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("PasswordHash") + .HasColumnType("text"); + + b.Property("PhoneNumber") + .HasColumnType("text"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("boolean"); + + b.Property("SecurityStamp") + .HasColumnType("text"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("TwoFactorEnabled") + .HasColumnType("boolean"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .IsUnique() + .HasDatabaseName("EmailIndex") + .HasFilter("\"IsActive\""); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex"); + + b.HasIndex("TenantId"); + + b.ToTable("AspNetUsers", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b => + { + 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", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text"); + + b.Property("ClaimValue") + .HasColumnType("text"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text"); + + b.Property("ClaimValue") + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("text"); + + b.Property("ProviderKey") + .HasColumnType("text"); + + b.Property("ProviderDisplayName") + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("uuid"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("uuid"); + + b.Property("LoginProvider") + .HasColumnType("text"); + + b.Property("Name") + .HasColumnType("text"); + + b.Property("Value") + .HasColumnType("text"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("DistroFlow.Domain.Entities.Employee", b => + { + b.HasOne("DistroFlow.Domain.Entities.Tenant", "Tenant") + .WithMany() + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("DistroFlow.Infrastructure.Identity.ApplicationUser", null) + .WithOne() + .HasForeignKey("DistroFlow.Domain.Entities.Employee", "UserId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("DistroFlow.Infrastructure.Identity.ApplicationUser", b => + { + b.HasOne("DistroFlow.Domain.Entities.Tenant", "Tenant") + .WithMany() + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("DistroFlow.Infrastructure.Identity.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("DistroFlow.Infrastructure.Identity.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("DistroFlow.Infrastructure.Identity.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("DistroFlow.Infrastructure.Identity.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/DistroFlow.Infrastructure/Persistence/Migrations/20261106152937_AddIdentityAndEmployees.cs b/src/DistroFlow.Infrastructure/Persistence/Migrations/20261106152937_AddIdentityAndEmployees.cs new file mode 100644 index 0000000..b8239a2 --- /dev/null +++ b/src/DistroFlow.Infrastructure/Persistence/Migrations/20261106152937_AddIdentityAndEmployees.cs @@ -0,0 +1,298 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace DistroFlow.Infrastructure.Persistence.Migrations +{ + /// + public partial class AddIdentityAndEmployees : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "AspNetRoles", + columns: table => new + { + 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) + }, + constraints: table => + { + table.PrimaryKey("PK_AspNetRoles", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "AspNetUsers", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + TenantId = table.Column(type: "uuid", nullable: false), + IsActive = table.Column(type: "boolean", nullable: false), + UserName = table.Column(type: "character varying(256)", maxLength: 256, nullable: true), + NormalizedUserName = table.Column(type: "character varying(256)", maxLength: 256, nullable: true), + Email = table.Column(type: "character varying(256)", maxLength: 256, nullable: true), + NormalizedEmail = table.Column(type: "character varying(256)", maxLength: 256, nullable: true), + EmailConfirmed = table.Column(type: "boolean", nullable: false), + PasswordHash = table.Column(type: "text", nullable: true), + SecurityStamp = table.Column(type: "text", nullable: true), + ConcurrencyStamp = table.Column(type: "text", nullable: true), + PhoneNumber = table.Column(type: "text", nullable: true), + PhoneNumberConfirmed = table.Column(type: "boolean", nullable: false), + TwoFactorEnabled = table.Column(type: "boolean", nullable: false), + LockoutEnd = table.Column(type: "timestamp with time zone", nullable: true), + LockoutEnabled = table.Column(type: "boolean", nullable: false), + AccessFailedCount = table.Column(type: "integer", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_AspNetUsers", x => x.Id); + table.ForeignKey( + name: "FK_AspNetUsers_Tenants_TenantId", + column: x => x.TenantId, + principalTable: "Tenants", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + }); + + migrationBuilder.CreateTable( + name: "AspNetRoleClaims", + columns: table => new + { + Id = table.Column(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + RoleId = table.Column(type: "uuid", nullable: false), + ClaimType = table.Column(type: "text", nullable: true), + ClaimValue = table.Column(type: "text", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_AspNetRoleClaims", x => x.Id); + table.ForeignKey( + name: "FK_AspNetRoleClaims_AspNetRoles_RoleId", + column: x => x.RoleId, + principalTable: "AspNetRoles", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "AspNetUserClaims", + columns: table => new + { + Id = table.Column(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + UserId = table.Column(type: "uuid", nullable: false), + ClaimType = table.Column(type: "text", nullable: true), + ClaimValue = table.Column(type: "text", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_AspNetUserClaims", x => x.Id); + table.ForeignKey( + name: "FK_AspNetUserClaims_AspNetUsers_UserId", + column: x => x.UserId, + principalTable: "AspNetUsers", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "AspNetUserLogins", + columns: table => new + { + 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: "uuid", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_AspNetUserLogins", x => new { x.LoginProvider, x.ProviderKey }); + table.ForeignKey( + name: "FK_AspNetUserLogins_AspNetUsers_UserId", + column: x => x.UserId, + principalTable: "AspNetUsers", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "AspNetUserRoles", + columns: table => new + { + UserId = table.Column(type: "uuid", nullable: false), + RoleId = table.Column(type: "uuid", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_AspNetUserRoles", x => new { x.UserId, x.RoleId }); + table.ForeignKey( + name: "FK_AspNetUserRoles_AspNetRoles_RoleId", + column: x => x.RoleId, + principalTable: "AspNetRoles", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_AspNetUserRoles_AspNetUsers_UserId", + column: x => x.UserId, + principalTable: "AspNetUsers", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "AspNetUserTokens", + columns: table => new + { + 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) + }, + constraints: table => + { + table.PrimaryKey("PK_AspNetUserTokens", x => new { x.UserId, x.LoginProvider, x.Name }); + table.ForeignKey( + name: "FK_AspNetUserTokens_AspNetUsers_UserId", + column: x => x.UserId, + principalTable: "AspNetUsers", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "Employees", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + TenantId = table.Column(type: "uuid", nullable: false), + UserId = table.Column(type: "uuid", nullable: true), + EmployeeCode = table.Column(type: "character varying(20)", maxLength: 20, nullable: true), + FullName = table.Column(type: "character varying(150)", maxLength: 150, nullable: false), + Phone = table.Column(type: "character varying(20)", maxLength: 20, nullable: true), + Status = table.Column(type: "character varying(20)", maxLength: 20, nullable: false), + DeactivatedAtUtc = table.Column(type: "timestamp with time zone", nullable: true), + AnonymisedAtUtc = table.Column(type: "timestamp with time zone", nullable: true), + CreatedAtUtc = table.Column(type: "timestamp with time zone", nullable: false), + UpdatedAtUtc = table.Column(type: "timestamp with time zone", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_Employees", x => x.Id); + table.UniqueConstraint("AK_Employees_TenantId_Id", x => new { x.TenantId, x.Id }); + table.CheckConstraint("CK_Employees_ActiveHasLogin", "\"Status\" <> 'Active' OR (\"UserId\" IS NOT NULL AND \"DeactivatedAtUtc\" IS NULL)"); + table.CheckConstraint("CK_Employees_AnonymisedHasNoPersonalData", "\"Status\" <> 'Anonymised' OR (\"AnonymisedAtUtc\" IS NOT NULL AND \"UserId\" IS NULL AND \"Phone\" IS NULL AND \"EmployeeCode\" IS NULL AND \"FullName\" = 'Former employee')"); + table.CheckConstraint("CK_Employees_DeactivatedAt", "\"Status\" = 'Active' OR \"DeactivatedAtUtc\" IS NOT NULL"); + table.CheckConstraint("CK_Employees_Status", "\"Status\" IN ('Active', 'Deactivated', 'Anonymised')"); + table.ForeignKey( + name: "FK_Employees_AspNetUsers_UserId", + column: x => x.UserId, + principalTable: "AspNetUsers", + principalColumn: "Id", + onDelete: ReferentialAction.SetNull); + table.ForeignKey( + name: "FK_Employees_Tenants_TenantId", + column: x => x.TenantId, + principalTable: "Tenants", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + }); + + migrationBuilder.CreateIndex( + name: "IX_AspNetRoleClaims_RoleId", + table: "AspNetRoleClaims", + column: "RoleId"); + + migrationBuilder.CreateIndex( + name: "RoleNameIndex", + table: "AspNetRoles", + column: "NormalizedName", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_AspNetUserClaims_UserId", + table: "AspNetUserClaims", + column: "UserId"); + + migrationBuilder.CreateIndex( + name: "IX_AspNetUserLogins_UserId", + table: "AspNetUserLogins", + column: "UserId"); + + migrationBuilder.CreateIndex( + name: "IX_AspNetUserRoles_RoleId", + table: "AspNetUserRoles", + column: "RoleId"); + + migrationBuilder.CreateIndex( + name: "EmailIndex", + table: "AspNetUsers", + column: "NormalizedEmail", + unique: true, + filter: "\"IsActive\""); + + migrationBuilder.CreateIndex( + name: "IX_AspNetUsers_TenantId", + table: "AspNetUsers", + column: "TenantId"); + + migrationBuilder.CreateIndex( + name: "UserNameIndex", + table: "AspNetUsers", + column: "NormalizedUserName", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_Employees_TenantId_EmployeeCode", + table: "Employees", + columns: new[] { "TenantId", "EmployeeCode" }, + unique: true, + filter: "\"EmployeeCode\" IS NOT NULL"); + + migrationBuilder.CreateIndex( + name: "IX_Employees_TenantId_Status", + table: "Employees", + columns: new[] { "TenantId", "Status" }); + + migrationBuilder.CreateIndex( + name: "IX_Employees_UserId", + table: "Employees", + column: "UserId", + unique: true, + filter: "\"UserId\" IS NOT NULL"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "AspNetRoleClaims"); + + migrationBuilder.DropTable( + name: "AspNetUserClaims"); + + migrationBuilder.DropTable( + name: "AspNetUserLogins"); + + migrationBuilder.DropTable( + name: "AspNetUserRoles"); + + migrationBuilder.DropTable( + name: "AspNetUserTokens"); + + migrationBuilder.DropTable( + name: "Employees"); + + migrationBuilder.DropTable( + name: "AspNetRoles"); + + migrationBuilder.DropTable( + name: "AspNetUsers"); + } + } +} diff --git a/src/DistroFlow.Infrastructure/Persistence/Migrations/20261106154310_SeedEmployeeRoles.Designer.cs b/src/DistroFlow.Infrastructure/Persistence/Migrations/20261106154310_SeedEmployeeRoles.Designer.cs new file mode 100644 index 0000000..3f7b76d --- /dev/null +++ b/src/DistroFlow.Infrastructure/Persistence/Migrations/20261106154310_SeedEmployeeRoles.Designer.cs @@ -0,0 +1,472 @@ +// +using System; +using DistroFlow.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace DistroFlow.Infrastructure.Persistence.Migrations +{ + [DbContext(typeof(AppDbContext))] + [Migration("20261106154310_SeedEmployeeRoles")] + partial class SeedEmployeeRoles + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.14") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("DistroFlow.Domain.Entities.Employee", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AnonymisedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("DeactivatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("EmployeeCode") + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("FullName") + .IsRequired() + .HasMaxLength(150) + .HasColumnType("character varying(150)"); + + b.Property("Phone") + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasAlternateKey("TenantId", "Id"); + + b.HasIndex("UserId") + .IsUnique() + .HasFilter("\"UserId\" IS NOT NULL"); + + b.HasIndex("TenantId", "EmployeeCode") + .IsUnique() + .HasFilter("\"EmployeeCode\" IS NOT NULL"); + + b.HasIndex("TenantId", "Status"); + + b.ToTable("Employees", t => + { + t.HasCheckConstraint("CK_Employees_ActiveHasLogin", "\"Status\" <> 'Active' OR (\"UserId\" IS NOT NULL AND \"DeactivatedAtUtc\" IS NULL)"); + + t.HasCheckConstraint("CK_Employees_AnonymisedHasNoPersonalData", "\"Status\" <> 'Anonymised' OR (\"AnonymisedAtUtc\" IS NOT NULL AND \"UserId\" IS NULL AND \"Phone\" IS NULL AND \"EmployeeCode\" IS NULL AND \"FullName\" = 'Former employee')"); + + t.HasCheckConstraint("CK_Employees_DeactivatedAt", "\"Status\" = 'Active' OR \"DeactivatedAtUtc\" IS NOT NULL"); + + t.HasCheckConstraint("CK_Employees_Status", "\"Status\" IN ('Active', 'Deactivated', 'Anonymised')"); + }); + }); + + modelBuilder.Entity("DistroFlow.Domain.Entities.Tenant", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CurrencyCode") + .IsRequired() + .HasMaxLength(3) + .HasColumnType("character varying(3)"); + + b.Property("Email") + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("FiscalYearStartMonth") + .HasColumnType("integer"); + + b.Property("InvoicePrefix") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("character varying(10)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Phone") + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("TimeZoneId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.ToTable("Tenants", t => + { + t.HasCheckConstraint("CK_Tenants_FiscalYearStartMonth", "\"FiscalYearStartMonth\" BETWEEN 1 AND 12"); + }); + }); + + modelBuilder.Entity("DistroFlow.Infrastructure.Identity.ApplicationUser", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AccessFailedCount") + .HasColumnType("integer"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("EmailConfirmed") + .HasColumnType("boolean"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("LockoutEnabled") + .HasColumnType("boolean"); + + b.Property("LockoutEnd") + .HasColumnType("timestamp with time zone"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("PasswordHash") + .HasColumnType("text"); + + b.Property("PhoneNumber") + .HasColumnType("text"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("boolean"); + + b.Property("SecurityStamp") + .HasColumnType("text"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("TwoFactorEnabled") + .HasColumnType("boolean"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .IsUnique() + .HasDatabaseName("EmailIndex") + .HasFilter("\"IsActive\""); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex"); + + b.HasIndex("TenantId"); + + b.ToTable("AspNetUsers", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b => + { + 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", (string)null); + + b.HasData( + new + { + Id = new Guid("c2281575-f1a9-41f8-810d-f53174c2b4d1"), + ConcurrencyStamp = "c2281575-f1a9-41f8-810d-f53174c2b4d1", + Name = "Administrator", + NormalizedName = "ADMINISTRATOR" + }, + new + { + Id = new Guid("b4f5e138-6741-45bb-a395-c971dc8e7962"), + ConcurrencyStamp = "b4f5e138-6741-45bb-a395-c971dc8e7962", + Name = "Sales Staff", + NormalizedName = "SALES STAFF" + }, + new + { + Id = new Guid("a3a51997-11f6-480c-8c9d-28210ab7e385"), + ConcurrencyStamp = "a3a51997-11f6-480c-8c9d-28210ab7e385", + Name = "Dispatch Staff", + NormalizedName = "DISPATCH STAFF" + }, + new + { + Id = new Guid("42e5faac-8ca2-41e8-98bc-f842f205e9aa"), + ConcurrencyStamp = "42e5faac-8ca2-41e8-98bc-f842f205e9aa", + Name = "Approver", + NormalizedName = "APPROVER" + }, + new + { + Id = new Guid("295f85df-e212-4cbc-8ee1-48b3c6ec2fee"), + ConcurrencyStamp = "295f85df-e212-4cbc-8ee1-48b3c6ec2fee", + Name = "Accounts Staff", + NormalizedName = "ACCOUNTS STAFF" + }); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text"); + + b.Property("ClaimValue") + .HasColumnType("text"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text"); + + b.Property("ClaimValue") + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("text"); + + b.Property("ProviderKey") + .HasColumnType("text"); + + b.Property("ProviderDisplayName") + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("uuid"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("uuid"); + + b.Property("LoginProvider") + .HasColumnType("text"); + + b.Property("Name") + .HasColumnType("text"); + + b.Property("Value") + .HasColumnType("text"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("DistroFlow.Domain.Entities.Employee", b => + { + b.HasOne("DistroFlow.Domain.Entities.Tenant", "Tenant") + .WithMany() + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("DistroFlow.Infrastructure.Identity.ApplicationUser", null) + .WithOne() + .HasForeignKey("DistroFlow.Domain.Entities.Employee", "UserId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("DistroFlow.Infrastructure.Identity.ApplicationUser", b => + { + b.HasOne("DistroFlow.Domain.Entities.Tenant", "Tenant") + .WithMany() + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("DistroFlow.Infrastructure.Identity.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("DistroFlow.Infrastructure.Identity.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("DistroFlow.Infrastructure.Identity.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("DistroFlow.Infrastructure.Identity.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/DistroFlow.Infrastructure/Persistence/Migrations/20261106154310_SeedEmployeeRoles.cs b/src/DistroFlow.Infrastructure/Persistence/Migrations/20261106154310_SeedEmployeeRoles.cs new file mode 100644 index 0000000..366bffd --- /dev/null +++ b/src/DistroFlow.Infrastructure/Persistence/Migrations/20261106154310_SeedEmployeeRoles.cs @@ -0,0 +1,58 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +#pragma warning disable CA1814 // Prefer jagged arrays over multidimensional + +namespace DistroFlow.Infrastructure.Persistence.Migrations +{ + /// + public partial class SeedEmployeeRoles : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.InsertData( + table: "AspNetRoles", + columns: new[] { "Id", "ConcurrencyStamp", "Name", "NormalizedName" }, + values: new object[,] + { + { new Guid("295f85df-e212-4cbc-8ee1-48b3c6ec2fee"), "295f85df-e212-4cbc-8ee1-48b3c6ec2fee", "Accounts Staff", "ACCOUNTS STAFF" }, + { new Guid("42e5faac-8ca2-41e8-98bc-f842f205e9aa"), "42e5faac-8ca2-41e8-98bc-f842f205e9aa", "Approver", "APPROVER" }, + { new Guid("a3a51997-11f6-480c-8c9d-28210ab7e385"), "a3a51997-11f6-480c-8c9d-28210ab7e385", "Dispatch Staff", "DISPATCH STAFF" }, + { new Guid("b4f5e138-6741-45bb-a395-c971dc8e7962"), "b4f5e138-6741-45bb-a395-c971dc8e7962", "Sales Staff", "SALES STAFF" }, + { new Guid("c2281575-f1a9-41f8-810d-f53174c2b4d1"), "c2281575-f1a9-41f8-810d-f53174c2b4d1", "Administrator", "ADMINISTRATOR" } + }); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DeleteData( + table: "AspNetRoles", + keyColumn: "Id", + keyValue: new Guid("295f85df-e212-4cbc-8ee1-48b3c6ec2fee")); + + migrationBuilder.DeleteData( + table: "AspNetRoles", + keyColumn: "Id", + keyValue: new Guid("42e5faac-8ca2-41e8-98bc-f842f205e9aa")); + + migrationBuilder.DeleteData( + table: "AspNetRoles", + keyColumn: "Id", + keyValue: new Guid("a3a51997-11f6-480c-8c9d-28210ab7e385")); + + migrationBuilder.DeleteData( + table: "AspNetRoles", + keyColumn: "Id", + keyValue: new Guid("b4f5e138-6741-45bb-a395-c971dc8e7962")); + + migrationBuilder.DeleteData( + table: "AspNetRoles", + keyColumn: "Id", + keyValue: new Guid("c2281575-f1a9-41f8-810d-f53174c2b4d1")); + } + } +} diff --git a/src/DistroFlow.Infrastructure/Persistence/Migrations/AppDbContextModelSnapshot.cs b/src/DistroFlow.Infrastructure/Persistence/Migrations/AppDbContextModelSnapshot.cs index 91cc178..05cfe1a 100644 --- a/src/DistroFlow.Infrastructure/Persistence/Migrations/AppDbContextModelSnapshot.cs +++ b/src/DistroFlow.Infrastructure/Persistence/Migrations/AppDbContextModelSnapshot.cs @@ -22,6 +22,74 @@ protected override void BuildModel(ModelBuilder modelBuilder) NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + modelBuilder.Entity("DistroFlow.Domain.Entities.Employee", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AnonymisedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("DeactivatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("EmployeeCode") + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("FullName") + .IsRequired() + .HasMaxLength(150) + .HasColumnType("character varying(150)"); + + b.Property("Phone") + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasAlternateKey("TenantId", "Id"); + + b.HasIndex("UserId") + .IsUnique() + .HasFilter("\"UserId\" IS NOT NULL"); + + b.HasIndex("TenantId", "EmployeeCode") + .IsUnique() + .HasFilter("\"EmployeeCode\" IS NOT NULL"); + + b.HasIndex("TenantId", "Status"); + + b.ToTable("Employees", t => + { + t.HasCheckConstraint("CK_Employees_ActiveHasLogin", "\"Status\" <> 'Active' OR (\"UserId\" IS NOT NULL AND \"DeactivatedAtUtc\" IS NULL)"); + + t.HasCheckConstraint("CK_Employees_AnonymisedHasNoPersonalData", "\"Status\" <> 'Anonymised' OR (\"AnonymisedAtUtc\" IS NOT NULL AND \"UserId\" IS NULL AND \"Phone\" IS NULL AND \"EmployeeCode\" IS NULL AND \"FullName\" = 'Former employee')"); + + t.HasCheckConstraint("CK_Employees_DeactivatedAt", "\"Status\" = 'Active' OR \"DeactivatedAtUtc\" IS NOT NULL"); + + t.HasCheckConstraint("CK_Employees_Status", "\"Status\" IN ('Active', 'Deactivated', 'Anonymised')"); + }); + }); + modelBuilder.Entity("DistroFlow.Domain.Entities.Tenant", b => { b.Property("Id") @@ -75,6 +143,326 @@ protected override void BuildModel(ModelBuilder modelBuilder) t.HasCheckConstraint("CK_Tenants_FiscalYearStartMonth", "\"FiscalYearStartMonth\" BETWEEN 1 AND 12"); }); }); + + modelBuilder.Entity("DistroFlow.Infrastructure.Identity.ApplicationUser", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AccessFailedCount") + .HasColumnType("integer"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("EmailConfirmed") + .HasColumnType("boolean"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("LockoutEnabled") + .HasColumnType("boolean"); + + b.Property("LockoutEnd") + .HasColumnType("timestamp with time zone"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("PasswordHash") + .HasColumnType("text"); + + b.Property("PhoneNumber") + .HasColumnType("text"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("boolean"); + + b.Property("SecurityStamp") + .HasColumnType("text"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("TwoFactorEnabled") + .HasColumnType("boolean"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .IsUnique() + .HasDatabaseName("EmailIndex") + .HasFilter("\"IsActive\""); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex"); + + b.HasIndex("TenantId"); + + b.ToTable("AspNetUsers", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b => + { + 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", (string)null); + + b.HasData( + new + { + Id = new Guid("c2281575-f1a9-41f8-810d-f53174c2b4d1"), + ConcurrencyStamp = "c2281575-f1a9-41f8-810d-f53174c2b4d1", + Name = "Administrator", + NormalizedName = "ADMINISTRATOR" + }, + new + { + Id = new Guid("b4f5e138-6741-45bb-a395-c971dc8e7962"), + ConcurrencyStamp = "b4f5e138-6741-45bb-a395-c971dc8e7962", + Name = "Sales Staff", + NormalizedName = "SALES STAFF" + }, + new + { + Id = new Guid("a3a51997-11f6-480c-8c9d-28210ab7e385"), + ConcurrencyStamp = "a3a51997-11f6-480c-8c9d-28210ab7e385", + Name = "Dispatch Staff", + NormalizedName = "DISPATCH STAFF" + }, + new + { + Id = new Guid("42e5faac-8ca2-41e8-98bc-f842f205e9aa"), + ConcurrencyStamp = "42e5faac-8ca2-41e8-98bc-f842f205e9aa", + Name = "Approver", + NormalizedName = "APPROVER" + }, + new + { + Id = new Guid("295f85df-e212-4cbc-8ee1-48b3c6ec2fee"), + ConcurrencyStamp = "295f85df-e212-4cbc-8ee1-48b3c6ec2fee", + Name = "Accounts Staff", + NormalizedName = "ACCOUNTS STAFF" + }); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text"); + + b.Property("ClaimValue") + .HasColumnType("text"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text"); + + b.Property("ClaimValue") + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("text"); + + b.Property("ProviderKey") + .HasColumnType("text"); + + b.Property("ProviderDisplayName") + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("uuid"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("uuid"); + + b.Property("LoginProvider") + .HasColumnType("text"); + + b.Property("Name") + .HasColumnType("text"); + + b.Property("Value") + .HasColumnType("text"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("DistroFlow.Domain.Entities.Employee", b => + { + b.HasOne("DistroFlow.Domain.Entities.Tenant", "Tenant") + .WithMany() + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("DistroFlow.Infrastructure.Identity.ApplicationUser", null) + .WithOne() + .HasForeignKey("DistroFlow.Domain.Entities.Employee", "UserId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("DistroFlow.Infrastructure.Identity.ApplicationUser", b => + { + b.HasOne("DistroFlow.Domain.Entities.Tenant", "Tenant") + .WithMany() + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("DistroFlow.Infrastructure.Identity.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("DistroFlow.Infrastructure.Identity.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("DistroFlow.Infrastructure.Identity.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("DistroFlow.Infrastructure.Identity.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); #pragma warning restore 612, 618 } } diff --git a/src/DistroFlow.Infrastructure/Persistence/UnitOfWork.cs b/src/DistroFlow.Infrastructure/Persistence/UnitOfWork.cs new file mode 100644 index 0000000..ac3f25c --- /dev/null +++ b/src/DistroFlow.Infrastructure/Persistence/UnitOfWork.cs @@ -0,0 +1,41 @@ +using DistroFlow.Application.Interfaces.Persistence; +using Microsoft.EntityFrameworkCore.Storage; + +namespace DistroFlow.Infrastructure.Persistence; + +public class UnitOfWork : IUnitOfWork +{ + private readonly AppDbContext _dbContext; + + public UnitOfWork(AppDbContext dbContext) + { + _dbContext = dbContext; + } + + public async Task SaveChangesAsync(CancellationToken cancellationToken = default) + { + await _dbContext.SaveChangesAsync(cancellationToken); + } + + public async Task BeginTransactionAsync(CancellationToken cancellationToken = default) + { + var transaction = await _dbContext.Database.BeginTransactionAsync(cancellationToken); + return new EfTransaction(transaction); + } + + private sealed class EfTransaction : ITransaction + { + private readonly IDbContextTransaction _transaction; + + public EfTransaction(IDbContextTransaction transaction) + { + _transaction = transaction; + } + + public Task CommitAsync(CancellationToken cancellationToken = default) => + _transaction.CommitAsync(cancellationToken); + + // EF rolls back an uncommitted transaction when it is disposed. + public ValueTask DisposeAsync() => _transaction.DisposeAsync(); + } +} diff --git a/src/DistroFlow.Infrastructure/Repositories/EmployeeRepository.cs b/src/DistroFlow.Infrastructure/Repositories/EmployeeRepository.cs new file mode 100644 index 0000000..b86c499 --- /dev/null +++ b/src/DistroFlow.Infrastructure/Repositories/EmployeeRepository.cs @@ -0,0 +1,34 @@ +using DistroFlow.Application.Interfaces.Repositories; +using DistroFlow.Domain.Entities; +using DistroFlow.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore; + +namespace DistroFlow.Infrastructure.Repositories; + +public class EmployeeRepository : IEmployeeRepository +{ + private readonly AppDbContext _dbContext; + + public EmployeeRepository(AppDbContext dbContext) + { + _dbContext = dbContext; + } + + public void Add(Employee employee) + { + _dbContext.Employees.Add(employee); + } + + public async Task GetForLoginByUserIdAsync( + Guid userId, + CancellationToken cancellationToken = default) + { + return await _dbContext.Employees + // At login nobody is authenticated yet, so there is no current tenant and the + // tenant filter would hide every row. Safe here: the user id comes from a + // successful password check, and the result is never returned to the caller. + .IgnoreQueryFilters() + .AsNoTracking() + .SingleOrDefaultAsync(e => e.UserId == userId, cancellationToken); + } +} diff --git a/src/DistroFlow.Infrastructure/Repositories/TenantRepository.cs b/src/DistroFlow.Infrastructure/Repositories/TenantRepository.cs index 23f405f..db89211 100644 --- a/src/DistroFlow.Infrastructure/Repositories/TenantRepository.cs +++ b/src/DistroFlow.Infrastructure/Repositories/TenantRepository.cs @@ -41,15 +41,6 @@ public void Add(Tenant tenant) cancellationToken); } - public async Task> GetAllAsync( - CancellationToken cancellationToken = default) - { - return await _dbContext.Tenants - .AsNoTracking() - .OrderBy(t => t.Name) - .ToListAsync(cancellationToken); - } - public async Task SaveChangesAsync( CancellationToken cancellationToken = default) { diff --git a/src/DistroFlow.Infrastructure/Services/TenantService.cs b/src/DistroFlow.Infrastructure/Services/TenantService.cs deleted file mode 100644 index 69c65ba..0000000 --- a/src/DistroFlow.Infrastructure/Services/TenantService.cs +++ /dev/null @@ -1,127 +0,0 @@ -using DistroFlow.Application.DTOs.Tenants; -using DistroFlow.Application.Interfaces.Services; -using DistroFlow.Domain.Entities; -using DistroFlow.Infrastructure.Persistence; -using Microsoft.EntityFrameworkCore; - -namespace DistroFlow.Infrastructure.Services; - -public class TenantService : ITenantService -{ - private readonly AppDbContext _dbContext; - - public TenantService(AppDbContext dbContext) - { - _dbContext = dbContext; - } - - public async Task CreateAsync( - CreateTenantRequest request, - CancellationToken cancellationToken = default) - { - var tenant = new Tenant - { - Name = request.Name.Trim(), - Email = request.Email?.Trim(), - Phone = request.Phone?.Trim(), - CurrencyCode = request.CurrencyCode.Trim().ToUpperInvariant(), - InvoicePrefix = request.InvoicePrefix.Trim().ToUpperInvariant(), - FiscalYearStartMonth = request.FiscalYearStartMonth, - TimeZoneId = request.TimeZoneId.Trim(), - CreatedAtUtc = DateTime.UtcNow - }; - - _dbContext.Tenants.Add(tenant); - - await _dbContext.SaveChangesAsync(cancellationToken); - - return MapToResponse(tenant); - } - - public async Task GetByIdAsync( - Guid id, - CancellationToken cancellationToken = default) - { - var tenant = await _dbContext.Tenants - .AsNoTracking() - .FirstOrDefaultAsync(t => t.Id == id, cancellationToken); - - return tenant is null ? null : MapToResponse(tenant); - } - - public async Task> GetAllAsync( - CancellationToken cancellationToken = default) - { - var tenants = await _dbContext.Tenants - .AsNoTracking() - .OrderBy(t => t.Name) - .ToListAsync(cancellationToken); - - return tenants.Select(MapToResponse).ToList(); - } - - public async Task UpdateAsync( - Guid id, - UpdateTenantRequest request, - CancellationToken cancellationToken = default) - { - var tenant = await _dbContext.Tenants - .FirstOrDefaultAsync(t => t.Id == id, cancellationToken); - - if (tenant is null) - return null; - - tenant.Name = request.Name.Trim(); - tenant.Email = request.Email?.Trim(); - tenant.Phone = request.Phone?.Trim(); - tenant.CurrencyCode = request.CurrencyCode.Trim().ToUpperInvariant(); - tenant.InvoicePrefix = request.InvoicePrefix.Trim().ToUpperInvariant(); - tenant.FiscalYearStartMonth = request.FiscalYearStartMonth; - tenant.TimeZoneId = request.TimeZoneId.Trim(); - tenant.IsActive = request.IsActive; - tenant.UpdatedAtUtc = DateTime.UtcNow; - - await _dbContext.SaveChangesAsync(cancellationToken); - - return MapToResponse(tenant); - } - - public async Task DeactivateAsync( - Guid id, - CancellationToken cancellationToken = default) - { - var tenant = await _dbContext.Tenants - .FirstOrDefaultAsync(t => t.Id == id, cancellationToken); - - if (tenant is null) - return false; - - if (!tenant.IsActive) - return true; - - tenant.IsActive = false; - tenant.UpdatedAtUtc = DateTime.UtcNow; - - await _dbContext.SaveChangesAsync(cancellationToken); - - return true; - } - - private static TenantResponse MapToResponse(Tenant tenant) - { - return new TenantResponse - { - Id = tenant.Id, - Name = tenant.Name, - Email = tenant.Email, - Phone = tenant.Phone, - CurrencyCode = tenant.CurrencyCode, - InvoicePrefix = tenant.InvoicePrefix, - FiscalYearStartMonth = tenant.FiscalYearStartMonth, - TimeZoneId = tenant.TimeZoneId, - IsActive = tenant.IsActive, - CreatedAtUtc = tenant.CreatedAtUtc, - UpdatedAtUtc = tenant.UpdatedAtUtc - }; - } -} \ No newline at end of file diff --git a/tests/DistroFlow.IntegrationTests/Controllers/AuthControllerTests.cs b/tests/DistroFlow.IntegrationTests/Controllers/AuthControllerTests.cs new file mode 100644 index 0000000..b25a9ee --- /dev/null +++ b/tests/DistroFlow.IntegrationTests/Controllers/AuthControllerTests.cs @@ -0,0 +1,140 @@ +using System.Net; +using System.Net.Http.Json; +using System.Text.Json; +using DistroFlow.Domain.Constants; +using DistroFlow.Domain.Entities; +using DistroFlow.Infrastructure.Identity; +using DistroFlow.Infrastructure.Persistence; +using DistroFlow.IntegrationTests.Infrastructure; +using Microsoft.AspNetCore.Identity; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; + +namespace DistroFlow.IntegrationTests.Controllers; + +public class AuthControllerTests : IClassFixture +{ + private const string RegisterUrl = "/api/v1/auth/register"; + private const string ValidPassword = "Str0ng!Passw0rd"; + + private readonly ApiIntegrationTestFactory _factory; + private readonly HttpClient _client; + + public AuthControllerTests(ApiIntegrationTestFactory factory) + { + _factory = factory; + _client = factory.CreateClient(); + } + + [Fact] + public async Task Register_creates_tenant_owner_login_and_administrator_employee() + { + var email = UniqueEmail(); + var request = ValidRequest(email, businessName: "Sharma Traders"); + + var response = await _client.PostAsJsonAsync(RegisterUrl, request); + + Assert.Equal(HttpStatusCode.Created, response.StatusCode); + var data = (await response.Content.ReadFromJsonAsync()).GetProperty("data"); + var tenantId = data.GetProperty("tenantId").GetGuid(); + var employeeId = data.GetProperty("employeeId").GetGuid(); + var userId = data.GetProperty("userId").GetGuid(); + + await using var scope = _factory.Services.CreateAsyncScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var userManager = scope.ServiceProvider.GetRequiredService>(); + + var tenant = await db.Tenants.AsNoTracking().SingleAsync(t => t.Id == tenantId); + Assert.Equal("Sharma Traders", tenant.Name); + Assert.Equal("Asia/Kolkata", tenant.TimeZoneId); + + var user = await userManager.FindByIdAsync(userId.ToString()); + Assert.NotNull(user); + Assert.Equal(tenantId, user.TenantId); + Assert.Equal(email, user.Email); + Assert.True(await userManager.CheckPasswordAsync(user, ValidPassword)); + Assert.True(await userManager.IsInRoleAsync(user, EmployeeRoles.Administrator)); + + // Verifying the database directly, outside any request (no tenant): bypass the tenant filter. + var employee = await db.Employees.IgnoreQueryFilters().AsNoTracking().SingleAsync(e => e.Id == employeeId); + Assert.Equal(tenantId, employee.TenantId); + Assert.Equal(userId, employee.UserId); + Assert.Equal("Ravi Sharma", employee.FullName); + Assert.Equal("+919876543210", employee.Phone); + Assert.Equal(EmployeeStatus.Active, employee.Status); + } + + [Fact] + public async Task Register_with_email_active_elsewhere_returns_409_and_saves_nothing() + { + var email = UniqueEmail(); + var first = await _client.PostAsJsonAsync(RegisterUrl, ValidRequest(email)); + Assert.Equal(HttpStatusCode.Created, first.StatusCode); + + var secondBusiness = $"Second {Guid.NewGuid():N}"; + var response = await _client.PostAsJsonAsync(RegisterUrl, ValidRequest(email, secondBusiness)); + + Assert.Equal(HttpStatusCode.Conflict, response.StatusCode); + var body = await response.Content.ReadFromJsonAsync(); + Assert.False(body.GetProperty("success").GetBoolean()); + + // The tenant row was inserted before the login failed; the transaction must have rolled it back. + Assert.False(await TenantExistsAsync(secondBusiness)); + } + + [Fact] + public async Task Register_with_password_failing_identity_policy_returns_400_and_saves_nothing() + { + var businessName = $"Weak {Guid.NewGuid():N}"; + // Long enough for the DTO, but no uppercase/digit/symbol: rejected by Identity. + var request = ValidRequest(UniqueEmail(), businessName) with { password = "weakpassword" }; + + var response = await _client.PostAsJsonAsync(RegisterUrl, request); + + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + var errors = (await response.Content.ReadFromJsonAsync()).GetProperty("errors"); + Assert.True(errors.GetProperty("Password").GetArrayLength() > 0); + Assert.False(await TenantExistsAsync(businessName)); + } + + [Fact] + public async Task Register_with_invalid_body_returns_400_and_saves_nothing() + { + var businessName = $"Invalid {Guid.NewGuid():N}"; + var request = ValidRequest("not-an-email", businessName) with { ownerPhone = "98765" }; + + var response = await _client.PostAsJsonAsync(RegisterUrl, request); + + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + var errors = (await response.Content.ReadFromJsonAsync()).GetProperty("errors"); + Assert.True(errors.TryGetProperty("Email", out _)); + Assert.True(errors.TryGetProperty("OwnerPhone", out _)); + Assert.False(await TenantExistsAsync(businessName)); + } + + private static string UniqueEmail() => $"owner-{Guid.NewGuid():N}@example.com"; + + private static RegisterBody ValidRequest(string email, string? businessName = null) => new( + businessName: businessName ?? $"Distributor {Guid.NewGuid():N}", + timeZoneId: "Asia/Kolkata", + ownerFullName: "Ravi Sharma", + ownerPhone: "+919876543210", + email: email, + password: ValidPassword); + + private async Task TenantExistsAsync(string name) + { + await using var scope = _factory.Services.CreateAsyncScope(); + var db = scope.ServiceProvider.GetRequiredService(); + return await db.Tenants.AnyAsync(t => t.Name == name); + } + + // Lower-case names so the JSON matches what the frontend will send. + private sealed record RegisterBody( + string businessName, + string timeZoneId, + string ownerFullName, + string? ownerPhone, + string email, + string password); +} diff --git a/tests/DistroFlow.IntegrationTests/Controllers/AuthLoginTests.cs b/tests/DistroFlow.IntegrationTests/Controllers/AuthLoginTests.cs new file mode 100644 index 0000000..59086f9 --- /dev/null +++ b/tests/DistroFlow.IntegrationTests/Controllers/AuthLoginTests.cs @@ -0,0 +1,193 @@ +using System.Net; +using System.Net.Http.Json; +using System.Text.Json; +using DistroFlow.Application.Common; +using DistroFlow.Domain.Constants; +using DistroFlow.Infrastructure.Persistence; +using DistroFlow.IntegrationTests.Infrastructure; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.IdentityModel.JsonWebTokens; + +namespace DistroFlow.IntegrationTests.Controllers; + +public class AuthLoginTests : IClassFixture +{ + private const string LoginUrl = "/api/v1/auth/login"; + private const string Password = "Str0ng!Passw0rd"; + private const string InvalidCredentialsMessage = "Invalid email or password."; + + private readonly ApiIntegrationTestFactory _factory; + private readonly HttpClient _client; + + public AuthLoginTests(ApiIntegrationTestFactory factory) + { + _factory = factory; + _client = factory.CreateClient(); + } + + [Fact] + public async Task Login_with_valid_credentials_returns_token_for_owner() + { + var owner = await RegisterAsync(); + + var response = await LoginAsync(owner.Email, Password); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var data = (await response.Content.ReadFromJsonAsync()).GetProperty("data"); + Assert.Equal("Bearer", data.GetProperty("tokenType").GetString()); + Assert.True(data.GetProperty("expiresAtUtc").GetDateTime() > DateTime.UtcNow); + + var token = new JsonWebToken(data.GetProperty("accessToken").GetString()); + Assert.Equal(owner.UserId.ToString(), token.GetClaim(AppClaimTypes.UserId).Value); + Assert.Equal(owner.EmployeeId.ToString(), token.GetClaim(AppClaimTypes.EmployeeId).Value); + Assert.Equal(owner.TenantId.ToString(), token.GetClaim(AppClaimTypes.TenantId).Value); + Assert.Equal(EmployeeRoles.Administrator, token.GetClaim(AppClaimTypes.Role).Value); + } + + [Fact] + public async Task Login_email_is_case_insensitive() + { + var owner = await RegisterAsync(); + + var response = await LoginAsync(owner.Email.ToUpperInvariant(), Password); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + } + + [Fact] + public async Task Wrong_password_and_unknown_email_return_identical_401() + { + var owner = await RegisterAsync(); + + var wrongPassword = await LoginAsync(owner.Email, "Wr0ng!Passw0rd"); + var unknownEmail = await LoginAsync(UniqueEmail(), Password); + + Assert.Equal(HttpStatusCode.Unauthorized, wrongPassword.StatusCode); + Assert.Equal(HttpStatusCode.Unauthorized, unknownEmail.StatusCode); + Assert.Equal( + await wrongPassword.Content.ReadAsStringAsync(), + await unknownEmail.Content.ReadAsStringAsync()); + Assert.Equal(InvalidCredentialsMessage, await MessageOf(wrongPassword)); + } + + [Fact] + public async Task Five_failed_attempts_lock_the_account_even_for_correct_password() + { + var owner = await RegisterAsync(); + + for (var i = 0; i < 5; i++) + { + await LoginAsync(owner.Email, "Wr0ng!Passw0rd"); + } + + var response = await LoginAsync(owner.Email, Password); + + Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); + Assert.Equal(InvalidCredentialsMessage, await MessageOf(response)); + } + + [Fact] + public async Task Successful_login_resets_failed_attempt_count() + { + var owner = await RegisterAsync(); + + for (var i = 0; i < 4; i++) await LoginAsync(owner.Email, "Wr0ng!Passw0rd"); + Assert.Equal(HttpStatusCode.OK, (await LoginAsync(owner.Email, Password)).StatusCode); + for (var i = 0; i < 4; i++) await LoginAsync(owner.Email, "Wr0ng!Passw0rd"); + + Assert.Equal(HttpStatusCode.OK, (await LoginAsync(owner.Email, Password)).StatusCode); + } + + [Fact] + public async Task Deactivated_employee_cannot_login() + { + var owner = await RegisterAsync(); + await DeactivateAsync(owner); + + var response = await LoginAsync(owner.Email, Password); + + Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); + } + + [Fact] + public async Task Suspended_distributor_returns_403() + { + var owner = await RegisterAsync(); + await using (var scope = _factory.Services.CreateAsyncScope()) + { + var db = scope.ServiceProvider.GetRequiredService(); + var tenant = await db.Tenants.SingleAsync(t => t.Id == owner.TenantId); + tenant.IsActive = false; + await db.SaveChangesAsync(); + } + + var response = await LoginAsync(owner.Email, Password); + + Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode); + } + + [Fact] + public async Task Reused_email_logs_into_the_new_distributor_after_old_account_is_deactivated() + { + var email = UniqueEmail(); + var oldJob = await RegisterAsync(email); + await DeactivateAsync(oldJob); + var newJob = await RegisterAsync(email); + + var response = await LoginAsync(email, Password); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var data = (await response.Content.ReadFromJsonAsync()).GetProperty("data"); + var token = new JsonWebToken(data.GetProperty("accessToken").GetString()); + Assert.Equal(newJob.TenantId.ToString(), token.GetClaim(AppClaimTypes.TenantId).Value); + Assert.NotEqual(oldJob.TenantId, newJob.TenantId); + } + + private sealed record RegisteredOwner(string Email, Guid TenantId, Guid EmployeeId, Guid UserId); + + private static string UniqueEmail() => $"login-{Guid.NewGuid():N}@example.com"; + + private async Task RegisterAsync(string? email = null) + { + email ??= UniqueEmail(); + var response = await _client.PostAsJsonAsync("/api/v1/auth/register", new + { + businessName = $"Distributor {Guid.NewGuid():N}", + timeZoneId = "Asia/Kolkata", + ownerFullName = "Ravi Sharma", + email, + password = Password + }); + Assert.Equal(HttpStatusCode.Created, response.StatusCode); + + var data = (await response.Content.ReadFromJsonAsync()).GetProperty("data"); + return new RegisteredOwner( + email, + data.GetProperty("tenantId").GetGuid(), + data.GetProperty("employeeId").GetGuid(), + data.GetProperty("userId").GetGuid()); + } + + /// + /// Deactivates both rows directly; the deactivate endpoint arrives with employee management. + /// + private async Task DeactivateAsync(RegisteredOwner owner) + { + await using var scope = _factory.Services.CreateAsyncScope(); + var db = scope.ServiceProvider.GetRequiredService(); + + var user = await db.Users.SingleAsync(u => u.Id == owner.UserId); + // Test setup runs outside any request (no tenant), so bypass the tenant filter. + var employee = await db.Employees.IgnoreQueryFilters().SingleAsync(e => e.Id == owner.EmployeeId); + user.IsActive = false; + employee.Deactivate(DateTime.UtcNow); + await db.SaveChangesAsync(); + } + + private Task LoginAsync(string email, string password) => + _client.PostAsJsonAsync(LoginUrl, new { email, password }); + + private static async Task MessageOf(HttpResponseMessage response) => + (await response.Content.ReadFromJsonAsync()).GetProperty("message").GetString(); +} diff --git a/tests/DistroFlow.IntegrationTests/Controllers/AuthorizationDefaultsTests.cs b/tests/DistroFlow.IntegrationTests/Controllers/AuthorizationDefaultsTests.cs new file mode 100644 index 0000000..f48cd01 --- /dev/null +++ b/tests/DistroFlow.IntegrationTests/Controllers/AuthorizationDefaultsTests.cs @@ -0,0 +1,79 @@ +using System.Net; +using DistroFlow.IntegrationTests.Infrastructure; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Authorization.Infrastructure; +using Microsoft.AspNetCore.Routing; +using Microsoft.Extensions.DependencyInjection; + +namespace DistroFlow.IntegrationTests.Controllers; + +/// +/// Proves the API is secure by default: login is required everywhere unless an +/// endpoint is deliberately, visibly public. +/// +public class AuthorizationDefaultsTests : IClassFixture +{ + private readonly ApiIntegrationTestFactory _factory; + private readonly HttpClient _anonymous; + + public AuthorizationDefaultsTests(ApiIntegrationTestFactory factory) + { + _factory = factory; + _anonymous = factory.CreateClient(); + } + + [Fact] + public async Task Fallback_policy_requires_an_authenticated_user() + { + var policyProvider = _factory.Services.GetRequiredService(); + + var fallback = await policyProvider.GetFallbackPolicyAsync(); + + Assert.NotNull(fallback); + Assert.Contains(fallback.Requirements, r => r is DenyAnonymousAuthorizationRequirement); + } + + [Fact] + public void Only_the_expected_endpoints_are_public() + { + // If this fails, a new endpoint allows anonymous access. Make sure that is + // intended, then add it here so the decision is reviewed in the diff. + string[] expectedPublic = + [ + "GET health/database", + "GET openapi/{documentName}.json", + "POST api/v1/auth/login", + "POST api/v1/auth/register" + ]; + + var endpoints = _factory.Services.GetRequiredService().Endpoints; + + var actualPublic = endpoints + .OfType() + .Where(e => e.Metadata.GetMetadata() is not null) + .SelectMany(e => (e.Metadata.GetMetadata()?.HttpMethods ?? ["ANY"]) + .Select(method => $"{method} {e.RoutePattern.RawText!.Trim('/')}")) + .Order() + .ToArray(); + + Assert.Equal(expectedPublic, actualPublic); + } + + [Fact] + public async Task Health_check_is_reachable_without_a_token() + { + var response = await _anonymous.GetAsync("/health/database"); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + } + + [Theory] + [InlineData("/api/v1/me")] + [InlineData("/api/v1/tenant")] + public async Task Protected_endpoints_return_401_without_a_token(string url) + { + var response = await _anonymous.GetAsync(url); + + Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); + } +} diff --git a/tests/DistroFlow.IntegrationTests/Controllers/MeControllerTests.cs b/tests/DistroFlow.IntegrationTests/Controllers/MeControllerTests.cs new file mode 100644 index 0000000..a76e54f --- /dev/null +++ b/tests/DistroFlow.IntegrationTests/Controllers/MeControllerTests.cs @@ -0,0 +1,160 @@ +using System.Net; +using System.Net.Http.Headers; +using System.Net.Http.Json; +using System.Text.Json; +using DistroFlow.Application.Interfaces.Identity; +using DistroFlow.Domain.Constants; +using DistroFlow.Infrastructure.Identity; +using DistroFlow.IntegrationTests.Infrastructure; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; +using Microsoft.IdentityModel.Tokens; + +namespace DistroFlow.IntegrationTests.Controllers; + +/// +/// End-to-end proof that bearer authentication accepts only tokens we issued, unmodified and unexpired. +/// +public class MeControllerTests : IClassFixture +{ + private const string MeUrl = "/api/v1/me"; + private const string Password = "Str0ng!Passw0rd"; + + private readonly ApiIntegrationTestFactory _factory; + private readonly HttpClient _client; + + public MeControllerTests(ApiIntegrationTestFactory factory) + { + _factory = factory; + _client = factory.CreateClient(); + } + + [Fact] + public async Task Without_token_returns_401() + { + var response = await _client.GetAsync(MeUrl); + + Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); + } + + [Fact] + public async Task With_valid_token_returns_identity_from_token() + { + var (token, tenantId) = await RegisterAndLoginAsync(); + + var response = await GetMeAsync(token); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var data = (await response.Content.ReadFromJsonAsync()).GetProperty("data"); + Assert.Equal(tenantId, data.GetProperty("tenantId").GetGuid()); + Assert.NotEqual(Guid.Empty, data.GetProperty("userId").GetGuid()); + Assert.NotEqual(Guid.Empty, data.GetProperty("employeeId").GetGuid()); + Assert.Equal([EmployeeRoles.Administrator], + data.GetProperty("roles").EnumerateArray().Select(r => r.GetString())); + } + + [Fact] + public async Task Token_with_tampered_tenant_id_returns_401() + { + var (token, _) = await RegisterAndLoginAsync(); + + // Swap tenant_id in the payload but keep the original signature. + var parts = token.Split('.'); + var payload = Base64UrlEncoder.Decode(parts[1]); + var json = JsonSerializer.Deserialize>(payload)!; + var forged = json.ToDictionary(kv => kv.Key, kv => (object)kv.Value); + forged["tenant_id"] = Guid.CreateVersion7().ToString(); + parts[1] = Base64UrlEncoder.Encode(JsonSerializer.Serialize(forged)); + + var response = await GetMeAsync(string.Join('.', parts)); + + Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); + } + + [Fact] + public async Task Token_signed_with_another_key_returns_401() + { + var options = JwtOptionsFromApp(); + var otherKeyOptions = new JwtOptions + { + Issuer = options.Issuer, + Audience = options.Audience, + SigningKey = "attacker-chosen-signing-key-0123456789-abcdefgh", + AccessTokenMinutes = options.AccessTokenMinutes + }; + + var token = IssueToken(otherKeyOptions, TimeProvider.System); + + Assert.Equal(HttpStatusCode.Unauthorized, (await GetMeAsync(token)).StatusCode); + } + + [Fact] + public async Task Expired_token_returns_401() + { + // Issued two hours ago with the real key: signature is valid, lifetime is not. + var twoHoursAgo = new FixedTimeProvider(DateTimeOffset.UtcNow.AddHours(-2)); + var token = IssueToken(JwtOptionsFromApp(), twoHoursAgo); + + Assert.Equal(HttpStatusCode.Unauthorized, (await GetMeAsync(token)).StatusCode); + } + + [Fact] + public async Task Token_for_another_audience_returns_401() + { + var options = JwtOptionsFromApp(); + var otherAudience = new JwtOptions + { + Issuer = options.Issuer, + Audience = "SomeOtherApp", + SigningKey = options.SigningKey, + AccessTokenMinutes = options.AccessTokenMinutes + }; + + var token = IssueToken(otherAudience, TimeProvider.System); + + Assert.Equal(HttpStatusCode.Unauthorized, (await GetMeAsync(token)).StatusCode); + } + + private JwtOptions JwtOptionsFromApp() => + _factory.Services.GetRequiredService>().Value; + + private static string IssueToken(JwtOptions options, TimeProvider time) => + new JwtTokenService(Options.Create(options), time) + .CreateAccessToken(new TokenSubject( + Guid.CreateVersion7(), Guid.CreateVersion7(), Guid.CreateVersion7(), + [EmployeeRoles.Administrator])) + .Token; + + private async Task<(string Token, Guid TenantId)> RegisterAndLoginAsync() + { + var email = $"me-{Guid.NewGuid():N}@example.com"; + var register = await _client.PostAsJsonAsync("/api/v1/auth/register", new + { + businessName = $"Distributor {Guid.NewGuid():N}", + timeZoneId = "Asia/Kolkata", + ownerFullName = "Ravi Sharma", + email, + password = Password + }); + var tenantId = (await register.Content.ReadFromJsonAsync()) + .GetProperty("data").GetProperty("tenantId").GetGuid(); + + var login = await _client.PostAsJsonAsync("/api/v1/auth/login", new { email, password = Password }); + var token = (await login.Content.ReadFromJsonAsync()) + .GetProperty("data").GetProperty("accessToken").GetString()!; + + return (token, tenantId); + } + + private Task GetMeAsync(string token) + { + var request = new HttpRequestMessage(HttpMethod.Get, MeUrl); + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token); + return _client.SendAsync(request); + } + + private sealed class FixedTimeProvider(DateTimeOffset now) : TimeProvider + { + public override DateTimeOffset GetUtcNow() => now; + } +} diff --git a/tests/DistroFlow.IntegrationTests/Controllers/TenantControllerTests.cs b/tests/DistroFlow.IntegrationTests/Controllers/TenantControllerTests.cs new file mode 100644 index 0000000..10c94b4 --- /dev/null +++ b/tests/DistroFlow.IntegrationTests/Controllers/TenantControllerTests.cs @@ -0,0 +1,176 @@ +using System.Net; +using System.Net.Http.Json; +using System.Text.Json; +using DistroFlow.Domain.Constants; +using DistroFlow.Infrastructure.Persistence; +using DistroFlow.IntegrationTests.Infrastructure; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; + +namespace DistroFlow.IntegrationTests.Controllers; + +public class TenantControllerTests : IClassFixture +{ + private const string TenantUrl = "/api/v1/tenant"; + + private readonly ApiIntegrationTestFactory _factory; + private readonly HttpClient _anonymous; + + public TenantControllerTests(ApiIntegrationTestFactory factory) + { + _factory = factory; + _anonymous = factory.CreateClient(); + } + + [Fact] + public async Task Get_without_token_returns_401() + { + var response = await _anonymous.GetAsync(TenantUrl); + + Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); + } + + [Fact] + public async Task Get_returns_the_callers_own_tenant() + { + var a = await TestAuth.RegisterAndLoginAsync(_anonymous); + await TestAuth.RegisterAndLoginAsync(_anonymous); // another distributor exists too + + var response = await TestAuth.CreateClientWithToken(_factory, a.AccessToken).GetAsync(TenantUrl); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var data = (await response.Content.ReadFromJsonAsync()).GetProperty("data"); + Assert.Equal(a.TenantId, data.GetProperty("id").GetGuid()); + Assert.Equal(a.BusinessName, data.GetProperty("name").GetString()); + } + + [Fact] + public async Task Any_employee_role_can_read_their_tenant() + { + var a = await TestAuth.RegisterAndLoginAsync(_anonymous); + var salesToken = TestAuth.IssueToken(_factory, a.TenantId, EmployeeRoles.SalesStaff); + + var response = await TestAuth.CreateClientWithToken(_factory, salesToken).GetAsync(TenantUrl); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + } + + [Fact] + public async Task Administrator_updates_own_tenant_and_other_tenants_are_untouched() + { + var a = await TestAuth.RegisterAndLoginAsync(_anonymous); + var b = await TestAuth.RegisterAndLoginAsync(_anonymous); + + var response = await TestAuth.CreateClientWithToken(_factory, a.AccessToken) + .PutAsJsonAsync(TenantUrl, ValidUpdate("Updated Distributor")); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var data = (await response.Content.ReadFromJsonAsync()).GetProperty("data"); + Assert.Equal("Updated Distributor", data.GetProperty("name").GetString()); + Assert.Equal("USD", data.GetProperty("currencyCode").GetString()); + Assert.Equal("UPD", data.GetProperty("invoicePrefix").GetString()); + Assert.NotNull(data.GetProperty("updatedAtUtc").GetString()); + + Assert.Equal("Updated Distributor", (await LoadTenantAsync(a.TenantId)).Name); + Assert.Equal(b.BusinessName, (await LoadTenantAsync(b.TenantId)).Name); + } + + [Fact] + public async Task Non_administrator_cannot_update_tenant() + { + var a = await TestAuth.RegisterAndLoginAsync(_anonymous); + var salesToken = TestAuth.IssueToken(_factory, a.TenantId, EmployeeRoles.SalesStaff); + + var response = await TestAuth.CreateClientWithToken(_factory, salesToken) + .PutAsJsonAsync(TenantUrl, ValidUpdate("Hijacked Name")); + + Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode); + Assert.Equal(a.BusinessName, (await LoadTenantAsync(a.TenantId)).Name); + } + + [Fact] + public async Task Update_cannot_change_active_status() + { + var a = await TestAuth.RegisterAndLoginAsync(_anonymous); + await SetTenantActiveAsync(a.TenantId, isActive: true); + + // isActive is not part of the request; sending it must have no effect. + var body = new + { + name = "Still Active Distributor", + currencyCode = "INR", + invoicePrefix = "INV", + fiscalYearStartMonth = 4, + timeZoneId = "Asia/Kolkata", + isActive = false + }; + var response = await TestAuth.CreateClientWithToken(_factory, a.AccessToken).PutAsJsonAsync(TenantUrl, body); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.True((await LoadTenantAsync(a.TenantId)).IsActive); + } + + [Fact] + public async Task Update_with_invalid_body_returns_400() + { + var a = await TestAuth.RegisterAndLoginAsync(_anonymous); + var invalid = new + { + name = "X", // too short + currencyCode = "usd", // must be 3 uppercase letters + invoicePrefix = "INV", + fiscalYearStartMonth = 13, // out of range + timeZoneId = "Not/AZone" + }; + + var response = await TestAuth.CreateClientWithToken(_factory, a.AccessToken).PutAsJsonAsync(TenantUrl, invalid); + + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + var errors = (await response.Content.ReadFromJsonAsync()).GetProperty("errors"); + Assert.True(errors.TryGetProperty("Name", out _)); + Assert.True(errors.TryGetProperty("CurrencyCode", out _)); + Assert.True(errors.TryGetProperty("FiscalYearStartMonth", out _)); + Assert.True(errors.TryGetProperty("TimeZoneId", out _)); + } + + [Theory] + [InlineData("GET", "/api/v1/tenants")] + [InlineData("GET", "/api/v1/tenants/0195f3a0-0000-7000-8000-000000000001")] + [InlineData("PATCH", "/api/v1/tenants/0195f3a0-0000-7000-8000-000000000001/deactivate")] + public async Task Old_cross_tenant_endpoints_no_longer_exist(string method, string url) + { + var a = await TestAuth.RegisterAndLoginAsync(_anonymous); + var client = TestAuth.CreateClientWithToken(_factory, a.AccessToken); + + var response = await client.SendAsync(new HttpRequestMessage(new HttpMethod(method), url)); + + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + } + + private static object ValidUpdate(string name) => new + { + name, + email = "updated@test.com", + phone = "+919876543210", + currencyCode = "USD", + invoicePrefix = "UPD", + fiscalYearStartMonth = 1, + timeZoneId = "Asia/Kolkata" + }; + + private async Task LoadTenantAsync(Guid tenantId) + { + await using var scope = _factory.Services.CreateAsyncScope(); + var db = scope.ServiceProvider.GetRequiredService(); + return await db.Tenants.AsNoTracking().SingleAsync(t => t.Id == tenantId); + } + + private async Task SetTenantActiveAsync(Guid tenantId, bool isActive) + { + await using var scope = _factory.Services.CreateAsyncScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var tenant = await db.Tenants.SingleAsync(t => t.Id == tenantId); + tenant.IsActive = isActive; + await db.SaveChangesAsync(); + } +} diff --git a/tests/DistroFlow.IntegrationTests/Identity/IdentityServiceTests.cs b/tests/DistroFlow.IntegrationTests/Identity/IdentityServiceTests.cs new file mode 100644 index 0000000..e31f684 --- /dev/null +++ b/tests/DistroFlow.IntegrationTests/Identity/IdentityServiceTests.cs @@ -0,0 +1,106 @@ +using DistroFlow.Application.Interfaces.Identity; +using DistroFlow.Domain.Constants; +using DistroFlow.Domain.Entities; +using DistroFlow.Infrastructure.Identity; +using DistroFlow.Infrastructure.Persistence; +using DistroFlow.IntegrationTests.Infrastructure; +using Microsoft.AspNetCore.Identity; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; + +namespace DistroFlow.IntegrationTests.Identity; + +public class IdentityServiceTests : IClassFixture +{ + private const string ValidPassword = "Str0ng!Passw0rd"; + + private readonly ApiIntegrationTestFactory _factory; + + public IdentityServiceTests(ApiIntegrationTestFactory factory) + { + _factory = factory; + } + + [Fact] + public async Task CreateUser_creates_active_login_with_hashed_password_and_role() + { + var tenant = await CreateTenantAsync(); + var email = UniqueEmail(); + + await using var scope = _factory.Services.CreateAsyncScope(); + var service = scope.ServiceProvider.GetRequiredService(); + var userManager = scope.ServiceProvider.GetRequiredService>(); + + var result = await service.CreateUserAsync(tenant.Id, email, ValidPassword, EmployeeRoles.Administrator); + + Assert.True(result.Succeeded); + var user = await userManager.FindByIdAsync(result.UserId!.Value.ToString()); + Assert.NotNull(user); + Assert.Equal(tenant.Id, user.TenantId); + Assert.Equal(email, user.Email); + Assert.True(user.IsActive); + Assert.True(await userManager.CheckPasswordAsync(user, ValidPassword)); + Assert.True(await userManager.IsInRoleAsync(user, EmployeeRoles.Administrator)); + } + + [Fact] + public async Task CreateUser_returns_EmailInUse_when_email_is_active_in_another_tenant() + { + var tenantA = await CreateTenantAsync(); + var tenantB = await CreateTenantAsync(); + var email = UniqueEmail(); + + await using (var first = _factory.Services.CreateAsyncScope()) + { + var service = first.ServiceProvider.GetRequiredService(); + Assert.True((await service.CreateUserAsync(tenantA.Id, email, ValidPassword, EmployeeRoles.SalesStaff)).Succeeded); + } + + await using var scope = _factory.Services.CreateAsyncScope(); + var secondService = scope.ServiceProvider.GetRequiredService(); + var db = scope.ServiceProvider.GetRequiredService(); + + // Different casing must still count as the same email. + var result = await secondService.CreateUserAsync( + tenantB.Id, email.ToUpperInvariant(), ValidPassword, EmployeeRoles.SalesStaff); + + Assert.False(result.Succeeded); + Assert.Equal(CreateUserError.EmailInUse, result.Error); + Assert.Null(result.UserId); + + // The failed insert must not stay tracked and be retried by a later save. + Assert.DoesNotContain(db.ChangeTracker.Entries(), e => e.State == EntityState.Added); + await db.SaveChangesAsync(); + Assert.Equal(0, await db.Users.CountAsync(u => u.TenantId == tenantB.Id)); + } + + [Fact] + public async Task CreateUser_returns_ValidationFailed_for_weak_password_and_saves_nothing() + { + var tenant = await CreateTenantAsync(); + + await using var scope = _factory.Services.CreateAsyncScope(); + var service = scope.ServiceProvider.GetRequiredService(); + var db = scope.ServiceProvider.GetRequiredService(); + + var result = await service.CreateUserAsync(tenant.Id, UniqueEmail(), "short", EmployeeRoles.SalesStaff); + + Assert.False(result.Succeeded); + Assert.Equal(CreateUserError.ValidationFailed, result.Error); + Assert.NotEmpty(result.Messages); + Assert.Equal(0, await db.Users.CountAsync(u => u.TenantId == tenant.Id)); + } + + private static string UniqueEmail() => $"user-{Guid.NewGuid():N}@example.com"; + + private async Task CreateTenantAsync() + { + await using var scope = _factory.Services.CreateAsyncScope(); + var db = scope.ServiceProvider.GetRequiredService(); + + var tenant = new Tenant { Name = $"Distributor {Guid.NewGuid():N}" }; + db.Tenants.Add(tenant); + await db.SaveChangesAsync(); + return tenant; + } +} diff --git a/tests/DistroFlow.IntegrationTests/Identity/IdentitySetupTests.cs b/tests/DistroFlow.IntegrationTests/Identity/IdentitySetupTests.cs new file mode 100644 index 0000000..5b38ed1 --- /dev/null +++ b/tests/DistroFlow.IntegrationTests/Identity/IdentitySetupTests.cs @@ -0,0 +1,80 @@ +using DistroFlow.Domain.Constants; +using DistroFlow.Domain.Entities; +using DistroFlow.Infrastructure.Identity; +using DistroFlow.Infrastructure.Persistence; +using DistroFlow.IntegrationTests.Infrastructure; +using Microsoft.AspNetCore.Identity; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; + +namespace DistroFlow.IntegrationTests.Identity; + +public class IdentitySetupTests : IClassFixture +{ + private readonly ApiIntegrationTestFactory _factory; + + public IdentitySetupTests(ApiIntegrationTestFactory factory) + { + _factory = factory; + } + + [Fact] + public async Task Migrations_seed_every_employee_role() + { + await using var scope = _factory.Services.CreateAsyncScope(); + var roleManager = scope.ServiceProvider.GetRequiredService>>(); + + var roleNames = await roleManager.Roles.Select(r => r.Name).ToListAsync(); + + Assert.Equal(EmployeeRoles.All.Order(), roleNames.Order()); + } + + [Fact] + public async Task UserManager_creates_user_with_hashed_password_and_assigns_role() + { + await using var scope = _factory.Services.CreateAsyncScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var userManager = scope.ServiceProvider.GetRequiredService>(); + + var tenant = new Tenant { Name = "Identity Setup Distributors" }; + db.Tenants.Add(tenant); + await db.SaveChangesAsync(); + + var user = new ApplicationUser + { + TenantId = tenant.Id, + Email = $"owner-{Guid.NewGuid():N}@example.com" + }; + user.UserName = user.Id.ToString(); + + var created = await userManager.CreateAsync(user, "Str0ng!Passw0rd"); + Assert.True(created.Succeeded, string.Join(", ", created.Errors.Select(e => e.Description))); + + var assigned = await userManager.AddToRoleAsync(user, EmployeeRoles.Administrator); + Assert.True(assigned.Succeeded); + + Assert.NotEqual("Str0ng!Passw0rd", user.PasswordHash); + Assert.True(await userManager.CheckPasswordAsync(user, "Str0ng!Passw0rd")); + Assert.True(await userManager.IsInRoleAsync(user, EmployeeRoles.Administrator)); + } + + [Fact] + public async Task Password_shorter_than_eight_characters_is_rejected() + { + await using var scope = _factory.Services.CreateAsyncScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var userManager = scope.ServiceProvider.GetRequiredService>(); + + var tenant = new Tenant { Name = "Weak Password Distributors" }; + db.Tenants.Add(tenant); + await db.SaveChangesAsync(); + + var user = new ApplicationUser { TenantId = tenant.Id, Email = $"weak-{Guid.NewGuid():N}@example.com" }; + user.UserName = user.Id.ToString(); + + var result = await userManager.CreateAsync(user, "Ab1!"); + + Assert.False(result.Succeeded); + Assert.Contains(result.Errors, e => e.Code == "PasswordTooShort"); + } +} diff --git a/tests/DistroFlow.IntegrationTests/Infrastructure/ApiIntegrationTestFactory.cs b/tests/DistroFlow.IntegrationTests/Infrastructure/ApiIntegrationTestFactory.cs index e78c5a6..41f2bd4 100644 --- a/tests/DistroFlow.IntegrationTests/Infrastructure/ApiIntegrationTestFactory.cs +++ b/tests/DistroFlow.IntegrationTests/Infrastructure/ApiIntegrationTestFactory.cs @@ -35,8 +35,13 @@ public async Task InitializeAsync() await dbContext.Database.MigrateAsync(); } + /// Test-only key; real keys come from user-secrets or environment variables. + public const string JwtSigningKey = "integration-tests-signing-key-not-a-real-secret-0123456789"; + protected override void ConfigureWebHost(IWebHostBuilder builder) { + builder.UseSetting("Jwt:SigningKey", JwtSigningKey); + builder.ConfigureServices(services => { services.RemoveAll>(); diff --git a/tests/DistroFlow.IntegrationTests/Infrastructure/Controllers/TenantsControllerTests.cs b/tests/DistroFlow.IntegrationTests/Infrastructure/Controllers/TenantsControllerTests.cs deleted file mode 100644 index f2486c6..0000000 --- a/tests/DistroFlow.IntegrationTests/Infrastructure/Controllers/TenantsControllerTests.cs +++ /dev/null @@ -1,415 +0,0 @@ - -using System.Net; -using DistroFlow.Infrastructure.Persistence; -using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.DependencyInjection; -using System.Net.Http.Json; -using System.Text.Json; -using DistroFlow.IntegrationTests.Infrastructure; - -namespace DistroFlow.IntegrationTests.Controllers; - -public class TenantsControllerTests - : IClassFixture -{ - private readonly HttpClient _client; - - private readonly ApiIntegrationTestFactory _factory; - - public TenantsControllerTests(ApiIntegrationTestFactory factory) - { - _factory = factory; - _client = factory.CreateClient(); - } - - - [Fact] - public async Task CreateTenant_WithValidRequest_PersistsTenantInDatabase() - { - // Arrange - var request = new - { - name = "Persistence Test Distributors", - email = "persistence@test.com", - currencyCode = "INR", - invoicePrefix = "PST", - fiscalYearStartMonth = 4, - timeZoneId = "Asia/Kolkata" - }; - - // Act - var response = await _client.PostAsJsonAsync( - "/api/v1/tenants", - request); - - // Assert - Assert.Equal(HttpStatusCode.Created, response.StatusCode); - - var body = await response.Content.ReadFromJsonAsync(); - - var tenantId = body - .GetProperty("data") - .GetProperty("id") - .GetGuid(); - - // Verify persistence using EF Core - using var scope = _factory.Services.CreateScope(); - - var dbContext = scope.ServiceProvider - .GetRequiredService(); - - var savedTenant = await dbContext.Tenants - .AsNoTracking() - .SingleOrDefaultAsync(t => t.Id == tenantId); - - Assert.NotNull(savedTenant); - - Assert.Equal( - "Persistence Test Distributors", - savedTenant.Name - ); - - Assert.Equal("INR", savedTenant.CurrencyCode); - } - - - [Fact] - public async Task GetAllTenants_ReturnsTenantList() - { - // Arrange - var request = new - { - name = "GetAll Test Distributors", - email = "getall@test.com", - currencyCode = "INR", - invoicePrefix = "GET", - fiscalYearStartMonth = 4, - timeZoneId = "Asia/Kolkata" - }; - - await _client.PostAsJsonAsync("/api/v1/tenants", request); - - // Act - var response = await _client.GetAsync("/api/v1/tenants"); - - // Assert - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - - var body = await response.Content.ReadFromJsonAsync(); - - Assert.True(body.GetProperty("success").GetBoolean()); - - var tenants = body.GetProperty("data"); - - Assert.Equal(JsonValueKind.Array, tenants.ValueKind); - - Assert.Contains( - tenants.EnumerateArray(), - tenant => tenant.GetProperty("name").GetString() - == "GetAll Test Distributors" - ); - } - - [Fact] - public async Task GetTenantById_WithExistingId_ReturnsTenant() - { - // Arrange - var request = new - { - name = "GetById Test Distributors", - email = "getbyid@test.com", - currencyCode = "INR", - invoicePrefix = "GID", - fiscalYearStartMonth = 4, - timeZoneId = "Asia/Kolkata" - }; - - var createResponse = await _client.PostAsJsonAsync( - "/api/v1/tenants", - request); - - var createdBody = await createResponse.Content - .ReadFromJsonAsync(); - - var tenantId = createdBody - .GetProperty("data") - .GetProperty("id") - .GetGuid(); - - // Act - var response = await _client.GetAsync( - $"/api/v1/tenants/{tenantId}"); - - // Assert - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - - var body = await response.Content.ReadFromJsonAsync(); - - Assert.True(body.GetProperty("success").GetBoolean()); - - var tenant = body.GetProperty("data"); - - Assert.Equal(tenantId, tenant.GetProperty("id").GetGuid()); - - Assert.Equal( - "GetById Test Distributors", - tenant.GetProperty("name").GetString() - ); - } - - [Fact] - public async Task GetTenantById_WithNonExistingId_ReturnsNotFound() - { - // Arrange - var nonExistingId = Guid.CreateVersion7(); - - // Act - var response = await _client.GetAsync( - $"/api/v1/tenants/{nonExistingId}"); - - // Assert - Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); - - var body = await response.Content.ReadFromJsonAsync(); - - Assert.False(body.GetProperty("success").GetBoolean()); - - Assert.Equal( - "Tenant not found.", - body.GetProperty("message").GetString() - ); - - Assert.Equal( - JsonValueKind.Null, - body.GetProperty("data").ValueKind - ); - } - - [Fact] - public async Task UpdateTenant_WithExistingId_UpdatesTenant() - { - // Arrange - var createRequest = new - { - name = "Original Distributor", - email = "original@test.com", - currencyCode = "INR", - invoicePrefix = "ORG", - fiscalYearStartMonth = 4, - timeZoneId = "Asia/Kolkata" - }; - - var createResponse = await _client.PostAsJsonAsync( - "/api/v1/tenants", - createRequest); - - var createdBody = await createResponse.Content - .ReadFromJsonAsync(); - - var tenantId = createdBody - .GetProperty("data") - .GetProperty("id") - .GetGuid(); - - var updateRequest = new - { - name = "Updated Distributor", - email = "updated@test.com", - phone = "+919876543210", - currencyCode = "USD", - invoicePrefix = "UPD", - fiscalYearStartMonth = 1, - timeZoneId = "Asia/Kolkata", - isActive = true - }; - - // Act - var response = await _client.PutAsJsonAsync( - $"/api/v1/tenants/{tenantId}", - updateRequest); - - // Assert - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - - var body = await response.Content.ReadFromJsonAsync(); - - Assert.True(body.GetProperty("success").GetBoolean()); - - var tenant = body.GetProperty("data"); - - Assert.Equal("Updated Distributor", tenant.GetProperty("name").GetString()); - Assert.Equal("updated@test.com", tenant.GetProperty("email").GetString()); - Assert.Equal("USD", tenant.GetProperty("currencyCode").GetString()); - Assert.Equal("UPD", tenant.GetProperty("invoicePrefix").GetString()); - Assert.Equal(1, tenant.GetProperty("fiscalYearStartMonth").GetInt32()); - Assert.True(tenant.GetProperty("isActive").GetBoolean()); - - Assert.NotNull(tenant.GetProperty("updatedAtUtc").GetString()); - } - - [Fact] - public async Task UpdateTenant_WithNonExistingId_ReturnsNotFound() - { - // Arrange - var nonExistingId = Guid.CreateVersion7(); - - var updateRequest = new - { - name = "Updated Distributor", - email = "updated@test.com", - phone = "+919876543210", - currencyCode = "USD", - invoicePrefix = "UPD", - fiscalYearStartMonth = 1, - timeZoneId = "Asia/Kolkata", - isActive = true - }; - - // Act - var response = await _client.PutAsJsonAsync( - $"/api/v1/tenants/{nonExistingId}", - updateRequest); - - // Assert - Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); - - var body = await response.Content.ReadFromJsonAsync(); - - Assert.False(body.GetProperty("success").GetBoolean()); - - Assert.Equal( - "Tenant not found.", - body.GetProperty("message").GetString() - ); - - Assert.Equal( - JsonValueKind.Null, - body.GetProperty("data").ValueKind - ); - } - [Fact] - public async Task DeactivateTenant_WithExistingId_DeactivatesTenant() - { - // Arrange - var createRequest = new - { - name = "Deactivate Test Distributor", - email = "deactivate@test.com", - currencyCode = "INR", - invoicePrefix = "DEC", - fiscalYearStartMonth = 4, - timeZoneId = "Asia/Kolkata" - }; - - var createResponse = await _client.PostAsJsonAsync( - "/api/v1/tenants", - createRequest); - - var createdBody = await createResponse.Content - .ReadFromJsonAsync(); - - var tenantId = createdBody - .GetProperty("data") - .GetProperty("id") - .GetGuid(); - - // Act - var response = await _client.PatchAsync( - $"/api/v1/tenants/{tenantId}/deactivate", - null); - - // Assert - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - - var body = await response.Content.ReadFromJsonAsync(); - - Assert.True(body.GetProperty("success").GetBoolean()); - - // Verify database state - using var scope = _factory.Services.CreateScope(); - - var dbContext = scope.ServiceProvider - .GetRequiredService(); - - var tenant = await dbContext.Tenants - .AsNoTracking() - .SingleAsync(t => t.Id == tenantId); - - Assert.False(tenant.IsActive); - Assert.NotNull(tenant.UpdatedAtUtc); - } - [Fact] - public async Task DeactivateTenant_WhenAlreadyInactive_ReturnsSuccess() - { - // Arrange - var createRequest = new - { - name = "Already Inactive Distributor", - email = "inactive@test.com", - currencyCode = "INR", - invoicePrefix = "INA", - fiscalYearStartMonth = 4, - timeZoneId = "Asia/Kolkata" - }; - - var createResponse = await _client.PostAsJsonAsync( - "/api/v1/tenants", - createRequest); - - var createdBody = await createResponse.Content - .ReadFromJsonAsync(); - - var tenantId = createdBody - .GetProperty("data") - .GetProperty("id") - .GetGuid(); - - // First deactivation - var firstResponse = await _client.PatchAsync( - $"/api/v1/tenants/{tenantId}/deactivate", - null); - - Assert.Equal(HttpStatusCode.OK, firstResponse.StatusCode); - - // Act: deactivate again - var secondResponse = await _client.PatchAsync( - $"/api/v1/tenants/{tenantId}/deactivate", - null); - - // Assert - Assert.Equal(HttpStatusCode.OK, secondResponse.StatusCode); - - var body = await secondResponse.Content - .ReadFromJsonAsync(); - - Assert.True(body.GetProperty("success").GetBoolean()); - } - [Fact] - public async Task DeactivateTenant_WithNonExistingId_ReturnsNotFound() - { - // Arrange - var nonExistingId = Guid.CreateVersion7(); - - // Act - var response = await _client.PatchAsync( - $"/api/v1/tenants/{nonExistingId}/deactivate", - null); - - // Assert - Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); - - var body = await response.Content.ReadFromJsonAsync(); - - Assert.False(body.GetProperty("success").GetBoolean()); - - Assert.Equal( - "Tenant not found.", - body.GetProperty("message").GetString() - ); - - Assert.Equal( - JsonValueKind.Null, - body.GetProperty("data").ValueKind - ); - } -} diff --git a/tests/DistroFlow.IntegrationTests/Infrastructure/TestAuth.cs b/tests/DistroFlow.IntegrationTests/Infrastructure/TestAuth.cs new file mode 100644 index 0000000..1a8fc6b --- /dev/null +++ b/tests/DistroFlow.IntegrationTests/Infrastructure/TestAuth.cs @@ -0,0 +1,74 @@ +using System.Net.Http.Headers; +using System.Net.Http.Json; +using System.Text.Json; +using DistroFlow.Application.Interfaces.Identity; +using DistroFlow.Infrastructure.Identity; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; + +namespace DistroFlow.IntegrationTests.Infrastructure; + +public sealed record RegisteredDistributor( + Guid TenantId, + Guid EmployeeId, + Guid UserId, + string BusinessName, + string Email, + string AccessToken); + +/// Helpers for getting authenticated clients in integration tests. +public static class TestAuth +{ + public const string Password = "Str0ng!Passw0rd"; + + /// Registers a new distributor through the real API and logs its owner in. + public static async Task RegisterAndLoginAsync(HttpClient client) + { + var businessName = $"Distributor {Guid.NewGuid():N}"; + var email = $"owner-{Guid.NewGuid():N}@example.com"; + + var register = await client.PostAsJsonAsync("/api/v1/auth/register", new + { + businessName, + timeZoneId = "Asia/Kolkata", + ownerFullName = "Ravi Sharma", + email, + password = Password + }); + register.EnsureSuccessStatusCode(); + var data = (await register.Content.ReadFromJsonAsync()).GetProperty("data"); + + var login = await client.PostAsJsonAsync("/api/v1/auth/login", new { email, password = Password }); + login.EnsureSuccessStatusCode(); + var token = (await login.Content.ReadFromJsonAsync()) + .GetProperty("data").GetProperty("accessToken").GetString()!; + + return new RegisteredDistributor( + data.GetProperty("tenantId").GetGuid(), + data.GetProperty("employeeId").GetGuid(), + data.GetProperty("userId").GetGuid(), + businessName, + email, + token); + } + + /// + /// Issues a valid token for a tenant with chosen roles, using the app's real signing settings. + /// Lets tests act as e.g. Sales Staff before employee management exists. + /// + public static string IssueToken(ApiIntegrationTestFactory factory, Guid tenantId, params string[] roles) + { + var options = factory.Services.GetRequiredService>(); + + return new JwtTokenService(options, TimeProvider.System) + .CreateAccessToken(new TokenSubject(Guid.CreateVersion7(), Guid.CreateVersion7(), tenantId, roles)) + .Token; + } + + public static HttpClient CreateClientWithToken(ApiIntegrationTestFactory factory, string token) + { + var client = factory.CreateClient(); + client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token); + return client; + } +} diff --git a/tests/DistroFlow.IntegrationTests/Persistence/ActiveEmailUniquenessTests.cs b/tests/DistroFlow.IntegrationTests/Persistence/ActiveEmailUniquenessTests.cs new file mode 100644 index 0000000..62609d7 --- /dev/null +++ b/tests/DistroFlow.IntegrationTests/Persistence/ActiveEmailUniquenessTests.cs @@ -0,0 +1,110 @@ +using DistroFlow.Domain.Entities; +using DistroFlow.Infrastructure.Identity; +using DistroFlow.Infrastructure.Persistence; +using DistroFlow.IntegrationTests.Infrastructure; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Npgsql; + +namespace DistroFlow.IntegrationTests.Persistence; + +/// +/// Proves the database (not just application code) enforces: +/// an email belongs to at most one active login account at a time. +/// +public class ActiveEmailUniquenessTests : IClassFixture +{ + private readonly ApiIntegrationTestFactory _factory; + + public ActiveEmailUniquenessTests(ApiIntegrationTestFactory factory) + { + _factory = factory; + } + + [Fact] + public async Task Two_active_accounts_with_same_email_in_different_tenants_are_rejected() + { + var email = UniqueEmail(); + var tenantA = await CreateTenantAsync("Distributor A"); + var tenantB = await CreateTenantAsync("Distributor B"); + + await AddUserAsync(tenantA.Id, email, isActive: true); + + var ex = await Assert.ThrowsAsync( + () => AddUserAsync(tenantB.Id, email, isActive: true)); + + var pg = Assert.IsType(ex.InnerException); + Assert.Equal(PostgresErrorCodes.UniqueViolation, pg.SqlState); + Assert.Equal("EmailIndex", pg.ConstraintName); + } + + [Fact] + public async Task Email_can_be_reused_once_previous_account_is_deactivated() + { + var email = UniqueEmail(); + var oldEmployer = await CreateTenantAsync("Old Employer"); + var newEmployer = await CreateTenantAsync("New Employer"); + + await AddUserAsync(oldEmployer.Id, email, isActive: false); + + await AddUserAsync(newEmployer.Id, email, isActive: true); + + await using var scope = _factory.Services.CreateAsyncScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var count = await db.Users.CountAsync(u => u.NormalizedEmail == email.ToUpperInvariant()); + Assert.Equal(2, count); + } + + [Fact] + public async Task Reactivating_old_account_fails_while_email_is_active_elsewhere() + { + var email = UniqueEmail(); + var oldEmployer = await CreateTenantAsync("Old Employer"); + var newEmployer = await CreateTenantAsync("New Employer"); + + var oldAccount = await AddUserAsync(oldEmployer.Id, email, isActive: false); + await AddUserAsync(newEmployer.Id, email, isActive: true); + + await using var scope = _factory.Services.CreateAsyncScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var user = await db.Users.SingleAsync(u => u.Id == oldAccount.Id); + user.IsActive = true; + + var ex = await Assert.ThrowsAsync(() => db.SaveChangesAsync()); + var pg = Assert.IsType(ex.InnerException); + Assert.Equal(PostgresErrorCodes.UniqueViolation, pg.SqlState); + } + + private static string UniqueEmail() => $"user-{Guid.NewGuid():N}@example.com"; + + private async Task CreateTenantAsync(string name) + { + await using var scope = _factory.Services.CreateAsyncScope(); + var db = scope.ServiceProvider.GetRequiredService(); + + var tenant = new Tenant { Name = name }; + db.Tenants.Add(tenant); + await db.SaveChangesAsync(); + return tenant; + } + + private async Task AddUserAsync(Guid tenantId, string email, bool isActive) + { + await using var scope = _factory.Services.CreateAsyncScope(); + var db = scope.ServiceProvider.GetRequiredService(); + + var user = new ApplicationUser + { + TenantId = tenantId, + Email = email, + NormalizedEmail = email.ToUpperInvariant(), + IsActive = isActive + }; + user.UserName = user.Id.ToString(); + user.NormalizedUserName = user.UserName.ToUpperInvariant(); + + db.Users.Add(user); + await db.SaveChangesAsync(); + return user; + } +} diff --git a/tests/DistroFlow.IntegrationTests/Persistence/TenantIsolationTests.cs b/tests/DistroFlow.IntegrationTests/Persistence/TenantIsolationTests.cs new file mode 100644 index 0000000..03d4dce --- /dev/null +++ b/tests/DistroFlow.IntegrationTests/Persistence/TenantIsolationTests.cs @@ -0,0 +1,114 @@ +using System.Net.Http.Json; +using System.Text.Json; +using DistroFlow.Application.Interfaces.Identity; +using DistroFlow.Infrastructure.Identity; +using DistroFlow.Infrastructure.Persistence; +using DistroFlow.IntegrationTests.Infrastructure; +using Microsoft.EntityFrameworkCore; + +namespace DistroFlow.IntegrationTests.Persistence; + +/// +/// Proves the global tenant filter: a distributor can never read another distributor's employees. +/// +public class TenantIsolationTests : IClassFixture +{ + private readonly ApiIntegrationTestFactory _factory; + private readonly HttpClient _client; + + public TenantIsolationTests(ApiIntegrationTestFactory factory) + { + _factory = factory; + _client = factory.CreateClient(); + } + + [Fact] + public async Task Tenant_sees_only_its_own_employees() + { + var a = await RegisterDistributorAsync(); + var b = await RegisterDistributorAsync(); + + await using var dbAsA = CreateDbContext(a.TenantId); + var visible = await dbAsA.Employees.ToListAsync(); + + Assert.Contains(visible, e => e.Id == a.EmployeeId); + Assert.DoesNotContain(visible, e => e.Id == b.EmployeeId); + Assert.All(visible, e => Assert.Equal(a.TenantId, e.TenantId)); + } + + [Fact] + public async Task Querying_another_tenants_employee_by_id_returns_nothing() + { + var a = await RegisterDistributorAsync(); + var b = await RegisterDistributorAsync(); + + await using var dbAsA = CreateDbContext(a.TenantId); + var employeeOfB = await dbAsA.Employees.SingleOrDefaultAsync(e => e.Id == b.EmployeeId); + + // Even knowing the exact id, A cannot load B's employee. + Assert.Null(employeeOfB); + } + + [Fact] + public async Task Without_a_tenant_no_employees_are_visible() + { + var a = await RegisterDistributorAsync(); + + await using var anonymous = CreateDbContext(tenantId: null); + + // Fail closed: an unknown tenant sees nothing, never everything. + Assert.False(await anonymous.Employees.AnyAsync(e => e.Id == a.EmployeeId)); + } + + [Fact] + public void Every_entity_with_TenantId_has_a_query_filter() + { + // Safety net: when a new tenant-owned entity (Order, Product...) is added, + // this fails until it gets a tenant filter. + var documentedExceptions = new[] { typeof(ApplicationUser) }; // see CLAUDE.md, Tenancy + + using var db = CreateDbContext(tenantId: null); + + var tenantOwned = db.Model.GetEntityTypes() + .Where(t => t.FindProperty("TenantId") is not null) + .Where(t => !documentedExceptions.Contains(t.ClrType)) + .ToList(); + + Assert.NotEmpty(tenantOwned); + Assert.All(tenantOwned, entityType => + Assert.True( + entityType.GetDeclaredQueryFilters().Count > 0, + $"{entityType.ClrType.Name} has a TenantId but no tenant query filter.")); + } + + private AppDbContext CreateDbContext(Guid? tenantId) + { + var options = new DbContextOptionsBuilder() + .UseNpgsql(_factory.ConnectionString) + .Options; + + return new AppDbContext(options, new FixedCurrentTenant(tenantId)); + } + + private async Task<(Guid TenantId, Guid EmployeeId)> RegisterDistributorAsync() + { + var response = await _client.PostAsJsonAsync("/api/v1/auth/register", new + { + businessName = $"Distributor {Guid.NewGuid():N}", + timeZoneId = "Asia/Kolkata", + ownerFullName = "Ravi Sharma", + email = $"iso-{Guid.NewGuid():N}@example.com", + password = "Str0ng!Passw0rd" + }); + response.EnsureSuccessStatusCode(); + + var data = (await response.Content.ReadFromJsonAsync()).GetProperty("data"); + return (data.GetProperty("tenantId").GetGuid(), data.GetProperty("employeeId").GetGuid()); + } + + /// Stands in for "the logged-in user's tenant" without needing a real request. + private sealed class FixedCurrentTenant(Guid? tenantId) : ICurrentTenant + { + public Guid? TenantId { get; } = tenantId; + } +} diff --git a/tests/DistroFlow.IntegrationTests/UnitTest1.cs b/tests/DistroFlow.IntegrationTests/UnitTest1.cs deleted file mode 100644 index f80526a..0000000 --- a/tests/DistroFlow.IntegrationTests/UnitTest1.cs +++ /dev/null @@ -1,10 +0,0 @@ -namespace DistroFlow.IntegrationTests; - -public class UnitTest1 -{ - [Fact] - public void Test1() - { - - } -} diff --git a/tests/DistroFlow.UnitTests/DistroFlow.UnitTests.csproj b/tests/DistroFlow.UnitTests/DistroFlow.UnitTests.csproj index db296aa..72bc6cf 100644 --- a/tests/DistroFlow.UnitTests/DistroFlow.UnitTests.csproj +++ b/tests/DistroFlow.UnitTests/DistroFlow.UnitTests.csproj @@ -18,4 +18,10 @@ + + + + + + \ No newline at end of file diff --git a/tests/DistroFlow.UnitTests/Domain/EmployeeTests.cs b/tests/DistroFlow.UnitTests/Domain/EmployeeTests.cs new file mode 100644 index 0000000..9efc068 --- /dev/null +++ b/tests/DistroFlow.UnitTests/Domain/EmployeeTests.cs @@ -0,0 +1,92 @@ +using DistroFlow.Domain.Entities; + +namespace DistroFlow.UnitTests.Domain; + +public class EmployeeTests +{ + private static readonly DateTime Now = new(2026, 11, 6, 10, 0, 0, DateTimeKind.Utc); + + private static Employee CreateEmployee() => new() + { + TenantId = Guid.NewGuid(), + UserId = Guid.NewGuid(), + EmployeeCode = "EMP-001", + FullName = "Ravi Kumar", + Phone = "+919876543210" + }; + + [Fact] + public void New_employee_is_active() + { + var employee = CreateEmployee(); + + Assert.Equal(EmployeeStatus.Active, employee.Status); + Assert.True(employee.IsActive); + Assert.Null(employee.DeactivatedAtUtc); + } + + [Fact] + public void Deactivate_keeps_personal_data_and_records_time() + { + var employee = CreateEmployee(); + + employee.Deactivate(Now); + + Assert.Equal(EmployeeStatus.Deactivated, employee.Status); + Assert.Equal(Now, employee.DeactivatedAtUtc); + Assert.Equal("Ravi Kumar", employee.FullName); + Assert.NotNull(employee.UserId); + } + + [Fact] + public void Reactivate_clears_deactivation() + { + var employee = CreateEmployee(); + employee.Deactivate(Now); + + employee.Reactivate(Now.AddDays(1)); + + Assert.True(employee.IsActive); + Assert.Null(employee.DeactivatedAtUtc); + } + + [Fact] + public void Anonymise_removes_all_personal_data_but_keeps_identity_of_row() + { + var employee = CreateEmployee(); + var id = employee.Id; + var tenantId = employee.TenantId; + + employee.Anonymise(Now); + + Assert.Equal(EmployeeStatus.Anonymised, employee.Status); + Assert.Equal(Employee.AnonymisedName, employee.FullName); + Assert.Null(employee.Phone); + Assert.Null(employee.EmployeeCode); + Assert.Null(employee.UserId); + Assert.Equal(Now, employee.AnonymisedAtUtc); + Assert.Equal(Now, employee.DeactivatedAtUtc); + Assert.Equal(id, employee.Id); + Assert.Equal(tenantId, employee.TenantId); + } + + [Fact] + public void Anonymise_keeps_original_deactivation_time() + { + var employee = CreateEmployee(); + employee.Deactivate(Now); + + employee.Anonymise(Now.AddYears(1)); + + Assert.Equal(Now, employee.DeactivatedAtUtc); + } + + [Fact] + public void Anonymised_employee_cannot_be_reactivated() + { + var employee = CreateEmployee(); + employee.Anonymise(Now); + + Assert.Throws(() => employee.Reactivate(Now)); + } +} diff --git a/tests/DistroFlow.UnitTests/Identity/HttpCurrentTenantTests.cs b/tests/DistroFlow.UnitTests/Identity/HttpCurrentTenantTests.cs new file mode 100644 index 0000000..b470d9b --- /dev/null +++ b/tests/DistroFlow.UnitTests/Identity/HttpCurrentTenantTests.cs @@ -0,0 +1,54 @@ +using System.Security.Claims; +using DistroFlow.Api.Services; +using DistroFlow.Application.Common; +using Microsoft.AspNetCore.Http; + +namespace DistroFlow.UnitTests.Identity; + +public class HttpCurrentTenantTests +{ + [Fact] + public void Returns_tenant_id_from_token_claim() + { + var tenantId = Guid.CreateVersion7(); + var currentTenant = CreateWithClaims(new Claim(AppClaimTypes.TenantId, tenantId.ToString())); + + Assert.Equal(tenantId, currentTenant.TenantId); + } + + [Fact] + public void Returns_null_when_there_is_no_request() + { + // e.g. code running outside an HTTP request (background job, startup). + var currentTenant = new HttpCurrentTenant(new HttpContextAccessor { HttpContext = null }); + + Assert.Null(currentTenant.TenantId); + } + + [Fact] + public void Returns_null_for_anonymous_request() + { + // e.g. POST /auth/login or /auth/register: no token, no claims. + var currentTenant = CreateWithClaims(); + + Assert.Null(currentTenant.TenantId); + } + + [Fact] + public void Returns_null_when_claim_is_not_a_guid() + { + var currentTenant = CreateWithClaims(new Claim(AppClaimTypes.TenantId, "not-a-guid")); + + Assert.Null(currentTenant.TenantId); + } + + private static HttpCurrentTenant CreateWithClaims(params Claim[] claims) + { + var httpContext = new DefaultHttpContext + { + User = new ClaimsPrincipal(new ClaimsIdentity(claims)) + }; + + return new HttpCurrentTenant(new HttpContextAccessor { HttpContext = httpContext }); + } +} diff --git a/tests/DistroFlow.UnitTests/Identity/JwtTokenServiceTests.cs b/tests/DistroFlow.UnitTests/Identity/JwtTokenServiceTests.cs new file mode 100644 index 0000000..f4b2b96 --- /dev/null +++ b/tests/DistroFlow.UnitTests/Identity/JwtTokenServiceTests.cs @@ -0,0 +1,100 @@ +using System.Text; +using DistroFlow.Application.Common; +using DistroFlow.Application.Interfaces.Identity; +using DistroFlow.Domain.Constants; +using DistroFlow.Infrastructure.Identity; +using Microsoft.Extensions.Options; +using Microsoft.IdentityModel.JsonWebTokens; +using Microsoft.IdentityModel.Tokens; + +namespace DistroFlow.UnitTests.Identity; + +public class JwtTokenServiceTests +{ + private static readonly DateTimeOffset Now = new(2026, 11, 6, 10, 0, 0, TimeSpan.Zero); + + private static readonly JwtOptions Options = new() + { + Issuer = "DistroFlow.Api", + Audience = "DistroFlow.Web", + SigningKey = "unit-tests-signing-key-0123456789-abcdefghij", + AccessTokenMinutes = 30 + }; + + private static readonly TokenSubject Subject = new( + UserId: Guid.CreateVersion7(), + EmployeeId: Guid.CreateVersion7(), + TenantId: Guid.CreateVersion7(), + Roles: [EmployeeRoles.Administrator, EmployeeRoles.Approver]); + + private static JwtTokenService CreateService() => + new(Microsoft.Extensions.Options.Options.Create(Options), new FixedTimeProvider(Now)); + + [Fact] + public void Token_contains_user_employee_tenant_and_all_roles() + { + var token = new JsonWebToken(CreateService().CreateAccessToken(Subject).Token); + + Assert.Equal(Subject.UserId.ToString(), token.GetClaim(AppClaimTypes.UserId).Value); + Assert.Equal(Subject.EmployeeId.ToString(), token.GetClaim(AppClaimTypes.EmployeeId).Value); + Assert.Equal(Subject.TenantId.ToString(), token.GetClaim(AppClaimTypes.TenantId).Value); + + var roles = token.Claims.Where(c => c.Type == AppClaimTypes.Role).Select(c => c.Value); + Assert.Equal(Subject.Roles.Order(), roles.Order()); + + Assert.False(string.IsNullOrEmpty(token.Id)); + Assert.Equal(Options.Issuer, token.Issuer); + Assert.Contains(Options.Audience, token.Audiences); + } + + [Fact] + public void Token_expires_after_configured_minutes() + { + var accessToken = CreateService().CreateAccessToken(Subject); + var token = new JsonWebToken(accessToken.Token); + + var expected = Now.UtcDateTime.AddMinutes(30); + Assert.Equal(expected, accessToken.ExpiresAtUtc); + Assert.Equal(expected, token.ValidTo); + Assert.Equal(Now.UtcDateTime, token.IssuedAt); + } + + [Fact] + public void Each_token_has_a_unique_id() + { + var service = CreateService(); + + var first = new JsonWebToken(service.CreateAccessToken(Subject).Token); + var second = new JsonWebToken(service.CreateAccessToken(Subject).Token); + + Assert.NotEqual(first.Id, second.Id); + } + + [Fact] + public async Task Token_is_signed_with_the_configured_key() + { + var token = CreateService().CreateAccessToken(Subject).Token; + var handler = new JsonWebTokenHandler(); + + var withRightKey = await handler.ValidateTokenAsync(token, ValidationParameters(Options.SigningKey)); + var withWrongKey = await handler.ValidateTokenAsync(token, ValidationParameters("some-other-key-0123456789-abcdefghijklmn")); + + Assert.True(withRightKey.IsValid, withRightKey.Exception?.Message); + Assert.False(withWrongKey.IsValid); + Assert.IsType(withWrongKey.Exception); + } + + private static TokenValidationParameters ValidationParameters(string key) => new() + { + ValidIssuer = Options.Issuer, + ValidAudience = Options.Audience, + IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(key)), + // Issued at a fixed past time; lifetime is covered by Token_expires_after_configured_minutes. + ValidateLifetime = false + }; + + private sealed class FixedTimeProvider(DateTimeOffset now) : TimeProvider + { + public override DateTimeOffset GetUtcNow() => now; + } +} diff --git a/tests/DistroFlow.UnitTests/UnitTest1.cs b/tests/DistroFlow.UnitTests/UnitTest1.cs deleted file mode 100644 index ffddb5a..0000000 --- a/tests/DistroFlow.UnitTests/UnitTest1.cs +++ /dev/null @@ -1,10 +0,0 @@ -namespace DistroFlow.UnitTests; - -public class UnitTest1 -{ - [Fact] - public void Test1() - { - - } -}