plutonium 0.65.0 → 0.66.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (104) hide show
  1. checksums.yaml +4 -4
  2. data/.claude/skills/plutonium/SKILL.md +43 -43
  3. data/.claude/skills/plutonium-app/SKILL.md +101 -59
  4. data/.claude/skills/plutonium-async-interactions/SKILL.md +19 -19
  5. data/.claude/skills/plutonium-auth/SKILL.md +119 -53
  6. data/.claude/skills/plutonium-behavior/SKILL.md +110 -78
  7. data/.claude/skills/plutonium-dashboard/SKILL.md +11 -4
  8. data/.claude/skills/plutonium-kanban/SKILL.md +81 -49
  9. data/.claude/skills/plutonium-resource/SKILL.md +144 -133
  10. data/.claude/skills/plutonium-tenancy/SKILL.md +104 -55
  11. data/.claude/skills/plutonium-testing/SKILL.md +130 -33
  12. data/.claude/skills/plutonium-ui/SKILL.md +151 -96
  13. data/.claude/skills/plutonium-wizard/SKILL.md +93 -82
  14. data/CHANGELOG.md +21 -0
  15. data/README.md +9 -9
  16. data/SECURITY.md +1 -1
  17. data/app/assets/plutonium.css +1 -1
  18. data/docs/.vitepress/sync-skills.mjs +6 -3
  19. data/docs/blog/introducing-plutonium-dashboards.md +4 -5
  20. data/docs/blog/introducing-plutonium-i18n.md +4 -5
  21. data/docs/getting-started/installation.md +5 -5
  22. data/docs/getting-started/tutorial/02-first-resource.md +3 -3
  23. data/docs/getting-started/tutorial/03-authentication.md +7 -7
  24. data/docs/getting-started/tutorial/04-authorization.md +21 -4
  25. data/docs/getting-started/tutorial/05-custom-actions.md +2 -2
  26. data/docs/getting-started/tutorial/06-nested-resources.md +5 -2
  27. data/docs/getting-started/tutorial/07-author-portal.md +2 -2
  28. data/docs/getting-started/tutorial/08-customizing-ui.md +45 -30
  29. data/docs/getting-started/tutorial/index.md +1 -1
  30. data/docs/guides/adding-resources.md +10 -7
  31. data/docs/guides/authentication.md +25 -25
  32. data/docs/guides/authorization.md +24 -24
  33. data/docs/guides/creating-packages.md +17 -17
  34. data/docs/guides/custom-actions.md +32 -32
  35. data/docs/guides/customizing-ui.md +29 -26
  36. data/docs/guides/dashboards.md +1 -1
  37. data/docs/guides/index.md +3 -3
  38. data/docs/guides/kanban.md +55 -55
  39. data/docs/guides/multi-tenancy.md +35 -22
  40. data/docs/guides/nested-resources.md +21 -21
  41. data/docs/guides/performance.md +3 -3
  42. data/docs/guides/search-filtering.md +13 -13
  43. data/docs/guides/testing.md +16 -12
  44. data/docs/guides/theming.md +32 -17
  45. data/docs/guides/troubleshooting.md +2 -2
  46. data/docs/guides/user-invites.md +17 -17
  47. data/docs/guides/user-profile.md +51 -24
  48. data/docs/guides/wizards.md +55 -55
  49. data/docs/reference/app/generators.md +22 -22
  50. data/docs/reference/app/index.md +15 -18
  51. data/docs/reference/app/packages.md +8 -8
  52. data/docs/reference/app/portals.md +75 -29
  53. data/docs/reference/auth/accounts.md +15 -15
  54. data/docs/reference/auth/index.md +12 -12
  55. data/docs/reference/auth/profile.md +67 -29
  56. data/docs/reference/behavior/async-interactions.md +24 -24
  57. data/docs/reference/behavior/controllers.md +28 -28
  58. data/docs/reference/behavior/index.md +5 -5
  59. data/docs/reference/behavior/interactions.md +44 -44
  60. data/docs/reference/behavior/policies.md +48 -28
  61. data/docs/reference/configuration.md +6 -6
  62. data/docs/reference/dashboard/dsl.md +2 -2
  63. data/docs/reference/dashboard/index.md +1 -1
  64. data/docs/reference/generators/lite.md +7 -7
  65. data/docs/reference/i18n.md +23 -0
  66. data/docs/reference/index.md +1 -1
  67. data/docs/reference/kanban/authorization.md +9 -9
  68. data/docs/reference/kanban/dsl.md +32 -32
  69. data/docs/reference/kanban/index.md +1 -1
  70. data/docs/reference/kanban/positioning.md +17 -15
  71. data/docs/reference/resource/actions.md +51 -51
  72. data/docs/reference/resource/definition.md +73 -73
  73. data/docs/reference/resource/export.md +6 -6
  74. data/docs/reference/resource/index.md +16 -16
  75. data/docs/reference/resource/model.md +24 -24
  76. data/docs/reference/resource/positioning.md +78 -76
  77. data/docs/reference/resource/query.md +13 -13
  78. data/docs/reference/tenancy/entity-scoping.md +65 -35
  79. data/docs/reference/tenancy/index.md +11 -11
  80. data/docs/reference/tenancy/invites.md +20 -20
  81. data/docs/reference/tenancy/nested-resources.md +13 -13
  82. data/docs/reference/testing/index.md +116 -22
  83. data/docs/reference/ui/assets.md +57 -25
  84. data/docs/reference/ui/components.md +20 -20
  85. data/docs/reference/ui/displays.md +14 -14
  86. data/docs/reference/ui/forms.md +35 -35
  87. data/docs/reference/ui/index.md +17 -15
  88. data/docs/reference/ui/layouts.md +21 -21
  89. data/docs/reference/ui/pages.md +22 -22
  90. data/docs/reference/ui/tables.md +7 -7
  91. data/docs/reference/wizard/anchoring-resume.md +33 -32
  92. data/docs/reference/wizard/dsl.md +44 -44
  93. data/docs/reference/wizard/index.md +6 -6
  94. data/docs/reference/wizard/one-time.md +18 -18
  95. data/docs/reference/wizard/registration-launch.md +32 -32
  96. data/docs/reference/wizard/storage-config.md +23 -23
  97. data/gemfiles/rails_8.1.gemfile.lock +1 -1
  98. data/lib/generators/pu/profile/conn_generator.rb +6 -0
  99. data/lib/plutonium/resource/record/associated_with.rb +23 -2
  100. data/lib/plutonium/ui/form/concerns/typeahead_attributes.rb +7 -1
  101. data/lib/plutonium/version.rb +1 -1
  102. data/package.json +1 -1
  103. data/src/css/components.css +10 -10
  104. metadata +2 -2
@@ -1,66 +1,66 @@
1
1
  ---
2
2
  name: plutonium-tenancy
