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.
- checksums.yaml +4 -4
- data/.claude/skills/plutonium/SKILL.md +43 -43
- data/.claude/skills/plutonium-app/SKILL.md +101 -59
- data/.claude/skills/plutonium-async-interactions/SKILL.md +19 -19
- data/.claude/skills/plutonium-auth/SKILL.md +119 -53
- data/.claude/skills/plutonium-behavior/SKILL.md +110 -78
- data/.claude/skills/plutonium-dashboard/SKILL.md +11 -4
- data/.claude/skills/plutonium-kanban/SKILL.md +81 -49
- data/.claude/skills/plutonium-resource/SKILL.md +144 -133
- data/.claude/skills/plutonium-tenancy/SKILL.md +104 -55
- data/.claude/skills/plutonium-testing/SKILL.md +130 -33
- data/.claude/skills/plutonium-ui/SKILL.md +151 -96
- data/.claude/skills/plutonium-wizard/SKILL.md +93 -82
- data/CHANGELOG.md +21 -0
- data/README.md +9 -9
- data/SECURITY.md +1 -1
- data/app/assets/plutonium.css +1 -1
- data/docs/.vitepress/sync-skills.mjs +6 -3
- data/docs/blog/introducing-plutonium-dashboards.md +4 -5
- data/docs/blog/introducing-plutonium-i18n.md +4 -5
- data/docs/getting-started/installation.md +5 -5
- data/docs/getting-started/tutorial/02-first-resource.md +3 -3
- data/docs/getting-started/tutorial/03-authentication.md +7 -7
- data/docs/getting-started/tutorial/04-authorization.md +21 -4
- data/docs/getting-started/tutorial/05-custom-actions.md +2 -2
- data/docs/getting-started/tutorial/06-nested-resources.md +5 -2
- data/docs/getting-started/tutorial/07-author-portal.md +2 -2
- data/docs/getting-started/tutorial/08-customizing-ui.md +45 -30
- data/docs/getting-started/tutorial/index.md +1 -1
- data/docs/guides/adding-resources.md +10 -7
- data/docs/guides/authentication.md +25 -25
- data/docs/guides/authorization.md +24 -24
- data/docs/guides/creating-packages.md +17 -17
- data/docs/guides/custom-actions.md +32 -32
- data/docs/guides/customizing-ui.md +29 -26
- data/docs/guides/dashboards.md +1 -1
- data/docs/guides/index.md +3 -3
- data/docs/guides/kanban.md +55 -55
- data/docs/guides/multi-tenancy.md +35 -22
- data/docs/guides/nested-resources.md +21 -21
- data/docs/guides/performance.md +3 -3
- data/docs/guides/search-filtering.md +13 -13
- data/docs/guides/testing.md +16 -12
- data/docs/guides/theming.md +32 -17
- data/docs/guides/troubleshooting.md +2 -2
- data/docs/guides/user-invites.md +17 -17
- data/docs/guides/user-profile.md +51 -24
- data/docs/guides/wizards.md +55 -55
- data/docs/reference/app/generators.md +22 -22
- data/docs/reference/app/index.md +15 -18
- data/docs/reference/app/packages.md +8 -8
- data/docs/reference/app/portals.md +75 -29
- data/docs/reference/auth/accounts.md +15 -15
- data/docs/reference/auth/index.md +12 -12
- data/docs/reference/auth/profile.md +67 -29
- data/docs/reference/behavior/async-interactions.md +24 -24
- data/docs/reference/behavior/controllers.md +28 -28
- data/docs/reference/behavior/index.md +5 -5
- data/docs/reference/behavior/interactions.md +44 -44
- data/docs/reference/behavior/policies.md +48 -28
- data/docs/reference/configuration.md +6 -6
- data/docs/reference/dashboard/dsl.md +2 -2
- data/docs/reference/dashboard/index.md +1 -1
- data/docs/reference/generators/lite.md +7 -7
- data/docs/reference/i18n.md +23 -0
- data/docs/reference/index.md +1 -1
- data/docs/reference/kanban/authorization.md +9 -9
- data/docs/reference/kanban/dsl.md +32 -32
- data/docs/reference/kanban/index.md +1 -1
- data/docs/reference/kanban/positioning.md +17 -15
- data/docs/reference/resource/actions.md +51 -51
- data/docs/reference/resource/definition.md +73 -73
- data/docs/reference/resource/export.md +6 -6
- data/docs/reference/resource/index.md +16 -16
- data/docs/reference/resource/model.md +24 -24
- data/docs/reference/resource/positioning.md +78 -76
- data/docs/reference/resource/query.md +13 -13
- data/docs/reference/tenancy/entity-scoping.md +65 -35
- data/docs/reference/tenancy/index.md +11 -11
- data/docs/reference/tenancy/invites.md +20 -20
- data/docs/reference/tenancy/nested-resources.md +13 -13
- data/docs/reference/testing/index.md +116 -22
- data/docs/reference/ui/assets.md +57 -25
- data/docs/reference/ui/components.md +20 -20
- data/docs/reference/ui/displays.md +14 -14
- data/docs/reference/ui/forms.md +35 -35
- data/docs/reference/ui/index.md +17 -15
- data/docs/reference/ui/layouts.md +21 -21
- data/docs/reference/ui/pages.md +22 -22
- data/docs/reference/ui/tables.md +7 -7
- data/docs/reference/wizard/anchoring-resume.md +33 -32
- data/docs/reference/wizard/dsl.md +44 -44
- data/docs/reference/wizard/index.md +6 -6
- data/docs/reference/wizard/one-time.md +18 -18
- data/docs/reference/wizard/registration-launch.md +32 -32
- data/docs/reference/wizard/storage-config.md +23 -23
- data/gemfiles/rails_8.1.gemfile.lock +1 -1
- data/lib/generators/pu/profile/conn_generator.rb +6 -0
- data/lib/plutonium/resource/record/associated_with.rb +23 -2
- data/lib/plutonium/ui/form/concerns/typeahead_attributes.rb +7 -1
- data/lib/plutonium/version.rb +1 -1
- data/package.json +1 -1
- data/src/css/components.css +10 -10
- metadata +2 -2
|
@@ -1,66 +1,66 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: plutonium-tenancy
|
|
3
|
-
description: Use BEFORE any multi-tenant work
|
|
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
|
|
6
|
+
# Plutonium Tenancy: Entity Scoping, Nested Resources, Invites
|
|
7
7
|
|
|
8
8
|
Three closely-coupled concerns:
|
|
9
9
|
|
|
10
|
-
1. **Entity scoping
|
|
11
|
-
2. **Nested resources
|
|
12
|
-
3. **Invites
|
|
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)
|
|
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}
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
42
|
+
## ✅ Before you edit: verify the ground truth (CHECK: read it, don't ask for it)
|
|
43
43
|
|
|
44
|
-
You have file access
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
86
|
-
2. **Direct `belongs_to` to entity class
|
|
87
|
-
3. **`has_one` / `has_one :through` to entity class
|
|
88
|
-
4. **Reverse `has_many` from entity
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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(...)
|
|
219
|
-
- `super(relation).where(...)
|
|
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
|
|
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`.
|
|
285
|
-
- **`param_key` differs from association name.**
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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"
|
|
741
|
-
- **Email mismatch
|
|
742
|
-
- **Rodauth redirect after login
|
|
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]]
|
|
749
|
-
- [[plutonium-behavior]]
|
|
750
|
-
- [[plutonium-app]]
|
|
751
|
-
- [[plutonium-auth]]
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
22
|
-
2. **Which portals?** **One file per (resource × portal)
|
|
23
|
-
3. **Nested?**
|
|
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
|
|
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
|
|
28
|
+
## ✅ Before you scaffold: verify the ground truth (CHECK: read it, don't ask for it)
|
|
29
29
|
|
|
30
|
-
You have file access
|
|
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
|
|
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
|
|
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
|
|
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) →
|
|
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 "
|
|
165
|
-
|
|
166
|
-
|
|
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
|
|
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
|
|
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
|
|
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"
|
|
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 |
|
|
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
|
|
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
|
|
300
|
-
- **Nested
|
|
301
|
-
-
|
|
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]]
|
|
306
|
-
- [[plutonium-resource]]
|
|
307
|
-
- [[plutonium-tenancy]]
|
|
308
|
-
- [[plutonium-app]]
|
|
309
|
-
- [[plutonium-auth]]
|
|
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
|