diff --git a/.claude/skills/seed-tenant/SKILL.md b/.claude/skills/seed-tenant/SKILL.md index 08425278..e109d995 100644 --- a/.claude/skills/seed-tenant/SKILL.md +++ b/.claude/skills/seed-tenant/SKILL.md @@ -1,369 +1,194 @@ --- name: seed-tenant description: > - Provision a tenant for local development or the request-level isolation suite — - the `tenants` row, its organizations, locales, settings, feature flags, domain, - and its `platform_host_to_tenant` mapping. USE FOR: bringing up a demo tenant, - adding a second tenant for cross-tenant isolation testing, reseeding after a - tenancy schema change. Seeding the two shipped customization aggregates is part - of it as of Packet 8: `SeedRunner` registers and publishes the built-in - `card` content type and `plain` taxonomy through the module's own commands. DO - NOT USE FOR: production tenant provisioning (operator action via Hub), - Self-Hosted license issuance (Hub-side), the customization aggregates that have - no schema yet or course content (later phases own those — see § What a later - phase adds), or domain-specific code (forbidden by ADR-0018 — everything is - data). + Provision and converge LearnStack's local demo tenants through the real request + pipeline. USE FOR: first-run seed, reruns after migrations, request-level isolation + fixtures and data-only showcases. P02d-2 includes enabled locales, built-in and + tenant-specific definitions, complete branding and scoped Course/Lesson content. + DO NOT USE FOR: production provisioning, production reseeding, Hub licensing, + identity setup or domain-specific application branches. --- # Seeding a tenant -## Purpose +## Purpose and current scope -Stand up a tenant + its organizations + its host mapping, all as **data**, so: +The [P02d-2 accepted package](../../../docs/roadmap/phase-02d-walking-skeleton.md#p02d-2-decision-package-2026-10-02) +records the decisions. Step 4 delivers the complete inventory and contextual +verification; both implementation review rounds and final verification passed. +PR review/merge remains pending. Public API reads and browser rendering remain +P02d-4 and P02d-5–7. Do not claim a rendered demo from seed alone. -- Local dev has two hosts that resolve to two different tenants. -- The [Packet 7](../../../docs/roadmap/phase-02a-kernel-tenancy.md) request-level - isolation suite has a deterministic two-tenant fixture. -- Two tenants in unrelated domains exist from Packet 7 onward and render side by - side from [Phase 02d](../../../docs/roadmap/phase-02d-walking-skeleton.md), so - genericity is proven **continuously**, by construction. - -[Phase 10](../../../docs/roadmap/phase-10-english-learning-mvp.md) is **not** the -genericity proof and disclaims the attribution itself: it is the depth showcase — -the first place one tenant fills all eight customization aggregates at once. +[SeedData](../../../backend/src/LearnStack.Tools.Seeder/SeedData.cs) owns all demo +identities, schemas, labels, palettes, bodies and computed inventory. The runner +has no English/Yoga branch. Adding a pure content shape changes data, not a module; +stateful entitlement and external capabilities remain the +[genericity boundary](../../../docs/architecture/01-platform-vision.md#genericity-boundary). ## When to use -- Local-dev first-run seed. -- Adding a parallel tenant for the cross-tenant isolation tests. -- Reseeding after a schema change to the tenancy aggregates. -- Authoring a new "domain showcase" tenant (music school, dance studio). - -## When not to use +- Local development first run or safe repeat. +- Request-level isolation tests using the shipped data declaration. +- A new data-only showcase whose required aggregates already exist. -- Production tenant create. That's an operator action from the Hub portal - (`operator-portal`) via `POST /api/internal/tenants`. -- Self-Hosted license issuance. Hub-side (Phase 02c / 09b). -- Reseeding production data. Never. +Production provisioning is a Hub operator action; licensing belongs to the Hub. +This skill never resets or reseeds production, creates a platform administrator, +or supplies enrollment, learner identity, commerce or live-session data. ## Inputs -| Input | Required | Description | -|-------|----------|-------------| -| Tenant id | Yes | Assigned by the registry that owns the tenant — the Hub in SaaS / Dedicated, configuration in Self-Hosted, the seeder here. `Tenant.Create` never mints one; its policy keys on `id`, so a factory-minted id could not satisfy its own `WITH CHECK`. | -| Tenant slug | Yes | URL-safe, ≤ 63 chars, unique across `tenants`: `demo-english`, `demo-yoga`. | -| Tenant name | Yes | Human-readable, ≤ 200 chars: "English Hero", "Anatolia Yoga". | -| Domain showcase | Yes | The "shape" the tenant demonstrates — drives the customization-data set, once the aggregates that hold it exist. | -| Organization slugs | Yes | Two per tenant; the first becomes `tenants.default_organization_id`. | -| Locale set | Yes | At least one, with **exactly one** `is_default`. | -| Host | Yes | One `platform_host_to_tenant` row per tenant, with or without `organization_id`. | +The data set is the input; there is no `--tenants` flag. The production entry point +uses `SeedData.All`. `SeedRunner.RunAsync` accepts alternate declarations for tests. +Connection configuration arrives in the environment, not command-line arguments. +Both `scripts/seed.sh` and the direct tool refuse a role other than `learnstack_app`. +The shared application data-source guard also refuses direct or transitive access +to a BYPASSRLS/superuser role on every physical connection. ## Workflow -### Step 1: Pick the showcase - -The two seeded showcases: - -| Showcase | Slug | Display name | Host | -|----------|------|--------------|------| -| Online English school | `demo-english` | English Hero | `demo-english.learnstack.local` | -| Yoga studio | `demo-yoga` | Anatolia Yoga | `demo-yoga.learnstack.local` | - -**A coding bootcamp is not a candidate.** Its defining feature — running a -learner's submitted code — is external capability invocation, which -[Platform Vision § Genericity boundary](../../../docs/architecture/01-platform-vision.md) -puts outside the customization model. Choosing it forces either a domain-specific -runner module or a showcase that omits the one thing that made the domain -interesting; [Phase 10](../../../docs/roadmap/phase-10-english-learning-mvp.md) -records the same rejection. A yoga studio's distinctive content and taxonomy are -pure shape, so it is honest about what the model can do. - -### Step 2: Run the seed +### 1. Check the environment and migrations ```bash make seed ``` -The target brings the stack up and runs `scripts/seed.sh`, which verifies compose -health and the two Keycloak realms, then invokes the seeder: +This target depends on migration and invokes `scripts/seed.sh`, which checks compose +health and both Keycloak realms before running the .NET tool. The direct tool runs +from `backend/`, where `global.json` pins the SDK: ```bash -(cd backend && ConnectionStrings__Default="" \ +(cd backend && ConnectionStrings__Default="" \ dotnet run --project src/LearnStack.Tools.Seeder --nologo) ``` -It runs **from `backend/`** rather than from the repository root, because that is where the -SDK pin lives (`backend/global.json`). From the root no `global.json` applies and whichever -SDK is newest answers — which is not the one CI and `make migrate` use. - -**What the tenants are is data, not arguments.** The two live in `SeedData.cs`, -so there is no `--tenants` flag and nothing to keep in step between a script and -a source file. The connection string is the only input, and it arrives in the -environment rather than on `argv` because it carries a password that `ps` would show for -as long as the process runs. There is no flag for it — a caller running the tool by hand -exports the variable too. - -`scripts/seed.sh` reads it from `ConnectionStrings__Default`, falling back to -`.env` — the Makefile does not export `.env` into a recipe's environment — and -**refuses any role but `learnstack_app`**: seeding as the owner would succeed -with every policy inert and prove nothing. - -There is no platform-admin user to seed. Packet 7 creates no `users` table — -Phase 03's Identity migration owns it — and `UserId.SystemActor` is a CLR -constant with no row behind it. There is no `make seed-tenant` and no -`infra/seed/` tree. - -The seed is **idempotent** — running it twice produces the same state. - -### Step 3: What Packet 7's seed creates - -**The provisioning transaction** — `BEGIN` → `SET LOCAL app.tenant_id` to the -assigned id → `INSERT tenants` → `INSERT organizations` → `UPDATE tenants SET -default_organization_id` → `COMMIT`: - -- Row in `tenants` — id, slug, display_name, `status = Trial`. **Not `Active`**: - `Tenant.Create` produces `Trial` and `ChangeStatus` is the only way out of it, - so a seed that wants `Active` calls the transition rather than writing the - column. -- One row in `organizations` — **the default one only** — and - `AssignDefaultOrganization` pointing the tenant at it. Tenant + default - organization in one transaction is the single bounded cross-aggregate write - ([ADR-0042](../../../docs/decisions/0042-tenant-provisioning-cross-aggregate-transaction.md)), - and it is bounded by enumeration — one operation, one allow-list entry. The - seeder **invokes** `ProvisionTenantCommand` rather than writing the two roots - itself, so the allow-list stays at one entry and the seed exercises the same - path production does. - -**The follow-on writes**, each its own command in its own transaction. Packet 7 ships -two of them; the rest are what a later packet adds, and this list says which is which. - -**Shipped:** - -- The second row in `organizations`, through `CreateOrganizationCommand`. It is a third - aggregate root, and ADR-0042's exception covers the two written together above and - nothing else. -- One row in `platform_host_to_tenant`, through `MapHostToTenantCommand` — **per tenant, - not per organization**. It - is a projection rather than an aggregate, outside the rule entirely, and it does - not share the provisioning transaction. `demo-english` leaves `organization_id` - NULL (a `TenantHost`); `demo-yoga` sets it (an `OrgHost`), so both live - classification classes from - [ADR-0036](../../../docs/decisions/0036-tenant-resolution-trusted-inputs.md) are - exercised by the seed and not only by a fixture. Neither host belongs in - `Tenancy:PlatformHosts`, which lists hosts that map to **no** tenant — and -`MapHostToTenantCommand` refuses one that does, rather than writing a row the resolver -would never read. - -**Not shipped, and owned by the packet that needs them:** - -- Rows in `tenant_domains` and `tenant_settings`. Under Packet 7's promotion - `TenantDomain` and `TenantSetting` are aggregate roots in their own right, so each - will be written the way any other root is — but no command writes either yet, and - the seeder writes neither. -- Rows in `tenant_locales` and `tenant_feature_flags`. These are navigations inside - `Tenant` rather than roots, and ADR-0042's enumeration names them among the rows it - does **not** cover: neither carries an atomicity invariant against the tenant row. - `Tenant` exposes mutators for both; nothing calls them outside tests. - -A seed that needs any of those today writes them as SQL in a fixture, which is what -`TenantIsolationHttpTests` does for `tenant_settings` — deliberately, because inventing -a seeder path to serve a test would put fixture data in front of every developer running -`make seed`. - -Two mechanics the seeder cannot skip: - -- **`app.tenant_id` is set once per transaction, before that transaction's first - insert.** `SET LOCAL` does not survive `COMMIT`, so every one of the writes - above sets it again rather than inheriting it. Every table's `WITH CHECK` is - live from the moment the migration finishes, and `tenants` keys its policy on - `id`, so the provisioning transaction sets the session variable to the assigned - id before the `INSERT`. -- **`platform_host_to_tenant` rows go in as `learnstack_app`.** Its policies are - qualified `TO learnstack_app`, so the table owner is denied on it — the one - table where the migration role cannot seed. - -Packet 6's `SchemaFixture` keeps its own `alpha` / `beta` tenants. It asserts -against the applied schema and does not read this seed; changing one does not -change the other. - -### Step 4: What a later phase adds - -The seed above is the whole of the tenancy slice. Everything a "complete" demo -tenant eventually carries belongs to a phase that has not written its schema yet: - -| Aggregate / artefact | Owning phase | -|---|---| -| `User`, `Membership`, roles, invitations | [Phase 03](../../../docs/roadmap/phase-03-identity-admin.md) | -| Keycloak OIDC wiring and the realm's `tenant_id` claim mapper | [Phase 02b](../../../docs/roadmap/phase-02b-events-auth.md) | -| `TenantContentType`, `TenantLevelTaxonomy` | **Shipped** — [Phase 02a Packet 8](../../../docs/roadmap/phase-02a-kernel-tenancy.md). `SeedRunner` writes the built-in pair through `RegisterTenantContentTypeCommand` / `PublishTenantContentTypeCommand` and their taxonomy siblings, so a seeded tenant already has something to render | -| `Course`, `Lesson` and their translation satellites | [Phase 02d](../../../docs/roadmap/phase-02d-walking-skeleton.md) | -| Rows in `tenant_locales` — each tenant's enabled locales and its one default — written through the Tenancy command raising `tenancy.locale.write` | [Phase 02d](../../../docs/roadmap/phase-02d-walking-skeleton.md) | -| Each tenant's **own** content type and level taxonomy, and its branding token **values** written as `TenantSetting` rows — the seed that makes the two tenants differ, not only the built-in pair they share | [Phase 02d](../../../docs/roadmap/phase-02d-walking-skeleton.md), through the `tenancy.setting.write` command those rows need | -| `TenantCustomFieldDef` | [Phase 03](../../../docs/roadmap/phase-03-identity-admin.md) | -| `TenantPageBlock` | [Phase 04](../../../docs/roadmap/phase-04-cms-media-pages.md) | -| `TenantLessonItemType`, `TenantScoringRule`, `TenantCompletionRule` | [Phase 05](../../../docs/roadmap/phase-05-education-learning-content.md) | -| The tenant-admin surface for editing branding tokens — the Studio screens and their write path, not the seeded values above | [Phase 06](../../../docs/roadmap/phase-06-renderer-admin-studio.md) | -| `TenantTemplateLibrary` | [Phase 08a](../../../docs/roadmap/phase-08a-assessment-notifications.md) | -| `InstructorAvailability`, `LiveSession`, `LiveBooking` | [Phase 08b](../../../docs/roadmap/phase-08b-scheduling.md) / [Phase 08c](../../../docs/roadmap/phase-08c-classroom.md) | -| Hub tenant mirror and the entitlement projection | [Phase 02c](../../../docs/roadmap/phase-02c-hub-foundation.md) / Packet 9 | - -The entitlement projection is demand-gated infrastructure, so its row owes four -things and a phase is only one of them: the port is `IEntitlementProvider`, the -working default is `NullEntitlementProvider`, the owners are the Phase 02c / -Packet 9 pair above, and the trigger — *a tenant must be billed or plan-gated* — -is in -[ADR-0035](../../../docs/decisions/0035-demand-gated-infrastructure.md)'s trigger -table. - -There is **no `tenant_branding` table** and no `tenant_branding` row to write. -Branding tokens are read from `TenantSetting`. Phase 02d seeds each tenant's own -values there so the two render differently; Phase 06 adds the tenant-admin surface -that lets someone edit them. Seeded data and the configuration surface are separate -deliverables, and the rows the first writes are the rows the second edits. - -Keycloak users are **not** seeded by this skill. `infra/keycloak/realms/learnstack.json` -imports them at compose boot and `scripts/seed.sh` prints their credentials; there -is no `SEED_USER_PASSWORD` in `.env.example`, and adding one would put a second -source of truth beside the realm import. - -### Step 5: Hosts file alias - -To browse a tenant on a host that matches production-like custom domains: - -``` -# /etc/hosts -127.0.0.1 demo-english.learnstack.local -127.0.0.1 demo-yoga.learnstack.local -``` - -The API resolves the host: `HostClassificationMiddleware` calls -`IHostToTenantResolver`, which reads `platform_host_to_tenant` and nothing else — never -the Hub ([ADR-0034](../../../docs/decisions/0034-hub-contract-surface-invariant.md)) -— and the renderer states the visitor's host to the API over the trusted hop -([ADR-0036](../../../docs/decisions/0036-tenant-resolution-trusted-inputs.md#effective-host-and-the-trusted-hop)). -The Next.js middleware at `frontend/apps/web/src/middleware.ts` is still a scaffold -that copies the raw host into `x-tenant-id`, so the web app renders no tenant page on -either host until [Phase 02d](../../../docs/roadmap/phase-02d-walking-skeleton.md). - -> **Open in Phase 02d.** Whether the seed hosts stay under `*.learnstack.local` with the -> alias above, and what step a browser needs to reach them, is G32 in -> [Phase 02d's decision register](../../../docs/roadmap/phase-02d-walking-skeleton.md#the-decision-register); -> the pass that closes it edits this step with its answer. - -### Step 6: Verify - -Connect as `learnstack_app`, inside a transaction, with the tenant context set — -the same way the application does. Without it every tenant-owned table correctly -returns zero rows, which reads exactly like "the seed did not run". - -`psql` takes its own arguments here. `$ConnectionStrings__Default` is a .NET -keyword string, which libpq rejects (`invalid connection option "Host"`), and -`.env` is read by compose rather than sourced into a shell, so the variable is -usually empty anyway. The password is the `learnstack_app` one in `.env`. +Do not put a real connection string on `argv` or print it. The script reads the +environment first and falls back to the local `.env`; the agent should not expose +that file. There is no `make seed-tenant`, `infra/seed/`, or seed-reset command. + +### 2. Read the declaration + +The existing fixed tenant, organization, host and built-in IDs are preserved. +English's host remains tenant-wide; Yoga's host maps to Studio One. The exact +[accepted inventory](../../../docs/roadmap/phase-02d-walking-skeleton.md#seed-inventory-and-ownership) +and `SeedData` own the choices; this skill does not keep a second literal list. + +The inventory includes enabled/default locales, unchanged Active `card`/`plain`, +each tenant's own Active type/taxonomy, one tenant-wide `branding.theme`, and +courses/lessons with explicit exact pins, scope, policy, translations and state. +All body/schema/label data pass the ordinary validators. No remote media, fonts, +URLs or logo are seeded. Restricted publication grants no anonymous lesson access +([ADR-0050](../../../docs/decisions/0050-publication-and-course-content-access.md)). + +### 3. Execute acts through the pipeline + +[SeedRunner](../../../backend/src/LearnStack.Tools.Seeder/SeedRunner.cs) sends: + +1. Provisioning, followed by default-organization verification, second organization + and host mapping. The tenant starts Trial. Provisioning alone writes the + sanctioned Tenant/Organization pair ([ADR-0042](../../../docs/decisions/0042-tenant-provisioning-cross-aggregate-transaction.md)). +2. Enabled locales, with the declared default first. +3. Built-in and tenant-specific definitions, each registered then published. +4. Complete branding, in tenant-wide scope. +5. Draft Course/Lesson creation and each translation, followed by each selected + publication. Course and Lesson publication remain independent. + +Every request gets a fresh composed trusted context. Provisioning writes unresolved; +verification reads resolved. Tenant-wide acts announce null organization. Scoped +roots, their translations and publication announce the exact root organization. +The runner never writes `ITenantContextAccessor.Current`, opens a private +transaction, calls a tenant setter, mutates EF state or executes seed SQL. + +### 4. Converge or fail safely + +[SeedVerification](../../../backend/src/LearnStack.Tools.Seeder/SeedVerification.cs) +checks exact identity, ownership, scope, pin, state and content before a skip. +Contextual module-owned `ISender` queries return immutable value DTOs and are audit +Off; they have no endpoint or unresolved/public marker. Internal ports retain typed +IDs; contract-local IDs cross the module boundary as Guid under ADR-0023. + +- Missing acts write through the ordinary command. +- Completed acts skip before invoking a writer, so the second completed run changes + no root, timestamp, version, generation or audit row. +- Creation accepts only the declared Draft intermediate or intended final state + with matching immutable data. Definition creation accepts Draft/Active, with a + separate publication act verifying Active. +- Existing translations are checked before draft-only insertion. JSON object + property order is immaterial; descriptor arrays and authored strings remain exact. +- Contextual Off queries check the logical key's Active identity before registration. + Customization publication sets `RequireNoIncumbent`; a different Active revision + is refused before mutation, while ordinary revision succession remains available. +- A typed uniqueness/concurrency/lifecycle race gets one fresh-scope completed + postcondition check. Generic failures are never success and there is no retry loop. +- A mismatch stops nonzero; the runner does not overwrite, unpublish, rebind, + reactivate, choose a newer revision or borrow another tenant's identity. +- If another run has not completed the exact act yet, this run fails safely. Re-run + explicitly after the competitor finishes; a failed command is not convergence. + +The process entry point exits 2 for missing connection configuration, 1 for a +refused credential or run failure, and 0 for a completed run. Completed acts remain +durable; a later explicit run resumes the remaining acts. If a different revision +becomes Active between registration's pre-read and post-read, the newly committed +Draft, generation increment and audit remain durable; the run refuses and requires +explicit operator reconciliation. It does not retire either revision or undo an +already committed act. + +### 5. Verify + +[SeederTests](../../../backend/tests/LearnStack.Tests.Integration/Database/SeederTests.cs) +uses disposable migrated databases and writes as `learnstack_app`. It proves fresh +inventory, exact DTO/scope/state, unchanged rerun, interrupted recovery, a coordinated +provisioning race, semantic JSON equivalence and mismatches without overwrites. +`TenantIsolationHttpTests` checks both mapped host classes and filtered/raw RLS +customization reads; expected projections come from `SeedData`. + +The [caller fence](../../../docs/standards/21-architecture-tests-catalogue.md#seeder_does_not_call_tenant_context_setters) +and planted companions catch calls, method groups, private transactions, accessor +assignment, announcing SQL, direct EF mutations and ad hoc database commands. +A separate guard requires the direct tool to build its pool through the shared +application-role guard. G20(a)'s literal reader observes the actual declaration +and fails on unreadable/empty data. The complete demo-literal production-branch guard +remains Registered for P02d-5/6 scope and P02d-7 exit. + +### 6. Reach the hosts + +The existing names are under `*.learnstack.local` and need hosts-file aliases for +local browsing. Development transport/host changes remain G32 in P02d-5. The web +middleware is still a scaffold; no browser render is supplied by this seed packet. +Do not add a host alias or change a deployment's reserved-host registry implicitly. + +### 7. Reset only an explicitly disposable development environment + +An exact rerun is the normal recovery. A reset destroys local volumes: ```bash -psql -h localhost -p 5432 -U learnstack_app -d learnstack <<'SQL' -BEGIN; -SELECT set_config('app.tenant_id', '', true); -SELECT slug, display_name, status FROM tenants; -SELECT slug, display_name FROM organizations; -SELECT locale, is_default FROM tenant_locales; -COMMIT; -SQL - -# platform_host_to_tenant is read before any tenant context exists, so its read -# policy admits exactly the host the resolver declares in `app.resolving_host`, -# or the caller's own tenant via `app.tenant_id`. With neither set, -# `learnstack_app` sees nothing — that is what stops an anonymous session -# enumerating the host map, not a failed seed. Check the second row under the -# other host, or a tenant's own row under `app.tenant_id`. -psql -h localhost -p 5432 -U learnstack_app -d learnstack <<'SQL' -BEGIN; -SELECT set_config('app.resolving_host', 'demo-english.learnstack.local', true); -SELECT host, organization_id, is_active, is_publicly_live FROM platform_host_to_tenant; -COMMIT; -SQL +make clean +make dev +make seed ``` -### Step 7: Reset +Never run the destructive reset without the user's authorization. Tests drop only +the database they created, preserving append-only audit controls in the shared stack. -There is no `make seed-reset`. The seed is idempotent, so re-running it is the -normal repair; a genuine reset drops the volumes and starts over: +## What later phases add -```bash -make clean # stops the stack and drops named volumes — destructive -make dev # brings the stack back up -make seed # depends on `migrate`, so both chains are applied first -``` - -### Step 8: Authoring a new showcase - -To add a third domain showcase (e.g. music school): - -1. Add its tenant, organizations, locales, settings, feature flags, domain and - host row to the seeder's data set. -2. Register the host in `/etc/hosts` and, from Phase 02d, expect it to render. -3. Run `make seed`. - -> **Open in Phase 02d.** Whether a new host needs that hosts-file entry, and which -> development domain it sits under, is G32 in -> [Phase 02d's decision register](../../../docs/roadmap/phase-02d-walking-skeleton.md#the-decision-register); -> the pass that closes it edits step 2 with its answer. - -Its customization data — content types, level taxonomy, blocks, rules, templates — -is added as each owning phase from § What a later phase adds lands the aggregate -that holds it. - -**No LearnStack code change is required for the domain shape.** That is the -substrate-genericity claim per -[ADR-0018](../../../docs/decisions/0018-tenant-driven-customization-model.md); if -you find yourself touching a module to express a domain, the design is wrong. The -claim's edge is -[Platform Vision § Genericity boundary](../../../docs/architecture/01-platform-vision.md): -stateful entitlement and external capability invocation are platform features -gated by plan, not customization rows. - -## Validation - -- `make seed` exits 0, and exits 0 again on a second run. -- Both tenants are present with `status = Trial`, each with two organizations and - a non-null `default_organization_id`. -- `platform_host_to_tenant` holds one row per tenant — one carrying - `organization_id`, one leaving it NULL. Checked **one host at a time**, each under - its own `app.resolving_host` (§ Step 6): the read policy admits the declared host or - the caller's own tenant, so no single `learnstack_app` query can see both rows, and a - count of two is not observable to the role this skill tells you to connect as. -- Both host rows carry `is_active` **and** `is_publicly_live` true. The resolver - requires both terms, so a row that is only `is_active` is a host that 404s under - a seed the checks above report as healthy. -- The Packet 7 request-level isolation suite is green **connected as - `learnstack_app`**, against both seeded tenants. -- From Phase 02d, both hosts render their own catalog page in a browser. - -## Common pitfalls - -- **Domain-specific code in the seeder.** The seeder reads its data set; it does - not contain `if (showcase == "english") ...` business logic. If you feel pulled - toward that, the data is missing a field. -- **Seeding `status = Active` directly.** `Tenant.Create` produces `Trial`. Write - the column and the aggregate's state diagram and the seed disagree from the - first row. -- **Minting the tenant id in the seeder's factory.** `Tenant.Create` takes the id; - the registry assigns it. A minted id has no `app.tenant_id` to match and the - `WITH CHECK` refuses its own insert. -- **A host row per organization.** One row per tenant. An `OrgHost` is a tenant - row that also carries `organization_id`, not a second row. -- **Seeding `platform_host_to_tenant` as the migration role.** Its policies are - qualified `TO learnstack_app`; the owner is denied and the insert fails. -- **Two locales flagged `is_default`.** The invariant lives in the database as a - partial unique index — `UNIQUE (tenant_id) WHERE is_default` — with an - aggregate guard for the message. An aggregate check alone does not hold across - concurrent transactions. -- **Non-idempotent seed.** Running twice should produce the same state. Reference - rows by stable keys. -- **Two tenants sharing the same slug.** Slugs are unique across `tenants`; the - seed will refuse. Note the consequence the aggregate documents: a duplicate-slug - insert reveals that *some* tenant holds the slug, which is accepted only because - slugs appear in hostnames and are public by construction. -- **`/etc/hosts` change for production.** Local-only. Production custom domains - resolve through `platform_host_to_tenant` rows the Hub writes. +| Data / surface | Owner | +|---|---| +| Keycloak OIDC and realm reconciliation | Phase 02b | +| Users, memberships, roles, invitations and custom fields | Phase 03 | +| CMS pages/media, locale lifecycle and customization revision editors | Phases 03–04 | +| Versions, modules, items, scoring and completion rules | Phase 05 | +| Studio editors, branding override/merge and rich renderer coverage | Phase 06 | +| Enrollment, course access evaluation and progress | Phase 07 | +| Templates | Phase 08a | +| Availability, scheduling, classroom and reservations | Phases 08b–08c | +| Billing and commerce | Phase 09; proposed Course Marketplace Phase 09a | +| Hub entitlement projection | Phase 02c, demand-gated under ADR-0035 | + +## Adding a showcase + +Add its explicit records to `SeedData`, keep fixed identities unique, and supply +localized schemas/bodies/pins supported by shipped contracts. Derive inventory and +test expectations from the declaration. A new locale must be enabled before its +translation is written. Domain-specific application behavior requires the owning +phase's platform-feature decision; it cannot be hidden inside seed orchestration. +Keycloak users still come from compose realm imports, not this tool. diff --git a/CLAUDE.md b/CLAUDE.md index f940da65..e884be60 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -7,8 +7,12 @@ the conventions you must follow when contributing. ## What this is LearnStack is a **white-label platform for multi-branch education -businesses that teach live** — not a single LMS, and not an education -product of its own. One binary, one schema, and one set of container +businesses that teach live**. Its endorsed product direction adds an optional +LearnStack-branded Course Marketplace while preserving independent institution +sites. [ADR-0049](docs/decisions/0049-institution-sites-and-course-marketplace.md) +and [Phase 09a](docs/roadmap/phase-09a-course-marketplace-pilot.md) remain Proposed; +marketplace architecture, commerce and delivery are not implemented or Accepted. +One binary, one schema, and one set of container images serve a language school, a yoga studio, a music school, or a coding bootcamp. What differs between them is **tenant customization data** loaded at provisioning, not code @@ -22,9 +26,10 @@ capability invocation (running submitted code, scoring speech) are platform features gated by plan — they need a release, not a customization row. Link to that section; do not restate it. -LearnStack ships in three production deployment modes — SaaS, Dedicated, -Self-Hosted — backed by the companion **LearnStack Hub** control plane -(separate repository, see +LearnStack targets three production deployment modes — SaaS, Dedicated, +Self-Hosted — with current readiness recorded in +[Deployment Models](docs/architecture/25-deployment-models.md#supported-today-versus-prepared-seam). +The companion **LearnStack Hub** control plane lives in a separate repository (see [ADR-0019](docs/decisions/0019-learnstack-hub.md)). On developer workstations the Hub repo is the sibling directory `../LearnStack-Hub`; GitHub: https://github.com/HodeTech/LearnStack-Hub. The Hub repository @@ -60,10 +65,22 @@ reader. The whole .NET suite runs with **zero skips**, which the runner now refu let change. **[Phase 02d](docs/roadmap/phase-02d-walking-skeleton.md) is in progress**: its kickoff shipped the packet table and the decision register, and every later packet opens with -its decision pass. **P02d-1 is complete**: Education's domain, schema and isolation -proofs pass, all three steps completed two independent agent review rounds, and the -five live required checks pass on [PR #22](https://github.com/HodeTech/LearnStack/pull/22). Education -commands and seed writes belong to P02d-2; public reads belong to P02d-4. +its decision pass. **P02d-1 is complete and merged** through +[PR #22](https://github.com/HodeTech/LearnStack/pull/22) on 2026-09-14. Education's +domain, schema and isolation proofs pass; all three steps completed two independent +agent review rounds. The [merge closeout](docs/roadmap/phase-02d-walking-skeleton.md#merge-and-closeout-2026-09-14) +records verification of the final PR head and merge commit. **P02d-2's decision pass +is Accepted — 2026-10-02**: its [decision package](docs/roadmap/phase-02d-walking-skeleton.md#p02d-2-decision-package-2026-10-02) +and ADR-0050/0051 establish protected content, exact write contracts and four +implementation steps. Implementation resumed on development: Step 1 supplies the +access-policy migration, exact-definition/locale contracts, presentation validation +and contextual seed verification queries; both review rounds passed. Step 2 adds +locale/branding writers and whole-value setting audit redaction; both review rounds +passed. +Step 3 adds Education writers; both review rounds and a fresh focused fix review +passed. Step 4 completes convergent seed execution after both review rounds. +P02d-2 implementation and final verification are complete; PR review/merge remains +pending. P02d-3 read internals are next; public reads belong to P02d-4. **Phase 01** shipped the .NET 10 solution scaffold under `backend/` (core + 7 modules × 4 projects + 4 test projects including the @@ -242,8 +259,9 @@ and `Organization` aggregates and `TenancyDbContext`; Customization — `CustomizationDbContext`; Audit — `AuditEntry`, `AuditConfig` and `AuditDbContext`; and Education — separate `Course` and `Lesson` roots, their contained translations and `EducationDbContext`. Content, Identity and Media remain scaffolded. -P02d-1's implementation, agent reviews and required PR checks are complete. -Command and public-read surfaces belong to the later packets. Other module-level references +P02d-1 is merged; its implementation, agent reviews and required PR checks are complete. +P02d-2 supplies unrouted authoring commands and seeded content; public reads belong +to P02d-4. Other module-level references in the docs (e.g. `ILiveClassProvider`, `ITenantSearch`) still describe intended shape owned by their named phases. @@ -318,7 +336,13 @@ let the entry point pick it. - **No → ship the port now, the adapter on a named trigger.** Dapr pub/sub, Kafka, Valkey-backed cache, Vault, APISIX, the Hub entitlement source, signed licence keys, custom-domain TLS automation, `audit_log` partitioning. Each has a port in `LearnStack.SharedKernel` wherever [ADR-0035 § The gated set](docs/decisions/0035-demand-gated-infrastructure.md#the-gated-set) names one, a working default implementation (`InProcessEventBus`, `InMemoryCacheService`, `ConfigurationSecretProvider`, `NullEntitlementProvider`), an owning phase, and a written trigger condition. A building block missing any of those, other than a port that table records as absent, is not demand-gated — it is missing. - **Provider adapters everywhere.** Payments, auth, storage, search, live classroom, notifications, **event bus, cache, secrets, Hub contract, entitlement source, host resolver** — all sit behind interfaces. No SaaS lock-in in `Domain` or `Application`. See [20-infrastructure-stack.md](docs/standards/20-infrastructure-stack.md). - **The Hub contract is governed by two invariants, not by a count** ([ADR-0034](docs/decisions/0034-hub-contract-surface-invariant.md)): (1) the Hub stores **no tenant content** — courses, lessons, learners, enrollments, sessions and media live only in LearnStack, and the Hub holds tenant *metadata* only; (2) **every LearnStack↔Hub crossing goes through a named adapter** — `IEntitlementProvider`, `IUsageReporter`, `IHubTenantSync`, and nothing else may hold a Hub client. Adding an endpoint still requires an ADR, because the surface is a cross-repository contract both repositories have to agree on. -- **One binary, five `DeploymentMode` values, two of them wired.** Selection happens at the composition root; module code never branches on the mode ([ADR-0020](docs/decisions/0020-triple-deployment-hybrid-license.md), enforced by `Modules_Do_Not_Reference_DeploymentMode`). `Development` and `SaaS` are wired end to end; `Dedicated`, `SelfHostedOnline` and `SelfHostedAirGapped` are **prepared seams, not supported deployments**, until [Phase 11](docs/roadmap/phase-11-production-hardening.md) builds their adapters and integration suites. +- **One binary, five `DeploymentMode` values.** Selection happens at the composition + root; module code never branches on the mode + ([ADR-0020](docs/decisions/0020-triple-deployment-hybrid-license.md), enforced by + `Modules_Do_Not_Reference_DeploymentMode`). + [Deployment Models § Supported today versus prepared seam](docs/architecture/25-deployment-models.md#supported-today-versus-prepared-seam) + owns the current foundation wiring, remaining adapters and production-readiness + boundary; a selectable enum value is not a supported deployment. ## Conventions when editing docs diff --git a/README.md b/README.md index bbd660d1..473c1587 100644 --- a/README.md +++ b/README.md @@ -24,6 +24,12 @@ and database schema, while keeping their own content, branding and tenant bounda The product design supports a platform subdomain and optional custom domains; a business does not need to bring its own domain. +The endorsed direction adds an **optional Course Marketplace** alongside those +sites: shared discovery, platform checkout, commission and institution payouts. +Its [pilot plan](docs/roadmap/phase-09a-course-marketplace-pilot.md) and +[direction ADR](docs/decisions/0049-institution-sites-and-course-marketplace.md) are +Proposed; payment, seller operations and marketplace delivery are still ahead. + The difference between those businesses lives in [tenant customization data](docs/architecture/32-tenant-customization-model.md). The [platform vision](docs/architecture/01-platform-vision.md) defines the scope and @@ -37,16 +43,17 @@ the boundary between customization and capabilities that require platform code. ## What it does The product vision connects discovery, course content and live teaching in one place. -Three surfaces serve the people on each side of that experience: +The planned surfaces serve the people on each side of that experience: | Surface | Who it serves | Intended experience | |---|---|---| | **Public site** | Visitors and prospective learners | Discover a school, browse its catalog and explore its content. | | **Admin Studio** | Institution staff and instructors | Author content, manage people and organize teaching. | | **Learner portal** | Enrolled learners | Work through lessons, track progress and join live sessions. | +| **Optional Course Marketplace** | Learners and participating institutions | Shared discovery and central checkout; endorsed target, architecture approval pending. | These are planned product capabilities. Today, the frontend contains route scaffolds -for all three surfaces; the status below separates delivered foundations from the +for the first three surfaces; the status below separates delivered foundations from the remaining product work. **Built for different ways of teaching.** Content types and level taxonomies already @@ -58,17 +65,25 @@ items, rules, custom fields and notification templates; their delivery is tracke ## Where it is today **Phase 01 and Phase 02a are complete. Phase 02d is in progress.** -[P02d-1](docs/roadmap/phase-02d-walking-skeleton.md) delivers the Education domain, -schema and isolation proofs. **P02d-2** owns course and lesson command handlers and -seed writes; **P02d-4** owns anonymous public API reads. Browser rendering follows -in P02d-5–7. +[P02d-1](docs/roadmap/phase-02d-walking-skeleton.md#merge-and-closeout-2026-09-14) is +**complete and merged**: Education domain, schema and isolation proofs. +**P02d-2's decision package and ADR-0050/0051 are Accepted** as of 2026-10-02, +with four implementation steps. Step 1 implements the access policy, exact +definition/locale readers, text-card metadata validation and seed verification queries. +Both Step 1 review rounds passed. Step 2 adds locale/branding writers and JSON audit +redaction; both review rounds passed. Step 3 adds Education writers; both review +rounds and the focused fix review passed. Step 4 completes convergent seed +execution after both review rounds. P02d-2 is verified and ready for PR review; +merge remains pending. P02d-3 read internals are next. +**P02d-4** owns anonymous public API reads. Browser rendering follows +in P02d-5–7; none of these later packets has started. | Area | Delivered now | Next milestone | |---|---|---| -| **Tenancy** | Tenant provisioning, organizations, host resolution and database isolation | User membership and permissions in [Phase 03](docs/roadmap/phase-03-identity-admin.md) | -| **Customization** | Content types, level taxonomies, payload validation and built-in seeds | Remaining authoring capabilities across [Phases 04–08a](docs/roadmap/README.md) | +| **Tenancy** | Tenant provisioning, organizations, locales, branding, host resolution and database isolation | User membership and permissions in [Phase 03](docs/roadmap/phase-03-identity-admin.md) | +| **Customization** | Content types, level taxonomies, exact-definition readers, text-card metadata validation and tenant-authored seeds | Remaining authoring capabilities across [Phases 04–08a](docs/roadmap/README.md) | | **Audit** | Classified write path and transactional durability for business changes | Operational hardening in [Phase 11](docs/roadmap/phase-11-production-hardening.md) | -| **Education** | Course and Lesson aggregates, translations, migrations and isolation tests | Commands, seeded content and public reading in [P02d-2–4](docs/roadmap/phase-02d-walking-skeleton.md) | +| **Education** | Course and Lesson aggregates, translations, protected-content policy, scoped authoring commands, complete demo seeds and isolation tests | Public reading in [P02d-4](docs/roadmap/phase-02d-walking-skeleton.md) | | **API foundation** | Error contracts, validation, tenancy, concurrency and observability infrastructure | Authentication and durable event processing in [Phase 02b](docs/roadmap/phase-02b-events-auth.md) | | **Frontend** | Next.js app and public / studio / portal route scaffolds | First two-tenant browser demo in [P02d-5–7](docs/roadmap/phase-02d-walking-skeleton.md) | @@ -106,8 +121,10 @@ make seed # start infrastructure, apply migrations, seed two demo tenants ``` The seed provisions `demo-english` and `demo-yoga`, their organizations and host -mappings, plus built-in content-type and taxonomy definitions. **It does not yet seed -courses or lessons**; P02d-2 owns those writes. +mappings, enabled locales, distinct branding, built-in and tenant-authored definitions, +and eight scoped courses, ten lessons and twenty-seven translations. It uses the +ordinary authoring pipeline and verifies completed acts before skipping them on rerun. +[SeedData](backend/src/LearnStack.Tools.Seeder/SeedData.cs) owns the complete inventory. ### 3. Start the applications in separate terminals diff --git a/backend/src/LearnStack.Api/Composition/PersistenceCompositionExtensions.cs b/backend/src/LearnStack.Api/Composition/PersistenceCompositionExtensions.cs index 34d3de33..4d93e51d 100644 --- a/backend/src/LearnStack.Api/Composition/PersistenceCompositionExtensions.cs +++ b/backend/src/LearnStack.Api/Composition/PersistenceCompositionExtensions.cs @@ -1,3 +1,6 @@ +using LearnStack.Modules.Customization.Application.Contracts.Definitions; +using LearnStack.Modules.Tenancy.Application.Contracts.Locales; +using LearnStack.Modules.Education.Application.Audit; using LearnStack.Infrastructure.Audit; using LearnStack.Modules.Customization.Application.Audit; using LearnStack.Modules.Tenancy.Application.Audit; @@ -85,7 +88,7 @@ public static class PersistenceCompositionExtensions private const string DefaultConnectionName = "Default"; /// The one role a runtime process may connect as. - internal const string RuntimeRole = "learnstack_app"; + internal const string RuntimeRole = ApplicationDataSource.RuntimeRole; public static IServiceCollection AddLearnStackPersistence( this IServiceCollection services, IConfiguration configuration) @@ -263,6 +266,7 @@ public static IServiceCollection AddLearnStackPersistence( services.TryAddEnumerable([ ServiceDescriptor.Singleton(), ServiceDescriptor.Singleton(), + ServiceDescriptor.Singleton(), ]); services.TryAddSingleton(provider => @@ -278,6 +282,8 @@ public static IServiceCollection AddLearnStackPersistence( // and the reverse reference is already a cycle — so these ports are how the first // production handler reaches persistence at all. services.TryAddScoped(); + services.TryAddScoped(); + services.TryAddScoped(); services.TryAddScoped(); services.TryAddScoped(); @@ -293,6 +299,15 @@ public static IServiceCollection AddLearnStackPersistence( // therefore not the write store: a content-type handler holding that store // would be a handler the cross-aggregate census counts as writing two roots. services.TryAddScoped(); + services.TryAddScoped(); + services.TryAddScoped(); + services.TryAddScoped(); + services.TryAddScoped(); + services.TryAddScoped(); + services.TryAddScoped(); + services.TryAddScoped(); + services.TryAddScoped(); + services.TryAddScoped(); return services; } @@ -307,22 +322,8 @@ public static IServiceCollection AddLearnStackPersistence( /// runtime credential is read, and an error that echoed it would put it in /// every log that captured the startup failure. /// - internal static NpgsqlDataSource BuildApplicationDataSource(string? connectionString) - { - ValidateApplicationConnectionString(connectionString); - - var builder = new NpgsqlDataSourceBuilder(connectionString); - - // Asked of the server, once per physical connection, because the name is - // not the privilege: learnstack_app could have been granted BYPASSRLS, and - // a superuser bypasses row security with rolbypassrls = false — which is - // why rolsuper is in the predicate. - builder.UsePhysicalConnectionInitializer( - connection => RefuseBypassRole(connection, async: false).GetAwaiter().GetResult(), - connection => RefuseBypassRole(connection, async: true)); - - return builder.Build(); - } + internal static NpgsqlDataSource BuildApplicationDataSource(string? connectionString) => + ApplicationDataSource.Build(connectionString); /// The configuration key the platform credential comes from. public const string PlatformConnectionName = "PlatformAdmin"; @@ -409,97 +410,14 @@ internal static void ValidatePlatformConnectionString(string? connectionString) /// still deferring the data source itself. The server-side bypass check is not /// here — it needs a connection, and it runs per physical connection. /// - internal static void ValidateApplicationConnectionString(string? connectionString) - { - if (string.IsNullOrWhiteSpace(connectionString)) - { - throw new InvalidOperationException( - "ConnectionStrings:Default is not configured. It names the learnstack_app " - + "role — the NOBYPASSRLS runtime credential — and is in .env.example. Do " - + "not point it at ConnectionStrings:Migration: that role owns every table, " - + "and a runtime that is the owner is what FORCE ROW LEVEL SECURITY exists " - + "to defeat."); - } - - NpgsqlConnectionStringBuilder parsed; - - try - { - parsed = new NpgsqlConnectionStringBuilder(connectionString); - } - catch (Exception exception) when (exception is ArgumentException or FormatException) - { - // Npgsql's own message names neither the key nor the file. An - // operator who pasted a URI-style DSN — the form DATABASE_URL carries - // on several hosts — otherwise gets a bare ArgumentException out of - // System.Data.Common. - // The value is NOT echoed, redacted or otherwise. It failed to parse, so - // there is no field to be confident about: the userinfo pattern could not - // cross a '/' or a second '@' inside a password, and either one put the - // secret in a startup log. The message's job is to name the key and the - // expected form, and it does that without quoting anything. - throw new InvalidOperationException( - "ConnectionStrings:Default is not a valid connection string. The expected " - + "form is a semicolon-separated key/value list — Host, Port, Database, " - + "Username, Password — not a URI. The value is not repeated here because " - + "an unparseable one cannot be reliably redacted. See .env.example.", - exception); - } - - if (!string.Equals(parsed.Username, RuntimeRole, StringComparison.Ordinal)) - { - throw new InvalidOperationException( - $"ConnectionStrings:Default names Username='{parsed.Username}', not {RuntimeRole}: " - + $"{Redact(parsed)}. A runtime process connects as the NOBYPASSRLS " - + "application role and nothing else. learnstack_migration owns every table, and " - + "learnstack_platform and learnstack_outbox_admin hold BYPASSRLS — with any of " - + "them here every Row Level Security policy in the database is inert, and the " - + "unresolved-tenant state that returns no rows returns every tenant's instead. " - + "EnterPlatformAdminScope is the only sanctioned path to a bypass credential."); - } - } - - private static async Task RefuseBypassRole(NpgsqlConnection connection, bool async) - { - await using var command = connection.CreateCommand(); - - // Reachability, not the role's own two attributes. `GRANT - // learnstack_platform TO learnstack_app` leaves `rolbypassrls` and - // `rolsuper` false on learnstack_app and still lets it `SET ROLE` into a - // BYPASSRLS role — measured, directly and through a bridge role that holds - // the membership on its behalf. `pg_has_role(..., 'MEMBER')` follows the - // whole chain and includes the role itself, so this subsumes the attribute - // check rather than sitting beside it. - command.CommandText = - """ - SELECT EXISTS ( - SELECT 1 FROM pg_roles r - WHERE (r.rolbypassrls OR r.rolsuper) - AND pg_has_role(current_user, r.oid, 'MEMBER')) - """; - - var bypasses = async - ? await command.ExecuteScalarAsync() - : command.ExecuteScalar(); - - if (bypasses is true) - { - throw new InvalidOperationException( - "The runtime connected as a role that can reach one which bypasses Row Level " - + "Security — by holding rolbypassrls or rolsuper itself, or by being a member " - + "of a role that does, directly or through another. Every policy in the " - + "database is then one SET ROLE away from inert. Check " - + "ConnectionStrings:Default and the role memberships granted to the role it " - + "names; EnterPlatformAdminScope is the only sanctioned path to a bypass " - + "credential."); - } - } + internal static void ValidateApplicationConnectionString(string? connectionString) => + ApplicationDataSource.Validate(connectionString); /// /// Refuses a platform connection whose role does not bypass row security. /// /// - /// The mirror of RefuseBypassRole, and asked of the server for the same + /// The mirror of the shared application-role guard, and asked of the server for the same /// reason: the name is not the privilege. A learnstack_platform that lost /// BYPASSRLS — a re-created role, a restored dump, an ALTER ROLE — is /// the failure that looks like nothing at all, because every cross-tenant query @@ -541,26 +459,4 @@ SELECT 1 FROM pg_roles r } } - /// The connection string with its password removed. - /// - /// From the parsed builder, not by pattern-matching the raw text. - /// Npgsql accepts Pwd and PSW as aliases for Password and - /// parses all three into the same field, so a keyword regex over the raw value - /// that knows only the canonical spelling carries the other two straight into - /// the exception message — measured, and it is what shipped first. Setting the - /// field is alias-proof by construction. (A regex over - /// parsed.ConnectionString would also work, because the round trip - /// normalises the aliases away — but it works for a reason a reader would have - /// to know, and the raw-string form one edit away from it does not.) - /// - private static string Redact(NpgsqlConnectionStringBuilder parsed) - { - var redacted = new NpgsqlConnectionStringBuilder(parsed.ConnectionString) - { - Password = "***", - }; - - return redacted.ConnectionString; - } - } diff --git a/backend/src/LearnStack.Api/Program.cs b/backend/src/LearnStack.Api/Program.cs index 00f752a0..0472ef57 100644 --- a/backend/src/LearnStack.Api/Program.cs +++ b/backend/src/LearnStack.Api/Program.cs @@ -31,13 +31,15 @@ // The module assemblies MediatR scans for handlers. Tenancy's is here as of Packet 7, // which shipped the first production request types — and the parameter existed all along, // so the change was one argument rather than a new seam. Customization's joined it in -// Packet 8. A module whose assembly is missing here has handlers nothing dispatches, and +// Packet 8; Education joins for P02d-2's contextual verification queries. +// A module whose assembly is missing here has handlers nothing dispatches, and // FluentValidation validators nothing runs — which fails as "no handler for request" at // the call site rather than at startup, and as a command that skipped its guards. builder.AddLearnStackCrossCuttingFoundation( deploymentMode, typeof(LearnStack.Modules.Tenancy.Application.AssemblyMarker).Assembly, - typeof(LearnStack.Modules.Customization.Application.AssemblyMarker).Assembly); + typeof(LearnStack.Modules.Customization.Application.AssemblyMarker).Assembly, + typeof(LearnStack.Modules.Education.Application.AssemblyMarker).Assembly); builder.Services.AddLearnStackTenancyEdge(builder.Configuration); builder.Services.AddLearnStackPersistence(builder.Configuration); builder.Services.AddLearnStackRateLimiting(); diff --git a/backend/src/LearnStack.Infrastructure.Validation/JsonSchemaNetValidator.cs b/backend/src/LearnStack.Infrastructure.Validation/JsonSchemaNetValidator.cs index d0a23d48..49451034 100644 --- a/backend/src/LearnStack.Infrastructure.Validation/JsonSchemaNetValidator.cs +++ b/backend/src/LearnStack.Infrastructure.Validation/JsonSchemaNetValidator.cs @@ -145,7 +145,7 @@ public Result> AdmitSchema(string jsonSc public Result ValidateInstance(string admittedSchema, string instanceJson) { // Both caps before either parse, for the reason AdmitSchema states. - if (Encoding.UTF8.GetByteCount(instanceJson) > MaxInstanceBytes) + if (!JsonInstanceLimits.IsWithinCap(instanceJson)) { return Fail("", "lockey_instance_too_large"); } @@ -214,12 +214,6 @@ public Result ValidateInstance(string admittedSchema, string instanceJson) } } - /// - /// The largest content entry this validator will evaluate, per - /// § 8.4. - /// - private const int MaxInstanceBytes = 1024 * 1024; - /// /// A fresh registry per build. Both arguments are load-bearing; see the /// remarks on the class. diff --git a/backend/src/LearnStack.Infrastructure.Validation/JsonSchemaProfile.cs b/backend/src/LearnStack.Infrastructure.Validation/JsonSchemaProfile.cs index 2ff8cc52..3b3480ec 100644 --- a/backend/src/LearnStack.Infrastructure.Validation/JsonSchemaProfile.cs +++ b/backend/src/LearnStack.Infrastructure.Validation/JsonSchemaProfile.cs @@ -130,7 +130,7 @@ internal static class JsonSchemaProfile /// spend a level of the depth budget § 8.4 measures on the schema tree. /// /// - private static readonly string[] ExtensionKeywords = ["x-renderer", "x-taxonomy", "x-language"]; + private static readonly string[] ExtensionKeywords = ["x-renderer", "x-taxonomy", "x-language", "x-fields"]; /// /// Keywords that run a tenant-authored regular expression. Refused until the @@ -368,6 +368,13 @@ private static void Walk( // its own keys are. if (!namesAreAuthored) { + if (property.Name == "x-fields" + && (pointer.Length != 0 || property.Value.ValueKind != JsonValueKind.Array + || property.Value.GetArrayLength() == 0)) + { + failures.Add(child, "lockey_schema_extension_unresolved"); + } + CheckKeyword(property, child, failures, references); // The keyword is checked; its VALUE is data, so the walk diff --git a/backend/src/LearnStack.Infrastructure/Persistence/ApplicationDataSource.cs b/backend/src/LearnStack.Infrastructure/Persistence/ApplicationDataSource.cs new file mode 100644 index 00000000..6fcbc7ae --- /dev/null +++ b/backend/src/LearnStack.Infrastructure/Persistence/ApplicationDataSource.cs @@ -0,0 +1,112 @@ +using Npgsql; + +namespace LearnStack.Infrastructure.Persistence; + +/// The shared application-role guard for HTTP and one-shot request hosts. +/// +/// ADR-0003's NOBYPASSRLS boundary applies to the seeder as well as the API. +/// Validation never connects; every physical connection checks transitive role +/// membership so a correctly named login cannot reach a bypass role with SET ROLE. +/// +public static class ApplicationDataSource +{ + public const string RuntimeRole = "learnstack_app"; + + public static NpgsqlDataSource Build(string? connectionString) + { + Validate(connectionString); + var builder = new NpgsqlDataSourceBuilder(connectionString); + builder.UsePhysicalConnectionInitializer( + connection => RefuseBypassRole(connection, async: false).GetAwaiter().GetResult(), + connection => RefuseBypassRole(connection, async: true)); + return builder.Build(); + } + + public static void Validate(string? connectionString) + { + if (string.IsNullOrWhiteSpace(connectionString)) + { + throw new InvalidOperationException( + "ConnectionStrings:Default is not configured. It names the learnstack_app " + + "role — the NOBYPASSRLS runtime credential — and is in .env.example. Do " + + "not point it at ConnectionStrings:Migration: that role owns every table, " + + "and a runtime that is the owner is what FORCE ROW LEVEL SECURITY exists " + + "to defeat."); + } + + NpgsqlConnectionStringBuilder parsed; + + try + { + parsed = new NpgsqlConnectionStringBuilder(connectionString); + } + catch (Exception exception) when (exception is ArgumentException or FormatException) + { + // Npgsql's own message names neither the key nor the file. An + // operator who pasted a URI-style DSN — the form DATABASE_URL carries + // on several hosts — otherwise gets a bare ArgumentException out of + // System.Data.Common. + // The value is NOT echoed, redacted or otherwise. It failed to parse, so + // there is no field to be confident about: the userinfo pattern could not + // cross a '/' or a second '@' inside a password, and either one put the + // secret in a startup log. The message's job is to name the key and the + // expected form, and it does that without quoting anything. + throw new InvalidOperationException( + "ConnectionStrings:Default is not a valid connection string. The expected " + + "form is a semicolon-separated key/value list — Host, Port, Database, " + + "Username, Password — not a URI. The value is not repeated here because " + + "an unparseable one cannot be reliably redacted. See .env.example."); + } + + if (!string.Equals(parsed.Username, RuntimeRole, StringComparison.Ordinal)) + { + throw new InvalidOperationException( + $"ConnectionStrings:Default names Username='{parsed.Username}', not {RuntimeRole}: " + + $"{Redact(parsed)}. A runtime process connects as the NOBYPASSRLS " + + "application role and nothing else. learnstack_migration owns the tables " + + "and has DDL privileges; learnstack_platform and learnstack_outbox_admin " + + "hold BYPASSRLS. A bypass credential makes the unresolved-tenant state " + + "that returns no rows return every tenant's instead. " + + "EnterPlatformAdminScope is the only sanctioned path to a bypass credential."); + } + } + + private static async Task RefuseBypassRole(NpgsqlConnection connection, bool async) + { + await using var command = connection.CreateCommand(); + + // Reachability, not the role's own two attributes. `GRANT + // learnstack_platform TO learnstack_app` leaves `rolbypassrls` and + // `rolsuper` false on learnstack_app and still lets it `SET ROLE` into a + // BYPASSRLS role — measured, directly and through a bridge role that holds + // the membership on its behalf. `pg_has_role(..., 'MEMBER')` follows the + // whole chain and includes the role itself, so this subsumes the attribute + // check rather than sitting beside it. + command.CommandText = + """ + SELECT EXISTS ( + SELECT 1 FROM pg_roles r + WHERE (r.rolbypassrls OR r.rolsuper) + AND pg_has_role(current_user, r.oid, 'MEMBER')) + """; + + var bypasses = async + ? await command.ExecuteScalarAsync() + : command.ExecuteScalar(); + + if (bypasses is true) + { + throw new InvalidOperationException( + "The runtime connected as a role that can reach one which bypasses Row Level " + + "Security — by holding rolbypassrls or rolsuper itself, or by being a member " + + "of a role that does, directly or through another. Every policy in the " + + "database is then one SET ROLE away from inert. Check " + + "ConnectionStrings:Default and the role memberships granted to the role it " + + "names; EnterPlatformAdminScope is the only sanctioned path to a bypass " + + "credential."); + } + } + + private static string Redact(NpgsqlConnectionStringBuilder parsed) => + new NpgsqlConnectionStringBuilder(parsed.ConnectionString) { Password = "***" }.ConnectionString; +} diff --git a/backend/src/LearnStack.Infrastructure/Persistence/WriteStoreTracking.cs b/backend/src/LearnStack.Infrastructure/Persistence/WriteStoreTracking.cs index c4a924e6..13b04289 100644 --- a/backend/src/LearnStack.Infrastructure/Persistence/WriteStoreTracking.cs +++ b/backend/src/LearnStack.Infrastructure/Persistence/WriteStoreTracking.cs @@ -34,10 +34,14 @@ public static class WriteStoreTracking /// 23505 only. Every other SQLSTATE is a fault and stays one; a 42501 in /// particular means a policy refused the write, which is never something to /// soften. + /// Callers supplying ownedConstraints translate only those names. An unknown + /// uniqueness stays a database fault, rather than carrying the port exception's + /// default business-rule classification to the HTTP boundary. The optional form + /// preserves the existing stores' contract; new writers explicitly name their set. /// /// public static async Task SaveTranslatingConflictsAsync( - DbContext db, CancellationToken cancellationToken) + DbContext db, CancellationToken cancellationToken, IReadOnlySet? ownedConstraints = null) { ArgumentNullException.ThrowIfNull(db); @@ -65,7 +69,8 @@ public static async Task SaveTranslatingConflictsAsync( "The aggregate changed after it was read; re-read it and retry.", stale); } catch (DbUpdateException failure) - when (failure.InnerException is PostgresException { SqlState: "23505" } conflict) + when (failure.InnerException is PostgresException { SqlState: "23505" } conflict + && (ownedConstraints is null || conflict.ConstraintName is { } constraint && ownedConstraints.Contains(constraint))) { // Detach what the database refused, before the exception leaves. EF keeps a // failed entry in the state it had — an Added row stays Added — so a caller diff --git a/backend/src/LearnStack.SharedKernel/Validation/JsonInstanceLimits.cs b/backend/src/LearnStack.SharedKernel/Validation/JsonInstanceLimits.cs new file mode 100644 index 00000000..086c7c02 --- /dev/null +++ b/backend/src/LearnStack.SharedKernel/Validation/JsonInstanceLimits.cs @@ -0,0 +1,13 @@ +using System.Text; + +namespace LearnStack.SharedKernel.Validation; + +/// Transport-independent instance admission bound, checked before parsing. +/// ADR-0043 caps entry instances at 1 MiB of UTF-8, independently of schema size. +public static class JsonInstanceLimits +{ + public const int MaxBytes = 1024 * 1024; + + public static bool IsWithinCap(string value) => + value is not null && value.Length <= MaxBytes && Encoding.UTF8.GetByteCount(value) <= MaxBytes; +} diff --git a/backend/src/LearnStack.Tools.Seeder/Program.cs b/backend/src/LearnStack.Tools.Seeder/Program.cs index ddaf766a..f04643ff 100644 --- a/backend/src/LearnStack.Tools.Seeder/Program.cs +++ b/backend/src/LearnStack.Tools.Seeder/Program.cs @@ -1,7 +1,7 @@ using LearnStack.SharedKernel.Tenancy; using LearnStack.Tools.Seeder; using Microsoft.Extensions.Logging; -using Npgsql; +using LearnStack.Infrastructure.Persistence; // The seeder is a host without an HTTP surface, and it exists so the two demo tenants are // written by the same commands a request writes them with — ADR-0042 requires that: a @@ -27,15 +27,14 @@ // One data source for the whole run, shared by every per-act provider: a seeder that // opened a pool per command would leave an idle connection behind for each one. -await using var dataSource = NpgsqlDataSource.Create(connectionString); using var loggerFactory = LoggerFactory.Create(logging => logging.AddSimpleConsole()); -var runner = new SeedRunner( - context => SeedComposition.Build(dataSource, context, loggerFactory), - loggerFactory.CreateLogger()); - try { + await using var dataSource = ApplicationDataSource.Build(connectionString); + var runner = new SeedRunner( + context => SeedComposition.Build(dataSource, context, loggerFactory), + loggerFactory.CreateLogger()); return await runner.RunAsync(CancellationToken.None); } catch (Exception failure) diff --git a/backend/src/LearnStack.Tools.Seeder/SeedComposition.cs b/backend/src/LearnStack.Tools.Seeder/SeedComposition.cs index 7840e6d3..cbf011b6 100644 --- a/backend/src/LearnStack.Tools.Seeder/SeedComposition.cs +++ b/backend/src/LearnStack.Tools.Seeder/SeedComposition.cs @@ -1,3 +1,6 @@ +using LearnStack.Modules.Customization.Application.Contracts.Definitions; +using LearnStack.Modules.Tenancy.Application.Contracts.Locales; +using LearnStack.Modules.Education.Application.Audit; using LearnStack.Application.Pipeline; using LearnStack.Infrastructure.MultiTenancy; using LearnStack.Infrastructure.Persistence; @@ -91,6 +94,8 @@ public static ServiceProvider Build( services.AddScoped(); services.AddModuleDbContext(); services.AddScoped(); + services.AddScoped(); + services.AddScoped(); services.AddScoped(); services.AddScoped(); @@ -110,6 +115,15 @@ public static ServiceProvider Build( services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); services.AddScoped(); // The Audit module's context, on the same helper and for the same reason as the @@ -196,6 +210,7 @@ public static ServiceProvider Build( services.TryAddEnumerable([ ServiceDescriptor.Singleton(), ServiceDescriptor.Singleton(), + ServiceDescriptor.Singleton(), ]); services.TryAddSingleton(provider => @@ -218,7 +233,8 @@ public static ServiceProvider Build( services.AddSingleton(NullHostResolutionInvalidator.Instance); services.AddLearnStackMediatRPipeline( typeof(ITenantWriteStore).Assembly, - typeof(ITenantContentTypeStore).Assembly); + typeof(ITenantContentTypeStore).Assembly, + typeof(LearnStack.Modules.Education.Application.AssemblyMarker).Assembly); return services.BuildServiceProvider(); } diff --git a/backend/src/LearnStack.Tools.Seeder/SeedData.cs b/backend/src/LearnStack.Tools.Seeder/SeedData.cs index 93af3daa..6c867104 100644 --- a/backend/src/LearnStack.Tools.Seeder/SeedData.cs +++ b/backend/src/LearnStack.Tools.Seeder/SeedData.cs @@ -1,114 +1,192 @@ +using System.Collections.Immutable; +using LearnStack.Modules.Customization.Application.Contracts.Customization; using LearnStack.SharedKernel.Identifiers; namespace LearnStack.Tools.Seeder; -/// -/// The two demo tenants, in domains chosen to be unrelated. -/// -/// -/// -/// Two, and in unrelated domains, is the point rather than a convenience. LearnStack -/// claims one binary and one schema serve a language school and a yoga studio, and the -/// claim is only tested by data that differs. A second tenant in the same domain would -/// exercise isolation and nothing else; these exercise -/// [the genericity boundary](../../../docs/architecture/01-platform-vision.md) as well. -/// -/// -/// The ids are fixed literals, not generated. Re-running the seeder has to land on -/// the same rows or it is not idempotent, and a fixed id is what lets the second run -/// recognise its own first. They are version-7 shaped so they sort like every other -/// identifier in the system. -/// -/// -/// Each tenant gets two organizations, because an organization is where -/// organization-scoped isolation is actually observable: one is the tenant's own default, -/// created by provisioning, and the second is what makes -/// Org_X_cannot_read_Org_Y_within_TenantA a statement about seeded data rather than -/// about a fixture. -/// -/// -/// One host row each, and deliberately of different classes. `demo-english` maps -/// host → tenant with a null organization and `demo-yoga` maps host → organization, so both -/// live classifications are exercised by the seed rather than only by a test. Which tenant -/// takes which is arbitrary on the merits and therefore settled by the corpus: -/// [the seed-tenant skill](../../../.claude/skills/seed-tenant/SKILL.md) named this pairing -/// before the code existed, and two documents disagreeing about a seeded row is how a -/// Phase 02d assertion ends up chasing the wrong host. -/// -/// +/// The sole declaration of demo identities, content and expected inventory. +/// Fixed UUIDv7 identities survive reruns; domain differences are data, never branches. public static class SeedData { - public static readonly SeedTenant English = new( - TenantId.From(Guid.Parse("01930000-0000-7000-8000-000000000001")), - "demo-english", - "English Hero", - new SeedOrganization( - OrganizationId.From(Guid.Parse("01930000-0000-7000-8000-0000000000a1")), - "kadikoy", - "Kadıköy Branch"), - new SeedOrganization( - OrganizationId.From(Guid.Parse("01930000-0000-7000-8000-0000000000a2")), - "besiktas", - "Beşiktaş Branch"), - "demo-english.learnstack.local", - MapHostToDefaultOrganization: false, - Guid.Parse("01930000-0000-7000-8000-0000000000c1"), - Guid.Parse("01930000-0000-7000-8000-0000000000d1")); + private static readonly SeedOrganization FirstBranch = new( + OrganizationId.From(Id("01930000-0000-7000-8000-0000000000a1")), "kadikoy", "Kadıköy Branch"); + private static readonly SeedOrganization SecondBranch = new( + OrganizationId.From(Id("01930000-0000-7000-8000-0000000000a2")), "besiktas", "Beşiktaş Branch"); + private static readonly SeedOrganization FirstStudio = new( + OrganizationId.From(Id("01930000-0000-7000-8000-0000000000b1")), "studio-one", "Studio One"); + private static readonly SeedOrganization SecondStudio = new( + OrganizationId.From(Id("01930000-0000-7000-8000-0000000000b2")), "studio-two", "Studio Two"); + public static readonly SeedTenant English = new( + TenantId.From(Id("01930000-0000-7000-8000-000000000001")), "demo-english", "English Hero", + FirstBranch, SecondBranch, "demo-english.learnstack.local", false, + Id("01930000-0000-7000-8000-0000000000c1"), Id("01930000-0000-7000-8000-0000000000d1"), + EnglishCurriculum()); public static readonly SeedTenant Yoga = new( - TenantId.From(Guid.Parse("01930000-0000-7000-8000-000000000002")), - "demo-yoga", - "Anatolia Yoga", - new SeedOrganization( - OrganizationId.From(Guid.Parse("01930000-0000-7000-8000-0000000000b1")), - "studio-one", - "Studio One"), - new SeedOrganization( - OrganizationId.From(Guid.Parse("01930000-0000-7000-8000-0000000000b2")), - "studio-two", - "Studio Two"), - "demo-yoga.learnstack.local", - MapHostToDefaultOrganization: true, - Guid.Parse("01930000-0000-7000-8000-0000000000c2"), - Guid.Parse("01930000-0000-7000-8000-0000000000d2")); - + TenantId.From(Id("01930000-0000-7000-8000-000000000002")), "demo-yoga", "Anatolia Yoga", + FirstStudio, SecondStudio, "demo-yoga.learnstack.local", true, + Id("01930000-0000-7000-8000-0000000000c2"), Id("01930000-0000-7000-8000-0000000000d2"), + YogaCurriculum()); public static readonly IReadOnlyList All = [English, Yoga]; -} -/// Created by provisioning, in the same transaction. -/// Created after, by an ordinary command. -/// -/// Whether the host row carries an organization id. One tenant sets it and one leaves it -/// null, so the seed covers both host classifications. -/// -/// -/// The id the built-in card content type takes for this tenant. -/// -/// -/// The id the built-in plain level taxonomy takes for this tenant. -/// -/// -/// The two customization ids are fixed literals for the same reason every other id -/// here is: a re-run has to conflict on something it wrote last time. An id -/// generated per run would not insert a second copy — the versioned key would -/// still refuse it — but it would report the collision as -/// lockey_schema_version_taken rather than lockey_identifier_taken, -/// and the seed would then be idempotent by a different accident on every run. -/// They are per tenant because the id is a global primary key while the key is -/// unique only within a tenant: two tenants sharing an id is a collision, and the -/// seeder's ownership check is what turns it into a stopped run rather than a -/// silent "already present". -/// -public sealed record SeedTenant( - TenantId TenantId, - string Slug, - string DisplayName, - SeedOrganization DefaultOrganization, - SeedOrganization SecondOrganization, - string Host, - bool MapHostToDefaultOrganization, - Guid BuiltInContentTypeId, - Guid BuiltInTaxonomyId); + // Provisioning audits both sanctioned roots; follow-on organization and host each add one. + public static long ExpectedAuditWrites => Inventory.Tenants * 4L + Inventory.Locales + + 2L * (Inventory.ContentTypes + Inventory.Taxonomies) + Inventory.Themes + + Inventory.Courses + Inventory.Lessons + Inventory.Translations + + All.Sum(tenant => tenant.Curriculum?.Courses.Sum(course => (course.Status == "Published" ? 1 : 0) + + course.Lessons.Count(lesson => lesson.Status == "Published")) ?? 0); + + public static SeedInventory Inventory => new( + All.Count, All.Count * 2, All.Count, + All.Sum(tenant => tenant.Curriculum?.Locales.Length ?? 0), + All.Sum(tenant => ContentTypes(tenant).Count()), All.Sum(tenant => Taxonomies(tenant).Count()), + All.Sum(tenant => Taxonomies(tenant).Sum(taxonomy => taxonomy.Bands.Length)), + All.Count(tenant => tenant.Curriculum is not null), + All.Sum(tenant => tenant.Curriculum?.Courses.Length ?? 0), + All.Sum(tenant => tenant.Curriculum?.Courses.Sum(course => course.Lessons.Length) ?? 0), + All.Sum(tenant => tenant.Curriculum?.Courses.Sum(course => course.Translations.Length + + course.Lessons.Sum(lesson => lesson.Translations.Length)) ?? 0)); + + public static IEnumerable ContentTypes(SeedTenant tenant) + { + yield return new(tenant.BuiltInContentTypeId, BuiltInCustomizations.Card.Key, + BuiltInCustomizations.SchemaVersion, BuiltInCustomizations.Card.DisplayName, + BuiltInCustomizations.Card.JsonSchema, BuiltInCustomizations.Card.RendererKey); + if (tenant.Curriculum is { } curriculum) yield return curriculum.ContentType; + } + public static IEnumerable Taxonomies(SeedTenant tenant) + { + yield return new(tenant.BuiltInTaxonomyId, BuiltInCustomizations.Plain.Key, + BuiltInCustomizations.SchemaVersion, BuiltInCustomizations.Plain.DisplayName, + [.. BuiltInCustomizations.Plain.Bands.Select(band => new SeedBand(band.Key, band.DisplayName, band.Sort))]); + if (tenant.Curriculum is { } curriculum) yield return curriculum.Taxonomy; + } + public static string[] CustomizationProjection(SeedTenant tenant) => + [.. ContentTypes(tenant).Select(type => $"content-type:{type.Key}@{tenant.TenantId}"), + .. Taxonomies(tenant).Select(taxonomy => $"taxonomy:{taxonomy.Key}@{tenant.TenantId}"), + .. Taxonomies(tenant).SelectMany(taxonomy => taxonomy.Bands.Select(band => $"band:{band.Key}@{tenant.TenantId}"))]; + public static long CustomizationGeneration(SeedTenant tenant) => + 2L * (ContentTypes(tenant).Count() + Taxonomies(tenant).Count()); + + private static SeedCurriculum EnglishCurriculum() => new( + [new("en", true, true, 0)], + new(Id("01930000-0000-7000-8000-0000000000c3"), "grammar-topic", 1, Labels("Grammar topic"), + """ + {"$schema":"https://json-schema.org/draft/2020-12/schema","type":"object", + "properties":{"concept":{"type":"string","minLength":1},"example":{"type":"string","minLength":1}}, + "required":["concept","example"],"additionalProperties":false, + "x-fields":[{"name":"concept","label":{"en":"Concept"}},{"name":"example","label":{"en":"Example"}}]} + """, "default-card"), + new(Id("01930000-0000-7000-8000-0000000000d3"), "cefr", 1, Labels("CEFR"), + [new("a1", Labels("A1 · Beginner"), 0), new("a2", Labels("A2 · Elementary"), 1), + new("b1", Labels("B1 · Intermediate"), 2), new("b2", Labels("B2 · Upper intermediate"), 3), + new("c1", Labels("C1 · Advanced"), 4), new("c2", Labels("C2 · Proficient"), 5)]), + new(Id("01930000-0000-7000-8000-0000000000e1"), + """{"primary":"#1d4ed8","background":"#ffffff","foreground":"#111827","muted":"#4b5563"}"""), + [EnglishCourse("01930000-0000-7000-8000-000000000e11", null, "foundations", "public", "Published", "a1", + "English foundations", "Build confidence with everyday English.", "foundation", + [EnglishLesson("01930000-0000-7000-8000-000000000e21", 0, "Published", "Present simple", "present-simple", + """{"concept":"Use the present simple for habits.","example":"I practise English every morning."}"""), + EnglishLesson("01930000-0000-7000-8000-000000000e22", 1, "Draft", "Questions", "questions", + """{"concept":"Begin questions with an auxiliary verb.","example":"Do you speak English?"}""")]), + EnglishCourse("01930000-0000-7000-8000-000000000e12", null, "next-steps", "public", "Draft", "a2", + "Next steps", "An upcoming course for curious learners.", "next-steps", + [EnglishLesson("01930000-0000-7000-8000-000000000e23", 0, "Published", "Past simple", "past-simple", + """{"concept":"Use the past simple for finished actions.","example":"We visited the library yesterday."}""")]), + EnglishCourse("01930000-0000-7000-8000-000000000e13", null, "guided-practice", "enrollment_required", "Published", "b1", + "Guided practice", "A published course reserved for enrolled learners.", "guided-practice", + [EnglishLesson("01930000-0000-7000-8000-000000000e24", 0, "Published", "Giving reasons", "giving-reasons", + """{"concept":"Connect an idea and its reason with because.","example":"I study English because I enjoy meeting people."}""")]), + EnglishCourse("01930000-0000-7000-8000-000000000e14", FirstBranch.OrganizationId, "branch-conversation", "public", "Published", "a1", + "Branch conversation", "Practice everyday exchanges at our branch.", "branch-conversation", + [EnglishLesson("01930000-0000-7000-8000-000000000e25", 0, "Published", "Introductions", "introductions", + """{"concept":"Introduce yourself with a greeting and your name.","example":"Hello, my name is Deniz."}""")])]); + + private static SeedCurriculum YogaCurriculum() => new( + [new("tr-TR", true, true, 0), new("en", true, false, 1)], + new(Id("01930000-0000-7000-8000-0000000000c4"), "asana-pose", 1, Labels("Asana pose", "Asana duruşu"), + """ + {"$schema":"https://json-schema.org/draft/2020-12/schema","type":"object", + "properties":{"pose":{"type":"string","minLength":1},"instruction":{"type":"string","minLength":1},"breathing":{"type":"string","minLength":1}}, + "required":["pose","instruction","breathing"],"additionalProperties":false, + "x-fields":[{"name":"pose","label":{"tr-TR":"Duruş","en":"Pose"}}, + {"name":"instruction","label":{"tr-TR":"Yönerge","en":"Instruction"}}, + {"name":"breathing","label":{"tr-TR":"Nefes","en":"Breathing"}}]} + """, "default-card"), + new(Id("01930000-0000-7000-8000-0000000000d4"), "yoga-difficulty", 1, Labels("Practice difficulty", "Pratik düzeyi"), + [new("foundation", Labels("Foundation", "Temel"), 0), new("developing", Labels("Developing", "Gelişen"), 1), + new("advanced", Labels("Advanced", "İleri"), 2)]), + new(Id("01930000-0000-7000-8000-0000000000e2"), + """{"primary":"#166534","background":"#fffbeb","foreground":"#292524","muted":"#57534e"}"""), + [YogaCourse("01930000-0000-7000-8000-000000000f11", null, "shared-practice", "public", "foundation", + "Ortak pratik", "Dengeli bir başlangıç için temel hareketler.", "ortak-pratik", + "Shared practice", "Simple movements for a balanced beginning.", "shared-practice", + [YogaLesson("01930000-0000-7000-8000-000000000f21", 0, "Dağ duruşu", "dag-durusu", + """{"pose":"Dağ duruşu","instruction":"Ayaklarını dengeli yerleştir ve sakin nefes al.","breathing":"Nefesini tutmadan doğal ritmini koru."}""", + "Mountain pose", "mountain-pose", """{"pose":"Mountain pose","instruction":"Stand evenly on both feet and breathe calmly.","breathing":"Keep a natural rhythm without holding your breath."}""")]), + YogaCourse("01930000-0000-7000-8000-000000000f12", FirstStudio.OrganizationId, "studio-foundations", "public", "foundation", + "Stüdyo temelleri", "Birinci stüdyoda temel duruşları keşfet.", "studyo-temelleri", + "Studio foundations", "Explore foundational poses in our first studio.", "foundation", + [YogaLesson("01930000-0000-7000-8000-000000000f22", 0, "Ağaç duruşu", "agac-durusu", + """{"pose":"Ağaç duruşu","instruction":"Bakışını sabit bir noktaya yönelt ve dengeni koru.","breathing":"Nefesini tutmadan doğal ritmini koru."}""", + "Tree pose", "tree-pose", """{"pose":"Tree pose","instruction":"Focus on a steady point and maintain your balance.","breathing":"Keep a natural rhythm without holding your breath."}"""), + YogaLesson("01930000-0000-7000-8000-000000000f23", 1, "Çocuk duruşu", "cocuk-durusu", + """{"pose":"Çocuk duruşu","instruction":"Gövdeni rahatlat ve nefesini yavaşlat.","breathing":"Nefesini tutmadan doğal ritmini koru."}""", + "Child pose", "child-pose", """{"pose":"Child pose","instruction":"Relax your torso and slow your breathing.","breathing":"Keep a natural rhythm without holding your breath."}""")]), + YogaCourse("01930000-0000-7000-8000-000000000f13", SecondStudio.OrganizationId, "studio-flow", "public", "developing", + "Stüdyo akışı", "İkinci stüdyoda hareketleri nefesle birleştir.", "studyo-akisi", + "Studio flow", "Connect movement with breath in our second studio.", "studio-flow", + [YogaLesson("01930000-0000-7000-8000-000000000f24", 0, "Savaşçı duruşu", "savasci-durusu", + """{"pose":"Savaşçı duruşu","instruction":"Dizini ayağınla hizala ve kollarını aç.","breathing":"Nefesini tutmadan doğal ritmini koru."}""", + "Warrior pose", "warrior-pose", """{"pose":"Warrior pose","instruction":"Align your knee with your foot and open your arms.","breathing":"Keep a natural rhythm without holding your breath."}""")]), + YogaCourse("01930000-0000-7000-8000-000000000f14", FirstStudio.OrganizationId, "guided-flow", "enrollment_required", "developing", + "Rehberli akış", "Kayıtlı öğrenciler için yayımlanmış pratik.", "rehberli-akis", + "Guided flow", "Published practice for enrolled learners.", "guided-flow", + [YogaLesson("01930000-0000-7000-8000-000000000f25", 0, "Köprü duruşu", "kopru-durusu", + """{"pose":"Köprü duruşu","instruction":"Kalçanı yavaşça kaldır ve omuzlarını gevşet.","breathing":"Nefesini tutmadan doğal ritmini koru."}""", + "Bridge pose", "bridge-pose", """{"pose":"Bridge pose","instruction":"Lift your hips slowly and relax your shoulders.","breathing":"Keep a natural rhythm without holding your breath."}""")])]); + + private static SeedCourse EnglishCourse(string id, OrganizationId? organization, string key, string access, + string status, string band, string title, string summary, string slug, ImmutableArray lessons) => + new(Id(id), organization, key, access, "cefr", 1, band, status, + [new("en", title, summary, slug)], lessons); + private static SeedLesson EnglishLesson(string id, int sort, string status, string title, string slug, string body) => + new(Id(id), sort, "grammar-topic", 1, status, [new("en", title, slug, body)]); + private static SeedCourse YogaCourse(string id, OrganizationId? organization, string key, string access, + string band, string trTitle, string trSummary, string trSlug, string enTitle, string enSummary, string enSlug, + ImmutableArray lessons) => new(Id(id), organization, key, access, "yoga-difficulty", 1, band, "Published", + [new("tr-TR", trTitle, trSummary, trSlug), new("en", enTitle, enSummary, enSlug)], lessons); + private static SeedLesson YogaLesson(string id, int sort, string trTitle, string trSlug, string trBody, + string enTitle, string enSlug, string enBody) => new(Id(id), sort, "asana-pose", 1, "Published", + [new("tr-TR", trTitle, trSlug, trBody), new("en", enTitle, enSlug, enBody)]); + private static Guid Id(string value) => Guid.Parse(value); + private static ImmutableDictionary Labels(string en, string? tr = null) + { + var labels = ImmutableDictionary.Empty.WithComparers(StringComparer.Ordinal).Add("en", en); + return tr is null ? labels : labels.Add("tr-TR", tr); + } +} -public sealed record SeedOrganization( - OrganizationId OrganizationId, string Slug, string DisplayName); +public sealed record SeedTenant(TenantId TenantId, string Slug, string DisplayName, + SeedOrganization DefaultOrganization, SeedOrganization SecondOrganization, string Host, + bool MapHostToDefaultOrganization, Guid BuiltInContentTypeId, Guid BuiltInTaxonomyId, + SeedCurriculum? Curriculum = null); +public sealed record SeedOrganization(OrganizationId OrganizationId, string Slug, string DisplayName); +public sealed record SeedCurriculum(ImmutableArray Locales, SeedContentType ContentType, + SeedTaxonomy Taxonomy, SeedTheme Theme, ImmutableArray Courses); +public sealed record SeedLocale(string Locale, bool IsEnabled, bool IsDefault, short Sort); +public sealed record SeedContentType(Guid Id, string Key, int SchemaVersion, + IReadOnlyDictionary DisplayName, string JsonSchema, string RendererKey); +public sealed record SeedTaxonomy(Guid Id, string Key, int SchemaVersion, + IReadOnlyDictionary DisplayName, ImmutableArray Bands); +public sealed record SeedBand(string Key, IReadOnlyDictionary DisplayName, short Sort, string? Metadata = null); +public sealed record SeedTheme(Guid Id, string Value); +public sealed record SeedCourse(Guid Id, OrganizationId? OrganizationId, string SlugKey, string ContentAccess, + string LevelTaxonomyKey, int LevelTaxonomySchemaVersion, string LevelBandKey, string Status, + ImmutableArray Translations, ImmutableArray Lessons); +public sealed record SeedCourseTranslation(string Locale, string Title, string? Summary, string Slug); +public sealed record SeedLesson(Guid Id, int Sort, string ContentTypeKey, int ContentTypeSchemaVersion, + string Status, ImmutableArray Translations); +public sealed record SeedLessonTranslation(string Locale, string Title, string Slug, string Body); +public sealed record SeedInventory(int Tenants, int Organizations, int Hosts, int Locales, + int ContentTypes, int Taxonomies, int Bands, int Themes, int Courses, int Lessons, int Translations); diff --git a/backend/src/LearnStack.Tools.Seeder/SeedRunner.cs b/backend/src/LearnStack.Tools.Seeder/SeedRunner.cs index 9be330c6..1acb8a29 100644 --- a/backend/src/LearnStack.Tools.Seeder/SeedRunner.cs +++ b/backend/src/LearnStack.Tools.Seeder/SeedRunner.cs @@ -1,454 +1,205 @@ using LearnStack.Modules.Customization.Application.Contracts.Customization; -using LearnStack.Modules.Customization.Infrastructure.Persistence; +using LearnStack.Modules.Customization.Application.Contracts.Seeding; +using LearnStack.Modules.Education.Application.Contracts.Courses; +using LearnStack.Modules.Education.Application.Contracts.Lessons; +using LearnStack.Modules.Education.Application.Contracts.Seeding; +using LearnStack.Modules.Tenancy.Application.Contracts.Branding; +using LearnStack.Modules.Tenancy.Application.Contracts.Locales; +using LearnStack.Modules.Tenancy.Application.Contracts.Seeding; using LearnStack.Modules.Tenancy.Application.Contracts.Tenant; using LearnStack.SharedKernel.Identifiers; using LearnStack.SharedKernel.Results; -using LearnStack.Modules.Tenancy.Infrastructure.Persistence; -using LearnStack.SharedKernel.Persistence; using LearnStack.SharedKernel.Tenancy; -using Microsoft.EntityFrameworkCore; using MediatR; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; namespace LearnStack.Tools.Seeder; -/// -/// Writes the two demo tenants by sending the same commands a request would. -/// +/// Converges declared seed acts through contextual requests and exact postconditions. /// -/// -/// It sends commands; it does not write rows. -/// [ADR-0042](../../../docs/decisions/0042-tenant-provisioning-cross-aggregate-transaction.md) -/// requires it: a seeder that inserted the tenant and its default organization itself -/// would be a second copy of the one sanctioned cross-aggregate write, and the allow-list -/// that keeps that exception at one entry would no longer describe the system. Sending the -/// command also means the seed exercises the pipeline production uses — validation, the -/// transaction, the announcement — so a seed that succeeds is evidence about the request -/// path and not only about the schema. -/// -/// -/// Each tenant is seeded in two acts, under two different contexts. Provisioning -/// runs unresolved, because the tenant it announces does not exist until it -/// commits. Everything after runs as that tenant: the second organization and the -/// host row are ordinary tenant-owned writes, and their policies check the row against the -/// announcement. Setting the accessor is how a non-request execution says which tenant it -/// is acting for — the same thing a background job does. -/// -/// -/// It never writes ITenantContextAccessor.Current. Writes to that member -/// are a closed, enumerated set of four -/// ([ADR-0036 Amendment 2](../../../docs/decisions/0036-tenant-resolution-trusted-inputs.md)), -/// because a writer of it can make work run under a tenant nothing resolved. A seeder -/// legitimately does that — it is the same shape as the Hangfire job activator — but -/// widening a security enumeration for a development tool is the wrong trade when the -/// alternative costs nothing: each act composes a scope around a -/// StaticTenantContextAccessor holding its context, so the seeder constructs -/// a context per unit of work instead of mutating an ambient one, and cannot move -/// the ambient tenant at all. -/// -/// -/// Idempotent by conflict, not by pre-check. Re-running is expected — make seed -/// is documented as safe to repeat — and a "does it exist already?" query cannot be asked: -/// under the provisioning announcement a SELECT over tenants returns no rows -/// by policy. So the seeder writes and treats a uniqueness refusal as "already seeded", -/// which is the same answer with one fewer round trip and no race. -/// -/// -/// A uniqueness refusal, read from the field-level reason — not from the -/// top-level code. business_rule_violation was a safe proxy while provisioning -/// was the only command: every cause of it really was "this row exists". It stopped being -/// one when MapHostToTenantCommand landed, which returns the same top-level code -/// for a host already taken, an organization that is not this tenant's, and a host the -/// deployment reserved. Only the first is "already seeded". Measured: with the proxy in -/// place, a wrong organization id in SeedData — a plausible copy-paste between two -/// tenants declared side by side — made the seeder log "already present", exit 0, and -/// never write the row that decides whose data an anonymous request sees. -/// +/// Each request owns a fresh composed scope. Completed acts skip before sending a writer, +/// including published translations. A typed race gets one fresh completed-state check, +/// never a blind retry, overwrite, scope setter, database context or private transaction. /// -public sealed class SeedRunner( - Func compose, ILogger logger) +public sealed class SeedRunner(Func compose, ILogger logger) { - /// Seeds , defaulting to the two demo tenants. - /// - /// The list is a parameter rather than a direct read of so - /// the classification below can be driven with data that fails for a reason other - /// than uniqueness. Without it the only way to reach that branch was to edit the - /// shipped seed, and the branch went untested — which is how the masking defect it - /// now guards against survived a review round. - /// - public async Task RunAsync( - CancellationToken cancellationToken, IReadOnlyList? tenants = null) + public async Task RunAsync(CancellationToken cancellationToken, IReadOnlyList? tenants = null) { - foreach (var tenant in tenants ?? SeedData.All) - { - await SeedTenantAsync(tenant, cancellationToken); - } - + var declared = tenants ?? SeedData.All; + foreach (var tenant in declared) SeedVerification.Declaration(tenant); + foreach (var tenant in declared) await SeedTenantAsync(tenant, cancellationToken); return 0; } - private async Task SeedTenantAsync(SeedTenant tenant, CancellationToken cancellationToken) + private async Task SeedTenantAsync(SeedTenant tenant, CancellationToken ct) { - // Act one, unresolved: the tenant and its default organization, on one - // transaction, announced with the id being created. - await SendAsync( - tenant, - context: null, - new ProvisionTenantCommand( - tenant.TenantId, - tenant.Slug, - tenant.DisplayName, - tenant.DefaultOrganization.OrganizationId, - tenant.DefaultOrganization.Slug, - tenant.DefaultOrganization.DisplayName), - "tenant", - cancellationToken); - - // Act two, as the tenant: writes the policies check against the announcement. - var asTenant = new SeedTenantContext( - tenant.TenantId, tenant.DefaultOrganization.OrganizationId); - - await SendAsync( - tenant, - asTenant, - new CreateOrganizationCommand( - tenant.SecondOrganization.OrganizationId, - tenant.SecondOrganization.Slug, - tenant.SecondOrganization.DisplayName), - SecondOrganizationAct, - cancellationToken); - - await SendAsync( - tenant, - asTenant, - new MapHostToTenantCommand( - tenant.Host, - tenant.MapHostToDefaultOrganization - ? tenant.DefaultOrganization.OrganizationId - : null, - IsActive: true, - IsPubliclyLive: true), - HostMappingAct, - cancellationToken); - - // Act three, still as the tenant: the two built-ins, registered and then - // published, so a tenant that has authored nothing still has a live content - // type and a live level vocabulary for the runtime to resolve. Both go in - // through the same four commands a tenant admin uses — nothing here writes a - // row the ordinary path could not. - await SeedBuiltInsAsync(tenant, asTenant, cancellationToken); + var context = new SeedTenantContext(tenant.TenantId, null); + async Task ReadTenant() => (await ReadAsync(context, new GetTenantSeedStateQuery(), ct)).State; + await ActAsync(tenant, context, "tenant", ReadTenant, row => SeedVerification.Tenant(row, tenant), + _ => new ProvisionTenantCommand(tenant.TenantId, tenant.Slug, tenant.DisplayName, + tenant.DefaultOrganization.OrganizationId, tenant.DefaultOrganization.Slug, tenant.DefaultOrganization.DisplayName), ct, + unresolvedWrite: true); + var defaultOrg = (await ReadAsync(context, new GetOrganizationSeedStateQuery(tenant.DefaultOrganization.OrganizationId), ct)).State; + SeedVerification.Require(SeedVerification.Organization(defaultOrg, tenant.DefaultOrganization, tenant, "default organization"), tenant, "default organization"); + await ActAsync(tenant, context, "second organization", + async () => (await ReadAsync(context, new GetOrganizationSeedStateQuery(tenant.SecondOrganization.OrganizationId), ct)).State, + row => SeedVerification.Organization(row, tenant.SecondOrganization, tenant, "second organization"), + _ => new CreateOrganizationCommand(tenant.SecondOrganization.OrganizationId, tenant.SecondOrganization.Slug, tenant.SecondOrganization.DisplayName), ct); + await ActAsync(tenant, context, "host mapping", + async () => (await ReadAsync(context, new GetHostMappingSeedStateQuery(tenant.Host), ct)).State, + row => SeedVerification.Host(row, tenant), + _ => new MapHostToTenantCommand(tenant.Host, tenant.MapHostToDefaultOrganization ? tenant.DefaultOrganization.OrganizationId : null, true, true), ct); + + if (tenant.Curriculum is { } curriculum) + foreach (var locale in curriculum.Locales.OrderByDescending(locale => locale.IsDefault).ThenBy(locale => locale.Sort)) + await ActAsync(tenant, context, "locale", ReadTenant, row => SeedVerification.Locale(row, locale, tenant), + row => new AddTenantLocaleCommand(Version(row), locale.Locale, locale.IsEnabled, locale.IsDefault, locale.Sort), ct); + foreach (var type in SeedData.ContentTypes(tenant)) await SeedContentTypeAsync(tenant, context, type, ct); + foreach (var taxonomy in SeedData.Taxonomies(tenant)) await SeedTaxonomyAsync(tenant, context, taxonomy, ct); + if (tenant.Curriculum is not { } content) return; + await ActAsync(tenant, context, "branding", + async () => (await ReadAsync(context, new GetSettingSeedStateQuery(content.Theme.Id), ct)).State, + row => SeedVerification.Theme(row, content.Theme, tenant), + _ => new SetTenantBrandingCommand(content.Theme.Id, content.Theme.Value, null), ct); + foreach (var course in content.Courses) await SeedCourseAsync(tenant, course, ct); + foreach (var course in content.Courses) await PublishCourseAsync(tenant, course, ct); } - private async Task SeedBuiltInsAsync( - SeedTenant tenant, ITenantContext asTenant, CancellationToken cancellationToken) + private async Task SeedContentTypeAsync(SeedTenant tenant, ITenantContext context, SeedContentType type, CancellationToken ct) { - await SendAsync( - tenant, - asTenant, - new RegisterTenantContentTypeCommand( - tenant.BuiltInContentTypeId, - BuiltInCustomizations.Card.Key, - BuiltInCustomizations.SchemaVersion, - BuiltInCustomizations.Card.DisplayName, - BuiltInCustomizations.Card.JsonSchema, - BuiltInCustomizations.Card.RendererKey), - ContentTypeAct, - cancellationToken); - - await SendAsync( - tenant, - asTenant, - new PublishTenantContentTypeCommand(tenant.BuiltInContentTypeId), - ContentTypePublishAct, - cancellationToken); - - await SendAsync( - tenant, - asTenant, - new RegisterTenantLevelTaxonomyCommand( - tenant.BuiltInTaxonomyId, - BuiltInCustomizations.Plain.Key, - BuiltInCustomizations.SchemaVersion, - BuiltInCustomizations.Plain.DisplayName, - [.. BuiltInCustomizations.Plain.Bands.Select(band => - new TaxonomyItemInput(band.Key, band.DisplayName, band.Sort))]), - TaxonomyAct, - cancellationToken); - - await SendAsync( - tenant, - asTenant, - new PublishTenantLevelTaxonomyCommand(tenant.BuiltInTaxonomyId), - TaxonomyPublishAct, - cancellationToken); + async Task Read() + { + var active = (await ReadAsync(context, new GetActiveContentTypeSeedRevisionQuery(type.Key), ct)).State; + SeedVerification.Require(active is null || active.Id == type.Id, tenant, "content type active revision"); + return (await ReadAsync(context, new GetContentTypeSeedStateQuery(type.Id), ct)).State; + } + await ActAsync(tenant, context, "content type", Read, row => SeedVerification.ContentType(row, type, tenant, false), + _ => new RegisterTenantContentTypeCommand(type.Id, type.Key, type.SchemaVersion, type.DisplayName, type.JsonSchema, type.RendererKey), ct); + await ActAsync(tenant, context, "content type publication", Read, row => SeedVerification.ContentType(row, type, tenant, true), + _ => new PublishTenantContentTypeCommand(type.Id, RequireNoIncumbent: true), ct); } - /// - /// Sends one command in a scope composed around . - /// - /// - /// - /// One scope per command, because a scope is one connection and one transaction under - /// [ADR-0040](../../../docs/decisions/0040-ambient-unit-of-work.md), and these three - /// commands are three units of work with different announcements. Sharing a scope - /// would put the second act on the transaction the first already committed. - /// - /// - /// The context is supplied by composition rather than assignment — see the class - /// remarks. A null one is an unresolved context, which is what provisioning needs and - /// what every other act must not get: an earlier version assigned only when non-null, - /// left the previous act's tenant in place, and the SECOND tenant's provisioning ran - /// announced as the FIRST. The database refused it 42501, which is the confused-deputy - /// guard working on the seeder's own bug; composing the value makes the state - /// unreachable rather than caught. - /// - /// - private async Task SendAsync( - SeedTenant tenant, - ITenantContext? context, - IRequest> command, - string what, - CancellationToken cancellationToken) + private async Task SeedTaxonomyAsync(SeedTenant tenant, ITenantContext context, SeedTaxonomy taxonomy, CancellationToken ct) { - await using var provider = compose(context); - await using var scope = provider.CreateAsyncScope(); - - var result = await scope.ServiceProvider.GetRequiredService() - .Send(command, cancellationToken); - - if (result.IsSuccess) + async Task Read() { - SeedRunnerLog.Seeded(logger, what, tenant.Slug); - return; + var active = (await ReadAsync(context, new GetActiveTaxonomySeedRevisionQuery(taxonomy.Key), ct)).State; + SeedVerification.Require(active is null || active.Id == taxonomy.Id, tenant, "level taxonomy active revision"); + return (await ReadAsync(context, new GetTaxonomySeedStateQuery(taxonomy.Id), ct)).State; } + await ActAsync(tenant, context, "level taxonomy", Read, row => SeedVerification.Taxonomy(row, taxonomy, tenant, false), + _ => new RegisterTenantLevelTaxonomyCommand(taxonomy.Id, taxonomy.Key, taxonomy.SchemaVersion, taxonomy.DisplayName, + [.. taxonomy.Bands.Select(band => new TaxonomyItemInput(band.Key, band.DisplayName, band.Sort, band.Metadata))]), ct); + await ActAsync(tenant, context, "level taxonomy publication", Read, row => SeedVerification.Taxonomy(row, taxonomy, tenant, true), + _ => new PublishTenantLevelTaxonomyCommand(taxonomy.Id, RequireNoIncumbent: true), ct); + } - // A uniqueness refusal is what a second run looks like, and it is the expected - // outcome of one. Anything else — a validation failure, a policy denial, an - // organization that is not this tenant's — is a seed that did not do its job, and - // the process exits non-zero on it. - // - // "Taken" is not the same as "taken by us", and the difference matters most for - // the host: `platform_host_to_tenant`'s primary key is the host, globally, so a - // conflict is equally consistent with our own prior run and with another tenant - // holding the name. Verified rather than assumed — and RLS is what makes the - // verification cheap, because under this tenant's own announcement the row is - // visible only if the row is this tenant's. - if (IsAlreadySeeded(result.Error!)) + private async Task SeedCourseAsync(SeedTenant tenant, SeedCourse course, CancellationToken ct) + { + var context = new SeedTenantContext(tenant.TenantId, course.OrganizationId); + async Task Read() => (await ReadAsync(context, new GetCourseSeedStateQuery(course.Id), ct)).State; + await ActAsync(tenant, context, "course", Read, row => SeedVerification.Course(row, course, tenant), + _ => new CreateCourseCommand(course.Id, course.SlugKey, course.ContentAccess, + course.LevelTaxonomyKey, course.LevelTaxonomySchemaVersion, course.LevelBandKey), ct); + foreach (var translation in course.Translations) + await ActAsync(tenant, context, "course translation", Read, + row => SeedVerification.CourseTranslation(row, course, translation, tenant), + row => new AddCourseTranslationCommand(course.Id, Version(row), translation.Locale, translation.Title, translation.Summary, translation.Slug), ct); + foreach (var lesson in course.Lessons) { - if (!await OwnsWhatConflictedAsync(tenant, context, what, cancellationToken)) - { - throw new InvalidOperationException( - $"Seeding the {what} for '{tenant.Slug}' hit a uniqueness conflict, and " - + "the row that holds the name is not this tenant's. The seed would " - + "report success while pointing at somebody else's data; fix the " - + "conflict and re-run."); - } - - SeedRunnerLog.AlreadyPresent(logger, what, tenant.Slug); - return; + async Task ReadLesson() => (await ReadAsync(context, new GetLessonSeedStateQuery(lesson.Id), ct)).State; + await ActAsync(tenant, context, "lesson", ReadLesson, row => SeedVerification.Lesson(row, course, lesson, tenant), + _ => new CreateLessonCommand(lesson.Id, course.Id, lesson.Sort, lesson.ContentTypeKey, lesson.ContentTypeSchemaVersion), ct); + foreach (var translation in lesson.Translations) + await ActAsync(tenant, context, "lesson translation", ReadLesson, + row => SeedVerification.LessonTranslation(row, course, lesson, translation, tenant), + row => new AddLessonTranslationCommand(lesson.Id, Version(row), translation.Locale, translation.Title, translation.Slug, translation.Body), ct); } - - throw new InvalidOperationException( - $"Seeding the {what} for '{tenant.Slug}' failed with '{result.Error.Code}'. " - + "The seed is not idempotent past this point; fix the cause and re-run."); } - /// The label for the act that adds a tenant's second organization. - private const string SecondOrganizationAct = "second organization"; - - /// The label for the act that points a host at the tenant. - private const string HostMappingAct = "host mapping"; - - /// The labels for the four acts that install the built-in customizations. - private const string ContentTypeAct = "built-in content type"; - - private const string ContentTypePublishAct = "built-in content type publication"; - - private const string TaxonomyAct = "built-in level taxonomy"; - - private const string TaxonomyPublishAct = "built-in level taxonomy publication"; + private async Task PublishCourseAsync(SeedTenant tenant, SeedCourse course, CancellationToken ct) + { + var context = new SeedTenantContext(tenant.TenantId, course.OrganizationId); + foreach (var lesson in course.Lessons) + { + async Task ReadLesson() => (await ReadAsync(context, new GetLessonSeedStateQuery(lesson.Id), ct)).State; + if (lesson.Status == "Published") + await ActAsync(tenant, context, "lesson publication", ReadLesson, + row => SeedVerification.LessonPublication(row, course, lesson, tenant), + row => new PublishLessonCommand(lesson.Id, Version(row)), ct); + else SeedVerification.Require(SeedVerification.LessonPublication(await ReadLesson(), course, lesson, tenant), tenant, "lesson final state"); + } + async Task ReadCourse() => (await ReadAsync(context, new GetCourseSeedStateQuery(course.Id), ct)).State; + if (course.Status == "Published") + await ActAsync(tenant, context, "course publication", ReadCourse, + row => SeedVerification.CoursePublication(row, course, tenant), + row => new PublishCourseCommand(course.Id, Version(row)), ct); + else SeedVerification.Require(SeedVerification.CoursePublication(await ReadCourse(), course, tenant), tenant, "course final state"); + } - /// - /// Whether the rows that conflicted belong to . - /// - /// - /// - /// Read under the tenant's own announcement, which is the whole trick: every table - /// this checks is tenant-owned or, for the host index, admits a row on - /// tenant_id = app.tenant_id. So "can I see it?" and "is it mine?" are the same - /// question, and the policies answer it without the seeder needing a cross-tenant - /// credential it should not have. - /// - /// - /// The provisioning act runs unresolved and cannot ask — but it does not need to: it - /// conflicts on its own registry-assigned id, which is a fixed literal in - /// , so a primary-key conflict there IS the prior run. Only the - /// acts that run as the tenant reach this. - /// - /// - private async Task OwnsWhatConflictedAsync( - SeedTenant tenant, ITenantContext? context, string what, CancellationToken cancellationToken) + private async Task ActAsync(SeedTenant tenant, ITenantContext context, string act, + Func> read, Func completed, Func>> command, + CancellationToken ct, bool unresolvedWrite = false) where TState : class { - if (context is null) + var before = await read(); + if (completed(before)) { - return true; + SeedRunnerLog.AlreadyPresent(logger, act, tenant.Slug); + return; } + var result = await SendAsync(unresolvedWrite ? null : context, command(before), ct); + if (!result.IsSuccess && (result.Error is not { } error || !IsRace(error))) + throw new InvalidOperationException($"Seeding the {act} for '{tenant.Slug}' failed with '{result.Error?.Code}'. Resolve the cause and re-run."); + // The writer has committed or returned a typed race. Neither outcome proves + // the declared act completed; read its exact postcondition in a fresh scope. + SeedVerification.Require(completed(await read()), tenant, act); + if (result.IsSuccess) SeedRunnerLog.Seeded(logger, act, tenant.Slug); + else SeedRunnerLog.AlreadyPresent(logger, act, tenant.Slug); + } + private async Task ReadAsync(ITenantContext context, IRequest> query, CancellationToken ct) + { + var result = await SendAsync(context, query, ct); + if (result.IsSuccess && result.Value is { } value) return value; + throw new InvalidOperationException($"Reading seed verification failed with '{result.Error?.Code}'."); + } + private async Task> SendAsync(ITenantContext? context, IRequest> request, CancellationToken ct) + { await using var provider = compose(context); await using var scope = provider.CreateAsyncScope(); - - var unitOfWork = scope.ServiceProvider.GetRequiredService(); - await using var frame = await unitOfWork.BeginTransactionAsync(cancellationToken); - await unitOfWork.SetTenantContextAsync(context, cancellationToken); - - // Both through local functions, so an act resolves only the context it reads. - // Building a module context is not free — measured at 14-19 ms, the same order - // as the transaction it sits beside — and two of the four arms below never - // touch the tenancy one. - static TenancyDbContext Tenancy(AsyncServiceScope scope) => - scope.ServiceProvider.GetRequiredService(); - - static CustomizationDbContext Customization(AsyncServiceScope scope) => - scope.ServiceProvider.GetRequiredService(); - - // The act that conflicted, and only that act. An earlier version asked "do we own - // either?" and the OR let the organization we had just created vouch for a host - // another tenant held — the verification passing on the strength of an unrelated - // row is exactly the failure it exists to prevent. - var owned = what switch - { - HostMappingAct => await Tenancy(scope).PlatformHostMappings - .AnyAsync(mapping => mapping.Host == tenant.Host, cancellationToken), - - SecondOrganizationAct => await Tenancy(scope).Organizations - .AnyAsync( - organization => organization.Slug == tenant.SecondOrganization.Slug, - cancellationToken), - - // The customization keys are per tenant rather than global, so a visible - // row under this announcement is this tenant's by construction — the same - // trick the two above use, on a narrower key. - ContentTypeAct or ContentTypePublishAct => await Customization(scope) - .TenantContentTypes.AnyAsync( - contentType => contentType.Key == BuiltInCustomizations.Card.Key, - cancellationToken), - - TaxonomyAct or TaxonomyPublishAct => await Customization(scope) - .TenantLevelTaxonomies.AnyAsync( - taxonomy => taxonomy.Key == BuiltInCustomizations.Plain.Key, - cancellationToken), - - // No other act runs with a resolved context, so nothing else reaches here. - _ => throw new ArgumentOutOfRangeException( - nameof(what), what, "No ownership check is defined for that act."), - }; - - await frame.FailAsync(CancellationToken.None); - - return owned; + return await scope.ServiceProvider.GetRequiredService().Send(request, ct); } - - /// - /// Whether says the row this act writes already exists. - /// - /// - /// Read from the field-level reasons rather than the top-level code, because the top - /// level says only business_rule_violation and three different conditions - /// produce it. These three are the uniqueness ones; a fourth reason under the same - /// code — lockey_organization_not_in_tenant, lockey_host_reserved — - /// deliberately falls through to the throw. - /// - private static bool IsAlreadySeeded(Error error) => - error.Details is { } details - && details.Values.SelectMany(reasons => reasons).Any(reason => - AlreadyExists.Contains(reason.Key)); - - private static readonly HashSet AlreadyExists = new(StringComparer.Ordinal) + private static long Version(TenantSeedDto? row) => row?.Version ?? throw new InvalidOperationException("Seed tenant is absent."); + private static long Version(CourseSeedDto? row) => row?.Version ?? throw new InvalidOperationException("Seed course is absent."); + private static long Version(LessonSeedDto? row) => row?.Version ?? throw new InvalidOperationException("Seed lesson is absent."); + private static bool IsRace(Error error) => error.Code == "concurrency_conflict" + || error.Code == "business_rule_violation" && error.Details is { } details + && details.Values.SelectMany(reasons => reasons).Any(reason => RaceReasons.Contains(reason.Key)); + private static readonly HashSet RaceReasons = new(StringComparer.Ordinal) { - "lockey_slug_taken", - "lockey_identifier_taken", - "lockey_host_taken", - - // The customization acts. A second run's PUBLISH refuses because the - // definition it names is already Active — that one is reached on every - // repeat, and without it `make seed` would throw the second time it ran. - // - // The two uniqueness reasons are the REGISTER side. The shipped seed carries - // a fixed id, so a repeat collides on the primary key and reports - // `lockey_identifier_taken` above; these two are what a register hits when - // the id differs and the KEY is what is taken — a hand-edited SeedData, or a - // tenant that authored its own `card` before the seeder reached it. - // - // In that second case the seeder STOPS, and the stop is one act later than - // it looks: the register is classified "already present", and the publish - // then names the fixed id, cannot see a row under it, and answers - // `not_found`, which is not in this set. That is the right outcome and not - // a gap — the built-in did not get installed, and a seed that exits 0 - // having installed nothing is the masking defect the ownership check exists - // to prevent. Resolving the tenant's own id and publishing that instead - // would be a different decision, and no shipped path can reach the case: - // the four commands have no HTTP endpoint, so the seeder is the only writer - // of a customization row. - "lockey_schema_version_taken", - "lockey_customization_key_already_live", - "lockey_customization_not_a_draft", + "lockey_slug_taken", "lockey_identifier_taken", "lockey_host_taken", "lockey_schema_version_taken", + "lockey_customization_key_already_live", "lockey_customization_not_a_draft", "lockey_locale_taken", "lockey_setting_taken", + "lockey_education_locale_already_exists", "lockey_education_translation_requires_draft", "lockey_education_publish_requires_draft", }; } -/// Source-generated logging, per the house CA1848 rule. public static partial class SeedRunnerLog { - [LoggerMessage(EventId = 7002, Level = LogLevel.Information, - Message = "Seeded {What} for {Slug}.")] + [LoggerMessage(EventId = 7002, Level = LogLevel.Information, Message = "Seeded {What} for {Slug}.")] public static partial void Seeded(ILogger logger, string what, string slug); - - [LoggerMessage(EventId = 7003, Level = LogLevel.Information, - Message = "{What} for {Slug} already present; leaving it alone.")] + [LoggerMessage(EventId = 7003, Level = LogLevel.Information, Message = "{What} for {Slug} already present; leaving it alone.")] public static partial void AlreadyPresent(ILogger logger, string what, string slug); } -/// -/// The tenant the seeder is currently acting for. -/// -/// -/// UserId is null, so every write is attributed to UserId.SystemActor by the -/// handlers — which is correct and not a shortcut: there is no user in a tenant the seeder -/// just created, and [Audit Coverage](../../../docs/standards/18-audit-coverage.md) puts -/// non-request execution under an actor of type system. -/// -public sealed class SeedTenantContext(TenantId tenantId, OrganizationId organizationId) - : ITenantContext +/// Trusted non-request execution, with the exact nullable organization of each act. +public sealed class SeedTenantContext(TenantId tenantId, OrganizationId? organizationId) : ITenantContext { public bool IsResolved => true; - - /// - /// — the origin for execution with no - /// request behind it. - /// - /// - /// Not decoration: TenantContextBehavior's second gate switches over stated - /// origins and fails closed on null, so a context that omitted this is refused - /// with the same 404 an unresolvable host gets — measured, as the seeder's first run. - /// Ambient is the same value EventTenantContext states, and for the same - /// reason: an integration-event consumer and a seeder are both LearnStack acting for a - /// tenant with no caller to authenticate. - /// public TenantContextOrigin? Origin => TenantContextOrigin.Ambient; - public TenantId TenantId => tenantId; - public OrganizationId? OrganizationId => organizationId; - public UserId? UserId => null; - public string? CorrelationId => null; - - /// - /// null, as the request-path TenantContext also returns. This - /// context announces every write the seeder makes, and four of them are - /// Customization's — a literal here tagged those spans and any error report - /// with the wrong module, which is the one thing this field is read for. - /// public string? ModuleName => null; } diff --git a/backend/src/LearnStack.Tools.Seeder/SeedVerification.cs b/backend/src/LearnStack.Tools.Seeder/SeedVerification.cs new file mode 100644 index 00000000..9092bcc3 --- /dev/null +++ b/backend/src/LearnStack.Tools.Seeder/SeedVerification.cs @@ -0,0 +1,164 @@ +using System.Text.Json; +using System.Text.Json.Nodes; +using LearnStack.Modules.Customization.Application.Contracts.Seeding; +using LearnStack.Modules.Education.Application.Contracts.Seeding; +using LearnStack.Modules.Tenancy.Application.Contracts.Seeding; +using LearnStack.Modules.Tenancy.Application.Branding; + +namespace LearnStack.Tools.Seeder; + +/// Exact act postconditions; partial authoring is admitted only as declared data. +internal static class SeedVerification +{ + internal static void Require(bool condition, SeedTenant tenant, string act) + { + if (!condition) + throw new InvalidOperationException($"Seed mismatch in {act} for '{tenant.Slug}': the row that holds the name is not this tenant's expected identity, scope, content or state. Resolve the mismatch and re-run; seed does not overwrite it."); + } + + internal static void Declaration(SeedTenant tenant) + { + if (tenant.Curriculum is not { } curriculum) return; + var locales = curriculum.Locales.Select(locale => locale.Locale).ToHashSet(StringComparer.Ordinal); + Require(locales.Count == curriculum.Locales.Length && locales.Count > 0 + && curriculum.Locales.All(locale => locale.IsEnabled) + && curriculum.Locales.Count(locale => locale.IsDefault) == 1, tenant, "locale declaration"); + foreach (var course in curriculum.Courses) + { + Require(course.Status is "Draft" or "Published" + && course.ContentAccess is "public" or "enrollment_required" + && course.Translations.Select(translation => translation.Locale).ToHashSet(StringComparer.Ordinal).SetEquals(locales) + && course.Translations.Length == locales.Count, tenant, "course declaration"); + foreach (var lesson in course.Lessons) + Require(lesson.Status is "Draft" or "Published" + && lesson.Translations.Select(translation => translation.Locale).ToHashSet(StringComparer.Ordinal).SetEquals(locales) + && lesson.Translations.Length == locales.Count, tenant, "lesson declaration"); + } + } + + internal static bool Tenant(TenantSeedDto? row, SeedTenant expected) + { + if (row is null) return false; + Require(row.Id == expected.TenantId && row.Slug == expected.Slug && row.DisplayName == expected.DisplayName + && row.Status == "Trial" && row.DefaultOrganizationId == expected.DefaultOrganization.OrganizationId, expected, "tenant"); + if (expected.Curriculum is { } curriculum) + Require(row.Locales.All(actual => curriculum.Locales.Any(locale => LocaleMatches(actual, locale))), expected, "tenant locales"); + return true; + } + + internal static bool Organization(OrganizationSeedDto? row, SeedOrganization expected, SeedTenant tenant, string act) + { + if (row is null) return false; + Require(row.Id == expected.OrganizationId && row.TenantId == tenant.TenantId && row.Slug == expected.Slug + && row.DisplayName == expected.DisplayName && row.Status == "Active", tenant, act); + return true; + } + + internal static bool Host(HostMappingSeedDto? row, SeedTenant tenant) + { + if (row is null) return false; + Require(row.Host == tenant.Host && row.TenantId == tenant.TenantId && row.OrganizationId == + (tenant.MapHostToDefaultOrganization ? tenant.DefaultOrganization.OrganizationId : null) + && row.IsActive && row.IsPubliclyLive, tenant, "host mapping"); + return true; + } + + internal static bool Locale(TenantSeedDto? row, SeedLocale expected, SeedTenant tenant) + { + Require(Tenant(row, tenant), tenant, "tenant locales"); + return row is not null && row.Locales.Any(actual => LocaleMatches(actual, expected)); + } + private static bool LocaleMatches(TenantLocaleSeedDto row, SeedLocale expected) => + row.Locale == expected.Locale && row.IsEnabled == expected.IsEnabled && row.IsDefault == expected.IsDefault && row.Sort == expected.Sort; + + internal static bool ContentType(ContentTypeSeedDto? row, SeedContentType expected, SeedTenant tenant, bool published) + { + if (row is null) return false; + Require(row.Id == expected.Id && row.TenantId == tenant.TenantId && row.Key == expected.Key + && row.SchemaVersion == expected.SchemaVersion && JsonEqual(row.DisplayNameJson, JsonSerializer.Serialize(expected.DisplayName)) + && JsonEqual(row.JsonSchema, expected.JsonSchema) && row.RendererKey == expected.RendererKey + && row.Status is "Draft" or "Active", tenant, "content type"); + return !published || row.Status == "Active"; + } + + internal static bool Taxonomy(TaxonomySeedDto? row, SeedTaxonomy expected, SeedTenant tenant, bool published) + { + if (row is null) return false; + Require(row.Id == expected.Id && row.TenantId == tenant.TenantId && row.Key == expected.Key + && row.SchemaVersion == expected.SchemaVersion && JsonEqual(row.DisplayNameJson, JsonSerializer.Serialize(expected.DisplayName)) + && row.Status is "Draft" or "Active" && row.Items.Length == expected.Bands.Length + && row.Items.All(actual => expected.Bands.Any(band => actual.Key == band.Key && actual.Sort == band.Sort + && JsonEqual(actual.DisplayNameJson, JsonSerializer.Serialize(band.DisplayName)) && JsonEqual(actual.Metadata, band.Metadata))), tenant, "level taxonomy"); + return !published || row.Status == "Active"; + } + + internal static bool Theme(SettingSeedDto? row, SeedTheme expected, SeedTenant tenant) + { + if (row is null) return false; + Require(row.Id == expected.Id && row.TenantId == tenant.TenantId && row.OrganizationId is null + && row.Key == BrandingThemeRegistry.SettingKey && JsonEqual(row.Value, expected.Value), tenant, "branding"); + return true; + } + + internal static bool Course(CourseSeedDto? row, SeedCourse expected, SeedTenant tenant) + { + if (row is null) return false; + Require(row.Id == expected.Id && row.TenantId == tenant.TenantId && row.OrganizationId == expected.OrganizationId + && row.SlugKey == expected.SlugKey && row.ContentAccess == expected.ContentAccess + && row.LevelTaxonomyKey == expected.LevelTaxonomyKey && row.LevelTaxonomySchemaVersion == expected.LevelTaxonomySchemaVersion + && row.LevelBandKey == expected.LevelBandKey && (row.Status == "Draft" || row.Status == expected.Status) + && row.Translations.All(actual => expected.Translations.Any(translation => CourseTranslationMatches(actual, translation))), tenant, "course"); + return true; + } + + internal static bool CourseTranslation(CourseSeedDto? row, SeedCourse course, SeedCourseTranslation expected, SeedTenant tenant) + { + Require(Course(row, course, tenant), tenant, "course translation"); + if (row is null) return false; + if (row.Translations.Any(actual => CourseTranslationMatches(actual, expected))) return true; + Require(row.Status == "Draft", tenant, "course translation"); + return false; + } + private static bool CourseTranslationMatches(CourseTranslationSeedDto row, SeedCourseTranslation expected) => + row.Locale == expected.Locale && row.Title == expected.Title && row.Summary == expected.Summary && row.Slug == expected.Slug; + + internal static bool CoursePublication(CourseSeedDto? row, SeedCourse expected, SeedTenant tenant) + { + Require(Course(row, expected, tenant) && row is not null && row.Translations.Length == expected.Translations.Length, tenant, "course publication"); + return row is not null && row.Status == expected.Status; + } + + internal static bool Lesson(LessonSeedDto? row, SeedCourse parent, SeedLesson expected, SeedTenant tenant) + { + if (row is null) return false; + Require(row.Id == expected.Id && row.TenantId == tenant.TenantId && row.OrganizationId == parent.OrganizationId + && row.CourseId == parent.Id && row.Sort == expected.Sort && row.ContentTypeKey == expected.ContentTypeKey + && row.ContentTypeSchemaVersion == expected.ContentTypeSchemaVersion && (row.Status == "Draft" || row.Status == expected.Status) + && row.Translations.All(actual => expected.Translations.Any(translation => LessonTranslationMatches(actual, translation))), tenant, "lesson"); + return true; + } + + internal static bool LessonTranslation(LessonSeedDto? row, SeedCourse parent, SeedLesson lesson, SeedLessonTranslation expected, SeedTenant tenant) + { + Require(Lesson(row, parent, lesson, tenant), tenant, "lesson translation"); + if (row is null) return false; + if (row.Translations.Any(actual => LessonTranslationMatches(actual, expected))) return true; + Require(row.Status == "Draft", tenant, "lesson translation"); + return false; + } + private static bool LessonTranslationMatches(LessonTranslationSeedDto row, SeedLessonTranslation expected) => + row.Locale == expected.Locale && row.Title == expected.Title && row.Slug == expected.Slug && JsonEqual(row.Body, expected.Body); + + internal static bool LessonPublication(LessonSeedDto? row, SeedCourse parent, SeedLesson expected, SeedTenant tenant) + { + Require(Lesson(row, parent, expected, tenant) && row is not null && row.Translations.Length == expected.Translations.Length, tenant, "lesson publication"); + return row is not null && row.Status == expected.Status; + } + + internal static bool JsonEqual(string? left, string? right) + { + if (left is null || right is null) return left == right; + try { return JsonNode.DeepEquals(JsonNode.Parse(left), JsonNode.Parse(right)); } + catch (JsonException) { return false; } + } +} diff --git a/backend/src/Modules/Customization/LearnStack.Modules.Customization.Application.Contracts/Customization/TenantContentTypeCommands.cs b/backend/src/Modules/Customization/LearnStack.Modules.Customization.Application.Contracts/Customization/TenantContentTypeCommands.cs index 5bc10e75..85c3dcae 100644 --- a/backend/src/Modules/Customization/LearnStack.Modules.Customization.Application.Contracts/Customization/TenantContentTypeCommands.cs +++ b/backend/src/Modules/Customization/LearnStack.Modules.Customization.Application.Contracts/Customization/TenantContentTypeCommands.cs @@ -77,8 +77,13 @@ public sealed record RegisterTenantContentTypeCommand( /// the retirement is this command's work, and the index is what catches the case /// where it did not happen. /// +/// The draft revision to publish. +/// +/// When true, refuse any other Active revision before mutation. Default false +/// preserves ordinary revision succession; convergence callers never retire one. +/// public sealed record PublishTenantContentTypeCommand( - Guid ContentTypeId) : IRequest>; + Guid ContentTypeId, bool RequireNoIncumbent = false) : IRequest>; /// What the caller now has. public sealed record TenantContentTypeDto( diff --git a/backend/src/Modules/Customization/LearnStack.Modules.Customization.Application.Contracts/Customization/TenantLevelTaxonomyCommands.cs b/backend/src/Modules/Customization/LearnStack.Modules.Customization.Application.Contracts/Customization/TenantLevelTaxonomyCommands.cs index ce85a590..05d3280a 100644 --- a/backend/src/Modules/Customization/LearnStack.Modules.Customization.Application.Contracts/Customization/TenantLevelTaxonomyCommands.cs +++ b/backend/src/Modules/Customization/LearnStack.Modules.Customization.Application.Contracts/Customization/TenantLevelTaxonomyCommands.cs @@ -62,8 +62,13 @@ public sealed record TaxonomyItemInput( /// : one live revision per key, the /// incumbent retired in this transaction, and the partial index as the guarantee. /// +/// The draft revision to publish. +/// +/// When true, refuse any other Active revision before mutation. Default false +/// preserves ordinary revision succession; convergence callers never retire one. +/// public sealed record PublishTenantLevelTaxonomyCommand( - Guid TaxonomyId) : IRequest>; + Guid TaxonomyId, bool RequireNoIncumbent = false) : IRequest>; /// What the caller now has. public sealed record TenantLevelTaxonomyDto( diff --git a/backend/src/Modules/Customization/LearnStack.Modules.Customization.Application.Contracts/Definitions/IExactCustomizationDefinitionReader.cs b/backend/src/Modules/Customization/LearnStack.Modules.Customization.Application.Contracts/Definitions/IExactCustomizationDefinitionReader.cs new file mode 100644 index 00000000..fddf4380 --- /dev/null +++ b/backend/src/Modules/Customization/LearnStack.Modules.Customization.Application.Contracts/Definitions/IExactCustomizationDefinitionReader.cs @@ -0,0 +1,43 @@ +using System.Collections.Immutable; +using LearnStack.SharedKernel.Localization; +using LearnStack.SharedKernel.Results; + +namespace LearnStack.Modules.Customization.Application.Contracts.Definitions; + +/// Eligibility is evaluated on the exact revision, never on the current live key. +public enum DefinitionReadPurpose +{ + NewBinding = 0, + ExistingPin = 1, +} + +public enum DefinitionStatus +{ + Active = 1, + Deprecated = 2, +} + +public sealed record TextCardFieldDto(string Name, LocalizedText Label); + +public sealed record ContentTypeDefinitionDto( + Guid Id, string Key, int SchemaVersion, DefinitionStatus Status, + string JsonSchema, string RendererKey, ImmutableArray Fields); + +public sealed record TaxonomyBandDto(string Key, LocalizedText DisplayName, short Sort, string? Metadata); + +public sealed record TaxonomyDefinitionDto( + Guid Id, string Key, int SchemaVersion, DefinitionStatus Status, ImmutableArray Bands); + +/// +/// Uncached, tenant-filtered reads on the caller's ambient transaction. A miss or +/// ineligible revision returns the same bounded validation refusal. Validity is +/// at read time; immutable pins survive a concurrent deprecation (P02d-2). +/// +public interface IExactCustomizationDefinitionReader +{ + Task> ReadContentTypeAsync( + string key, int schemaVersion, DefinitionReadPurpose purpose, CancellationToken cancellationToken); + + Task> ReadTaxonomyAsync( + string key, int schemaVersion, DefinitionReadPurpose purpose, CancellationToken cancellationToken); +} diff --git a/backend/src/Modules/Customization/LearnStack.Modules.Customization.Application.Contracts/Seeding/SeedStateQueries.cs b/backend/src/Modules/Customization/LearnStack.Modules.Customization.Application.Contracts/Seeding/SeedStateQueries.cs new file mode 100644 index 00000000..9f585531 --- /dev/null +++ b/backend/src/Modules/Customization/LearnStack.Modules.Customization.Application.Contracts/Seeding/SeedStateQueries.cs @@ -0,0 +1,22 @@ +using System.Collections.Immutable; +using LearnStack.SharedKernel.Identifiers; +using LearnStack.SharedKernel.Results; +using MediatR; + +namespace LearnStack.Modules.Customization.Application.Contracts.Seeding; + +// Trusted contextual verification only: no public marker, endpoint or tenant input. +public sealed record SeedLookup(T? State) where T : class; + +public sealed record ContentTypeSeedDto(Guid Id, TenantId TenantId, string Key, int SchemaVersion, + string Status, string DisplayNameJson, string JsonSchema, string RendererKey); +public sealed record TaxonomySeedItemDto(string Key, string DisplayNameJson, short Sort, string? Metadata); +public sealed record TaxonomySeedDto(Guid Id, TenantId TenantId, string Key, int SchemaVersion, + string Status, string DisplayNameJson, ImmutableArray Items); + +public sealed record GetContentTypeSeedStateQuery(Guid ContentTypeId) : IRequest>>; +public sealed record GetTaxonomySeedStateQuery(Guid TaxonomyId) : IRequest>>; + +public sealed record ActiveSeedRevision(Guid Id); +public sealed record GetActiveContentTypeSeedRevisionQuery(string Key) : IRequest>>; +public sealed record GetActiveTaxonomySeedRevisionQuery(string Key) : IRequest>>; diff --git a/backend/src/Modules/Customization/LearnStack.Modules.Customization.Application/Abstractions/ISeedStateReader.cs b/backend/src/Modules/Customization/LearnStack.Modules.Customization.Application/Abstractions/ISeedStateReader.cs new file mode 100644 index 00000000..0d035e84 --- /dev/null +++ b/backend/src/Modules/Customization/LearnStack.Modules.Customization.Application/Abstractions/ISeedStateReader.cs @@ -0,0 +1,14 @@ +using LearnStack.Modules.Customization.Application.Contracts.Seeding; +using LearnStack.SharedKernel.Identifiers; +using LearnStack.Modules.Customization.Domain; + +namespace LearnStack.Modules.Customization.Application.Abstractions; + +/// Filtered, uncached verification on the caller's announced transaction. +public interface ISeedStateReader +{ + Task ReadContentTypeAsync(TenantContentTypeId contentTypeId, CancellationToken cancellationToken); + Task ReadTaxonomyAsync(TenantLevelTaxonomyId taxonomyId, CancellationToken cancellationToken); + Task ReadActiveContentTypeAsync(string key, CancellationToken cancellationToken); + Task ReadActiveTaxonomyAsync(string key, CancellationToken cancellationToken); +} diff --git a/backend/src/Modules/Customization/LearnStack.Modules.Customization.Application/Audit/CustomizationAuditCatalogSource.cs b/backend/src/Modules/Customization/LearnStack.Modules.Customization.Application/Audit/CustomizationAuditCatalogSource.cs index 73d26e78..f8f29530 100644 --- a/backend/src/Modules/Customization/LearnStack.Modules.Customization.Application/Audit/CustomizationAuditCatalogSource.cs +++ b/backend/src/Modules/Customization/LearnStack.Modules.Customization.Application/Audit/CustomizationAuditCatalogSource.cs @@ -1,3 +1,4 @@ +using LearnStack.Modules.Customization.Application.Contracts.Seeding; using LearnStack.Modules.Customization.Application.Contracts.Customization; using LearnStack.Modules.Customization.Domain; using LearnStack.SharedKernel.Audit; @@ -24,6 +25,11 @@ public void Describe(IAuditCatalogBuilder builder) { ArgumentNullException.ThrowIfNull(builder); + builder.Off(); + builder.Off(); + builder.Off(); + builder.Off(); + builder .MustAudit( "customization.content_type.register", diff --git a/backend/src/Modules/Customization/LearnStack.Modules.Customization.Application/Customization/PublishTenantContentTypeCommandHandler.cs b/backend/src/Modules/Customization/LearnStack.Modules.Customization.Application/Customization/PublishTenantContentTypeCommandHandler.cs index c38ba8f3..8629fae7 100644 --- a/backend/src/Modules/Customization/LearnStack.Modules.Customization.Application/Customization/PublishTenantContentTypeCommandHandler.cs +++ b/backend/src/Modules/Customization/LearnStack.Modules.Customization.Application/Customization/PublishTenantContentTypeCommandHandler.cs @@ -89,15 +89,18 @@ public async Task> Handle( var actor = tenantContext.UserId ?? UserId.SystemActor; var incumbent = await contentTypes.FindActiveAsync(successor.Key, cancellationToken); - var retired = false; - // No identity comparison between the two: the guard above has already - // established the successor is a Draft and this read returns an Active row, - // so they cannot be the same row. A defensive `incumbent.Id != successor.Id` - // here would be a branch no test can reach and therefore a comment. + // READ COMMITTED can observe a competitor's publication between these two + // reads. EF returns the already-tracked Draft instance for that same id; + // it is not an incumbent to retire, and this attempt is an ordinary stale write. + if (incumbent?.Id == successor.Id) + return CustomizationFailures.Stale(); + + if (request.RequireNoIncumbent && incumbent is not null) + return CustomizationFailures.BusinessRule("Key", "lockey_customization_key_already_live"); + if (incumbent is not null) { - retired = true; incumbent.Deprecate(clock, actor); try @@ -109,7 +112,7 @@ public async Task> Handle( // The loser of two concurrent successions. Its UPDATE matched nothing // because the winner already retired this row — re-read and retry is // the answer, and it is the one the concurrency token exists to give. - return CustomizationFailures.Stale(); + return Undo(CustomizationFailures.Stale()); } } @@ -123,11 +126,11 @@ public async Task> Handle( { var (field, reason) = CustomizationFailures.Conflict(conflict.ConstraintName); - return Undo(retired, CustomizationFailures.BusinessRule(field, reason)); + return Undo(CustomizationFailures.BusinessRule(field, reason)); } catch (AggregateConcurrencyException) { - return Undo(retired, CustomizationFailures.Stale()); + return Undo(CustomizationFailures.Stale()); } await generations.BumpAsync(tenantContext.TenantId, cancellationToken); @@ -140,27 +143,15 @@ public async Task> Handle( successor.Status.ToString())); } - /// - /// Escalates a failure that arrives after the incumbent was retired. - /// + /// Refuses later commit after a publication mutation/save has failed. /// - /// The deprecation is already saved by the time the successor's write can - /// fail, and - /// ADR-0040 - /// § Nesting is explicit that an inner Result.Fail an outer - /// handler absorbs does not roll the unit back — "only an exception, or an - /// explicit MarkRollbackOnly, does". Without it, an outer handler that - /// absorbs this and commits leaves the tenant with the incumbent deprecated, - /// the successor still a draft, and NO live revision for the key — measured - /// against a real database through the real pipeline. + /// Both the successor's dirty tracked state and an already-saved retirement + /// belong to the ambient transaction. An outer handler may absorb Result.Fail, + /// so ADR-0040 requires rollback-only even for a first publication with no incumbent. /// - private Result Undo(bool retired, Result failure) + private Result Undo(Result failure) { - if (retired) - { - unitOfWork.MarkRollbackOnly(); - } - + unitOfWork.MarkRollbackOnly(); return failure; } } diff --git a/backend/src/Modules/Customization/LearnStack.Modules.Customization.Application/Customization/PublishTenantLevelTaxonomyCommandHandler.cs b/backend/src/Modules/Customization/LearnStack.Modules.Customization.Application/Customization/PublishTenantLevelTaxonomyCommandHandler.cs index 4b21fb66..f25504ca 100644 --- a/backend/src/Modules/Customization/LearnStack.Modules.Customization.Application/Customization/PublishTenantLevelTaxonomyCommandHandler.cs +++ b/backend/src/Modules/Customization/LearnStack.Modules.Customization.Application/Customization/PublishTenantLevelTaxonomyCommandHandler.cs @@ -74,11 +74,18 @@ public async Task> Handle( var actor = tenantContext.UserId ?? UserId.SystemActor; var incumbent = await taxonomies.FindActiveAsync(successor.Key, cancellationToken); - var retired = false; + + // READ COMMITTED can observe a competitor's publication between these two + // reads. EF returns the already-tracked Draft instance for that same id; + // it is not an incumbent to retire, and this attempt is an ordinary stale write. + if (incumbent?.Id == successor.Id) + return CustomizationFailures.Stale(); + + if (request.RequireNoIncumbent && incumbent is not null) + return CustomizationFailures.BusinessRule("Key", "lockey_customization_key_already_live"); if (incumbent is not null) { - retired = true; incumbent.Deprecate(clock, actor); try @@ -90,7 +97,7 @@ public async Task> Handle( // The loser of two concurrent successions. Its UPDATE matched nothing // because the winner already retired this row — re-read and retry is // the answer, and it is the one the concurrency token exists to give. - return CustomizationFailures.Stale(); + return Undo(CustomizationFailures.Stale()); } } @@ -104,11 +111,11 @@ public async Task> Handle( { var (field, reason) = CustomizationFailures.Conflict(conflict.ConstraintName); - return Undo(retired, CustomizationFailures.BusinessRule(field, reason)); + return Undo(CustomizationFailures.BusinessRule(field, reason)); } catch (AggregateConcurrencyException) { - return Undo(retired, CustomizationFailures.Stale()); + return Undo(CustomizationFailures.Stale()); } await generations.BumpAsync(tenantContext.TenantId, cancellationToken); @@ -122,27 +129,15 @@ public async Task> Handle( successor.Items.Count)); } - /// - /// Escalates a failure that arrives after the incumbent was retired. - /// + /// Refuses later commit after a publication mutation/save has failed. /// - /// The deprecation is already saved by the time the successor's write can - /// fail, and - /// ADR-0040 - /// § Nesting is explicit that an inner Result.Fail an outer - /// handler absorbs does not roll the unit back — "only an exception, or an - /// explicit MarkRollbackOnly, does". Without it, an outer handler that - /// absorbs this and commits leaves the tenant with the incumbent deprecated, - /// the successor still a draft, and NO live revision for the key — measured - /// against a real database through the real pipeline. + /// Both the successor's dirty tracked state and an already-saved retirement + /// belong to the ambient transaction. An outer handler may absorb Result.Fail, + /// so ADR-0040 requires rollback-only even for a first publication with no incumbent. /// - private Result Undo(bool retired, Result failure) + private Result Undo(Result failure) { - if (retired) - { - unitOfWork.MarkRollbackOnly(); - } - + unitOfWork.MarkRollbackOnly(); return failure; } } diff --git a/backend/src/Modules/Customization/LearnStack.Modules.Customization.Application/Customization/RegisterTenantContentTypeCommandHandler.cs b/backend/src/Modules/Customization/LearnStack.Modules.Customization.Application/Customization/RegisterTenantContentTypeCommandHandler.cs index 62ea5c53..64e0dbb3 100644 --- a/backend/src/Modules/Customization/LearnStack.Modules.Customization.Application/Customization/RegisterTenantContentTypeCommandHandler.cs +++ b/backend/src/Modules/Customization/LearnStack.Modules.Customization.Application/Customization/RegisterTenantContentTypeCommandHandler.cs @@ -58,13 +58,19 @@ public async Task> Handle( return CustomizationFailures.SchemaRefused(admitted.Error!); } + var presentation = TextCardPresentation.Resolve(request.JsonSchema, request.RendererKey); + if (presentation.IsFailure) + { + return CustomizationFailures.SchemaRefused(presentation.Error); + } + // The gates admit LearnStack's own keywords without resolving them — // ADR-0043 § 4 — so the half that needs the registries happens here, before // anything is written. A schema naming a renderer or a taxonomy that does // not exist would otherwise be stored, published, and then trusted by a // read path that never validates. var unresolved = await SchemaExtensionResolution.UnresolvedAsync( - admitted.Value!, taxonomies, cancellationToken); + admitted.Value!, taxonomies, cancellationToken, textCardResolved: true); if (unresolved.Count > 0) { diff --git a/backend/src/Modules/Customization/LearnStack.Modules.Customization.Application/Customization/SchemaExtensionResolution.cs b/backend/src/Modules/Customization/LearnStack.Modules.Customization.Application/Customization/SchemaExtensionResolution.cs index d00c6533..a88bf823 100644 --- a/backend/src/Modules/Customization/LearnStack.Modules.Customization.Application/Customization/SchemaExtensionResolution.cs +++ b/backend/src/Modules/Customization/LearnStack.Modules.Customization.Application/Customization/SchemaExtensionResolution.cs @@ -50,7 +50,8 @@ internal static class SchemaExtensionResolution internal static async Task> UnresolvedAsync( IReadOnlyList extensions, ITenantLevelTaxonomyCatalog taxonomies, - CancellationToken cancellationToken) + CancellationToken cancellationToken, + bool textCardResolved = false) { var missingTaxonomies = await MissingTaxonomiesAsync(extensions, taxonomies, cancellationToken); var unresolved = new List(); @@ -61,12 +62,12 @@ internal static async Task> UnresolvedAs { RendererKeyword => PrimitiveRendererKey.IsKnown(extension.Value), TaxonomyKeyword => !missingTaxonomies.Contains(extension.Value), - - // x-language, and any extension a later release adds to the - // validator's list before this one learns to resolve it. Accepting - // is the direction that fails safe: the alternative refuses a - // document for a keyword nobody has decided about yet. - _ => true, + // ADR-0051 is resolved separately after all four schema gates. + "x-fields" => textCardResolved, + // Existing explicit exception: Phase 04 owns the language registry. + "x-language" => true, + // A newly recognized extension owes a resolver before it can pass. + _ => false, }; if (!resolves) diff --git a/backend/src/Modules/Customization/LearnStack.Modules.Customization.Application/Customization/TextCardPresentation.cs b/backend/src/Modules/Customization/LearnStack.Modules.Customization.Application/Customization/TextCardPresentation.cs new file mode 100644 index 00000000..14187548 --- /dev/null +++ b/backend/src/Modules/Customization/LearnStack.Modules.Customization.Application/Customization/TextCardPresentation.cs @@ -0,0 +1,189 @@ +using System.Collections.Immutable; +using System.Text.Json; +using LearnStack.Modules.Customization.Application.Contracts.Definitions; +using LearnStack.SharedKernel.Localization; +using LearnStack.SharedKernel.Results; + +namespace LearnStack.Modules.Customization.Application.Customization; + +/// ADR-0051 semantic resolution, after schema admission; no registry or locale lookup. +public static class TextCardPresentation +{ + private const int MaxFailures = 25; + + public static Result> Resolve(string admittedSchema, string rendererKey) + { + ArgumentNullException.ThrowIfNull(admittedSchema); + var failures = new Dictionary>(StringComparer.Ordinal); + void Refuse(string location) + { + if (failures.Count < MaxFailures) + { + failures.TryAdd(location, [new LocalizedMessage("lockey_schema_extension_unresolved")]); + } + } + + Result> Failure() => + Result>.Fail( + new Error(new LocalizedMessage("lockey_validation_failed"), failures)); + + JsonDocument parsed; + try + { + parsed = JsonDocument.Parse(admittedSchema); + } + catch (JsonException) + { + failures.Add("", [new LocalizedMessage("lockey_schema_not_well_formed_json")]); + return Failure(); + } + + using var document = parsed; + var root = document.RootElement; + if (root.ValueKind != JsonValueKind.Object) + { + Refuse("/properties"); + return Failure(); + } + + if (!root.TryGetProperty("x-fields", out var descriptors)) + { + return Result.Ok(ImmutableArray.Empty); + } + + if (!root.TryGetProperty("properties", out var properties) + || properties.ValueKind != JsonValueKind.Object) + { + Refuse("/properties"); + return Failure(); + } + + if (rendererKey != "default-card") + { + Refuse("/x-fields"); + } + + CheckShape(root, "", "object", Refuse); + if (!root.TryGetProperty("additionalProperties", out var additional) + || additional.ValueKind != JsonValueKind.False) + { + Refuse("/additionalProperties"); + } + + var names = new HashSet(StringComparer.Ordinal); + foreach (var property in properties.EnumerateObject()) + { + if (!names.Add(property.Name)) + { + Refuse("/properties/" + Escape(property.Name)); + } + + CheckShape(property.Value, "/properties/" + Escape(property.Name), "string", Refuse); + } + + var fields = ImmutableArray.CreateBuilder(); + var covered = new HashSet(StringComparer.Ordinal); + if (descriptors.ValueKind != JsonValueKind.Array || descriptors.GetArrayLength() == 0) + { + Refuse("/x-fields"); + } + else + { + var index = 0; + foreach (var descriptor in descriptors.EnumerateArray()) + { + var location = "/x-fields/" + index++; + if (descriptor.ValueKind != JsonValueKind.Object) + { + Refuse(location); + continue; + } + + var members = new HashSet(StringComparer.Ordinal); + foreach (var member in descriptor.EnumerateObject()) + { + if (!members.Add(member.Name) || member.Name is not ("name" or "label")) + { + Refuse(location + "/" + Escape(member.Name)); + } + } + + if (!descriptor.TryGetProperty("name", out var nameValue) + || nameValue.ValueKind != JsonValueKind.String) + { + Refuse(location + "/name"); + continue; + } + + var name = nameValue.GetString()!; + if (!names.Contains(name) || !covered.Add(name)) + { + Refuse(location + "/name"); + } + + if (!descriptor.TryGetProperty("label", out var label)) + { + Refuse(location + "/label"); + continue; + } + + try + { + fields.Add(new TextCardFieldDto(name, LocalizedText.FromJson(label.GetRawText()))); + } + catch (ArgumentException) + { + Refuse(location + "/label"); + } + } + } + + foreach (var missing in names.Except(covered)) + { + Refuse("/properties/" + Escape(missing)); + } + + return failures.Count == 0 + ? Result.Ok(fields.ToImmutable()) + : Failure(); + } + + private static void CheckShape(JsonElement schema, string location, string expectedType, Action refuse) + { + if (schema.ValueKind != JsonValueKind.Object) + { + refuse(location); + return; + } + + if (!schema.TryGetProperty("type", out var type) + || type.ValueKind != JsonValueKind.String || type.GetString() != expectedType) + { + refuse(location + "/type"); + } + + var members = new HashSet(StringComparer.Ordinal); + foreach (var property in schema.EnumerateObject()) + { + if (!members.Add(property.Name)) + { + refuse(location + "/" + Escape(property.Name)); + } + + // Unknown inert annotations stay legal. Every shape-changing applicator, + // alternate value set, field format and rendering extension is refused. + if (property.Name is "$ref" or "$dynamicRef" or "allOf" or "anyOf" or "oneOf" + or "not" or "if" or "then" or "else" or "enum" or "const" + or "items" or "prefixItems" or "contains" or "unevaluatedItems" + or "dependentSchemas" or "patternProperties" or "unevaluatedProperties" + || (expectedType == "string" && (property.Name is "format" or "properties" + or "additionalProperties" || property.Name.StartsWith("x-", StringComparison.Ordinal)))) + { + refuse(location + "/" + Escape(property.Name)); + } + } + } + + private static string Escape(string name) => name.Replace("~", "~0", StringComparison.Ordinal) + .Replace("/", "~1", StringComparison.Ordinal); +} diff --git a/backend/src/Modules/Customization/LearnStack.Modules.Customization.Application/Seeding/SeedStateQueryHandlers.cs b/backend/src/Modules/Customization/LearnStack.Modules.Customization.Application/Seeding/SeedStateQueryHandlers.cs new file mode 100644 index 00000000..a7e13782 --- /dev/null +++ b/backend/src/Modules/Customization/LearnStack.Modules.Customization.Application/Seeding/SeedStateQueryHandlers.cs @@ -0,0 +1,69 @@ +using LearnStack.Modules.Customization.Domain; +using LearnStack.Modules.Customization.Application.Abstractions; +using LearnStack.Modules.Customization.Application.Contracts.Seeding; +using LearnStack.SharedKernel.Localization; +using LearnStack.SharedKernel.Results; +using LearnStack.SharedKernel.Tenancy; +using MediatR; + +namespace LearnStack.Modules.Customization.Application.Seeding; + +internal sealed class GetContentTypeSeedStateQueryHandler(ISeedStateReader reader, ITenantContext context) + : IRequestHandler>> +{ + public async Task>> Handle( + GetContentTypeSeedStateQuery request, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(request); + if (!context.IsResolved) + { + return Result>.Fail(new Error(new LocalizedMessage("lockey_tenant_mismatch"))); + } + + return Result.Ok(new SeedLookup( + await reader.ReadContentTypeAsync(TenantContentTypeId.From(request.ContentTypeId), cancellationToken))); + } +} + +internal sealed class GetTaxonomySeedStateQueryHandler(ISeedStateReader reader, ITenantContext context) + : IRequestHandler>> +{ + public async Task>> Handle( + GetTaxonomySeedStateQuery request, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(request); + if (!context.IsResolved) + { + return Result>.Fail(new Error(new LocalizedMessage("lockey_tenant_mismatch"))); + } + + return Result.Ok(new SeedLookup( + await reader.ReadTaxonomyAsync(TenantLevelTaxonomyId.From(request.TaxonomyId), cancellationToken))); + } +} + +internal sealed class GetActiveContentTypeSeedRevisionQueryHandler(ISeedStateReader reader, ITenantContext context) + : IRequestHandler>> +{ + public async Task>> Handle( + GetActiveContentTypeSeedRevisionQuery request, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(request); + if (!context.IsResolved) + return Result>.Fail(new Error(new LocalizedMessage("lockey_tenant_mismatch"))); + return Result.Ok(new SeedLookup(await reader.ReadActiveContentTypeAsync(request.Key, cancellationToken))); + } +} + +internal sealed class GetActiveTaxonomySeedRevisionQueryHandler(ISeedStateReader reader, ITenantContext context) + : IRequestHandler>> +{ + public async Task>> Handle( + GetActiveTaxonomySeedRevisionQuery request, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(request); + if (!context.IsResolved) + return Result>.Fail(new Error(new LocalizedMessage("lockey_tenant_mismatch"))); + return Result.Ok(new SeedLookup(await reader.ReadActiveTaxonomyAsync(request.Key, cancellationToken))); + } +} diff --git a/backend/src/Modules/Customization/LearnStack.Modules.Customization.Application/Seeding/SeedStateQueryValidators.cs b/backend/src/Modules/Customization/LearnStack.Modules.Customization.Application/Seeding/SeedStateQueryValidators.cs new file mode 100644 index 00000000..df776fff --- /dev/null +++ b/backend/src/Modules/Customization/LearnStack.Modules.Customization.Application/Seeding/SeedStateQueryValidators.cs @@ -0,0 +1,38 @@ +using LearnStack.Modules.Customization.Application.Customization; +using FluentValidation; +using LearnStack.Modules.Customization.Application.Contracts.Seeding; +using LearnStack.SharedKernel.Tenancy; + +namespace LearnStack.Modules.Customization.Application.Seeding; + +internal sealed class GetContentTypeSeedStateQueryValidator : AbstractValidator +{ + public GetContentTypeSeedStateQueryValidator() + { + RuleFor(request => request.ContentTypeId).NotEmpty().WithErrorCode("lockey_identifier_required"); + } +} + +internal sealed class GetTaxonomySeedStateQueryValidator : AbstractValidator +{ + public GetTaxonomySeedStateQueryValidator() + { + RuleFor(request => request.TaxonomyId).NotEmpty().WithErrorCode("lockey_identifier_required"); + } +} + +internal sealed class GetActiveContentTypeSeedRevisionQueryValidator : AbstractValidator +{ + public GetActiveContentTypeSeedRevisionQueryValidator() + { + RuleFor(request => request.Key).MustBeACustomizationKey(); + } +} + +internal sealed class GetActiveTaxonomySeedRevisionQueryValidator : AbstractValidator +{ + public GetActiveTaxonomySeedRevisionQueryValidator() + { + RuleFor(request => request.Key).MustBeACustomizationKey(); + } +} diff --git a/backend/src/Modules/Customization/LearnStack.Modules.Customization.Domain/TenantContentType.cs b/backend/src/Modules/Customization/LearnStack.Modules.Customization.Domain/TenantContentType.cs index 7e8e22e5..e858cc08 100644 --- a/backend/src/Modules/Customization/LearnStack.Modules.Customization.Domain/TenantContentType.cs +++ b/backend/src/Modules/Customization/LearnStack.Modules.Customization.Domain/TenantContentType.cs @@ -61,10 +61,10 @@ private TenantContentType() /// /// As authored only on the instance a caller built: the column is jsonb, so /// an instance materialized from the row carries the same members and values but - /// not the author's key order or whitespace. How a content type's field order and - /// labels are carried is G18 in - /// Phase 02d's decision register, - /// whose pass edits this remark with its answer. + /// not the author's key order or whitespace. Optional root x-fields stores + /// field order as an array and labels as Pattern-B values, per + /// ADR-0051. + /// Its semantic resolver validates the bounded text-card profile before registration. /// public string JsonSchema { get; private set; } diff --git a/backend/src/Modules/Customization/LearnStack.Modules.Customization.Infrastructure/Persistence/CustomizationSeedStateReader.cs b/backend/src/Modules/Customization/LearnStack.Modules.Customization.Infrastructure/Persistence/CustomizationSeedStateReader.cs new file mode 100644 index 00000000..bfd5bb45 --- /dev/null +++ b/backend/src/Modules/Customization/LearnStack.Modules.Customization.Infrastructure/Persistence/CustomizationSeedStateReader.cs @@ -0,0 +1,38 @@ +using System.Collections.Immutable; +using LearnStack.Modules.Customization.Application.Abstractions; +using LearnStack.Modules.Customization.Application.Contracts.Seeding; +using LearnStack.Modules.Customization.Domain; +using Microsoft.EntityFrameworkCore; + +namespace LearnStack.Modules.Customization.Infrastructure.Persistence; + +public sealed class CustomizationSeedStateReader(CustomizationDbContext context) : ISeedStateReader +{ + public async Task ReadContentTypeAsync(TenantContentTypeId contentTypeId, CancellationToken cancellationToken) + { + var row = await context.TenantContentTypes.AsNoTracking().SingleOrDefaultAsync( + definition => definition.Id == contentTypeId && definition.DeletedAt == null, cancellationToken); + return row is null ? null : new ContentTypeSeedDto(row.Id.Value, row.TenantId, row.Key, row.SchemaVersion, + row.Status.ToString(), row.DisplayName.ToJson(), row.JsonSchema, row.RendererKey); + } + + public async Task ReadTaxonomyAsync(TenantLevelTaxonomyId taxonomyId, CancellationToken cancellationToken) + { + var row = await context.TenantLevelTaxonomies.AsNoTracking().Include(definition => definition.Items) + .SingleOrDefaultAsync(definition => definition.Id == taxonomyId && definition.DeletedAt == null, cancellationToken); + return row is null ? null : new TaxonomySeedDto(row.Id.Value, row.TenantId, row.Key, row.SchemaVersion, + row.Status.ToString(), row.DisplayName.ToJson(), row.Items.OrderBy(item => item.Sort) + .Select(item => new TaxonomySeedItemDto(item.Key, item.DisplayName.ToJson(), item.Sort, item.Metadata)) + .ToImmutableArray()); + } + + public Task ReadActiveContentTypeAsync(string key, CancellationToken cancellationToken) => + context.TenantContentTypes.AsNoTracking() + .Where(row => row.Key == key && row.Status == CustomizationStatus.Active && row.DeletedAt == null) + .Select(row => new ActiveSeedRevision(row.Id.Value)).SingleOrDefaultAsync(cancellationToken); + + public Task ReadActiveTaxonomyAsync(string key, CancellationToken cancellationToken) => + context.TenantLevelTaxonomies.AsNoTracking() + .Where(row => row.Key == key && row.Status == CustomizationStatus.Active && row.DeletedAt == null) + .Select(row => new ActiveSeedRevision(row.Id.Value)).SingleOrDefaultAsync(cancellationToken); +} diff --git a/backend/src/Modules/Customization/LearnStack.Modules.Customization.Infrastructure/Persistence/ExactCustomizationDefinitionReader.cs b/backend/src/Modules/Customization/LearnStack.Modules.Customization.Infrastructure/Persistence/ExactCustomizationDefinitionReader.cs new file mode 100644 index 00000000..55353f3d --- /dev/null +++ b/backend/src/Modules/Customization/LearnStack.Modules.Customization.Infrastructure/Persistence/ExactCustomizationDefinitionReader.cs @@ -0,0 +1,83 @@ +using System.Collections.Immutable; +using LearnStack.Modules.Customization.Application.Contracts.Definitions; +using LearnStack.Modules.Customization.Application.Customization; +using LearnStack.Modules.Customization.Domain; +using LearnStack.SharedKernel.Domain; +using LearnStack.SharedKernel.Localization; +using LearnStack.SharedKernel.Results; +using LearnStack.SharedKernel.Tenancy; +using Microsoft.EntityFrameworkCore; + +namespace LearnStack.Modules.Customization.Infrastructure.Persistence; + +public sealed class ExactCustomizationDefinitionReader(CustomizationDbContext context, ITenantContext tenantContext) + : IExactCustomizationDefinitionReader +{ + public async Task> ReadContentTypeAsync( + string key, int schemaVersion, DefinitionReadPurpose purpose, CancellationToken cancellationToken) + { + if (!EligibleInput(key, schemaVersion, purpose)) + { + return Refused(); + } + + var definition = await context.TenantContentTypes.AsNoTracking().SingleOrDefaultAsync( + row => row.Key == key && row.SchemaVersion == schemaVersion && row.DeletedAt == null, + cancellationToken); + if (definition is null || !EligibleStatus(definition.Status, purpose)) + { + return Refused(); + } + + var presentation = TextCardPresentation.Resolve(definition.JsonSchema, definition.RendererKey); + if (presentation.IsFailure) + { + // A malformed stored definition must not leak its private schema details. + return Refused(); + } + + return Result.Ok(new ContentTypeDefinitionDto(definition.Id.Value, definition.Key, + definition.SchemaVersion, Status(definition.Status), definition.JsonSchema, + definition.RendererKey, presentation.Value)); + } + + public async Task> ReadTaxonomyAsync( + string key, int schemaVersion, DefinitionReadPurpose purpose, CancellationToken cancellationToken) + { + if (!EligibleInput(key, schemaVersion, purpose)) + { + return Refused(); + } + + var definition = await context.TenantLevelTaxonomies.AsNoTracking().Include(row => row.Items) + .SingleOrDefaultAsync(row => row.Key == key && row.SchemaVersion == schemaVersion && row.DeletedAt == null, + cancellationToken); + if (definition is null || !EligibleStatus(definition.Status, purpose)) + { + return Refused(); + } + + return Result.Ok(new TaxonomyDefinitionDto(definition.Id.Value, definition.Key, + definition.SchemaVersion, Status(definition.Status), definition.Items.OrderBy(item => item.Sort) + .Select(item => new TaxonomyBandDto(item.Key, item.DisplayName, item.Sort, item.Metadata)) + .ToImmutableArray())); + } + + private bool EligibleInput(string key, int version, DefinitionReadPurpose purpose) => + tenantContext.IsResolved && !string.IsNullOrEmpty(key) && key.Length <= CustomizationKey.MaxLength + && UrlSlug.IsUrlSafe(key) && version > 0 && Enum.IsDefined(purpose); + + private static bool EligibleStatus(CustomizationStatus status, DefinitionReadPurpose purpose) => + status == CustomizationStatus.Active + || (purpose == DefinitionReadPurpose.ExistingPin && status == CustomizationStatus.Deprecated); + + private static DefinitionStatus Status(CustomizationStatus status) => status == CustomizationStatus.Active + ? DefinitionStatus.Active : DefinitionStatus.Deprecated; + + private static Result Refused() => Result.Fail(new Error( + new LocalizedMessage("lockey_validation_failed"), + new Dictionary>(StringComparer.Ordinal) + { + ["Definition"] = [new LocalizedMessage("lockey_schema_extension_unresolved")], + })); +} diff --git a/backend/src/Modules/Education/LearnStack.Modules.Education.Application.Contracts/Courses/CourseCommands.cs b/backend/src/Modules/Education/LearnStack.Modules.Education.Application.Contracts/Courses/CourseCommands.cs new file mode 100644 index 00000000..0455a5f3 --- /dev/null +++ b/backend/src/Modules/Education/LearnStack.Modules.Education.Application.Contracts/Courses/CourseCommands.cs @@ -0,0 +1,26 @@ +using LearnStack.SharedKernel.Results; +using MediatR; + +namespace LearnStack.Modules.Education.Application.Contracts.Courses; + +/// One course's identity, committed root version, publication and content policy. +public sealed record CourseWriteDto(Guid Id, long Version, string Status, string ContentAccess); + +/// Creates a draft in trusted context scope; policy is explicitly public or enrollment_required. +/// The optional taxonomy reference is an all-or-none exact Active revision/band pin. +public sealed record CreateCourseCommand(Guid CourseId, string SlugKey, string? ContentAccess, + string? LevelTaxonomyKey = null, int? LevelTaxonomySchemaVersion = null, string? LevelBandKey = null) + : IRequest>; + +/// Adds one enabled canonical locale's draft translation; never overwrites an existing locale. +/// Explicit root identity, visible and writable in trusted context. +/// Required exact root version; null is refused. +/// Tenant-enabled canonicalizable locale. +/// Nonblank storable title. +/// Optional storable marketing summary. +/// Routable slug reserved on insertion, independently of publication. +public sealed record AddCourseTranslationCommand(Guid CourseId, long? ExpectedVersion, + string Locale, string Title, string? Summary, string Slug) : IRequest>; + +/// Publishes one draft course at its exact version; no lesson or access grant changes. +public sealed record PublishCourseCommand(Guid CourseId, long? ExpectedVersion) : IRequest>; diff --git a/backend/src/Modules/Education/LearnStack.Modules.Education.Application.Contracts/Lessons/LessonCommands.cs b/backend/src/Modules/Education/LearnStack.Modules.Education.Application.Contracts/Lessons/LessonCommands.cs new file mode 100644 index 00000000..f036b25c --- /dev/null +++ b/backend/src/Modules/Education/LearnStack.Modules.Education.Application.Contracts/Lessons/LessonCommands.cs @@ -0,0 +1,25 @@ +using LearnStack.SharedKernel.Results; +using MediatR; + +namespace LearnStack.Modules.Education.Application.Contracts.Lessons; + +/// One lesson's identity, committed root version and independent publication state. +public sealed record LessonWriteDto(Guid Id, long Version, string Status); + +/// Creates a draft from a visible writable parent and an exact Active content-type pin. +/// Scope is derived from the parent and trusted context; the command carries no tenant authority. +public sealed record CreateLessonCommand(Guid LessonId, Guid CourseId, int Sort, + string ContentTypeKey, int ContentTypeSchemaVersion) : IRequest>; + +/// Adds draft translated content validated against the lesson's immutable Active/Deprecated pin. +/// Explicit visible root identity. +/// Required exact root version; null is refused. +/// Tenant-enabled canonicalizable locale. +/// Nonblank storable title. +/// Routable slug reserved on insertion. +/// A JSON object validated against the exact pinned admitted schema. +public sealed record AddLessonTranslationCommand(Guid LessonId, long? ExpectedVersion, + string Locale, string Title, string Slug, string Body) : IRequest>; + +/// Publishes one draft lesson at its exact version; its course remains unchanged. +public sealed record PublishLessonCommand(Guid LessonId, long? ExpectedVersion) : IRequest>; diff --git a/backend/src/Modules/Education/LearnStack.Modules.Education.Application.Contracts/Seeding/SeedStateQueries.cs b/backend/src/Modules/Education/LearnStack.Modules.Education.Application.Contracts/Seeding/SeedStateQueries.cs new file mode 100644 index 00000000..ad92ade9 --- /dev/null +++ b/backend/src/Modules/Education/LearnStack.Modules.Education.Application.Contracts/Seeding/SeedStateQueries.cs @@ -0,0 +1,22 @@ +using System.Collections.Immutable; +using LearnStack.SharedKernel.Identifiers; +using LearnStack.SharedKernel.Results; +using MediatR; + +namespace LearnStack.Modules.Education.Application.Contracts.Seeding; + +// Trusted contextual verification only: no public marker, endpoint or tenant input. +public sealed record SeedLookup(T? State) where T : class; + +public sealed record CourseTranslationSeedDto(string Locale, string Title, string? Summary, string Slug); +public sealed record LessonTranslationSeedDto(string Locale, string Title, string Slug, string Body); +public sealed record CourseSeedDto(Guid Id, TenantId TenantId, OrganizationId? OrganizationId, + string SlugKey, string Status, string ContentAccess, string? LevelTaxonomyKey, + int? LevelTaxonomySchemaVersion, string? LevelBandKey, long Version, + ImmutableArray Translations); +public sealed record LessonSeedDto(Guid Id, TenantId TenantId, OrganizationId? OrganizationId, + Guid CourseId, int Sort, string Status, string ContentTypeKey, int ContentTypeSchemaVersion, + long Version, ImmutableArray Translations); + +public sealed record GetCourseSeedStateQuery(Guid CourseId) : IRequest>>; +public sealed record GetLessonSeedStateQuery(Guid LessonId) : IRequest>>; diff --git a/backend/src/Modules/Education/LearnStack.Modules.Education.Application/Abstractions/EducationWriteStores.cs b/backend/src/Modules/Education/LearnStack.Modules.Education.Application/Abstractions/EducationWriteStores.cs new file mode 100644 index 00000000..df269475 --- /dev/null +++ b/backend/src/Modules/Education/LearnStack.Modules.Education.Application/Abstractions/EducationWriteStores.cs @@ -0,0 +1,30 @@ +using LearnStack.Modules.Education.Domain; +using LearnStack.SharedKernel.Persistence; + +namespace LearnStack.Modules.Education.Application.Abstractions; + +/// Filtered tracked course graphs; writes exactly this aggregate root. +public interface ICourseWriteStore : IAggregateWriteStore +{ + Task FindAsync(CourseId id, CancellationToken cancellationToken); +} + +/// Filtered tracked lesson graphs; writes exactly this aggregate root. +public interface ILessonWriteStore : IAggregateWriteStore +{ + Task FindAsync(LessonId id, CancellationToken cancellationToken); +} + +/// Read-only detached parent, used by the domain factory to derive child scope. +/// No parent write port or tracked parent is exposed to the lesson handler. +public interface IParentCourseReader +{ + Task ReadAsync(CourseId courseId, CancellationToken cancellationToken); +} + +/// Resolves only visible live root identities for bounded slug-collision diagnostics. +public interface ITranslationCollisionReader +{ + Task ReadCourseAsync(string locale, string slug, CancellationToken cancellationToken); + Task ReadLessonAsync(string locale, string slug, CancellationToken cancellationToken); +} diff --git a/backend/src/Modules/Education/LearnStack.Modules.Education.Application/Abstractions/ISeedStateReader.cs b/backend/src/Modules/Education/LearnStack.Modules.Education.Application/Abstractions/ISeedStateReader.cs new file mode 100644 index 00000000..94d90234 --- /dev/null +++ b/backend/src/Modules/Education/LearnStack.Modules.Education.Application/Abstractions/ISeedStateReader.cs @@ -0,0 +1,12 @@ +using LearnStack.Modules.Education.Application.Contracts.Seeding; +using LearnStack.SharedKernel.Identifiers; +using LearnStack.Modules.Education.Domain; + +namespace LearnStack.Modules.Education.Application.Abstractions; + +/// Filtered, uncached verification on the caller's announced transaction. +public interface ISeedStateReader +{ + Task ReadCourseAsync(CourseId courseId, CancellationToken cancellationToken); + Task ReadLessonAsync(LessonId lessonId, CancellationToken cancellationToken); +} diff --git a/backend/src/Modules/Education/LearnStack.Modules.Education.Application/Audit/EducationAuditCatalogSource.cs b/backend/src/Modules/Education/LearnStack.Modules.Education.Application/Audit/EducationAuditCatalogSource.cs new file mode 100644 index 00000000..db9d2d6b --- /dev/null +++ b/backend/src/Modules/Education/LearnStack.Modules.Education.Application/Audit/EducationAuditCatalogSource.cs @@ -0,0 +1,26 @@ +using LearnStack.Modules.Education.Application.Contracts.Seeding; +using LearnStack.Modules.Education.Application.Contracts.Courses; +using LearnStack.Modules.Education.Application.Contracts.Lessons; +using LearnStack.Modules.Education.Domain; +using LearnStack.SharedKernel.Audit; + +namespace LearnStack.Modules.Education.Application.Audit; + +/// One-root writer classifications and trusted Off verification reads. +public sealed class EducationAuditCatalogSource : IAuditCatalogSource +{ + public string ModuleName => "education"; + + public void Describe(IAuditCatalogBuilder builder) + { + ArgumentNullException.ThrowIfNull(builder); + builder.ShouldAudit("education.course.create", OperationType.Create, typeof(Course)); + builder.ShouldAudit("education.course.translation_add", OperationType.Update, typeof(Course)); + builder.MustAudit("education.course.publish", OperationType.Update, typeof(Course)); + builder.ShouldAudit("education.lesson.create", OperationType.Create, typeof(Lesson)); + builder.ShouldAudit("education.lesson.translation_add", OperationType.Update, typeof(Lesson)); + builder.MustAudit("education.lesson.publish", OperationType.Update, typeof(Lesson)); + builder.Off(); + builder.Off(); + } +} diff --git a/backend/src/Modules/Education/LearnStack.Modules.Education.Application/Courses/AddCourseTranslationCommandHandler.cs b/backend/src/Modules/Education/LearnStack.Modules.Education.Application/Courses/AddCourseTranslationCommandHandler.cs new file mode 100644 index 00000000..da8e6ada --- /dev/null +++ b/backend/src/Modules/Education/LearnStack.Modules.Education.Application/Courses/AddCourseTranslationCommandHandler.cs @@ -0,0 +1,44 @@ +using LearnStack.Modules.Education.Application.Abstractions; +using LearnStack.Modules.Education.Application.Writing; +using LearnStack.Modules.Education.Domain; +using LearnStack.SharedKernel.Audit; +using LearnStack.SharedKernel.Identifiers; +using LearnStack.SharedKernel.Persistence; +using LearnStack.SharedKernel.Results; +using LearnStack.SharedKernel.Tenancy; +using LearnStack.SharedKernel.Time; +using MediatR; +using LearnStack.Modules.Tenancy.Application.Contracts.Locales; + +using LearnStack.Modules.Education.Application.Contracts.Courses; + +namespace LearnStack.Modules.Education.Application.Courses; + +internal sealed class AddCourseTranslationCommandHandler(ICourseWriteStore roots, ITenantLocaleEligibilityReader locales, ITranslationCollisionReader collisions, ITenantContext tenantContext, + IUnitOfWork unitOfWork, IAuditSubject auditSubject, IClock clock) + : IRequestHandler> +{ + public async Task> Handle(AddCourseTranslationCommand request, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(request); + if (EducationWriteSupport.Context(tenantContext) is { } unresolved) return unresolved; + var root = await roots.FindAsync(CourseId.From(request.CourseId), cancellationToken); + if (root is null) return EducationWriteSupport.Code("lockey_not_found"); + if (EducationWriteSupport.Scope(root, tenantContext) is { } scopeFailure) return scopeFailure; + auditSubject.Designate(root); + if (EducationWriteSupport.Version(root.Version, request.ExpectedVersion) is { } stale) return stale; + if (EducationWriteSupport.Draft(root.Status) is { } lifecycle) return lifecycle; + var locale = await locales.ReadEligibleAsync(request.Locale, cancellationToken); + if (locale.IsFailure) return Result.Fail(locale.Error); + + var added = root.AddTranslation(locale.Value, request.Title, request.Summary, request.Slug, clock, tenantContext.UserId ?? UserId.SystemActor); + if (added.IsFailure) return Result.Fail(added.Error); + return await EducationWriteSupport.SaveAsync(() => roots.UpdateAsync(root, cancellationToken), + () => EducationWriteSupport.Dto(root), unitOfWork, async constraint => + { + if (constraint != "ux_course_translations_tenant_id_locale_slug") return null; + var visible = await collisions.ReadCourseAsync(locale.Value, request.Slug, cancellationToken); + return EducationWriteSupport.SlugConflict(locale.Value, request.Slug, visible?.Value); + }); + } +} diff --git a/backend/src/Modules/Education/LearnStack.Modules.Education.Application/Courses/CreateCourseCommandHandler.cs b/backend/src/Modules/Education/LearnStack.Modules.Education.Application/Courses/CreateCourseCommandHandler.cs new file mode 100644 index 00000000..8c535b54 --- /dev/null +++ b/backend/src/Modules/Education/LearnStack.Modules.Education.Application/Courses/CreateCourseCommandHandler.cs @@ -0,0 +1,42 @@ +using LearnStack.Modules.Education.Application.Abstractions; +using LearnStack.Modules.Education.Application.Writing; +using LearnStack.Modules.Education.Domain; +using LearnStack.SharedKernel.Audit; +using LearnStack.SharedKernel.Identifiers; +using LearnStack.SharedKernel.Persistence; +using LearnStack.SharedKernel.Results; +using LearnStack.SharedKernel.Tenancy; +using LearnStack.SharedKernel.Time; +using MediatR; + +using LearnStack.Modules.Customization.Application.Contracts.Definitions; +using LearnStack.Modules.Education.Application.Contracts.Courses; + +namespace LearnStack.Modules.Education.Application.Courses; + +internal sealed class CreateCourseCommandHandler(ICourseWriteStore courses, + IExactCustomizationDefinitionReader definitions, ITenantContext tenantContext, + IUnitOfWork unitOfWork, IAuditSubject auditSubject, IClock clock) + : IRequestHandler> +{ + public async Task> Handle(CreateCourseCommand request, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(request); + if (EducationWriteSupport.Context(tenantContext) is { } unresolved) return unresolved; + if (request is { LevelTaxonomyKey: { } key, LevelTaxonomySchemaVersion: { } version, LevelBandKey: { } bandKey }) + { + var taxonomy = await definitions.ReadTaxonomyAsync(key, version, + DefinitionReadPurpose.NewBinding, cancellationToken); + if (taxonomy.IsFailure) return Result.Fail(taxonomy.Error); + if (!taxonomy.Value.Bands.Any(band => band.Key == bandKey)) + return EducationWriteSupport.Field("LevelBandKey", "lockey_education_band_invalid"); + } + var access = request.ContentAccess == "public" ? CourseContentAccess.Public : CourseContentAccess.EnrollmentRequired; + var root = Course.Create(CourseId.From(request.CourseId), tenantContext.TenantId, tenantContext.OrganizationId, + request.SlugKey, access, clock, tenantContext.UserId ?? UserId.SystemActor, + request.LevelTaxonomyKey, request.LevelTaxonomySchemaVersion, request.LevelBandKey); + auditSubject.Designate(root); + return await EducationWriteSupport.SaveAsync(() => courses.AddAsync(root, cancellationToken), + () => EducationWriteSupport.Dto(root), unitOfWork); + } +} diff --git a/backend/src/Modules/Education/LearnStack.Modules.Education.Application/Courses/PublishCourseCommandHandler.cs b/backend/src/Modules/Education/LearnStack.Modules.Education.Application/Courses/PublishCourseCommandHandler.cs new file mode 100644 index 00000000..30421bc9 --- /dev/null +++ b/backend/src/Modules/Education/LearnStack.Modules.Education.Application/Courses/PublishCourseCommandHandler.cs @@ -0,0 +1,34 @@ +using LearnStack.Modules.Education.Application.Abstractions; +using LearnStack.Modules.Education.Application.Writing; +using LearnStack.Modules.Education.Domain; +using LearnStack.SharedKernel.Audit; +using LearnStack.SharedKernel.Identifiers; +using LearnStack.SharedKernel.Persistence; +using LearnStack.SharedKernel.Results; +using LearnStack.SharedKernel.Tenancy; +using LearnStack.SharedKernel.Time; +using MediatR; + +using LearnStack.Modules.Education.Application.Contracts.Courses; + +namespace LearnStack.Modules.Education.Application.Courses; + +internal sealed class PublishCourseCommandHandler(ICourseWriteStore roots, ITenantContext tenantContext, + IUnitOfWork unitOfWork, IAuditSubject auditSubject, IClock clock) + : IRequestHandler> +{ + public async Task> Handle(PublishCourseCommand request, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(request); + if (EducationWriteSupport.Context(tenantContext) is { } unresolved) return unresolved; + var root = await roots.FindAsync(CourseId.From(request.CourseId), cancellationToken); + if (root is null) return EducationWriteSupport.Code("lockey_not_found"); + if (EducationWriteSupport.Scope(root, tenantContext) is { } scopeFailure) return scopeFailure; + auditSubject.Designate(root); + if (EducationWriteSupport.Version(root.Version, request.ExpectedVersion) is { } stale) return stale; + var published = root.Publish(clock, tenantContext.UserId ?? UserId.SystemActor); + if (published.IsFailure) return Result.Fail(published.Error); + return await EducationWriteSupport.SaveAsync(() => roots.UpdateAsync(root, cancellationToken), + () => EducationWriteSupport.Dto(root), unitOfWork); + } +} diff --git a/backend/src/Modules/Education/LearnStack.Modules.Education.Application/LearnStack.Modules.Education.Application.csproj b/backend/src/Modules/Education/LearnStack.Modules.Education.Application/LearnStack.Modules.Education.Application.csproj index e7ebafff..6a586e4d 100644 --- a/backend/src/Modules/Education/LearnStack.Modules.Education.Application/LearnStack.Modules.Education.Application.csproj +++ b/backend/src/Modules/Education/LearnStack.Modules.Education.Application/LearnStack.Modules.Education.Application.csproj @@ -6,6 +6,8 @@ + + > +{ + public async Task> Handle(AddLessonTranslationCommand request, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(request); + if (EducationWriteSupport.Context(tenantContext) is { } unresolved) return unresolved; + var root = await roots.FindAsync(LessonId.From(request.LessonId), cancellationToken); + if (root is null) return EducationWriteSupport.Code("lockey_not_found"); + if (EducationWriteSupport.Scope(root, tenantContext) is { } scopeFailure) return scopeFailure; + auditSubject.Designate(root); + if (EducationWriteSupport.Version(root.Version, request.ExpectedVersion) is { } stale) return stale; + if (EducationWriteSupport.Draft(root.Status) is { } lifecycle) return lifecycle; + var locale = await locales.ReadEligibleAsync(request.Locale, cancellationToken); + if (locale.IsFailure) return Result.Fail(locale.Error); + + var definition = await definitions.ReadContentTypeAsync(root.ContentTypeKey, root.ContentTypeSchemaVersion, + DefinitionReadPurpose.ExistingPin, cancellationToken); + if (definition.IsFailure) return Result.Fail(definition.Error); + var body = schemas.ValidateInstance(definition.Value.JsonSchema, request.Body); + if (body.IsFailure) return Result.Fail(body.Error); + + var added = root.AddTranslation(locale.Value, request.Title, request.Slug, request.Body, clock, tenantContext.UserId ?? UserId.SystemActor); + if (added.IsFailure) return Result.Fail(added.Error); + return await EducationWriteSupport.SaveAsync(() => roots.UpdateAsync(root, cancellationToken), + () => EducationWriteSupport.Dto(root), unitOfWork, async constraint => + { + if (constraint != "ux_lesson_translations_tenant_id_locale_slug") return null; + var visible = await collisions.ReadLessonAsync(locale.Value, request.Slug, cancellationToken); + return EducationWriteSupport.SlugConflict(locale.Value, request.Slug, visible?.Value); + }); + } +} diff --git a/backend/src/Modules/Education/LearnStack.Modules.Education.Application/Lessons/CreateLessonCommandHandler.cs b/backend/src/Modules/Education/LearnStack.Modules.Education.Application/Lessons/CreateLessonCommandHandler.cs new file mode 100644 index 00000000..344962f5 --- /dev/null +++ b/backend/src/Modules/Education/LearnStack.Modules.Education.Application/Lessons/CreateLessonCommandHandler.cs @@ -0,0 +1,38 @@ +using LearnStack.Modules.Education.Application.Abstractions; +using LearnStack.Modules.Education.Application.Writing; +using LearnStack.Modules.Education.Domain; +using LearnStack.SharedKernel.Audit; +using LearnStack.SharedKernel.Identifiers; +using LearnStack.SharedKernel.Persistence; +using LearnStack.SharedKernel.Results; +using LearnStack.SharedKernel.Tenancy; +using LearnStack.SharedKernel.Time; +using MediatR; + +using LearnStack.Modules.Customization.Application.Contracts.Definitions; +using LearnStack.Modules.Education.Application.Contracts.Lessons; + +namespace LearnStack.Modules.Education.Application.Lessons; + +internal sealed class CreateLessonCommandHandler(ILessonWriteStore lessons, IParentCourseReader parents, + IExactCustomizationDefinitionReader definitions, ITenantContext tenantContext, + IUnitOfWork unitOfWork, IAuditSubject auditSubject, IClock clock) + : IRequestHandler> +{ + public async Task> Handle(CreateLessonCommand request, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(request); + if (EducationWriteSupport.Context(tenantContext) is { } unresolved) return unresolved; + var parent = await parents.ReadAsync(CourseId.From(request.CourseId), cancellationToken); + if (parent is null) return EducationWriteSupport.Code("lockey_not_found"); + if (EducationWriteSupport.Scope(parent, tenantContext) is { } scopeFailure) return scopeFailure; + var definition = await definitions.ReadContentTypeAsync(request.ContentTypeKey, request.ContentTypeSchemaVersion, + DefinitionReadPurpose.NewBinding, cancellationToken); + if (definition.IsFailure) return Result.Fail(definition.Error); + var root = Lesson.Create(LessonId.From(request.LessonId), parent, request.Sort, request.ContentTypeKey, + request.ContentTypeSchemaVersion, clock, tenantContext.UserId ?? UserId.SystemActor); + auditSubject.Designate(root); + return await EducationWriteSupport.SaveAsync(() => lessons.AddAsync(root, cancellationToken), + () => EducationWriteSupport.Dto(root), unitOfWork); + } +} diff --git a/backend/src/Modules/Education/LearnStack.Modules.Education.Application/Lessons/PublishLessonCommandHandler.cs b/backend/src/Modules/Education/LearnStack.Modules.Education.Application/Lessons/PublishLessonCommandHandler.cs new file mode 100644 index 00000000..367ca415 --- /dev/null +++ b/backend/src/Modules/Education/LearnStack.Modules.Education.Application/Lessons/PublishLessonCommandHandler.cs @@ -0,0 +1,34 @@ +using LearnStack.Modules.Education.Application.Abstractions; +using LearnStack.Modules.Education.Application.Writing; +using LearnStack.Modules.Education.Domain; +using LearnStack.SharedKernel.Audit; +using LearnStack.SharedKernel.Identifiers; +using LearnStack.SharedKernel.Persistence; +using LearnStack.SharedKernel.Results; +using LearnStack.SharedKernel.Tenancy; +using LearnStack.SharedKernel.Time; +using MediatR; + +using LearnStack.Modules.Education.Application.Contracts.Lessons; + +namespace LearnStack.Modules.Education.Application.Lessons; + +internal sealed class PublishLessonCommandHandler(ILessonWriteStore roots, ITenantContext tenantContext, + IUnitOfWork unitOfWork, IAuditSubject auditSubject, IClock clock) + : IRequestHandler> +{ + public async Task> Handle(PublishLessonCommand request, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(request); + if (EducationWriteSupport.Context(tenantContext) is { } unresolved) return unresolved; + var root = await roots.FindAsync(LessonId.From(request.LessonId), cancellationToken); + if (root is null) return EducationWriteSupport.Code("lockey_not_found"); + if (EducationWriteSupport.Scope(root, tenantContext) is { } scopeFailure) return scopeFailure; + auditSubject.Designate(root); + if (EducationWriteSupport.Version(root.Version, request.ExpectedVersion) is { } stale) return stale; + var published = root.Publish(clock, tenantContext.UserId ?? UserId.SystemActor); + if (published.IsFailure) return Result.Fail(published.Error); + return await EducationWriteSupport.SaveAsync(() => roots.UpdateAsync(root, cancellationToken), + () => EducationWriteSupport.Dto(root), unitOfWork); + } +} diff --git a/backend/src/Modules/Education/LearnStack.Modules.Education.Application/Seeding/SeedStateQueryHandlers.cs b/backend/src/Modules/Education/LearnStack.Modules.Education.Application/Seeding/SeedStateQueryHandlers.cs new file mode 100644 index 00000000..b247c14d --- /dev/null +++ b/backend/src/Modules/Education/LearnStack.Modules.Education.Application/Seeding/SeedStateQueryHandlers.cs @@ -0,0 +1,43 @@ +using LearnStack.Modules.Education.Domain; +using LearnStack.Modules.Education.Application.Abstractions; +using LearnStack.Modules.Education.Application.Contracts.Seeding; +using LearnStack.SharedKernel.Localization; +using LearnStack.SharedKernel.Results; +using LearnStack.SharedKernel.Tenancy; +using MediatR; + +namespace LearnStack.Modules.Education.Application.Seeding; + +internal sealed class GetCourseSeedStateQueryHandler(ISeedStateReader reader, ITenantContext context) + : IRequestHandler>> +{ + public async Task>> Handle( + GetCourseSeedStateQuery request, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(request); + if (!context.IsResolved) + { + return Result>.Fail(new Error(new LocalizedMessage("lockey_tenant_mismatch"))); + } + + return Result.Ok(new SeedLookup( + await reader.ReadCourseAsync(CourseId.From(request.CourseId), cancellationToken))); + } +} + +internal sealed class GetLessonSeedStateQueryHandler(ISeedStateReader reader, ITenantContext context) + : IRequestHandler>> +{ + public async Task>> Handle( + GetLessonSeedStateQuery request, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(request); + if (!context.IsResolved) + { + return Result>.Fail(new Error(new LocalizedMessage("lockey_tenant_mismatch"))); + } + + return Result.Ok(new SeedLookup( + await reader.ReadLessonAsync(LessonId.From(request.LessonId), cancellationToken))); + } +} diff --git a/backend/src/Modules/Education/LearnStack.Modules.Education.Application/Seeding/SeedStateQueryValidators.cs b/backend/src/Modules/Education/LearnStack.Modules.Education.Application/Seeding/SeedStateQueryValidators.cs new file mode 100644 index 00000000..79fb3432 --- /dev/null +++ b/backend/src/Modules/Education/LearnStack.Modules.Education.Application/Seeding/SeedStateQueryValidators.cs @@ -0,0 +1,21 @@ +using FluentValidation; +using LearnStack.Modules.Education.Application.Contracts.Seeding; +using LearnStack.SharedKernel.Tenancy; + +namespace LearnStack.Modules.Education.Application.Seeding; + +internal sealed class GetCourseSeedStateQueryValidator : AbstractValidator +{ + public GetCourseSeedStateQueryValidator() + { + RuleFor(request => request.CourseId).NotEmpty().WithErrorCode("lockey_identifier_required"); + } +} + +internal sealed class GetLessonSeedStateQueryValidator : AbstractValidator +{ + public GetLessonSeedStateQueryValidator() + { + RuleFor(request => request.LessonId).NotEmpty().WithErrorCode("lockey_identifier_required"); + } +} diff --git a/backend/src/Modules/Education/LearnStack.Modules.Education.Application/Writing/EducationCommandValidators.cs b/backend/src/Modules/Education/LearnStack.Modules.Education.Application/Writing/EducationCommandValidators.cs new file mode 100644 index 00000000..be1fc3fa --- /dev/null +++ b/backend/src/Modules/Education/LearnStack.Modules.Education.Application/Writing/EducationCommandValidators.cs @@ -0,0 +1,99 @@ +using System.Text.Json; +using FluentValidation; +using LearnStack.Modules.Education.Application.Contracts.Courses; +using LearnStack.Modules.Education.Application.Contracts.Lessons; +using LearnStack.Modules.Education.Domain; +using LearnStack.SharedKernel.Domain; +using LearnStack.SharedKernel.Localization; +using LearnStack.SharedKernel.Validation; + +namespace LearnStack.Modules.Education.Application.Writing; + +internal sealed class CreateCourseCommandValidator : AbstractValidator +{ + public CreateCourseCommandValidator() + { + RuleFor(request => request.CourseId).NotEmpty().WithErrorCode("lockey_identifier_invalid"); + RuleFor(request => request.SlugKey).Must(EducationSlug.IsValid).WithErrorCode("lockey_education_slug_invalid"); + RuleFor(request => request.ContentAccess).Must(value => value is "public" or "enrollment_required") + .WithErrorCode("lockey_education_content_access_invalid"); + RuleFor(request => request).Must(request => + request.LevelTaxonomyKey is null && request.LevelTaxonomySchemaVersion is null && request.LevelBandKey is null + || request is { LevelTaxonomyKey: { } key, LevelTaxonomySchemaVersion: > 0, LevelBandKey: { } band } + && EducationPinKey.IsValid(key) && EducationPinKey.IsValid(band)) + .OverridePropertyName(nameof(CreateCourseCommand.LevelTaxonomyKey)).WithErrorCode("lockey_education_level_pin_invalid"); + } +} + +internal sealed class CreateLessonCommandValidator : AbstractValidator +{ + public CreateLessonCommandValidator() + { + RuleFor(request => request.LessonId).NotEmpty().WithErrorCode("lockey_identifier_invalid"); + RuleFor(request => request.CourseId).NotEmpty().WithErrorCode("lockey_identifier_invalid"); + RuleFor(request => request.Sort).GreaterThanOrEqualTo(0).WithErrorCode("lockey_education_sort_invalid"); + RuleFor(request => request.ContentTypeKey).Must(EducationPinKey.IsValid).WithErrorCode("lockey_education_content_type_invalid"); + RuleFor(request => request.ContentTypeSchemaVersion).GreaterThan(0).WithErrorCode("lockey_education_content_type_invalid"); + } +} + +internal sealed class AddCourseTranslationCommandValidator : AbstractValidator +{ + public AddCourseTranslationCommandValidator() + { + RuleFor(request => request.CourseId).NotEmpty().WithErrorCode("lockey_identifier_invalid"); + RuleFor(request => request.ExpectedVersion).Must(value => value is >= 0).WithErrorCode("lockey_concurrency_conflict"); + RuleFor(request => request.Locale).Must(EducationInput.Locale).WithErrorCode("lockey_education_locale_invalid"); + RuleFor(request => request.Title).Must(EducationInput.Title).WithErrorCode("lockey_education_title_invalid"); + RuleFor(request => request.Summary).Must(value => value is null || JsonValue.IsStorableText(value)).WithErrorCode("lockey_education_summary_invalid"); + RuleFor(request => request.Slug).Must(EducationSlug.IsValid).WithErrorCode("lockey_education_slug_invalid"); + } +} + +internal sealed class AddLessonTranslationCommandValidator : AbstractValidator +{ + public AddLessonTranslationCommandValidator() + { + RuleFor(request => request.LessonId).NotEmpty().WithErrorCode("lockey_identifier_invalid"); + RuleFor(request => request.ExpectedVersion).Must(value => value is >= 0).WithErrorCode("lockey_concurrency_conflict"); + RuleFor(request => request.Locale).Must(EducationInput.Locale).WithErrorCode("lockey_education_locale_invalid"); + RuleFor(request => request.Title).Must(EducationInput.Title).WithErrorCode("lockey_education_title_invalid"); + RuleFor(request => request.Slug).Must(EducationSlug.IsValid).WithErrorCode("lockey_education_slug_invalid"); + RuleFor(request => request.Body).Must(EducationInput.Body).WithErrorCode("lockey_education_body_invalid"); + } +} + +internal sealed class PublishCourseCommandValidator : AbstractValidator +{ + public PublishCourseCommandValidator() + { + RuleFor(request => request.CourseId).NotEmpty().WithErrorCode("lockey_identifier_invalid"); + RuleFor(request => request.ExpectedVersion).Must(value => value is >= 0).WithErrorCode("lockey_concurrency_conflict"); + } +} + +internal sealed class PublishLessonCommandValidator : AbstractValidator +{ + public PublishLessonCommandValidator() + { + RuleFor(request => request.LessonId).NotEmpty().WithErrorCode("lockey_identifier_invalid"); + RuleFor(request => request.ExpectedVersion).Must(value => value is >= 0).WithErrorCode("lockey_concurrency_conflict"); + } +} + +internal static class EducationInput +{ + internal static bool Title(string value) => !string.IsNullOrWhiteSpace(value) && JsonValue.IsStorableText(value); + internal static bool Body(string value) + { + if (!JsonInstanceLimits.IsWithinCap(value) || !JsonValue.IsWellFormed(value)) return false; + using var document = JsonDocument.Parse(value); + return document.RootElement.ValueKind == JsonValueKind.Object; + } + internal static bool Locale(string value) + { + if (string.IsNullOrEmpty(value) || value.Length > LocaleTag.MaxLength) return false; + try { LocaleTag.EnsureWellFormed(value, nameof(value)); return true; } + catch (ArgumentException) { return false; } + } +} diff --git a/backend/src/Modules/Education/LearnStack.Modules.Education.Application/Writing/EducationWriteSupport.cs b/backend/src/Modules/Education/LearnStack.Modules.Education.Application/Writing/EducationWriteSupport.cs new file mode 100644 index 00000000..5cce73fb --- /dev/null +++ b/backend/src/Modules/Education/LearnStack.Modules.Education.Application/Writing/EducationWriteSupport.cs @@ -0,0 +1,84 @@ +using LearnStack.Modules.Education.Application.Contracts.Courses; +using LearnStack.Modules.Education.Application.Contracts.Lessons; +using LearnStack.Modules.Education.Domain; +using LearnStack.SharedKernel.Localization; +using LearnStack.SharedKernel.Persistence; +using LearnStack.SharedKernel.Results; +using LearnStack.SharedKernel.Tenancy; + +namespace LearnStack.Modules.Education.Application.Writing; + +internal static class EducationWriteSupport +{ + internal static Result? Context(ITenantContext context) => context.IsResolved + ? null : Code("lockey_tenant_mismatch"); + + internal static Result? Scope(IOrganizationScoped root, ITenantContext context) => + root.TenantId != context.TenantId ? Code("lockey_not_found") + : root.OrganizationId != context.OrganizationId ? Code("lockey_resource_scope_violation") : null; + + internal static Result? Version(long version, long? expected) => version == expected + ? null : Code("lockey_concurrency_conflict"); + + internal static Result? Draft(PublicationStatus status) => status == PublicationStatus.Draft + ? null : Result.Fail(FieldError("lockey_business_rule_violation", "Status", "lockey_education_translation_requires_draft")); + + internal static Result Code(string code) => Result.Fail(new Error(new LocalizedMessage(code))); + internal static Result Field(string field, string reason) => Result.Fail(FieldError("lockey_validation_failed", field, reason)); + + private static Error FieldError(string code, string field, string reason) => new(new LocalizedMessage(code), + new Dictionary>(StringComparer.Ordinal) + { + [field] = [new LocalizedMessage(reason)], + }); + + internal static CourseWriteDto Dto(Course root) => new(root.Id.Value, root.Version, root.Status.ToString(), + root.ContentAccess == CourseContentAccess.Public ? "public" : "enrollment_required"); + internal static LessonWriteDto Dto(Lesson root) => new(root.Id.Value, root.Version, root.Status.ToString()); + + internal static async Task> SaveAsync(Func save, Func response, IUnitOfWork unit, Func>? explainConflict = null) + { + try + { + await save(); + } + catch (AggregateConcurrencyException) + { + unit.MarkRollbackOnly(); + return Code("lockey_concurrency_conflict"); + } + catch (AggregateConflictException conflict) when (KnownConflict(conflict.ConstraintName) is not null) + { + unit.MarkRollbackOnly(); + if (explainConflict is not null && await explainConflict(conflict.ConstraintName) is { } explanation) + return Result.Fail(explanation); + // The exception filter admitted only a constraint with a known mapping. + var (field, reason) = KnownConflict(conflict.ConstraintName)!.Value; + return Result.Fail(FieldError("lockey_business_rule_violation", field, reason)); + } + + return Result.Ok(response()); + } + + internal static Error SlugConflict(string locale, string slug, Guid? visibleRoot) + { + var parameters = new Dictionary(StringComparer.Ordinal) { ["locale"] = locale, ["slug"] = slug }; + if (visibleRoot is { } id) parameters["entityId"] = id.ToString(); + return new Error(new LocalizedMessage("lockey_business_rule_violation"), + new Dictionary>(StringComparer.Ordinal) + { + ["Slug"] = [new LocalizedMessage("lockey_slug_taken", parameters)], + }); + } + + private static (string Field, string Reason)? KnownConflict(string? constraint) => constraint switch + { + "pk_courses" or "ux_courses_tenant_id_id" or "pk_lessons" or "ux_lessons_tenant_id_id" => + ("Id", "lockey_identifier_taken"), + "ux_courses_tenant_id_slug_key" => ("SlugKey", "lockey_slug_taken"), + "pk_course_translations" or "pk_lesson_translations" => ("Locale", "lockey_education_locale_already_exists"), + "ux_course_translations_tenant_id_locale_slug" or "ux_lesson_translations_tenant_id_locale_slug" => + ("Slug", "lockey_slug_taken"), + _ => null, + }; +} diff --git a/backend/src/Modules/Education/LearnStack.Modules.Education.Domain/Course.cs b/backend/src/Modules/Education/LearnStack.Modules.Education.Domain/Course.cs index 0b5451f5..99482c86 100644 --- a/backend/src/Modules/Education/LearnStack.Modules.Education.Domain/Course.cs +++ b/backend/src/Modules/Education/LearnStack.Modules.Education.Domain/Course.cs @@ -26,6 +26,7 @@ private Course(CourseId id, string slugKey) public int? LevelTaxonomySchemaVersion { get; private set; } public string? LevelBandKey { get; private set; } public PublicationStatus Status { get; private set; } + public CourseContentAccess ContentAccess { get; private set; } public IReadOnlyCollection Translations => _translations.AsReadOnly(); /// Builds a draft from validated application input, with no definition lookup. @@ -34,6 +35,7 @@ public static Course Create( TenantId tenantId, OrganizationId? organizationId, string slugKey, + CourseContentAccess contentAccess, IClock clock, UserId createdBy, string? levelTaxonomyKey = null, @@ -54,6 +56,11 @@ public static Course Create( } EducationSlug.EnsureValid(slugKey, nameof(slugKey)); + if (!Enum.IsDefined(contentAccess)) + { + throw new ArgumentOutOfRangeException(nameof(contentAccess)); + } + EnsureLevelPin(levelTaxonomyKey, levelTaxonomySchemaVersion, levelBandKey); var course = new Course(id, slugKey) @@ -64,6 +71,7 @@ public static Course Create( LevelTaxonomySchemaVersion = levelTaxonomySchemaVersion, LevelBandKey = levelBandKey, Status = PublicationStatus.Draft, + ContentAccess = contentAccess, }; course.MarkCreated(clock.UtcNow, createdBy); return course; diff --git a/backend/src/Modules/Education/LearnStack.Modules.Education.Domain/CourseContentAccess.cs b/backend/src/Modules/Education/LearnStack.Modules.Education.Domain/CourseContentAccess.cs new file mode 100644 index 00000000..8327b252 --- /dev/null +++ b/backend/src/Modules/Education/LearnStack.Modules.Education.Domain/CourseContentAccess.cs @@ -0,0 +1,8 @@ +namespace LearnStack.Modules.Education.Domain; + +/// Course-level content policy inherited by lessons (ADR-0050). +public enum CourseContentAccess +{ + EnrollmentRequired = 0, + Public = 1, +} diff --git a/backend/src/Modules/Education/LearnStack.Modules.Education.Domain/PublicationStatus.cs b/backend/src/Modules/Education/LearnStack.Modules.Education.Domain/PublicationStatus.cs index e0adce40..705a8c74 100644 --- a/backend/src/Modules/Education/LearnStack.Modules.Education.Domain/PublicationStatus.cs +++ b/backend/src/Modules/Education/LearnStack.Modules.Education.Domain/PublicationStatus.cs @@ -1,6 +1,6 @@ namespace LearnStack.Modules.Education.Domain; -/// Independent publication state of each Education root (ADR-0048). +/// Independent publication state, separate from content access (ADR-0050). public enum PublicationStatus { Draft = 0, diff --git a/backend/src/Modules/Education/LearnStack.Modules.Education.Infrastructure/Persistence/Configurations.cs b/backend/src/Modules/Education/LearnStack.Modules.Education.Infrastructure/Persistence/Configurations.cs index 052d3cfb..41553d97 100644 --- a/backend/src/Modules/Education/LearnStack.Modules.Education.Infrastructure/Persistence/Configurations.cs +++ b/backend/src/Modules/Education/LearnStack.Modules.Education.Infrastructure/Persistence/Configurations.cs @@ -44,6 +44,7 @@ public void Configure(EntityTypeBuilder builder) { table.HasCheckConstraint("ck_courses_slug_key_format", EducationMapping.SlugCheck("slug_key")); table.HasCheckConstraint("ck_courses_status", "status IN ('draft', 'published')"); + table.HasCheckConstraint("ck_courses_content_access", "content_access IN ('public', 'enrollment_required')"); table.HasCheckConstraint("ck_courses_level_reference", """ (level_taxonomy_key IS NULL AND level_taxonomy_schema_version IS NULL AND level_band_key IS NULL) OR (level_taxonomy_key IS NOT NULL AND level_taxonomy_schema_version IS NOT NULL @@ -58,6 +59,13 @@ public void Configure(EntityTypeBuilder builder) builder.HasAlternateKey(x => new { x.TenantId, x.Id }).HasName("ux_courses_tenant_id_id"); builder.Property(x => x.SlugKey).HasMaxLength(EducationSlug.MaxLength).IsRequired(); builder.Property(x => x.Status).MapStatus(); + builder.Property(x => x.ContentAccess) + .HasConversion( + value => value == CourseContentAccess.Public ? "public" : "enrollment_required", + value => value == "public" ? CourseContentAccess.Public : CourseContentAccess.EnrollmentRequired) + .HasColumnType("text") + .HasDefaultValue(CourseContentAccess.EnrollmentRequired) + .IsRequired(); builder.Property(x => x.LevelTaxonomyKey).HasMaxLength(EducationPinKey.MaxLength); builder.Property(x => x.LevelTaxonomySchemaVersion); builder.Property(x => x.LevelBandKey).HasMaxLength(EducationPinKey.MaxLength); diff --git a/backend/src/Modules/Education/LearnStack.Modules.Education.Infrastructure/Persistence/EducationSeedStateReader.cs b/backend/src/Modules/Education/LearnStack.Modules.Education.Infrastructure/Persistence/EducationSeedStateReader.cs new file mode 100644 index 00000000..c1940a8f --- /dev/null +++ b/backend/src/Modules/Education/LearnStack.Modules.Education.Infrastructure/Persistence/EducationSeedStateReader.cs @@ -0,0 +1,33 @@ +using System.Collections.Immutable; +using LearnStack.Modules.Education.Application.Abstractions; +using LearnStack.Modules.Education.Application.Contracts.Seeding; +using LearnStack.Modules.Education.Domain; +using Microsoft.EntityFrameworkCore; + +namespace LearnStack.Modules.Education.Infrastructure.Persistence; + +public sealed class EducationSeedStateReader(EducationDbContext context) : ISeedStateReader +{ + public async Task ReadCourseAsync(CourseId courseId, CancellationToken cancellationToken) + { + var row = await context.Courses.AsNoTracking().Include(course => course.Translations) + .SingleOrDefaultAsync(course => course.Id == courseId && course.DeletedAt == null, cancellationToken); + return row is null ? null : new CourseSeedDto(row.Id.Value, row.TenantId, row.OrganizationId, + row.SlugKey, row.Status.ToString(), row.ContentAccess == CourseContentAccess.Public ? "public" : "enrollment_required", + row.LevelTaxonomyKey, row.LevelTaxonomySchemaVersion, row.LevelBandKey, row.Version, + row.Translations.OrderBy(translation => translation.Locale, StringComparer.Ordinal) + .Select(translation => new CourseTranslationSeedDto(translation.Locale, translation.Title, + translation.Summary, translation.Slug)).ToImmutableArray()); + } + + public async Task ReadLessonAsync(LessonId lessonId, CancellationToken cancellationToken) + { + var row = await context.Lessons.AsNoTracking().Include(lesson => lesson.Translations) + .SingleOrDefaultAsync(lesson => lesson.Id == lessonId && lesson.DeletedAt == null, cancellationToken); + return row is null ? null : new LessonSeedDto(row.Id.Value, row.TenantId, row.OrganizationId, + row.CourseId.Value, row.Sort, row.Status.ToString(), row.ContentTypeKey, row.ContentTypeSchemaVersion, + row.Version, row.Translations.OrderBy(translation => translation.Locale, StringComparer.Ordinal) + .Select(translation => new LessonTranslationSeedDto(translation.Locale, translation.Title, + translation.Slug, translation.Body)).ToImmutableArray()); + } +} diff --git a/backend/src/Modules/Education/LearnStack.Modules.Education.Infrastructure/Persistence/EducationWriteStores.cs b/backend/src/Modules/Education/LearnStack.Modules.Education.Infrastructure/Persistence/EducationWriteStores.cs new file mode 100644 index 00000000..4ff931cb --- /dev/null +++ b/backend/src/Modules/Education/LearnStack.Modules.Education.Infrastructure/Persistence/EducationWriteStores.cs @@ -0,0 +1,71 @@ +using LearnStack.Infrastructure.Persistence; +using LearnStack.Modules.Education.Application.Abstractions; +using LearnStack.Modules.Education.Domain; +using Microsoft.EntityFrameworkCore; + +namespace LearnStack.Modules.Education.Infrastructure.Persistence; + +/// Own-module tracked course persistence on the announced ambient transaction. +public sealed class CourseWriteStore(EducationDbContext db) : ICourseWriteStore +{ + private static readonly HashSet OwnedConstraints = new(StringComparer.Ordinal) + { + "pk_courses", "ux_courses_tenant_id_id", "ux_courses_tenant_id_slug_key", + "pk_course_translations", "ux_course_translations_tenant_id_locale_slug", + }; + public Task FindAsync(CourseId id, CancellationToken cancellationToken) => db.Courses + .Include(root => root.Translations).SingleOrDefaultAsync(root => root.Id == id && root.DeletedAt == null, cancellationToken); + public Task AddAsync(Course aggregate, CancellationToken cancellationToken = default) + { + db.Courses.Add(aggregate); + return WriteStoreTracking.SaveTranslatingConflictsAsync(db, cancellationToken, OwnedConstraints); + } + public Task UpdateAsync(Course aggregate, CancellationToken cancellationToken = default) + { + WriteStoreTracking.EnsureTracked(db, aggregate); + return WriteStoreTracking.SaveTranslatingConflictsAsync(db, cancellationToken, OwnedConstraints); + } +} + +/// Own-module tracked lesson persistence; no parent aggregate is attached or saved. +public sealed class LessonWriteStore(EducationDbContext db) : ILessonWriteStore +{ + private static readonly HashSet OwnedConstraints = new(StringComparer.Ordinal) + { + "pk_lessons", "ux_lessons_tenant_id_id", "pk_lesson_translations", "ux_lesson_translations_tenant_id_locale_slug", + }; + public Task FindAsync(LessonId id, CancellationToken cancellationToken) => db.Lessons + .Include(root => root.Translations).SingleOrDefaultAsync(root => root.Id == id && root.DeletedAt == null, cancellationToken); + public Task AddAsync(Lesson aggregate, CancellationToken cancellationToken = default) + { + db.Lessons.Add(aggregate); + return WriteStoreTracking.SaveTranslatingConflictsAsync(db, cancellationToken, OwnedConstraints); + } + public Task UpdateAsync(Lesson aggregate, CancellationToken cancellationToken = default) + { + WriteStoreTracking.EnsureTracked(db, aggregate); + return WriteStoreTracking.SaveTranslatingConflictsAsync(db, cancellationToken, OwnedConstraints); + } +} + +/// Filtered detached parent read on the same connection as the child's write. +public sealed class ParentCourseReader(EducationDbContext db) : IParentCourseReader +{ + public Task ReadAsync(CourseId courseId, CancellationToken cancellationToken) + { + return db.Courses.AsNoTracking().SingleOrDefaultAsync(root => root.Id == courseId && root.DeletedAt == null, cancellationToken); + } +} + +/// Own-module collision reads retain both parent and satellite scope filters. +public sealed class TranslationCollisionReader(EducationDbContext db) : ITranslationCollisionReader +{ + public Task ReadCourseAsync(string locale, string slug, CancellationToken cancellationToken) => + db.Courses.AsNoTracking().Where(root => root.DeletedAt == null + && root.Translations.Any(translation => translation.Locale == locale && translation.Slug == slug)) + .Select(root => (CourseId?)root.Id).SingleOrDefaultAsync(cancellationToken); + public Task ReadLessonAsync(string locale, string slug, CancellationToken cancellationToken) => + db.Lessons.AsNoTracking().Where(root => root.DeletedAt == null + && root.Translations.Any(translation => translation.Locale == locale && translation.Slug == slug)) + .Select(root => (LessonId?)root.Id).SingleOrDefaultAsync(cancellationToken); +} diff --git a/backend/src/Modules/Education/LearnStack.Modules.Education.Infrastructure/Persistence/Migrations/20261001233219_add_course_content_access.Designer.cs b/backend/src/Modules/Education/LearnStack.Modules.Education.Infrastructure/Persistence/Migrations/20261001233219_add_course_content_access.Designer.cs new file mode 100644 index 00000000..64546896 --- /dev/null +++ b/backend/src/Modules/Education/LearnStack.Modules.Education.Infrastructure/Persistence/Migrations/20261001233219_add_course_content_access.Designer.cs @@ -0,0 +1,379 @@ +// +using System; +using LearnStack.Modules.Education.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 LearnStack.Modules.Education.Infrastructure.Persistence.Migrations +{ + [DbContext(typeof(EducationDbContext))] + [Migration("20261001233219_add_course_content_access")] + partial class add_course_content_access + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.12") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("LearnStack.Modules.Education.Domain.Course", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("ContentAccess") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("text") + .HasDefaultValue("enrollment_required") + .HasColumnName("content_access"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedBy") + .HasColumnType("uuid") + .HasColumnName("created_by"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("deleted_at"); + + b.Property("DeletedBy") + .HasColumnType("uuid") + .HasColumnName("deleted_by"); + + b.Property("LevelBandKey") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("level_band_key"); + + b.Property("LevelTaxonomyKey") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("level_taxonomy_key"); + + b.Property("LevelTaxonomySchemaVersion") + .HasColumnType("integer") + .HasColumnName("level_taxonomy_schema_version"); + + b.Property("OrganizationId") + .HasColumnType("uuid") + .HasColumnName("organization_id"); + + b.Property("SlugKey") + .IsRequired() + .HasMaxLength(160) + .HasColumnType("character varying(160)") + .HasColumnName("slug_key"); + + b.Property("Status") + .IsRequired() + .HasColumnType("text") + .HasColumnName("status"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UpdatedBy") + .HasColumnType("uuid") + .HasColumnName("updated_by"); + + b.Property("Version") + .IsConcurrencyToken() + .HasColumnType("bigint") + .HasDefaultValue(0L) + .HasColumnName("row_version"); + + b.HasKey("Id") + .HasName("pk_courses"); + + b.HasAlternateKey("TenantId", "Id") + .HasName("ux_courses_tenant_id_id"); + + b.HasIndex("TenantId", "OrganizationId") + .HasDatabaseName("ix_courses_tenant_id_organization_id"); + + b.HasIndex("TenantId", "SlugKey") + .IsUnique() + .HasDatabaseName("ux_courses_tenant_id_slug_key") + .HasFilter("deleted_at IS NULL"); + + b.HasIndex("TenantId", "OrganizationId", "CreatedAt", "Id") + .HasDatabaseName("ix_courses_tenant_id_organization_id_created_at_id") + .HasFilter("deleted_at IS NULL"); + + b.ToTable("courses", null, t => + { + t.HasCheckConstraint("ck_courses_content_access", "content_access IN ('public', 'enrollment_required')"); + + t.HasCheckConstraint("ck_courses_level_reference", "(level_taxonomy_key IS NULL AND level_taxonomy_schema_version IS NULL AND level_band_key IS NULL)\nOR (level_taxonomy_key IS NOT NULL AND level_taxonomy_schema_version IS NOT NULL\n AND level_taxonomy_schema_version > 0 AND level_band_key IS NOT NULL)"); + + t.HasCheckConstraint("ck_courses_slug_key_format", "slug_key ~ '^[a-z0-9]+(-[a-z0-9]+)*$' AND slug_key !~ '^[0-9a-f]{32}$' AND slug_key !~ '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$'"); + + t.HasCheckConstraint("ck_courses_status", "status IN ('draft', 'published')"); + }); + }); + + modelBuilder.Entity("LearnStack.Modules.Education.Domain.CourseTranslation", b => + { + b.Property("CourseId") + .HasColumnType("uuid") + .HasColumnName("course_id"); + + b.Property("Locale") + .HasMaxLength(35) + .HasColumnType("character varying(35)") + .HasColumnName("locale"); + + b.Property("OrganizationId") + .HasColumnType("uuid") + .HasColumnName("organization_id"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(160) + .HasColumnType("character varying(160)") + .HasColumnName("slug"); + + b.Property("Summary") + .HasColumnType("text") + .HasColumnName("summary"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("Title") + .IsRequired() + .HasColumnType("text") + .HasColumnName("title"); + + b.HasKey("CourseId", "Locale") + .HasName("pk_course_translations"); + + b.HasAlternateKey("TenantId", "Locale", "Slug") + .HasName("ux_course_translations_tenant_id_locale_slug"); + + b.HasIndex("TenantId", "CourseId") + .HasDatabaseName("ix_course_translations_tenant_id_course_id"); + + b.HasIndex("TenantId", "OrganizationId") + .HasDatabaseName("ix_course_translations_tenant_id_organization_id"); + + b.ToTable("course_translations", null, t => + { + t.HasCheckConstraint("ck_course_translations_slug_format", "slug ~ '^[a-z0-9]+(-[a-z0-9]+)*$' AND slug !~ '^[0-9a-f]{32}$' AND slug !~ '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$'"); + }); + }); + + modelBuilder.Entity("LearnStack.Modules.Education.Domain.Lesson", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("ContentTypeKey") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("content_type_key"); + + b.Property("ContentTypeSchemaVersion") + .HasColumnType("integer") + .HasColumnName("content_type_schema_version"); + + b.Property("CourseId") + .HasColumnType("uuid") + .HasColumnName("course_id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedBy") + .HasColumnType("uuid") + .HasColumnName("created_by"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("deleted_at"); + + b.Property("DeletedBy") + .HasColumnType("uuid") + .HasColumnName("deleted_by"); + + b.Property("OrganizationId") + .HasColumnType("uuid") + .HasColumnName("organization_id"); + + b.Property("Sort") + .HasColumnType("integer") + .HasColumnName("sort"); + + b.Property("Status") + .IsRequired() + .HasColumnType("text") + .HasColumnName("status"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UpdatedBy") + .HasColumnType("uuid") + .HasColumnName("updated_by"); + + b.Property("Version") + .IsConcurrencyToken() + .HasColumnType("bigint") + .HasDefaultValue(0L) + .HasColumnName("row_version"); + + b.HasKey("Id") + .HasName("pk_lessons"); + + b.HasAlternateKey("TenantId", "Id") + .HasName("ux_lessons_tenant_id_id"); + + b.HasIndex("TenantId", "CourseId") + .HasDatabaseName("ix_lessons_tenant_id_course_id"); + + b.HasIndex("TenantId", "OrganizationId") + .HasDatabaseName("ix_lessons_tenant_id_organization_id"); + + b.HasIndex("TenantId", "CourseId", "Sort", "Id") + .HasDatabaseName("ix_lessons_tenant_id_course_id_sort_id") + .HasFilter("deleted_at IS NULL"); + + b.ToTable("lessons", null, t => + { + t.HasCheckConstraint("ck_lessons_content_type_schema_version", "content_type_schema_version > 0"); + + t.HasCheckConstraint("ck_lessons_sort", "sort >= 0"); + + t.HasCheckConstraint("ck_lessons_status", "status IN ('draft', 'published')"); + }); + }); + + modelBuilder.Entity("LearnStack.Modules.Education.Domain.LessonTranslation", b => + { + b.Property("LessonId") + .HasColumnType("uuid") + .HasColumnName("lesson_id"); + + b.Property("Locale") + .HasMaxLength(35) + .HasColumnType("character varying(35)") + .HasColumnName("locale"); + + b.Property("Body") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("body"); + + b.Property("OrganizationId") + .HasColumnType("uuid") + .HasColumnName("organization_id"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(160) + .HasColumnType("character varying(160)") + .HasColumnName("slug"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("Title") + .IsRequired() + .HasColumnType("text") + .HasColumnName("title"); + + b.HasKey("LessonId", "Locale") + .HasName("pk_lesson_translations"); + + b.HasAlternateKey("TenantId", "Locale", "Slug") + .HasName("ux_lesson_translations_tenant_id_locale_slug"); + + b.HasIndex("TenantId", "LessonId") + .HasDatabaseName("ix_lesson_translations_tenant_id_lesson_id"); + + b.HasIndex("TenantId", "OrganizationId") + .HasDatabaseName("ix_lesson_translations_tenant_id_organization_id"); + + b.ToTable("lesson_translations", null, t => + { + t.HasCheckConstraint("ck_lesson_translations_body_object", "jsonb_typeof(body) = 'object'"); + + t.HasCheckConstraint("ck_lesson_translations_slug_format", "slug ~ '^[a-z0-9]+(-[a-z0-9]+)*$' AND slug !~ '^[0-9a-f]{32}$' AND slug !~ '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$'"); + }); + }); + + modelBuilder.Entity("LearnStack.Modules.Education.Domain.CourseTranslation", b => + { + b.HasOne("LearnStack.Modules.Education.Domain.Course", null) + .WithMany("Translations") + .HasForeignKey("TenantId", "CourseId") + .HasPrincipalKey("TenantId", "Id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_course_translations_course"); + }); + + modelBuilder.Entity("LearnStack.Modules.Education.Domain.Lesson", b => + { + b.HasOne("LearnStack.Modules.Education.Domain.Course", null) + .WithMany() + .HasForeignKey("TenantId", "CourseId") + .HasPrincipalKey("TenantId", "Id") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired() + .HasConstraintName("fk_lessons_course"); + }); + + modelBuilder.Entity("LearnStack.Modules.Education.Domain.LessonTranslation", b => + { + b.HasOne("LearnStack.Modules.Education.Domain.Lesson", null) + .WithMany("Translations") + .HasForeignKey("TenantId", "LessonId") + .HasPrincipalKey("TenantId", "Id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_lesson_translations_lesson"); + }); + + modelBuilder.Entity("LearnStack.Modules.Education.Domain.Course", b => + { + b.Navigation("Translations"); + }); + + modelBuilder.Entity("LearnStack.Modules.Education.Domain.Lesson", b => + { + b.Navigation("Translations"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/backend/src/Modules/Education/LearnStack.Modules.Education.Infrastructure/Persistence/Migrations/20261001233219_add_course_content_access.cs b/backend/src/Modules/Education/LearnStack.Modules.Education.Infrastructure/Persistence/Migrations/20261001233219_add_course_content_access.cs new file mode 100644 index 00000000..2386fc53 --- /dev/null +++ b/backend/src/Modules/Education/LearnStack.Modules.Education.Infrastructure/Persistence/Migrations/20261001233219_add_course_content_access.cs @@ -0,0 +1,43 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace LearnStack.Modules.Education.Infrastructure.Persistence.Migrations +{ + /// + public partial class add_course_content_access : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + // ADD COLUMN's default backfills legacy rows without a row UPDATE or + // an RLS bypass. Publication, translations, pins and scope stay intact. + migrationBuilder.AddColumn( + name: "content_access", + table: "courses", + type: "text", + nullable: false, + defaultValue: "enrollment_required"); + + migrationBuilder.AddCheckConstraint( + name: "ck_courses_content_access", + table: "courses", + sql: "content_access IN ('public', 'enrollment_required')"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + // Technical reversal for disposable migration tests only. A live + // rollback to ADR-0048 readers would expose restricted content; keep + // this column or stop public Education reads (ADR-0050). + migrationBuilder.DropCheckConstraint( + name: "ck_courses_content_access", + table: "courses"); + + migrationBuilder.DropColumn( + name: "content_access", + table: "courses"); + } + } +} diff --git a/backend/src/Modules/Education/LearnStack.Modules.Education.Infrastructure/Persistence/Migrations/EducationDbContextModelSnapshot.cs b/backend/src/Modules/Education/LearnStack.Modules.Education.Infrastructure/Persistence/Migrations/EducationDbContextModelSnapshot.cs index 757c6e3c..08ae5110 100644 --- a/backend/src/Modules/Education/LearnStack.Modules.Education.Infrastructure/Persistence/Migrations/EducationDbContextModelSnapshot.cs +++ b/backend/src/Modules/Education/LearnStack.Modules.Education.Infrastructure/Persistence/Migrations/EducationDbContextModelSnapshot.cs @@ -28,6 +28,13 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasColumnType("uuid") .HasColumnName("id"); + b.Property("ContentAccess") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("text") + .HasDefaultValue("enrollment_required") + .HasColumnName("content_access"); + b.Property("CreatedAt") .HasColumnType("timestamp with time zone") .HasColumnName("created_at"); @@ -111,6 +118,8 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("courses", null, t => { + t.HasCheckConstraint("ck_courses_content_access", "content_access IN ('public', 'enrollment_required')"); + t.HasCheckConstraint("ck_courses_level_reference", "(level_taxonomy_key IS NULL AND level_taxonomy_schema_version IS NULL AND level_band_key IS NULL)\nOR (level_taxonomy_key IS NOT NULL AND level_taxonomy_schema_version IS NOT NULL\n AND level_taxonomy_schema_version > 0 AND level_band_key IS NOT NULL)"); t.HasCheckConstraint("ck_courses_slug_key_format", "slug_key ~ '^[a-z0-9]+(-[a-z0-9]+)*$' AND slug_key !~ '^[0-9a-f]{32}$' AND slug_key !~ '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$'"); diff --git a/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Application.Contracts/Branding/SetTenantBrandingCommand.cs b/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Application.Contracts/Branding/SetTenantBrandingCommand.cs new file mode 100644 index 00000000..03246ef8 --- /dev/null +++ b/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Application.Contracts/Branding/SetTenantBrandingCommand.cs @@ -0,0 +1,14 @@ +using LearnStack.SharedKernel.Results; +using MediatR; + +namespace LearnStack.Modules.Tenancy.Application.Contracts.Branding; + +/// The setting identity, committed root version and canonical complete palette. +public sealed record TenantBrandingDto(Guid SettingId, long Version, string Theme); + +/// Unrouted whole-theme write in the trusted context's tenant-wide scope. +/// Explicit identity; no lookup or replacement by display name. +/// Complete closed palette, admitted for safe colors and contrast. +/// Null is create-only; an exact version is replace-only. +public sealed record SetTenantBrandingCommand(Guid SettingId, string Theme, long? ExpectedVersion) + : IRequest>; diff --git a/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Application.Contracts/Locales/ITenantLocaleEligibilityReader.cs b/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Application.Contracts/Locales/ITenantLocaleEligibilityReader.cs new file mode 100644 index 00000000..4a4333ee --- /dev/null +++ b/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Application.Contracts/Locales/ITenantLocaleEligibilityReader.cs @@ -0,0 +1,13 @@ +using LearnStack.SharedKernel.Results; + +namespace LearnStack.Modules.Tenancy.Application.Contracts.Locales; + +/// +/// Reads canonical enabled membership in the announced tenant on the ambient +/// transaction, without caching, fallback or an implicit default language. +/// Invalid stored configuration is a bounded validation refusal (P02d-2). +/// +public interface ITenantLocaleEligibilityReader +{ + Task> ReadEligibleAsync(string locale, CancellationToken cancellationToken); +} diff --git a/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Application.Contracts/Locales/LocaleCommands.cs b/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Application.Contracts/Locales/LocaleCommands.cs new file mode 100644 index 00000000..c7df1571 --- /dev/null +++ b/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Application.Contracts/Locales/LocaleCommands.cs @@ -0,0 +1,28 @@ +using System.Collections.Immutable; +using LearnStack.SharedKernel.Identifiers; +using LearnStack.SharedKernel.Results; +using MediatR; + +namespace LearnStack.Modules.Tenancy.Application.Contracts.Locales; + +/// Canonical locale membership; defaults are always enabled. +public sealed record TenantLocaleDto(string Locale, bool IsEnabled, bool IsDefault, short Sort); +/// Committed tenant-root version and immutable membership ordered by sort/locale. +public sealed record TenantLocalesDto(TenantId TenantId, long Version, ImmutableArray Locales); + +/// +/// Adds canonical membership in the trusted tenant-wide context. The first enabled +/// locale becomes default; an explicit enabled default replaces the incumbent. +/// +/// Exact current tenant-root version. +/// Well-formed locale tag, canonicalized before insertion. +/// Whether the locale admits content writes. +/// Explicit promotion; requires enabled membership. +/// Nonnegative presentation order; ties are permitted. +public sealed record AddTenantLocaleCommand(long ExpectedVersion, string Locale, + bool IsEnabled, bool IsDefault, short Sort) : IRequest>; +/// Promotes an existing enabled locale in the trusted tenant-wide context. +/// Exact current tenant-root version. +/// Well-formed tag identifying existing canonical membership. +public sealed record SetDefaultTenantLocaleCommand(long ExpectedVersion, string Locale) + : IRequest>; diff --git a/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Application.Contracts/Seeding/SeedStateQueries.cs b/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Application.Contracts/Seeding/SeedStateQueries.cs new file mode 100644 index 00000000..62ddc616 --- /dev/null +++ b/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Application.Contracts/Seeding/SeedStateQueries.cs @@ -0,0 +1,24 @@ +using System.Collections.Immutable; +using LearnStack.SharedKernel.Identifiers; +using LearnStack.SharedKernel.Results; +using MediatR; + +namespace LearnStack.Modules.Tenancy.Application.Contracts.Seeding; + +// Trusted contextual verification only: no public marker, endpoint or tenant input. +public sealed record SeedLookup(T? State) where T : class; + +public sealed record TenantLocaleSeedDto(string Locale, bool IsEnabled, bool IsDefault, short Sort); +public sealed record TenantSeedDto(TenantId Id, string Slug, string DisplayName, string Status, + OrganizationId? DefaultOrganizationId, long Version, ImmutableArray Locales); +public sealed record OrganizationSeedDto(OrganizationId Id, TenantId TenantId, string Slug, + string DisplayName, string Status); +public sealed record HostMappingSeedDto(string Host, TenantId TenantId, OrganizationId? OrganizationId, + bool IsActive, bool IsPubliclyLive); +public sealed record SettingSeedDto(Guid Id, TenantId TenantId, OrganizationId? OrganizationId, + string Key, string Value, long Version); + +public sealed record GetTenantSeedStateQuery() : IRequest>>; +public sealed record GetOrganizationSeedStateQuery(OrganizationId OrganizationId) : IRequest>>; +public sealed record GetHostMappingSeedStateQuery(string Host) : IRequest>>; +public sealed record GetSettingSeedStateQuery(Guid SettingId) : IRequest>>; diff --git a/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Application/Abstractions/ISeedStateReader.cs b/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Application/Abstractions/ISeedStateReader.cs new file mode 100644 index 00000000..138e8394 --- /dev/null +++ b/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Application/Abstractions/ISeedStateReader.cs @@ -0,0 +1,14 @@ +using LearnStack.Modules.Tenancy.Application.Contracts.Seeding; +using LearnStack.SharedKernel.Identifiers; +using LearnStack.Modules.Tenancy.Domain; + +namespace LearnStack.Modules.Tenancy.Application.Abstractions; + +/// Filtered, uncached verification on the caller's announced transaction. +public interface ISeedStateReader +{ + Task ReadTenantAsync(CancellationToken cancellationToken); + Task ReadOrganizationAsync(OrganizationId organizationId, CancellationToken cancellationToken); + Task ReadHostMappingAsync(string host, CancellationToken cancellationToken); + Task ReadSettingAsync(TenantSettingId settingId, CancellationToken cancellationToken); +} diff --git a/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Application/Abstractions/ITenantExistenceReader.cs b/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Application/Abstractions/ITenantExistenceReader.cs new file mode 100644 index 00000000..3d75f812 --- /dev/null +++ b/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Application/Abstractions/ITenantExistenceReader.cs @@ -0,0 +1,7 @@ +namespace LearnStack.Modules.Tenancy.Application.Abstractions; + +/// Checks that the announced tenant exists and is not soft-deleted. +public interface ITenantExistenceReader +{ + Task ExistsAsync(CancellationToken cancellationToken); +} diff --git a/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Application/Abstractions/TenancyWriteStores.cs b/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Application/Abstractions/TenancyWriteStores.cs index 8eff963b..4e138097 100644 --- a/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Application/Abstractions/TenancyWriteStores.cs +++ b/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Application/Abstractions/TenancyWriteStores.cs @@ -10,7 +10,16 @@ namespace LearnStack.Modules.Tenancy.Application.Abstractions; /// is a forbidden edge, and the reverse reference already exists, so a handler that named /// TenancyDbContext would be a project cycle the compiler refuses. /// -public interface ITenantWriteStore : IAggregateWriteStore; +public interface ITenantWriteStore : IAggregateWriteStore +{ + Task FindAsync(TenantId id, CancellationToken cancellationToken = default); +} + +/// The tracked write side of one setting root. +public interface ITenantSettingWriteStore : IAggregateWriteStore +{ + Task FindAsync(TenantSettingId id, CancellationToken cancellationToken = default); +} /// The write side of the Organization aggregate. /// diff --git a/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Application/Audit/TenancyAuditCatalogSource.cs b/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Application/Audit/TenancyAuditCatalogSource.cs index 900bd059..3a599ca0 100644 --- a/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Application/Audit/TenancyAuditCatalogSource.cs +++ b/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Application/Audit/TenancyAuditCatalogSource.cs @@ -1,3 +1,6 @@ +using LearnStack.Modules.Tenancy.Application.Contracts.Seeding; +using LearnStack.Modules.Tenancy.Application.Contracts.Locales; +using LearnStack.Modules.Tenancy.Application.Contracts.Branding; using LearnStack.Modules.Tenancy.Application.Contracts.Tenant; using LearnStack.Modules.Tenancy.Domain; using LearnStack.SharedKernel.Audit; @@ -11,7 +14,7 @@ namespace LearnStack.Modules.Tenancy.Application.Audit; /// /// Only the operations whose command exists. /// The matrix carries -/// fifteen more rows marked (planned) — classification ahead of code — and +/// thirteen more rows marked (planned) — classification ahead of code — and /// registering one of those would claim a writer that does not exist, which is the half /// of the join that has no way to notice. /// @@ -36,7 +39,15 @@ public void Describe(IAuditCatalogBuilder builder) { ArgumentNullException.ThrowIfNull(builder); + builder.Off(); + builder.Off(); + builder.Off(); + builder.Off(); + builder + .ShouldAudit("tenancy.locale.write", OperationType.Update, typeof(Domain.Tenant)) + .ShouldAudit("tenancy.locale.write", OperationType.Update, typeof(Domain.Tenant)) + .MustAudit("tenancy.setting.write", OperationType.Update, typeof(TenantSetting)) // TWO entries for one command, and the reason is the whole of ADR-0044 § 3: // ProvisionTenantCommand writes two aggregate roots on one transaction and the // matrix classifies both MUST. Under a singular reading the Organization row diff --git a/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Application/Branding/BrandingThemeRegistry.cs b/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Application/Branding/BrandingThemeRegistry.cs new file mode 100644 index 00000000..61ebd5f9 --- /dev/null +++ b/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Application/Branding/BrandingThemeRegistry.cs @@ -0,0 +1,98 @@ +using System.Buffers; +using System.Collections.Immutable; +using System.Globalization; +using System.Text.Json; +using LearnStack.Modules.Tenancy.Application.Tenant; +using LearnStack.SharedKernel.Domain; +using LearnStack.SharedKernel.Results; + +namespace LearnStack.Modules.Tenancy.Application.Branding; + +public sealed record BrandingColorDescriptor(string JsonName, string CssVariable, double MinimumContrast); + +/// The closed authoring registry; generic settings remain unconstrained. +public static class BrandingThemeRegistry +{ + private static readonly SearchValues HexDigits = SearchValues.Create("0123456789abcdefABCDEF"); + public const string SettingKey = "branding.theme"; + public static ImmutableArray Colors { get; } = + [ + new("primary", "--ls-primary", 3), + new("background", "--ls-bg", 0), + new("foreground", "--ls-fg", 4.5), + new("muted", "--ls-muted", 4.5), + ]; + + public static Result ValidateAndCanonicalize(string theme) + { + if (string.IsNullOrWhiteSpace(theme) || !JsonValue.IsWithinRowCap(theme)) + { + return Invalid("lockey_branding_invalid"); + } + + try + { + using var document = JsonDocument.Parse(theme); + if (document.RootElement.ValueKind != JsonValueKind.Object) + { + return Invalid("lockey_branding_invalid"); + } + + var values = new Dictionary(StringComparer.Ordinal); + foreach (var property in document.RootElement.EnumerateObject()) + { + if (!Colors.Any(color => color.JsonName == property.Name) + || property.Value.ValueKind != JsonValueKind.String + || !IsHex(property.Value.GetString()) + || !values.TryAdd(property.Name, property.Value.GetString()!.ToLowerInvariant())) + { + return Invalid("lockey_branding_invalid"); + } + } + + if (values.Count != Colors.Length) + { + return Invalid("lockey_branding_invalid"); + } + + var background = Luminance(values["background"]); + foreach (var color in Colors.Where(color => color.MinimumContrast > 0)) + { + var luminance = Luminance(values[color.JsonName]); + var ratio = (Math.Max(luminance, background) + 0.05) / (Math.Min(luminance, background) + 0.05); + if (ratio < color.MinimumContrast) + { + return TenantWriteFailures.Field("lockey_validation_failed", + "/" + color.JsonName, "lockey_branding_contrast"); + } + } + + // Registry order is canonical; authored JSON property order has no semantics. + return Result.Ok(JsonSerializer.Serialize(Colors.ToDictionary(color => color.JsonName, + color => values[color.JsonName], StringComparer.Ordinal))); + } + catch (JsonException) + { + return Invalid("lockey_branding_invalid"); + } + } + + private static Result Invalid(string reason) => + TenantWriteFailures.Field("lockey_validation_failed", "Theme", reason); + + private static bool IsHex(string? value) => value is { Length: 7 } && value[0] == '#' + && value.AsSpan(1).IndexOfAnyExcept(HexDigits) < 0; + + private static double Luminance(string color) + { + static double Linear(byte channel) + { + var value = channel / 255d; + return value <= 0.04045 ? value / 12.92 : Math.Pow((value + 0.055) / 1.055, 2.4); + } + + return 0.2126 * Linear(byte.Parse(color.AsSpan(1, 2), NumberStyles.HexNumber, CultureInfo.InvariantCulture)) + + 0.7152 * Linear(byte.Parse(color.AsSpan(3, 2), NumberStyles.HexNumber, CultureInfo.InvariantCulture)) + + 0.0722 * Linear(byte.Parse(color.AsSpan(5, 2), NumberStyles.HexNumber, CultureInfo.InvariantCulture)); + } +} diff --git a/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Application/Branding/SetTenantBrandingCommandHandler.cs b/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Application/Branding/SetTenantBrandingCommandHandler.cs new file mode 100644 index 00000000..d551cc5f --- /dev/null +++ b/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Application/Branding/SetTenantBrandingCommandHandler.cs @@ -0,0 +1,91 @@ +using LearnStack.Modules.Tenancy.Application.Abstractions; +using LearnStack.Modules.Tenancy.Application.Contracts.Branding; +using LearnStack.Modules.Tenancy.Application.Tenant; +using LearnStack.Modules.Tenancy.Domain; +using LearnStack.SharedKernel.Audit; +using LearnStack.SharedKernel.Identifiers; +using LearnStack.SharedKernel.Persistence; +using LearnStack.SharedKernel.Results; +using LearnStack.SharedKernel.Tenancy; +using LearnStack.SharedKernel.Time; +using MediatR; + +namespace LearnStack.Modules.Tenancy.Application.Branding; + +internal sealed class SetTenantBrandingCommandHandler(ITenantSettingWriteStore settings, ITenantExistenceReader tenants, ITenantContext tenantContext, + IUnitOfWork unitOfWork, IAuditSubject auditSubject, IClock clock) + : IRequestHandler> +{ + public async Task> Handle(SetTenantBrandingCommand request, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(request); + if (TenantWriteFailures.Scope(tenantContext) is { } scopeFailure) + { + return scopeFailure; + } + + if (!await tenants.ExistsAsync(cancellationToken)) + { + return TenantWriteFailures.Code("lockey_not_found"); + } + + var theme = BrandingThemeRegistry.ValidateAndCanonicalize(request.Theme); + if (theme.IsFailure) + { + return Result.Fail(theme.Error); + } + + var setting = await settings.FindAsync(TenantSettingId.From(request.SettingId), cancellationToken); + if (request.ExpectedVersion is null) + { + if (setting is not null) + { + return TenantWriteFailures.Field("lockey_business_rule_violation", "SettingId", "lockey_identifier_taken"); + } + + setting = TenantSetting.Create(TenantSettingId.From(request.SettingId), tenantContext.TenantId, null, + BrandingThemeRegistry.SettingKey, theme.Value, clock, tenantContext.UserId ?? UserId.SystemActor); + } + else + { + if (setting is null || setting.OrganizationId is not null || setting.Key != BrandingThemeRegistry.SettingKey) + { + return TenantWriteFailures.Code("lockey_not_found"); + } + + auditSubject.Designate(setting); + if (setting.Version != request.ExpectedVersion) + { + return TenantWriteFailures.Code("lockey_concurrency_conflict"); + } + + setting.SetValue(theme.Value, clock, tenantContext.UserId ?? UserId.SystemActor); + } + + auditSubject.Designate(setting); + try + { + if (request.ExpectedVersion is null) + { + await settings.AddAsync(setting, cancellationToken); + } + else + { + await settings.UpdateAsync(setting, cancellationToken); + } + } + catch (AggregateConcurrencyException) + { + unitOfWork.MarkRollbackOnly(); + return TenantWriteFailures.Code("lockey_concurrency_conflict"); + } + catch (AggregateConflictException conflict) when (conflict.ConstraintName is + "pk_tenant_settings" or "ux_tenant_settings_tenant_id_organization_id_key") + { + unitOfWork.MarkRollbackOnly(); + return TenantWriteFailures.Field("lockey_business_rule_violation", "SettingId", "lockey_setting_taken"); + } + + return Result.Ok(new TenantBrandingDto(setting.Id.Value, setting.Version, setting.Value)); + } +} diff --git a/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Application/Branding/SetTenantBrandingCommandValidator.cs b/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Application/Branding/SetTenantBrandingCommandValidator.cs new file mode 100644 index 00000000..2e47c236 --- /dev/null +++ b/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Application/Branding/SetTenantBrandingCommandValidator.cs @@ -0,0 +1,16 @@ +using FluentValidation; +using LearnStack.Modules.Tenancy.Application.Contracts.Branding; + +namespace LearnStack.Modules.Tenancy.Application.Branding; + +internal sealed class SetTenantBrandingCommandValidator : AbstractValidator +{ + public SetTenantBrandingCommandValidator() + { + RuleFor(command => command.SettingId).NotEmpty().WithErrorCode("lockey_identifier_required"); + RuleFor(command => command.ExpectedVersion).Must(version => version is null or >= 0) + .WithErrorCode("lockey_concurrency_conflict"); + // Complete theme resolution is performed once by the handler before any mutation. + RuleFor(command => command.Theme).NotEmpty().WithErrorCode("lockey_branding_invalid"); + } +} diff --git a/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Application/Locales/AddTenantLocaleCommandHandler.cs b/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Application/Locales/AddTenantLocaleCommandHandler.cs new file mode 100644 index 00000000..84b245c5 --- /dev/null +++ b/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Application/Locales/AddTenantLocaleCommandHandler.cs @@ -0,0 +1,48 @@ +using LearnStack.Modules.Tenancy.Application.Abstractions; +using LearnStack.Modules.Tenancy.Application.Contracts.Locales; +using LearnStack.Modules.Tenancy.Application.Tenant; +using LearnStack.SharedKernel.Audit; +using LearnStack.SharedKernel.Identifiers; +using LearnStack.SharedKernel.Localization; +using LearnStack.SharedKernel.Persistence; +using LearnStack.SharedKernel.Results; +using LearnStack.SharedKernel.Tenancy; +using LearnStack.SharedKernel.Time; +using MediatR; + +namespace LearnStack.Modules.Tenancy.Application.Locales; + +internal sealed class AddTenantLocaleCommandHandler(ITenantWriteStore tenants, ITenantContext tenantContext, + IUnitOfWork unitOfWork, IAuditSubject auditSubject, IClock clock) + : IRequestHandler> +{ + public async Task> Handle(AddTenantLocaleCommand request, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(request); + if (TenantWriteFailures.Scope(tenantContext) is { } scopeFailure) + { + return scopeFailure; + } + + var tenant = await tenants.FindAsync(tenantContext.TenantId, cancellationToken); + if (tenant is null) + { + return TenantWriteFailures.Code("lockey_not_found"); + } + + auditSubject.Designate(tenant); + if (LocaleWriteSupport.Preflight(tenant, request.ExpectedVersion) is { } failure) + { + return failure; + } + + if (tenant.Locales.Any(locale => locale.Locale == LocaleTag.Canonicalize(request.Locale))) + { + return TenantWriteFailures.Field("lockey_business_rule_violation", "Locale", "lockey_locale_taken"); + } + + tenant.AddLocale(request.Locale, request.IsDefault, clock, tenantContext.UserId ?? UserId.SystemActor, + request.IsEnabled, request.Sort); + return await LocaleWriteSupport.SaveAsync(tenant, tenants, unitOfWork, cancellationToken); + } +} diff --git a/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Application/Locales/LocaleCommandValidators.cs b/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Application/Locales/LocaleCommandValidators.cs new file mode 100644 index 00000000..bf23c6cb --- /dev/null +++ b/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Application/Locales/LocaleCommandValidators.cs @@ -0,0 +1,47 @@ +using FluentValidation; +using LearnStack.Modules.Tenancy.Application.Contracts.Locales; +using LearnStack.SharedKernel.Localization; + +namespace LearnStack.Modules.Tenancy.Application.Locales; + +internal sealed class AddTenantLocaleCommandValidator : AbstractValidator +{ + public AddTenantLocaleCommandValidator() + { + RuleFor(command => command.ExpectedVersion).GreaterThanOrEqualTo(0).WithErrorCode("lockey_concurrency_conflict"); + RuleFor(command => command.Locale).Must(LocaleInput.IsValid).WithErrorCode("lockey_locale_invalid"); + RuleFor(command => command.Sort).GreaterThanOrEqualTo((short)0).WithErrorCode("lockey_locale_invalid"); + RuleFor(command => command).Must(command => !command.IsDefault || command.IsEnabled) + .OverridePropertyName(nameof(AddTenantLocaleCommand.IsDefault)).WithErrorCode("lockey_locale_disabled"); + } +} + +internal sealed class SetDefaultTenantLocaleCommandValidator : AbstractValidator +{ + public SetDefaultTenantLocaleCommandValidator() + { + RuleFor(command => command.ExpectedVersion).GreaterThanOrEqualTo(0).WithErrorCode("lockey_concurrency_conflict"); + RuleFor(command => command.Locale).Must(LocaleInput.IsValid).WithErrorCode("lockey_locale_invalid"); + } +} + +internal static class LocaleInput +{ + internal static bool IsValid(string value) + { + if (string.IsNullOrEmpty(value) || value.Length > LocaleTag.MaxLength) + { + return false; + } + + try + { + LocaleTag.EnsureWellFormed(value, nameof(value)); + return true; + } + catch (ArgumentException) + { + return false; + } + } +} diff --git a/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Application/Locales/LocaleWriteSupport.cs b/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Application/Locales/LocaleWriteSupport.cs new file mode 100644 index 00000000..53d89e94 --- /dev/null +++ b/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Application/Locales/LocaleWriteSupport.cs @@ -0,0 +1,45 @@ +using System.Collections.Immutable; +using LearnStack.Modules.Tenancy.Application.Abstractions; +using LearnStack.Modules.Tenancy.Application.Contracts.Locales; +using LearnStack.Modules.Tenancy.Application.Tenant; +using LearnStack.SharedKernel.Persistence; +using LearnStack.SharedKernel.Results; + +namespace LearnStack.Modules.Tenancy.Application.Locales; + +internal static class LocaleWriteSupport +{ + internal static Result? Preflight(Domain.Tenant tenant, long expectedVersion) => + tenant.Version != expectedVersion + ? TenantWriteFailures.Code("lockey_concurrency_conflict") + : !tenant.HasValidLocaleConfiguration() + ? TenantWriteFailures.Field("lockey_validation_failed", "Locale", "lockey_locale_configuration_invalid") + : null; + + internal static async Task> SaveAsync(Domain.Tenant tenant, ITenantWriteStore store, + IUnitOfWork unitOfWork, CancellationToken cancellationToken) + { + try + { + await store.UpdateAsync(tenant, cancellationToken); + } + catch (AggregateConcurrencyException) + { + unitOfWork.MarkRollbackOnly(); + return TenantWriteFailures.Code("lockey_concurrency_conflict"); + } + catch (AggregateConflictException conflict) when (conflict.ConstraintName is + "pk_tenant_locales" or "ux_tenant_locales_tenant_id_is_default") + { + // A mutation or first two-pass save may already exist. An absorbed nested + // refusal must not flush it later from the ambient tracker. + unitOfWork.MarkRollbackOnly(); + return TenantWriteFailures.Field("lockey_business_rule_violation", "Locale", "lockey_locale_taken"); + } + + return Result.Ok(new TenantLocalesDto(tenant.Id, tenant.Version, tenant.Locales + .OrderBy(locale => locale.Sort).ThenBy(locale => locale.Locale, StringComparer.Ordinal) + .Select(locale => new TenantLocaleDto(locale.Locale, locale.IsEnabled, locale.IsDefault, locale.Sort)) + .ToImmutableArray())); + } +} diff --git a/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Application/Locales/SetDefaultTenantLocaleCommandHandler.cs b/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Application/Locales/SetDefaultTenantLocaleCommandHandler.cs new file mode 100644 index 00000000..9e690d95 --- /dev/null +++ b/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Application/Locales/SetDefaultTenantLocaleCommandHandler.cs @@ -0,0 +1,49 @@ +using LearnStack.Modules.Tenancy.Application.Abstractions; +using LearnStack.Modules.Tenancy.Application.Contracts.Locales; +using LearnStack.Modules.Tenancy.Application.Tenant; +using LearnStack.SharedKernel.Audit; +using LearnStack.SharedKernel.Identifiers; +using LearnStack.SharedKernel.Localization; +using LearnStack.SharedKernel.Persistence; +using LearnStack.SharedKernel.Results; +using LearnStack.SharedKernel.Tenancy; +using LearnStack.SharedKernel.Time; +using MediatR; + +namespace LearnStack.Modules.Tenancy.Application.Locales; + +internal sealed class SetDefaultTenantLocaleCommandHandler(ITenantWriteStore tenants, ITenantContext tenantContext, + IUnitOfWork unitOfWork, IAuditSubject auditSubject, IClock clock) + : IRequestHandler> +{ + public async Task> Handle(SetDefaultTenantLocaleCommand request, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(request); + if (TenantWriteFailures.Scope(tenantContext) is { } scopeFailure) + { + return scopeFailure; + } + + var tenant = await tenants.FindAsync(tenantContext.TenantId, cancellationToken); + if (tenant is null) + { + return TenantWriteFailures.Code("lockey_not_found"); + } + + auditSubject.Designate(tenant); + if (LocaleWriteSupport.Preflight(tenant, request.ExpectedVersion) is { } failure) + { + return failure; + } + + var target = tenant.Locales.SingleOrDefault(locale => locale.Locale == LocaleTag.Canonicalize(request.Locale)); + if (target is null || !target.IsEnabled) + { + return TenantWriteFailures.Field("lockey_validation_failed", "Locale", + target is null ? "lockey_locale_not_found" : "lockey_locale_disabled"); + } + + tenant.SetDefaultLocale(target.Locale, clock, tenantContext.UserId ?? UserId.SystemActor); + return await LocaleWriteSupport.SaveAsync(tenant, tenants, unitOfWork, cancellationToken); + } +} diff --git a/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Application/Seeding/SeedStateQueryHandlers.cs b/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Application/Seeding/SeedStateQueryHandlers.cs new file mode 100644 index 00000000..6af6e306 --- /dev/null +++ b/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Application/Seeding/SeedStateQueryHandlers.cs @@ -0,0 +1,77 @@ +using LearnStack.Modules.Tenancy.Domain; +using LearnStack.Modules.Tenancy.Application.Abstractions; +using LearnStack.Modules.Tenancy.Application.Contracts.Seeding; +using LearnStack.SharedKernel.Localization; +using LearnStack.SharedKernel.Results; +using LearnStack.SharedKernel.Tenancy; +using MediatR; + +namespace LearnStack.Modules.Tenancy.Application.Seeding; + +internal sealed class GetTenantSeedStateQueryHandler(ISeedStateReader reader, ITenantContext context) + : IRequestHandler>> +{ + public async Task>> Handle( + GetTenantSeedStateQuery request, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(request); + if (!context.IsResolved) + { + return Result>.Fail(new Error(new LocalizedMessage("lockey_tenant_mismatch"))); + } + + return Result.Ok(new SeedLookup( + await reader.ReadTenantAsync(cancellationToken))); + } +} + +internal sealed class GetOrganizationSeedStateQueryHandler(ISeedStateReader reader, ITenantContext context) + : IRequestHandler>> +{ + public async Task>> Handle( + GetOrganizationSeedStateQuery request, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(request); + if (!context.IsResolved) + { + return Result>.Fail(new Error(new LocalizedMessage("lockey_tenant_mismatch"))); + } + + return Result.Ok(new SeedLookup( + await reader.ReadOrganizationAsync(request.OrganizationId, cancellationToken))); + } +} + +internal sealed class GetHostMappingSeedStateQueryHandler(ISeedStateReader reader, ITenantContext context) + : IRequestHandler>> +{ + public async Task>> Handle( + GetHostMappingSeedStateQuery request, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(request); + if (!context.IsResolved) + { + return Result>.Fail(new Error(new LocalizedMessage("lockey_tenant_mismatch"))); + } + + return Result.Ok(new SeedLookup( + await reader.ReadHostMappingAsync(request.Host, cancellationToken))); + } +} + +internal sealed class GetSettingSeedStateQueryHandler(ISeedStateReader reader, ITenantContext context) + : IRequestHandler>> +{ + public async Task>> Handle( + GetSettingSeedStateQuery request, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(request); + if (!context.IsResolved) + { + return Result>.Fail(new Error(new LocalizedMessage("lockey_tenant_mismatch"))); + } + + return Result.Ok(new SeedLookup( + await reader.ReadSettingAsync(TenantSettingId.From(request.SettingId), cancellationToken))); + } +} diff --git a/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Application/Seeding/SeedStateQueryValidators.cs b/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Application/Seeding/SeedStateQueryValidators.cs new file mode 100644 index 00000000..50a63264 --- /dev/null +++ b/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Application/Seeding/SeedStateQueryValidators.cs @@ -0,0 +1,29 @@ +using FluentValidation; +using LearnStack.Modules.Tenancy.Application.Contracts.Seeding; +using LearnStack.SharedKernel.Tenancy; + +namespace LearnStack.Modules.Tenancy.Application.Seeding; + +internal sealed class GetOrganizationSeedStateQueryValidator : AbstractValidator +{ + public GetOrganizationSeedStateQueryValidator() + { + RuleFor(request => request.OrganizationId).Must(id => id.IsInitialized() && id.Value != Guid.Empty).WithErrorCode("lockey_identifier_required"); + } +} + +internal sealed class GetHostMappingSeedStateQueryValidator : AbstractValidator +{ + public GetHostMappingSeedStateQueryValidator() + { + RuleFor(request => request.Host).Must(host => EffectiveHost.Normalize(host) is not null).WithErrorCode("lockey_host_not_resolvable"); + } +} + +internal sealed class GetSettingSeedStateQueryValidator : AbstractValidator +{ + public GetSettingSeedStateQueryValidator() + { + RuleFor(request => request.SettingId).NotEmpty().WithErrorCode("lockey_identifier_required"); + } +} diff --git a/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Application/Tenant/TenantWriteFailures.cs b/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Application/Tenant/TenantWriteFailures.cs new file mode 100644 index 00000000..79532f32 --- /dev/null +++ b/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Application/Tenant/TenantWriteFailures.cs @@ -0,0 +1,20 @@ +using LearnStack.SharedKernel.Localization; +using LearnStack.SharedKernel.Results; +using LearnStack.SharedKernel.Tenancy; + +namespace LearnStack.Modules.Tenancy.Application.Tenant; + +internal static class TenantWriteFailures +{ + internal static Result? Scope(ITenantContext context) => !context.IsResolved + ? Code("lockey_tenant_mismatch") + : context.OrganizationId is not null ? Code("lockey_resource_scope_violation") : null; + + internal static Result Code(string code) => Result.Fail(new Error(new LocalizedMessage(code))); + + internal static Result Field(string code, string field, string reason) => Result.Fail( + new Error(new LocalizedMessage(code), new Dictionary>(StringComparer.Ordinal) + { + [field] = [new LocalizedMessage(reason)], + })); +} diff --git a/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Domain/CompositeKeyedEntities.cs b/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Domain/CompositeKeyedEntities.cs index 89e6f68d..c2145109 100644 --- a/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Domain/CompositeKeyedEntities.cs +++ b/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Domain/CompositeKeyedEntities.cs @@ -46,7 +46,7 @@ public sealed class TenantLocale : ITenantOwned /// public string Locale { get; private set; } - /// Exactly one locale per tenant carries this. + /// Exactly one enabled locale carries this when any locale is enabled. public bool IsDefault { get; private set; } /// A disabled locale keeps its translations but is not offered. @@ -73,6 +73,7 @@ internal static TenantLocale Create( ArgumentException.ThrowIfNullOrWhiteSpace(locale); MappedLength.EnsureAtMost(locale, LocaleTag.MaxLength, nameof(locale)); LocaleTag.EnsureWellFormed(locale, nameof(locale)); + ArgumentOutOfRangeException.ThrowIfNegative(sort); TenantOwnership.EnsureRealTenant(tenantId, "A locale belongs to a tenant.", nameof(tenantId)); diff --git a/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Domain/Tenant.cs b/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Domain/Tenant.cs index 0d873d46..c51c9f2f 100644 --- a/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Domain/Tenant.cs +++ b/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Domain/Tenant.cs @@ -188,6 +188,7 @@ public void AddLocale( { ArgumentNullException.ThrowIfNull(clock); + EnsureLocaleConfigurationValid(); var added = TenantLocale.Create(Id, locale, isDefault: false, isEnabled, sort); if (_locales.Any(existing => string.Equals( @@ -214,13 +215,8 @@ public void AddLocale( MarkUpdated(clock.UtcNow, updatedBy); _locales.Add(added); - // The FIRST locale is the default whether the caller asked for it or not. The - // partial unique index guarantees at most one default; nothing guarantees at - // least one, so a tenant whose only locale arrived with isDefault:false has a - // non-empty locale set and no default — a state every reader of "the tenant's - // default locale" has to handle and none of them expects. Promoting is the only - // answer that leaves the aggregate in a state the schema can also express. - if (isDefault || (_locales.Count == 1 && isEnabled)) + // Disabled rows do not consume the first enabled locale's default promotion. + if (isDefault || (isEnabled && _locales.Count(candidate => candidate.IsEnabled) == 1)) { PromoteDefault(added); } @@ -232,22 +228,46 @@ public void SetDefaultLocale(string locale, IClock clock, UserId updatedBy) ArgumentNullException.ThrowIfNull(clock); ArgumentException.ThrowIfNullOrWhiteSpace(locale); + MappedLength.EnsureAtMost(locale, LocaleTag.MaxLength, nameof(locale)); + LocaleTag.EnsureWellFormed(locale, nameof(locale)); + EnsureLocaleConfigurationValid(); var canonical = LocaleTag.Canonicalize(locale); var target = _locales.FirstOrDefault(existing => string.Equals( existing.Locale, canonical, StringComparison.Ordinal)) ?? throw new InvalidOperationException( $"This tenant does not publish in '{canonical}', so it cannot be the default."); + if (!target.IsEnabled) + { + throw new InvalidOperationException("A disabled locale cannot be the default."); + } + MarkUpdated(clock.UtcNow, updatedBy); PromoteDefault(target); } + /// Zero/all-disabled rows are valid; enabled rows require one enabled default. + public bool HasValidLocaleConfiguration() => + !_locales.Any(locale => locale.IsDefault && !locale.IsEnabled) + && (!_locales.Any(locale => locale.IsEnabled) || _locales.Count(locale => locale.IsDefault) == 1); + + private void EnsureLocaleConfigurationValid() + { + if (!HasValidLocaleConfiguration()) + { + throw new InvalidOperationException("The existing locale configuration is invalid; remediate it explicitly."); + } + } + /// Removes a locale, which must not be the default. public void RemoveLocale(string locale, IClock clock, UserId updatedBy) { ArgumentNullException.ThrowIfNull(clock); ArgumentException.ThrowIfNullOrWhiteSpace(locale); + MappedLength.EnsureAtMost(locale, LocaleTag.MaxLength, nameof(locale)); + LocaleTag.EnsureWellFormed(locale, nameof(locale)); + EnsureLocaleConfigurationValid(); var canonical = LocaleTag.Canonicalize(locale); var target = _locales.FirstOrDefault(existing => string.Equals( existing.Locale, canonical, StringComparison.Ordinal)) diff --git a/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Domain/TenantSetting.cs b/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Domain/TenantSetting.cs index 6fc33683..deccbee2 100644 --- a/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Domain/TenantSetting.cs +++ b/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Domain/TenantSetting.cs @@ -1,3 +1,4 @@ +using LearnStack.SharedKernel.DataProtection; using LearnStack.SharedKernel.Domain; using LearnStack.SharedKernel.Identifiers; using LearnStack.SharedKernel.Persistence; @@ -58,6 +59,7 @@ private TenantSetting() public string Key { get; private set; } /// The value, as JSON. The shape is the caller's to know. + [PiiSensitive] public string Value { get; private set; } public static TenantSetting Create( diff --git a/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Infrastructure/Persistence/Configurations.cs b/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Infrastructure/Persistence/Configurations.cs index 99e7e2e9..c1cb2561 100644 --- a/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Infrastructure/Persistence/Configurations.cs +++ b/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Infrastructure/Persistence/Configurations.cs @@ -163,7 +163,8 @@ internal sealed class TenantLocaleConfiguration : IEntityTypeConfiguration builder) { - builder.ToTable("tenant_locales"); + builder.ToTable("tenant_locales", table => table.HasCheckConstraint( + "ck_tenant_locales_default_enabled", "NOT is_default OR is_enabled")); // Composite natural key, no surrogate id: a second row for the same // tenant and locale is not a second locale, it is a duplicate. diff --git a/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Infrastructure/Persistence/Migrations/20261002001839_tenant_locale_default_enabled.Designer.cs b/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Infrastructure/Persistence/Migrations/20261002001839_tenant_locale_default_enabled.Designer.cs new file mode 100644 index 00000000..6e0acfd5 --- /dev/null +++ b/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Infrastructure/Persistence/Migrations/20261002001839_tenant_locale_default_enabled.Designer.cs @@ -0,0 +1,556 @@ +// +using System; +using LearnStack.Modules.Tenancy.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 LearnStack.Modules.Tenancy.Infrastructure.Persistence.Migrations +{ + [DbContext(typeof(TenancyDbContext))] + [Migration("20261002001839_tenant_locale_default_enabled")] + partial class tenant_locale_default_enabled + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.12") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("LearnStack.Modules.Tenancy.Domain.Organization", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedBy") + .HasColumnType("uuid") + .HasColumnName("created_by"); + + b.Property("CustomSubdomain") + .HasMaxLength(253) + .HasColumnType("character varying(253)") + .HasColumnName("custom_subdomain"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("deleted_at"); + + b.Property("DeletedBy") + .HasColumnType("uuid") + .HasColumnName("deleted_by"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("display_name"); + + b.Property("ReportingParentId") + .HasColumnType("uuid") + .HasColumnName("reporting_parent_id"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(63) + .HasColumnType("character varying(63)") + .HasColumnName("slug"); + + b.Property("Status") + .IsRequired() + .HasColumnType("text") + .HasColumnName("status"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UpdatedBy") + .HasColumnType("uuid") + .HasColumnName("updated_by"); + + b.Property("Version") + .IsConcurrencyToken() + .HasColumnType("bigint") + .HasDefaultValue(0L) + .HasColumnName("row_version"); + + b.HasKey("Id") + .HasName("pk_organizations"); + + b.HasIndex("TenantId", "Id") + .IsUnique() + .HasDatabaseName("ux_organizations_tenant_id_id"); + + b.HasIndex("TenantId", "ReportingParentId") + .HasDatabaseName("ix_organizations_tenant_id_reporting_parent_id"); + + b.HasIndex("TenantId", "Slug") + .IsUnique() + .HasDatabaseName("ux_organizations_tenant_id_slug") + .HasFilter("deleted_at IS NULL"); + + b.ToTable("organizations", (string)null); + }); + + modelBuilder.Entity("LearnStack.Modules.Tenancy.Domain.PlatformEntitlement", b => + { + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("Compliance") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("compliance"); + + b.Property("Features") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("features"); + + b.Property("Generation") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasDefaultValue(1L) + .HasColumnName("generation"); + + b.Property("GraceUntil") + .HasColumnType("timestamp with time zone") + .HasColumnName("grace_until"); + + b.Property("Limits") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("limits"); + + b.Property("PlanCode") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("plan_code"); + + b.Property("RefreshedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("refreshed_at") + .HasDefaultValueSql("now()"); + + b.Property("Source") + .IsRequired() + .HasColumnType("text") + .HasColumnName("source"); + + b.Property("ValidUntil") + .HasColumnType("timestamp with time zone") + .HasColumnName("valid_until"); + + b.HasKey("TenantId") + .HasName("pk_platform_entitlement_cache"); + + b.ToTable("platform_entitlement_cache", (string)null); + }); + + modelBuilder.Entity("LearnStack.Modules.Tenancy.Domain.PlatformHostMapping", b => + { + b.Property("Host") + .HasMaxLength(253) + .HasColumnType("character varying(253)") + .HasColumnName("host"); + + b.Property("IsActive") + .HasColumnType("boolean") + .HasColumnName("is_active"); + + b.Property("IsPubliclyLive") + .HasColumnType("boolean") + .HasColumnName("is_publicly_live"); + + b.Property("OrganizationId") + .HasColumnType("uuid") + .HasColumnName("organization_id"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.HasKey("Host") + .HasName("pk_platform_host_to_tenant"); + + b.HasIndex("TenantId", "OrganizationId") + .HasDatabaseName("ix_platform_host_to_tenant_tenant_id_organization_id"); + + b.ToTable("platform_host_to_tenant", (string)null); + }); + + modelBuilder.Entity("LearnStack.Modules.Tenancy.Domain.PlatformKillswitch", b => + { + b.Property("Key") + .HasMaxLength(150) + .HasColumnType("character varying(150)") + .HasColumnName("key"); + + b.Property("IsEnabled") + .HasColumnType("boolean") + .HasColumnName("is_enabled"); + + b.Property("Reason") + .HasColumnType("text") + .HasColumnName("reason"); + + b.Property("ToggledAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("toggled_at"); + + b.Property("ToggledBy") + .HasColumnType("uuid") + .HasColumnName("toggled_by"); + + b.HasKey("Key") + .HasName("pk_platform_killswitches"); + + b.ToTable("platform_killswitches", (string)null); + }); + + modelBuilder.Entity("LearnStack.Modules.Tenancy.Domain.Tenant", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedBy") + .HasColumnType("uuid") + .HasColumnName("created_by"); + + b.Property("DefaultOrganizationId") + .HasColumnType("uuid") + .HasColumnName("default_organization_id"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("deleted_at"); + + b.Property("DeletedBy") + .HasColumnType("uuid") + .HasColumnName("deleted_by"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("display_name"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(63) + .HasColumnType("character varying(63)") + .HasColumnName("slug"); + + b.Property("Status") + .IsRequired() + .HasColumnType("text") + .HasColumnName("status"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UpdatedBy") + .HasColumnType("uuid") + .HasColumnName("updated_by"); + + b.Property("Version") + .IsConcurrencyToken() + .HasColumnType("bigint") + .HasDefaultValue(0L) + .HasColumnName("row_version"); + + b.HasKey("Id") + .HasName("pk_tenants"); + + b.HasIndex("Slug") + .IsUnique() + .HasDatabaseName("ux_tenants_slug"); + + b.ToTable("tenants", (string)null); + }); + + modelBuilder.Entity("LearnStack.Modules.Tenancy.Domain.TenantDomain", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedBy") + .HasColumnType("uuid") + .HasColumnName("created_by"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("deleted_at"); + + b.Property("DeletedBy") + .HasColumnType("uuid") + .HasColumnName("deleted_by"); + + b.Property("Host") + .IsRequired() + .HasMaxLength(253) + .HasColumnType("character varying(253)") + .HasColumnName("host"); + + b.Property("Kind") + .IsRequired() + .HasColumnType("text") + .HasColumnName("kind"); + + b.Property("LastVerificationError") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)") + .HasColumnName("last_verification_error"); + + b.Property("Status") + .IsRequired() + .HasColumnType("text") + .HasColumnName("status"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UpdatedBy") + .HasColumnType("uuid") + .HasColumnName("updated_by"); + + b.Property("VerificationAttempts") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0) + .HasColumnName("verification_attempts"); + + b.Property("VerifiedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("verified_at"); + + b.Property("Version") + .IsConcurrencyToken() + .HasColumnType("bigint") + .HasDefaultValue(0L) + .HasColumnName("row_version"); + + b.HasKey("Id") + .HasName("pk_tenant_domains"); + + b.HasIndex("Host") + .IsUnique() + .HasDatabaseName("ux_tenant_domains_host") + .HasFilter("deleted_at IS NULL"); + + b.HasIndex("TenantId") + .HasDatabaseName("ix_tenant_domains_tenant_id"); + + b.ToTable("tenant_domains", (string)null); + }); + + modelBuilder.Entity("LearnStack.Modules.Tenancy.Domain.TenantFeatureFlag", b => + { + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("Key") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("key"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at") + .HasDefaultValueSql("now()"); + + b.Property("UpdatedBy") + .HasColumnType("uuid") + .HasColumnName("updated_by"); + + b.Property("Value") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("value"); + + b.HasKey("TenantId", "Key") + .HasName("pk_tenant_feature_flags"); + + b.ToTable("tenant_feature_flags", (string)null); + }); + + modelBuilder.Entity("LearnStack.Modules.Tenancy.Domain.TenantLocale", b => + { + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("Locale") + .HasMaxLength(35) + .HasColumnType("character varying(35)") + .HasColumnName("locale"); + + b.Property("IsDefault") + .HasColumnType("boolean") + .HasColumnName("is_default"); + + b.Property("IsEnabled") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true) + .HasColumnName("is_enabled"); + + b.Property("Sort") + .ValueGeneratedOnAdd() + .HasColumnType("smallint") + .HasDefaultValue((short)0) + .HasColumnName("sort"); + + b.HasKey("TenantId", "Locale") + .HasName("pk_tenant_locales"); + + b.HasIndex("TenantId") + .IsUnique() + .HasDatabaseName("ux_tenant_locales_tenant_id_is_default") + .HasFilter("is_default"); + + b.ToTable("tenant_locales", null, t => + { + t.HasCheckConstraint("ck_tenant_locales_default_enabled", "NOT is_default OR is_enabled"); + }); + }); + + modelBuilder.Entity("LearnStack.Modules.Tenancy.Domain.TenantSetting", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedBy") + .HasColumnType("uuid") + .HasColumnName("created_by"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("deleted_at"); + + b.Property("DeletedBy") + .HasColumnType("uuid") + .HasColumnName("deleted_by"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("key"); + + b.Property("OrganizationId") + .HasColumnType("uuid") + .HasColumnName("organization_id"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UpdatedBy") + .HasColumnType("uuid") + .HasColumnName("updated_by"); + + b.Property("Value") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("value"); + + b.Property("Version") + .IsConcurrencyToken() + .HasColumnType("bigint") + .HasDefaultValue(0L) + .HasColumnName("row_version"); + + b.HasKey("Id") + .HasName("pk_tenant_settings"); + + b.HasIndex("TenantId", "OrganizationId") + .HasDatabaseName("ix_tenant_settings_tenant_id_organization_id"); + + b.HasIndex("TenantId", "OrganizationId", "Key") + .IsUnique() + .HasDatabaseName("ux_tenant_settings_tenant_id_organization_id_key") + .HasFilter("deleted_at IS NULL"); + + NpgsqlIndexBuilderExtensions.AreNullsDistinct(b.HasIndex("TenantId", "OrganizationId", "Key"), false); + + b.ToTable("tenant_settings", (string)null); + }); + + modelBuilder.Entity("LearnStack.Modules.Tenancy.Domain.TenantFeatureFlag", b => + { + b.HasOne("LearnStack.Modules.Tenancy.Domain.Tenant", null) + .WithMany("FeatureFlags") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired() + .HasConstraintName("fk_tenant_feature_flags_tenant"); + }); + + modelBuilder.Entity("LearnStack.Modules.Tenancy.Domain.TenantLocale", b => + { + b.HasOne("LearnStack.Modules.Tenancy.Domain.Tenant", null) + .WithMany("Locales") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired() + .HasConstraintName("fk_tenant_locales_tenant"); + }); + + modelBuilder.Entity("LearnStack.Modules.Tenancy.Domain.Tenant", b => + { + b.Navigation("FeatureFlags"); + + b.Navigation("Locales"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Infrastructure/Persistence/Migrations/20261002001839_tenant_locale_default_enabled.cs b/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Infrastructure/Persistence/Migrations/20261002001839_tenant_locale_default_enabled.cs new file mode 100644 index 00000000..e90feabf --- /dev/null +++ b/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Infrastructure/Persistence/Migrations/20261002001839_tenant_locale_default_enabled.cs @@ -0,0 +1,27 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace LearnStack.Modules.Tenancy.Infrastructure.Persistence.Migrations +{ + /// + public partial class tenant_locale_default_enabled : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddCheckConstraint( + name: "ck_tenant_locales_default_enabled", + table: "tenant_locales", + sql: "NOT is_default OR is_enabled"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropCheckConstraint( + name: "ck_tenant_locales_default_enabled", + table: "tenant_locales"); + } + } +} diff --git a/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Infrastructure/Persistence/Migrations/TenancyDbContextModelSnapshot.cs b/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Infrastructure/Persistence/Migrations/TenancyDbContextModelSnapshot.cs index b77b8afc..3dbb059b 100644 --- a/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Infrastructure/Persistence/Migrations/TenancyDbContextModelSnapshot.cs +++ b/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Infrastructure/Persistence/Migrations/TenancyDbContextModelSnapshot.cs @@ -444,7 +444,10 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasDatabaseName("ux_tenant_locales_tenant_id_is_default") .HasFilter("is_default"); - b.ToTable("tenant_locales", (string)null); + b.ToTable("tenant_locales", null, t => + { + t.HasCheckConstraint("ck_tenant_locales_default_enabled", "NOT is_default OR is_enabled"); + }); }); modelBuilder.Entity("LearnStack.Modules.Tenancy.Domain.TenantSetting", b => diff --git a/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Infrastructure/Persistence/TenancySeedStateReader.cs b/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Infrastructure/Persistence/TenancySeedStateReader.cs new file mode 100644 index 00000000..4edc2fac --- /dev/null +++ b/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Infrastructure/Persistence/TenancySeedStateReader.cs @@ -0,0 +1,50 @@ +using System.Collections.Immutable; +using LearnStack.Modules.Tenancy.Application.Abstractions; +using LearnStack.Modules.Tenancy.Application.Contracts.Seeding; +using LearnStack.Modules.Tenancy.Domain; +using LearnStack.SharedKernel.Identifiers; +using LearnStack.SharedKernel.Tenancy; +using Microsoft.EntityFrameworkCore; + +namespace LearnStack.Modules.Tenancy.Infrastructure.Persistence; + +public sealed class TenancySeedStateReader(TenancyDbContext context, ITenantContext tenantContext) : ISeedStateReader +{ + public async Task ReadTenantAsync(CancellationToken cancellationToken) + { + var row = await context.Tenants.AsNoTracking().Include(tenant => tenant.Locales) + .SingleOrDefaultAsync(tenant => tenant.Id == tenantContext.TenantId && tenant.DeletedAt == null, + cancellationToken); + return row is null ? null : new TenantSeedDto(row.Id, row.Slug, row.DisplayName, row.Status.ToString(), + row.DefaultOrganizationId, row.Version, row.Locales.OrderBy(locale => locale.Sort) + .ThenBy(locale => locale.Locale, StringComparer.Ordinal) + .Select(locale => new TenantLocaleSeedDto(locale.Locale, locale.IsEnabled, locale.IsDefault, locale.Sort)) + .ToImmutableArray()); + } + + public async Task ReadOrganizationAsync( + OrganizationId organizationId, CancellationToken cancellationToken) + { + var row = await context.Organizations.AsNoTracking().SingleOrDefaultAsync( + organization => organization.Id == organizationId && organization.DeletedAt == null, cancellationToken); + return row is null ? null : new OrganizationSeedDto(row.Id, row.TenantId, row.Slug, row.DisplayName, + row.Status.ToString()); + } + + public async Task ReadHostMappingAsync(string host, CancellationToken cancellationToken) + { + var canonical = EffectiveHost.Normalize(host); + var row = await context.PlatformHostMappings.AsNoTracking().SingleOrDefaultAsync( + mapping => mapping.Host == canonical && mapping.TenantId == tenantContext.TenantId, cancellationToken); + return row is null ? null : new HostMappingSeedDto(row.Host, row.TenantId, row.OrganizationId, + row.IsActive, row.IsPubliclyLive); + } + + public async Task ReadSettingAsync(TenantSettingId settingId, CancellationToken cancellationToken) + { + var row = await context.TenantSettings.AsNoTracking().SingleOrDefaultAsync( + setting => setting.Id == settingId && setting.DeletedAt == null, cancellationToken); + return row is null ? null : new SettingSeedDto(row.Id.Value, row.TenantId, row.OrganizationId, + row.Key, row.Value, row.Version); + } +} diff --git a/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Infrastructure/Persistence/TenancyWriteStores.cs b/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Infrastructure/Persistence/TenancyWriteStores.cs index 978e988d..e232b8d2 100644 --- a/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Infrastructure/Persistence/TenancyWriteStores.cs +++ b/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Infrastructure/Persistence/TenancyWriteStores.cs @@ -47,6 +47,11 @@ namespace LearnStack.Modules.Tenancy.Infrastructure.Persistence; /// public sealed class TenantWriteStore(TenancyDbContext db) : ITenantWriteStore { + public Task FindAsync(LearnStack.SharedKernel.Identifiers.TenantId id, + CancellationToken cancellationToken = default) => db.Tenants.AsSingleQuery() + .Include(tenant => tenant.Locales).Include(tenant => tenant.FeatureFlags) + .SingleOrDefaultAsync(tenant => tenant.Id == id && tenant.DeletedAt == null, cancellationToken); + public Task AddAsync(Tenant aggregate, CancellationToken cancellationToken = default) { db.Tenants.Add(aggregate); @@ -60,6 +65,29 @@ public async Task UpdateAsync(Tenant aggregate, CancellationToken cancellationTo } } +/// One tracked setting root, on the same announced ambient context. +public sealed class TenantSettingWriteStore(TenancyDbContext db) : ITenantSettingWriteStore +{ + private static readonly HashSet OwnedConstraints = new(StringComparer.Ordinal) + { + "pk_tenant_settings", "ux_tenant_settings_tenant_id_organization_id_key", + }; + public Task FindAsync(TenantSettingId id, CancellationToken cancellationToken = default) => + db.TenantSettings.SingleOrDefaultAsync(setting => setting.Id == id, cancellationToken); + + public Task AddAsync(TenantSetting aggregate, CancellationToken cancellationToken = default) + { + db.TenantSettings.Add(aggregate); + return SaveTranslatingConflictsAsync(db, cancellationToken, OwnedConstraints); + } + + public Task UpdateAsync(TenantSetting aggregate, CancellationToken cancellationToken = default) + { + EnsureTracked(db, aggregate); + return SaveTranslatingConflictsAsync(db, cancellationToken, OwnedConstraints); + } +} + /// The Organization aggregate's writes. /// /// Same shape and the same three reasons as , which @@ -108,6 +136,10 @@ public Task AddAsync( /// internal static class TenancyWriteStoreTracking { + private static readonly HashSet LocaleConstraints = new(StringComparer.Ordinal) + { + "pk_tenant_locales", "ux_tenant_locales_tenant_id_is_default", + }; /// /// Saves, clearing an outgoing default locale before setting the incoming one. /// @@ -133,21 +165,24 @@ internal static class TenancyWriteStoreTracking /// promotions are released and saved. A partial unique index permits ZERO defaults — /// it forbids two — so the state between the two saves is one the schema allows, and /// both saves are inside the caller's transaction, so no one else observes it. - /// Domain state is never touched: only which properties EF considers pending. + /// The tracker temporarily lowers incoming defaults (Added or Modified), restores + /// them before the second save and retains the final aggregate state on success. + /// A failed mutation poisons the ambient unit in the locale handler; its rollback + /// undoes the first save even if a nested caller absorbs the refusal. /// /// internal static async Task SaveDefaultLocaleInTwoPassesAsync( TenancyDbContext db, CancellationToken cancellationToken) { var promotions = db.ChangeTracker.Entries() - .Where(entry => entry.State == EntityState.Modified - && entry.Property(locale => locale.IsDefault).IsModified + .Where(entry => (entry.State == EntityState.Added || entry.State == EntityState.Modified) + && (entry.State == EntityState.Added || entry.Property(locale => locale.IsDefault).IsModified) && entry.Property(locale => locale.IsDefault).CurrentValue) .ToList(); if (promotions.Count == 0) { - await SaveTranslatingConflictsAsync(db, cancellationToken); + await SaveTranslatingConflictsAsync(db, cancellationToken, LocaleConstraints); return; } @@ -162,14 +197,14 @@ internal static async Task SaveDefaultLocaleInTwoPassesAsync( promotion.Property(locale => locale.IsDefault).CurrentValue = false; } - await SaveTranslatingConflictsAsync(db, cancellationToken); + await SaveTranslatingConflictsAsync(db, cancellationToken, LocaleConstraints); foreach (var promotion in promotions) { promotion.Property(locale => locale.IsDefault).CurrentValue = true; } - await SaveTranslatingConflictsAsync(db, cancellationToken); + await SaveTranslatingConflictsAsync(db, cancellationToken, LocaleConstraints); } } diff --git a/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Infrastructure/Persistence/TenantExistenceReader.cs b/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Infrastructure/Persistence/TenantExistenceReader.cs new file mode 100644 index 00000000..edc63739 --- /dev/null +++ b/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Infrastructure/Persistence/TenantExistenceReader.cs @@ -0,0 +1,12 @@ +using LearnStack.Modules.Tenancy.Application.Abstractions; +using LearnStack.SharedKernel.Tenancy; +using Microsoft.EntityFrameworkCore; + +namespace LearnStack.Modules.Tenancy.Infrastructure.Persistence; + +/// Uncached, scalar lookup on the announced ambient transaction. +public sealed class TenantExistenceReader(TenancyDbContext db, ITenantContext tenantContext) : ITenantExistenceReader +{ + public Task ExistsAsync(CancellationToken cancellationToken) => db.Tenants + .AnyAsync(tenant => tenant.Id == tenantContext.TenantId && tenant.DeletedAt == null, cancellationToken); +} diff --git a/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Infrastructure/Persistence/TenantLocaleEligibilityReader.cs b/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Infrastructure/Persistence/TenantLocaleEligibilityReader.cs new file mode 100644 index 00000000..4bf7fdf9 --- /dev/null +++ b/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Infrastructure/Persistence/TenantLocaleEligibilityReader.cs @@ -0,0 +1,47 @@ +using LearnStack.Modules.Tenancy.Application.Contracts.Locales; +using LearnStack.SharedKernel.Localization; +using LearnStack.SharedKernel.Results; +using LearnStack.SharedKernel.Tenancy; +using Microsoft.EntityFrameworkCore; + +namespace LearnStack.Modules.Tenancy.Infrastructure.Persistence; + +public sealed class TenantLocaleEligibilityReader(TenancyDbContext context, ITenantContext tenantContext) + : ITenantLocaleEligibilityReader +{ + public async Task> ReadEligibleAsync(string locale, CancellationToken cancellationToken) + { + if (!tenantContext.IsResolved || string.IsNullOrEmpty(locale) || locale.Length > LocaleTag.MaxLength) + { + return Refused(); + } + + try + { + LocaleTag.EnsureWellFormed(locale, nameof(locale)); + } + catch (ArgumentException) + { + return Refused(); + } + + var tenant = await context.Tenants.AsNoTracking().Include(row => row.Locales) + .SingleOrDefaultAsync(row => row.Id == tenantContext.TenantId && row.DeletedAt == null, + cancellationToken); + if (tenant is null || !tenant.HasValidLocaleConfiguration()) + { + return Refused(); + } + + var canonical = LocaleTag.Canonicalize(locale); + return tenant.Locales.Any(row => row.Locale == canonical && row.IsEnabled) + ? Result.Ok(canonical) : Refused(); + } + + private static Result Refused() => Result.Fail(new Error( + new LocalizedMessage("lockey_validation_failed"), + new Dictionary>(StringComparer.Ordinal) + { + ["Locale"] = [new LocalizedMessage("lockey_locale_invalid")], + })); +} diff --git a/backend/tests/LearnStack.Tests.Architecture/AggregateWriteTests.cs b/backend/tests/LearnStack.Tests.Architecture/AggregateWriteTests.cs index 9563badc..cdcf11aa 100644 --- a/backend/tests/LearnStack.Tests.Architecture/AggregateWriteTests.cs +++ b/backend/tests/LearnStack.Tests.Architecture/AggregateWriteTests.cs @@ -1,6 +1,9 @@ using System.Reflection; using FluentAssertions; using LearnStack.SharedKernel.Persistence; +using LearnStack.SharedKernel.Identifiers; +using LearnStack.Modules.Education.Application.Abstractions; +using LearnStack.Modules.Education.Domain; using MediatR; using Xunit; @@ -33,17 +36,10 @@ public void Cross_Aggregate_Writes_Are_Confined_To_Tenant_Provisioning() // forbidden, so no handler can name a DbSet at all. A rule at Implemented status // that cannot fire is worse than one at Registered, because the catalogue then // claims coverage it does not have. - var offenders = ProductionAssemblies() + var offenders = CrossAggregateWriters(ProductionAssemblies() .Select(Assembly.Load) - .SelectMany(assembly => assembly.GetTypes()) - .Where(type => type is { IsAbstract: false, IsInterface: false }) - .Where(IsMessageHandler) - .Where(type => type.GetConstructors( - BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance) - .Any(constructor => AggregatesWrittenBy(constructor).Count > 1)) - .Select(type => type.Name) - .Distinct() - .ToList(); + .SelectMany(assembly => assembly.GetTypes())) + .Select(type => type.Name).Distinct().ToList(); offenders.Should().BeEquivalentTo( ["ProvisionTenantCommandHandler"], @@ -67,7 +63,10 @@ public void Every_Write_Port_Is_Countable_Or_Enumerated() // // Detected by shape, not by name: an interface whose method takes a type from a // module's Domain assembly is a port that writes domain objects, whatever it is - // called. A rule keyed on "ends in Store" is satisfied by renaming. + // called. A typed identifier can also reach a key-only DeleteAsync write, so + // only explicitly enumerated read methods may exclude keys. A rule keyed on + // "ends in Store" or "takes a mutable root" is satisfied by renaming or deleting + // by id. ADR-0023 still requires the approved readers' keys to stay typed. var domainAssemblies = ProductionAssemblies() .Select(Assembly.Load) .Where(assembly => assembly.GetName().Name?.EndsWith(".Domain", StringComparison.Ordinal) @@ -78,10 +77,7 @@ public void Every_Write_Port_Is_Countable_Or_Enumerated() .Select(Assembly.Load) .SelectMany(assembly => assembly.GetTypes()) .Where(type => type.IsInterface) - .Where(type => type.GetMethods().Any(method => - method.GetParameters().Any(parameter => - Unwrap(parameter.ParameterType).Any(inner => - domainAssemblies.Contains(inner.Assembly))))) + .Where(type => TakesDomainObject(type, domainAssemblies)) .Where(type => !WriteStoreConstructions(type).Any()) .Select(type => type.Name) .Distinct() @@ -96,6 +92,101 @@ public void Every_Write_Port_Is_Countable_Or_Enumerated() + "sanctioned case, and a second name here needs its own decision"); } + [Fact] + public void Write_Port_Census_Distinguishes_Typed_Read_Keys_From_Wrapped_Domain_Writes() + { + var domains = new HashSet { typeof(Course).Assembly }; + TakesDomainObject(typeof(IParentCourseReader), domains).Should().BeFalse(); + TakesDomainObject(typeof(ITranslationCollisionReader), domains).Should().BeFalse(); + TakesDomainObject(typeof(LearnStack.Modules.Education.Application.Abstractions.ISeedStateReader), domains).Should().BeFalse(); + TakesDomainObject(typeof(IWrappedKeyReader), domains).Should().BeTrue("an unenumerated key-only port must not escape; Fix: enumerate genuine read methods explicitly"); + foreach (var writer in new[] { typeof(IDirectWriter), typeof(IBulkWriter), typeof(IArrayWriter), typeof(IByRefWriter), typeof(IMixedWriter), typeof(IInheritedWriter), typeof(IIdOnlyWriteProbe), typeof(IValueReturningKeyWriter) }) + { + TakesDomainObject(writer, domains).Should().BeTrue($"{writer.Name} must remain visible to the census; Fix: inspect domain objects inside every wrapper"); + WriteStoreConstructions(writer).Should().BeEmpty("these planted writes must fail the production guard rather than count as sanctioned stores"); + } + TakesDomainObject(typeof(ICourseWriteStore), domains).Should().BeTrue(); + WriteStoreConstructions(typeof(ICourseWriteStore)).Should().ContainSingle(); + } + + /// Exercise the same handler predicate and constructor scan as production. + [Fact] + public void Cross_Aggregate_Census_Catches_Fused_Separate_Notification_And_Internal_Constructors() + { + var forbidden = new[] { typeof(FusedHandler), typeof(TwoPortHandler), typeof(NotificationHandler), typeof(VoidHandler) }; + CrossAggregateWriters(forbidden.Concat([typeof(SameRootHandler), typeof(NonHandler)])) + .Should().BeEquivalentTo(forbidden, + "all message shapes and non-public constructors must count every reachable root; Fix: preserve the shared production census"); + } + + private static IEnumerable CrossAggregateWriters(IEnumerable types) => types + .Where(type => type is { IsAbstract: false, IsInterface: false }) + .Where(IsMessageHandler) + .Where(type => type.GetConstructors(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance) + .Any(constructor => AggregatesWrittenBy(constructor).Count > 1)); + + private static bool TakesDomainObject(Type type, HashSet domains) => + type.GetInterfaces().Append(type).SelectMany(contract => contract.GetMethods()) + .Any(method => method.GetParameters().Any(parameter => + Unwrap(parameter.ParameterType).Any(inner => !inner.HasElementType && domains.Contains(inner.Assembly) + && (!IsTypedId(inner) || !ReadMethods.Contains(method))))); + + private static bool IsTypedId(Type type) => type.GetInterfaces().Any(contract => + contract.IsGenericType && contract.GetGenericTypeDefinition() == typeof(IStronglyTypedId<>)); + + // Method-level enumeration: adding DeleteAsync to an approved reader must still + // enter the census. A value-returning key-only writer is not a read exemption. + private static readonly HashSet ReadMethods = + [ + typeof(IParentCourseReader).GetMethod(nameof(IParentCourseReader.ReadAsync))!, + typeof(LearnStack.Modules.Education.Application.Abstractions.ISeedStateReader).GetMethod("ReadCourseAsync")!, + typeof(LearnStack.Modules.Education.Application.Abstractions.ISeedStateReader).GetMethod("ReadLessonAsync")!, + typeof(LearnStack.Modules.Tenancy.Application.Abstractions.ISeedStateReader).GetMethod("ReadSettingAsync")!, + typeof(LearnStack.Modules.Customization.Application.Abstractions.ISeedStateReader).GetMethod("ReadContentTypeAsync")!, + typeof(LearnStack.Modules.Customization.Application.Abstractions.ISeedStateReader).GetMethod("ReadTaxonomyAsync")!, + ]; + + private interface IIdOnlyWriteProbe { Task DeleteAsync(CourseId id); } + private interface IValueReturningKeyWriter { Task DeleteAsync(CourseId id); } + private interface IFusedWritePort : IAggregateWriteStore, IAggregateWriteStore; + private sealed record CommandProbe : IRequest; + private sealed record ValueProbe : IRequest; + private sealed record NotificationProbe : INotification; + private sealed class FusedHandler : IRequestHandler + { + internal FusedHandler(IFusedWritePort store) { } + public Task Handle(ValueProbe request, CancellationToken cancellationToken) => Task.FromResult(false); + } + private sealed class TwoPortHandler : IRequestHandler + { + public TwoPortHandler(ICourseWriteStore courses, ILessonWriteStore lessons) { } + public Task Handle(ValueProbe request, CancellationToken cancellationToken) => Task.FromResult(false); + } + private sealed class NotificationHandler : INotificationHandler + { + public NotificationHandler(ICourseWriteStore courses, ILessonWriteStore lessons) { } + public Task Handle(NotificationProbe notification, CancellationToken cancellationToken) => Task.CompletedTask; + } + private sealed class VoidHandler : IRequestHandler + { + public VoidHandler(ICourseWriteStore courses, ILessonWriteStore lessons) { } + public Task Handle(CommandProbe request, CancellationToken cancellationToken) => Task.CompletedTask; + } + private sealed class SameRootHandler : IRequestHandler + { + public SameRootHandler(ICourseWriteStore first, ICourseWriteStore second) { } + public Task Handle(ValueProbe request, CancellationToken cancellationToken) => Task.FromResult(false); + } + private sealed class NonHandler { public NonHandler(IFusedWritePort store) { } } + + private interface IWrappedKeyReader { void Read(IEnumerable ids); } + private interface IDirectWriter { void Apply(Course root); } + private interface IBulkWriter { void Apply(IEnumerable roots); } + private interface IArrayWriter { void Apply(Course[] roots); } + private interface IByRefWriter { void Apply(in Course root); } + private interface IMixedWriter { void Apply(CourseId id, Course root); } + private interface IInheritedWriter : IDirectWriter { } + /// /// The distinct aggregate roots a constructor's write ports reach. /// diff --git a/backend/tests/LearnStack.Tests.Architecture/PersistenceConventionTests.cs b/backend/tests/LearnStack.Tests.Architecture/PersistenceConventionTests.cs index e858591d..96ad6cb8 100644 --- a/backend/tests/LearnStack.Tests.Architecture/PersistenceConventionTests.cs +++ b/backend/tests/LearnStack.Tests.Architecture/PersistenceConventionTests.cs @@ -172,10 +172,9 @@ public void Module_DbContexts_Enlist_In_The_Ambient_UnitOfWork() // Eight files under backend/src may reach for a connection at all: the five // design-time factories, where a connection string is the point — one per // migration chain, and a module that ships a schema ships one; the shared - // helper, which passes a connection rather than a string; and the two - // composition roots — the API's, which builds the one application data - // source behind its credential guard, and the seeder's, which is the same act - // for a host with no HTTP surface. A ninth is a new decision. + // module helper, which passes a connection rather than a string; the API's + // separately guarded platform-role builder; and the shared application-role + // builder used by both runtime hosts. A ninth is a new decision. // // The scan covers the raw constructors as well as `UseNpgsql` and // `AddDbContext`, because a call site that opened its own @@ -211,12 +210,9 @@ public void Module_DbContexts_Enlist_In_The_Ambient_UnitOfWork() "Persistence/AuditDbContextFactory.cs", "Persistence/EducationDbContextFactory.cs", - // A deliberate entry rather than a discovered one: the seeder - // is a second composition root, and building the one application data source - // is the same act PersistenceCompositionExtensions performs for the API. It - // is in the set — not exempted from it — so the next tool that reaches for a - // connection is still a reviewed diff. - "LearnStack.Tools.Seeder/Program.cs", + // Both runtime hosts delegate application-role construction to this + // shared guard; the API retains its separate platform-role builder. + "Persistence/ApplicationDataSource.cs", ]); } diff --git a/backend/tests/LearnStack.Tests.Architecture/SeederConventionTests.cs b/backend/tests/LearnStack.Tests.Architecture/SeederConventionTests.cs new file mode 100644 index 00000000..416870b8 --- /dev/null +++ b/backend/tests/LearnStack.Tests.Architecture/SeederConventionTests.cs @@ -0,0 +1,198 @@ +using System.Text.Json; +using Mono.Cecil; +using LearnStack.Infrastructure.Persistence; +using System.Text.RegularExpressions; +using FluentAssertions; +using LearnStack.Tools.Seeder; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Xunit; + +namespace LearnStack.Tests.Architecture; + +/// P02d-2 G15 caller fence and G20(a) literal-source proof, not the later branch guard. +public sealed class SeederConventionTests +{ + [Fact] + public void Seeder_Does_Not_Call_Tenant_Context_Setters() + { + var files = Directory.GetFiles(Path.Combine(RepositoryPaths.BackendSrc(), "LearnStack.Tools.Seeder"), "*.cs", SearchOption.AllDirectories) + .Where(path => !path.Split(Path.DirectorySeparatorChar).Any(segment => segment is "bin" or "obj")).ToArray(); + files.Should().NotBeEmpty("the caller fence must scan the production seeder; Fix: restore its source discovery"); + files.Select(Path.GetFileName).Should().Contain("SeedRunner.cs").And.Contain("SeedComposition.cs"); + files.SelectMany(path => ForbiddenReferences(File.ReadAllText(path)).Select(reference => $"{Path.GetFileName(path)}: {reference}")) + .Should().BeEmpty("seed verification must use contextual ISender queries; Fix: remove private announcements/transactions and direct setter references"); + using var assembly = AssemblyDefinition.ReadAssembly(typeof(SeedRunner).Assembly.Location); + var calls = Calls(assembly).ToArray(); + calls.Should().NotBeEmpty("the seed orchestration must be inspected; Fix: restore the IL census"); + calls.Where(IsDirectPersistenceWrite).Select(call => call.FullName).Should().BeEmpty( + "the seeder writes only through contextual requests; Fix: remove direct EF mutation, SaveChanges and ad hoc SQL"); + } + + [Fact] + public void Seeder_Caller_Fence_Catches_Calls_Method_Groups_And_Sql_But_Admits_Dispatch() + { + foreach (var statement in new[] + { + "await unit.SetTenantContextAsync(context);", "var announce = unit.SetTenantContextAsync;", + "await connection.BeginTransactionAsync();", "var begin = connection.BeginTransaction;", + "accessor.Current = context;", "var sql = \"SELECT set_config('app.tenant_id', @tenant, true)\";", + "var sql = \"SET LOCAL app.organization_id = 'x'\";", + "var sql = $\"SELECT set_config('app.scope', '{scope}', true)\";", + "var sql = \"SELECT set_config(\" + \"'app.resolving_host', @host, true)\";", + }) + ForbiddenReferences($"class Probe {{ void Run() {{ {statement} }} }}").Should().NotBeEmpty($"the planted caller must fail: {statement}; Fix: keep method groups and composed SQL visible"); + ForbiddenReferences(""" + class Probe { + void Run() { + // unit.SetTenantContextAsync(context); accessor.Current = context; + var text = "SetTenantContextAsync"; + var context = new SeedTenantContext(tenant, null); + var accessor = new StaticTenantContextAccessor(context); + sender.Send(new GetTenantSeedStateQuery()); + } + } + """).Should().BeEmpty("comments/plain diagnostics and trusted construction/dispatch do not announce database authority"); + } + + [Fact] + public void Seeder_Uses_The_Shared_Application_Role_Guard() + { + using var assembly = AssemblyDefinition.ReadAssembly(typeof(SeedRunner).Assembly.Location); + Calls(assembly).Where(call => call.DeclaringType.FullName == typeof(ApplicationDataSource).FullName + && call.Name == nameof(ApplicationDataSource.Build)).Should().ContainSingle( + "direct tool execution must enforce the same NOBYPASSRLS role boundary as HTTP; Fix: build the one pool with ApplicationDataSource.Build"); + } + + [Fact] + public void Seeder_Write_Fence_Catches_Ef_Mutations_And_Ad_Hoc_Commands() + { + using var module = ModuleDefinition.CreateModule("PlantedSeedCalls", ModuleKind.Dll); + MethodReference Call(string ns, string type, string name) => + new(name, module.TypeSystem.Void, new TypeReference(ns, type, module, module)); + foreach (var call in new[] + { + Call("Microsoft.EntityFrameworkCore", "DbSet`1", "Add"), + Call("Microsoft.EntityFrameworkCore", "DbSet`1", "UpdateRange"), + Call("Microsoft.EntityFrameworkCore", "DbSet`1", "Remove"), + Call("Microsoft.EntityFrameworkCore", "DbContext", "SaveChangesAsync"), + Call("Microsoft.EntityFrameworkCore", "RelationalDatabaseFacadeExtensions", "ExecuteSqlRawAsync"), + Call("Microsoft.EntityFrameworkCore", "EntityFrameworkQueryableExtensions", "ExecuteUpdateAsync"), + Call("Microsoft.EntityFrameworkCore", "EntityFrameworkQueryableExtensions", "ExecuteDeleteAsync"), + Call("Npgsql", "NpgsqlCommand", ".ctor"), + }) + IsDirectPersistenceWrite(call).Should().BeTrue($"{call.FullName} must fail the fence; Fix: preserve provider/mutation classification"); + foreach (var call in new[] + { + Call("System.Collections.Immutable", "ImmutableDictionary`2", "Add"), + Call("MediatR", "ISender", "Send"), + Call("Microsoft.Extensions.DependencyInjection", "ServiceCollectionServiceExtensions", "AddScoped"), + Call("Microsoft.EntityFrameworkCore", "EntityFrameworkQueryableExtensions", "ToListAsync"), + }) + IsDirectPersistenceWrite(call).Should().BeFalse("data construction, composition and reads are not mutation APIs"); + } + + private static IEnumerable Calls(AssemblyDefinition assembly) => + assembly.MainModule.Types.SelectMany(AllTypes).SelectMany(type => type.Methods) + .Where(method => method.HasBody).SelectMany(method => method.Body.Instructions) + .Select(instruction => instruction.Operand).OfType(); + + private static IEnumerable AllTypes(TypeDefinition type) => + new[] { type }.Concat(type.NestedTypes.SelectMany(AllTypes)); + + private static bool IsDirectPersistenceWrite(MethodReference call) => + call.DeclaringType.FullName == "Npgsql.NpgsqlCommand" + || (call.DeclaringType.Namespace == "Microsoft.EntityFrameworkCore" + && (call.Name.StartsWith("SaveChanges", StringComparison.Ordinal) + || call.Name.StartsWith("ExecuteSql", StringComparison.Ordinal) + || call.Name.StartsWith("ExecuteUpdate", StringComparison.Ordinal) + || call.Name.StartsWith("ExecuteDelete", StringComparison.Ordinal) + || (call.DeclaringType.Name is "DbSet`1" or "DbContext" + && call.Name is "Add" or "AddAsync" or "AddRange" or "AddRangeAsync" + or "Update" or "UpdateRange" or "Remove" or "RemoveRange"))); + + [Fact] + public void Seed_Literal_Source_Is_Complete_And_Readable() + { + var source = File.ReadAllText(Path.Combine(RepositoryPaths.BackendSrc(), "LearnStack.Tools.Seeder", "SeedData.cs")); + var literals = SeedLiterals(source); + literals.Should().NotBeEmpty("the later genericity guard needs an actual declaration, not a second identity list"); + SeedData.All.Should().NotBeEmpty(); + SeedData.All.Should().OnlyContain(tenant => tenant.Curriculum != null); + using var declared = JsonDocument.Parse(JsonSerializer.Serialize(SeedData.All)); + var values = StringValues(declared.RootElement).ToHashSet(StringComparer.Ordinal); + values.Should().NotBeEmpty(); + literals.Should().Contain(values, "every declared identity/body/label is read from SeedData itself; Fix: expand the literal reader rather than copy missing demo literals"); + SeedLiterals("""class SeedData { const string Host = "new-showcase.invalid"; const string Body = "{\"name\":\"A fresh label\"}"; }""") + .Should().Contain("new-showcase.invalid").And.Contain("A fresh label"); + foreach (var malformed in new[] { "class Different {}", "class SeedData {}", "class SeedData { const string Broken =" }) + { + var read = () => SeedLiterals(malformed); + read.Should().Throw("unreadable/missing/empty declaration must fail closed"); + } + } + + internal static HashSet SeedLiterals(string source) + { + var root = Parse(source); + var declarations = root.DescendantNodes().OfType().Where(type => type.Identifier.ValueText == "SeedData").ToArray(); + if (declarations.Length != 1) throw new InvalidOperationException("Expected exactly one SeedData declaration."); + var result = declarations[0].DescendantNodes().OfType() + .Where(literal => literal.IsKind(SyntaxKind.StringLiteralExpression)) + .Select(literal => literal.Token.ValueText).Where(value => value.Length > 0).ToHashSet(StringComparer.Ordinal); + if (result.Count == 0) throw new InvalidOperationException("SeedData contains no readable literals."); + foreach (var literal in result.ToArray()) + { + try + { + using var json = JsonDocument.Parse(literal); + result.UnionWith(StringValues(json.RootElement)); + } + catch (JsonException) { /* Ordinary non-JSON literals remain in the result. */ } + } + return result; + } + + private static IEnumerable StringValues(JsonElement element) => element.ValueKind switch + { + JsonValueKind.String => [element.GetString() ?? string.Empty], + JsonValueKind.Array => element.EnumerateArray().SelectMany(StringValues), + JsonValueKind.Object => element.EnumerateObject().SelectMany(property => StringValues(property.Value)), + _ => [], + }; + + private static SyntaxNode Parse(string source) + { + var tree = CSharpSyntaxTree.ParseText(source); + if (tree.GetDiagnostics().Any(diagnostic => diagnostic.Severity == DiagnosticSeverity.Error)) + throw new InvalidOperationException("Cannot scan malformed seeder source."); + return tree.GetRoot(); + } + + private static IEnumerable ForbiddenReferences(string source) + { + var root = Parse(source); + foreach (var name in root.DescendantNodes().OfType()) + if (name.Identifier.ValueText is "SetTenantContextAsync" or "BeginTransactionAsync" or "BeginTransaction") + yield return name.Identifier.ValueText; + foreach (var assignment in root.DescendantNodes().OfType()) + if (assignment.Left is MemberAccessExpressionSyntax { Name.Identifier.ValueText: "Current" }) + yield return "ambient accessor assignment"; + foreach (var expression in root.DescendantNodes().OfType()) + { + var text = ConstantText(expression); + if (text is not null && (Regex.IsMatch(text, @"set_config\s*\(\s*['""\s]*app\.", RegexOptions.IgnoreCase) + || Regex.IsMatch(text, @"\bSET\s+(?:LOCAL\s+|SESSION\s+)?app\.", RegexOptions.IgnoreCase))) + yield return "database context SQL"; + } + } + private static string? ConstantText(ExpressionSyntax expression) => expression switch + { + LiteralExpressionSyntax literal when literal.IsKind(SyntaxKind.StringLiteralExpression) => literal.Token.ValueText, + InterpolatedStringExpressionSyntax interpolation => string.Concat(interpolation.Contents.OfType().Select(text => text.TextToken.ValueText)), + BinaryExpressionSyntax binary when binary.IsKind(SyntaxKind.AddExpression) + && ConstantText(binary.Left) is { } left && ConstantText(binary.Right) is { } right => left + right, + _ => null, + }; +} diff --git a/backend/tests/LearnStack.Tests.Integration/Database/AuditPipelineTests.cs b/backend/tests/LearnStack.Tests.Integration/Database/AuditPipelineTests.cs index 51abdd2c..aed92947 100644 --- a/backend/tests/LearnStack.Tests.Integration/Database/AuditPipelineTests.cs +++ b/backend/tests/LearnStack.Tests.Integration/Database/AuditPipelineTests.cs @@ -42,13 +42,17 @@ namespace LearnStack.Tests.Integration.Database; [Collection(SharedSchema.Name)] public sealed class AuditPipelineTests : IAsyncLifetime { + // Keep the audit subject focused on provisioning and unchanged built-ins. + private static readonly SeedTenant Tenant = SeedData.English with { Curriculum = null }; + private readonly SchemaFixture _schema; + private DisposableSchemaDatabase _database = null!; // Initialized by the per-test fixture. public AuditPipelineTests(SchemaFixture schema) => _schema = schema; - public Task InitializeAsync() => Task.CompletedTask; + public async Task InitializeAsync() => _database = await DisposableSchemaDatabase.CreateAsync(_schema.Postgres); - public Task DisposeAsync() => CleanUpAsync(); + public Task DisposeAsync() => _database.DisposeAsync().AsTask(); /// /// Provisioning a tenant writes the two rows its matrix promises, on the business @@ -67,15 +71,15 @@ public async Task MustClass_Audit_Writes_Share_The_Business_Transaction() // ProvisionTenantCommand writes two aggregate roots on one transaction and the // Tenancy matrix classifies both MUST, so a singular reading of "one row per // request" would silently never write the Organization row the matrix promises. - await using var dataSource = NpgsqlDataSource.Create(_schema.Postgres.AppConnectionString); + await using var dataSource = NpgsqlDataSource.Create(_database.AppConnectionString); - var exitCode = await Runner(dataSource).RunAsync(CancellationToken.None, [SeedData.English]); + var exitCode = await Runner(dataSource).RunAsync(CancellationToken.None, [Tenant]); exitCode.Should().Be(0); - var rows = await RowsAsync(SeedData.English.TenantId.Value); + var rows = await RowsAsync(Tenant.TenantId.Value); - // The whole seed for one tenant, every operation its module's matrix classifies + // The focused provisioning/built-in declaration, every operation its matrix classifies // MUST. The pair is the point — ProvisionTenantCommand alone accounts for the // first two — and the rest are here because a case asserting only the pair would // pass while every other command audited nothing. @@ -109,8 +113,8 @@ public async Task A_MUST_row_that_cannot_be_written_takes_its_business_write_dow // SaveChanges inside an open transaction in a savepoint, so the business rows carry // subtransaction ids while the audit row, raw SQL at the top level, carries the // parent's. Rows on one transaction show different `xmin`s. - await using var dataSource = NpgsqlDataSource.Create(_schema.Postgres.AppConnectionString); - (await Runner(dataSource).RunAsync(CancellationToken.None, [SeedData.English])).Should().Be(0); + await using var dataSource = NpgsqlDataSource.Create(_database.AppConnectionString); + (await Runner(dataSource).RunAsync(CancellationToken.None, [Tenant])).Should().Be(0); const string Host = "unauditable.example"; @@ -132,7 +136,7 @@ CREATE TRIGGER refuse_probe_audit BEFORE INSERT ON audit_log { await using var provider = SeedComposition.Build( dataSource, - new SeedTenantContext(SeedData.English.TenantId, SeedData.English.DefaultOrganization.OrganizationId), + new SeedTenantContext(Tenant.TenantId, Tenant.DefaultOrganization.OrganizationId), NullLoggerFactory.Instance); var act = async () => @@ -177,15 +181,15 @@ public async Task Audit_Classification_Does_Not_Read_The_Database_On_The_Request try { - await using var dataSource = NpgsqlDataSource.Create(_schema.Postgres.AppConnectionString); + await using var dataSource = NpgsqlDataSource.Create(_database.AppConnectionString); - var exitCode = await Runner(dataSource).RunAsync(CancellationToken.None, [SeedData.English]); + var exitCode = await Runner(dataSource).RunAsync(CancellationToken.None, [Tenant]); exitCode.Should().Be(0, "classification reads the in-process catalogue, and the tenant override read is " + "the one failure ADR-0033 does not reject the operation for"); - var rows = await RowsAsync(SeedData.English.TenantId.Value); + var rows = await RowsAsync(Tenant.TenantId.Value); rows.Where(row => MustOperations.Contains(row.Operation)) .Should().NotBeEmpty("the MUST rows are written at the classification the catalogue carries") @@ -239,14 +243,14 @@ public async Task The_row_carries_the_tenant_the_transaction_announced() // reading the context would give the all-zero tenant and the policy would refuse // the insert. Measured: it did, with 42501, before the behaviour read // IProvisionsTenant.ProvisioningTenantId instead (ADR-0044 § 2). - await using var dataSource = NpgsqlDataSource.Create(_schema.Postgres.AppConnectionString); + await using var dataSource = NpgsqlDataSource.Create(_database.AppConnectionString); - (await Runner(dataSource).RunAsync(CancellationToken.None, [SeedData.English])).Should().Be(0); + (await Runner(dataSource).RunAsync(CancellationToken.None, [Tenant])).Should().Be(0); - var rows = await RowsAsync(SeedData.English.TenantId.Value); + var rows = await RowsAsync(Tenant.TenantId.Value); rows.Should().NotBeEmpty(); - rows.Should().OnlyContain(row => row.TenantId == SeedData.English.TenantId.Value); + rows.Should().OnlyContain(row => row.TenantId == Tenant.TenantId.Value); rows.Should().OnlyContain(row => row.Outcome == "success", "a clean seed refuses nothing"); } @@ -259,15 +263,15 @@ public async Task The_row_carries_the_snapshot_the_interceptor_captured() // row from it on the business transaction. A snapshot that arrived empty here // would mean the interceptor never attached — which is exactly the failure that // reports success everywhere else. - await using var dataSource = NpgsqlDataSource.Create(_schema.Postgres.AppConnectionString); + await using var dataSource = NpgsqlDataSource.Create(_database.AppConnectionString); - (await Runner(dataSource).RunAsync(CancellationToken.None, [SeedData.English])).Should().Be(0); + (await Runner(dataSource).RunAsync(CancellationToken.None, [Tenant])).Should().Be(0); - var tenantRow = (await RowsAsync(SeedData.English.TenantId.Value)) + var tenantRow = (await RowsAsync(Tenant.TenantId.Value)) .Single(row => row.Operation == "tenancy.tenant.create"); tenantRow.EntityType.Should().Be("Tenant"); - tenantRow.EntityId.Should().Be(SeedData.English.TenantId.Value.ToString()); + tenantRow.EntityId.Should().Be(Tenant.TenantId.Value.ToString()); // The EARLIEST capture's before state, and the tenant is created in this request — // so there is no prior state, and a non-null one would mean the merge walked past @@ -275,7 +279,7 @@ public async Task The_row_carries_the_snapshot_the_interceptor_captured() tenantRow.BeforeState.Should().BeNull(); tenantRow.AfterState.Should().NotBeNull(); - tenantRow.AfterState.Should().Contain(SeedData.English.Slug); + tenantRow.AfterState.Should().Contain(Tenant.Slug); // The LATEST capture's after state. ProvisionTenantCommand saves three times and // assigns the default organization on the third, so a merge that kept the first @@ -290,18 +294,18 @@ public async Task The_host_mapping_row_carries_the_state_it_changed() // keyless projection row rather than an aggregate root, and a capture that only walked // roots wrote `tenancy.hostmapping.write` with no before, no after and no changes — a // row that records that something happened and not what. - await using var dataSource = NpgsqlDataSource.Create(_schema.Postgres.AppConnectionString); + await using var dataSource = NpgsqlDataSource.Create(_database.AppConnectionString); - (await Runner(dataSource).RunAsync(CancellationToken.None, [SeedData.English])).Should().Be(0); + (await Runner(dataSource).RunAsync(CancellationToken.None, [Tenant])).Should().Be(0); - var mapping = (await RowsAsync(SeedData.English.TenantId.Value)) + var mapping = (await RowsAsync(Tenant.TenantId.Value)) .Single(row => row.Operation == "tenancy.hostmapping.write"); mapping.EntityType.Should().Be("PlatformHostMapping"); - mapping.EntityId.Should().Be(SeedData.English.Host); + mapping.EntityId.Should().Be(Tenant.Host); mapping.BeforeState.Should().BeNull("the host is mapped for the first time here"); - mapping.AfterState.Should().NotBeNull().And.Contain(SeedData.English.Host, + mapping.AfterState.Should().NotBeNull().And.Contain(Tenant.Host, "the row says which host now points where"); mapping.AfterState.Should().Contain("IsPubliclyLive", "and the flags that decide whether the host serves anything"); @@ -309,7 +313,7 @@ public async Task The_host_mapping_row_carries_the_state_it_changed() // The tenant is the row's own column rather than a snapshot field: every row in // audit_log carries one, and repeating it inside the state would be a second place for // it to be wrong. - mapping.TenantId.Should().Be(SeedData.English.TenantId.Value); + mapping.TenantId.Should().Be(Tenant.TenantId.Value); } /// @@ -326,23 +330,35 @@ public async Task The_host_mapping_row_carries_the_state_it_changed() [Fact] public async Task Audit_Survives_Transaction_Rollback() { - // The seed is idempotent, so the second run refuses before it writes — and a - // refusal that produced a success row would be worse than no row at all. - await using var dataSource = NpgsqlDataSource.Create(_schema.Postgres.AppConnectionString); + // Completed seed acts skip writers. Explicitly repeat the commands to prove + // real refusals retain audit intents after their business transaction rolls back. + await using var dataSource = NpgsqlDataSource.Create(_database.AppConnectionString); - (await Runner(dataSource).RunAsync(CancellationToken.None, [SeedData.English])).Should().Be(0); - (await Runner(dataSource).RunAsync(CancellationToken.None, [SeedData.English])).Should().Be(0); + (await Runner(dataSource).RunAsync(CancellationToken.None, [Tenant])).Should().Be(0); + await using (var unresolved = SeedComposition.Build(dataSource, null, NullLoggerFactory.Instance)) + { + (await unresolved.GetRequiredService().Send(new ProvisionTenantCommand( + Tenant.TenantId, Tenant.Slug, Tenant.DisplayName, Tenant.DefaultOrganization.OrganizationId, + Tenant.DefaultOrganization.Slug, Tenant.DefaultOrganization.DisplayName))).IsFailure.Should().BeTrue(); + } + await using (var resolved = SeedComposition.Build(dataSource, + new SeedTenantContext(Tenant.TenantId, null), NullLoggerFactory.Instance)) + { + (await resolved.GetRequiredService().Send(new CreateOrganizationCommand( + Tenant.SecondOrganization.OrganizationId, Tenant.SecondOrganization.Slug, + Tenant.SecondOrganization.DisplayName))).IsFailure.Should().BeTrue(); + } - var rows = await RowsAsync(SeedData.English.TenantId.Value); + var rows = await RowsAsync(Tenant.TenantId.Value); - // The second run REFUSES, and every refusal is recorded. That is the point rather + // The repeated commands REFUSE, and every refusal is recorded. That is the point rather // than an inconvenience: a repeated provisioning attempt is exactly the shape a // probe takes, and Audit Coverage justifies the whole `denied` class with it. rows.Count(row => row.Outcome == "success").Should().Be(8, "the first run's rows are untouched"); rows.Where(row => row.Outcome != "success").Should().NotBeEmpty( - "the refused second run is on the record too"); + "the explicit refused commands are on the record too"); rows.Where(row => row.Outcome != "success") .Should().OnlyContain(row => row.Outcome == "failed" || row.Outcome == "denied"); @@ -358,9 +374,9 @@ public async Task Audit_Survives_Transaction_Rollback() refused.Should().Contain( row => row.Operation == "tenancy.tenant.create" - && row.EntityId == SeedData.English.TenantId.Value.ToString(), + && row.EntityId == Tenant.TenantId.Value.ToString(), "the refused provisioning records its first intent"); - // The seed refuses two organization creations, and they are not the same one. The + // The repeated commands refuse two organization creations, which are distinct. The // standalone CreateOrganizationCommand names the organization it was asked for; the // provisioning's SECOND INTENT names none, because it was refused before the aggregate // existed to be designated. Distinguishing them is the point: an assertion on the slug @@ -371,7 +387,7 @@ public async Task Audit_Survives_Transaction_Rollback() .ToList(); organizations.Should().Contain( - row => row.EntityId == SeedData.English.SecondOrganization.OrganizationId.Value.ToString(), + row => row.EntityId == Tenant.SecondOrganization.OrganizationId.Value.ToString(), "the standalone command's refusal names the organization it was asked for"); organizations.Should().Contain( row => row.EntityId == null, @@ -380,10 +396,10 @@ public async Task Audit_Survives_Transaction_Rollback() // And the refused run wrote no business row: the counts are the first run's, exactly. (await ScalarAsync("SELECT count(*) FROM tenants WHERE id = @tenant", - SeedData.English.TenantId.Value)).Should().Be(1L, + Tenant.TenantId.Value)).Should().Be(1L, "a refused run leaves the row the first run committed and adds none"); (await ScalarAsync("SELECT count(*) FROM organizations WHERE tenant_id = @tenant", - SeedData.English.TenantId.Value)).Should().Be(2L, + Tenant.TenantId.Value)).Should().Be(2L, "the seed's two organizations, and the refused run added neither"); } @@ -391,7 +407,7 @@ public async Task Audit_Survives_Transaction_Rollback() private async Task ScalarAsync(string sql, Guid tenant) { await using var connection = await PostgresFixture.OpenAsync( - _schema.Postgres.PlatformConnectionString); + _database.PlatformConnectionString); await using var command = new NpgsqlCommand(sql, (NpgsqlConnection)connection); command.Parameters.AddWithValue("tenant", tenant); @@ -422,7 +438,7 @@ private static SeedRunner Runner(NpgsqlDataSource dataSource) => private async Task> RowsAsync(Guid tenantId) { await using var connection = await PostgresFixture.OpenAsync( - _schema.Postgres.PlatformConnectionString); + _database.PlatformConnectionString); await using var command = new NpgsqlCommand( """ @@ -455,7 +471,7 @@ ORDER BY operation private async Task CountAsync(string sql, object key) { - await using var connection = await PostgresFixture.OpenAsync(_schema.Postgres.PlatformConnectionString); + await using var connection = await PostgresFixture.OpenAsync(_database.PlatformConnectionString); await using var command = new NpgsqlCommand(sql, (NpgsqlConnection)connection); command.Parameters.AddWithValue("key", key); @@ -504,65 +520,9 @@ private static AuditCatalogEntry Operation(string operation, OperationClass oper private async Task ExecuteAsOwnerAsync(string sql) { - await using var owner = await PostgresFixture.OpenAsync(_schema.Postgres.MigrationConnectionString); + await using var owner = await PostgresFixture.OpenAsync(_database.MigrationConnectionString); await using var command = new NpgsqlCommand(sql, (NpgsqlConnection)owner); await command.ExecuteNonQueryAsync(); } - /// Removes what a case seeded, so the shared fixture's counts do not move. - /// - /// Three roles, and each is the only one that can do its part. audit_log and the - /// tenancy tables go as learnstack_platform — the audit rows because it is the - /// only role holding DELETE on that table, the tenancy rows because they belong - /// to tenants with no context left to announce. The customization tables go as the - /// OWNER with the tenant announced, because learnstack_platform holds only - /// SELECT on them. - /// - private async Task CleanUpAsync() - { - var ids = SeedData.All.Select(tenant => tenant.TenantId.Value).ToArray(); - - await using (var platform = await PostgresFixture.OpenAsync( - _schema.Postgres.PlatformConnectionString)) - { - foreach (var statement in new[] - { - "DELETE FROM audit_log WHERE tenant_id = ANY(@ids)", - "DELETE FROM platform_host_to_tenant WHERE tenant_id = ANY(@ids)", - "UPDATE tenants SET default_organization_id = NULL WHERE id = ANY(@ids)", - "DELETE FROM organizations WHERE tenant_id = ANY(@ids)", - "DELETE FROM tenants WHERE id = ANY(@ids)", - }) - { - await using var cleanup = new NpgsqlCommand(statement, (NpgsqlConnection)platform); - cleanup.Parameters.AddWithValue("ids", ids); - await cleanup.ExecuteNonQueryAsync(); - } - } - - await using var owner = await PostgresFixture.OpenAsync( - _schema.Postgres.MigrationConnectionString); - - foreach (var tenant in SeedData.All) - { - await using var transaction = await owner.BeginTransactionAsync(); - await SchemaQueries.SetTenantAsync(owner, transaction, tenant.TenantId.Value); - - foreach (var statement in new[] - { - "DELETE FROM tenant_level_taxonomy_items WHERE tenant_id = @tenant", - "DELETE FROM tenant_level_taxonomies WHERE tenant_id = @tenant", - "DELETE FROM tenant_content_types WHERE tenant_id = @tenant", - "DELETE FROM customization_generations WHERE tenant_id = @tenant", - "DELETE FROM tenant_domains WHERE tenant_id = @tenant", - "DELETE FROM tenant_locales WHERE tenant_id = @tenant", - }) - { - await SchemaQueries.ExecuteAsync(owner, transaction, statement, - ("tenant", tenant.TenantId.Value)); - } - - await transaction.CommitAsync(); - } - } } diff --git a/backend/tests/LearnStack.Tests.Integration/Database/AuditWorkflowTests.cs b/backend/tests/LearnStack.Tests.Integration/Database/AuditWorkflowTests.cs index de983940..5e59c8fc 100644 --- a/backend/tests/LearnStack.Tests.Integration/Database/AuditWorkflowTests.cs +++ b/backend/tests/LearnStack.Tests.Integration/Database/AuditWorkflowTests.cs @@ -37,7 +37,7 @@ namespace LearnStack.Tests.Integration.Database; /// tenant would send and reads the columns a reader would read. /// /// -/// Same composition root, same role, same cleanup discipline as +/// Same composition root, same role, same disposable-database discipline as /// : the seeder's graph is a real one, and the rows are /// written by learnstack_app under the policies and read back as /// learnstack_platform. @@ -50,15 +50,17 @@ public sealed class AuditWorkflowTests : IAsyncLifetime private static readonly Guid Actor = Guid.Parse("dddddddd-0000-7000-8000-00000000a0a0"); private const string Correlation = "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01"; - private static readonly SeedTenant Tenant = SeedData.English; + // Audit workflows need only provisioning and built-ins, not the demo curriculum. + private static readonly SeedTenant Tenant = SeedData.English with { Curriculum = null }; private readonly SchemaFixture _schema; + private DisposableSchemaDatabase _database = null!; // Initialized by the per-test fixture. public AuditWorkflowTests(SchemaFixture schema) => _schema = schema; - public Task InitializeAsync() => Task.CompletedTask; + public async Task InitializeAsync() => _database = await DisposableSchemaDatabase.CreateAsync(_schema.Postgres); - public Task DisposeAsync() => CleanUpAsync(); + public Task DisposeAsync() => _database.DisposeAsync().AsTask(); [Fact] public async Task Replacing_a_live_content_type_is_one_row_about_the_successor() @@ -67,7 +69,7 @@ public async Task Replacing_a_live_content_type_is_one_row_about_the_successor() // one transaction — two instances of one aggregate — and the composer refused the // pair, which rolled back every replacement publication. The handler now designates // the successor, and the retirement travels in `changes` under its own pointer. - await using var dataSource = NpgsqlDataSource.Create(_schema.Postgres.AppConnectionString); + await using var dataSource = NpgsqlDataSource.Create(_database.AppConnectionString); await SeedAsync(dataSource); var incumbent = Tenant.BuiltInContentTypeId; @@ -111,7 +113,7 @@ public async Task Replacing_a_live_content_type_is_one_row_about_the_successor() [Fact] public async Task Replacing_a_live_taxonomy_is_one_row_about_the_successor() { - await using var dataSource = NpgsqlDataSource.Create(_schema.Postgres.AppConnectionString); + await using var dataSource = NpgsqlDataSource.Create(_database.AppConnectionString); await SeedAsync(dataSource); var incumbent = Tenant.BuiltInTaxonomyId; @@ -157,7 +159,7 @@ public async Task The_bands_a_tenant_authors_are_in_the_row_that_records_the_tax // taxonomy's own type, so the persisted row held the parent's metadata and none of // the vocabulary the tenant wrote. Asserted on the persisted JSON, because an // interceptor-only case never proved the row kept what was captured. - await using var dataSource = NpgsqlDataSource.Create(_schema.Postgres.AppConnectionString); + await using var dataSource = NpgsqlDataSource.Create(_database.AppConnectionString); await SeedAsync(dataSource); var taxonomy = Guid.CreateVersion7(); @@ -201,7 +203,7 @@ public async Task A_removed_band_is_in_the_changes_of_the_persisted_row() // row goes through the real interceptor, capture, composer and store. What this // proves is the removal's path to the column, which is the half the planned command // will not be able to change. - await using var dataSource = NpgsqlDataSource.Create(_schema.Postgres.AppConnectionString); + await using var dataSource = NpgsqlDataSource.Create(_database.AppConnectionString); await SeedAsync(dataSource); var taxonomyId = Guid.CreateVersion7(); @@ -274,7 +276,7 @@ public async Task A_partially_loaded_taxonomy_is_never_recorded_as_owning_only_w // taxonomy with one band — permanently. Membership is now written down only for an // owner created in the request, so a loaded owner's collection is left out as // unknown, however it was loaded (ADR-0044 Amendment 6 § 4). - await using var dataSource = NpgsqlDataSource.Create(_schema.Postgres.AppConnectionString); + await using var dataSource = NpgsqlDataSource.Create(_database.AppConnectionString); await SeedAsync(dataSource); var intentId = AuditEntryId.From(Guid.CreateVersion7()); @@ -341,7 +343,7 @@ public async Task A_created_taxonomy_that_loses_bands_to_the_tracker_is_never_re // request is complete only while the tracker holds what the root was created with; // past that the membership is unknown, and the first flush's changes still carry // every band (ADR-0044 Amendment 6 § 4). - await using var dataSource = NpgsqlDataSource.Create(_schema.Postgres.AppConnectionString); + await using var dataSource = NpgsqlDataSource.Create(_database.AppConnectionString); await SeedAsync(dataSource); var intentId = AuditEntryId.From(Guid.CreateVersion7()); @@ -422,7 +424,7 @@ public async Task A_host_mapping_of_any_valid_length_commits_with_its_whole_key_ // standalone record of the attempt failed the same way, and the audit health check // went unhealthy. 100 is the control that passed before; the key goes on the row // whole, because a truncated key names a different subject. - await using var dataSource = NpgsqlDataSource.Create(_schema.Postgres.AppConnectionString); + await using var dataSource = NpgsqlDataSource.Create(_database.AppConnectionString); await SeedAsync(dataSource); var host = HostOfLength(length); @@ -449,7 +451,7 @@ public async Task A_refused_publication_is_recorded_against_its_subject_with_act // The failure path of the same two columns, written by the other writer. Publishing // a revision that is already live is refused after the handler designated it, so // the reconcile's standalone row names the instance it refused. - await using var dataSource = NpgsqlDataSource.Create(_schema.Postgres.AppConnectionString); + await using var dataSource = NpgsqlDataSource.Create(_database.AppConnectionString); await SeedAsync(dataSource); await using (var provider = Compose(dataSource)) @@ -479,7 +481,7 @@ public async Task A_validation_refusal_writes_no_row_because_nothing_has_classif // refused command never reaches the step that would classify it — no intent, no row. // The valid send afterwards is the control: the same operation through the same graph // does write, so an unchanged count is the pipeline's answer and not a blind read. - await using var dataSource = NpgsqlDataSource.Create(_schema.Postgres.AppConnectionString); + await using var dataSource = NpgsqlDataSource.Create(_database.AppConnectionString); await SeedAsync(dataSource); var before = (await RowsAsync()).Count; @@ -577,7 +579,7 @@ private sealed record Row( /// Reads the tenant's rows as learnstack_platform — the read is the only bypass. private async Task> RowsAsync() { - await using var connection = await PostgresFixture.OpenAsync(_schema.Postgres.PlatformConnectionString); + await using var connection = await PostgresFixture.OpenAsync(_database.PlatformConnectionString); await using var command = new NpgsqlCommand( """ SELECT id, operation, outcome, entity_id, before_state::text, after_state::text, @@ -644,7 +646,7 @@ private static string HostOfLength(int length) /// How many mappings the database holds for a host, read as the platform role. private async Task HostMappingCountAsync(string host) { - await using var connection = await PostgresFixture.OpenAsync(_schema.Postgres.PlatformConnectionString); + await using var connection = await PostgresFixture.OpenAsync(_database.PlatformConnectionString); await using var command = new NpgsqlCommand( "SELECT count(*) FROM platform_host_to_tenant WHERE host = @host", (NpgsqlConnection)connection); command.Parameters.AddWithValue("host", host); @@ -655,7 +657,7 @@ private async Task HostMappingCountAsync(string host) /// The bands the database holds under a taxonomy key, read as the owner under the tenant. private async Task BandCountAsync(string taxonomyKey) { - await using var owner = await PostgresFixture.OpenAsync(_schema.Postgres.MigrationConnectionString); + await using var owner = await PostgresFixture.OpenAsync(_database.MigrationConnectionString); await using var transaction = await owner.BeginTransactionAsync(); await SchemaQueries.SetTenantAsync(owner, transaction, Tenant.TenantId.Value); @@ -668,49 +670,4 @@ private async Task BandCountAsync(string taxonomyKey) return (long)(await command.ExecuteScalarAsync())!; } - /// The same cleanup as , for the same reasons. - private async Task CleanUpAsync() - { - var ids = SeedData.All.Select(tenant => tenant.TenantId.Value).ToArray(); - - await using (var platform = await PostgresFixture.OpenAsync(_schema.Postgres.PlatformConnectionString)) - { - foreach (var statement in new[] - { - "DELETE FROM audit_log WHERE tenant_id = ANY(@ids)", - "DELETE FROM platform_host_to_tenant WHERE tenant_id = ANY(@ids)", - "UPDATE tenants SET default_organization_id = NULL WHERE id = ANY(@ids)", - "DELETE FROM organizations WHERE tenant_id = ANY(@ids)", - "DELETE FROM tenants WHERE id = ANY(@ids)", - }) - { - await using var cleanup = new NpgsqlCommand(statement, (NpgsqlConnection)platform); - cleanup.Parameters.AddWithValue("ids", ids); - await cleanup.ExecuteNonQueryAsync(); - } - } - - await using var owner = await PostgresFixture.OpenAsync(_schema.Postgres.MigrationConnectionString); - - foreach (var tenant in SeedData.All) - { - await using var transaction = await owner.BeginTransactionAsync(); - await SchemaQueries.SetTenantAsync(owner, transaction, tenant.TenantId.Value); - - foreach (var statement in new[] - { - "DELETE FROM tenant_level_taxonomy_items WHERE tenant_id = @tenant", - "DELETE FROM tenant_level_taxonomies WHERE tenant_id = @tenant", - "DELETE FROM tenant_content_types WHERE tenant_id = @tenant", - "DELETE FROM customization_generations WHERE tenant_id = @tenant", - "DELETE FROM tenant_domains WHERE tenant_id = @tenant", - "DELETE FROM tenant_locales WHERE tenant_id = @tenant", - }) - { - await SchemaQueries.ExecuteAsync(owner, transaction, statement, ("tenant", tenant.TenantId.Value)); - } - - await transaction.CommitAsync(); - } - } } diff --git a/backend/tests/LearnStack.Tests.Integration/Database/CourseContentAccessMigrationTests.cs b/backend/tests/LearnStack.Tests.Integration/Database/CourseContentAccessMigrationTests.cs new file mode 100644 index 00000000..2be8cd02 --- /dev/null +++ b/backend/tests/LearnStack.Tests.Integration/Database/CourseContentAccessMigrationTests.cs @@ -0,0 +1,143 @@ +using System.Data.Common; +using FluentAssertions; +using LearnStack.Modules.Education.Infrastructure.Persistence; +using LearnStack.SharedKernel.Tenancy; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Npgsql; +using Xunit; + +namespace LearnStack.Tests.Integration.Database; + +[Trait(RequiresDocker.Key, RequiresDocker.Value)] +[Collection(SharedSchema.Name)] +public sealed class CourseContentAccessMigrationTests(SchemaFixture schema) +{ + [Fact] + public async Task Legacy_rows_are_restricted_without_changing_payload_pins_scope_or_publication_and_reapply_is_safe() + { + await using var database = await DisposableSchemaDatabase.CreateAsync(schema.Postgres); + await using var context = new EducationDbContext(new DbContextOptionsBuilder() + .UseNpgsql(database.MigrationConnectionString, + provider => provider.MigrationsHistoryTable(EducationDbContextFactory.HistoryTable)).Options, + StaticTenantContextAccessor.Unresolved); + context.Database.HasPendingModelChanges().Should().BeFalse(); + var migrations = context.Database.GetMigrations().ToArray(); + var policyIndex = Array.IndexOf(migrations, "20261001233219_add_course_content_access"); + policyIndex.Should().BeGreaterThan(0, "the policy migration must exist and have a predecessor"); + var previous = migrations[policyIndex - 1]; + // Technical reversal is confined to this disposable database. ADR-0050 + // forbids using it as a live rollback with old anonymous public readers. + await context.GetService().MigrateAsync(previous); + await using (var app = await PostgresFixture.OpenAsync(database.AppConnectionString)) + { + await using var insert = new NpgsqlCommand(LegacyRows, (NpgsqlConnection)app); + await insert.ExecuteNonQueryAsync(); + } + + var before = await SnapshotAsync(database.AppConnectionString); + await context.Database.MigrateAsync(); + (await SnapshotAsync(database.AppConnectionString)).Should().Be(before); + await AssertLegacyPoliciesAsync(database.AppConnectionString); + + await context.GetService().MigrateAsync(previous); + (await SnapshotAsync(database.AppConnectionString)).Should().Be(before); + await context.Database.MigrateAsync(); + (await SnapshotAsync(database.AppConnectionString)).Should().Be(before); + await AssertLegacyPoliciesAsync(database.AppConnectionString); + context.Database.HasPendingModelChanges().Should().BeFalse(); + await AssertDefaultAndCheckAsync(database.AppConnectionString); + } + + private static async Task AssertLegacyPoliciesAsync(string connectionString) + { + await using (var app = await PostgresFixture.OpenAsync(connectionString)) + { + await using var transaction = await app.BeginTransactionAsync(); + await EducationSchemaSeed.AnnounceAsync(app, transaction, SchemaFixture.TenantA, SchemaFixture.OrgA1); + await using var read = new NpgsqlCommand( + "SELECT count(*) FROM courses WHERE content_access = 'enrollment_required'", + (NpgsqlConnection)app, (NpgsqlTransaction)transaction); + (await read.ExecuteScalarAsync()).Should().Be(2L); + await transaction.CommitAsync(); + } + } + + private static async Task AssertDefaultAndCheckAsync(string connectionString) + { + await using var app = await PostgresFixture.OpenAsync(connectionString); + await using var transaction = await app.BeginTransactionAsync(); + await EducationSchemaSeed.AnnounceAsync(app, transaction, SchemaFixture.TenantA, null); + var course = Guid.CreateVersion7(); + await EducationSchemaSeed.InsertAsync(app, transaction, "courses", SchemaFixture.TenantA, + null, course, course, "fresh-default"); + await using var read = new NpgsqlCommand("SELECT content_access FROM courses WHERE id = @id", + (NpgsqlConnection)app, (NpgsqlTransaction)transaction); + read.Parameters.AddWithValue("id", course); + (await read.ExecuteScalarAsync()).Should().Be("enrollment_required"); + await using var change = new NpgsqlCommand("UPDATE courses SET content_access = @policy WHERE id = @id", + (NpgsqlConnection)app, (NpgsqlTransaction)transaction); + change.Parameters.AddWithValue("policy", "public"); + change.Parameters.AddWithValue("id", course); + (await change.ExecuteNonQueryAsync()).Should().Be(1); + change.Parameters["policy"].Value = "PUBLIC"; + var rejected = async () => await change.ExecuteNonQueryAsync(); + var error = (await rejected.Should().ThrowAsync()).Which; + error.SqlState.Should().Be(PostgresErrorCodes.CheckViolation); + error.ConstraintName.Should().Be("ck_courses_content_access"); + await transaction.RollbackAsync(); + } + + private static async Task SnapshotAsync(string connectionString) + { + await using var app = await PostgresFixture.OpenAsync(connectionString); + await using var transaction = await app.BeginTransactionAsync(); + await EducationSchemaSeed.AnnounceAsync(app, transaction, SchemaFixture.TenantA, SchemaFixture.OrgA1); + var snapshots = new List(); + foreach (var table in EducationSchemaSeed.Tables) + { + await using var read = new NpgsqlCommand( + $"SELECT jsonb_agg(to_jsonb(row) - 'content_access' ORDER BY to_jsonb(row)::text)::text FROM {table} row", + (NpgsqlConnection)app, (NpgsqlTransaction)transaction); + snapshots.Add((string)(await read.ExecuteScalarAsync())!); + } + + await transaction.CommitAsync(); + return string.Join('\n', snapshots); + } + + private const string LegacyRows = """ + BEGIN; + SET LOCAL app.tenant_id = '11111111-1111-7111-8111-111111111111'; + INSERT INTO tenants (id, slug, display_name, status, created_at, created_by, row_version) + VALUES ('11111111-1111-7111-8111-111111111111', 'policy-proof', 'Policy', 'Trial', now(), + '00000000-0000-7000-8000-000000000001', 0); + INSERT INTO organizations (id, tenant_id, slug, display_name, status, created_at, created_by, row_version) + VALUES ('aaaaaaaa-1111-7111-8111-111111111111', '11111111-1111-7111-8111-111111111111', + 'main', 'Main', 'Active', now(), '00000000-0000-7000-8000-000000000001', 0); + INSERT INTO courses (id, tenant_id, slug_key, status, level_taxonomy_key, level_taxonomy_schema_version, + level_band_key, created_at, created_by, row_version) + VALUES ('cccccccc-0000-7000-8000-000000000001', '11111111-1111-7111-8111-111111111111', + 'legacy-wide', 'published', 'difficulty', 7, 'intro', now(), '00000000-0000-7000-8000-000000000001', 8); + SET LOCAL app.organization_id = 'aaaaaaaa-1111-7111-8111-111111111111'; + INSERT INTO courses (id, tenant_id, organization_id, slug_key, status, created_at, created_by, row_version) + VALUES ('cccccccc-0000-7000-8000-000000000002', '11111111-1111-7111-8111-111111111111', + 'aaaaaaaa-1111-7111-8111-111111111111', 'legacy-scoped', 'draft', now(), + '00000000-0000-7000-8000-000000000001', 4); + SET LOCAL app.organization_id = ''; + INSERT INTO course_translations (course_id, tenant_id, locale, title, summary, slug) + VALUES ('cccccccc-0000-7000-8000-000000000001', '11111111-1111-7111-8111-111111111111', + 'en', 'Legacy course', 'Preserved summary', 'legacy-course'); + SET LOCAL app.organization_id = 'aaaaaaaa-1111-7111-8111-111111111111'; + INSERT INTO lessons (id, tenant_id, organization_id, course_id, sort, status, + content_type_key, content_type_schema_version, created_at, created_by, row_version) + VALUES ('dddddddd-0000-7000-8000-000000000001', '11111111-1111-7111-8111-111111111111', + 'aaaaaaaa-1111-7111-8111-111111111111', 'cccccccc-0000-7000-8000-000000000002', 3, + 'published', 'text-card', 9, now(), '00000000-0000-7000-8000-000000000001', 2); + INSERT INTO lesson_translations (lesson_id, tenant_id, organization_id, locale, title, slug, body) + VALUES ('dddddddd-0000-7000-8000-000000000001', '11111111-1111-7111-8111-111111111111', + 'aaaaaaaa-1111-7111-8111-111111111111', 'en', 'Legacy lesson', 'legacy-lesson', '{"body":"Private 🧘"}'); + COMMIT; + """; +} diff --git a/backend/tests/LearnStack.Tests.Integration/Database/CustomizationPublicationConcurrencyTests.cs b/backend/tests/LearnStack.Tests.Integration/Database/CustomizationPublicationConcurrencyTests.cs new file mode 100644 index 00000000..3c2e4b8c --- /dev/null +++ b/backend/tests/LearnStack.Tests.Integration/Database/CustomizationPublicationConcurrencyTests.cs @@ -0,0 +1,282 @@ +using FluentAssertions; +using LearnStack.Modules.Customization.Application.Abstractions; +using LearnStack.Modules.Customization.Application.Contracts.Customization; +using LearnStack.Modules.Customization.Application.Contracts.Seeding; +using LearnStack.Modules.Customization.Domain; +using LearnStack.Modules.Customization.Infrastructure.Persistence; +using LearnStack.Modules.Tenancy.Application.Contracts.Tenant; +using LearnStack.SharedKernel.Audit; +using LearnStack.SharedKernel.Identifiers; +using LearnStack.SharedKernel.Persistence; +using LearnStack.SharedKernel.Results; +using LearnStack.SharedKernel.Tenancy; +using LearnStack.Tools.Seeder; +using MediatR; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging.Abstractions; +using Npgsql; +using Xunit; + +namespace LearnStack.Tests.Integration.Database; + +[Trait(RequiresDocker.Key, RequiresDocker.Value)] +[Collection(SharedSchema.Name)] +public sealed class CustomizationPublicationConcurrencyTests(SchemaFixture schema, WebApplicationFactory factory) + : IClassFixture> +{ + [Theory] + [InlineData(false, false)] + [InlineData(false, true)] + [InlineData(true, false)] + [InlineData(true, true)] + public async Task A_competitor_publishing_between_reads_is_refused_without_self_or_other_retirement(bool taxonomy, bool differentRevision) + { + await using var database = await DisposableSchemaDatabase.CreateAsync(schema.Postgres); + await using var source = NpgsqlDataSource.Create(database.AppConnectionString); + var context = await ProvisionAsync(source); + var id = Guid.CreateVersion7(); + await RegisterAsync(source, context, id, taxonomy, "race-definition"); + var initialVersion = await VersionAsync(source, context, id, taxonomy); + var gate = new ActiveReadGate(); + await using var host = factory.WithWebHostBuilder(builder => builder + .UseSetting("ConnectionStrings:Default", database.AppConnectionString) + .ConfigureServices(services => + { + services.AddScoped(provider => new ControlledContentStore( + new TenantContentTypeStore(provider.GetRequiredService()), gate)); + services.AddScoped(provider => new ControlledTaxonomyStore( + new TenantLevelTaxonomyStore(provider.GetRequiredService()), gate)); + })); + async Task LosingAttempt() + { + await using var scope = host.Services.CreateAsyncScope(); + scope.ServiceProvider.GetRequiredService().Current = context; + var sender = scope.ServiceProvider.GetRequiredService(); + return taxonomy ? (await sender.Send(new PublishTenantLevelTaxonomyCommand(id, RequireNoIncumbent: true))).Error + : (await sender.Send(new PublishTenantContentTypeCommand(id, RequireNoIncumbent: true))).Error; + } + var loser = LosingAttempt(); + await gate.Entered.Task.WaitAsync(TimeSpan.FromSeconds(30)); + var winnerId = differentRevision ? Guid.CreateVersion7() : id; + var winnerVersion = initialVersion; + try + { + if (differentRevision) + { + await RegisterAsync(source, context, winnerId, taxonomy, "race-definition", version: 2); + winnerVersion = await VersionAsync(source, context, winnerId, taxonomy); + } + if (taxonomy) (await SendAsync(source, context, new PublishTenantLevelTaxonomyCommand(winnerId))).IsSuccess.Should().BeTrue(); + else (await SendAsync(source, context, new PublishTenantContentTypeCommand(winnerId))).IsSuccess.Should().BeTrue(); + } + finally { gate.Release.TrySetResult(); } + var error = await loser; + error.Should().NotBeNull(); + error!.Code.Should().Be(differentRevision ? "business_rule_violation" : "concurrency_conflict"); + if (differentRevision) error.Details!["Key"].Should().ContainSingle(reason => reason.Key == "lockey_customization_key_already_live"); + await AssertStateAsync(source, context, id, taxonomy, differentRevision ? "Draft" : "Active", initialVersion + (differentRevision ? 0 : 1)); + if (differentRevision) await AssertStateAsync(source, context, winnerId, taxonomy, "Active", winnerVersion + 1); + (await GenerationAsync(database, context.TenantId)).Should().Be(differentRevision ? 3 : 2, + "only real registration and winner publication bump generation; the loser never retires an Active row"); + await using var connection = await PostgresFixture.OpenAsync(database.PlatformConnectionString); + await using var audits = new NpgsqlCommand("SELECT count(*) FROM audit_log WHERE entity_id = @id AND operation IN ('customization.content_type.publish', 'customization.level_taxonomy.publish') AND outcome = 'success'", (NpgsqlConnection)connection); + audits.Parameters.AddWithValue("id", id.ToString()); + ((long)(await audits.ExecuteScalarAsync())!).Should().Be(differentRevision ? 0 : 1); + } + + [Theory] + [InlineData(false, false)] + [InlineData(false, true)] + [InlineData(true, false)] + [InlineData(true, true)] + public async Task An_absorbed_first_publication_save_failure_cannot_commit_the_dirty_successor_or_later_write(bool taxonomy, bool concurrency) + { + await using var database = await DisposableSchemaDatabase.CreateAsync(schema.Postgres); + await using var source = NpgsqlDataSource.Create(database.AppConnectionString); + var context = await ProvisionAsync(source); + var id = Guid.CreateVersion7(); + var later = Guid.CreateVersion7(); + await RegisterAsync(source, context, id, taxonomy, "first-definition"); + var initialVersion = await VersionAsync(source, context, id, taxonomy); + await using var host = factory.WithWebHostBuilder(builder => builder + .UseSetting("ConnectionStrings:Default", database.AppConnectionString) + .ConfigureServices(services => + { + services.AddScoped(provider => new ControlledContentStore( + new TenantContentTypeStore(provider.GetRequiredService()), failure: concurrency)); + services.AddScoped(provider => new ControlledTaxonomyStore( + new TenantLevelTaxonomyStore(provider.GetRequiredService()), failure: concurrency)); + services.AddTransient>, AbsorbingPublicationHandler>(); + services.AddSingleton(); + })); + async Task> Outer() + { + await using var scope = host.Services.CreateAsyncScope(); + scope.ServiceProvider.GetRequiredService().Current = context; + return await scope.ServiceProvider.GetRequiredService().Send(new AbsorbingPublicationCommand(id, later, taxonomy)); + } + (await ((Func>>)Outer).Should().ThrowAsync()).WithMessage("*rollback-only*"); + await AssertStateAsync(source, context, id, taxonomy, "Draft", initialVersion); + if (taxonomy) (await SendAsync(source, context, new GetTaxonomySeedStateQuery(later))).Value!.State.Should().BeNull(); + else (await SendAsync(source, context, new GetContentTypeSeedStateQuery(later))).Value!.State.Should().BeNull(); + (await GenerationAsync(database, context.TenantId)).Should().Be(1); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task Replacement_publication_rolls_back_both_revisions_and_generation_when_must_audit_fails(bool taxonomy) + { + await using var database = await DisposableSchemaDatabase.CreateAsync(schema.Postgres); + await using var source = NpgsqlDataSource.Create(database.AppConnectionString); + var context = await ProvisionAsync(source); + var incumbent = Guid.CreateVersion7(); + var successor = Guid.CreateVersion7(); + await RegisterAsync(source, context, incumbent, taxonomy, "audit-replacement"); + if (taxonomy) (await SendAsync(source, context, new PublishTenantLevelTaxonomyCommand(incumbent))).IsSuccess.Should().BeTrue(); + else (await SendAsync(source, context, new PublishTenantContentTypeCommand(incumbent))).IsSuccess.Should().BeTrue(); + await RegisterAsync(source, context, successor, taxonomy, "audit-replacement", version: 2); + var incumbentVersion = await VersionAsync(source, context, incumbent, taxonomy); + var successorVersion = await VersionAsync(source, context, successor, taxonomy); + var generation = await GenerationAsync(database, context.TenantId); + await using (var owner = await PostgresFixture.OpenAsync(database.MigrationConnectionString)) + { + await using var revoke = new NpgsqlCommand("REVOKE INSERT ON audit_log FROM learnstack_app", (NpgsqlConnection)owner); + await revoke.ExecuteNonQueryAsync(); + } + + async Task Publish() + { + if (taxonomy) await SendAsync(source, context, new PublishTenantLevelTaxonomyCommand(successor)); + else await SendAsync(source, context, new PublishTenantContentTypeCommand(successor)); + } + (await ((Func)Publish).Should().ThrowAsync()).Which.Error.Code.Should().Be("audit_unavailable"); + await AssertStateAsync(source, context, incumbent, taxonomy, "Active", incumbentVersion); + await AssertStateAsync(source, context, successor, taxonomy, "Draft", successorVersion); + (await GenerationAsync(database, context.TenantId)).Should().Be(generation); + await using var platform = await PostgresFixture.OpenAsync(database.PlatformConnectionString); + await using var audit = new NpgsqlCommand("SELECT count(*) FROM audit_log WHERE entity_id = @id AND outcome = 'success'", (NpgsqlConnection)platform); + audit.Parameters.AddWithValue("id", successor.ToString()); + ((long)(await audit.ExecuteScalarAsync())!).Should().Be(1, "only the earlier registration committed, not publication"); + } + + private static async Task ProvisionAsync(NpgsqlDataSource source) + { + var tenant = TenantId.From(Guid.CreateVersion7()); + (await SendAsync(source, null, new ProvisionTenantCommand(tenant, "publication-proof", "Publication proof", + OrganizationId.From(Guid.CreateVersion7()), "main", "Main"))).IsSuccess.Should().BeTrue(); + return new(tenant, null); + } + private static async Task> SendAsync(NpgsqlDataSource source, ITenantContext? context, IRequest> command) + { + await using var provider = SeedComposition.Build(source, context, NullLoggerFactory.Instance); + await using var scope = provider.CreateAsyncScope(); + return await scope.ServiceProvider.GetRequiredService().Send(command); + } + private static async Task RegisterAsync(NpgsqlDataSource source, ITenantContext context, Guid id, bool taxonomy, string key, int version = 1) + { + if (taxonomy) (await SendAsync(source, context, Taxonomy(id, key, version))).IsSuccess.Should().BeTrue(); + else (await SendAsync(source, context, ContentType(id, key, version))).IsSuccess.Should().BeTrue(); + } + private static RegisterTenantContentTypeCommand ContentType(Guid id, string key, int version = 1) => new(id, key, version, + new Dictionary { ["en"] = "Definition" }, BuiltInCustomizations.Card.JsonSchema, BuiltInCustomizations.Card.RendererKey); + private static RegisterTenantLevelTaxonomyCommand Taxonomy(Guid id, string key, int version = 1) => new(id, key, version, + new Dictionary { ["en"] = "Definition" }, [new("basic", new Dictionary { ["en"] = "Basic" }, 0)]); + private static async Task AssertStateAsync(NpgsqlDataSource source, ITenantContext context, Guid id, bool taxonomy, string status, long version) + { + if (taxonomy) (await SendAsync(source, context, new GetTaxonomySeedStateQuery(id))).Value!.State!.Status.Should().Be(status); + else (await SendAsync(source, context, new GetContentTypeSeedStateQuery(id))).Value!.State!.Status.Should().Be(status); + (await VersionAsync(source, context, id, taxonomy)).Should().Be(version); + } + private static async Task VersionAsync(NpgsqlDataSource source, ITenantContext context, Guid id, bool taxonomy) + { + await using var provider = SeedComposition.Build(source, context, NullLoggerFactory.Instance); + await using var scope = provider.CreateAsyncScope(); + var unit = scope.ServiceProvider.GetRequiredService(); + await using var frame = await unit.BeginTransactionAsync(); + await unit.SetTenantContextAsync(context); + var table = taxonomy ? "tenant_level_taxonomies" : "tenant_content_types"; + await using var query = new NpgsqlCommand($"SELECT row_version FROM {table} WHERE id = @id", (NpgsqlConnection)unit.Connection, (NpgsqlTransaction)unit.Transaction!); + query.Parameters.AddWithValue("id", id); + var version = (long)(await query.ExecuteScalarAsync())!; + await frame.FailAsync(); + return version; + } + private static async Task GenerationAsync(DisposableSchemaDatabase database, TenantId tenant) + { + await using var connection = await PostgresFixture.OpenAsync(database.PlatformConnectionString); + await using var query = new NpgsqlCommand("SELECT generation FROM customization_generations WHERE tenant_id = @tenant", (NpgsqlConnection)connection); + query.Parameters.AddWithValue("tenant", tenant.Value); + return (long)(await query.ExecuteScalarAsync())!; + } + private sealed class ActiveReadGate + { + internal TaskCompletionSource Entered { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); + internal TaskCompletionSource Release { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); + internal async Task PauseAsync(CancellationToken ct) + { + Entered.TrySetResult(); + await Release.Task.WaitAsync(ct); + } + } + private static void Fail(bool concurrency, bool taxonomy) + { + if (concurrency) throw new AggregateConcurrencyException("Injected after real save."); + throw new AggregateConflictException("Injected after real save.", taxonomy ? "ux_tenant_level_taxonomies_tenant_id_key_active" : "ux_tenant_content_types_tenant_id_key_active"); + } + private sealed class ControlledContentStore(ITenantContentTypeStore inner, ActiveReadGate? gate = null, bool? failure = null) : ITenantContentTypeStore + { + public Task FindAsync(TenantContentTypeId id, CancellationToken ct = default) => inner.FindAsync(id, ct); + public async Task FindActiveAsync(string key, CancellationToken ct = default) + { + if (gate is not null) await gate.PauseAsync(ct); + return await inner.FindActiveAsync(key, ct); + } + public Task AddAsync(TenantContentType root, CancellationToken ct = default) => inner.AddAsync(root, ct); + public async Task UpdateAsync(TenantContentType root, CancellationToken ct = default) + { + await inner.UpdateAsync(root, ct); + if (failure is { } concurrency) Fail(concurrency, taxonomy: false); + } + } + private sealed class ControlledTaxonomyStore(ITenantLevelTaxonomyStore inner, ActiveReadGate? gate = null, bool? failure = null) : ITenantLevelTaxonomyStore + { + public Task FindAsync(TenantLevelTaxonomyId id, CancellationToken ct = default) => inner.FindAsync(id, ct); + public async Task FindActiveAsync(string key, CancellationToken ct = default) + { + if (gate is not null) await gate.PauseAsync(ct); + return await inner.FindActiveAsync(key, ct); + } + public Task AddAsync(TenantLevelTaxonomy root, CancellationToken ct = default) => inner.AddAsync(root, ct); + public async Task UpdateAsync(TenantLevelTaxonomy root, CancellationToken ct = default) + { + await inner.UpdateAsync(root, ct); + if (failure is { } concurrency) Fail(concurrency, taxonomy: true); + } + } + public sealed record AbsorbingPublicationCommand(Guid Id, Guid LaterId, bool Taxonomy) : IRequest>; + private sealed class AbsorbingPublicationHandler(ISender sender) : IRequestHandler> + { + public async Task> Handle(AbsorbingPublicationCommand request, CancellationToken ct) + { + if (request.Taxonomy) + { + (await sender.Send(new PublishTenantLevelTaxonomyCommand(request.Id), ct)).IsFailure.Should().BeTrue(); + (await sender.Send(Taxonomy(request.LaterId, "later-definition"), ct)).IsSuccess.Should().BeTrue(); + } + else + { + (await sender.Send(new PublishTenantContentTypeCommand(request.Id), ct)).IsFailure.Should().BeTrue(); + (await sender.Send(ContentType(request.LaterId, "later-definition"), ct)).IsSuccess.Should().BeTrue(); + } + return Result.Ok(None.Value); + } + } + private sealed class OuterAuditSource : IAuditCatalogSource + { + public string ModuleName => "test"; + public void Describe(IAuditCatalogBuilder builder) => builder.Off(); + } +} diff --git a/backend/tests/LearnStack.Tests.Integration/Database/EducationPersistenceTests.cs b/backend/tests/LearnStack.Tests.Integration/Database/EducationPersistenceTests.cs index be614c27..6f9dda06 100644 --- a/backend/tests/LearnStack.Tests.Integration/Database/EducationPersistenceTests.cs +++ b/backend/tests/LearnStack.Tests.Integration/Database/EducationPersistenceTests.cs @@ -76,7 +76,8 @@ public async Task PersistedGraphs_RoundTripPinsTranslationsAndContainedAuditWhil await using (var creating = await Operation.OpenAsync(provider)) { var course = Course.Create(CourseId, TenantId.From(SchemaFixture.TenantA), organization, - "persisted-course", Clock, Actor, "difficulty", 7, "intro"); + "persisted-course", organizationScoped ? CourseContentAccess.EnrollmentRequired : CourseContentAccess.Public, + Clock, Actor, "difficulty", 7, "intro"); course.AddTranslation("EN-us", "Course 日本語", null, "course-en", Clock, Actor).IsSuccess.Should().BeTrue(); creating.Context.Courses.Add(course); await creating.Context.SaveChangesAsync(); @@ -155,6 +156,7 @@ public async Task PersistedGraphs_RoundTripPinsTranslationsAndContainedAuditWhil storedCourse.LevelBandKey.Should().Be("intro"); storedCourse.Version.Should().Be(3); storedCourse.Status.Should().Be(PublicationStatus.Published); + storedCourse.ContentAccess.Should().Be(organizationScoped ? CourseContentAccess.EnrollmentRequired : CourseContentAccess.Public); storedCourse.CreatedAt.Should().Be(Clock.UtcNow); storedCourse.UpdatedAt.Should().Be(Later.UtcNow); storedCourse.Translations.Select(translation => translation.Locale).Should().BeEquivalentTo("en-US", "fr"); @@ -234,7 +236,7 @@ private static async Task CreateRootsAsync(ServiceProvider provider, bool lesson await using (var creating = await Operation.OpenAsync(provider)) { creating.Context.Courses.Add(Course.Create(CourseId, TenantId.From(SchemaFixture.TenantA), null, - "concurrency", Clock, Actor)); + "concurrency", CourseContentAccess.EnrollmentRequired, Clock, Actor)); await creating.Context.SaveChangesAsync(); await creating.Frame.CompleteAsync(); } diff --git a/backend/tests/LearnStack.Tests.Integration/Database/EducationWriterTests.cs b/backend/tests/LearnStack.Tests.Integration/Database/EducationWriterTests.cs new file mode 100644 index 00000000..6be92811 --- /dev/null +++ b/backend/tests/LearnStack.Tests.Integration/Database/EducationWriterTests.cs @@ -0,0 +1,604 @@ +using System.Text.Json; +using FluentAssertions; +using LearnStack.Modules.Customization.Application.Contracts.Customization; +using LearnStack.Modules.Education.Application.Abstractions; +using LearnStack.Modules.Education.Application.Contracts.Courses; +using LearnStack.Modules.Education.Application.Contracts.Lessons; +using LearnStack.Modules.Education.Application.Contracts.Seeding; +using LearnStack.Modules.Education.Domain; +using LearnStack.Modules.Education.Infrastructure.Persistence; +using LearnStack.Modules.Tenancy.Application.Contracts.Locales; +using LearnStack.Modules.Tenancy.Application.Contracts.Seeding; +using LearnStack.Modules.Tenancy.Application.Contracts.Tenant; +using LearnStack.SharedKernel.Audit; +using LearnStack.SharedKernel.Identifiers; +using LearnStack.SharedKernel.Persistence; +using LearnStack.SharedKernel.Results; +using LearnStack.SharedKernel.Tenancy; +using LearnStack.SharedKernel.Validation; +using LearnStack.Tools.Seeder; +using MediatR; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging.Abstractions; +using Npgsql; +using Xunit; + +namespace LearnStack.Tests.Integration.Database; + +[Trait(RequiresDocker.Key, RequiresDocker.Value)] +[Collection(SharedSchema.Name)] +public sealed class EducationWriterTests(SchemaFixture schema, WebApplicationFactory factory) + : IClassFixture> +{ + private const string StringSchema = """{"$schema":"https://json-schema.org/draft/2020-12/schema","type":"object","properties":{"text":{"type":"string"}},"required":["text"],"additionalProperties":false}"""; + private const string NumberSchema = """{"$schema":"https://json-schema.org/draft/2020-12/schema","type":"object","properties":{"text":{"type":"number"}},"required":["text"],"additionalProperties":false}"""; + private static readonly string[] ExpectedOperations = ["education.course.create", "education.course.translation_add", "education.course.publish", "education.lesson.create", "education.lesson.translation_add", "education.lesson.publish"]; + private const string Body = """{"text":"A lesson"}"""; + + [Fact] + public async Task Composed_writer_bounds_instances_before_lookup_and_preserves_state_on_oversize() + { + await using var database = await DisposableSchemaDatabase.CreateAsync(schema.Postgres); + await using var source = NpgsqlDataSource.Create(database.AppConnectionString); + var context = await ProvisionAsync(source); + await TypeAsync(source, context, 1, StringSchema); + var course = await CourseAsync(source, context, "bounded"); + var lesson = await LessonAsync(source, context, course.Id); + var before = await LessonStateAsync(source, context, lesson.Id); + var auditCount = await CountAuditsAsync(source, context, "education.lesson.translation_add", onlySuccessful: false); + var body = "{\"text\":\"" + new string('a', JsonInstanceLimits.MaxBytes - 11) + "\"}"; + System.Text.Encoding.UTF8.GetByteCount(body).Should().Be(JsonInstanceLimits.MaxBytes); + var command = new AddLessonTranslationCommand(lesson.Id, lesson.Version, "en", "Bounded", "bounded", body); + foreach (var target in new[] { lesson.Id, Guid.CreateVersion7() }) + { + var refused = await SendAsync(source, context, command with { LessonId = target, Body = body + " " }); + refused.Error!.Code.Should().Be("validation_failed"); + refused.Error.Details.Should().ContainKey("Body", "size admission runs before even a missing-root lookup"); + } + (await LessonStateAsync(source, context, lesson.Id)).Should().BeEquivalentTo(before); + (await CountAuditsAsync(source, context, "education.lesson.translation_add", onlySuccessful: false)).Should().Be(auditCount); + (await SendAsync(source, context, command)).IsSuccess.Should().BeTrue("the inclusive instance boundary must still pass the composed schema writer"); + } + + [Fact] + public async Task Six_commands_preserve_independent_roots_explicit_policy_and_contained_audit_subjects() + { + await using var database = await DisposableSchemaDatabase.CreateAsync(schema.Postgres); + await using var source = NpgsqlDataSource.Create(database.AppConnectionString); + var context = await ProvisionAsync(source); + await TypeAsync(source, context, 1, StringSchema); + var course = await CourseAsync(source, context, "course", "enrollment_required"); + var translated = await SendAsync(source, context, new AddCourseTranslationCommand(course.Id, course.Version, "EN", "Course", "Summary", "translated-course")); + translated.IsSuccess.Should().BeTrue(); + var parentBefore = await CourseStateAsync(source, context, course.Id); + var lesson = await LessonAsync(source, context, course.Id); + var lessonTranslation = await SendAsync(source, context, new AddLessonTranslationCommand(lesson.Id, lesson.Version, "EN", "Lesson", "translated-lesson", Body)); + lessonTranslation.IsSuccess.Should().BeTrue(); + var publishedLesson = await SendAsync(source, context, new PublishLessonCommand(lesson.Id, lessonTranslation.Value!.Version)); + publishedLesson.Value!.Status.Should().Be("Published"); + (await CourseStateAsync(source, context, course.Id)).Should().BeEquivalentTo(parentBefore); + var publishedCourse = await SendAsync(source, context, new PublishCourseCommand(course.Id, translated.Value!.Version)); + publishedCourse.Value!.ContentAccess.Should().Be("enrollment_required"); + (await LessonStateAsync(source, context, lesson.Id)).Version.Should().Be(publishedLesson.Value.Version); + var courseFinal = await CourseStateAsync(source, context, course.Id); + courseFinal.Translations.Should().ContainSingle().Which.Locale.Should().Be("en"); + var refused = await SendAsync(source, context, new AddCourseTranslationCommand(course.Id, courseFinal.Version, "en", "Changed", null, "changed")); + refused.Error!.Code.Should().Be("business_rule_violation"); + (await CourseStateAsync(source, context, course.Id)).Should().BeEquivalentTo(courseFinal); + await AssertAuditAsync(source, context, course.Id, lesson.Id); + } + + [Fact] + public async Task Exact_active_bindings_and_existing_deprecated_pins_use_their_own_schema() + { + await using var database = await DisposableSchemaDatabase.CreateAsync(schema.Postgres); + await using var source = NpgsqlDataSource.Create(database.AppConnectionString); + var context = await ProvisionAsync(source); + var taxonomyId = Guid.CreateVersion7(); + (await SendAsync(source, context, new RegisterTenantLevelTaxonomyCommand(taxonomyId, "levels", 1, + new Dictionary { ["en"] = "Levels" }, [new TaxonomyItemInput("basic", new Dictionary { ["en"] = "Basic" }, 0)]))).IsSuccess.Should().BeTrue(); + (await SendAsync(source, context, new PublishTenantLevelTaxonomyCommand(taxonomyId))).IsSuccess.Should().BeTrue(); + var invalidBand = await SendAsync(source, context, new CreateCourseCommand(Guid.CreateVersion7(), "wrong-band", "public", "levels", 1, "absent")); + invalidBand.Error!.Code.Should().Be("validation_failed"); + var missingRevision = await SendAsync(source, context, new CreateCourseCommand(Guid.CreateVersion7(), "wrong-revision", "public", "levels", 2, "basic")); + missingRevision.Error!.Code.Should().Be("validation_failed"); + var course = await SendAsync(source, context, new CreateCourseCommand(Guid.CreateVersion7(), "pinned", "public", "levels", 1, "basic")); + course.IsSuccess.Should().BeTrue(); + await TypeAsync(source, context, 1, StringSchema); + var oldLesson = await LessonAsync(source, context, course.Value!.Id); + var oldBefore = await LessonStateAsync(source, context, oldLesson.Id); + (await SendAsync(source, context, new AddLessonTranslationCommand(oldLesson.Id, 0, "fr", "Absent", "absent", Body))) + .Error!.Code.Should().Be("validation_failed"); + (await LessonStateAsync(source, context, oldLesson.Id)).Should().BeEquivalentTo(oldBefore); + await TypeAsync(source, context, 2, NumberSchema); + var refusedNew = await SendAsync(source, context, new CreateLessonCommand(Guid.CreateVersion7(), course.Value.Id, 1, "shape", 1)); + refusedNew.Error!.Code.Should().Be("validation_failed"); + var oldBody = await SendAsync(source, context, new AddLessonTranslationCommand(oldLesson.Id, 0, "en", "Old", "old", Body)); + oldBody.IsSuccess.Should().BeTrue("an existing pin remains eligible after deprecation and never picks v2"); + var oldAfter = await LessonStateAsync(source, context, oldLesson.Id); + (await SendAsync(source, context, new PublishLessonCommand(oldLesson.Id, 0))).Error!.Code.Should().Be("concurrency_conflict"); + (await SendAsync(source, context, new AddLessonTranslationCommand(oldLesson.Id, oldAfter.Version, "EN", "Duplicate", "duplicate", Body))) + .Error!.Code.Should().Be("business_rule_violation"); + (await LessonStateAsync(source, context, oldLesson.Id)).Should().BeEquivalentTo(oldAfter); + var newLesson = await LessonAsync(source, context, course.Value.Id, version: 2); + var before = await LessonStateAsync(source, context, newLesson.Id); + var wrongBody = await SendAsync(source, context, new AddLessonTranslationCommand(newLesson.Id, before.Version, "en", "New", "new", Body)); + wrongBody.Error!.Code.Should().Be("validation_failed"); + wrongBody.Error.Details.Should().ContainKey("/text"); + wrongBody.Error.Details!.Count.Should().BeLessThanOrEqualTo(25); + (await LessonStateAsync(source, context, newLesson.Id)).Should().BeEquivalentTo(before); + var numberBody = await SendAsync(source, context, new AddLessonTranslationCommand(newLesson.Id, before.Version, "en", "New", "new", """{"text":7}""")); + numberBody.IsSuccess.Should().BeTrue("writers admit all approved schemas, independently of the text-card rendering subset"); + } + + [Fact] + public async Task Hidden_roots_are_not_found_and_visible_incompatible_scope_is_refused_before_mutation() + { + await using var database = await DisposableSchemaDatabase.CreateAsync(schema.Postgres); + await using var source = NpgsqlDataSource.Create(database.AppConnectionString); + var context = await ProvisionAsync(source); + var foreign = await ProvisionAsync(source); + await TypeAsync(source, context, 1, StringSchema); + var wide = await CourseAsync(source, context, "wide"); + var orgA = context with { Organization = context.FirstOrganization }; + var orgB = context with { Organization = context.SecondOrganization }; + var scoped = await CourseAsync(source, orgA, "scoped"); + var foreignCourse = await CourseAsync(source, foreign, "foreign"); + (await SendAsync(source, orgA, new CreateLessonCommand(Guid.CreateVersion7(), wide.Id, 0, "shape", 1))) + .Error!.Code.Should().Be("resource_scope_violation"); + (await SendAsync(source, orgA, new PublishCourseCommand(wide.Id, wide.Version))).Error!.Code.Should().Be("resource_scope_violation"); + (await SendAsync(source, orgA, new AddCourseTranslationCommand(wide.Id, wide.Version, "en", "Wide", null, "wide"))) + .Error!.Code.Should().Be("resource_scope_violation"); + var wideLesson = await LessonAsync(source, context, wide.Id); + var wideLessonBefore = await LessonStateAsync(source, context, wideLesson.Id); + (await SendAsync(source, orgA, new AddLessonTranslationCommand(wideLesson.Id, wideLesson.Version, "en", "Wide", "wide", Body))) + .Error!.Code.Should().Be("resource_scope_violation"); + (await SendAsync(source, orgA, new PublishLessonCommand(wideLesson.Id, wideLesson.Version))) + .Error!.Code.Should().Be("resource_scope_violation"); + foreach (var hidden in new[] { scoped.Id, foreignCourse.Id, Guid.CreateVersion7() }) + { + (await SendAsync(source, orgB, new CreateLessonCommand(Guid.CreateVersion7(), hidden, 0, "shape", 1))) + .Error!.Code.Should().Be("not_found"); + (await SendAsync(source, orgB, new PublishCourseCommand(hidden, 0))).Error!.Code.Should().Be("not_found"); + (await SendAsync(source, orgB, new AddCourseTranslationCommand(hidden, 0, "en", "Hidden", null, "hidden"))) + .Error!.Code.Should().Be("not_found"); + } + (await SendAsync(source, context, new PublishCourseCommand(scoped.Id, 0))).Error!.Code.Should().Be("not_found"); + var child = await LessonAsync(source, orgA, scoped.Id); + var state = await LessonStateAsync(source, orgA, child.Id); + state.OrganizationId.Should().Be(orgA.OrganizationId); + state.TenantId.Should().Be(context.TenantId); + (await SendAsync(source, orgB, new PublishLessonCommand(child.Id, 0))).Error!.Code.Should().Be("not_found"); + (await SendAsync(source, context, new PublishLessonCommand(child.Id, 0))).Error!.Code.Should().Be("not_found"); + (await SendAsync(source, orgB, new AddLessonTranslationCommand(child.Id, 0, "en", "Hidden", "hidden", Body))).Error!.Code.Should().Be("not_found"); + (await SendAsync(source, context, new AddLessonTranslationCommand(child.Id, 0, "en", "Scoped", "scoped", Body))) + .Error!.Code.Should().Be("not_found"); + // Reverse the foreign-root case: every existing-root writer also refuses a + // local identity when the trusted current context belongs to the other tenant. + (await SendAsync(source, foreign, new CreateLessonCommand(Guid.CreateVersion7(), wide.Id, 0, "shape", 1))).Error!.Code.Should().Be("not_found"); + (await SendAsync(source, foreign, new PublishCourseCommand(wide.Id, 0))).Error!.Code.Should().Be("not_found"); + (await SendAsync(source, foreign, new AddCourseTranslationCommand(wide.Id, 0, "en", "Hidden", null, "hidden"))).Error!.Code.Should().Be("not_found"); + (await SendAsync(source, foreign, new PublishLessonCommand(wideLesson.Id, 0))).Error!.Code.Should().Be("not_found"); + (await SendAsync(source, foreign, new AddLessonTranslationCommand(wideLesson.Id, 0, "en", "Hidden", "hidden", Body))).Error!.Code.Should().Be("not_found"); + (await CourseStateAsync(source, context, wide.Id)).Version.Should().Be(0); + (await CourseStateAsync(source, orgA, scoped.Id)).Version.Should().Be(0); + (await LessonStateAsync(source, context, wideLesson.Id)).Should().BeEquivalentTo(wideLessonBefore); + (await LessonStateAsync(source, orgA, child.Id)).Should().BeEquivalentTo(state); + } + + [Fact] + public async Task Locale_slug_lifecycle_and_stale_refusals_preserve_the_root_and_reserve_slugs_on_insert() + { + await using var database = await DisposableSchemaDatabase.CreateAsync(schema.Postgres); + await using var source = NpgsqlDataSource.Create(database.AppConnectionString); + var context = await ProvisionAsync(source); + var foreign = await ProvisionAsync(source); + var tenant = (await SendAsync(source, context, new GetTenantSeedStateQuery())).Value!.State!; + (await SendAsync(source, context, new AddTenantLocaleCommand(tenant.Version, "fr", false, false, 1))).IsSuccess.Should().BeTrue(); + var first = await CourseAsync(source, context, "first"); + var second = await CourseAsync(source, context, "second"); + var before = await CourseStateAsync(source, context, first.Id); + (await SendAsync(source, context, new AddCourseTranslationCommand(first.Id, 0, "fr", "Disabled", null, "disabled"))) + .Error!.Code.Should().Be("validation_failed"); + (await SendAsync(source, context, new AddCourseTranslationCommand(first.Id, 0, "de", "Absent", null, "absent"))) + .Error!.Code.Should().Be("validation_failed"); + (await CourseStateAsync(source, context, first.Id)).Should().BeEquivalentTo(before); + var translated = await SendAsync(source, context, new AddCourseTranslationCommand(first.Id, 0, "en", "Title", null, "reserved")); + translated.IsSuccess.Should().BeTrue(); + (await SendAsync(source, context, new AddCourseTranslationCommand(second.Id, 0, "en", "Collision", null, "reserved"))) + .Error!.Code.Should().Be("business_rule_violation"); + (await CourseStateAsync(source, context, second.Id)).Version.Should().Be(0); + (await SendAsync(source, context, new CreateCourseCommand(Guid.CreateVersion7(), "first", "public"))) + .Error!.Code.Should().Be("business_rule_violation"); + var after = await CourseStateAsync(source, context, first.Id); + (await SendAsync(source, context, new PublishCourseCommand(first.Id, 0))).Error!.Code.Should().Be("concurrency_conflict"); + (await SendAsync(source, context, new AddCourseTranslationCommand(first.Id, after.Version, "EN", "Duplicate", null, "other"))) + .Error!.Code.Should().Be("business_rule_violation"); + (await CourseStateAsync(source, context, first.Id)).Should().BeEquivalentTo(after); + var foreignRoot = await CourseAsync(source, foreign, "first"); + (await SendAsync(source, foreign, new AddCourseTranslationCommand(foreignRoot.Id, 0, "en", "Foreign", null, "reserved"))) + .IsSuccess.Should().BeTrue("slug uniqueness is tenant-local"); + (await CountAuditsAsync(source, context, "education.course.translation_add")).Should().Be(1); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task Publication_requires_durable_must_audit_and_rolls_back_on_audit_failure(bool lessonPublication) + { + await using var database = await DisposableSchemaDatabase.CreateAsync(schema.Postgres); + await using var source = NpgsqlDataSource.Create(database.AppConnectionString); + var context = await ProvisionAsync(source); + await TypeAsync(source, context, 1, StringSchema); + var course = await CourseAsync(source, context, "course"); + var lesson = await LessonAsync(source, context, course.Id); + await OwnerAsync(database, "REVOKE INSERT ON audit_log FROM learnstack_app"); + Func publish = lessonPublication + ? async () => { await SendAsync(source, context, new PublishLessonCommand(lesson.Id, 0)); } + : async () => { await SendAsync(source, context, new PublishCourseCommand(course.Id, 0)); }; + (await publish.Should().ThrowAsync()).Which.Error.Code.Should().Be("audit_unavailable"); + (await CourseStateAsync(source, context, course.Id)).Status.Should().Be("Draft"); + (await CourseStateAsync(source, context, course.Id)).Version.Should().Be(0); + (await LessonStateAsync(source, context, lesson.Id)).Status.Should().Be("Draft"); + (await LessonStateAsync(source, context, lesson.Id)).Version.Should().Be(0); + (await CountAuditsAsync(source, context, lessonPublication ? "education.lesson.publish" : "education.course.publish")).Should().Be(0); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task Slug_diagnostics_identify_only_visible_conflicting_roots(bool lessonTranslation) + { + await using var database = await DisposableSchemaDatabase.CreateAsync(schema.Postgres); + await using var source = NpgsqlDataSource.Create(database.AppConnectionString); + var context = await ProvisionAsync(source); + await TypeAsync(source, context, 1, StringSchema); + var orgA = context with { Organization = context.FirstOrganization }; + var orgB = context with { Organization = context.SecondOrganization }; + var courseA = await CourseAsync(source, orgA, "a"); + var courseB = await CourseAsync(source, orgB, "b"); + var courseC = await CourseAsync(source, orgA, "c"); + var first = lessonTranslation ? (await LessonAsync(source, orgB, courseB.Id)).Id : courseB.Id; + var target = lessonTranslation ? (await LessonAsync(source, orgA, courseA.Id)).Id : courseA.Id; + var visibleTarget = lessonTranslation ? (await LessonAsync(source, orgA, courseC.Id)).Id : courseC.Id; + async Task Translate(Context owner, Guid id, string slug) + { + if (lessonTranslation) + return (await SendAsync(source, owner, new AddLessonTranslationCommand(id, 0, "EN", "Title", slug, Body))).Error; + return (await SendAsync(source, owner, new AddCourseTranslationCommand(id, 0, "EN", "Title", null, slug))).Error; + } + (await Translate(orgB, first, "hidden-slug")).Should().BeNull(); + var hidden = (await Translate(orgA, target, "hidden-slug"))!; + hidden.Code.Should().Be("business_rule_violation"); + hidden.Details!["Slug"].Single().Params.Should().BeEquivalentTo(new Dictionary + { + ["locale"] = "en", + ["slug"] = "hidden-slug", + }); + JsonSerializer.Serialize(hidden).Should().NotContain(first.ToString()); + (await Translate(orgA, target, "visible-slug")).Should().BeNull(); + var visible = (await Translate(orgA, visibleTarget, "visible-slug"))!; + visible.Details!["Slug"].Single().Params!["entityId"].Should().Be(target.ToString()); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task Unknown_education_unique_constraints_remain_infrastructure_faults(bool lessonCreation) + { + await using var database = await DisposableSchemaDatabase.CreateAsync(schema.Postgres); + await using var source = NpgsqlDataSource.Create(database.AppConnectionString); + var context = await ProvisionAsync(source); + await TypeAsync(source, context, 1, StringSchema); + var course = await CourseAsync(source, context, "first"); + if (lessonCreation) await LessonAsync(source, context, course.Id); + await OwnerAsync(database, lessonCreation + ? "CREATE UNIQUE INDEX ux_test_unowned_education ON lessons(sort)" + : "CREATE UNIQUE INDEX ux_test_unowned_education ON courses(content_access)"); + var id = Guid.CreateVersion7(); + Func write = lessonCreation + ? async () => { await SendAsync(source, context, new CreateLessonCommand(id, course.Id, 0, "shape", 1)); } + : async () => { await SendAsync(source, context, new CreateCourseCommand(id, "second", "public")); }; + var fault = (await write.Should().ThrowAsync()).Which; + fault.InnerException.Should().BeOfType().Which.ConstraintName.Should().Be("ux_test_unowned_education"); + var problem = LearnStack.Api.Common.ProblemDetailsFactory.For(fault); + problem.Status.Should().Be(500); + problem.Extensions["code"].Should().Be("internal_error"); + if (lessonCreation) (await SendAsync(source, context, new GetLessonSeedStateQuery(id))).Value!.State.Should().BeNull(); + else (await SendAsync(source, context, new GetCourseSeedStateQuery(id))).Value!.State.Should().BeNull(); + } + + [Fact] + public async Task Concurrent_publication_at_one_version_has_one_winner_and_one_must_success_audit() + { + await using var database = await DisposableSchemaDatabase.CreateAsync(schema.Postgres); + await using var source = NpgsqlDataSource.Create(database.AppConnectionString); + var context = await ProvisionAsync(source); + var course = await CourseAsync(source, context, "course"); + var barrier = new ReadBarrier(); + await using var host = factory.WithWebHostBuilder(builder => builder + .UseSetting("ConnectionStrings:Default", database.AppConnectionString) + .ConfigureServices(services => services.AddScoped(provider => + new BarrierCourseStore(new CourseWriteStore(provider.GetRequiredService()), barrier)))); + async Task> Publish() + { + await using var scope = host.Services.CreateAsyncScope(); + scope.ServiceProvider.GetRequiredService().Current = context; + return await scope.ServiceProvider.GetRequiredService().Send(new PublishCourseCommand(course.Id, 0)); + } + var results = await Task.WhenAll(Publish(), Publish()); + results.Should().ContainSingle(result => result.IsSuccess); + results.Should().ContainSingle(result => result.IsFailure).Which.Error!.Code.Should().Be("concurrency_conflict"); + var state = await CourseStateAsync(source, context, course.Id); + state.Status.Should().Be("Published"); + state.Version.Should().Be(1); + (await CountAuditsAsync(source, context, "education.course.publish")).Should().Be(1); + } + + [Fact] + public async Task Concurrent_translation_inserts_reserve_one_tenant_slug_and_roll_back_the_loser_stamp() + { + await using var database = await DisposableSchemaDatabase.CreateAsync(schema.Postgres); + await using var source = NpgsqlDataSource.Create(database.AppConnectionString); + var context = await ProvisionAsync(source); + var first = await CourseAsync(source, context, "first"); + var second = await CourseAsync(source, context, "second"); + var barrier = new ReadBarrier(); + await using var host = factory.WithWebHostBuilder(builder => builder + .UseSetting("ConnectionStrings:Default", database.AppConnectionString) + .ConfigureServices(services => services.AddScoped(provider => + new BarrierCourseStore(new CourseWriteStore(provider.GetRequiredService()), barrier)))); + async Task> Translate(Guid id) + { + await using var scope = host.Services.CreateAsyncScope(); + scope.ServiceProvider.GetRequiredService().Current = context; + return await scope.ServiceProvider.GetRequiredService().Send(new AddCourseTranslationCommand(id, 0, "en", "Title", null, "reserved")); + } + var results = await Task.WhenAll(Translate(first.Id), Translate(second.Id)); + results.Should().ContainSingle(result => result.IsSuccess); + results.Should().ContainSingle(result => result.IsFailure).Which.Error!.Code.Should().Be("business_rule_violation"); + var states = new[] { await CourseStateAsync(source, context, first.Id), await CourseStateAsync(source, context, second.Id) }; + states.Should().ContainSingle(state => state.Version == 1 && state.Translations.Length == 1); + states.Should().ContainSingle(state => state.Version == 0 && state.Translations.Length == 0); + (await CountAuditsAsync(source, context, "education.course.translation_add")).Should().Be(1); + } + + [Theory] + [InlineData(false, false)] + [InlineData(false, true)] + [InlineData(true, false)] + [InlineData(true, true)] + public async Task An_outer_handler_absorbing_a_post_save_refusal_cannot_commit_the_dirty_or_later_root(bool concurrency, bool lessonPublication) + { + await using var database = await DisposableSchemaDatabase.CreateAsync(schema.Postgres); + await using var source = NpgsqlDataSource.Create(database.AppConnectionString); + var context = await ProvisionAsync(source); + var course = await CourseAsync(source, context, "course"); + await TypeAsync(source, context, 1, StringSchema); + var lesson = await LessonAsync(source, context, course.Id); + var failingId = lessonPublication ? lesson.Id : course.Id; + var later = Guid.CreateVersion7(); + await using var host = factory.WithWebHostBuilder(builder => builder + .UseSetting("ConnectionStrings:Default", database.AppConnectionString) + .ConfigureServices(services => + { + services.AddScoped(provider => new FailAfterSaveCourseStore( + new CourseWriteStore(provider.GetRequiredService()), course.Id, concurrency)); + services.AddScoped(provider => new FailAfterSaveLessonStore( + new LessonWriteStore(provider.GetRequiredService()), lesson.Id, concurrency)); + services.AddTransient>, AbsorbingOuterHandler>(); + services.AddSingleton(); + })); + await using (var scope = host.Services.CreateAsyncScope()) + { + scope.ServiceProvider.GetRequiredService().Current = context; + var send = async () => await scope.ServiceProvider.GetRequiredService().Send(new AbsorbingOuterCommand(failingId, later, lessonPublication, concurrency)); + await send.Should().ThrowAsync().WithMessage("*rollback-only*"); + } + var final = await CourseStateAsync(source, context, course.Id); + final.Version.Should().Be(0); + final.Status.Should().Be("Draft"); + var finalLesson = await LessonStateAsync(source, context, lesson.Id); + finalLesson.Version.Should().Be(0); + finalLesson.Status.Should().Be("Draft"); + (await SendAsync(source, context, new GetCourseSeedStateQuery(later))).Value!.State.Should().BeNull(); + (await CountAuditsAsync(source, context, "education.course.publish")).Should().Be(0); + (await CountAuditsAsync(source, context, "education.lesson.publish")).Should().Be(0); + (await CountAuditsAsync(source, context, "education.course.create")).Should().Be(1); + } + + private static async Task ProvisionAsync(NpgsqlDataSource source) + { + var tenant = TenantId.From(Guid.CreateVersion7()); + var first = OrganizationId.From(Guid.CreateVersion7()); + var second = OrganizationId.From(Guid.CreateVersion7()); + (await SendAsync(source, null, new ProvisionTenantCommand(tenant, "writer-" + tenant.Value.ToString("N"), "Writer", first, "first", "First"))).IsSuccess.Should().BeTrue(); + var context = new Context(tenant, first, second); + (await SendAsync(source, context, new CreateOrganizationCommand(second, "second", "Second"))).IsSuccess.Should().BeTrue(); + var state = (await SendAsync(source, context, new GetTenantSeedStateQuery())).Value!.State!; + (await SendAsync(source, context, new AddTenantLocaleCommand(state.Version, "en", true, true, 0))).IsSuccess.Should().BeTrue(); + return context; + } + + private static async Task TypeAsync(NpgsqlDataSource source, Context context, int version, string json) + { + var id = Guid.CreateVersion7(); + (await SendAsync(source, context, new RegisterTenantContentTypeCommand(id, "shape", version, + new Dictionary { ["en"] = "Shape" }, json, "default-card"))).IsSuccess.Should().BeTrue(); + (await SendAsync(source, context, new PublishTenantContentTypeCommand(id))).IsSuccess.Should().BeTrue(); + } + + private static async Task CourseAsync(NpgsqlDataSource source, Context context, string slug, string access = "public") + { + var result = await SendAsync(source, context, new CreateCourseCommand(Guid.CreateVersion7(), slug, access)); + result.IsSuccess.Should().BeTrue(); + return result.Value!; + } + + private static async Task LessonAsync(NpgsqlDataSource source, Context context, Guid course, int version = 1) + { + var result = await SendAsync(source, context, new CreateLessonCommand(Guid.CreateVersion7(), course, 0, "shape", version)); + result.IsSuccess.Should().BeTrue(); + return result.Value!; + } + + private static async Task> SendAsync(NpgsqlDataSource source, Context? context, IRequest> command) + { + await using var provider = SeedComposition.Build(source, context, NullLoggerFactory.Instance); + await using var scope = provider.CreateAsyncScope(); + return await scope.ServiceProvider.GetRequiredService().Send(command); + } + + private static async Task CourseStateAsync(NpgsqlDataSource source, Context context, Guid id) => + (await SendAsync(source, context, new GetCourseSeedStateQuery(id))).Value!.State!; + private static async Task LessonStateAsync(NpgsqlDataSource source, Context context, Guid id) => + (await SendAsync(source, context, new GetLessonSeedStateQuery(id))).Value!.State!; + + private static async Task CountAuditsAsync(NpgsqlDataSource source, Context context, string operation, bool onlySuccessful = true) + { + await using var provider = SeedComposition.Build(source, context, NullLoggerFactory.Instance); + await using var scope = provider.CreateAsyncScope(); + var unit = scope.ServiceProvider.GetRequiredService(); + await using var frame = await unit.BeginTransactionAsync(); + await unit.SetTenantContextAsync(context); + await using var command = new NpgsqlCommand("SELECT count(*) FROM audit_log WHERE operation = @operation AND (NOT @onlySuccessful OR outcome = 'success')", (NpgsqlConnection)unit.Connection, (NpgsqlTransaction)unit.Transaction!); + command.Parameters.AddWithValue("operation", operation); + command.Parameters.AddWithValue("onlySuccessful", onlySuccessful); + var count = (long)(await command.ExecuteScalarAsync())!; + await frame.FailAsync(); + return count; + } + + private static async Task AssertAuditAsync(NpgsqlDataSource source, Context context, Guid course, Guid lesson) + { + await using var provider = SeedComposition.Build(source, context, NullLoggerFactory.Instance); + await using var scope = provider.CreateAsyncScope(); + var unit = scope.ServiceProvider.GetRequiredService(); + await using var frame = await unit.BeginTransactionAsync(); + await unit.SetTenantContextAsync(context); + await using var command = new NpgsqlCommand(""" + SELECT operation, entity_type, entity_id, operation_class, before_state::text, after_state::text, changes::text + FROM audit_log WHERE operation LIKE 'education.%' AND outcome = 'success' + """, (NpgsqlConnection)unit.Connection, (NpgsqlTransaction)unit.Transaction!); + var operations = new List(); + await using (var rows = await command.ExecuteReaderAsync()) + { + while (await rows.ReadAsync()) + { + var operation = rows.GetString(0); + operations.Add(operation); + var type = operation.Contains(".course.", StringComparison.Ordinal) ? nameof(Course) : nameof(Lesson); + rows.GetString(1).Should().Be(type); + rows.GetString(2).Should().Be((type == nameof(Course) ? course : lesson).ToString()); + rows.GetString(3).Should().Be(operation.EndsWith(".publish", StringComparison.Ordinal) ? "Must" : "Should"); + using var after = JsonDocument.Parse(rows.GetString(5)); + after.RootElement.GetProperty("Status").GetString().Should().Be(operation.EndsWith(".publish", StringComparison.Ordinal) ? "published" : "draft"); + if (operation.EndsWith(".publish", StringComparison.Ordinal)) + { + using var before = JsonDocument.Parse(rows.GetString(4)); + before.RootElement.GetProperty("Status").GetString().Should().Be("draft"); + } + if (operation.EndsWith(".translation_add", StringComparison.Ordinal)) + { + rows.GetString(6).Should().Contain("/Translations/en/Title"); + } + } + } + operations.Should().BeEquivalentTo(ExpectedOperations); + await frame.FailAsync(); + } + + private static async Task OwnerAsync(DisposableSchemaDatabase database, string sql) + { + await using var connection = new NpgsqlConnection(database.MigrationConnectionString); + await connection.OpenAsync(); + await using var command = new NpgsqlCommand(sql, connection); + await command.ExecuteNonQueryAsync(); + } + + private sealed record Context(TenantId TenantId, OrganizationId FirstOrganization, OrganizationId SecondOrganization, + OrganizationId? Organization = null) : ITenantContext + { + public bool IsResolved => true; + public OrganizationId? OrganizationId => Organization; + public UserId? UserId => null; + public TenantContextOrigin? Origin => TenantContextOrigin.Ambient; + public string? CorrelationId => null; + public string? ModuleName => null; + } + private sealed class ReadBarrier + { + private readonly TaskCompletionSource _both = new(TaskCreationOptions.RunContinuationsAsynchronously); + private int _readers; + public async Task WaitAsync(CancellationToken cancellationToken) + { + if (Interlocked.Increment(ref _readers) == 2) _both.TrySetResult(); + await _both.Task.WaitAsync(TimeSpan.FromSeconds(20), cancellationToken); + } + } + private sealed class BarrierCourseStore(ICourseWriteStore inner, ReadBarrier barrier) : ICourseWriteStore + { + public async Task FindAsync(CourseId id, CancellationToken cancellationToken) + { + var root = await inner.FindAsync(id, cancellationToken); + await barrier.WaitAsync(cancellationToken); + return root; + } + public Task AddAsync(Course aggregate, CancellationToken cancellationToken = default) => inner.AddAsync(aggregate, cancellationToken); + public Task UpdateAsync(Course aggregate, CancellationToken cancellationToken = default) => inner.UpdateAsync(aggregate, cancellationToken); + } + private sealed class FailAfterSaveCourseStore(ICourseWriteStore inner, Guid failingId, bool concurrency) : ICourseWriteStore + { + public Task FindAsync(CourseId id, CancellationToken cancellationToken) => inner.FindAsync(id, cancellationToken); + public Task AddAsync(Course aggregate, CancellationToken cancellationToken = default) => inner.AddAsync(aggregate, cancellationToken); + public async Task UpdateAsync(Course aggregate, CancellationToken cancellationToken = default) + { + await inner.UpdateAsync(aggregate, cancellationToken); + if (aggregate.Id.Value == failingId) + { + if (concurrency) throw new AggregateConcurrencyException("injected post-save refusal"); + throw new AggregateConflictException("injected post-save refusal", "pk_courses"); + } + } + } + private sealed class FailAfterSaveLessonStore(ILessonWriteStore inner, Guid failingId, bool concurrency) : ILessonWriteStore + { + public Task FindAsync(LessonId id, CancellationToken cancellationToken) => inner.FindAsync(id, cancellationToken); + public Task AddAsync(Lesson aggregate, CancellationToken cancellationToken = default) => inner.AddAsync(aggregate, cancellationToken); + public async Task UpdateAsync(Lesson aggregate, CancellationToken cancellationToken = default) + { + await inner.UpdateAsync(aggregate, cancellationToken); + if (aggregate.Id.Value == failingId) + { + if (concurrency) throw new AggregateConcurrencyException("injected post-save refusal"); + throw new AggregateConflictException("injected post-save refusal", "pk_lessons"); + } + } + } + private sealed record AbsorbingOuterCommand(Guid FailingId, Guid LaterId, bool LessonPublication, bool Concurrency) : IRequest>; + private sealed class AbsorbingOuterHandler(ISender sender) : IRequestHandler> + { + public async Task> Handle(AbsorbingOuterCommand request, CancellationToken cancellationToken) + { + var refusal = request.LessonPublication + ? (await sender.Send(new PublishLessonCommand(request.FailingId, 0), cancellationToken)).Error + : (await sender.Send(new PublishCourseCommand(request.FailingId, 0), cancellationToken)).Error; + refusal.Should().NotBeNull("the refusal is absorbed only after its mutation/save executed"); + refusal!.Code.Should().Be(request.Concurrency ? "concurrency_conflict" : "business_rule_violation"); + if (!request.Concurrency) + refusal.Details!["Id"].Should().ContainSingle().Which.Key.Should().Be("lockey_identifier_taken"); + var later = await sender.Send(new CreateCourseCommand(request.LaterId, "later", "public"), cancellationToken); + later.IsSuccess.Should().BeTrue("later work really saved on the shared transaction before the owner rejects commit"); + return Result.Ok(None.Value); + } + } + private sealed class TestAuditSource : IAuditCatalogSource + { + public string ModuleName => "test"; + public void Describe(IAuditCatalogBuilder builder) => builder.Off(); + } + +} diff --git a/backend/tests/LearnStack.Tests.Integration/Database/P02d2FoundationTests.cs b/backend/tests/LearnStack.Tests.Integration/Database/P02d2FoundationTests.cs new file mode 100644 index 00000000..f52d9017 --- /dev/null +++ b/backend/tests/LearnStack.Tests.Integration/Database/P02d2FoundationTests.cs @@ -0,0 +1,299 @@ +using FluentAssertions; +using LearnStack.Modules.Customization.Application.Contracts.Customization; +using LearnStack.Modules.Customization.Application.Contracts.Definitions; +using LearnStack.Modules.Customization.Application.Contracts.Seeding; +using LearnStack.Modules.Education.Application.Contracts.Seeding; +using LearnStack.Modules.Tenancy.Application.Contracts.Locales; +using LearnStack.Modules.Tenancy.Application.Contracts.Seeding; +using LearnStack.Modules.Tenancy.Infrastructure.Persistence; +using LearnStack.SharedKernel.Identifiers; +using LearnStack.SharedKernel.Persistence; +using LearnStack.SharedKernel.Tenancy; +using LearnStack.Tools.Seeder; +using MediatR; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging.Abstractions; +using Npgsql; +using Xunit; + +namespace LearnStack.Tests.Integration.Database; + +[Trait(RequiresDocker.Key, RequiresDocker.Value)] +[Collection(SharedSchema.Name)] +public sealed class P02d2FoundationTests(SchemaFixture schema) +{ + private const string Profile = """ + {"$schema":"https://json-schema.org/draft/2020-12/schema","type":"object", + "properties":{"a":{"type":"string"},"b":{"type":"string"}}, + "additionalProperties":false, + "x-fields":[{"name":"b","label":{"en":"Second"}},{"name":"a","label":{"en":"First"}}]} + """; + + private static readonly Dictionary Label = new(StringComparer.Ordinal) { ["en"] = "Profile" }; + + [Fact] + public async Task Exact_reads_distinguish_new_bindings_from_pins_and_preserve_stored_descriptor_order() + { + // Audit reconciliation writes independently of a rolled-back business frame. + // Isolate this writer proof from the shared fixture's exact audit row counts. + await using var database = await DisposableSchemaDatabase.CreateAsync(schema.Postgres); + await using var dataSource = NpgsqlDataSource.Create(database.AppConnectionString); + await using var provider = SeedComposition.Build(dataSource, new Context(SchemaFixture.TenantA), NullLoggerFactory.Instance); + await using var scope = provider.CreateAsyncScope(); + var unit = scope.ServiceProvider.GetRequiredService(); + await using var frame = await unit.BeginTransactionAsync(); + await unit.SetTenantContextAsync(scope.ServiceProvider.GetRequiredService()); + await using (var setup = new NpgsqlCommand(""" + INSERT INTO tenants (id, slug, display_name, status, created_at, created_by, row_version) + VALUES (@tenant, 'definition-proof', 'Definition', 'Trial', now(), @actor, 0) + """, (NpgsqlConnection)unit.Connection, (NpgsqlTransaction)unit.Transaction!)) + { + setup.Parameters.AddWithValue("tenant", SchemaFixture.TenantA); + setup.Parameters.AddWithValue("actor", UserId.SystemActor.Value); + await setup.ExecuteNonQueryAsync(); + } + + var sender = scope.ServiceProvider.GetRequiredService(); + var reader = scope.ServiceProvider.GetRequiredService(); + var first = Guid.CreateVersion7(); + var next = Guid.CreateVersion7(); + var key = "foundation-profile-" + Guid.NewGuid().ToString("N"); + (await sender.Send(new RegisterTenantContentTypeCommand(first, key, 1, Label, Profile, "default-card"))) + .IsSuccess.Should().BeTrue(); + (await reader.ReadContentTypeAsync(key, 1, DefinitionReadPurpose.NewBinding, default)).IsFailure.Should().BeTrue(); + (await reader.ReadContentTypeAsync(key, 1, DefinitionReadPurpose.ExistingPin, default)).IsFailure.Should().BeTrue(); + (await sender.Send(new PublishTenantContentTypeCommand(first))).IsSuccess.Should().BeTrue(); + var active = await reader.ReadContentTypeAsync(key, 1, DefinitionReadPurpose.NewBinding, default); + active.IsSuccess.Should().BeTrue(); + active.Value!.Id.Should().Be(first); + active.Value.Fields.Select(field => field.Name).Should().Equal("b", "a"); + (await sender.Send(new RegisterTenantContentTypeCommand(next, key, 2, Label, Profile, "default-card"))) + .IsSuccess.Should().BeTrue(); + (await sender.Send(new PublishTenantContentTypeCommand(next))).IsSuccess.Should().BeTrue(); + (await reader.ReadContentTypeAsync(key, 1, DefinitionReadPurpose.NewBinding, default)).IsFailure.Should().BeTrue(); + var pinned = await reader.ReadContentTypeAsync(key, 1, DefinitionReadPurpose.ExistingPin, default); + pinned.IsSuccess.Should().BeTrue(); + pinned.Value!.Id.Should().Be(first); + pinned.Value.Status.Should().Be(DefinitionStatus.Deprecated); + (await reader.ReadContentTypeAsync(key, 2, DefinitionReadPurpose.NewBinding, default)).Value!.Id.Should().Be(next); + (await reader.ReadContentTypeAsync(key, 3, DefinitionReadPurpose.ExistingPin, default)).IsFailure.Should().BeTrue(); + (await reader.ReadContentTypeAsync(key, 1, (DefinitionReadPurpose)99, default)).IsFailure.Should().BeTrue(); + (await sender.Send(new GetContentTypeSeedStateQuery(first))).Value!.State!.JsonSchema.Should().Contain("x-fields"); + var taxonomyId = Guid.CreateVersion7(); + var taxonomyNext = Guid.CreateVersion7(); + (await sender.Send(new RegisterTenantLevelTaxonomyCommand(taxonomyId, key, 1, Label, + [new TaxonomyItemInput("intro", Label, 0)]))).IsSuccess.Should().BeTrue(); + (await reader.ReadTaxonomyAsync(key, 1, DefinitionReadPurpose.ExistingPin, default)).IsFailure.Should().BeTrue(); + (await sender.Send(new PublishTenantLevelTaxonomyCommand(taxonomyId))).IsSuccess.Should().BeTrue(); + (await reader.ReadTaxonomyAsync(key, 1, DefinitionReadPurpose.NewBinding, default)) + .Value!.Bands.Should().ContainSingle().Which.Key.Should().Be("intro"); + (await sender.Send(new RegisterTenantLevelTaxonomyCommand(taxonomyNext, key, 2, Label, + [new TaxonomyItemInput("later", Label, 0)]))).IsSuccess.Should().BeTrue(); + (await sender.Send(new PublishTenantLevelTaxonomyCommand(taxonomyNext))).IsSuccess.Should().BeTrue(); + (await reader.ReadTaxonomyAsync(key, 1, DefinitionReadPurpose.NewBinding, default)).IsFailure.Should().BeTrue(); + (await reader.ReadTaxonomyAsync(key, 1, DefinitionReadPurpose.ExistingPin, default)) + .Value!.Bands.Should().ContainSingle().Which.Key.Should().Be("intro"); + (await sender.Send(new GetTaxonomySeedStateQuery(taxonomyId))) + .Value!.State!.Items.Should().ContainSingle().Which.Key.Should().Be("intro"); + await frame.FailAsync(); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task Every_seed_query_runs_on_the_composed_pipeline_and_hides_foreign_or_sibling_roots(bool scoped) + { + await using var dataSource = NpgsqlDataSource.Create(schema.Postgres.AppConnectionString); + await using var provider = SeedComposition.Build(dataSource, + new Context(SchemaFixture.TenantA, scoped ? SchemaFixture.OrgA1 : null), NullLoggerFactory.Instance); + await using var scope = provider.CreateAsyncScope(); + var sender = scope.ServiceProvider.GetRequiredService(); + var visible = EducationSchemaSeed.Find(SchemaFixture.TenantA, scoped ? SchemaFixture.OrgA1 : null); + var sibling = EducationSchemaSeed.Find(SchemaFixture.TenantA, SchemaFixture.OrgA2); + var foreign = EducationSchemaSeed.Find(SchemaFixture.TenantB, null); + var tenant = (await sender.Send(new GetTenantSeedStateQuery())).Value!.State; + tenant!.Id.Should().Be(TenantId.From(SchemaFixture.TenantA)); + tenant.Locales.Should().ContainSingle().Which.Locale.Should().Be("tr-TR"); + (await sender.Send(new GetOrganizationSeedStateQuery(OrganizationId.From(SchemaFixture.OrgA1)))) + .Value!.State!.TenantId.Should().Be(tenant.Id); + (await sender.Send(new GetOrganizationSeedStateQuery(OrganizationId.From(SchemaFixture.OrgB1)))) + .Value!.State.Should().BeNull(); + (await sender.Send(new GetHostMappingSeedStateQuery(SchemaFixture.HostA))).Value!.State!.TenantId.Should().Be(tenant.Id); + (await sender.Send(new GetHostMappingSeedStateQuery(SchemaFixture.HostB))).Value!.State.Should().BeNull(); + var tenantSetting = await FindSettingIdAsync(dataSource, new Context(SchemaFixture.TenantA), "tz"); + var ownSetting = await FindSettingIdAsync(dataSource, new Context(SchemaFixture.TenantA, SchemaFixture.OrgA1), "theme"); + var siblingSetting = await FindSettingIdAsync(dataSource, new Context(SchemaFixture.TenantA, SchemaFixture.OrgA2), "theme"); + var foreignSetting = await FindSettingIdAsync(dataSource, new Context(SchemaFixture.TenantB), "beta-only"); + (await sender.Send(new GetSettingSeedStateQuery(tenantSetting))).Value!.State.Should().BeEquivalentTo( + new SettingSeedDto(tenantSetting, tenant.Id, null, "tz", "\"Europe/Istanbul\"", 0)); + if (scoped) + { + (await sender.Send(new GetSettingSeedStateQuery(ownSetting))).Value!.State.Should().BeEquivalentTo( + new SettingSeedDto(ownSetting, tenant.Id, OrganizationId.From(SchemaFixture.OrgA1), "theme", "\"main\"", 0)); + } + else + { + (await sender.Send(new GetSettingSeedStateQuery(ownSetting))).Value!.State.Should().BeNull(); + } + + foreach (var hiddenSetting in new[] { siblingSetting, foreignSetting, Guid.CreateVersion7() }) + { + (await sender.Send(new GetSettingSeedStateQuery(hiddenSetting))).Value!.State.Should().BeNull(); + } + (await sender.Send(new GetContentTypeSeedStateQuery(Guid.CreateVersion7()))).Value!.State.Should().BeNull(); + (await sender.Send(new GetTaxonomySeedStateQuery(Guid.CreateVersion7()))).Value!.State.Should().BeNull(); + (await sender.Send(new GetCourseSeedStateQuery(visible.CourseId))).Value!.State!.Id.Should().Be(visible.CourseId); + (await sender.Send(new GetLessonSeedStateQuery(visible.LessonId))).Value!.State!.CourseId.Should().Be(visible.CourseId); + foreach (var hidden in new[] { sibling, foreign }) + { + (await sender.Send(new GetCourseSeedStateQuery(hidden.CourseId))).Value!.State.Should().BeNull(); + (await sender.Send(new GetLessonSeedStateQuery(hidden.LessonId))).Value!.State.Should().BeNull(); + } + } + + [Fact] + public async Task Locale_and_exact_definition_reads_use_the_same_announced_connection_and_do_not_fall_back() + { + await using var dataSource = NpgsqlDataSource.Create(schema.Postgres.AppConnectionString); + await using var provider = SeedComposition.Build(dataSource, new Context(SchemaFixture.TenantA), NullLoggerFactory.Instance); + await using var scope = provider.CreateAsyncScope(); + var unit = scope.ServiceProvider.GetRequiredService(); + await using var frame = await unit.BeginTransactionAsync(); + await unit.SetTenantContextAsync(scope.ServiceProvider.GetRequiredService()); + var locales = scope.ServiceProvider.GetRequiredService(); + (await locales.ReadEligibleAsync("TR-tr", default)).Value.Should().Be("tr-TR"); + (await locales.ReadEligibleAsync("en", default)).IsFailure.Should().BeTrue(); + (await locales.ReadEligibleAsync("en-US", default)).IsFailure.Should().BeTrue(); + (await locales.ReadEligibleAsync("tr_TR", default)).IsFailure.Should().BeTrue(); + var reader = scope.ServiceProvider.GetRequiredService(); + // No implicit substitution of a different live definition. + (await reader.ReadContentTypeAsync("card", 1, DefinitionReadPurpose.NewBinding, default)).IsFailure.Should().BeTrue(); + var absent = await reader.ReadTaxonomyAsync("absent", 1, DefinitionReadPurpose.ExistingPin, default); + absent.IsFailure.Should().BeTrue(); + (await reader.ReadTaxonomyAsync("proficiency", 1, DefinitionReadPurpose.NewBinding, default)) + .Value!.Bands.Should().ContainSingle().Which.Key.Should().Be("beginner"); + await using (var other = SeedComposition.Build(dataSource, new Context(SchemaFixture.TenantB), NullLoggerFactory.Instance)) + await using (var otherScope = other.CreateAsyncScope()) + { + var otherUnit = otherScope.ServiceProvider.GetRequiredService(); + await using var otherFrame = await otherUnit.BeginTransactionAsync(); + await otherUnit.SetTenantContextAsync(otherScope.ServiceProvider.GetRequiredService()); + var otherLocales = otherScope.ServiceProvider.GetRequiredService(); + (await otherLocales.ReadEligibleAsync("en-us", default)).Value.Should().Be("en-US"); + (await otherLocales.ReadEligibleAsync("tr-TR", default)).IsFailure.Should().BeTrue(); + var otherReader = otherScope.ServiceProvider.GetRequiredService(); + var foreignType = (await otherReader.ReadContentTypeAsync("announcement", 1, DefinitionReadPurpose.NewBinding, default)).Value!; + var foreignTaxonomy = (await otherReader.ReadTaxonomyAsync("proficiency", 1, DefinitionReadPurpose.NewBinding, default)).Value!; + foreignTaxonomy.Bands.Should().ContainSingle().Which.Key.Should().Be("starter"); + var sender = scope.ServiceProvider.GetRequiredService(); + (await sender.Send(new GetContentTypeSeedStateQuery(foreignType.Id))).Value!.State.Should().BeNull(); + (await sender.Send(new GetTaxonomySeedStateQuery(foreignTaxonomy.Id))).Value!.State.Should().BeNull(); + await otherFrame.FailAsync(); + } + + await using (var legacy = new NpgsqlCommand(""" + INSERT INTO tenant_locales (tenant_id, locale, is_default, is_enabled, sort) + VALUES (@tenant, 'en', false, false, 1); + """, (NpgsqlConnection)unit.Connection, (NpgsqlTransaction)unit.Transaction!)) + { + legacy.Parameters.AddWithValue("tenant", SchemaFixture.TenantA); + await legacy.ExecuteNonQueryAsync(); + } + + // Disabled membership must fail even while the enabled default is valid. + (await locales.ReadEligibleAsync("tr-TR", default)).Value.Should().Be("tr-TR"); + (await locales.ReadEligibleAsync("en", default)).IsFailure.Should().BeTrue(); + await using (var invalid = new NpgsqlCommand( + "UPDATE tenant_locales SET is_default = false WHERE tenant_id = @tenant", + (NpgsqlConnection)unit.Connection, (NpgsqlTransaction)unit.Transaction!)) + { + invalid.Parameters.AddWithValue("tenant", SchemaFixture.TenantA); + await invalid.ExecuteNonQueryAsync(); + } + + // A supported locale does not conceal an invalid configuration. + (await locales.ReadEligibleAsync("tr-TR", default)).IsFailure.Should().BeTrue(); + await frame.FailAsync(); + } + + [Fact] + public async Task A_disabled_legacy_default_refuses_an_enabled_locale_independently_of_default_count() + { + await using var database = await DisposableSchemaDatabase.CreateAsync(schema.Postgres, applyMigrations: false); + await using (var context = new TenancyDbContext(new DbContextOptionsBuilder() + .UseNpgsql(database.MigrationConnectionString, + options => options.MigrationsHistoryTable(TenancyDbContextFactory.HistoryTable)).Options, + StaticTenantContextAccessor.Unresolved)) + { + // Pin the predecessor that can contain this invalid legacy configuration. + // Future CHECK constraints must remain intact on the shared/current database. + await context.GetService().MigrateAsync("20260914114306_org_insert_scope_and_keyless_guard"); + } + + await using var dataSource = NpgsqlDataSource.Create(database.AppConnectionString); + await using var provider = SeedComposition.Build(dataSource, new Context(SchemaFixture.TenantA), NullLoggerFactory.Instance); + await using var scope = provider.CreateAsyncScope(); + var unit = scope.ServiceProvider.GetRequiredService(); + await using var frame = await unit.BeginTransactionAsync(); + await unit.SetTenantContextAsync(scope.ServiceProvider.GetRequiredService()); + await using (var legacy = new NpgsqlCommand(""" + INSERT INTO tenants (id, slug, display_name, status, created_at, created_by, row_version) + VALUES (@tenant, 'legacy-locale', 'Legacy', 'Trial', now(), @actor, 0); + INSERT INTO tenant_locales (tenant_id, locale, is_default, is_enabled, sort) + VALUES (@tenant, 'tr-TR', true, false, 0), (@tenant, 'en', false, true, 1); + """, (NpgsqlConnection)unit.Connection, (NpgsqlTransaction)unit.Transaction!)) + { + legacy.Parameters.AddWithValue("tenant", SchemaFixture.TenantA); + legacy.Parameters.AddWithValue("actor", UserId.SystemActor.Value); + await legacy.ExecuteNonQueryAsync(); + } + + var reader = scope.ServiceProvider.GetRequiredService(); + (await reader.ReadEligibleAsync("en", default)).IsFailure.Should().BeTrue(); + await frame.FailAsync(); + } + + private static async Task FindSettingIdAsync(NpgsqlDataSource dataSource, Context context, string key) + { + await using var provider = SeedComposition.Build(dataSource, context, NullLoggerFactory.Instance); + await using var scope = provider.CreateAsyncScope(); + var unit = scope.ServiceProvider.GetRequiredService(); + await using var frame = await unit.BeginTransactionAsync(); + await unit.SetTenantContextAsync(context); + // Test discovery uses the same app role and declared scope as the production reader. + await using var command = new NpgsqlCommand("SELECT id FROM tenant_settings WHERE key = @key", + (NpgsqlConnection)unit.Connection, (NpgsqlTransaction)unit.Transaction!); + command.Parameters.AddWithValue("key", key); + var id = (Guid)(await command.ExecuteScalarAsync())!; + var state = (await scope.ServiceProvider.GetRequiredService().Send(new GetSettingSeedStateQuery(id))).Value!.State; + state.Should().NotBeNull(); + state!.Id.Should().Be(id); + state.TenantId.Should().Be(context.TenantId); + state.OrganizationId.Should().Be(context.OrganizationId); + state.Key.Should().Be(key); + state.Value.Should().Be(key switch + { + "tz" => "\"Europe/Istanbul\"", + "beta-only" => "\"visible to beta alone\"", + _ => context.OrganizationId == OrganizationId.From(SchemaFixture.OrgA1) ? "\"main\"" : "\"branch\"", + }); + state.Version.Should().Be(0); + await frame.FailAsync(); + return id; + } + + private sealed class Context(Guid tenantId, Guid? organizationId = null) : ITenantContext + { + public bool IsResolved => true; + public TenantId TenantId => TenantId.From(tenantId); + public OrganizationId? OrganizationId => organizationId is { } value + ? LearnStack.SharedKernel.Identifiers.OrganizationId.From(value) : null; + public UserId? UserId => null; + public TenantContextOrigin? Origin => TenantContextOrigin.HostAndClaim; + public string? CorrelationId => null; + public string? ModuleName => null; + } +} diff --git a/backend/tests/LearnStack.Tests.Integration/Database/SeederTests.cs b/backend/tests/LearnStack.Tests.Integration/Database/SeederTests.cs index 57cd77cd..f3bc57ce 100644 --- a/backend/tests/LearnStack.Tests.Integration/Database/SeederTests.cs +++ b/backend/tests/LearnStack.Tests.Integration/Database/SeederTests.cs @@ -1,3 +1,6 @@ +using LearnStack.Modules.Customization.Application.Contracts.Customization; +using System.Text.Json; +using LearnStack.Modules.Customization.Application.Contracts.Seeding; using FluentAssertions; using LearnStack.Api.Common; using LearnStack.Application.Pipeline; @@ -7,6 +10,7 @@ using LearnStack.Modules.Tenancy.Application.Contracts.Tenant; using LearnStack.SharedKernel.Identifiers; using LearnStack.SharedKernel.Persistence; +using LearnStack.SharedKernel.Results; using LearnStack.SharedKernel.Tenancy; using MediatR; using LearnStack.SharedKernel.Time; @@ -14,6 +18,11 @@ using Microsoft.AspNetCore.Http; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Logging; +using System.Text.Json.Nodes; +using LearnStack.Modules.Education.Application.Contracts.Seeding; +using LearnStack.Modules.Tenancy.Application.Contracts.Seeding; +using LearnStack.Modules.Tenancy.Application.Contracts.Locales; using Npgsql; using Xunit; @@ -37,8 +46,9 @@ namespace LearnStack.Tests.Integration.Database; /// with every policy inert. /// /// -/// The container is shared, so each case removes what it wrote. Cleanup runs as -/// learnstack_platform: the rows belong to tenants with no context to announce. +/// The container is shared, but each case owns a disposable migrated database. +/// Append-only audit rows and restrictive Education foreign keys are never erased +/// to reset a later case; dropping the test-owned database ends the fixture. /// /// [Trait(RequiresDocker.Key, RequiresDocker.Value)] @@ -46,12 +56,13 @@ namespace LearnStack.Tests.Integration.Database; public sealed class SeederTests : IAsyncLifetime { private readonly SchemaFixture _schema; + private DisposableSchemaDatabase _database = null!; // Initialized by the per-test fixture. public SeederTests(SchemaFixture schema) => _schema = schema; - public Task InitializeAsync() => Task.CompletedTask; + public async Task InitializeAsync() => _database = await DisposableSchemaDatabase.CreateAsync(_schema.Postgres); - public Task DisposeAsync() => CleanUpAsync(); + public Task DisposeAsync() => _database.DisposeAsync().AsTask(); [Fact] public async Task The_seed_writes_two_tenants_each_with_two_organizations_and_one_host() @@ -161,14 +172,14 @@ SELECT count(*) FROM tenant_level_taxonomy_items """, "tenant", tenant.TenantId.Value)) .Should().Be(3L, "a level vocabulary with no bands resolves everything to nothing"); - // The counter every cache key embeds. Four customization writes land four - // bumps, so a reader holding a key composed before the seed cannot reach a + // The counter every cache key embeds. Both built-in and declared definitions + // each register and publish; a reader holding a pre-seed key cannot reach a // stale entry — and a generation still at its default would mean the writes // happened without invalidating anything. (await ScalarAsPlatformAsync(""" SELECT generation FROM customization_generations WHERE tenant_id = @tenant """, "tenant", tenant.TenantId.Value)) - .Should().Be(4L, "one bump per customization write, in that write's transaction"); + .Should().Be(SeedData.CustomizationGeneration(tenant), "one bump per customization write, in that write's transaction"); } } @@ -202,14 +213,15 @@ public async Task The_built_ins_are_the_tenant_s_own_rows_and_not_shared() public async Task Running_the_seed_twice_changes_nothing_and_still_succeeds() { // `make seed` is documented as safe to repeat, and it runs on every `make dev`. - // The second run cannot pre-check: under the provisioning announcement a SELECT - // over `tenants` returns no rows by policy, so idempotency is a uniqueness - // refusal recognised as "already seeded" rather than a query. + // Contextual pre-checks skip completed acts before sending any writer. The + // full snapshot includes every root, satellite, generation and audit outcome. await using var dataSource = DataSource(); (await Runner(dataSource).RunAsync(CancellationToken.None)).Should().Be(0); + var before = await SnapshotAsync(); (await Runner(dataSource).RunAsync(CancellationToken.None)).Should().Be(0, "a second run is the ordinary case, not an error"); + (await SnapshotAsync()).Should().Be(before, "all rows, timestamps, versions, generations and every audit outcome remain unchanged"); (await ScalarAsPlatformAsync("SELECT count(*) FROM tenants WHERE id = ANY(@ids)", "ids", SeedData.All.Select(tenant => tenant.TenantId.Value).ToArray())) .Should().Be(2L, "and it did not double anything"); @@ -224,15 +236,15 @@ public async Task Running_the_seed_twice_changes_nothing_and_still_succeeds() (await ScalarAsPlatformAsync( "SELECT count(*) FROM tenant_content_types WHERE tenant_id = ANY(@ids)", "ids", SeedData.All.Select(tenant => tenant.TenantId.Value).ToArray())) - .Should().Be(2L, "a second run registers nothing"); + .Should().Be(SeedData.Inventory.ContentTypes, "a second run registers nothing"); (await ScalarAsPlatformAsync( "SELECT count(*) FROM tenant_level_taxonomy_items WHERE tenant_id = ANY(@ids)", "ids", SeedData.All.Select(tenant => tenant.TenantId.Value).ToArray())) - .Should().Be(6L, "and adds no bands"); + .Should().Be(SeedData.Inventory.Bands, "and adds no bands"); (await ScalarAsPlatformAsync( "SELECT max(generation) FROM customization_generations WHERE tenant_id = ANY(@ids)", "ids", SeedData.All.Select(tenant => tenant.TenantId.Value).ToArray())) - .Should().Be(4L, "and invalidates nothing, because it changed nothing"); + .Should().Be(SeedData.All.Max(SeedData.CustomizationGeneration), "and invalidates nothing, because it changed nothing"); } [Fact] @@ -247,11 +259,8 @@ public async Task A_failure_that_is_not_a_conflict_stops_the_run() // every failure swallowed, every run exiting 0 — left all three other cases green. // A seeder that silently ignores a 42501 was indistinguishable from a correct one. // - // Provoked by composing every act unresolved. Provisioning still succeeds, because - // it is the one command marked [AllowsUnresolvedTenantContext]; the second - // organization is then refused by the pipeline with a code that is NOT - // business_rule_violation, which is the only code the runner treats as - // "already seeded". + // Refusing contextual verification must stop before any writer. Supplying + // an unresolved context for every requested scope exercises that boundary. await using var dataSource = DataSource(); var alwaysUnresolved = new SeedRunner( @@ -262,7 +271,7 @@ public async Task A_failure_that_is_not_a_conflict_stops_the_run() (await seed.Should().ThrowAsync( "a refusal that is not a conflict means the seed did not do its job")) - .WithMessage("*second organization*"); + .WithMessage("*Reading seed verification*"); // And it stopped where it failed rather than carrying on: the host row for the // first tenant was never written. @@ -357,6 +366,7 @@ public async Task A_host_naming_another_tenants_organization_is_refused_not_cras await using var dataSource = DataSource(); (await Runner(dataSource).RunAsync(CancellationToken.None)).Should().Be(0); + await ProvisionForeignAsync(dataSource); var provider = SeedComposition.Build( dataSource, @@ -440,10 +450,11 @@ public async Task A_seed_host_another_tenant_already_holds_stops_the_run() // RLS is what makes the discrimination cheap: under demo-english's own // announcement the row is visible only if the row is demo-english's. await using var dataSource = DataSource(); + await ProvisionForeignAsync(dataSource); - // The fixture's tenant A claims the seed host first, on its own announcement. + // A foreign tenant claims the seed host first, on its own announcement. await using (var connection = await PostgresFixture.OpenAsync( - _schema.Postgres.AppConnectionString)) + _database.AppConnectionString)) await using (var claim = new NpgsqlCommand( $""" BEGIN; @@ -470,7 +481,7 @@ INSERT INTO platform_host_to_tenant finally { await using var platform = await PostgresFixture.OpenAsync( - _schema.Postgres.PlatformConnectionString); + _database.PlatformConnectionString); await using var cleanup = new NpgsqlCommand( "DELETE FROM platform_host_to_tenant WHERE host = @host", (NpgsqlConnection)platform); @@ -479,6 +490,377 @@ INSERT INTO platform_host_to_tenant } } + [Fact] + public async Task Seeder_process_returns_zero_on_repeat_and_nonzero_on_mismatch() + { + async Task RunProcess() + { + var start = new System.Diagnostics.ProcessStartInfo("dotnet") + { + WorkingDirectory = AppContext.BaseDirectory, + RedirectStandardOutput = true, + RedirectStandardError = true, + }; + start.ArgumentList.Add("exec"); + start.ArgumentList.Add("--runtimeconfig"); + start.ArgumentList.Add(Path.Combine(AppContext.BaseDirectory, "LearnStack.Tests.Integration.runtimeconfig.json")); + start.ArgumentList.Add("--depsfile"); + start.ArgumentList.Add(Path.Combine(AppContext.BaseDirectory, "LearnStack.Tests.Integration.deps.json")); + start.ArgumentList.Add(typeof(SeedRunner).Assembly.Location); + // The test-owned password stays in the environment, never argv or a failure message. + start.Environment["ConnectionStrings__Default"] = _database.AppConnectionString; + using var process = new System.Diagnostics.Process { StartInfo = start }; + process.Start().Should().BeTrue(); + var output = process.StandardOutput.ReadToEndAsync(); + var errors = process.StandardError.ReadToEndAsync(); + using var deadline = new CancellationTokenSource(TimeSpan.FromSeconds(90)); + try { await process.WaitForExitAsync(deadline.Token); } + finally { if (!process.HasExited) process.Kill(entireProcessTree: true); } + await Task.WhenAll(output, errors); + return process.ExitCode; + } + (await RunProcess()).Should().Be(0); + var complete = await SnapshotAsync(); + (await RunProcess()).Should().Be(0); + (await SnapshotAsync()).Should().Be(complete); + // A test-owned pre-existing mismatch, not a second normal seed write path. + await using (var connection = await PostgresFixture.OpenAsync(_database.PlatformConnectionString)) + await using (var change = new NpgsqlCommand("UPDATE tenants SET display_name = 'Different' WHERE id = @id", (NpgsqlConnection)connection)) + { + change.Parameters.AddWithValue("id", SeedData.English.TenantId.Value); + (await change.ExecuteNonQueryAsync()).Should().Be(1); + } + var mismatch = await SnapshotAsync(); + (await RunProcess()).Should().Be(1); + (await SnapshotAsync()).Should().Be(mismatch); + } + + [Fact] + public async Task Complete_inventory_has_exact_states_pins_translations_and_null_or_exact_write_scope() + { + await using var source = DataSource(); + (await Runner(source).RunAsync(CancellationToken.None)).Should().Be(0); + await AssertInventoryAsync(source); + var propertyCounts = new List(); + foreach (var tenant in SeedData.All) + { + var type = tenant.Curriculum?.ContentType ?? throw new InvalidOperationException("Missing curriculum."); + await using var provider = SeedComposition.Build(source, new SeedTenantContext(tenant.TenantId, null), NullLoggerFactory.Instance); + var result = await provider.GetRequiredService().Send(new GetContentTypeSeedStateQuery(type.Id)); + result.IsSuccess.Should().BeTrue(); + using var schema = JsonDocument.Parse(result.Value!.State!.JsonSchema); + propertyCounts.Add(schema.RootElement.GetProperty("properties").EnumerateObject().Count()); + } + propertyCounts.Distinct().Count().Should().Be(SeedData.All.Count, + "the two seeded schemas must differ in shape, not only property names or text"); + (await ScalarAsPlatformAsync("SELECT count(*) FROM audit_log")).Should().Be(SeedData.ExpectedAuditWrites, + "verification is Off, and each normal seed write is audited exactly once"); + } + + [Theory] + [InlineData("content type")] + [InlineData("lesson translation")] + public async Task Interrupted_authoring_resumes_without_rewriting_completed_acts(string interruptedAct) + { + await using var source = DataSource(); + var interrupted = new SeedRunner(context => SeedComposition.Build(source, context, NullLoggerFactory.Instance), + new InterruptAfter(interruptedAct)); + var run = () => interrupted.RunAsync(CancellationToken.None); + (await run.Should().ThrowAsync()).WithMessage("Injected seed interruption"); + var previousAudits = await AuditRowsAsync(); + previousAudits.Should().NotBeEmpty("the interruption follows real committed seed writes"); + (await Runner(source).RunAsync(CancellationToken.None)).Should().Be(0); + await AssertInventoryAsync(source); + (await AuditRowsAsync()).Should().Contain(previousAudits, "the completed writes retain their exact durable audit rows"); + (await ScalarAsPlatformAsync("SELECT count(*) FROM audit_log")).Should().Be(SeedData.ExpectedAuditWrites, + "resuming creates only the remaining acts, without duplicate success or failure audits"); + var completed = await SnapshotAsync(); + (await Runner(source).RunAsync(CancellationToken.None)).Should().Be(0); + (await SnapshotAsync()).Should().Be(completed); + } + + [Fact] + public async Task Concurrent_seeds_prove_a_real_provisioning_race_and_converge_without_duplicates() + { + await using var source = DataSource(); + using var barrier = new Barrier(2); + using var deadline = new CancellationTokenSource(TimeSpan.FromSeconds(90)); + ServiceProvider Compose(ITenantContext? context) + { + if (context is null && !barrier.SignalAndWait(TimeSpan.FromSeconds(30), deadline.Token)) + throw new InvalidOperationException("Seed race barrier timed out."); + return SeedComposition.Build(source, context, NullLoggerFactory.Instance); + } + var first = new SeedRunner(Compose, NullLogger.Instance); + var second = new SeedRunner(Compose, NullLogger.Instance); + // Both runners read the absent tenant before either provisioning write can start. + // Only the first tenant participates in this barrier; later runs use normal scopes. + var outcomes = await Task.WhenAll(Task.Run(() => first.RunAsync(deadline.Token, [SeedData.English])), + Task.Run(() => second.RunAsync(deadline.Token, [SeedData.English]))); + outcomes.Should().OnlyContain(code => code == 0); + (await Runner(source).RunAsync(CancellationToken.None)).Should().Be(0); + await AssertInventoryAsync(source); + (await ScalarAsPlatformAsync("SELECT count(*) FROM audit_log WHERE outcome = 'success'")) + .Should().Be(SeedData.ExpectedAuditWrites, "only one successful writer wins each act"); + var completed = await SnapshotAsync(); + (await Runner(source).RunAsync(CancellationToken.None)).Should().Be(0); + (await SnapshotAsync()).Should().Be(completed); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task Concurrent_translation_writers_recheck_the_completed_act_after_a_real_non_provisioning_race(bool divergent) + { + await using var source = DataSource(); + var original = SeedData.English.Curriculum!.Courses[0]; + var course = original with { Status = "Draft", Lessons = [], Translations = [original.Translations[0]] }; + var tenant = SeedData.English with { Curriculum = SeedData.English.Curriculum with { Courses = [course] } }; + var competingTranslation = course.Translations[0] with { Title = course.Translations[0].Title + " competing" }; + var competingCourse = course with { Translations = [competingTranslation] }; + var competing = divergent + ? tenant with { Curriculum = tenant.Curriculum! with { Courses = [competingCourse] } } + : tenant; + var interrupted = new SeedRunner(context => SeedComposition.Build(source, context, NullLoggerFactory.Instance), + new InterruptAfter("course")); + var initial = () => interrupted.RunAsync(CancellationToken.None, [tenant]); + (await initial.Should().ThrowAsync()).WithMessage("Injected seed interruption"); + + // Hold the first UPDATE after the incomplete-state reads. The competing + // translation batch waits on the root or translation's unique key. Both + // have chosen a writer, so one real refusal must reach ActAsync's recheck. + var lockKey = Random.Shared.NextInt64(1, long.MaxValue); + await using var owner = await PostgresFixture.OpenAsync(_database.MigrationConnectionString); + await using (var setup = new NpgsqlCommand($""" + CREATE FUNCTION seed_translation_gate() RETURNS trigger LANGUAGE plpgsql AS $$ + BEGIN + IF NEW.id = '{course.Id}'::uuid THEN + PERFORM pg_advisory_xact_lock({lockKey}); + END IF; + RETURN NEW; + END $$; + CREATE TRIGGER seed_translation_gate BEFORE UPDATE ON courses + FOR EACH ROW EXECUTE FUNCTION seed_translation_gate(); + SELECT pg_advisory_lock({lockKey}); + """, (NpgsqlConnection)owner)) + await setup.ExecuteNonQueryAsync(); + + using var deadline = new CancellationTokenSource(TimeSpan.FromSeconds(45)); + await using var observer = await source.OpenConnectionAsync(deadline.Token); + var firstLog = new SeedActRecorder("course translation"); + var secondLog = new SeedActRecorder("course translation"); + async Task Attempt(SeedTenant declared, SeedActRecorder log) + { + try + { + var runner = new SeedRunner(context => SeedComposition.Build(source, context, NullLoggerFactory.Instance), log); + (await runner.RunAsync(deadline.Token, [declared])).Should().Be(0); + return null; + } + catch (InvalidOperationException refused) { return refused; } + } + var first = Attempt(tenant, firstLog); + var second = Attempt(competing, secondLog); + try + { + var bothWaiting = false; + while (!deadline.IsCancellationRequested) + { + await using var waiting = new NpgsqlCommand(""" + SELECT count(*) FROM pg_stat_activity + WHERE datname = current_database() AND usename = 'learnstack_app' + AND wait_event_type = 'Lock' + AND (query LIKE '%courses%' OR query LIKE '%course_translations%') + """, observer); + if ((long)(await waiting.ExecuteScalarAsync(deadline.Token))! == 2) + { + bothWaiting = true; + break; + } + await Task.Delay(25, deadline.Token); + } + bothWaiting.Should().BeTrue("both real translation writers must reach their locked save before release"); + } + finally + { + await using var release = new NpgsqlCommand($"SELECT pg_advisory_unlock({lockKey})", (NpgsqlConnection)owner); + await release.ExecuteScalarAsync(); + await Task.WhenAll(first, second); + } + var outcomes = await Task.WhenAll(first, second); + SeedTenant winner; + if (divergent) + { + outcomes.Should().ContainSingle(outcome => outcome == null); + outcomes.Should().ContainSingle(outcome => outcome != null).Which!.Message.Should().StartWith("Seed mismatch in course"); + var loserLog = outcomes[0] is not null ? firstLog : secondLog; + loserLog.Completed.Should().BeFalse("the typed race must fail its exact postcondition before reporting the act complete; a later final-state check is insufficient"); + winner = outcomes[0] is null ? tenant : competing; + } + else + { + outcomes.Should().OnlyContain(outcome => outcome == null); + new[] { firstLog.AlreadyPresent, secondLog.AlreadyPresent }.Should().ContainSingle(present => present); + winner = tenant; + } + (await ScalarAsPlatformAsync("SELECT count(*) FROM audit_log WHERE operation = 'education.course.translation_add' AND outcome = 'success'")) + .Should().Be(1); + (await ScalarAsPlatformAsync("SELECT count(*) FROM audit_log WHERE operation = 'education.course.translation_add' AND outcome <> 'success'")) + .Should().Be(1, "the loser is a witnessed real refusal, not an incidental completed-act skip"); + var state = (await ReadAsync(source, new SeedTenantContext(tenant.TenantId, course.OrganizationId), new GetCourseSeedStateQuery(course.Id))).State!; + state.Version.Should().Be(1); + state.Translations.Should().ContainSingle().Which.Slug.Should().Be(course.Translations[0].Slug); + state.Translations[0].Title.Should().Be(winner.Curriculum!.Courses[0].Translations[0].Title); + var completed = await SnapshotAsync(); + (await Runner(source).RunAsync(CancellationToken.None, [winner])).Should().Be(0); + (await SnapshotAsync()).Should().Be(completed, "the rerun retains the failed race audit and adds no outcome"); + } + + [Theory] + [InlineData("tenant")] + [InlineData("default organization")] + [InlineData("second organization")] + [InlineData("host scope")] + [InlineData("locale")] + [InlineData("undeclared locale")] + [InlineData("type key")] + [InlineData("type revision")] + [InlineData("schema")] + [InlineData("taxonomy")] + [InlineData("theme")] + [InlineData("course pin")] + [InlineData("course access")] + [InlineData("course state")] + [InlineData("course translation")] + [InlineData("lesson pin")] + [InlineData("lesson sort")] + [InlineData("lesson body")] + public async Task Completed_seed_mismatch_fails_without_mutating_any_existing_row_or_audit(string mismatch) + { + await using var source = DataSource(); + (await Runner(source).RunAsync(CancellationToken.None)).Should().Be(0); + var original = SeedData.English; + if (mismatch == "undeclared locale") + { + await using var provider = SeedComposition.Build(source, new SeedTenantContext(original.TenantId, null), NullLoggerFactory.Instance); + await using var scope = provider.CreateAsyncScope(); + var sender = scope.ServiceProvider.GetRequiredService(); + var state = (await sender.Send(new GetTenantSeedStateQuery())).Value!.State!; + (await sender.Send(new AddTenantLocaleCommand(state.Version, "fr", true, false, 3))).IsSuccess.Should().BeTrue(); + } + var before = await SnapshotAsync(); + var content = original.Curriculum ?? throw new InvalidOperationException("Missing fixture curriculum."); + var course = content.Courses[0]; + var lesson = course.Lessons[0]; + var changed = mismatch switch + { + "tenant" => original with { DisplayName = "Different" }, + "default organization" => original with { DefaultOrganization = original.DefaultOrganization with { DisplayName = "Different" } }, + "second organization" => original with { SecondOrganization = original.SecondOrganization with { DisplayName = "Different" } }, + "host scope" => original with { MapHostToDefaultOrganization = true }, + "locale" => original with { Curriculum = content with { Locales = [content.Locales[0] with { Sort = 4 }] } }, + "undeclared locale" => original, + "type key" => original with { Curriculum = content with { ContentType = content.ContentType with { Key = "different" } } }, + "type revision" => original with { Curriculum = content with { ContentType = content.ContentType with { SchemaVersion = 2 } } }, + "schema" => original with { Curriculum = content with { ContentType = content.ContentType with { JsonSchema = "{}" } } }, + "taxonomy" => original with { Curriculum = content with { Taxonomy = content.Taxonomy with { Bands = content.Taxonomy.Bands.RemoveAt(0) } } }, + "theme" => original with { Curriculum = content with { Theme = content.Theme with { Value = content.Theme.Value.Replace("#1d4ed8", "#3730a3", StringComparison.Ordinal) } } }, + "course pin" => CourseChanged(course with { LevelTaxonomySchemaVersion = 2 }), + "course access" => CourseChanged(course with { ContentAccess = "enrollment_required" }), + "course state" => CourseChanged(course with { Status = "Draft" }), + "course translation" => CourseChanged(course with { Translations = [course.Translations[0] with { Title = "Different" }] }), + "lesson pin" => LessonChanged(lesson with { ContentTypeSchemaVersion = 2 }), + "lesson sort" => LessonChanged(lesson with { Sort = 99 }), + "lesson body" => LessonChanged(lesson with { Translations = [lesson.Translations[0] with { Body = "{}" }] }), + _ => throw new ArgumentOutOfRangeException(nameof(mismatch)), + }; + var run = () => Runner(source).RunAsync(CancellationToken.None, [changed]); + (await run.Should().ThrowAsync()).WithMessage("*Seed mismatch*"); + (await SnapshotAsync()).Should().Be(before, "a visible mismatch is refused before any writer or overwrite"); + SeedTenant CourseChanged(SeedCourse value) => original with { Curriculum = content with { Courses = content.Courses.SetItem(0, value) } }; + SeedTenant LessonChanged(SeedLesson value) => CourseChanged(course with { Lessons = course.Lessons.SetItem(0, value) }); + } + + [Fact] + public async Task Semantic_json_property_order_does_not_turn_a_completed_act_into_a_write() + { + await using var source = DataSource(); + (await Runner(source).RunAsync(CancellationToken.None)).Should().Be(0); + var before = await SnapshotAsync(); + var tenant = SeedData.English; + var content = tenant.Curriculum ?? throw new InvalidOperationException("Missing fixture curriculum."); + var course = content.Courses[0]; + var lesson = course.Lessons[0]; + static string Reordered(string value) + { + var node = JsonNode.Parse(value)?.AsObject() ?? throw new InvalidOperationException("Expected object fixture."); + return new JsonObject(node.Reverse().Select(pair => KeyValuePair.Create(pair.Key, pair.Value?.DeepClone()))).ToJsonString(); + } + var expected = tenant with + { + Curriculum = content with + { + Theme = content.Theme with { Value = Reordered(content.Theme.Value) }, + ContentType = content.ContentType with { JsonSchema = Reordered(content.ContentType.JsonSchema) }, + Courses = content.Courses.SetItem(0, course with + { + Lessons = course.Lessons.SetItem(0, lesson with + { Translations = [lesson.Translations[0] with { Body = Reordered(lesson.Translations[0].Body) }] }) + }), + } + }; + (await Runner(source).RunAsync(CancellationToken.None, [expected])).Should().Be(0); + (await SnapshotAsync()).Should().Be(before); + } + + [Theory] + [InlineData(false, false)] + [InlineData(false, true)] + [InlineData(true, false)] + [InlineData(true, true)] + public async Task Seed_publication_refuses_a_different_active_revision_without_retiring_or_rebinding_it(bool taxonomy, bool expectedDraftExists) + { + await using var source = DataSource(); + var tenant = SeedData.English with { Curriculum = null }; + var interrupted = new SeedRunner(context => SeedComposition.Build(source, context, NullLoggerFactory.Instance), + new InterruptAfter(taxonomy ? (expectedDraftExists ? "level taxonomy" : "content type publication") + : (expectedDraftExists ? "content type" : "host mapping"))); + var initial = () => interrupted.RunAsync(CancellationToken.None, [tenant]); + await initial.Should().ThrowAsync(); + var incumbent = Guid.CreateVersion7(); + var context = new SeedTenantContext(tenant.TenantId, null); + if (taxonomy) + { + var definition = SeedData.Taxonomies(tenant).First(); + await Write(new RegisterTenantLevelTaxonomyCommand(incumbent, definition.Key, definition.SchemaVersion + 1, + definition.DisplayName, [.. definition.Bands.Select(band => new TaxonomyItemInput(band.Key, band.DisplayName, band.Sort, band.Metadata))])); + await Write(new PublishTenantLevelTaxonomyCommand(incumbent)); + } + else + { + var definition = SeedData.ContentTypes(tenant).First(); + await Write(new RegisterTenantContentTypeCommand(incumbent, definition.Key, definition.SchemaVersion + 1, + definition.DisplayName, definition.JsonSchema, definition.RendererKey)); + await Write(new PublishTenantContentTypeCommand(incumbent)); + } + var before = await SnapshotAsync(); + var audits = await AuditRowsAsync(); + var successes = await ScalarAsPlatformAsync("SELECT count(*) FROM audit_log WHERE outcome = 'success'"); + var rerun = () => Runner(source).RunAsync(CancellationToken.None, [tenant]); + (await rerun.Should().ThrowAsync()).WithMessage("*Seed mismatch*"); + (await SnapshotAsync()).Should().Be(before, + "absent/Draft expected state, the other Active revision, versions, generations and all audit rows must remain unchanged"); + (await AuditRowsAsync()).Should().Contain(audits); + (await ScalarAsPlatformAsync("SELECT count(*) FROM audit_log WHERE outcome = 'success'")).Should().Be(successes); + + async Task Write(IRequest> request) + { + await using var provider = SeedComposition.Build(source, context, NullLoggerFactory.Instance); + await using var scope = provider.CreateAsyncScope(); + (await scope.ServiceProvider.GetRequiredService().Send(request)).IsSuccess.Should().BeTrue(); + } + } + // ── Harness ────────────────────────────────────────────────────────────── /// @@ -495,73 +877,148 @@ private static SeedRunner Runner(NpgsqlDataSource dataSource) => NullLogger.Instance); private NpgsqlDataSource DataSource() => - NpgsqlDataSource.Create(_schema.Postgres.AppConnectionString); + NpgsqlDataSource.Create(_database.AppConnectionString); - /// - /// Removes everything a case seeded, so the next one starts from nothing. - /// - /// - /// - /// Two roles, because the grant matrix gives them different reach. - /// learnstack_platform holds DELETE on the tenancy tables and - /// BYPASSRLS, so it removes those without an announcement. It holds - /// SELECT only on the four customization tables - /// (Database Standards - /// § GRANT matrix), so those go through the owner instead. - /// - /// - /// And the owner needs the announcement. Those tables are under - /// FORCE ROW LEVEL SECURITY, so learnstack_migration is subject - /// to its own policy and USING is the only gate a DELETE has — - /// measured, without app.tenant_id the statement reports - /// DELETE 0 and every later case in this shared container runs against - /// rows a previous one left. - /// - /// - private async Task CleanUpAsync() + private async Task AssertInventoryAsync(NpgsqlDataSource source) { - var ids = SeedData.All.Select(tenant => tenant.TenantId.Value).ToArray(); - - await using (var platform = await PostgresFixture.OpenAsync( - _schema.Postgres.PlatformConnectionString)) + (await ScalarAsPlatformAsync("SELECT count(*) FROM courses")).Should().Be(SeedData.Inventory.Courses); + (await ScalarAsPlatformAsync("SELECT count(*) FROM lessons")).Should().Be(SeedData.Inventory.Lessons); + (await ScalarAsPlatformAsync("SELECT (SELECT count(*) FROM course_translations) + (SELECT count(*) FROM lesson_translations)")) + .Should().Be(SeedData.Inventory.Translations); + foreach (var tenant in SeedData.All) { - foreach (var statement in new[] + var curriculum = tenant.Curriculum ?? throw new InvalidOperationException("Missing fixture curriculum."); + var wide = new SeedTenantContext(tenant.TenantId, null); + var tenantRow = (await ReadAsync(source, wide, new GetTenantSeedStateQuery())).State; + tenantRow.Should().NotBeNull(); + tenantRow!.Locales.Should().BeEquivalentTo(curriculum.Locales); + var theme = (await ReadAsync(source, wide, new GetSettingSeedStateQuery(curriculum.Theme.Id))).State; + theme.Should().NotBeNull(); + theme!.OrganizationId.Should().BeNull(); + JsonNode.DeepEquals(JsonNode.Parse(theme.Value), JsonNode.Parse(curriculum.Theme.Value)).Should().BeTrue(); + foreach (var course in curriculum.Courses) { - "DELETE FROM platform_host_to_tenant WHERE tenant_id = ANY(@ids)", - "UPDATE tenants SET default_organization_id = NULL WHERE id = ANY(@ids)", - "DELETE FROM organizations WHERE tenant_id = ANY(@ids)", - "DELETE FROM tenants WHERE id = ANY(@ids)", - }) - { - await using var cleanup = new NpgsqlCommand(statement, (NpgsqlConnection)platform); - cleanup.Parameters.AddWithValue("ids", ids); - await cleanup.ExecuteNonQueryAsync(); + var context = new SeedTenantContext(tenant.TenantId, course.OrganizationId); + var actual = (await ReadAsync(source, context, new GetCourseSeedStateQuery(course.Id))).State; + actual.Should().NotBeNull(); + actual!.TenantId.Should().Be(tenant.TenantId); + actual.OrganizationId.Should().Be(course.OrganizationId); + actual.Status.Should().Be(course.Status); + actual.ContentAccess.Should().Be(course.ContentAccess); + actual.LevelTaxonomyKey.Should().Be(course.LevelTaxonomyKey); + actual.LevelTaxonomySchemaVersion.Should().Be(course.LevelTaxonomySchemaVersion); + actual.LevelBandKey.Should().Be(course.LevelBandKey); + actual.Translations.Should().BeEquivalentTo(course.Translations); + var foreign = new SeedTenantContext(SeedData.All.Single(other => other.TenantId != tenant.TenantId).TenantId, null); + (await ReadAsync(source, foreign, new GetCourseSeedStateQuery(course.Id))).State.Should().BeNull(); + if (course.OrganizationId is not null) + { + var sibling = new SeedTenantContext(tenant.TenantId, course.OrganizationId == tenant.DefaultOrganization.OrganizationId + ? tenant.SecondOrganization.OrganizationId : tenant.DefaultOrganization.OrganizationId); + (await ReadAsync(source, sibling, new GetCourseSeedStateQuery(course.Id))).State.Should().BeNull(); + (await ReadAsync(source, wide, new GetCourseSeedStateQuery(course.Id))).State.Should().BeNull(); + } + foreach (var lesson in course.Lessons) + { + var member = (await ReadAsync(source, context, new GetLessonSeedStateQuery(lesson.Id))).State; + member.Should().NotBeNull(); + member!.CourseId.Should().Be(course.Id); + member.TenantId.Should().Be(tenant.TenantId); + member.OrganizationId.Should().Be(course.OrganizationId); + member.Sort.Should().Be(lesson.Sort); + member.Status.Should().Be(lesson.Status); + member.ContentTypeKey.Should().Be(lesson.ContentTypeKey); + member.ContentTypeSchemaVersion.Should().Be(lesson.ContentTypeSchemaVersion); + member.Translations.Should().HaveCount(lesson.Translations.Length); + foreach (var expected in lesson.Translations) + { + var translation = member.Translations.Single(value => value.Locale == expected.Locale); + translation.Title.Should().Be(expected.Title); + translation.Slug.Should().Be(expected.Slug); + JsonNode.DeepEquals(JsonNode.Parse(translation.Body), JsonNode.Parse(expected.Body)).Should().BeTrue(); + } + (await ReadAsync(source, foreign, new GetLessonSeedStateQuery(lesson.Id))).State.Should().BeNull(); + } } } + } - await using var owner = await PostgresFixture.OpenAsync( - _schema.Postgres.MigrationConnectionString); + private static async Task ReadAsync(NpgsqlDataSource source, ITenantContext context, IRequest> query) + { + await using var provider = SeedComposition.Build(source, context, NullLoggerFactory.Instance); + await using var scope = provider.CreateAsyncScope(); + var result = await scope.ServiceProvider.GetRequiredService().Send(query); + result.IsSuccess.Should().BeTrue(); + return result.Value!; // The successful query contract returns a non-null lookup DTO. + } - foreach (var tenant in SeedData.All) + private static async Task ProvisionForeignAsync(NpgsqlDataSource source) + { + await using var provider = SeedComposition.Build(source, null, NullLoggerFactory.Instance); + await using var scope = provider.CreateAsyncScope(); + var result = await scope.ServiceProvider.GetRequiredService().Send(new ProvisionTenantCommand( + TenantId.From(SchemaFixture.TenantA), "foreign-owner", "Foreign owner", OrganizationId.From(SchemaFixture.OrgA1), "main", "Main")); + result.IsSuccess.Should().BeTrue(); + } + + private async Task SnapshotAsync() + { + var output = new SortedDictionary(StringComparer.Ordinal); + var ids = SeedData.All.Select(tenant => tenant.TenantId.Value).ToArray(); + await using var connection = await PostgresFixture.OpenAsync(_database.PlatformConnectionString); + foreach (var table in new[] { "tenants", "organizations", "tenant_locales", "tenant_settings", "platform_host_to_tenant", + "tenant_content_types", "tenant_level_taxonomies", "tenant_level_taxonomy_items", "customization_generations", + "courses", "lessons", "course_translations", "lesson_translations", "audit_log" }) { - await using var transaction = await owner.BeginTransactionAsync(); - await SchemaQueries.SetTenantAsync(owner, transaction, tenant.TenantId.Value); + var column = table == "tenants" ? "id" : "tenant_id"; + // Table/column are a closed test-owned list, never external SQL input. + await using var query = new NpgsqlCommand($"SELECT COALESCE(jsonb_agg(to_jsonb(row) ORDER BY to_jsonb(row)::text), '[]'::jsonb)::text FROM {table} row WHERE {column} = ANY(@ids)", (NpgsqlConnection)connection); + query.Parameters.AddWithValue("ids", ids); + output.Add(table, (string)(await query.ExecuteScalarAsync())!); + } + return System.Text.Json.JsonSerializer.Serialize(output); + } - // Items first: their foreign key cascades, but the cascade runs with row - // security bypassed and leaving it implicit hides which rows went. - foreach (var statement in new[] - { - "DELETE FROM tenant_level_taxonomy_items WHERE tenant_id = @tenant", - "DELETE FROM tenant_level_taxonomies WHERE tenant_id = @tenant", - "DELETE FROM tenant_content_types WHERE tenant_id = @tenant", - "DELETE FROM customization_generations WHERE tenant_id = @tenant", - }) + private async Task AuditRowsAsync() + { + var rows = new List(); + await using var connection = await PostgresFixture.OpenAsync(_database.PlatformConnectionString); + await using var query = new NpgsqlCommand("SELECT to_jsonb(row)::text FROM audit_log row ORDER BY id", (NpgsqlConnection)connection); + await using var reader = await query.ExecuteReaderAsync(); + while (await reader.ReadAsync()) rows.Add(reader.GetString(0)); + return [.. rows]; + } + + private sealed class SeedActRecorder(string act) : ILogger + { + public bool Completed { get; private set; } + public bool AlreadyPresent { get; private set; } + public IDisposable? BeginScope(TState state) where TState : notnull => null; + public bool IsEnabled(LogLevel logLevel) => true; + public void Log(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func formatter) + { + if (eventId.Id is 7002 or 7003 && state is IEnumerable> values + && values.Any(value => value.Key == "What" && Equals(value.Value, act))) { - await SchemaQueries.ExecuteAsync(owner, transaction, statement, - ("tenant", tenant.TenantId.Value)); + Completed = true; + AlreadyPresent = eventId.Id == 7003; } + } + } - await transaction.CommitAsync(); + private sealed class InterruptAfter(string act) : ILogger + { + private bool _interrupted; + public IDisposable? BeginScope(TState state) where TState : notnull => null; + public bool IsEnabled(LogLevel logLevel) => true; + public void Log(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func formatter) + { + if (!_interrupted && eventId.Id == 7002 && state is IEnumerable> values + && values.Any(value => value.Key == "What" && Equals(value.Value, act))) + { + _interrupted = true; + throw new InvalidOperationException("Injected seed interruption"); + } } } @@ -579,7 +1036,7 @@ private async Task ScalarAsPlatformAsync( string sql, string? parameterName, object? value) { await using var platform = await PostgresFixture.OpenAsync( - _schema.Postgres.PlatformConnectionString); + _database.PlatformConnectionString); await using var query = new NpgsqlCommand(sql, (NpgsqlConnection)platform); if (parameterName is not null) @@ -593,7 +1050,7 @@ private async Task ScalarAsPlatformAsync( private async Task CountAsPlatformAsync(string sql, string host) { await using var platform = await PostgresFixture.OpenAsync( - _schema.Postgres.PlatformConnectionString); + _database.PlatformConnectionString); await using var query = new NpgsqlCommand(sql, (NpgsqlConnection)platform); query.Parameters.AddWithValue("host", host); @@ -603,7 +1060,7 @@ private async Task CountAsPlatformAsync(string sql, string host) private async Task TextAsPlatformAsync(string sql, string host) { await using var platform = await PostgresFixture.OpenAsync( - _schema.Postgres.PlatformConnectionString); + _database.PlatformConnectionString); await using var query = new NpgsqlCommand(sql, (NpgsqlConnection)platform); query.Parameters.AddWithValue("host", host); diff --git a/backend/tests/LearnStack.Tests.Integration/Database/TenancyWriterTests.cs b/backend/tests/LearnStack.Tests.Integration/Database/TenancyWriterTests.cs new file mode 100644 index 00000000..42209fab --- /dev/null +++ b/backend/tests/LearnStack.Tests.Integration/Database/TenancyWriterTests.cs @@ -0,0 +1,719 @@ +using System.Text.Json; +using System.Text.Json.Nodes; +using FluentAssertions; +using LearnStack.Modules.Tenancy.Application.Abstractions; +using LearnStack.Modules.Tenancy.Application.Contracts.Branding; +using LearnStack.Modules.Tenancy.Application.Contracts.Locales; +using LearnStack.Modules.Tenancy.Application.Contracts.Seeding; +using LearnStack.Modules.Tenancy.Application.Contracts.Tenant; +using LearnStack.Modules.Tenancy.Domain; +using LearnStack.Modules.Tenancy.Infrastructure.Persistence; +using LearnStack.SharedKernel.Audit; +using LearnStack.SharedKernel.Identifiers; +using LearnStack.SharedKernel.Persistence; +using LearnStack.SharedKernel.Results; +using LearnStack.SharedKernel.Secrets; +using LearnStack.SharedKernel.Tenancy; +using LearnStack.SharedKernel.Time; +using LearnStack.Tools.Seeder; +using MediatR; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Diagnostics; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging.Abstractions; +using Npgsql; +using Xunit; + +namespace LearnStack.Tests.Integration.Database; + +[Trait(RequiresDocker.Key, RequiresDocker.Value)] +[Collection(SharedSchema.Name)] +public sealed class TenancyWriterTests(SchemaFixture schema, WebApplicationFactory factory) + : IClassFixture> +{ + private const string Theme = """{"primary":"#2345aa","background":"#ffffff","foreground":"#111111","muted":"#555555"}"""; + private const string OtherTheme = """{"primary":"#663399","background":"#ffffff","foreground":"#000000","muted":"#444444"}"""; + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task Unknown_unique_constraints_remain_database_faults_and_roll_back(bool branding) + { + await using var database = await DisposableSchemaDatabase.CreateAsync(schema.Postgres); + await using var source = NpgsqlDataSource.Create(database.AppConnectionString); + var context = await ProvisionAsync(source); + var foreign = await ProvisionAsync(source); + var initial = await ReadTenantAsync(source, context); + (await SendAsync(source, context, new AddTenantLocaleCommand(initial.Version, "en", true, true, 0))).IsSuccess.Should().BeTrue(); + if (branding) + { + (await SendAsync(source, foreign, new SetTenantBrandingCommand(Guid.CreateVersion7(), Theme, null))) + .IsSuccess.Should().BeTrue(); + } + + await using (var owner = new NpgsqlConnection(database.MigrationConnectionString)) + await using (var index = new NpgsqlCommand(branding + ? "CREATE UNIQUE INDEX ux_test_unowned ON tenant_settings (value)" + : "CREATE UNIQUE INDEX ux_test_unowned ON tenant_locales (sort)", owner)) + { + await owner.OpenAsync(); + await index.ExecuteNonQueryAsync(); + } + + var before = await ReadTenantAsync(source, context); + var settingId = Guid.CreateVersion7(); + Func send = branding + ? async () => { await SendAsync(source, context, new SetTenantBrandingCommand(settingId, Theme, null)); } + : async () => { await SendAsync(source, context, new AddTenantLocaleCommand(before.Version, "fr", true, false, 0)); }; + var fault = (await send.Should().ThrowAsync()).Which; + fault.InnerException.Should().BeOfType().Which.ConstraintName.Should().Be("ux_test_unowned"); + var problem = LearnStack.Api.Common.ProblemDetailsFactory.For(fault); + problem.Status.Should().Be(500); + problem.Extensions["code"].Should().Be("internal_error"); + problem.Extensions.Should().NotContainKey("errors"); + (await ReadTenantAsync(source, context)).Should().BeEquivalentTo(before); + (await SendAsync(source, context, new GetSettingSeedStateQuery(settingId))).Value!.State.Should().BeNull(); + (await CountSuccessfulWritesAsync(source, context, "tenancy.locale.write")).Should().Be(1); + (await CountSuccessfulWritesAsync(source, context, "tenancy.setting.write")).Should().Be(0); + } + + [Fact] + public async Task Locale_commands_promote_first_enabled_then_switch_existing_and_new_defaults_atomically() + { + await using var database = await DisposableSchemaDatabase.CreateAsync(schema.Postgres); + await using var source = NpgsqlDataSource.Create(database.AppConnectionString); + var context = await ProvisionAsync(source); + var before = await ReadTenantAsync(source, context); + var disabled = await SendAsync(source, context, new AddTenantLocaleCommand(before.Version, "fr", false, false, 0)); + disabled.IsSuccess.Should().BeTrue(); + disabled.Value!.Locales.Should().ContainSingle().Which.IsDefault.Should().BeFalse(); + var enabled = await SendAsync(source, context, new AddTenantLocaleCommand(disabled.Value.Version, "EN-us", true, false, 1)); + enabled.Value!.Locales.Single(locale => locale.IsDefault).Locale.Should().Be("en-US"); + var additional = await SendAsync(source, context, new AddTenantLocaleCommand(enabled.Value.Version, "tr-TR", true, false, 2)); + additional.IsSuccess.Should().BeTrue(); + var switched = await SendAsync(source, context, new SetDefaultTenantLocaleCommand(additional.Value!.Version, "TR-tr")); + switched.Value!.Locales.Single(locale => locale.IsDefault).Locale.Should().Be("tr-TR"); + // An Added default must be held back too; EF inserts before clearing existing rows. + var addedDefault = await SendAsync(source, context, new AddTenantLocaleCommand(switched.Value.Version, "de", true, true, 3)); + addedDefault.IsSuccess.Should().BeTrue(); + var final = await ReadTenantAsync(source, context); + final.Version.Should().Be(before.Version + 5); + final.Locales.Should().HaveCount(4); + final.Locales.Single(locale => locale.IsDefault).Locale.Should().Be("de"); + (await CountSuccessfulWritesAsync(source, context, "tenancy.locale.write")).Should().Be(5); + await AssertLocaleAuditAsync(source, context); + + var refused = await SendAsync(source, context, new SetDefaultTenantLocaleCommand(final.Version, "fr")); + refused.Error!.Code.Should().Be("validation_failed"); + (await ReadTenantAsync(source, context)).Should().BeEquivalentTo(final); + (await CountSuccessfulWritesAsync(source, context, "tenancy.locale.write")).Should().Be(5); + var duplicate = await SendAsync(source, context, new AddTenantLocaleCommand(final.Version, "DE", true, false, 4)); + duplicate.Error!.Code.Should().Be("business_rule_violation"); + (await ReadTenantAsync(source, context)).Should().BeEquivalentTo(final); + } + + [Fact] + public async Task Branding_is_create_or_exact_replace_only_and_cannot_touch_foreign_or_generic_settings() + { + await using var database = await DisposableSchemaDatabase.CreateAsync(schema.Postgres); + await using var source = NpgsqlDataSource.Create(database.AppConnectionString); + var context = await ProvisionAsync(source); + var foreign = await ProvisionAsync(source); + var tenantBefore = await ReadTenantAsync(source, context); + var organizationContext = context with { Organization = context.DefaultOrganization }; + (await SendAsync(source, organizationContext, new AddTenantLocaleCommand(tenantBefore.Version, "en", true, true, 0))) + .Error!.Code.Should().Be("resource_scope_violation"); + (await SendAsync(source, organizationContext, new SetDefaultTenantLocaleCommand(tenantBefore.Version, "en"))) + .Error!.Code.Should().Be("resource_scope_violation"); + (await ReadTenantAsync(source, context)).Should().BeEquivalentTo(tenantBefore); + var id = Guid.CreateVersion7(); + var foreignId = Guid.CreateVersion7(); + (await SendAsync(source, context, new SetTenantBrandingCommand(id, Theme, null))).IsSuccess.Should().BeTrue(); + (await SendAsync(source, foreign, new SetTenantBrandingCommand(foreignId, OtherTheme, null))).IsSuccess.Should().BeTrue(); + (await SendAsync(source, context, new SetTenantBrandingCommand(foreignId, Theme, 0))).Error!.Code.Should().Be("not_found"); + (await SendAsync(source, context with { Organization = context.DefaultOrganization }, new SetTenantBrandingCommand(id, Theme, 0))) + .Error!.Code.Should().Be("resource_scope_violation"); + (await SendAsync(source, context, new SetTenantBrandingCommand(id, OtherTheme, null))).Error!.Code.Should().Be("business_rule_violation"); + (await SendAsync(source, context, new SetTenantBrandingCommand(Guid.CreateVersion7(), Theme, null))) + .Error!.Code.Should().Be("business_rule_violation"); + var changed = await SendAsync(source, context, new SetTenantBrandingCommand(id, OtherTheme, 0)); + changed.Value!.Version.Should().Be(1); + (await SendAsync(source, context, new SetTenantBrandingCommand(id, Theme, 0))).Error!.Code.Should().Be("concurrency_conflict"); + (await SendAsync(source, context, new SetTenantBrandingCommand(id, "{}", 1))).Error!.Code.Should().Be("validation_failed"); + var state = (await SendAsync(source, context, new GetSettingSeedStateQuery(id))).Value!.State!; + JsonNode.DeepEquals(JsonNode.Parse(state.Value), JsonNode.Parse(changed.Value.Theme)).Should().BeTrue(); + state.Version.Should().Be(1); + (await CountSuccessfulWritesAsync(source, context, "tenancy.setting.write")).Should().Be(2); + var foreignState = (await SendAsync(source, foreign, new GetSettingSeedStateQuery(foreignId))).Value!.State!; + JsonNode.DeepEquals(JsonNode.Parse(foreignState.Value), JsonNode.Parse(OtherTheme)).Should().BeTrue(); + foreignState.Version.Should().Be(0); + + await using var provider = SeedComposition.Build(source, context, NullLoggerFactory.Instance); + await using var scope = provider.CreateAsyncScope(); + var unit = scope.ServiceProvider.GetRequiredService(); + await using var frame = await unit.BeginTransactionAsync(); + await unit.SetTenantContextAsync(context); + var generic = TenantSetting.Create(TenantSettingId.From(Guid.CreateVersion7()), context.TenantId, null, + "tz", "\"Europe/Istanbul\"", new SystemClock(), UserId.SystemActor); + await scope.ServiceProvider.GetRequiredService().AddAsync(generic); + var refused = await scope.ServiceProvider.GetRequiredService() + .Send(new SetTenantBrandingCommand(generic.Id.Value, Theme, generic.Version)); + refused.Error!.Code.Should().Be("not_found"); + generic.Key.Should().Be("tz"); + generic.Value.Should().Be("\"Europe/Istanbul\""); + await frame.FailAsync(); + } + + [Theory] + [InlineData(false, false)] + [InlineData(false, true)] + [InlineData(true, false)] + [InlineData(true, true)] + public async Task Branding_refuses_missing_or_deleted_tenants_before_setting_access(bool missingTenant, bool replacement) + { + await using var database = await DisposableSchemaDatabase.CreateAsync(schema.Postgres); + await using var source = NpgsqlDataSource.Create(database.AppConnectionString); + var context = await ProvisionAsync(source); + (await ReadTenantAsync(source, context)).Status.Should().Be(nameof(TenantStatus.Trial)); + var id = Guid.CreateVersion7(); + if (replacement) + { + (await SendAsync(source, context, new SetTenantBrandingCommand(id, Theme, null))).IsSuccess.Should().BeTrue(); + } + var before = (await SendAsync(source, context, new GetSettingSeedStateQuery(id))).Value!.State; + var auditsBefore = await CountSuccessfulWritesAsync(source, context, "tenancy.setting.write"); + if (!missingTenant) + { + await using var provider = SeedComposition.Build(source, context, NullLoggerFactory.Instance); + await using var scope = provider.CreateAsyncScope(); + var unit = scope.ServiceProvider.GetRequiredService(); + await using var frame = await unit.BeginTransactionAsync(); + await unit.SetTenantContextAsync(context); + var store = scope.ServiceProvider.GetRequiredService(); + var tenant = (await store.FindAsync(context.TenantId))!; + tenant.SoftDelete(new SystemClock().UtcNow, UserId.SystemActor); + await store.UpdateAsync(tenant); + await frame.CompleteAsync(); + } + + var refusedContext = missingTenant ? context with { TenantId = TenantId.From(Guid.CreateVersion7()) } : context; + await using var host = factory.WithWebHostBuilder(builder => builder + .UseSetting("ConnectionStrings:Default", database.AppConnectionString) + .ConfigureServices(services => services.AddScoped())); + await using (var scope = host.Services.CreateAsyncScope()) + { + scope.ServiceProvider.GetRequiredService().Current = refusedContext; + var unit = scope.ServiceProvider.GetRequiredService(); + await using var frame = await unit.BeginTransactionAsync(); + await unit.SetTenantContextAsync(refusedContext); + var result = await scope.ServiceProvider.GetRequiredService() + .Send(new SetTenantBrandingCommand(id, OtherTheme, replacement ? 0 : null)); + result.IsFailure.Should().BeTrue(); + result.Error!.Code.Should().Be("not_found"); + scope.ServiceProvider.GetRequiredService().ChangeTracker.Entries().Should().BeEmpty(); + scope.ServiceProvider.GetRequiredService().Changes.Should().BeEmpty(); + await frame.FailAsync(); + } + var after = (await SendAsync(source, context, new GetSettingSeedStateQuery(id))).Value!.State; + after.Should().BeEquivalentTo(before); + (await CountSuccessfulWritesAsync(source, context, "tenancy.setting.write")).Should().Be(auditsBefore); + } + + [Fact] + public async Task Whole_json_value_is_redacted_in_the_capture_and_durable_setting_audit() + { + await using var database = await DisposableSchemaDatabase.CreateAsync(schema.Postgres); + await using var source = NpgsqlDataSource.Create(database.AppConnectionString); + var context = await ProvisionAsync(source); + var id = Guid.CreateVersion7(); + (await SendAsync(source, context, new SetTenantBrandingCommand(id, Theme, null))).IsSuccess.Should().BeTrue(); + (await SendAsync(source, context, new SetTenantBrandingCommand(id, OtherTheme, 0))).IsSuccess.Should().BeTrue(); + await using var provider = SeedComposition.Build(source, context, NullLoggerFactory.Instance); + await using var scope = provider.CreateAsyncScope(); + var unit = scope.ServiceProvider.GetRequiredService(); + await using var frame = await unit.BeginTransactionAsync(); + await unit.SetTenantContextAsync(context); + await using (var command = new NpgsqlCommand(""" + SELECT before_state::text, after_state::text, changes::text, entity_type, entity_id + FROM audit_log WHERE operation = 'tenancy.setting.write' AND outcome = 'success' + ORDER BY timestamp, id + """, (NpgsqlConnection)unit.Connection, (NpgsqlTransaction)unit.Transaction!)) + await using (var rows = await command.ExecuteReaderAsync()) + { + var count = 0; + while (await rows.ReadAsync()) + { + var after = rows.GetString(1); + using var document = JsonDocument.Parse(after); + document.RootElement.GetProperty("Value").GetString().Should().Be(SensitiveTokenCatalog.RedactedValue); + if (!rows.IsDBNull(0)) + { + using var before = JsonDocument.Parse(rows.GetString(0)); + before.RootElement.GetProperty("Value").GetString().Should().Be(SensitiveTokenCatalog.RedactedValue); + } + + rows.GetString(2).Should().NotContain("#2345aa").And.NotContain("#663399"); + rows.GetString(3).Should().Be(nameof(TenantSetting)); + rows.GetString(4).Should().Be(id.ToString()); + count++; + } + + count.Should().Be(2); + } + + // Generic JSON has unknown fields: the marker must redact the entire value too. + var generic = TenantSetting.Create(TenantSettingId.From(Guid.CreateVersion7()), context.TenantId, null, + "arbitrary", """{"invented":"private@example.test","nested":{"note":"secret"}}""", new SystemClock(), UserId.SystemActor); + await scope.ServiceProvider.GetRequiredService().AddAsync(generic); + var capture = scope.ServiceProvider.GetRequiredService().Changes.Single(change => change.EntityType == nameof(TenantSetting)); + capture.AfterJson.Should().Contain(SensitiveTokenCatalog.RedactedValue).And.NotContain("private@example.test").And.NotContain("secret"); + capture.Fields.Single(field => field.Path.EndsWith("/Value", StringComparison.Ordinal)).AfterJson + .Should().Be(JsonSerializer.Serialize(SensitiveTokenCatalog.RedactedValue)); + await frame.FailAsync(); + } + + [Fact] + public async Task A_failed_must_audit_rolls_back_the_already_saved_branding_root() + { + await using var database = await DisposableSchemaDatabase.CreateAsync(schema.Postgres); + await using var source = NpgsqlDataSource.Create(database.AppConnectionString); + var context = await ProvisionAsync(source); + await using (var owner = new NpgsqlConnection(database.MigrationConnectionString)) + { + await owner.OpenAsync(); + await using var revoke = new NpgsqlCommand("REVOKE INSERT ON audit_log FROM learnstack_app", owner); + await revoke.ExecuteNonQueryAsync(); + } + + var id = Guid.CreateVersion7(); + var send = async () => await SendAsync(source, context, new SetTenantBrandingCommand(id, Theme, null)); + (await send.Should().ThrowAsync()).Which.Error.Code.Should().Be("audit_unavailable"); + (await SendAsync(source, context, new GetSettingSeedStateQuery(id))).Value!.State.Should().BeNull(); + (await CountSuccessfulWritesAsync(source, context, "tenancy.setting.write")).Should().Be(0); + } + + [Fact] + public async Task Second_default_save_failure_rolls_back_the_first_clear_and_root_stamp_from_a_fresh_scope() + { + await using var database = await DisposableSchemaDatabase.CreateAsync(schema.Postgres); + await using var source = NpgsqlDataSource.Create(database.AppConnectionString); + var context = await ProvisionAsync(source); + var initial = await ReadTenantAsync(source, context); + var first = await SendAsync(source, context, new AddTenantLocaleCommand(initial.Version, "tr-TR", true, true, 0)); + var second = await SendAsync(source, context, new AddTenantLocaleCommand(first.Value!.Version, "en-US", true, false, 1)); + var before = await ReadTenantAsync(source, context); + await using var host = factory.WithWebHostBuilder(builder => builder + .UseSetting("ConnectionStrings:Default", database.AppConnectionString) + .ConfigureServices(services => services.AddScoped())); + await using (var scope = host.Services.CreateAsyncScope()) + { + scope.ServiceProvider.GetRequiredService().Current = context; + var send = async () => await scope.ServiceProvider.GetRequiredService() + .Send(new SetDefaultTenantLocaleCommand(second.Value!.Version, "en-US")); + await send.Should().ThrowAsync().WithMessage("injected second Tenancy save"); + } + + (await ReadTenantAsync(source, context)).Should().BeEquivalentTo(before); + (await CountSuccessfulWritesAsync(source, context, "tenancy.locale.write")).Should().Be(2); + } + + [Fact] + public async Task Concurrent_palette_replacements_cannot_mix_colors_or_commit_two_successful_versions() + { + await using var database = await DisposableSchemaDatabase.CreateAsync(schema.Postgres); + await using var source = NpgsqlDataSource.Create(database.AppConnectionString); + var context = await ProvisionAsync(source); + var id = Guid.CreateVersion7(); + (await SendAsync(source, context, new SetTenantBrandingCommand(id, Theme, null))).IsSuccess.Should().BeTrue(); + var barrier = new ReadBarrier(); + await using var host = factory.WithWebHostBuilder(builder => builder + .UseSetting("ConnectionStrings:Default", database.AppConnectionString) + .ConfigureServices(services => services.AddScoped(provider => + new BarrierSettingStore(new TenantSettingWriteStore(provider.GetRequiredService()), barrier)))); + async Task> Replace(string theme) + { + await using var scope = host.Services.CreateAsyncScope(); + scope.ServiceProvider.GetRequiredService().Current = context; + return await scope.ServiceProvider.GetRequiredService().Send(new SetTenantBrandingCommand(id, theme, 0)); + } + + var results = await Task.WhenAll(Replace(OtherTheme), Replace(Theme)); + results.Should().ContainSingle(result => result.IsSuccess); + results.Should().ContainSingle(result => result.IsFailure).Which.Error!.Code.Should().Be("concurrency_conflict"); + var winner = results.Single(result => result.IsSuccess).Value!; + var state = (await SendAsync(source, context, new GetSettingSeedStateQuery(id))).Value!.State!; + state.Version.Should().Be(1); + JsonNode.DeepEquals(JsonNode.Parse(state.Value), JsonNode.Parse(winner.Theme)).Should().BeTrue(); + (await CountSuccessfulWritesAsync(source, context, "tenancy.setting.write")).Should().Be(2); + } + + [Fact] + public async Task Concurrent_palette_creates_leave_one_setting_and_one_successful_audit() + { + await using var database = await DisposableSchemaDatabase.CreateAsync(schema.Postgres); + await using var source = NpgsqlDataSource.Create(database.AppConnectionString); + var context = await ProvisionAsync(source); + var barrier = new ReadBarrier(); + await using var host = factory.WithWebHostBuilder(builder => builder + .UseSetting("ConnectionStrings:Default", database.AppConnectionString) + .ConfigureServices(services => services.AddScoped(provider => + new BarrierSettingStore(new TenantSettingWriteStore(provider.GetRequiredService()), barrier)))); + async Task> Create(string theme) + { + await using var scope = host.Services.CreateAsyncScope(); + scope.ServiceProvider.GetRequiredService().Current = context; + return await scope.ServiceProvider.GetRequiredService() + .Send(new SetTenantBrandingCommand(Guid.CreateVersion7(), theme, null)); + } + + var results = await Task.WhenAll(Create(Theme), Create(OtherTheme)); + results.Should().ContainSingle(result => result.IsSuccess); + results.Should().ContainSingle(result => result.IsFailure).Which.Error!.Code.Should().Be("business_rule_violation"); + var winner = results.Single(result => result.IsSuccess).Value!; + var state = (await SendAsync(source, context, new GetSettingSeedStateQuery(winner.SettingId))).Value!.State!; + state.Version.Should().Be(0); + JsonNode.DeepEquals(JsonNode.Parse(state.Value), JsonNode.Parse(winner.Theme)).Should().BeTrue(); + (await CountSuccessfulWritesAsync(source, context, "tenancy.setting.write")).Should().Be(1); + } + + [Fact] + public async Task Disabled_legacy_default_is_not_implicitly_repaired_by_either_locale_writer() + { + await using var database = await DisposableSchemaDatabase.CreateAsync(schema.Postgres); + await using (var migration = new TenancyDbContext(new DbContextOptionsBuilder() + .UseNpgsql(database.MigrationConnectionString, + options => options.MigrationsHistoryTable(TenancyDbContextFactory.HistoryTable)).Options, + StaticTenantContextAccessor.Unresolved)) + { + await migration.GetService().MigrateAsync("20260914114306_org_insert_scope_and_keyless_guard"); + } + + await using var source = NpgsqlDataSource.Create(database.AppConnectionString); + var context = await ProvisionAsync(source); + var initial = await ReadTenantAsync(source, context); + var first = await SendAsync(source, context, new AddTenantLocaleCommand(initial.Version, "en", true, true, 0)); + var second = await SendAsync(source, context, new AddTenantLocaleCommand(first.Value!.Version, "fr", true, false, 1)); + await using var provider = SeedComposition.Build(source, context, NullLoggerFactory.Instance); + await using var scope = provider.CreateAsyncScope(); + var unit = scope.ServiceProvider.GetRequiredService(); + await using var frame = await unit.BeginTransactionAsync(); + await unit.SetTenantContextAsync(context); + await using (var command = new NpgsqlCommand("UPDATE tenant_locales SET is_enabled = false WHERE is_default", + (NpgsqlConnection)unit.Connection, (NpgsqlTransaction)unit.Transaction!)) + { + (await command.ExecuteNonQueryAsync()).Should().Be(1); + } + + var sender = scope.ServiceProvider.GetRequiredService(); + var before = (await sender.Send(new GetTenantSeedStateQuery())).Value!.State!; + (await sender.Send(new AddTenantLocaleCommand(second.Value!.Version, "de", true, true, 2))).Error!.Code.Should().Be("validation_failed"); + (await sender.Send(new SetDefaultTenantLocaleCommand(second.Value.Version, "fr"))).Error!.Code.Should().Be("validation_failed"); + (await sender.Send(new GetTenantSeedStateQuery())).Value!.State.Should().BeEquivalentTo(before); + await frame.FailAsync(); + } + + [Fact] + public async Task Enabled_without_default_is_refused_by_writers_before_root_or_navigation_mutation() + { + await using var database = await DisposableSchemaDatabase.CreateAsync(schema.Postgres); + await using var source = NpgsqlDataSource.Create(database.AppConnectionString); + var context = await ProvisionAsync(source); + var initial = await ReadTenantAsync(source, context); + var added = await SendAsync(source, context, new AddTenantLocaleCommand(initial.Version, "en", true, true, 0)); + await using var provider = SeedComposition.Build(source, context, NullLoggerFactory.Instance); + await using var scope = provider.CreateAsyncScope(); + var unit = scope.ServiceProvider.GetRequiredService(); + await using var frame = await unit.BeginTransactionAsync(); + await unit.SetTenantContextAsync(context); + await using (var command = new NpgsqlCommand("UPDATE tenant_locales SET is_default = false", + (NpgsqlConnection)unit.Connection, (NpgsqlTransaction)unit.Transaction!)) + { + (await command.ExecuteNonQueryAsync()).Should().Be(1); + } + + var sender = scope.ServiceProvider.GetRequiredService(); + var before = (await sender.Send(new GetTenantSeedStateQuery())).Value!.State!; + (await sender.Send(new AddTenantLocaleCommand(added.Value!.Version, "fr", true, false, 1))).Error!.Code.Should().Be("validation_failed"); + (await sender.Send(new SetDefaultTenantLocaleCommand(added.Value.Version, "en"))).Error!.Code.Should().Be("validation_failed"); + (await sender.Send(new GetTenantSeedStateQuery())).Value!.State.Should().BeEquivalentTo(before); + scope.ServiceProvider.GetRequiredService().Changes.Should().BeEmpty(); + await frame.FailAsync(); + } + + [Fact] + public async Task Soft_deleted_tenants_are_not_write_targets_and_live_lookup_keeps_locales_and_flags() + { + await using var database = await DisposableSchemaDatabase.CreateAsync(schema.Postgres); + await using var source = NpgsqlDataSource.Create(database.AppConnectionString); + var context = await ProvisionAsync(source); + var initial = await ReadTenantAsync(source, context); + var added = await SendAsync(source, context, new AddTenantLocaleCommand(initial.Version, "en", true, true, 0)); + added.IsSuccess.Should().BeTrue(); + var settingId = Guid.CreateVersion7(); + (await SendAsync(source, context, new SetTenantBrandingCommand(settingId, Theme, null))).IsSuccess.Should().BeTrue(); + var brandingBefore = (await SendAsync(source, context, new GetSettingSeedStateQuery(settingId))).Value!.State; + await using (var provider = SeedComposition.Build(source, context, NullLoggerFactory.Instance)) + await using (var scope = provider.CreateAsyncScope()) + { + var unit = scope.ServiceProvider.GetRequiredService(); + await using var frame = await unit.BeginTransactionAsync(); + await unit.SetTenantContextAsync(context); + var store = scope.ServiceProvider.GetRequiredService(); + var tenant = (await store.FindAsync(context.TenantId))!; + tenant.Locales.Should().ContainSingle().Which.Locale.Should().Be("en"); + tenant.SetFeatureFlag(LearnStack.SharedKernel.Entitlements.FeatureKeys.LessonPlayerV2, "true", new SystemClock(), UserId.SystemActor); + await store.UpdateAsync(tenant); + await frame.CompleteAsync(); + } + long version; + await using (var provider = SeedComposition.Build(source, context, NullLoggerFactory.Instance)) + await using (var scope = provider.CreateAsyncScope()) + { + var unit = scope.ServiceProvider.GetRequiredService(); + await using var frame = await unit.BeginTransactionAsync(); + await unit.SetTenantContextAsync(context); + var store = scope.ServiceProvider.GetRequiredService(); + var tenant = (await store.FindAsync(context.TenantId))!; + tenant.Locales.Should().ContainSingle(); + tenant.FeatureFlags.Should().ContainSingle().Which.Value.Should().Be("true"); + tenant.SoftDelete(new SystemClock().UtcNow, UserId.SystemActor); + await store.UpdateAsync(tenant); + version = tenant.Version; + await frame.CompleteAsync(); + } + await using (var provider = SeedComposition.Build(source, context, NullLoggerFactory.Instance)) + await using (var scope = provider.CreateAsyncScope()) + { + var unit = scope.ServiceProvider.GetRequiredService(); + await using var frame = await unit.BeginTransactionAsync(); + await unit.SetTenantContextAsync(context); + (await scope.ServiceProvider.GetRequiredService().FindAsync(context.TenantId)).Should().BeNull(); + var sender = scope.ServiceProvider.GetRequiredService(); + (await sender.Send(new AddTenantLocaleCommand(version, "fr", true, false, 1))).Error!.Code.Should().Be("not_found"); + (await sender.Send(new SetDefaultTenantLocaleCommand(version, "en"))).Error!.Code.Should().Be("not_found"); + (await sender.Send(new SetTenantBrandingCommand(Guid.CreateVersion7(), OtherTheme, null))).Error!.Code.Should().Be("not_found"); + (await sender.Send(new SetTenantBrandingCommand(settingId, OtherTheme, 0))).Error!.Code.Should().Be("not_found"); + scope.ServiceProvider.GetRequiredService().Changes.Should().BeEmpty(); + await frame.FailAsync(); + } + (await CountSuccessfulWritesAsync(source, context, "tenancy.locale.write")).Should().Be(1); + (await CountSuccessfulWritesAsync(source, context, "tenancy.setting.write")).Should().Be(1); + (await SendAsync(source, context, new GetSettingSeedStateQuery(settingId))).Value!.State.Should().BeEquivalentTo(brandingBefore); + } + + [Fact] + public async Task Locale_writers_use_the_current_tenant_and_never_change_foreign_locales() + { + await using var database = await DisposableSchemaDatabase.CreateAsync(schema.Postgres); + await using var source = NpgsqlDataSource.Create(database.AppConnectionString); + var context = await ProvisionAsync(source); + var foreign = await ProvisionAsync(source); + var a = await ReadTenantAsync(source, context); + var b = await ReadTenantAsync(source, foreign); + (await SendAsync(source, context, new AddTenantLocaleCommand(a.Version, "en", true, true, 0))).IsSuccess.Should().BeTrue(); + (await SendAsync(source, foreign, new AddTenantLocaleCommand(b.Version, "en", true, true, 0))).IsSuccess.Should().BeTrue(); + var foreignBefore = await ReadTenantAsync(source, foreign); + var own = await ReadTenantAsync(source, context); + var added = await SendAsync(source, context, new AddTenantLocaleCommand(own.Version, "fr", true, false, 1)); + (await SendAsync(source, context, new SetDefaultTenantLocaleCommand(added.Value!.Version, "fr"))).IsSuccess.Should().BeTrue(); + (await ReadTenantAsync(source, foreign)).Should().BeEquivalentTo(foreignBefore); + await using var provider = SeedComposition.Build(source, context, NullLoggerFactory.Instance); + await using var scope = provider.CreateAsyncScope(); + var unit = scope.ServiceProvider.GetRequiredService(); + await using var frame = await unit.BeginTransactionAsync(); + await unit.SetTenantContextAsync(context); + (await scope.ServiceProvider.GetRequiredService().FindAsync(foreign.TenantId)).Should().BeNull(); + await frame.FailAsync(); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task Concurrent_locale_changes_commit_only_one_exact_root_version(bool switchDefault) + { + await using var database = await DisposableSchemaDatabase.CreateAsync(schema.Postgres); + await using var source = NpgsqlDataSource.Create(database.AppConnectionString); + var context = await ProvisionAsync(source); + var initial = await ReadTenantAsync(source, context); + var first = await SendAsync(source, context, new AddTenantLocaleCommand(initial.Version, "en", true, true, 0)); + if (switchDefault) + { + var second = await SendAsync(source, context, new AddTenantLocaleCommand(first.Value!.Version, "fr", true, false, 1)); + (await SendAsync(source, context, new AddTenantLocaleCommand(second.Value!.Version, "de", true, false, 2))).IsSuccess.Should().BeTrue(); + } + var before = await ReadTenantAsync(source, context); + var audits = await CountSuccessfulWritesAsync(source, context, "tenancy.locale.write"); + var barrier = new ReadBarrier(); + await using var host = factory.WithWebHostBuilder(builder => builder + .UseSetting("ConnectionStrings:Default", database.AppConnectionString) + .ConfigureServices(services => services.AddScoped(provider => + new BarrierTenantStore(new TenantWriteStore(provider.GetRequiredService()), barrier)))); + async Task> Change(string locale, short sort) + { + await using var scope = host.Services.CreateAsyncScope(); + scope.ServiceProvider.GetRequiredService().Current = context; + return switchDefault + ? await scope.ServiceProvider.GetRequiredService().Send(new SetDefaultTenantLocaleCommand(before.Version, locale)) + : await scope.ServiceProvider.GetRequiredService().Send(new AddTenantLocaleCommand(before.Version, locale, true, false, sort)); + } + var results = await Task.WhenAll(Change("fr", 1), Change("de", 2)); + results.Should().ContainSingle(result => result.IsSuccess); + results.Should().ContainSingle(result => result.IsFailure).Which.Error!.Code.Should().Be("concurrency_conflict"); + var winner = results.Single(result => result.IsSuccess).Value!; + var final = await ReadTenantAsync(source, context); + final.Version.Should().Be(before.Version + 1); + final.Locales.Should().BeEquivalentTo(winner.Locales); + final.Locales.Should().HaveCount(before.Locales.Length + (switchDefault ? 0 : 1)); + final.Locales.Should().ContainSingle(locale => locale.IsDefault); + (await CountSuccessfulWritesAsync(source, context, "tenancy.locale.write")).Should().Be(audits + 1); + } + + private static async Task ProvisionAsync(NpgsqlDataSource source) + { + var id = TenantId.From(Guid.CreateVersion7()); + var organization = OrganizationId.From(Guid.CreateVersion7()); + var result = await SendAsync(source, null, new ProvisionTenantCommand(id, "writer-" + id.Value.ToString("N"), + "Writer proof", organization, "main", "Main")); + result.IsSuccess.Should().BeTrue(); + return new Context(id, organization); + } + + private static async Task> SendAsync(NpgsqlDataSource source, Context? context, IRequest> request) + { + await using var provider = SeedComposition.Build(source, context, NullLoggerFactory.Instance); + await using var scope = provider.CreateAsyncScope(); + return await scope.ServiceProvider.GetRequiredService().Send(request); + } + + private static async Task ReadTenantAsync(NpgsqlDataSource source, Context context) => + (await SendAsync(source, context, new GetTenantSeedStateQuery())).Value!.State!; + + private static async Task CountSuccessfulWritesAsync(NpgsqlDataSource source, Context context, string operation) + { + await using var provider = SeedComposition.Build(source, context, NullLoggerFactory.Instance); + await using var scope = provider.CreateAsyncScope(); + var unit = scope.ServiceProvider.GetRequiredService(); + await using var frame = await unit.BeginTransactionAsync(); + await unit.SetTenantContextAsync(context); + await using var command = new NpgsqlCommand("SELECT count(*) FROM audit_log WHERE operation = @operation AND outcome = 'success'", + (NpgsqlConnection)unit.Connection, (NpgsqlTransaction)unit.Transaction!); + command.Parameters.AddWithValue("operation", operation); + var count = (long)(await command.ExecuteScalarAsync())!; + await frame.FailAsync(); + return count; + } + + private static async Task AssertLocaleAuditAsync(NpgsqlDataSource source, Context context) + { + await using var provider = SeedComposition.Build(source, context, NullLoggerFactory.Instance); + await using var scope = provider.CreateAsyncScope(); + var unit = scope.ServiceProvider.GetRequiredService(); + await using var frame = await unit.BeginTransactionAsync(); + await unit.SetTenantContextAsync(context); + await using var command = new NpgsqlCommand(""" + SELECT entity_type, entity_id, after_state::text, changes::text FROM audit_log + WHERE operation = 'tenancy.locale.write' AND outcome = 'success' + ORDER BY timestamp DESC, id DESC LIMIT 1 + """, (NpgsqlConnection)unit.Connection, (NpgsqlTransaction)unit.Transaction!); + await using (var row = await command.ExecuteReaderAsync()) + { + (await row.ReadAsync()).Should().BeTrue(); + row.GetString(0).Should().Be(nameof(Tenant)); + row.GetString(1).Should().Be(context.TenantId.Value.ToString()); + using var after = JsonDocument.Parse(row.GetString(2)); + after.RootElement.GetProperty("Slug").GetString().Should().StartWith("writer-"); + // ADR-0044 records existing membership as unknown, even for an Include. + // Individual contained changes carry the default switch, including both saves. + after.RootElement.TryGetProperty("Locales", out _).Should().BeFalse(); + using var changes = JsonDocument.Parse(row.GetString(3)); + var prefix = "/Tenant/" + context.TenantId.Value + "/Locales/"; + var fields = changes.RootElement.EnumerateArray().ToArray(); + fields.Last(field => field.GetProperty("path").GetString() == prefix + "de/IsDefault") + .GetProperty("after").GetBoolean().Should().BeTrue(); + var cleared = fields.Single(field => field.GetProperty("path").GetString() == prefix + "tr-TR/IsDefault"); + cleared.GetProperty("before").GetBoolean().Should().BeTrue(); + cleared.GetProperty("after").GetBoolean().Should().BeFalse(); + } + + await frame.FailAsync(); + } + + private sealed record Context(TenantId TenantId, OrganizationId DefaultOrganization, OrganizationId? Organization = null) : ITenantContext + { + public bool IsResolved => true; + public OrganizationId? OrganizationId => Organization; + public UserId? UserId => null; + public TenantContextOrigin? Origin => TenantContextOrigin.Ambient; + public string? CorrelationId => null; + public string? ModuleName => "tenancy"; + } + + private sealed class ThrowOnSecondTenancySave : SaveChangesInterceptor + { + private int _saves; + public override ValueTask> SavingChangesAsync(DbContextEventData eventData, + InterceptionResult result, CancellationToken cancellationToken = default) + { + if (eventData.Context is TenancyDbContext && ++_saves == 2) + { + throw new InvalidOperationException("injected second Tenancy save"); + } + + return ValueTask.FromResult(result); + } + } + + private sealed class ReadBarrier + { + private readonly TaskCompletionSource _both = new(TaskCreationOptions.RunContinuationsAsynchronously); + private int _readers; + public async Task WaitAsync(CancellationToken cancellationToken) + { + if (Interlocked.Increment(ref _readers) == 2) + { + _both.TrySetResult(); + } + + await _both.Task.WaitAsync(TimeSpan.FromSeconds(20), cancellationToken); + } + } + + private sealed class BarrierTenantStore(ITenantWriteStore inner, ReadBarrier barrier) : ITenantWriteStore + { + public async Task FindAsync(TenantId id, CancellationToken cancellationToken = default) + { + var tenant = await inner.FindAsync(id, cancellationToken); + await barrier.WaitAsync(cancellationToken); + return tenant; + } + public Task AddAsync(Tenant aggregate, CancellationToken cancellationToken = default) => inner.AddAsync(aggregate, cancellationToken); + public Task UpdateAsync(Tenant aggregate, CancellationToken cancellationToken = default) => inner.UpdateAsync(aggregate, cancellationToken); + } + + private sealed class UnexpectedSettingAccess : ITenantSettingWriteStore + { + public Task FindAsync(TenantSettingId id, CancellationToken cancellationToken = default) => + throw new InvalidOperationException("A refused tenant must not load a setting."); + public Task AddAsync(TenantSetting aggregate, CancellationToken cancellationToken = default) => + throw new InvalidOperationException("A refused tenant must not create a setting."); + public Task UpdateAsync(TenantSetting aggregate, CancellationToken cancellationToken = default) => + throw new InvalidOperationException("A refused tenant must not replace a setting."); + } + + private sealed class BarrierSettingStore(ITenantSettingWriteStore inner, ReadBarrier barrier) : ITenantSettingWriteStore + { + public async Task FindAsync(TenantSettingId id, CancellationToken cancellationToken = default) + { + var setting = await inner.FindAsync(id, cancellationToken); + await barrier.WaitAsync(cancellationToken); + return setting; + } + + public Task AddAsync(TenantSetting aggregate, CancellationToken cancellationToken = default) => inner.AddAsync(aggregate, cancellationToken); + public Task UpdateAsync(TenantSetting aggregate, CancellationToken cancellationToken = default) => inner.UpdateAsync(aggregate, cancellationToken); + } +} diff --git a/backend/tests/LearnStack.Tests.Integration/Database/TenantIsolationHttpTests.cs b/backend/tests/LearnStack.Tests.Integration/Database/TenantIsolationHttpTests.cs index 634c8ea3..2e47e10b 100644 --- a/backend/tests/LearnStack.Tests.Integration/Database/TenantIsolationHttpTests.cs +++ b/backend/tests/LearnStack.Tests.Integration/Database/TenantIsolationHttpTests.cs @@ -219,11 +219,11 @@ public async Task Tenant_A_cannot_read_Tenant_B_customizations() var english = await ReadCustomizationsAsync(SeedData.English.Host); var yoga = await ReadCustomizationsAsync(SeedData.Yoga.Host); - english.Should().BeEquivalentTo(BuiltIns(SeedData.English.TenantId), + english.Should().BeEquivalentTo(SeedData.CustomizationProjection(SeedData.English), "one of each, owned by this tenant — two of each would be both tenants', " + "and the other tenant's id would be a substitution"); - yoga.Should().BeEquivalentTo(BuiltIns(SeedData.Yoga.TenantId)); + yoga.Should().BeEquivalentTo(SeedData.CustomizationProjection(SeedData.Yoga)); english.Should().NotIntersectWith(yoga, "the keys are the same for both and the rows are not"); @@ -244,8 +244,8 @@ public async Task The_policy_holds_the_customization_tables_without_the_filter() var english = await GetAsync(SeedData.English.Host, "customizations-unfiltered"); var yoga = await GetAsync(SeedData.Yoga.Host, "customizations-unfiltered"); - english.Should().BeEquivalentTo(BuiltIns(SeedData.English.TenantId)); - yoga.Should().BeEquivalentTo(BuiltIns(SeedData.Yoga.TenantId)); + english.Should().BeEquivalentTo(SeedData.CustomizationProjection(SeedData.English)); + yoga.Should().BeEquivalentTo(SeedData.CustomizationProjection(SeedData.Yoga)); } [Fact] @@ -270,16 +270,6 @@ public async Task An_unresolved_request_reads_no_customization() (await ReadCustomizationsAsync(SeedData.English.Host)).Should().NotBeEmpty(); } - /// The built-in seed as the probe projects it, for one owner. - private static string[] BuiltIns(TenantId tenantId) => - [ - $"content-type:card@{tenantId}", - $"taxonomy:plain@{tenantId}", - $"band:beginner@{tenantId}", - $"band:intermediate@{tenantId}", - $"band:advanced@{tenantId}", - ]; - private async Task> ReadCustomizationsAsync(string host) => await GetAsync(host, "customizations"); diff --git a/backend/tests/LearnStack.Tests.Integration/Database/TenantLocaleEnabledMigrationTests.cs b/backend/tests/LearnStack.Tests.Integration/Database/TenantLocaleEnabledMigrationTests.cs new file mode 100644 index 00000000..ad77f591 --- /dev/null +++ b/backend/tests/LearnStack.Tests.Integration/Database/TenantLocaleEnabledMigrationTests.cs @@ -0,0 +1,96 @@ +using FluentAssertions; +using LearnStack.Modules.Tenancy.Infrastructure.Persistence; +using LearnStack.SharedKernel.Tenancy; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Npgsql; +using Xunit; + +namespace LearnStack.Tests.Integration.Database; + +[Trait(RequiresDocker.Key, RequiresDocker.Value)] +[Collection(SharedSchema.Name)] +public sealed class TenantLocaleEnabledMigrationTests(SchemaFixture schema) +{ + private const string Predecessor = "20260914114306_org_insert_scope_and_keyless_guard"; + + [Fact] + public async Task Invalid_legacy_default_blocks_migration_without_repair_and_valid_rows_survive_down_reapply() + { + await using var database = await DisposableSchemaDatabase.CreateAsync(schema.Postgres, applyMigrations: false); + await using var migration = new TenancyDbContext(new DbContextOptionsBuilder() + .UseNpgsql(database.MigrationConnectionString, + provider => provider.MigrationsHistoryTable(TenancyDbContextFactory.HistoryTable)).Options, + StaticTenantContextAccessor.Unresolved); + await migration.GetService().MigrateAsync(Predecessor); + await using (var app = await PostgresFixture.OpenAsync(database.AppConnectionString)) + await using (var insert = new NpgsqlCommand(""" + BEGIN; + SET LOCAL app.tenant_id = '11111111-1111-7111-8111-111111111111'; + INSERT INTO tenants (id, slug, display_name, status, created_at, created_by, row_version) + VALUES ('11111111-1111-7111-8111-111111111111', 'locale-migration', 'Locale', 'Trial', now(), + '00000000-0000-7000-8000-000000000001', 7); + INSERT INTO tenant_locales (tenant_id, locale, is_default, is_enabled, sort) + VALUES ('11111111-1111-7111-8111-111111111111', 'en', true, false, 0); + COMMIT; + """, (NpgsqlConnection)app)) + { + await insert.ExecuteNonQueryAsync(); + } + + var invalid = await SnapshotAsync(database.AppConnectionString); + var apply = async () => await migration.Database.MigrateAsync(); + var refused = (await apply.Should().ThrowAsync()).Which; + refused.SqlState.Should().Be(PostgresErrorCodes.CheckViolation); + refused.ConstraintName.Should().Be("ck_tenant_locales_default_enabled"); + (await SnapshotAsync(database.AppConnectionString)).Should().Be(invalid); + (await migration.Database.GetAppliedMigrationsAsync()).Last().Should().Be(Predecessor); + + // Explicit test-owned operator remediation; migration never chooses a locale. + await using (var app = await PostgresFixture.OpenAsync(database.AppConnectionString)) + await using (var repair = new NpgsqlCommand(""" + BEGIN; + SET LOCAL app.tenant_id = '11111111-1111-7111-8111-111111111111'; + UPDATE tenant_locales SET is_enabled = true; + COMMIT; + """, (NpgsqlConnection)app)) + { + await repair.ExecuteNonQueryAsync(); + } + + var valid = await SnapshotAsync(database.AppConnectionString); + await migration.Database.MigrateAsync(); + (await SnapshotAsync(database.AppConnectionString)).Should().Be(valid); + migration.Database.HasPendingModelChanges().Should().BeFalse(); + await migration.GetService().MigrateAsync(Predecessor); + await using (var owner = await PostgresFixture.OpenAsync(database.MigrationConnectionString)) + await using (var check = new NpgsqlCommand("SELECT count(*) FROM pg_constraint WHERE conrelid = 'tenant_locales'::regclass AND conname = 'ck_tenant_locales_default_enabled'", (NpgsqlConnection)owner)) + (await check.ExecuteScalarAsync()).Should().Be(0L, "Down must actually remove the new CHECK before reapply"); + (await SnapshotAsync(database.AppConnectionString)).Should().Be(valid); + await migration.Database.MigrateAsync(); + (await SnapshotAsync(database.AppConnectionString)).Should().Be(valid); + await using var connection = await PostgresFixture.OpenAsync(database.AppConnectionString); + await using var transaction = await connection.BeginTransactionAsync(); + await EducationSchemaSeed.AnnounceAsync(connection, transaction, SchemaFixture.TenantA, null); + await using var invalidUpdate = new NpgsqlCommand("UPDATE tenant_locales SET is_enabled = false", + (NpgsqlConnection)connection, (NpgsqlTransaction)transaction); + var update = async () => await invalidUpdate.ExecuteNonQueryAsync(); + (await update.Should().ThrowAsync()).Which.ConstraintName.Should().Be("ck_tenant_locales_default_enabled"); + await transaction.RollbackAsync(); + } + + private static async Task SnapshotAsync(string connectionString) + { + await using var app = await PostgresFixture.OpenAsync(connectionString); + await using var transaction = await app.BeginTransactionAsync(); + await EducationSchemaSeed.AnnounceAsync(app, transaction, SchemaFixture.TenantA, null); + await using var command = new NpgsqlCommand(""" + SELECT jsonb_build_object('tenant', (SELECT to_jsonb(t) FROM tenants t), + 'locales', (SELECT jsonb_agg(to_jsonb(l) ORDER BY locale) FROM tenant_locales l))::text + """, (NpgsqlConnection)app, (NpgsqlTransaction)transaction); + var snapshot = (string)(await command.ExecuteScalarAsync())!; + await transaction.RollbackAsync(); + return snapshot; + } +} diff --git a/backend/tests/LearnStack.Tests.Integration/Database/UnitOfWorkTests.cs b/backend/tests/LearnStack.Tests.Integration/Database/UnitOfWorkTests.cs index 17f3b17f..be479ebd 100644 --- a/backend/tests/LearnStack.Tests.Integration/Database/UnitOfWorkTests.cs +++ b/backend/tests/LearnStack.Tests.Integration/Database/UnitOfWorkTests.cs @@ -734,8 +734,10 @@ public async Task The_runtime_role_does_not_bypass_row_security() "every isolation assertion in this suite is vacuous against a bypass role"); } - [Fact] - public async Task The_data_source_refuses_a_runtime_role_that_was_granted_bypass() + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task The_data_source_refuses_a_runtime_role_that_was_granted_bypass(bool seederBuilder) { // The name check cannot see this one: the connection string still says // learnstack_app. Only the server knows the role was granted BYPASSRLS, @@ -754,8 +756,8 @@ public async Task The_data_source_refuses_a_runtime_role_that_was_granted_bypass // CREATEROLE plus ADMIN OPTION. await _schema.Postgres.ExecuteAsSuperuserAsync("ALTER ROLE learnstack_app BYPASSRLS"); - await using var dataSource = PersistenceCompositionExtensions.BuildApplicationDataSource( - _schema.Postgres.AppConnectionString); + await using var dataSource = (seederBuilder ? ApplicationDataSource.Build(_schema.Postgres.AppConnectionString) + : PersistenceCompositionExtensions.BuildApplicationDataSource(_schema.Postgres.AppConnectionString)); var open = async () => { @@ -772,15 +774,17 @@ public async Task The_data_source_refuses_a_runtime_role_that_was_granted_bypass // And the same data source is fine once the grant is gone, so the guard is // a guard rather than a permanent refusal. - await using var restored = PersistenceCompositionExtensions.BuildApplicationDataSource( - _schema.Postgres.AppConnectionString); + await using var restored = (seederBuilder ? ApplicationDataSource.Build(_schema.Postgres.AppConnectionString) + : PersistenceCompositionExtensions.BuildApplicationDataSource(_schema.Postgres.AppConnectionString)); await using var healthy = await restored.OpenConnectionAsync(); healthy.State.Should().Be(System.Data.ConnectionState.Open); } - [Fact] - public async Task The_data_source_refuses_a_runtime_role_that_can_reach_one() + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task The_data_source_refuses_a_runtime_role_that_can_reach_one(bool seederBuilder) { // The escalation a check on the role's own attributes cannot see: // `GRANT learnstack_platform TO learnstack_app` leaves learnstack_app's @@ -794,8 +798,8 @@ await _schema.Postgres.ExecuteAsSuperuserAsync( + "GRANT learnstack_platform TO uow_bridge; " + "GRANT uow_bridge TO learnstack_app"); - await using var dataSource = PersistenceCompositionExtensions.BuildApplicationDataSource( - _schema.Postgres.AppConnectionString); + await using var dataSource = (seederBuilder ? ApplicationDataSource.Build(_schema.Postgres.AppConnectionString) + : PersistenceCompositionExtensions.BuildApplicationDataSource(_schema.Postgres.AppConnectionString)); var open = async () => { diff --git a/backend/tests/LearnStack.Tests.Unit/Api/Composition/ApplicationDataSourceGuardTests.cs b/backend/tests/LearnStack.Tests.Unit/Api/Composition/ApplicationDataSourceGuardTests.cs index d34ab2af..7a5a4cfd 100644 --- a/backend/tests/LearnStack.Tests.Unit/Api/Composition/ApplicationDataSourceGuardTests.cs +++ b/backend/tests/LearnStack.Tests.Unit/Api/Composition/ApplicationDataSourceGuardTests.cs @@ -1,5 +1,6 @@ using FluentAssertions; using LearnStack.Api.Composition; +using LearnStack.Infrastructure.Persistence; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Xunit; @@ -34,6 +35,19 @@ public sealed class ApplicationDataSourceGuardTests private const string Valid = "Host=localhost;Port=5432;Database=learnstack;Username=learnstack_app;Password=s3cret"; // leakwatch:ignore + [Theory] + [InlineData("Host=localhost;Username=learnstack_migration;Password=synthetic-canary")] // leakwatch:ignore + [InlineData("Host=localhost;Username=learnstack_platform;Pwd=synthetic-canary")] // leakwatch:ignore + [InlineData("Host=localhost;Port=nope;Username=learnstack_app;Password=synthetic-canary")] // leakwatch:ignore + [InlineData("postgres://learnstack_app:synthetic-canary@localhost/database")] // leakwatch:ignore + public void The_shared_seeder_guard_refuses_wrong_or_malformed_credentials_without_secret_echo(string value) + { + var build = () => ApplicationDataSource.Build(value); + var failure = build.Should().Throw().Which; + failure.ToString().Should().NotContain("synthetic-canary"); + failure.Message.Should().Contain("ConnectionStrings:Default"); + } + [Fact] public void The_application_role_is_accepted() { diff --git a/backend/tests/LearnStack.Tests.Unit/Education/EducationAggregateTests.cs b/backend/tests/LearnStack.Tests.Unit/Education/EducationAggregateTests.cs index 0bef7aa9..e79f77d2 100644 --- a/backend/tests/LearnStack.Tests.Unit/Education/EducationAggregateTests.cs +++ b/backend/tests/LearnStack.Tests.Unit/Education/EducationAggregateTests.cs @@ -18,7 +18,7 @@ public sealed class EducationAggregateTests private static readonly LessonId LessonId = LessonId.From(Guid.Parse("aaaaaaaa-1111-7111-8111-111111111111")); private static Course NewCourse(OrganizationId? organization = null, string slug = "course") => - Course.Create(CourseId, Tenant, organization, slug, Clock, Actor); + Course.Create(CourseId, Tenant, organization, slug, CourseContentAccess.EnrollmentRequired, Clock, Actor); private static Lesson NewLesson(Course? course = null, int sort = 0, string key = "content", int version = 1) => Lesson.Create(LessonId, course ?? NewCourse(), sort, key, version, Clock, Actor); @@ -56,6 +56,27 @@ public void Create_DerivesScopeAndStartsDraftWithoutMutatingCourse(bool scoped) lesson.UpdatedAt.Should().BeNull(); } + [Theory] + [InlineData(CourseContentAccess.Public)] + [InlineData(CourseContentAccess.EnrollmentRequired)] + public void Content_access_is_explicit_and_independent_of_publication(CourseContentAccess policy) + { + var course = Course.Create(CourseId, Tenant, null, "course", policy, Clock, Actor); + course.ContentAccess.Should().Be(policy); + course.Publish(Later, Actor).IsSuccess.Should().BeTrue(); + course.ContentAccess.Should().Be(policy); + var lesson = NewLesson(course); + lesson.Publish(Later, Actor).IsSuccess.Should().BeTrue(); + course.ContentAccess.Should().Be(policy); + } + + [Fact] + public void Undefined_content_access_cannot_create_a_course() + { + var create = () => Course.Create(CourseId, Tenant, null, "course", (CourseContentAccess)99, Clock, Actor); + create.Should().Throw(); + } + [Fact] public void Publish_EmptyRootsSucceedIndependentlyAndRepeatedCallsPreserveState() { @@ -230,7 +251,7 @@ public void Translations_ExposedCollectionCannotBeMutatedByDowncast() [InlineData("taxonomy", 1, "")] public void Create_PartialOrInvalidLevelPinRefuses(string? key, int? version, string? band) { - var create = () => Course.Create(CourseId, Tenant, null, "course", Clock, Actor, key, version, band); + var create = () => Course.Create(CourseId, Tenant, null, "course", CourseContentAccess.EnrollmentRequired, Clock, Actor, key, version, band); create.Should().Throw(); } @@ -239,7 +260,7 @@ public void Create_CompleteLevelPinRemainsExactAcrossPublication() { var key = new string('k', 100); var band = new string('b', 100); - var course = Course.Create(CourseId, Tenant, null, "course", Clock, Actor, key, 7, band); + var course = Course.Create(CourseId, Tenant, null, "course", CourseContentAccess.EnrollmentRequired, Clock, Actor, key, 7, band); course.Publish(Later, Actor).IsSuccess.Should().BeTrue(); course.LevelTaxonomyKey.Should().Be(key); course.LevelTaxonomySchemaVersion.Should().Be(7); diff --git a/backend/tests/LearnStack.Tests.Unit/Education/EducationInputTests.cs b/backend/tests/LearnStack.Tests.Unit/Education/EducationInputTests.cs index 700faae8..3aa8ca55 100644 --- a/backend/tests/LearnStack.Tests.Unit/Education/EducationInputTests.cs +++ b/backend/tests/LearnStack.Tests.Unit/Education/EducationInputTests.cs @@ -16,7 +16,7 @@ public sealed class EducationInputTests private static readonly LessonId LessonId = LessonId.From(Guid.Parse("aaaaaaaa-1111-7111-8111-111111111111")); private static Course NewCourse(string slug = "course") => - Course.Create(CourseId, Tenant, null, slug, Clock, Actor); + Course.Create(CourseId, Tenant, null, slug, CourseContentAccess.EnrollmentRequired, Clock, Actor); private static Lesson NewLesson() => Lesson.Create(LessonId, NewCourse(), 0, "content", 1, Clock, Actor); @@ -82,8 +82,8 @@ public void Slug_UsesEducationWidthAndPreservesValidValues() public void PinKey_InvalidShapeRefusesInEveryPinPosition(string key) { var content = () => Lesson.Create(LessonId, NewCourse(), 0, key, 1, Clock, Actor); - var taxonomy = () => Course.Create(CourseId, Tenant, null, "course", Clock, Actor, key, 1, "band"); - var band = () => Course.Create(CourseId, Tenant, null, "course", Clock, Actor, "taxonomy", 1, key); + var taxonomy = () => Course.Create(CourseId, Tenant, null, "course", CourseContentAccess.EnrollmentRequired, Clock, Actor, key, 1, "band"); + var band = () => Course.Create(CourseId, Tenant, null, "course", CourseContentAccess.EnrollmentRequired, Clock, Actor, "taxonomy", 1, key); content.Should().Throw(); taxonomy.Should().Throw(); band.Should().Throw(); @@ -224,12 +224,12 @@ public void Create_UnassignedOrEmptyScopeAndIdentifiersRefuse() var id = (new CourseId[1])[0]; var tenant = (new TenantId[1])[0]; var organization = (new OrganizationId[1])[0]; - var invalidId = () => Course.Create(id, Tenant, null, "course", Clock, Actor); - var invalidTenant = () => Course.Create(CourseId, tenant, null, "course", Clock, Actor); - var invalidOrganization = () => Course.Create(CourseId, Tenant, organization, "course", Clock, Actor); - var sentinel = () => Course.Create(CourseId, TenantId.PlatformSentinel, null, "course", Clock, Actor); - var emptyId = () => Course.Create(CourseId.From(Guid.Empty), Tenant, null, "course", Clock, Actor); - var emptyOrganization = () => Course.Create(CourseId, Tenant, OrganizationId.From(Guid.Empty), "course", Clock, Actor); + var invalidId = () => Course.Create(id, Tenant, null, "course", CourseContentAccess.EnrollmentRequired, Clock, Actor); + var invalidTenant = () => Course.Create(CourseId, tenant, null, "course", CourseContentAccess.EnrollmentRequired, Clock, Actor); + var invalidOrganization = () => Course.Create(CourseId, Tenant, organization, "course", CourseContentAccess.EnrollmentRequired, Clock, Actor); + var sentinel = () => Course.Create(CourseId, TenantId.PlatformSentinel, null, "course", CourseContentAccess.EnrollmentRequired, Clock, Actor); + var emptyId = () => Course.Create(CourseId.From(Guid.Empty), Tenant, null, "course", CourseContentAccess.EnrollmentRequired, Clock, Actor); + var emptyOrganization = () => Course.Create(CourseId, Tenant, OrganizationId.From(Guid.Empty), "course", CourseContentAccess.EnrollmentRequired, Clock, Actor); var invalidLessonId = () => Lesson.Create((new LessonId[1])[0], NewCourse(), 0, "content", 1, Clock, Actor); invalidId.Should().Throw(); invalidTenant.Should().Throw(); diff --git a/backend/tests/LearnStack.Tests.Unit/Infrastructure/Audit/AuditChangeTrackerInterceptorTests.cs b/backend/tests/LearnStack.Tests.Unit/Infrastructure/Audit/AuditChangeTrackerInterceptorTests.cs index 6ab1dd4c..3b2cbc55 100644 --- a/backend/tests/LearnStack.Tests.Unit/Infrastructure/Audit/AuditChangeTrackerInterceptorTests.cs +++ b/backend/tests/LearnStack.Tests.Unit/Infrastructure/Audit/AuditChangeTrackerInterceptorTests.cs @@ -26,12 +26,10 @@ namespace LearnStack.Tests.Unit.Infrastructure.Audit; /// measure exactly what a save would have given it. /// /// -/// The entities are the suite's own, and that is not laziness: neither redaction gate has -/// a shipped consumer yet. No property in Tenancy or Customization carries -/// and none is named for a -/// token — the first personal data lands with Identity -/// in Phase 03. A gate with no test until its first consumer arrives is a gate that ships -/// wrong and is discovered by the consumer. +/// Synthetic entities independently constrain both redaction gates. P02d-2 adds the +/// first production marker on generic TenantSetting.Value; its real composed capture +/// and durable audit proof lives in the integration TenancyWriterTests. These cases +/// retain coverage of inheritance, name tokens and whole JSON redaction. /// /// public sealed class AuditChangeTrackerInterceptorTests diff --git a/backend/tests/LearnStack.Tests.Unit/Modules/Customization/CustomizationCommandTests.cs b/backend/tests/LearnStack.Tests.Unit/Modules/Customization/CustomizationCommandTests.cs index 1fd938f5..18fc75c3 100644 --- a/backend/tests/LearnStack.Tests.Unit/Modules/Customization/CustomizationCommandTests.cs +++ b/backend/tests/LearnStack.Tests.Unit/Modules/Customization/CustomizationCommandTests.cs @@ -360,6 +360,30 @@ public async Task Every_taxonomy_a_document_names_is_asked_about_in_one_query() .Which.Should().Be("proficiency,second", "and it asks about each distinct key"); } + [Fact] + public async Task A_recognized_extension_without_a_resolver_is_refused_before_any_write() + { + var (sender, stores) = Build(gate: Reporting(("/x-future", "x-future", "value"))); + var result = await sender.Send(RegisterContentType()); + result.IsFailure.Should().BeTrue(); + result.Error!.Details.Should().ContainKey("/x-future"); + stores.Writes.Should().BeEmpty(); + } + + [Fact] + public async Task Text_card_semantic_failure_happens_after_admission_and_before_persistence() + { + var (sender, stores) = Build(); + var command = RegisterContentType() with + { + JsonSchema = """{"type":"object","properties":{"body":{"type":"string"}},"additionalProperties":false,"x-fields":[{"name":"unknown","label":{"en":"Label"}}]}""", + }; + var result = await sender.Send(command); + result.IsFailure.Should().BeTrue(); + result.Error!.Details.Should().ContainKey("/x-fields/0/name"); + stores.Writes.Should().BeEmpty(); + } + [Fact] public async Task An_x_language_is_admitted_because_its_registry_does_not_exist() { @@ -609,7 +633,7 @@ public async Task A_race_lost_on_the_incumbent_asks_the_caller_to_re_read(string // The answer IOptimisticConcurrency's own remarks promise. Untranslated this // is a DbUpdateException, which HttpStatusMap has no arm for — a 500 for the // one outcome the concurrency token exists to report. - var (sender, stores) = Build(); + var (sender, stores, unit) = BuildWithUnit(); if (subject == "content-type") { @@ -622,6 +646,7 @@ public async Task A_race_lost_on_the_incumbent_asks_the_caller_to_re_read(string var result = await sender.Send(new PublishTenantContentTypeCommand(successorId)); result.Error!.Message.Key.Should().Be("lockey_concurrency_conflict"); + unit.IsRollbackOnly.Should().BeTrue("a refused incumbent save must poison the shared unit"); return; } @@ -634,6 +659,7 @@ public async Task A_race_lost_on_the_incumbent_asks_the_caller_to_re_read(string var taxonomyResult = await sender.Send(new PublishTenantLevelTaxonomyCommand(successor)); taxonomyResult.Error!.Message.Key.Should().Be("lockey_concurrency_conflict"); + unit.IsRollbackOnly.Should().BeTrue("a refused incumbent save must poison the shared unit"); } [Theory] @@ -889,11 +915,11 @@ public async Task A_publish_whose_successor_collides_after_the_retirement_poison } [Fact] - public async Task A_publish_that_never_retired_anything_leaves_the_unit_alone() + public async Task A_first_publication_save_failure_poisons_the_unit() { - // The other edge. A first-ever publish writes only the successor, so a - // failure there has nothing committed behind it — marking the unit would - // roll back an outer handler's own work for no reason. + // A first publication still mutates the tracked successor before saving. + // If an outer handler absorbs this failure, a later save could flush it. + // The real database absorption tests prove rollback-only is required. var (sender, stores, unit) = BuildWithUnit(); await sender.Send(RegisterContentType()); @@ -902,7 +928,7 @@ public async Task A_publish_that_never_retired_anything_leaves_the_unit_alone() var result = await sender.Send(new PublishTenantContentTypeCommand(ContentTypeId)); result.Error!.Message.Key.Should().Be("lockey_concurrency_conflict"); - unit.IsRollbackOnly.Should().BeFalse("nothing was written before the failure"); + unit.IsRollbackOnly.Should().BeTrue("the failed save must not leave dirty publication state available to an outer handler"); } // ── What the validators refuse ──────────────────────────────────────── @@ -969,6 +995,7 @@ public void Every_renderer_key_the_closed_set_names_is_accepted() // The other side of the same rule. Asserted over the set rather than over one // member, because a predicate that answered false for everything would pass // the refusal case on its own. + CompositeRendererKey.All.Should().NotBeEmpty("the admission loop must exercise the closed registry; Fix: restore the renderer set"); foreach (var key in CompositeRendererKey.All) { Refuse(new RegisterTenantContentTypeCommand( diff --git a/backend/tests/LearnStack.Tests.Unit/Modules/Customization/TextCardPresentationTests.cs b/backend/tests/LearnStack.Tests.Unit/Modules/Customization/TextCardPresentationTests.cs new file mode 100644 index 00000000..fac9680b --- /dev/null +++ b/backend/tests/LearnStack.Tests.Unit/Modules/Customization/TextCardPresentationTests.cs @@ -0,0 +1,141 @@ +using System.Text.Json.Nodes; +using FluentAssertions; +using LearnStack.Infrastructure.Validation; +using LearnStack.Modules.Customization.Application.Customization; +using Xunit; + +namespace LearnStack.Tests.Unit.Modules.Customization; + +public sealed class TextCardPresentationTests +{ + private readonly JsonSchemaNetValidator _validator = new(); + private const string Schema = """ + {"$schema":"https://json-schema.org/draft/2020-12/schema","type":"object", + "properties":{"first":{"type":"string","minLength":1},"second":{"type":"string"}}, + "additionalProperties":false,"required":["first"], + "x-fields":[{"name":"second","label":{"TR-tr":"İkinci","en":"Second"}}, + {"name":"first","label":{"en":"First"}}]} + """; + + [Fact] + public void Descriptors_preserve_array_order_and_canonical_localized_fallback() + { + _validator.AdmitSchema(Schema).IsSuccess.Should().BeTrue(); + var result = TextCardPresentation.Resolve(Schema, "default-card"); + result.IsSuccess.Should().BeTrue(); + result.Value.Select(field => field.Name).Should().Equal("second", "first"); + result.Value[0].Label.Locales.Should().Equal("en", "tr-TR"); + result.Value[1].Label.Resolve("tr-TR").Should().Be("First"); + _validator.ValidateInstance(Schema, """{"first":""}""").IsFailure.Should().BeTrue(); + _validator.ValidateInstance(Schema, """{"first":"https://example.com/