3
- description: Use BEFORE any multi-tenant work — scoping a model to a tenant, writing relation_scope, configuring portal entity strategies, setting up parent/child nested resources, or wiring user invitations. The single source for entity scoping, nested resources, and invites.
3
+ description: 'Use BEFORE any multi-tenant work: scoping a model to a tenant, writing relation_scope, configuring portal entity strategies, setting up parent/child nested resources, or wiring user invitations. The single source for entity scoping, nested resources, and invites.'
4
4
  ---
5
5
 
6
- # Plutonium Tenancy — Entity Scoping, Nested Resources, Invites
6
+ # Plutonium Tenancy: Entity Scoping, Nested Resources, Invites
7
7
 
8
8
  Three closely-coupled concerns:
9
9
 
10
- 1. **Entity scoping** — every record belongs to a tenant; queries are filtered automatically.
11
- 2. **Nested resources** — parent/child URLs; parent scoping takes precedence over entity scoping.
12
- 3. **Invites** — onboarding users into a tenant's membership.
10
+ 1. **Entity scoping**: every record belongs to a tenant; queries are filtered automatically.
11
+ 2. **Nested resources**: parent/child URLs; parent scoping takes precedence over entity scoping.
12
+ 3. **Invites**: onboarding users into a tenant's membership.
13
13
 
14
14
  Cross-references back to [[plutonium-resource]] (models, definitions) and [[plutonium-behavior]] (policies, controllers).
15
15
 
16
16
  ## 🚨 Critical (read first)
17
17
 
18
- - **Never bypass `default_relation_scope`.** Overriding `relation_scope` with `where(organization: ...)` or manual joins to the entity triggers `verify_default_relation_scope_applied!`. Make sure the chain ends up calling `default_relation_scope(relation)` — explicitly, or via `super(relation)` (the framework base calls it).
18
+ - **Never bypass `default_relation_scope`.** Overriding `relation_scope` with `where(organization: ...)` or manual joins to the entity triggers `verify_default_relation_scope_applied!`. Make sure the chain ends up calling `default_relation_scope(relation)`, explicitly, or via `super(relation)` (the framework base calls it).
19
19
  - **Always declare an association path from model to entity.** Direct `belongs_to`, `has_one :through`, or a custom `associated_with_<entity>` scope. If `associated_with` can't resolve, Plutonium raises. Fix the **model**, not the policy.
20
20
  - **Parent scoping beats entity scoping.** When a parent is present (nested resource), `default_relation_scope` scopes via the parent, NOT via `entity_scope`. Don't double-scope.
21
21
  - **One level of nesting only.** Grandparent → parent → child nested routes are NOT supported. Use top-level routes for deeper relationships.
22
- - **Compound uniqueness scoped to the tenant FK.** `validates :code, uniqueness: {scope: :organization_id}` — without this, uniqueness leaks across tenants.
22
+ - **Compound uniqueness scoped to the tenant FK.** `validates :code, uniqueness: {scope: :organization_id}`; without this, uniqueness leaks across tenants.
23
23
  - **Invite email must match the accepting user's email.** Security feature. Don't disable `enforce_email?` lightly.
24
24
  - **Use generators.** `pu:saas:setup`, `pu:pkg:portal --scope=Entity`, `pu:res:scaffold`, `pu:invites:install`, `pu:invites:invitable`. Hand-wiring is how leaks happen.
25
25
 
26
26
  ---
27
27
 
28
- ## 🛑 Before you scope anything: confirm the shape (ASK — don't infer)
28
+ ## 🛑 Before you scope anything: confirm the shape (ASK: don't infer)
29
29
 
30
30
  Tenancy decisions are **underspecified by a one-line request and have high blast radius**: guess the entity, the strategy, or the association path and you ship a model that *compiles but leaks across tenants*, *raises at runtime*, or *produces the wrong URL*. "Scope X to the tenant" does **not** determine any of the below.
31
31
 
32
- Resolve each decision — **by inspecting the app (next section), not by guessing** — then restate the resolved shape in a sentence and confirm:
32
+ Resolve each decision, **by inspecting the app (next section), not by guessing**; then restate the resolved shape in a sentence and confirm:
33
33
 
34
34
  1. **Is this portal even entity-scoped?** A model is only tenant-filtered inside a portal that declares `scope_to_entity`. No `scope_to_entity` ⇒ your model change does nothing. (Verify it exists *before* touching the model.)
35
- 2. **Which entity model, and which strategy?** `Organization` / `Account` / `Tenant` / `Company`? `:path` (most common) or custom (subdomain/session)? **Never default to `Organization` + `:path`** — read it.
36
- 3. **What is the association PATH from this model to the entity?** Direct `belongs_to`, multi-hop `has_one :through`, a membership/join, or polymorphic needing a custom `associated_with_<entity>` scope (§ Three model shapes). This is the #1 thing to confirm against the **actual model** — wrong path ⇒ leak *or* raise.
37
- 4. **Nested (parent-scoped) or entity-scoped?** Reached through a parent ⇒ parent scoping wins, don't double-scope. And **nesting is ONE level only** — a three-level URL request can't be met with `register_resource` nesting; say so before wiring it.
35
+ 2. **Which entity model, and which strategy?** `Organization` / `Account` / `Tenant` / `Company`? `:path` (most common) or custom (subdomain/session)? **Never default to `Organization` + `:path`**; read it.
36
+ 3. **What is the association PATH from this model to the entity?** Direct `belongs_to`, multi-hop `has_one :through`, a membership/join, or polymorphic needing a custom `associated_with_<entity>` scope (§ Three model shapes). This is the #1 thing to confirm against the **actual model**: wrong path ⇒ leak *or* raise.
37
+ 4. **Nested (parent-scoped) or entity-scoped?** Reached through a parent ⇒ parent scoping wins, don't double-scope. And **nesting is ONE level only**: a three-level URL request can't be met with `register_resource` nesting; say so before wiring it.
38
38
  5. **Uniqueness scoped to the tenant FK?** Any `validates … uniqueness` must scope to the tenant FK (`scope: :organization_id`) or it leaks across tenants.
39
39
 
40
40
  **Never emit applied scoping code from a *guessed* association path.** Confirm the path against the real model first; fall back to `AskUserQuestion` only for genuinely product-level choices you can't read off the code (which entity, which strategy). The decisions compound: *no scoped portal ⇒ nothing filters*; *nested ⇒ parent-scoped, not entity-scoped*; *multi-hop ⇒ needs `has_one :through` or a custom scope*.
41
41
 
42
- ## ✅ Before you edit: verify the ground truth (CHECK — read it, don't ask for it)
42
+ ## ✅ Before you edit: verify the ground truth (CHECK: read it, don't ask for it)
43
43
 
44
- You have file access — **use it.** "Paste me the model" is a fallback for when you genuinely can't read the repo, **not** the default. Inspect first, then act:
44
+ You have file access, **use it.** "Paste me the model" is a fallback for when you genuinely can't read the repo, **not** the default. Inspect first, then act:
45
45
 
46
46
  | Check | How | Why it matters |
47
47
  |---|---|---|
48
48
  | Portal is scoped | `rg "scope_to_entity" -n` in the portal engine(s) | Confirms entity class + strategy; absent ⇒ scoping is a no-op |
