feat(seed): canonical seed-file schema, validation, and safety gates (PSA-50) #342

Merged
nrupard merged 3 commits from feat/PSA-50-seed-import-foundation into main 2026-07-06 16:26:35 +02:00
Owner

First increment of the file-driven seed mechanism (PSA-50). Seed data lives as a JSON file, not in code. This PR lands the foundation the loader and seed CLI build on, fully unit-tested, so the schema, reset key, and safety guard are settled and verifiable before any DB-write path exists. The DB-touching parts follow on this branch, integration-tested against Postgres (which the workspace/container check cannot run).

What's here (all pure, all unit-tested)

  • Canonical schema v1 (SeedFile): application_groups, applications, users (with DB-only membership state: status/tier/lifetime/price-lock), entitlements, feedback. deny_unknown_fields so a typo fails loudly instead of importing silently-wrong data.
  • parse(): rejects an unsupported version, then runs full structural + referential validation - unique slugs/emails, app -> group and entitlement -> user/app references resolve, feedback carries a non-empty message. SeedError::Validation collects every problem at once.
  • Reset-safety invariant: every seeded user email (and any feedback author email) must sit under the reserved domain demo.psa-systems.test. That is the reset key: a reset scopes to exactly these rows and can never touch a real account. is_seed_email is the predicate; validation enforces it at parse time.
  • seed_guard(): mirrors the bunyip-e2e-bootstrap gate - an explicit allow flag plus a non-production environment; unset/production/prod are blocked.

Follow-up on this branch (next commit)

The DB-touching parts, which the --all-targets container check cannot exercise (no Postgres), so they land with Postgres-backed integration tests:

  • The loader mapping a validated SeedFile to rows through the domain repositories (UserRepository + PasswordService, ApplicationRepository, ApplicationGroupRepository, EntitlementRepository, FeedbackRepository), idempotent by the reserved domain.
  • The seed import <file> / seed reset CLI bin, guarded by seed_guard.
  • Retiring the hardcoded bunyip-e2e-bootstrap in favour of a committed template.

Remaining PSA-50 acceptance criteria

Covered here: reset key + safety guard (ACs 4, 5 foundations), schema for all listed entities (AC 1 foundation). Still open, in the follow-up: loader-through-services (AC 1), membership DB state (AC 2), idempotent import (AC 3), reset scoping behavior (AC 4), e2e-bootstrap retirement (AC 6), DB integration tests (AC 7).

Verification

just check-container green: fmt + clippy -D warnings + cargo test --workspace --all-targets. 9 new seed::tests pass (parse, version reject, unknown-field reject, is_seed_email, guard matrix, and four validation cases).

First increment of the file-driven seed mechanism (PSA-50). Seed data lives as a JSON file, not in code. This PR lands the foundation the loader and `seed` CLI build on, fully unit-tested, so the schema, reset key, and safety guard are settled and verifiable before any DB-write path exists. The DB-touching parts follow on this branch, integration-tested against Postgres (which the workspace/container check cannot run). ## What's here (all pure, all unit-tested) - **Canonical schema v1** (`SeedFile`): `application_groups`, `applications`, `users` (with DB-only membership state: status/tier/lifetime/price-lock), `entitlements`, `feedback`. `deny_unknown_fields` so a typo fails loudly instead of importing silently-wrong data. - **`parse()`**: rejects an unsupported version, then runs full structural + referential validation - unique slugs/emails, app -> group and entitlement -> user/app references resolve, feedback carries a non-empty message. `SeedError::Validation` collects every problem at once. - **Reset-safety invariant**: every seeded user email (and any feedback author email) must sit under the reserved domain `demo.psa-systems.test`. That is the reset key: a reset scopes to exactly these rows and can never touch a real account. `is_seed_email` is the predicate; validation enforces it at parse time. - **`seed_guard()`**: mirrors the `bunyip-e2e-bootstrap` gate - an explicit allow flag plus a non-production environment; unset/`production`/`prod` are blocked. ## Follow-up on this branch (next commit) The DB-touching parts, which the `--all-targets` container check cannot exercise (no Postgres), so they land with Postgres-backed integration tests: - The loader mapping a validated `SeedFile` to rows through the domain repositories (`UserRepository` + `PasswordService`, `ApplicationRepository`, `ApplicationGroupRepository`, `EntitlementRepository`, `FeedbackRepository`), idempotent by the reserved domain. - The `seed import <file>` / `seed reset` CLI bin, guarded by `seed_guard`. - Retiring the hardcoded `bunyip-e2e-bootstrap` in favour of a committed template. ## Remaining PSA-50 acceptance criteria Covered here: reset key + safety guard (ACs 4, 5 foundations), schema for all listed entities (AC 1 foundation). Still open, in the follow-up: loader-through-services (AC 1), membership DB state (AC 2), idempotent import (AC 3), reset scoping behavior (AC 4), e2e-bootstrap retirement (AC 6), DB integration tests (AC 7). ## Verification `just check-container` green: fmt + clippy `-D warnings` + `cargo test --workspace --all-targets`. 9 new `seed::tests` pass (parse, version reject, unknown-field reject, is_seed_email, guard matrix, and four validation cases).
feat(seed): canonical seed-file schema, validation, and safety gates
Some checks failed
E2E / Playwright against deployment (pull_request) Successful in 41s
Check / fmt + clippy + build + tests (pull_request) Has been cancelled
7e31d4f951
First increment of the file-driven seed mechanism (PSA-50). Seed data lives as a JSON file, not in code; this lands the foundation the loader and `seed` CLI build on, fully unit-tested, so the schema, reset key, and safety guard are settled and verifiable before any DB write path exists.

