test(pgmpro/resources): coverage for security guards + updateBudgetItem regression#3655
test(pgmpro/resources): coverage for security guards + updateBudgetItem regression#3655gloriafolaron wants to merge 3 commits into
Conversation
…em regression
Companion to plugins#75 (feat/pgmpro-resource-tab-updates) — the code
under test lives in the plugin submodule at that PR's tip.
Requires plugins#75 to merge (and the submodule pointer to bump on
feat/logic-model-reverse) before CI on this PR can go green. Tests
verified locally against the plugin PR branch's working tree — all
40 assertions pass.
## tests/Unit/.../Hxcontrollers/Concerns/ChecksResourceAccessTest.php (new)
21 tests covering the ownership trait every mutating HxController
depends on:
- canEditProgram — refuses zero id, nonexistent project, regular
project type, strategy type, missing session user.
- canEditProgram — owner + admin bypass assignment (bypass path).
- canEditProgram — assigned non-admin allowed; unassigned non-admin
refused (the core IDOR case).
- canEditCanvas / canEditItem — refuse when repo resolver returns null
(blocks cross-canvas-type IDOR: passing a Blueprints canvas id must
not slip through), delegate to canEditProgram when the resolver
returns a program id.
- canAttachProject — refuses program-type / strategy-type (can't nest
containers under containers), enforces the assignment / admin bypass
the same way canEditProgram does.
- forbid() throws AccessDeniedHttpException → Laravel renders 403.
## tests/Unit/.../ResourceStructureServiceTest.php (12 added)
- updateBudgetItem regression + new-field coverage:
* refuses wrong box (people row won't mutate through budget path)
without touching updateItem
* refuses missing item
* persists `spent` (Marcel's exact regression — old isset guard
dropped it)
* persists `projectId` (new — supports budget→project reassignment)
* projectId=0 (unassigned sentinel) persists as 0
* `spent: 0` persists (the isset→array_key_exists fix)
* partial update preserves fields not in the payload
* name → description column mapping
- getProgramIdForCanvas + getProgramIdForItem pass-through + null path
(the null return is the entire security guarantee — asserts that
a POST'd id which doesn't map to a resource canvas resolves to null,
not to some other program).
## Test run
- ChecksResourceAccessTest: 21 tests / 21 assertions — all pass.
- ResourceStructureServiceTest: 19 tests / 53 assertions (7 pre-existing + 12 new) — all pass.
## Not included
- Controller-level integration test (Marcel's suggested negative-auth
test: foreign programId → 403). Deferred to a follow-up because it
requires bootstrapping the HTMX dispatcher stack; the trait tests
cover the same guarantee at the enforcement layer.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
Status: ready-pending-dependency · Priority: P2 (test-only; unblocks the resources security work) · Next action: land plugins#75 first, then bump submodule pointer · Owner: @gloriafolaron, review gate @marcelfolaron / @broskees Automated maintainer review (advisory only — not an approval; merge authority stays with @marcelfolaron / @broskees). Reviewed at head Intent: Adds unit coverage for the PgmPro Resources security guards and the This is exactly the kind of coverage the resources work was missing — well-targeted. The tests encode the real regressions (
Acceptance checklist (must pass before merge):
CI: Red-until-dependency is expected here and is not a flaky-CI flag — but confirm in the UI that it goes green after the pointer bump before merging. Since this is the test half of a security-guard change, treat green CI post-bump as the gate. |
…od calls the guard Closes Marcel's suggested "at least one negative-auth integration test" line on PR #75. The ChecksResourceAccessTest already proves the trait returns bool correctly; this file proves each ResourceItem mutating method actually **calls** it — someone can't accidentally delete an `if (! canEditX) return;` line and have the tests still pass. Together the two files close the "guard exists AND is invoked" loop that a full HTTP integration test would cover, without needing to bootstrap the HTMX dispatcher (which requires DI container + event dispatch setup, high cost for the guarantee added). ## Coverage 16 tests / 17 assertions covering: ### canEditProgram path - seedFromProjects: refuses foreign programId; allows authorised (also verifies both seedPeople + seedBudget are reached when mode='both'). ### canEditCanvas path (canvas → program resolution) - addPerson: refuses when repo returns null (cross-canvas-type IDOR); refuses when program lookup fails; allows authorised. - addBudget / addDependency: refuse when canvas doesn't resolve. ### canEditItem path (item → canvas → program resolution) - updatePerson: refuses when repo returns null; refuses when program lookup fails; allows authorised. - updateBudget / deletePerson / deleteBudget: refuse when item doesn't resolve. ### canEditProgram + canAttachProject dual path - attachProject: refuses foreign programId (Marcel's flagged IDOR); refuses foreign projectId even when programId is owned (the inverse attack — yanking someone else's project under your own program). ### canEditProgram path - createProject: refuses foreign programId. ## Approach ResourceItem is instantiated via newInstanceWithoutConstructor to skip the base HtmxController::__construct (which requires the DI container + event dispatch). Services + $tpl + $response are injected on an anonymous subclass. $_POST is mutated per test and cleared in tearDown so tests don't leak state. Services are stubbed via anonymous subclasses (not mock builders) so the stubs can expose call-count counters — the "authorised" assertions verify the service mutation was actually reached, not just that no exception fired. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
Status: ready-pending-dependency (progressed) · Priority: P2 (test-only; unblocks the resources security work) · Next action: land plugins#75 first, then bump the submodule pointer · Owner: @gloriafolaron, review gate @marcelfolaron / @broskees Automated maintainer re-review (advisory only — not an approval; merge authority stays with @marcelfolaron / @broskees). Re-reviewed at head Intent: Adds unit coverage for the PgmPro Resources security guards + the Progress since last review — the one open item I flagged is now closed:
Change requests on the new commit:
Acceptance checklist (must pass before merge):
CI: Red-until-dependency is expected and is not a flaky-CI flag — but confirm in the UI it goes green after the pointer bump before merging. This is the test gate for a security-guard change: treat green CI post-bump as the merge condition. Advisory re-review only — not an approval, not marking ready, not merging. |
…roject order Marcel's re-review on 07-17 flagged two follow-ups on the controller wiring tests. Both addressed here. ## Docblock — DI wiring is deliberately out of scope Adds a class-docblock section documenting that the tests skip HtmxController::__construct and inject services directly, so they prove "the action-method body invokes the guard" but NOT "the DI-instantiated controller can even be constructed." Keeps the follow-up idea of one HTTP-level 403 test on the backlog for the DI-shape guarantee this file cannot offer. ## attachProject order-sensitive assertions Previously the two attachProject refusal tests only asserted an exception was thrown — a bug that reordered the two gates (canAttachProject before canEditProgram) would still throw and the tests would silently pass. Now the projectService stub tracks every getProject($id) call in order, and the tests assert: - `refuses_unauthorized_program_and_short_circuits` — expects probedIds=[999999], proving canEditProgram short-circuited before canAttachProject ever ran (avoids the side-channel leak of "does this project exist?" via response timing or downstream logging). - `refuses_unauthorized_project_and_verifies_order` — expects probedIds=[42, 999], proving canEditProgram(42) ran FIRST and canAttachProject(999) SECOND. A swap would produce probedIds=[999] (canAttachProject would refuse immediately without canEditProgram ever running), failing this assertion even though the exception itself would still be thrown. Same 16 tests, 17 assertions — the 2 tests changed from `expectException` to `try/catch` + `assertSame` to combine exception + order checks. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Adds unit-level coverage for the PgmPro plugin’s resource ownership/authorization guards and for ResourceStructureService::updateBudgetItem() regressions/field persistence, strengthening the security/IDOR guarantees and preventing silent budget update drops.
Changes:
- Adds trait-level tests for
ChecksResourceAccess(program/canvas/item edit checks, attach-project checks, and 403 failure surface). - Adds a wiring test to ensure each mutating
ResourceItemaction method actually invokes the guard(s). - Extends
ResourceStructureServiceTestwith regression and partial-update coverage for budget item updates plus program-id resolver pass-through/null behavior.
Reviewed changes
Copilot reviewed 3 out of 3 changed files in this pull request and generated 3 comments.
| File | Description |
|---|---|
| tests/Unit/app/Plugins/PgmPro/Hxcontrollers/ResourceItemAuthTest.php | New wiring tests ensuring ResourceItem mutating endpoints call the ownership guards and throw 403 on refusal. |
| tests/Unit/app/Plugins/PgmPro/Hxcontrollers/Concerns/ChecksResourceAccessTest.php | New unit tests covering the guard trait’s allow/deny behavior and its 403 “forbid” surface. |
| tests/Unit/app/Plugins/PgmPro/Domain/Resources/Services/ResourceStructureServiceTest.php | Adds regression/field persistence tests for updateBudgetItem and verifies resolver methods’ delegation/null guarantees. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| * Setup: | ||
| * - ResourceItem is instantiated via newInstanceWithoutConstructor | ||
| * to skip the base HtmxController constructor (which requires DI + | ||
| * event dispatch bootstrapping). | ||
| * - Services are stubbed via anonymous subclasses so we can steer |
| * Build a ResourceItem with services stubbed to the passed shape. | ||
| * The controller is instantiated without its base constructor so | ||
| * we don't have to bootstrap the HTMX request stack; services are | ||
| * injected via reflection. |
| /** | ||
| * Bypass the base HtmxController constructor (which requires the | ||
| * app container + event dispatch) and inject the services + a | ||
| * fresh Response via reflection. Anonymous subclass exposes the | ||
| * mocked services so tests can assert on their call counters. | ||
| */ |
Companion to plugins#75 — the code under test lives in the plugin submodule at that PR's tip.
Depends on plugins#75 merging first. CI on this PR will be red until the plugin PR merges into
feat/logic-model-reverseand the submodule pointer bumps. All 40 assertions pass locally against the plugin PR branch's working tree.Coverage added
tests/Unit/.../Hxcontrollers/Concerns/ChecksResourceAccessTest.php (new — 21 tests)
Ownership trait every mutating HxController depends on:
canEditProgram— refuses zero id / nonexistent / regular project type / strategy type / missing session usercanEditProgram— owner + admin bypass assignmentcanEditProgram— assigned non-admin allowed; unassigned non-admin refused (the core IDOR case)canEditCanvas/canEditItem— refuse when repo resolver returns null (blocks cross-canvas-type IDOR); delegate to canEditProgram when it returns a program idcanAttachProject— refuses program-type / strategy-type; enforces assignment / admin bypassforbid()throws AccessDeniedHttpException → 403tests/Unit/.../ResourceStructureServiceTest.php (12 added)
updateBudgetItemrefuses wrong box / missing item (without touching updateItem)updateBudgetItempersistsspent(Marcel's exact regression — old isset guard dropped it)updateBudgetItempersistsprojectId(new — budget→project reassignment)projectId=0sentinel persists as 0spent: 0persists (isset→array_key_exists fix)namemaps todescriptioncolumngetProgramIdForCanvas/getProgramIdForItempass-through + null path (the null return is the security guarantee)Not included
Test run
🤖 Generated with Claude Code