49
- | Model is a resource | Read the model — `include Plutonium::Resource::Record` / `< ResourceRecord` | `associated_with` only exists on resource records |
49
+ | Model is a resource | Read the model, `include Plutonium::Resource::Record` / `< ResourceRecord` | `associated_with` only exists on resource records |
50
50
  | Association path resolves | Read the model's `belongs_to`/`has_one :through` chain to the entity (or a `associated_with_<entity>` scope) | This is the real fix site; missing path ⇒ raise |
51
51
  | Denormalized FK already present | Read the schema/migration for an existing `<entity>_id` column | Collapses a multi-hop chain to a one-line `belongs_to` |
52
- | No leaky override | `rg "relation_scope" -n` in the policy | A manual `where(<entity>:…)` is the leak — **remove it**, don't patch it |
52
+ | No leaky override | `rg "relation_scope" -n` in the policy | A manual `where(<entity>:…)` is the leak, **remove it**, don't patch it |
53
53
  | (Invites) prerequisites | Membership model exists with `enum :role`; AR encryption keys set (`bin/rails db:encryption:init`) | `pu:invites:install` fails loudly without both |
54
54
 
55
55
  Do this inspection with your own tools **before** proposing code. Surfacing a concrete edit you haven't grounded in the real files is how the "looks right, leaks anyway" bug ships.
56
56
 
57
- ## 🛠 Use the generator — and verify its precondition first
57
+ ## 🛠 Use the generator, and verify its precondition first
58
58
 
59
59
  Hand-wiring tenancy (invite models, membership tables, join records) is how leaks happen. Reach for the generator, run it with `--dest=` to avoid prompts, and **confirm the precondition before running**:
60
60
 
61
61
  | Task | Generator | Verify first |
62
62
  |---|---|---|
63
- | New SaaS spine (user + entity + membership + join) | `pu:saas:setup --user U --entity E` | None — this is the bootstrap |
63
+ | New SaaS spine (user + entity + membership + join) | `pu:saas:setup --user U --entity E` | None; this is the bootstrap |
64
64
  | Scope a portal to an entity | `pu:pkg:portal --scope=Entity` | Entity model exists |
65
65
  | New tenant-scoped model | `pu:res:scaffold Model entity:belongs_to …` then `pu:res:conn` | Migrations from prior scaffolds are run |
66
66
  | Invite flow | `pu:invites:install` | Membership model exists (`enum :role`) **and** AR encryption keys configured |
@@ -68,7 +68,7 @@ Hand-wiring tenancy (invite models, membership tables, join records) is how leak
68
68
 
69
69
  ---
70
70
 
71
- # Part 1 — Entity Scoping
71
+ # Part 1: Entity Scoping
72
72
 
73
73
  Built on three cooperating pieces:
74
74
 
@@ -82,12 +82,12 @@ Built on three cooperating pieces:
82
82
 
83
83
  `Model.associated_with(entity)` tries, in order:
84
84
 
85
- 1. **Custom scope** `associated_with_<entity_name>` — highest priority, full SQL control.
86
- 2. **Direct `belongs_to` to entity class** — `WHERE <entity>_id = ?`, most efficient.
87
- 3. **`has_one` / `has_one :through` to entity class** — JOIN + WHERE, auto-detected via `reflect_on_all_associations`.
88
- 4. **Reverse `has_many` from entity** — JOIN required, logs a warning (less efficient).
85
+ 1. **Custom scope** `associated_with_<entity_name>`: highest priority, full SQL control.
86
+ 2. **Direct `belongs_to` to entity class**: `WHERE <entity>_id = ?` (most efficient).
87
+ 3. **`has_one` / `has_one :through` to entity class**: JOIN + WHERE, auto-detected via `reflect_on_all_associations`.
88
+ 4. **Reverse `has_many` from entity**: JOIN required, logs a warning (less efficient).
89
89
 
90
- If none apply: `Could not resolve the association between 'Model' and 'Entity'`. Fix on the **model** — either declare an association path (`belongs_to`, `has_one :through`) OR define a custom `associated_with_<entity>` scope. Never work around this by overriding `relation_scope` in the policy.
90
+ If none apply: `Could not resolve the association between 'Model' and 'Entity'`. Fix on the **model**: either declare an association path (`belongs_to`, `has_one :through`) OR define a custom `associated_with_<entity>` scope. Never work around this by overriding `relation_scope` in the policy.
91
91
 
92
92
  ## Three model shapes
93
93
 
@@ -176,7 +176,7 @@ Use when:
176
176
 
177
177
  Picked up BEFORE association detection.
178
178
 
179
- ## `relation_scope` — safe overrides
179
+ ## `relation_scope`: safe overrides
180
180
 
181
181
  `default_relation_scope(relation)` does two things:
182
182
 
@@ -186,7 +186,7 @@ Picked up BEFORE association detection.
186
186
  ### Correct
187
187
 
188
188
  ```ruby
189
- # ✅ Best: don't override — the inherited scope already does it.
189
+ # ✅ Best: don't override: the inherited scope already does it.
190
190
 
191
191
  # ✅ Extra filters on top
192
192
  relation_scope do |relation|
@@ -203,23 +203,25 @@ end
203
203
  ### Wrong
204
204
 
205
205
  ```ruby
206
- # ❌ Manually filtering by entity — bypasses default_relation_scope
206
+ # ❌ Manually filtering by entity: bypasses default_relation_scope
207
207
  relation_scope { |r| r.where(organization: current_scoped_entity) }
208
208
 
209
- # ❌ Manual joins — same problem
209
+ # ❌ Manual joins: same problem
210
210
  relation_scope { |r| r.joins(:project).where(projects: {organization_id: current_scoped_entity.id}) }
211
211
 
212
- # ❌ Missing default_relation_scope entirely — raises at runtime
212
+ # ❌ Missing default_relation_scope entirely: raises at runtime
213
213
  relation_scope { |r| r.where(published: true) }
214
214
  ```
215
215
 
216
- **`default_relation_scope(relation)` must end up being called somewhere in the chain** — runtime verification just checks it was hit, not that you wrote it in this class. Both work:
216
+ **`default_relation_scope(relation)` must end up being called somewhere in the chain**; runtime verification just checks it was hit, not that you wrote it in this class. Both work:
217
217
 
218
- - `default_relation_scope(relation).where(...)` — explicit, always safe
219
- - `super(relation).where(...)` — `Plutonium::Resource::Policy`'s `relation_scope` block calls `default_relation_scope`, so chaining through `super` picks it up
218
+ - `default_relation_scope(relation).where(...)`: explicit, always safe
219
+ - `super(relation).where(...)`: `Plutonium::Resource::Policy`'s `relation_scope` block calls `default_relation_scope`, so chaining through `super` picks it up
220
220
 
221
221
  Pick the one that reads better for the situation.
222
222
 
223
+ Verify a role-based scope with an integration test that logs in as each role and checks another tenant's records never appear. The login helpers (`login_as`) come from `Plutonium::Testing::AuthHelpers`, which your test must include; see [[plutonium-testing]].
224
+
223
225
  ### Intentionally skipping
224
226
 
225
227
  Rare. Before reaching for this, consider a separate, unscoped portal.
@@ -247,7 +249,7 @@ module AdminPortal
247
249
  end
248
250
  ```