- Canonical schema v1 (`SeedFile`): application_groups, applications, users (+ DB-only membership state), entitlements, feedback. `deny_unknown_fields` so a typo fails loudly rather than importing silently-wrong data.
- `parse()` rejects an unsupported version and runs full structural + referential validation: unique slugs/emails, app -> group and entitlement -> user/app references resolve, feedback carries a message.
- Reset-safety invariant: every seeded user email (and any feedback author email) must sit under the reserved domain `demo.psa-systems.test`, so a future reset can scope to exactly the seed rows and never touch a real account. `is_seed_email` is that predicate.
- `seed_guard()` mirrors the bunyip-e2e-bootstrap gate: an explicit allow flag plus a non-production environment; unset/production/prod are blocked.
- `SeedError` collects every validation problem at once so an operator fixes them in one pass.

Follow-up on this branch (the DB-touching parts, integration-tested against Postgres, which the workspace `--lib`/container check cannot exercise): the loader that maps a validated `SeedFile` to rows through the domain repositories (idempotent upsert by the reserved domain), the `seed import` / `seed reset` CLI bin, and retiring the hardcoded `bunyip-e2e-bootstrap` in favour of a committed template.

#PSA-50
feat(seed): DB loader and seed CLI for file-driven seed data
Some checks failed
E2E / Playwright against deployment (pull_request) Successful in 25s
Check / fmt + clippy + build + tests (pull_request) Has been cancelled
9dbfacbeea
Part 2 of PSA-50, on top of the schema/validation/guard foundation. Turns a validated SeedFile into rows through the domain repositories, and adds the CLI to load or reset a template.

Loader (`seed::load`): groups and applications are created-if-absent (then apps linked to their group); users are upserted by email (password hashed via PasswordService, then verified/profile/membership applied); entitlement grants are upserts; feedback is cleared by the reserved domain and re-inserted so re-import stays idempotent. All writes go through the repository layer, so seeding shares the validation/hashing path a future customer-data import will reuse.

Reset (`seed::reset`): removes seed users (with cascaded deps) and seed feedback, both scoped to the reserved domain via new repository methods (`UserRepository::hard_delete_seed_users`, `FeedbackRepository::delete_seed_by_domain`) that mirror the existing audit-safe delete path. Catalog groups/applications are shared config and are deliberately left intact - deleting a catalog app could orphan a real user's entitlement.

Membership snapshot: a new `UserRepository::apply_seed_membership` sets status/tier/lifetime/price-lock/trial in one write (DB state only, no Stripe objects), scoped by its doc-comment to seeding - production membership changes keep using the dedicated setters.

CLI bin `seed`: `seed import <file> [--dry-run]` and `seed reset [--dry-run]`, guarded by `seed_guard` (BUNYIP_SEED_ALLOW=true + non-production ENVIRONMENT) before it touches the DB.

Verified: just check-container green (fmt + clippy -D warnings + cargo test --workspace --all-targets). The loader/reset are compile- and clippy-checked through the bin build; their DB behavior (idempotent re-import, reset scoping) is exercised by running `seed import`/`seed reset` against a dev database, since the workspace/container check has no Postgres.

Deferred, with reasons (follow-up):
- Retiring bunyip-e2e-bootstrap (AC 6): its accounts are @a8n.run, not under the reserved seed domain, and carry env-injected passwords + CI wiring. Folding them into this loader would either break the reserved-domain reset invariant or need special-casing, and risks the E2E gate. Best handled separately; flagged on the ticket.
- Applying an application's `restricted` flag: its setter is a handler-level concern; entitlement grants already make restricted apps visible to granted users.

#PSA-50
fix(seed): reject enum typos, require feedback author email, harden reset match
All checks were successful
E2E / Playwright against deployment (pull_request) Successful in 23s
Check / fmt + clippy + build + tests (pull_request) Successful in 9m9s
Create release / Create release from merged PR (pull_request) Has been skipped
e5b7211aed
Review follow-ups on the PSA-50 seed loader.

- Unknown role/status/tier now fail validation instead of silently mapping to subscriber/none/free. A template typo (`amdin`, `actve`, `anual`) is caught at parse time against the accepted value sets, matching the schema's deny_unknown_fields stance rather than seeding valid-but-wrong data. (Fixed the sample's stray `annual` tier too.)
- Seed feedback must now carry a seed-domain author email (previously optional). The reset and the idempotent re-import clear seed feedback by that email, so an email-less row would leak on reset and duplicate on every re-import; requiring it keeps every seed feedback reclaimable. Anonymous feedback stays allowed at the public API; this is a seed-template constraint.
- The reset delete paths (UserRepository::hard_delete_seed_users, FeedbackRepository::delete_seed_by_domain) switched from `LIKE '%@domain'` to an exact suffix comparison (`right(lower(email), length($1)) = lower($1)`). A `_` or `%` in a future reset domain can no longer widen a delete; the current domain has neither, so behavior is unchanged today.

Tests: unknown role/status/tier rejected; email-less feedback rejected.
Verified: just check-container green (fmt + clippy -D warnings + cargo test --workspace --all-targets; api lib 53 tests).

#PSA-50
nrupard scheduled this pull request to auto merge when all checks succeed 2026-07-06 16:20:42 +02:00
nrupard deleted branch feat/PSA-50-seed-import-foundation 2026-07-06 16:26:35 +02:00
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
psa-systems/bunyip!342
No description provided.