249
251
 
250
- Routes become `/<mount>/:organization_scoped/posts` (resolving to `/<mount>/42/posts` at request time — the entity id is the first path segment after the mount). Portal extracts `params[:organization_scoped]` and loads the entity automatically. The `_scoped` suffix on the param name avoids colliding with `params[:organization]` from a `belongs_to :organization` on child models.
252
+ Routes become `/<mount>/:organization_scoped/posts` (resolving to `/<mount>/42/posts` at request time; the entity id is the first path segment after the mount). Portal extracts `params[:organization_scoped]` and loads the entity automatically. The `_scoped` suffix on the param name avoids colliding with `params[:organization]` from a `belongs_to :organization` on child models.
251
253
 
252
254
  ### Custom strategy (subdomain, session, etc.)
253
255
 
@@ -279,19 +281,65 @@ scoped_to_entity?
279
281
  entity_scope
280
282
  ```
281
283
 
284
+ ## The entity field in permitted attributes
285
+
286
+ Listing the entity association in the policy is fine, and usually what you want:
287
+
288
+ ```ruby
289
+ def permitted_attributes_for_create
290
+ [:organization, :name]
291
+ end
292
+ ```
293
+
294
+ In an entity-scoped portal the controller drops the entity field from both the rendered form/display and the accepted params, then sets it from `current_scoped_entity` on create and update. The same policy then works unchanged in an unscoped portal (e.g. admin), where `organization` stays a normal, selectable input. So don't write a portal-specific policy just to remove the entity field, and don't treat its presence as a leak: a forged `organization_id` in the request is overwritten.
295
+
296
+ What gets removed is the portal's param key plus the scoping association and its `_id` (`organization_scoped`, `organization_scoped_id`, `organization`, `organization_id`). Other associations to the same class are untouched. To show the entity field anyway, override `present_scoped_entity?` on the controller (and `submit_scoped_entity?` to let users change it; it defaults to `present_scoped_entity?`).
297
+
298
+ ## Two associations to the entity class
299
+
300
+ Example: a `Referral` where the current org refers another org.
301
+
302
+ ```ruby
303
+ class Referral < ResourceRecord
304
+ belongs_to :organization # the tenant
305
+ belongs_to :referred_organization, class_name: "Organization" # a normal input
306
+
307
+ # Required: associated_with raises AmbiguousAssociationError without it
308
+ scope :associated_with_organization, ->(organization) { where(organization:) }
309
+
310
+ validates :referred_organization, uniqueness: {scope: :organization_id}
311
+ end
312
+ ```
313
+
314
+ Two pieces of code pick "the" entity association, and both refuse to guess:
315
+
316
+ - **Controller** (`scoped_entity_association`) raises when the model has more than one `belongs_to` to the entity class, on any scoped page that renders fields. Override it on the portal controller to name the tenant association:
317
+
318
+ ```ruby
319
+ class OrgPortal::ReferralsController < ::ReferralsController
320
+ private
321
+
322
+ def scoped_entity_association = :organization
323
+ end
324
+ ```
325
+
326
+ - **Model** (`associated_with`) raises `Plutonium::Resource::Record::AssociatedWith::AmbiguousAssociationError` when more than one association links the two classes (in either direction) and no `associated_with_<entity>` scope exists. Define that scope (as above); the error message names it.
327
+
328
+ With both in place, the policy can permit `[:organization, :referred_organization, :note, :status]`: in the org portal `organization` is stripped and forced to the current org, while `referred_organization` stays a select the user fills in.
329
+
282
330
  ## Gotchas
283
331
 
284
- - **Multiple associations to the same entity class.** E.g. `Match belongs_to :home_team, :away_team` both pointing at `Team`. Plutonium raises — override `scoped_entity_association` on the controller to pick one (`def scoped_entity_association = :home_team`).
285
- - **`param_key` differs from association name.** Fine — Plutonium matches by **class**, not param key. `scope_to_entity Competition::Team, param_key: :team` works with `belongs_to :competition_team`.
286
- - **Default `param_key` includes `_scoped` suffix.** `scope_to_entity Organization` reads `params[:organization_scoped]` (not `params[:organization]`) so it doesn't collide with `params[:organization]` from a `belongs_to :organization` on child models. The URL itself is unchanged — the entity id is just the first path segment after the mount (`/<mount>/42/posts`). Pass `param_key:` only if you want a different param name in your controllers.
332
+ - **Multiple associations to the same entity class.** E.g. `Match belongs_to :home_team, :away_team` both pointing at `Team`. The controller raises; override `scoped_entity_association` (`def scoped_entity_association = :home_team`) and add an `associated_with_team` scope. See § Two associations to the entity class.
333
+ - **`param_key` differs from association name.** That's fine: Plutonium matches by **class**, not param key. `scope_to_entity Competition::Team, param_key: :team` works with `belongs_to :competition_team`.
334
+ - **Default `param_key` includes `_scoped` suffix.** `scope_to_entity Organization` reads `params[:organization_scoped]` (not `params[:organization]`) so it doesn't collide with `params[:organization]` from a `belongs_to :organization` on child models. The URL itself is unchanged; the entity id is just the first path segment after the mount (`/<mount>/42/posts`). Pass `param_key:` only if you want a different param name in your controllers.
287
335
  - **Forgetting compound uniqueness.** `validates :code, uniqueness: true` leaks across tenants. Use `uniqueness: {scope: :organization_id}`.
288
336
  - **"Temporary" `where` bypass for debugging.** Use `skip_default_relation_scope!` explicitly. Never leave a `where` bypass in code.
289
337
 
290
338
  ---
291
339
 
292
- # Part 2 — Nested Resources
340
+ # Part 2: Nested Resources
293
341
 
294
- Plutonium auto-generates nested routes from `has_many` / `has_one` associations on a registered parent. **One level only** — no grandparent → parent → child chains.
342
+ Plutonium auto-generates nested routes from `has_many` / `has_one` associations on a registered parent. **One level only**: no grandparent → parent → child chains.
295
343
 
296
344
  ## Setup
297
345
 
@@ -340,7 +388,7 @@ behaves the same either way, and top-level routes are unaffected.
340
388
 
341
389
  **Before turning `:declared` on:** it is global, so every resource naming nothing
342
390
  loses its nested routes, and a policy's `permitted_associations` panel links to the
343
- nested route — permit an association there without declaring it here and the panel
391
+ nested route; permit an association there without declaring it here and the panel
344
392
  points nowhere.
345
393
 
346
394
  ## Automatic behavior in nested routes
@@ -370,14 +418,14 @@ current_nested_association # :properties
370
418
  parent_input_param # :company
371
419
  ```
372
420
 
373
- The parent class and association are read from the **route** (each nested route carries its registration key), not inferred from the URL — which is why a `singular: true` parent works as a parent despite contributing no id segment. There is no `parent_route_param`.
421
+ The parent class and association are read from the **route** (each nested route carries its registration key), not inferred from the URL; this is why a `singular: true` parent works as a parent despite contributing no id segment. There is no `parent_route_param`.
374
422
 
375
423
  ## Parent vs entity scoping
376
424
 
377
- When a parent is present, **parent scoping wins**: `default_relation_scope` scopes via the parent association, not `entity_scope`. The parent was already authorized and entity-scoped during its own authorization — double-scoping isn't needed.
425
+ When a parent is present, **parent scoping wins**: `default_relation_scope` scopes via the parent association, not `entity_scope`. The parent was already authorized and entity-scoped during its own authorization, so double-scoping isn't needed.
378
426
 
379
427
  ```ruby
380
- # In the child policy — just call default_relation_scope, it handles both cases
428
+ # In the child policy: just call default_relation_scope; it handles both cases
381
429
  relation_scope do |relation|
382
430
  default_relation_scope(relation) # uses parent when present, entity_scope otherwise
383
431
  end
@@ -437,7 +485,7 @@ class PropertiesController < ResourceController
437
485
  end
438
486
  ```
439
487
 
440
- Conditional pattern — show parent only when accessed standalone:
488
+ Conditional pattern: show parent only when accessed standalone:
441
489
 
442
490
  ```ruby
443
491
  def present_parent?
@@ -472,7 +520,7 @@ Auto-include parent: `Companies > Acme Corp > Properties > Property #123`.
472
520
 
473
521
  ---
474
522
 
475
- # Part 3 — Invites
523
+ # Part 3: Invites
476
524
 
477
525
  A complete user-invitation system: token-based emails, secure acceptance, Rodauth integration, entity membership creation, and "invitable" hooks for app-specific behavior.
478
526
 
@@ -505,11 +553,11 @@ rails generate pu:invites:install
505
553
  | `--dest=PACKAGE` | `main_app` | Package where the entity model lives (controls where `invite_user_interaction.rb` is generated) |
506
554
 
507
555
  ::: 🚨 No `--roles` flag here
508
- Role list is derived from the membership model's `enum :role`. Set roles via `pu:saas:membership --roles=...` (or edit the enum directly). **Index 0 is the most privileged** — typically `owner`, which the invite UI excludes from selectable choices; new invitees default to the second role (`roles[1]`).
556
+ Role list is derived from the membership model's `enum :role`. Set roles via `pu:saas:membership --roles=...` (or edit the enum directly). **Index 0 is the most privileged**, typically `owner`; the invite UI excludes it from selectable choices, and new invitees default to the second role (`roles[1]`).
509
557
  :::
510
558
 
511
559
  ::: 🚨 ActiveRecord encryption keys required
512
- The invite model uses `encrypts :token, deterministic: true`. Without configured AR encryption keys, creating or accepting an invite raises `ActiveRecord::Encryption::Errors::Configuration`. The generator detects this and warns at install time — generate keys with `bin/rails db:encryption:init`, then paste the printed `active_record_encryption:` block into `config/credentials.yml.enc` (or set the equivalent `ACTIVE_RECORD_ENCRYPTION_*` ENV vars in production).
560
+ The invite model uses `encrypts :token, deterministic: true`. Without configured AR encryption keys, creating or accepting an invite raises `ActiveRecord::Encryption::Errors::Configuration`. The generator detects this and warns at install time; generate keys with `bin/rails db:encryption:init`, then paste the printed `active_record_encryption:` block into `config/credentials.yml.enc` (or set the equivalent `ACTIVE_RECORD_ENCRYPTION_*` ENV vars in production).
513
561
  :::
514
562
 
515
563
  ### What gets created
@@ -544,7 +592,7 @@ post "invitations/:token/signup", to: "invites/user_invitations#signup"
544
592
 
545
593
  ## Multiple invite flows in one app
546
594
 
547
- Run `pu:invites:install` once per flow. Default class name derives as `<EntityModel><UserModel>Invite` — no literal `UserInvite` default. Single-flow apps don't need `--invite-model`.
595
+ Run `pu:invites:install` once per flow. Default class name derives as `<EntityModel><UserModel>Invite`, with no literal `UserInvite` default. Single-flow apps don't need `--invite-model`.
548
596
 
549
597
  ```bash
550
598
  rails g pu:invites:install \
@@ -579,7 +627,7 @@ def invitation_path_for(token)
579
627
  end
580
628
  ```
581
629
 
582
- ## Invitables — app models notified on accept
630
+ ## Invitables: app models notified on accept
583
631
 
584
632
  An "invitable" is an app model that triggers invitations and gets notified when one is accepted. Examples: `Tenant`, `TeamMember`, `ProjectCollaborator`.
585
633
 
@@ -737,15 +785,16 @@ Invites are entity-scoped automatically: `Invites::UserInvite belongs_to :entity
737
785
 
738
786
  ## Common issues
739
787
 
740
- - **"Invite not found"** — token expired (default 1 week), invite cancelled, or no longer `pending`.
741
- - **Email mismatch** — `enforce_email?` is on by default. The accepting user's email must match the invited email. Override `def enforce_email? = false` only if you fully understand the security trade-off.
742
- - **Rodauth redirect after login** — make sure `login_redirect "/welcome"` is set in the rodauth plugin.
788
+ - **"Invite not found"**: token expired (default 1 week), invite cancelled, or no longer `pending`.
789
+ - **Email mismatch**: `enforce_email?` is on by default. The accepting user's email must match the invited email. Override `def enforce_email? = false` only if you fully understand the security trade-off.
790
+ - **Rodauth redirect after login**: make sure `login_redirect "/welcome"` is set in the rodauth plugin.
743
791
 
744
792
  ---
745
793
 
746
794
  ## Related skills
747
795
 
748
- - [[plutonium-resource]] — model declarations (`belongs_to`, `has_one :through`, custom scopes), `permitted_associations` for show-page tabs.
749
- - [[plutonium-behavior]] — `relation_scope` syntax, policy authorization context, controller presentation hooks.
750
- - [[plutonium-app]] — portal setup, `scope_to_entity`, mounting engines.
751
- - [[plutonium-auth]] — Rodauth signup flow for invite acceptance.
796
+ - [[plutonium-resource]]: model declarations (`belongs_to`, `has_one :through`, custom scopes), `permitted_associations` for show-page tabs.
797
+ - [[plutonium-behavior]]: `relation_scope` syntax, policy authorization context, controller presentation hooks.
798
+ - [[plutonium-app]]: portal setup, `scope_to_entity`, mounting engines.
799
+ - [[plutonium-auth]]: Rodauth signup flow for invite acceptance.
800
+ - [[plutonium-testing]]: integration tests for scoped portals, `login_as` via `Plutonium::Testing::AuthHelpers`.
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: plutonium-testing
3
- description: Use BEFORE writing tests for a Plutonium resource, running pu:test:scaffold, or including Plutonium::Testing::* concerns. Covers the full testing toolkit — CRUD, policy, definition, interaction, model, nested, portal access, and auth helpers.
3
+ description: 'Use BEFORE writing tests for a Plutonium resource, running pu:test:scaffold, or including Plutonium::Testing::* concerns. Covers the full testing toolkit: CRUD, policy, definition, interaction, model, nested, portal access, and auth helpers.'
4
4
  ---
5
5
 
6
6
  # Plutonium Testing
@@ -8,45 +8,46 @@ description: Use BEFORE writing tests for a Plutonium resource, running pu:test:
8
8
  ## 🚨 Critical (read first)
9
9
 
10
10
  - **Use the generators.** `pu:test:install` once per app, then `pu:test:scaffold ResourceClass --portals=...` per resource × portal. Hand-written test files drift from conventions.
11
- - **Tests are opt-in.** `Plutonium::Testing` is only loaded when `require "plutonium/testing"` runs — it's never autoloaded, never present in production.
11
+ - **Tests are opt-in.** `Plutonium::Testing` is only loaded when `require "plutonium/testing"` runs; it's never autoloaded, never present in production.
12
12
  - **One file per (resource × portal).** Same model in admin and org portals = two test files. Each portal has different auth, scoping, and allowed actions.
13
- - **Stub methods are required.** Concerns ship with `NotImplementedError` stubs — your test class supplies the test data via `create_resource!`, `valid_create_params`, `policy_roles`, etc.
13
+ - **Stub methods are required.** Concerns ship with `NotImplementedError` stubs: your test class supplies the test data via `create_resource!`, `valid_create_params`, `policy_roles`, etc.
14
14
 
15
15
  ---
16
16
 
17
- ## 🛑 Before you scaffold tests: confirm the shape (ASK — don't infer)
17
+ ## 🛑 Before you scaffold tests: confirm the shape (ASK: don't infer)
18
18
 
19
- "Write tests for X" leaves out what actually drives the files. Resolve each — confirming by inspection (next section):
19
+ "Write tests for X" leaves out what actually drives the files. Resolve each, confirming by inspection (next section):
20
20
 
21
- 1. **Which concerns?** `crud` / `policy` / `definition` / `model` / `nested` / `interaction` / `portal_access`. Don't scaffold all blindly — pick what the resource needs.
22
- 2. **Which portals?** **One file per (resource × portal)** — each has different auth, scoping, and allowed actions. A resource in admin + org ⇒ two files.
23
- 3. **Nested?** A child resource needs `--parent=` **and** a real `parent_record!` stub.
21
+ 1. **Which concerns?** `crud` / `policy` / `definition` / `model` / `nested` / `interaction` / `portal_access`. Don't scaffold all blindly, pick what the resource needs.
22
+ 2. **Which portals?** **One file per (resource × portal)**: each has different auth, scoping, and allowed actions. A resource in admin + org ⇒ two files.
23
+ 3. **Nested or entity-scoped?** If the portal calls `scope_to_entity Model, strategy: :path` (e.g. `/org/:id/...`), any "records from another tenant aren't reachable" requirement is the `NestedResource` concern with the entity as the parent: scaffold with `--concerns=crud,nested --parent=organization`. `--parent` alone does not add the `NestedResource` include. See [Entity-scoped portals](#entity-scoped-portals-crud--tenant-isolation) for the two-class layout this needs.
24
24
  4. **Auth flavor.** Rodauth (the default `login_as` POSTs the hardcoded `password123`) or custom (override `sign_in_for_tests`)?
25
25
 
26
- **Never ship a guessed policy matrix, factory name, or field list** — read the model/definition/policy for the real actions, roles, and fields before filling stubs.
26
+ **Never ship a guessed policy matrix, factory name, or field list**: read the model/definition/policy for the real actions, roles, and fields before filling stubs.
27
27
 
28
- ## ✅ Before you scaffold: verify the ground truth (CHECK — read it, don't ask for it)
28
+ ## ✅ Before you scaffold: verify the ground truth (CHECK: read it, don't ask for it)
29
29
 
30
- You have file access — **inspect**; don't ask the user to describe their setup.
30
+ You have file access: **inspect**; don't ask the user to describe their setup.
31
31
 
32
32
  | Check | How | Why it matters |
33
33
  |---|---|---|
34
- | Harness installed | grep `test/test_helper.rb` for `require "plutonium/testing"` | Concerns never autoload — run `pu:test:install` first |
34
+ | Harness installed | grep `test/test_helper.rb` for `require "plutonium/testing"` | Concerns never autoload; run `pu:test:install` first |
35
35
  | Resource exposed in each named portal | The resource is `register_resource`'d in each portal | `--portals=` must match mounted engines |
36
36
  | Portal engine names | `:admin` ⇒ `AdminPortal::Engine` | Mismatch ⇒ pass `path_prefix:` explicitly |
37
37
  | Login password | Test accounts seeded with `password123` (fixtures/factories) | `login_as` POSTs that hardcoded value, or use `sign_in_for_tests` |
38
38
  | Tenant binding | `create_resource!`/`policy_record` return `@tenant`-bound records | Else scope tests pass for the wrong reason |
39
+ | Entity strategy | grep the portal's `lib/engine.rb` for `scope_to_entity` | `:path` strategy ⇒ the resolved prefix is the bare mount (`/org`), so CRUD needs a `current_path_prefix` override and tenant isolation needs `NestedResource` |
39
40
 
40
41
  Inspect with your own tools **before** scaffolding.
41
42
 
42
- ## 🛠 Use the generator — fill the stubs, don't hand-write
43
+ ## 🛠 Use the generator: fill the stubs, don't hand-write
43
44
 
44
45
  | Task | Generator | Verify first |
45
46
  |---|---|---|
46
47
  | Install harness (once per app) | `pu:test:install` | Not already in `test_helper.rb` |
47
48
  | Scaffold tests | `pu:test:scaffold Klass --portals=… --concerns=…` | Harness installed; resource exposed in those portals |
48
49
 
49
- Hand-written test files drift from conventions — scaffold, then fill the `NotImplementedError` stubs with tenant-correct data.
50
+ Hand-written test files drift from conventions; scaffold, then fill the `NotImplementedError` stubs with tenant-correct data.
50
51
 
51
52
  ---
52
53
 
@@ -126,6 +127,11 @@ class AdminPortal::BloggingPostsTest < ActionDispatch::IntegrationTest
126
127
  end
127
128
  ```
128
129
 
130
+ **`valid_update_params` is also the assertion.** After the PATCH, the update test runs `assert_equal value, record.reload.public_send(attr)` for every key. So:
131
+
132
+ - Use values that read back identically: enums as strings (`status: "published"`, since the enum reader returns a String and `:published` would fail).
133
+ - Keep association SGIDs out of it. The loop only skips values starting with `gid://`, but `to_sgid.to_s` is a signed token, so it would compare the token to the associated record and fail. Reassigning an association belongs in its own `test` block that PATCHes the SGID and asserts `record.reload.user == other_user`. `valid_create_params` has no such check, so SGIDs are fine there.
134
+
129
135
  ### `Plutonium::Testing::ResourcePolicy`
130
136
 
131
137
  Asserts the `permit?` matrix across action × role and verifies `relation_scope` returns an `ActiveRecord::Relation`.
@@ -145,6 +151,8 @@ def policy_matrix = {
145
151
  }
146
152
  ```
147
153
 
154
+ Each role lambda is `instance_exec`'d inside the test, once per (action × role), so returning accounts built in `setup` is the intended shape. A lambda that calls `create_user!` would mint a new account on every call, without the membership a tenant-scoped policy checks. Read the policy for the real answers: the matrix above is an example, not a default.
155
+
148
156
  ### `Plutonium::Testing::ResourceDefinition`
149
157
 
150
158
  Smoke-tests the resource definition: the class is constantize-able, every defineable prop dictionary (fields/inputs/displays/columns/scopes/filters/sorts/actions) is queryable, and declared fields exist on the model.
@@ -158,18 +166,31 @@ Outcome-assertion helpers for `Plutonium::Interaction::Base` subclasses.
158
166
  **Helpers:**
159
167
  - `assert_interaction_success(klass, **input)` → returns the success outcome
160
168
  - `assert_interaction_failure(klass, **input)` → returns the failure outcome
161
- - `interaction_view_context` (overridable) → defaults to a mock view context
169
+ - `interaction_view_context` (overridable) → the view context both helpers pass in; override it only when the interaction reads something from the view context
170
+
171
+ Use the helpers for both outcomes; they build the interaction and call it for you.
162
172
 
163
173
  ```ruby
164
- test "RebuildSearchInteraction succeeds" do
165
- outcome = assert_interaction_success(RebuildSearchInteraction, since: 1.day.ago)
166
- assert_equal 42, outcome.value[:rebuilt_count]
174
+ test "PublishProduct moves a draft to active" do
175
+ product = create_product!(status: :draft)
176
+ assert_interaction_success(Catalog::PublishProduct, resource: product)
177
+ assert product.reload.active?
178
+ end
179
+
180
+ test "PublishProduct fails for a product that isn't a draft" do
181
+ product = create_product!(status: :active)
182
+ assert_interaction_failure(Catalog::PublishProduct, resource: product)
183
+ assert product.reload.active? # state unchanged
167
184
  end
168
185
  ```
169
186
 
187
+ The failure outcome carries no validation errors (they live on the interaction instance), so assert the unchanged state rather than building the interaction by hand to read `errors`.
188
+
189
+ `ResourceInteraction` includes neither the DSL nor `AuthHelpers`. A file scaffolded with only `--concerns=interaction` still has the template's `resource_tests_for` and `login_as(@account)`, which raise `NoMethodError`: delete both (the helpers need no portal and no login).
190
+
170
191
  ### `Plutonium::Testing::ResourceModel`
171
192
 
172
- Tests `associated_with` scope, SGID routing, and `has_cents` accessors — gated by DSL flags.
193
+ Tests `associated_with` scope, SGID routing, and `has_cents` accessors, gated by DSL flags.
173
194
 
174
195
  **Stubs:**
175
196
  - `model_test_record` → persisted record
@@ -190,13 +211,77 @@ Only the flagged features generate tests.
190
211
  Asserts CRUD under a parent + scope-boundary tests (sibling tenants invisible).
191
212
 
192
213
  **Stubs:**
193
- - `parent_record!` → current tenant
214
+ - `parent_record!` → current tenant (called several times per test, so return the same record each time, e.g. `@org`)
194
215
  - `other_parent_record!` → sibling tenant
195
216
  - `create_resource!(parent:)` → persisted record under given parent
196
217
 
218
+ The concern builds its URLs as `"#{current_path_prefix}/#{parent.id}/#{collection}"`, so it expects the bare portal mount as the prefix and inserts the parent id itself.
219
+
220
+ #### Entity-scoped portals: CRUD + tenant isolation
221
+
222
+ In a `strategy: :path` portal, the two concerns need different prefixes and different `create_resource!` signatures:
223
+
224
+ | | `ResourceCrud` | `NestedResource` |
225
+ |---|---|---|
226
+ | URL built | `prefix/collection` | `prefix/parent.id/collection` |
227
+ | Prefix needed | `/org/#{@org.to_param}` (override `current_path_prefix`) | `/org` (the resolved default) |
228
+ | Calls | `create_resource!` | `create_resource!(parent:)` |
229
+
230
+ One class can't satisfy both, so split the scaffolded file into two classes:
231
+
232
+ ```bash
233
+ rails g pu:test:scaffold Catalog::Variant --portals=org --concerns=crud,nested --parent=organization
234
+ ```
235
+
236
+ ```ruby
237
+ class OrgPortal::CatalogVariantTest < ActionDispatch::IntegrationTest
238
+ include IntegrationTestHelper
239
+ include Plutonium::Testing::ResourceCrud
240
+
241
+ resource_tests_for Catalog::Variant, portal: :org
242
+
243
+ setup do
244
+ @org = create_organization!
245
+ @user = create_user!
246
+ create_membership!(organization: @org, user: @user)
247
+ @product = create_product!(user: @user, organization: @org)
248
+ login_as(@user) # :org logs in through /users/login
249
+ end
250
+
251
+ def current_path_prefix = "/org/#{@org.to_param}"
252
+ def create_resource! = create_variant!(product: @product)
253
+ def valid_create_params = {name: "Red", sku: "RED-1", stock_count: 5, product: @product.to_sgid.to_s}
254
+ def valid_update_params = {name: "Red / Large"}
255
+ end
256
+
257
+ class OrgPortal::CatalogVariantNestedTest < ActionDispatch::IntegrationTest
258
+ include IntegrationTestHelper
259
+ include Plutonium::Testing::NestedResource
260
+
261
+ resource_tests_for Catalog::Variant, portal: :org, parent: :organization
262
+
263
+ setup do
264
+ @org = create_organization!
265
+ @other_org = create_organization!
266
+ @user = create_user!
267
+ create_membership!(organization: @org, user: @user)
268
+ login_as(@user)
269
+ end
270
+
271
+ def parent_record! = @org
272
+ def other_parent_record! = @other_org
273
+
274
+ def create_resource!(parent:)
275
+ create_variant!(product: create_product!(organization: parent))
276
+ end
277
+ end
278
+ ```
279
+
280
+ `create_resource!(parent:)` must create under `parent`, not always under `@org`: the isolation test passes `other_parent_record!` and expects a 404 (or redirect) for that record.
281
+
197
282
  ### `Plutonium::Testing::PortalAccess`
198
283
 
199
- Cross-portal access boundaries. Uses its own DSL — not `resource_tests_for`.
284
+ Cross-portal access boundaries. Uses its own DSL, not `resource_tests_for`.
200
285
 
201
286
  ```ruby
202
287
  class PortalAccessTest < ActionDispatch::IntegrationTest
@@ -233,7 +318,16 @@ Generates one test per (role × portal). Allowed = `200|302`; blocked = `302|401
233
318
 
234
319
  ## Auth helpers
235
320
 
236
- `Plutonium::Testing::AuthHelpers` is included transitively by every concern.
321
+ `login_as` and friends come from `Plutonium::Testing::AuthHelpers`, which only some concerns pull in. The default portal comes from the DSL (`resource_tests_for`), which is a separate include:
322
+
323
+ | Concern | `login_as` available | Bare `login_as(account)` works |
324
+ |---|---|---|
325
+ | `ResourceCrud`, `NestedResource` | yes | yes (portal from `resource_tests_for`) |
326
+ | `PortalAccess` | yes | no: pass `portal:` every time |
327
+ | `ResourcePolicy`, `ResourceDefinition`, `ResourceModel` | no | no |
328
+ | `ResourceInteraction` | no | no |
329
+
330
+ A hand-written integration test that only includes the app's own helpers (e.g. an `IntegrationTestHelper`) has no `login_as` at all. Add `include Plutonium::Testing::AuthHelpers` (and `require "plutonium/testing"` if `test_helper.rb` doesn't) and pass `portal:` explicitly. Likewise, delete the scaffold's `login_as(@account)` from a file whose concerns don't provide it.
237
331
 
238
332
  ```ruby
239
333
  login_as(account) # uses portal from DSL
@@ -245,7 +339,7 @@ current_account(portal: :admin)
245
339
  with_portal(:org) { ... } # scoped portal switch
246
340
  ```
247
341
 
248
- **Default Rodauth login expects `password: "password123"`** — `login_as` POSTs to `/<account_table>/login` with that hardcoded password. Either seed test accounts with it (fixtures/factories) or override via `sign_in_for_tests` below.
342
+ **Default Rodauth login expects `password: "password123"`.** `login_as` POSTs to `/<account_table>/login` with that hardcoded password. The table comes from the portal: `:admin` ⇒ `/admins/login`, `:user` and `:org` ⇒ `/users/login`, anything else is pluralized (`:locus` ⇒ `/locus/login`), so log org members in with `portal: :user` from portals that aren't named `:org`. Either seed test accounts with it (fixtures/factories) or override via `sign_in_for_tests` below.
249
343
 
250
344
  **Override hook for non-Rodauth apps (or to bypass Rodauth in tests):** define `sign_in_for_tests(account, portal:)` in your test class (or in `test/support/plutonium_testing.rb` for project-wide use). `AuthHelpers` will defer to it.
251
345
 
@@ -278,16 +372,18 @@ rails g pu:test:scaffold Blogging::Post --portals=org --parent=organization --de
278
372
  |---|---|---|
279
373
  | `--portals=admin,org` | required | Emit one file per portal |
280
374
  | `--concerns=...` | `crud,policy,definition` | Concerns to include (`crud,policy,definition,nested,model,interaction,portal_access`) |
281
- | `--parent=organization` | none | Wires `NestedResource` parent |
375
+ | `--parent=organization` | none | Adds `parent:` to `resource_tests_for` and, only together with `nested` in `--concerns`, the `parent_record!`/`other_parent_record!` stubs |
282
376
  | `--dest=main_app\|<package>` | `main_app` | Output destination |
283
377
 
284
378
  Output path: `test/integration/<portal>_portal/<resource_underscored>_test.rb`.
285
379
 
380
+ The template is one class with every requested include, a `setup` that calls `login_as(@account)`, and a no-argument `create_resource!`. Treat it as a starting point: drop `login_as`/`resource_tests_for` where the concerns don't provide them (see Auth helpers), and split `crud` and `nested` into two classes for entity-scoped portals.
381
+
286
382
  ## Customization & escape hatches
287
383
 
288
384
  - **Skip individual tests:** `resource_tests_for Klass, portal: :admin, skip: %i[destroy]`
289
385
  - **Restrict action set:** `resource_tests_for Klass, portal: :admin, actions: %i[index show]`
290
- - **Custom assertions:** add regular `test "..."` blocks alongside the generated matrix — they coexist.
386
+ - **Custom assertions:** add regular `test "..."` blocks alongside the generated matrix; they coexist.
291
387
  - **Non-Rodauth auth:** override `sign_in_for_tests`. See AuthHelpers section.
292
388
  - **Custom path prefix:** `path_prefix: "/v2/admin"` overrides portal resolution.
293
389
 
@@ -296,14 +392,15 @@ Output path: `test/integration/<portal>_portal/<resource_underscored>_test.rb`.
296
392
  - **Forgotten stubs raise `NotImplementedError`** with the stub name. Look for the missing method in your test class.
297
393
  - **Portal mismatch:** `:admin` portal expects `AdminPortal::Engine` constant. If your portal is named differently, pass `path_prefix:` explicitly.
298
394
  - **Tenant leakage in stubs:** `create_resource!` for an org portal must return a record bound to the test's `@org`. Otherwise scope filtering tests will pass for the wrong reason.
299
- - **`policy_record` for tenant-scoped resources** must belong to a tenant the role has access to — otherwise even allowed roles will see `false`.
300
- - **Nested resources need `parent: :foo`** in the DSL AND a real parent record from `parent_record!`. Without both, path interpolation fails.
301
- - **`PortalAccess` doesn't use `resource_tests_for`** — use `portal_access_for` instead. Mixing them on the same class is undefined behavior.
395
+ - **`policy_record` for tenant-scoped resources** must belong to a tenant the role has access to; otherwise even allowed roles will see `false`.
396
+ - **Nested paths come from `parent_record!.id`**, so it must return a real, persisted tenant the logged-in account belongs to. `parent: :foo` in the DSL documents the relationship; the concern doesn't read it.
397
+ - **Entity-scoped CRUD hitting `/org/<collection>`** (404 or routing error): a `:path` portal resolves to the bare mount, so override `current_path_prefix` to include the tenant. Don't do this in a `NestedResource` class, which adds the id itself.
398
+ - **`PortalAccess` doesn't use `resource_tests_for`**: use `portal_access_for` instead. Mixing them on the same class is undefined behavior.
302
399
 
303
400
  ## Related skills
304
401
 
305
- - [[plutonium-behavior]] — policies (verified by `ResourcePolicy`), interactions (asserted by `ResourceInteraction`)
306
- - [[plutonium-resource]] — definition props the smoke test introspects (`field`, `input`, `display`, `column`, `scope`, `filter`, `sort`, `action`)
307
- - [[plutonium-tenancy]] — `relation_scope`, parent scoping, nested resources (matched by `NestedResource`)
308
- - [[plutonium-app]] — portal mounting and entity strategies that drive auth/scoping
309
- - [[plutonium-auth]] — Rodauth setup behind the default login flow
402
+ - [[plutonium-behavior]]: policies (verified by `ResourcePolicy`), interactions (asserted by `ResourceInteraction`)
403
+ - [[plutonium-resource]]: definition props the smoke test introspects (`field`, `input`, `display`, `column`, `scope`, `filter`, `sort`, `action`)
404
+ - [[plutonium-tenancy]]: `relation_scope`, parent scoping, nested resources (matched by `NestedResource`)
405
+ - [[plutonium-app]]: portal mounting and entity strategies that drive auth/scoping
406
+ - [[plutonium-auth]]: Rodauth setup behind the default login flow