Compare commits

..

7 Commits

Author SHA1 Message Date
81a07a41e4 feat: implement runtime trend recalibration reporting (#244)
Some checks failed
Main Confidence / confidence (push) Failing after 46s
## Summary
- implement Spec 211 runtime trend reporting with bounded lane history, drift classification, hotspot trend output, and recalibration evidence handling
- extend the repo-truth governance seams and workflow wrappers for comparable-bundle hydration, trend artifact publication, and contract-backed reporting
- add the Spec 211 planning artifacts, data model, quickstart, tasks, and repository contract documents

## Validation
- parsed `specs/211-runtime-trend-recalibration/contracts/test-runtime-trend-history.schema.json`
- parsed `specs/211-runtime-trend-recalibration/contracts/test-runtime-trend.logical.openapi.yaml`
- re-ran cross-artifact consistency analysis for the Spec 211 artifact set until no material findings remained
- no application test suite was re-run as part of this final commit/push/PR step

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #244
2026-04-18 07:36:05 +00:00
bf38ec1780 Spec 210: implement CI test matrix budget enforcement (#243)
Some checks failed
Main Confidence / confidence (push) Failing after 3m36s
## Summary
- add explicit Gitea workflow files for PR Fast Feedback, `dev` Confidence, Heavy Governance, and Browser lanes
- extend the repo-truth lane support seams with workflow profiles, trigger-aware budget enforcement, artifact publication contracts, CI summaries, and failure classification
- add deterministic artifact staging, new CI governance guard coverage, and Spec 210 planning/contracts/docs updates

## Validation
- `cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent`
- `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Guards/CiFastFeedbackWorkflowContractTest.php tests/Feature/Guards/CiConfidenceWorkflowContractTest.php tests/Feature/Guards/CiHeavyBrowserWorkflowContractTest.php tests/Feature/Guards/CiLaneFailureClassificationContractTest.php tests/Feature/Guards/FastFeedbackLaneContractTest.php tests/Feature/Guards/ConfidenceLaneContractTest.php tests/Feature/Guards/HeavyGovernanceLaneContractTest.php tests/Feature/Guards/BrowserLaneIsolationTest.php tests/Feature/Guards/FixtureLaneImpactBudgetTest.php tests/Feature/Guards/TestLaneManifestTest.php tests/Feature/Guards/TestLaneArtifactsContractTest.php tests/Feature/Guards/TestLaneCommandContractTest.php`
- `./scripts/platform-test-lane fast-feedback`
- `./scripts/platform-test-lane confidence`
- `./scripts/platform-test-lane heavy-governance`
- `./scripts/platform-test-lane browser`
- `./scripts/platform-test-report fast-feedback`
- `./scripts/platform-test-report confidence`

## Notes
- scheduled Heavy Governance and Browser workflows stay gated behind `TENANTATLAS_ENABLE_HEAVY_GOVERNANCE_SCHEDULE=1` and `TENANTATLAS_ENABLE_BROWSER_SCHEDULE=1`
- the remaining rollout evidence task is capturing the live Gitea run set this PR enables: PR Fast Feedback, `dev` Confidence, manual and scheduled Heavy Governance, and manual and scheduled Browser runs

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #243
2026-04-17 18:04:35 +00:00
a2fdca43fd feat: implement heavy governance cost recovery (#242)
## Summary
- implement Spec 209 heavy-governance cost recovery end to end
- add the heavy-governance contract, hotspot inventory, decomposition, snapshots, budget outcome, and author-guidance surfaces in the shared lane support seams
- slim the baseline and findings hotspot families, harden wrapper behavior, and refresh the spec, quickstart, and contract artifacts

## Validation
- `cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent`
- `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Guards/TestLaneCommandContractTest.php tests/Feature/Guards/ActionSurfaceContractTest.php tests/Feature/Guards/OperationLifecycleOpsUxGuardTest.php`
- `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Filament/BaselineProfileCaptureStartSurfaceTest.php tests/Feature/Filament/BaselineProfileCompareStartSurfaceTest.php tests/Feature/Filament/BaselineActionAuthorizationTest.php tests/Feature/Findings/FindingsListFiltersTest.php tests/Feature/Findings/FindingExceptionRenewalTest.php tests/Feature/Findings/FindingWorkflowRowActionsTest.php tests/Feature/Findings/FindingWorkflowViewActionsTest.php tests/Feature/Guards/ActionSurfaceContractTest.php tests/Feature/Guards/OperationLifecycleOpsUxGuardTest.php`
- `./scripts/platform-sail artisan test --compact`

## Outcome
- heavy-governance latest artifacts now agree on an authoritative `330s` threshold with `recalibrated` outcome after the honest rerun
- full suite result: `3760 passed`, `8 skipped`, `23535 assertions`

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #242
2026-04-17 13:17:13 +00:00
0d5d1fc9f4 Spec 208: finalize heavy suite segmentation (#241)
## Summary
- add the checked-in Spec 208 heavy-suite classification and family manifest with config-driven lane generation, attribution, and budget reporting
- update Pest grouping, guard coverage, wrapper/report contracts, and spec artifacts for the segmented lane model
- complete the targeted follow-up pass that re-homes the remaining in-scope confidence hotspots into explicit heavy-governance families

## Acceptance
- confidence is repaired and now measures 389.613832s, down from 587.446894s and below the 450s lane budget
- confidence is also slightly below the post-Spec-207 baseline of 394.383441s (delta -4.769609s)
- this closes the central Spec 208 acceptance issue that had kept the spec open

## Intentionally Re-homed Families
- finding-bulk-actions-workflow
- drift-bulk-triage-all-matching
- baseline-profile-start-surfaces
- workspace-settings-slice-management
- findings-workflow-surfaces
- workspace-only-admin-surface-independence

## Explicit Residual Risk
- heavy-governance now measures 318.296962s, above its documented 300s threshold
- the cost was not removed; it was moved into the correct lane and made visible on clearly named heavy families
- this is documented residual debt, not an open Spec 208 failure

## Validation
- focused guard/support validation: 206 passed (3607 assertions)
- lane wrapper/report validation completed for confidence and heavy-governance
- no full-suite run was performed in this pass by request

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #241
2026-04-17 09:53:55 +00:00
d8e331e92f Spec 207: implement shared test fixture slimming (#240)
## Summary
- implement the canonical shared fixture profile model with minimal, standard, and full semantics plus temporary legacy alias resolution
- slim default factory behavior for operation runs, backup sets, provider connections, and provider credentials while keeping explicit heavy opt-in states
- migrate the first console, navigation, RBAC, and drift caller packs to explicit lean helpers and wire lane comparison reporting into the existing Spec 206 seams
- reconcile spec 207 docs, contracts, quickstart guidance, and task tracking with the implemented behavior

## Validation
- `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Unit/Support/CreateUserWithTenantProfilesTest.php tests/Unit/Factories/TenantFactoryTest.php tests/Unit/Factories/OperationRunFactoryTest.php tests/Unit/Factories/BackupSetFactoryTest.php tests/Unit/Factories/ProviderConnectionFactoryTest.php tests/Unit/Factories/ProviderCredentialFactoryTest.php tests/Feature/Guards/FixtureCostProfilesGuardTest.php tests/Feature/Guards/FixtureLaneImpactBudgetTest.php tests/Feature/Guards/TestLaneArtifactsContractTest.php tests/Feature/Console/ReconcileOperationRunsCommandTest.php tests/Feature/Console/ReconcileBackupScheduleOperationRunsCommandTest.php tests/Feature/Navigation/RelatedNavigationResolverMemoizationTest.php tests/Feature/Spec080WorkspaceManagedTenantAdminMigrationTest.php tests/Feature/BaselineDriftEngine/FindingFidelityTest.php`
- `cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent`
- `./scripts/platform-test-lane fast-feedback`
- `./scripts/platform-test-lane confidence`
- `./scripts/platform-test-report fast-feedback`
- `./scripts/platform-test-report confidence`

## Lane outcome
- `fast-feedback`: 136.400761s vs 176.73623s baseline, status `improved`
- `confidence`: 394.5669s vs 394.383441s baseline, status `stable`

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #240
2026-04-16 17:29:25 +00:00
3c38192405 Spec 206: implement test suite governance foundation (#239)
## Summary

This PR implements Spec 206 end to end and establishes the first checked-in test suite governance foundation for the platform app.

Key changes:
- add manifest-backed test lanes for fast-feedback, confidence, browser, heavy-governance, profiling, and junit
- add budget and report helpers plus app-local artifact generation under `apps/platform/storage/logs/test-lanes`
- add repo-root Sail-friendly lane/report wrappers
- switch the default contributor test path to the fast-feedback lane
- introduce explicit fixture profiles and cheaper defaults for shared tenant/provider test setup
- add minimal/heavy factory states for tenant and provider connection setup
- migrate the first high-usage and provider-sensitive tests to explicit fixture profiles
- document budgets, taxonomy rules, DB reset guidance, and the full Spec 206 plan/contracts/tasks set

## Validation

Executed during implementation:
- focused Spec 206 guard/support/factory validation pack: 31 passed
- provider-sensitive regression pack: 29 passed
- first high-usage caller migration pack: 120 passed
- lane routing and wrapper validation succeeded
- pint completed successfully

Measured lane baselines captured in docs:
- fast-feedback: 176.74s
- confidence: 394.38s
- heavy-governance: 83.66s
- browser: 128.87s
- junit: 380.14s
- profiling: 2701.51s
- full-suite baseline anchor: 2624.60s

## Notes

- Livewire v4 / Filament v5 runtime behavior is unchanged by this PR.
- No new runtime routes, product UI flows, or database migrations are introduced.
- Panel provider registration remains unchanged in `bootstrap/providers.php`.

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #239
2026-04-16 13:58:50 +00:00
e02799b383 feat: implement spec 198 monitoring page state contract (#238)
## Summary
- implement Spec 198 monitoring page-state contracts across Operations, Audit Log, Finding Exceptions Queue, Evidence Overview, Baseline Compare Landing, and Baseline Compare Matrix
- align selected-record and draft/apply behavior with query/session restoration semantics, including canonical navigation and tenant-filter normalization helpers
- add Spec 198 feature and browser coverage, update closure/spec artifacts, and refresh affected regression tests that asserted pre-contract behavior

## Verification
- focused Spec 198 feature pack passed through Sail
- Spec 198 browser smoke passed through Sail
- existing Spec 190 and Spec 194 browser smokes passed through Sail
- targeted fallout tests were updated and rerun during full-suite triage

## Notes
- Livewire v4 / Filament v5 compliant only; no legacy API reintroduction
- no provider registration changes; Laravel 11+ provider registration remains in `bootstrap/providers.php`
- no global-search behavior changed for any resource
- destructive queue decision actions remain confirmation-gated and authorization-backed
- no new Filament assets were added; existing deploy step for `php artisan filament:assets` remains unchanged

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #238
2026-04-15 21:59:42 +00:00
149 changed files with 23679 additions and 495 deletions

View File

@ -0,0 +1,80 @@
name: Browser Lane
on:
workflow_dispatch:
schedule:
- cron: '43 4 * * 1-5'
permissions:
actions: read
contents: read
jobs:
browser:
if: ${{ github.event_name != 'schedule' || vars.TENANTATLAS_ENABLE_BROWSER_SCHEDULE == '1' }}
runs-on: ubuntu-latest
env:
SAIL_TTY: 'false'
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Set up PHP
uses: shivammathur/setup-php@v2
with:
php-version: '8.4'
coverage: none
tools: composer:v2
- name: Install platform dependencies
run: |
cd apps/platform
if [[ ! -f .env ]]; then
cp .env.example .env
fi
composer install --no-interaction --prefer-dist --optimize-autoloader
- name: Boot Sail
run: |
cd apps/platform
./vendor/bin/sail up -d
./vendor/bin/sail artisan key:generate --force --no-interaction
- name: Resolve Browser context
id: context
run: |
if [[ "${{ github.event_name }}" == "schedule" ]]; then
echo "workflow_id=browser-scheduled" >> "$GITHUB_OUTPUT"
echo "trigger_class=scheduled" >> "$GITHUB_OUTPUT"
else
echo "workflow_id=browser-manual" >> "$GITHUB_OUTPUT"
echo "trigger_class=manual" >> "$GITHUB_OUTPUT"
fi
- name: Run Browser lane
run: ./scripts/platform-test-lane browser --workflow-id=${{ steps.context.outputs.workflow_id }} --trigger-class=${{ steps.context.outputs.trigger_class }}
- name: Refresh Browser report
if: always()
env:
TENANTATLAS_GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
run: ./scripts/platform-test-report browser --workflow-id=${{ steps.context.outputs.workflow_id }} --trigger-class=${{ steps.context.outputs.trigger_class }} --fetch-latest-history
- name: Stage Browser artifacts
if: always()
run: ./scripts/platform-test-artifacts browser .gitea-artifacts/browser --workflow-id=${{ steps.context.outputs.workflow_id }} --trigger-class=${{ steps.context.outputs.trigger_class }}
- name: Upload Browser artifacts
if: always()
uses: actions/upload-artifact@v4
with:
name: browser-artifacts
path: .gitea-artifacts/browser
if-no-files-found: error
- name: Stop Sail
if: always()
run: |
cd apps/platform
./vendor/bin/sail stop

View File

@ -0,0 +1,80 @@
name: Heavy Governance Lane
on:
workflow_dispatch:
schedule:
- cron: '17 4 * * 1-5'
permissions:
actions: read
contents: read
jobs:
heavy-governance:
if: ${{ github.event_name != 'schedule' || vars.TENANTATLAS_ENABLE_HEAVY_GOVERNANCE_SCHEDULE == '1' }}
runs-on: ubuntu-latest
env:
SAIL_TTY: 'false'
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Set up PHP
uses: shivammathur/setup-php@v2
with:
php-version: '8.4'
coverage: none
tools: composer:v2
- name: Install platform dependencies
run: |
cd apps/platform
if [[ ! -f .env ]]; then
cp .env.example .env
fi
composer install --no-interaction --prefer-dist --optimize-autoloader
- name: Boot Sail
run: |
cd apps/platform
./vendor/bin/sail up -d
./vendor/bin/sail artisan key:generate --force --no-interaction
- name: Resolve Heavy Governance context
id: context
run: |
if [[ "${{ github.event_name }}" == "schedule" ]]; then
echo "workflow_id=heavy-governance-scheduled" >> "$GITHUB_OUTPUT"
echo "trigger_class=scheduled" >> "$GITHUB_OUTPUT"
else
echo "workflow_id=heavy-governance-manual" >> "$GITHUB_OUTPUT"
echo "trigger_class=manual" >> "$GITHUB_OUTPUT"
fi
- name: Run Heavy Governance lane
run: ./scripts/platform-test-lane heavy-governance --workflow-id=${{ steps.context.outputs.workflow_id }} --trigger-class=${{ steps.context.outputs.trigger_class }}
- name: Refresh Heavy Governance report
if: always()
env:
TENANTATLAS_GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
run: ./scripts/platform-test-report heavy-governance --workflow-id=${{ steps.context.outputs.workflow_id }} --trigger-class=${{ steps.context.outputs.trigger_class }} --fetch-latest-history
- name: Stage Heavy Governance artifacts
if: always()
run: ./scripts/platform-test-artifacts heavy-governance .gitea-artifacts/heavy-governance --workflow-id=${{ steps.context.outputs.workflow_id }} --trigger-class=${{ steps.context.outputs.trigger_class }}
- name: Upload Heavy Governance artifacts
if: always()
uses: actions/upload-artifact@v4
with:
name: heavy-governance-artifacts
path: .gitea-artifacts/heavy-governance
if-no-files-found: error
- name: Stop Sail
if: always()
run: |
cd apps/platform
./vendor/bin/sail stop

View File

@ -0,0 +1,68 @@
name: Main Confidence
on:
push:
branches:
- dev
permissions:
actions: read
contents: read
jobs:
confidence:
runs-on: ubuntu-latest
env:
SAIL_TTY: 'false'
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Set up PHP
uses: shivammathur/setup-php@v2
with:
php-version: '8.4'
coverage: none
tools: composer:v2
- name: Install platform dependencies
run: |
cd apps/platform
if [[ ! -f .env ]]; then
cp .env.example .env
fi
composer install --no-interaction --prefer-dist --optimize-autoloader
- name: Boot Sail
run: |
cd apps/platform
./vendor/bin/sail up -d
./vendor/bin/sail artisan key:generate --force --no-interaction
- name: Run Confidence lane
run: ./scripts/platform-test-lane confidence --workflow-id=main-confidence --trigger-class=mainline-push
- name: Refresh Confidence report
if: always()
env:
TENANTATLAS_GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
run: ./scripts/platform-test-report confidence --workflow-id=main-confidence --trigger-class=mainline-push --fetch-latest-history
- name: Stage Confidence artifacts
if: always()
run: ./scripts/platform-test-artifacts confidence .gitea-artifacts/main-confidence --workflow-id=main-confidence --trigger-class=mainline-push
- name: Upload Confidence artifacts
if: always()
uses: actions/upload-artifact@v4
with:
name: confidence-artifacts
path: .gitea-artifacts/main-confidence
if-no-files-found: error
- name: Stop Sail
if: always()
run: |
cd apps/platform
./vendor/bin/sail stop

View File

@ -0,0 +1,70 @@
name: PR Fast Feedback
on:
pull_request:
types:
- opened
- reopened
- synchronize
permissions:
actions: read
contents: read
jobs:
fast-feedback:
runs-on: ubuntu-latest
env:
SAIL_TTY: 'false'
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Set up PHP
uses: shivammathur/setup-php@v2
with:
php-version: '8.4'
coverage: none
tools: composer:v2
- name: Install platform dependencies
run: |
cd apps/platform
if [[ ! -f .env ]]; then
cp .env.example .env
fi
composer install --no-interaction --prefer-dist --optimize-autoloader
- name: Boot Sail
run: |
cd apps/platform
./vendor/bin/sail up -d
./vendor/bin/sail artisan key:generate --force --no-interaction
- name: Run Fast Feedback lane
run: ./scripts/platform-test-lane fast-feedback --workflow-id=pr-fast-feedback --trigger-class=pull-request
- name: Refresh Fast Feedback report
if: always()
env:
TENANTATLAS_GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
run: ./scripts/platform-test-report fast-feedback --workflow-id=pr-fast-feedback --trigger-class=pull-request --fetch-latest-history
- name: Stage Fast Feedback artifacts
if: always()
run: ./scripts/platform-test-artifacts fast-feedback .gitea-artifacts/pr-fast-feedback --workflow-id=pr-fast-feedback --trigger-class=pull-request
- name: Upload Fast Feedback artifacts
if: always()
uses: actions/upload-artifact@v4
with:
name: fast-feedback-artifacts
path: .gitea-artifacts/pr-fast-feedback
if-no-files-found: error
- name: Stop Sail
if: always()
run: |
cd apps/platform
./vendor/bin/sail stop

View File

@ -190,6 +190,16 @@ ## Active Technologies
- PostgreSQL unchanged; no new persistence, cache store, or durable UI artifact (197-shared-detail-contract) - PostgreSQL unchanged; no new persistence, cache store, or durable UI artifact (197-shared-detail-contract)
- PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Pest v4, Tailwind CSS v4, existing `CanonicalAdminTenantFilterState`, `CanonicalNavigationContext`, `OperateHubShell`, Filament `InteractsWithTable`, and page-local Livewire state on the affected Filament pages (198-monitoring-page-state) - PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Pest v4, Tailwind CSS v4, existing `CanonicalAdminTenantFilterState`, `CanonicalNavigationContext`, `OperateHubShell`, Filament `InteractsWithTable`, and page-local Livewire state on the affected Filament pages (198-monitoring-page-state)
- PostgreSQL plus existing Laravel session-backed table filter, search, and sort persistence; no schema change planned (198-monitoring-page-state) - PostgreSQL plus existing Laravel session-backed table filter, search, and sort persistence; no schema change planned (198-monitoring-page-state)
- PHP 8.4.15 + Laravel 12, Pest v4, PHPUnit 12, Pest Browser plugin, Filament v5, Livewire v4, Laravel Sail (206-test-suite-governance)
- SQLite `:memory:` for the default test configuration, dedicated PostgreSQL config for the schema-level `Pgsql` suite, and local runner artifacts under `apps/platform/storage/logs/test-lanes` (206-test-suite-governance)
- PHP 8.4.15 + Laravel 12, Pest v4, PHPUnit 12, Filament v5, Livewire v4, Laravel Sail (207-shared-test-fixture-slimming)
- SQLite `:memory:` for the default test environment, isolated PostgreSQL coverage via the existing dedicated suite, and lane-measurement artifacts under the app-root contract path `storage/logs/test-lanes` (207-shared-test-fixture-slimming)
- SQLite `:memory:` for the default test environment, existing lane artifacts under the app-root contract path `storage/logs/test-lanes`, and no new product persistence (208-heavy-suite-segmentation)
- SQLite `:memory:` for the default test environment, mixed database strategy for some heavy-governance families as declared in `TestLaneManifest`, and existing lane artifacts under the app-root contract path `storage/logs/test-lanes` (209-heavy-governance-cost)
- PHP 8.4.15 for repo-truth test governance, Bash for repo-root wrappers, and GitHub-compatible Gitea Actions workflow YAML under `.gitea/workflows/` + Laravel 12, Pest v4, PHPUnit 12, Filament v5, Livewire v4, Laravel Sail, Gitea Actions backed by `act_runner`, and the existing `Tests\Support\TestLaneManifest`, `TestLaneBudget`, and `TestLaneReport` seams (210-ci-matrix-budget-enforcement)
- SQLite `:memory:` for default lane execution, filesystem artifacts under the app-root contract path `storage/logs/test-lanes`, checked-in workflow YAML under `.gitea/workflows/`, and no new product database persistence (210-ci-matrix-budget-enforcement)
- PHP 8.4.15 for repo-truth governance logic, Bash for repo-root wrappers, GitHub-compatible Gitea Actions workflow YAML under `.gitea/workflows/`, plus JSON Schema and logical OpenAPI for repository contracts + Laravel 12, Pest v4, PHPUnit 12, Filament v5, Livewire v4, Laravel Sail, Gitea Actions backed by `act_runner`, uploaded artifact bundles, and the existing `Tests\Support\TestLaneManifest`, `TestLaneBudget`, and `TestLaneReport` seams (211-runtime-trend-recalibration)
- SQLite `:memory:` for lane execution, filesystem artifacts under `apps/platform/storage/logs/test-lanes`, staged CI bundles under `.gitea-artifacts/<workflow-profile>`, bounded derived trend/history artifacts adjacent to current lane artifacts, and no new product database persistence (211-runtime-trend-recalibration)
- PHP 8.4.15 (feat/005-bulk-operations) - PHP 8.4.15 (feat/005-bulk-operations)
@ -224,8 +234,8 @@ ## Code Style
PHP 8.4.15: Follow standard conventions PHP 8.4.15: Follow standard conventions
## Recent Changes ## Recent Changes
- 198-monitoring-page-state: Added PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Pest v4, Tailwind CSS v4, existing `CanonicalAdminTenantFilterState`, `CanonicalNavigationContext`, `OperateHubShell`, Filament `InteractsWithTable`, and page-local Livewire state on the affected Filament pages - 211-runtime-trend-recalibration: Added PHP 8.4.15 for repo-truth governance logic, Bash for repo-root wrappers, GitHub-compatible Gitea Actions workflow YAML under `.gitea/workflows/`, plus JSON Schema and logical OpenAPI for repository contracts + Laravel 12, Pest v4, PHPUnit 12, Filament v5, Livewire v4, Laravel Sail, Gitea Actions backed by `act_runner`, uploaded artifact bundles, and the existing `Tests\Support\TestLaneManifest`, `TestLaneBudget`, and `TestLaneReport` seams
- 197-shared-detail-contract: Added PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Pest v4, Tailwind CSS v4, existing `VerificationReportViewer`, `VerificationReportChangeIndicator`, `PolicyNormalizer`, `VersionDiff`, `DriftFindingDiffBuilder`, and `SettingsCatalogSettingsTable` - 210-ci-matrix-budget-enforcement: Added PHP 8.4.15 for repo-truth test governance, Bash for repo-root wrappers, and GitHub-compatible Gitea Actions workflow YAML under `.gitea/workflows/` + Laravel 12, Pest v4, PHPUnit 12, Filament v5, Livewire v4, Laravel Sail, Gitea Actions backed by `act_runner`, and the existing `Tests\Support\TestLaneManifest`, `TestLaneBudget`, and `TestLaneReport` seams
- 205-compare-job-cleanup: Added PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Pest v4, Laravel Sail, existing `BaselineCompareService`, `CompareBaselineToTenantJob`, `CompareStrategyRegistry`, `IntuneCompareStrategy`, `CurrentStateHashResolver`, and current finding lifecycle services - 209-heavy-governance-cost: Added PHP 8.4.15 + Laravel 12, Pest v4, PHPUnit 12, Filament v5, Livewire v4, Laravel Sail
<!-- MANUAL ADDITIONS START --> <!-- MANUAL ADDITIONS START -->
<!-- MANUAL ADDITIONS END --> <!-- MANUAL ADDITIONS END -->

4
.gitignore vendored
View File

@ -37,6 +37,10 @@ coverage/
/apps/platform/storage/framework /apps/platform/storage/framework
/storage/logs /storage/logs
/apps/platform/storage/logs /apps/platform/storage/logs
/apps/platform/storage/logs/*
!/apps/platform/storage/logs/test-lanes/
/apps/platform/storage/logs/test-lanes/*
!/apps/platform/storage/logs/test-lanes/.gitignore
/storage/debugbar /storage/debugbar
/apps/platform/storage/debugbar /apps/platform/storage/debugbar
/vendor /vendor

View File

@ -10,5 +10,6 @@ ## Important
- `plan.md` - `plan.md`
- `tasks.md` - `tasks.md`
- `checklists/requirements.md` - `checklists/requirements.md`
- Runtime-changing work MUST carry testing/lane/runtime impact through the active `spec.md`, `plan.md`, and `tasks.md`; lane upkeep belongs to the feature, not to a later cleanup pass.
The files `.specify/spec.md`, `.specify/plan.md`, `.specify/tasks.md` may exist as legacy references only. The files `.specify/spec.md`, `.specify/plan.md`, `.specify/tasks.md` may exist as legacy references only.

View File

@ -1,37 +1,32 @@
<!-- <!--
Sync Impact Report Sync Impact Report
- Version change: 2.2.0 -> 2.3.0 - Version change: 2.3.0 -> 2.4.0
- Modified principles: - Modified principles:
- UI-CONST-001: expanded to make TenantPilot's decision-first - Quality Gates: expanded to require narrowest-lane validation and
governance identity explicit runtime-drift notes for runtime changes
- UI-REVIEW-001: spec and PR review gates expanded for surface role, - Governance review expectations: expanded to make lane/runtime
human-in-the-loop justification, workflow-vs-storage IA, and impact a mandatory part of spec and PR review
attention-load reduction
- Immediate Retrofit Priorities: expanded with a classification-first
wave for existing surfaces
- Added sections: - Added sections:
- Decision-First Operating Model & Progressive Disclosure - Test Suite Governance Must Live In The Delivery Workflow
(DECIDE-001) (TEST-GOV-001)
- Removed sections: None - Removed sections: None
- Templates requiring updates: - Templates requiring updates:
- ✅ .specify/memory/constitution.md - ✅ .specify/memory/constitution.md
- ✅ .specify/templates/plan-template.md (Constitution Check updated for - ✅ .specify/templates/plan-template.md (test-governance planning and
decision-first surface roles, workflow-first IA, and calm-surface lane-impact checks added)
review) - ✅ .specify/templates/spec-template.md (mandatory testing/lane/runtime
- ✅ .specify/templates/spec-template.md (surface role classification, impact section added)
operator contract, and requirements updated for decision-first - ✅ .specify/templates/tasks-template.md (lane classification,
governance) fixture-cost, and runtime-drift task guidance added)
- ✅ .specify/templates/tasks-template.md (implementation task guidance - ✅ .specify/templates/checklist-template.md (runtime checklist note
updated for progressive disclosure, single-case context, and added)
attention-load reduction) - ✅ .specify/README.md (SpecKit workflow note added for lane/runtime
- ✅ docs/product/standards/README.md (Constitution index updated for ownership)
DECIDE-001) - ✅ README.md (developer routine updated for test-governance upkeep)
- Commands checked: - Commands checked:
- N/A `.specify/templates/commands/*.md` directory is not present in this repo - N/A `.specify/templates/commands/*.md` directory is not present in this repo
- Follow-up TODOs: - Follow-up TODOs: None
- Create a dedicated surface / IA classification spec to retrofit
existing surfaces against DECIDE-001.
--> -->
# TenantPilot Constitution # TenantPilot Constitution
@ -107,6 +102,13 @@ ### Tests Must Protect Business Truth (TEST-TRUTH-001)
- Large dedicated test surfaces for thin presentation indirection SHOULD be avoided. - Large dedicated test surfaces for thin presentation indirection SHOULD be avoided.
- If a pattern creates more test burden than product certainty, the pattern SHOULD be simplified. - If a pattern creates more test burden than product certainty, the pattern SHOULD be simplified.
### Test Suite Governance Must Live In The Delivery Workflow (TEST-GOV-001)
- Test-suite governance is a standing workflow rule, not an occasional cleanup project.
- Every runtime-changing spec MUST declare the affected validation lane(s), any fixture/helper cost risk, whether it introduces or expands heavy-governance or browser coverage, and whether budget/baseline follow-up is needed.
- Plans MUST choose the narrowest lane mix that proves the change and MUST call out new heavy families, expensive defaults, or CI/runtime drift before implementation starts.
- Tasks and reviews MUST confirm lane classification, keep default fixtures cheap, reject accidental heavy promotion, and record material runtime drift or recalibration work in the active spec or PR.
- Standalone follow-up specs for test governance are reserved for recurring pain or structural lane changes; ordinary recalibration belongs inside normal delivery work.
### Enterprise Complexity Is Allowed Only Where Risk Demands It (RISK-COMP-001) ### Enterprise Complexity Is Allowed Only Where Risk Demands It (RISK-COMP-001)
- Heavier architecture is explicitly legitimate for workspace or tenant isolation, RBAC and policy enforcement, auditability, immutable history and snapshot truth, queue/job execution legitimacy, provider credential safety, retention/compliance evidence, and operator-critical lifecycle correctness. - Heavier architecture is explicitly legitimate for workspace or tenant isolation, RBAC and policy enforcement, auditability, immutable history and snapshot truth, queue/job execution legitimacy, provider credential safety, retention/compliance evidence, and operator-critical lifecycle correctness.
- Badge systems, explanation builders, trust/confidence overlays, presentation taxonomies, generic provider frameworks without real provider variance, speculative export/report/review infrastructure, UI meta-governance frameworks, and derived helper entities promoted into persisted truth are high-risk overproduction zones and require extra restraint. - Badge systems, explanation builders, trust/confidence overlays, presentation taxonomies, generic provider frameworks without real provider variance, speculative export/report/review infrastructure, UI meta-governance frameworks, and derived helper entities promoted into persisted truth are high-risk overproduction zones and require extra restraint.
@ -1326,6 +1328,7 @@ ### Spec-First Workflow
## Quality Gates ## Quality Gates
- Changes MUST be programmatically tested (Pest) and run via targeted `php artisan test ...`. - Changes MUST be programmatically tested (Pest) and run via targeted `php artisan test ...`.
- Runtime changes MUST validate the narrowest relevant lane and document any material budget, baseline, or trend follow-up in the active spec or PR.
- Run `./vendor/bin/sail bin pint --dirty` before finalizing. - Run `./vendor/bin/sail bin pint --dirty` before finalizing.
## Governance ## Governance
@ -1334,9 +1337,11 @@ ### Scope, Compliance, and Review Expectations
- This constitution applies across the repo. Feature specs may add stricter constraints but not weaker ones. - This constitution applies across the repo. Feature specs may add stricter constraints but not weaker ones.
- Restore semantics changes require: spec update, checklist update (if applicable), and tests proving safety. - Restore semantics changes require: spec update, checklist update (if applicable), and tests proving safety.
- Specs and PRs that introduce new persisted truth, abstractions, states, DTO/presenter layers, or taxonomies MUST include the proportionality review required by BLOAT-001. - Specs and PRs that introduce new persisted truth, abstractions, states, DTO/presenter layers, or taxonomies MUST include the proportionality review required by BLOAT-001.
- Runtime-changing specs and PRs MUST include testing/lane/runtime impact covering affected lanes, fixture/helper cost changes, any heavy-family expansion, expected budget/baseline effect, and the minimal validation commands.
- Specs and PRs that change operator-facing surfaces MUST classify each - Specs and PRs that change operator-facing surfaces MUST classify each
affected surface under DECIDE-001 and justify any new Primary affected surface under DECIDE-001 and justify any new Primary
Decision Surface or workflow-first navigation change. Decision Surface or workflow-first navigation change.
- Reviews MUST reject runtime changes when lane classification is missing, expensive defaults are introduced silently, or material CI/runtime drift is left undocumented.
- Review and approval MUST favor simplification, replacement, and absorption over additive semantic layering. - Review and approval MUST favor simplification, replacement, and absorption over additive semantic layering.
- Future-release preparation alone is not sufficient justification for new persistence or frameworkization unless security, tenant isolation, auditability, compliance evidence, or queue correctness already require it. - Future-release preparation alone is not sufficient justification for new persistence or frameworkization unless security, tenant isolation, auditability, compliance evidence, or queue correctness already require it.
@ -1350,4 +1355,4 @@ ### Versioning Policy (SemVer)
- **MINOR**: new principle/section or materially expanded guidance. - **MINOR**: new principle/section or materially expanded guidance.
- **MAJOR**: removing/redefining principles in a backward-incompatible way. - **MAJOR**: removing/redefining principles in a backward-incompatible way.
**Version**: 2.3.0 | **Ratified**: 2026-01-03 | **Last Amended**: 2026-04-12 **Version**: 2.4.0 | **Ratified**: 2026-01-03 | **Last Amended**: 2026-04-17

View File

@ -5,6 +5,7 @@ # [CHECKLIST TYPE] Checklist: [FEATURE NAME]
**Feature**: [Link to spec.md or relevant documentation] **Feature**: [Link to spec.md or relevant documentation]
**Note**: This checklist is generated by the `/speckit.checklist` command based on feature context and requirements. **Note**: This checklist is generated by the `/speckit.checklist` command based on feature context and requirements.
If the checklist covers runtime behavior changes, include lane classification, fixture-cost review, heavy-family justification, minimal validation commands, and any budget/baseline follow-up checks.
<!-- <!--
============================================================================ ============================================================================

View File

@ -21,6 +21,7 @@ ## Technical Context
**Primary Dependencies**: [e.g., FastAPI, UIKit, LLVM or NEEDS CLARIFICATION] **Primary Dependencies**: [e.g., FastAPI, UIKit, LLVM or NEEDS CLARIFICATION]
**Storage**: [if applicable, e.g., PostgreSQL, CoreData, files or N/A] **Storage**: [if applicable, e.g., PostgreSQL, CoreData, files or N/A]
**Testing**: [e.g., pytest, XCTest, cargo test or NEEDS CLARIFICATION] **Testing**: [e.g., pytest, XCTest, cargo test or NEEDS CLARIFICATION]
**Validation Lanes**: [e.g., fast-feedback, confidence or NEEDS CLARIFICATION]
**Target Platform**: [e.g., Linux server, iOS 15+, WASM or NEEDS CLARIFICATION] **Target Platform**: [e.g., Linux server, iOS 15+, WASM or NEEDS CLARIFICATION]
**Project Type**: [single/web/mobile - determines source structure] **Project Type**: [single/web/mobile - determines source structure]
**Performance Goals**: [domain-specific, e.g., 1000 req/s, 10k lines/sec, 60 fps or NEEDS CLARIFICATION] **Performance Goals**: [domain-specific, e.g., 1000 req/s, 10k lines/sec, 60 fps or NEEDS CLARIFICATION]
@ -48,6 +49,7 @@ ## Constitution Check
- Ops-UX system runs: initiator-null runs emit no terminal DB notification; audit remains via Monitoring; tenant-wide alerting goes through Alerts (not OperationRun notifications) - Ops-UX system runs: initiator-null runs emit no terminal DB notification; audit remains via Monitoring; tenant-wide alerting goes through Alerts (not OperationRun notifications)
- Automation: queued/scheduled ops use locks + idempotency; handle 429/503 with backoff+jitter - Automation: queued/scheduled ops use locks + idempotency; handle 429/503 with backoff+jitter
- Data minimization: Inventory stores metadata + whitelisted meta; logs contain no secrets/tokens - Data minimization: Inventory stores metadata + whitelisted meta; logs contain no secrets/tokens
- Test governance (TEST-GOV-001): affected lanes, fixture/helper cost risks, heavy-family changes, and any budget/baseline follow-up are explicit; the narrowest proving lane is planned
- Proportionality (PROP-001): any new structure, layer, persisted truth, or semantic machinery is justified by current release truth, current operator workflow, and why a narrower solution is insufficient - Proportionality (PROP-001): any new structure, layer, persisted truth, or semantic machinery is justified by current release truth, current operator workflow, and why a narrower solution is insufficient
- No premature abstraction (ABSTR-001): no new factories, registries, resolvers, strategy systems, interfaces, type registries, or orchestration pipelines before at least 2 real concrete cases exist, unless security, tenant isolation, auditability, compliance evidence, or queue correctness require it now - No premature abstraction (ABSTR-001): no new factories, registries, resolvers, strategy systems, interfaces, type registries, or orchestration pipelines before at least 2 real concrete cases exist, unless security, tenant isolation, auditability, compliance evidence, or queue correctness require it now
- Persisted truth (PERSIST-001): new tables/entities/artifacts represent independent product truth or lifecycle; convenience projections and UI helpers stay derived - Persisted truth (PERSIST-001): new tables/entities/artifacts represent independent product truth or lifecycle; convenience projections and UI helpers stay derived
@ -87,6 +89,18 @@ ## Constitution Check
selection actions, navigation, and object actions; risky or rare selection actions, navigation, and object actions; risky or rare
actions are grouped and ordered by meaning/frequency/risk; any special actions are grouped and ordered by meaning/frequency/risk; any special
type or workflow-hub exception is explicit and justified type or workflow-hub exception is explicit and justified
## Test Governance Check
> **Fill for any runtime-changing feature. Docs-only or template-only work may state `N/A`.**
- **Affected validation lanes**: [fast-feedback / confidence / heavy-governance / browser / profiling / junit / N/A]
- **Narrowest proving command(s)**: [Exact commands reviewers should run before merge]
- **Fixture / helper cost risks**: [none / describe]
- **Heavy-family additions or promotions**: [none / describe]
- **Budget / baseline / trend follow-up**: [none / describe]
- **Why no dedicated follow-up spec is needed**: [Routine upkeep stays inside this feature unless recurring pain or structural lane changes justify a separate spec]
## Project Structure ## Project Structure
### Documentation (this feature) ### Documentation (this feature)

View File

@ -88,6 +88,18 @@ ## Proportionality Review *(mandatory when structural complexity is introduced)*
- **Alternative intentionally rejected**: [What simpler option was considered and why it was not sufficient] - **Alternative intentionally rejected**: [What simpler option was considered and why it was not sufficient]
- **Release truth**: [Current-release truth or future-release preparation] - **Release truth**: [Current-release truth or future-release preparation]
## Testing / Lane / Runtime Impact *(mandatory for runtime behavior changes)*
For docs-only changes, state `N/A` for each field.
- **Validation lane(s)**: [fast-feedback / confidence / heavy-governance / browser / profiling / junit / N/A]
- **Why these lanes are sufficient**: [Why the narrowest listed lane(s) prove the change]
- **New or expanded test families**: [none / describe]
- **Fixture / helper cost impact**: [none / describe new defaults, factories, seeds, helpers, browser setup, etc.]
- **Heavy coverage justification**: [none / explain any heavy-governance or browser addition]
- **Budget / baseline / trend impact**: [none / expected drift + follow-up]
- **Planned validation commands**: [Exact minimal commands reviewers should run]
## User Scenarios & Testing *(mandatory)* ## User Scenarios & Testing *(mandatory)*
<!-- <!--
@ -175,6 +187,13 @@ ## Requirements *(mandatory)*
If the feature introduces a new enum/status family, DTO/presenter/envelope, persisted entity/table, interface/contract/registry/resolver, If the feature introduces a new enum/status family, DTO/presenter/envelope, persisted entity/table, interface/contract/registry/resolver,
or taxonomy/classification system, the Proportionality Review section above is mandatory. or taxonomy/classification system, the Proportionality Review section above is mandatory.
**Constitution alignment (TEST-GOV-001):** If this feature changes runtime behavior or tests, the spec MUST describe:
- the affected validation lane(s) and why they are the narrowest sufficient proof,
- any new or expanded heavy-governance or browser coverage,
- any fixture, helper, factory, seed, or default setup cost added or avoided,
- any expected budget, baseline, or trend impact,
- and the exact minimal validation commands reviewers should run.
**Constitution alignment (OPS-UX):** If this feature creates/reuses an `OperationRun`, the spec MUST: **Constitution alignment (OPS-UX):** If this feature creates/reuses an `OperationRun`, the spec MUST:
- explicitly state compliance with the Ops-UX 3-surface feedback contract (toast intent-only, progress surfaces, terminal DB notification), - explicitly state compliance with the Ops-UX 3-surface feedback contract (toast intent-only, progress surfaces, terminal DB notification),
- state that `OperationRun.status` / `OperationRun.outcome` transitions are service-owned (only via `OperationRunService`), - state that `OperationRun.status` / `OperationRun.outcome` transitions are service-owned (only via `OperationRunService`),

View File

@ -9,6 +9,12 @@ # Tasks: [FEATURE NAME]
**Prerequisites**: plan.md (required), spec.md (required for user stories), research.md, data-model.md, contracts/ **Prerequisites**: plan.md (required), spec.md (required for user stories), research.md, data-model.md, contracts/
**Tests**: For runtime behavior changes in this repo, tests are REQUIRED (Pest). Only docs-only changes may omit tests. **Tests**: For runtime behavior changes in this repo, tests are REQUIRED (Pest). Only docs-only changes may omit tests.
Runtime-changing features MUST also include tasks to:
- classify or confirm the affected validation lane(s),
- keep new helpers, factories, and seeds cheap by default or isolate expensive setup behind explicit opt-ins,
- justify any new heavy-governance or browser coverage,
- run the narrowest relevant lane before merge,
- and record budget, baseline, or trend follow-up when runtime cost shifts materially.
**Operations**: If this feature introduces long-running/remote/queued/scheduled work, include tasks to create/reuse and update a **Operations**: If this feature introduces long-running/remote/queued/scheduled work, include tasks to create/reuse and update a
canonical `OperationRun`, and ensure “View run” links route to the canonical Monitoring hub. canonical `OperationRun`, and ensure “View run” links route to the canonical Monitoring hub.
If security-relevant DB-only actions skip `OperationRun`, include tasks for `AuditLog` entries (before/after + actor + tenant). If security-relevant DB-only actions skip `OperationRun`, include tasks for `AuditLog` entries (before/after + actor + tenant).
@ -123,6 +129,7 @@ # Tasks: [FEATURE NAME]
- and adding tests around business consequences, permissions, lifecycle behavior, isolation, or audit responsibilities rather than thin indirection alone. - and adding tests around business consequences, permissions, lifecycle behavior, isolation, or audit responsibilities rather than thin indirection alone.
**Organization**: Tasks are grouped by user story to enable independent implementation and testing of each story. **Organization**: Tasks are grouped by user story to enable independent implementation and testing of each story.
Runtime behavior changes SHOULD include at least one explicit task for lane validation or runtime-impact review so upkeep stays inside the feature instead of becoming separate cleanup.
## Format: `[ID] [P?] [Story] Description` ## Format: `[ID] [P?] [Story] Description`

101
README.md
View File

@ -37,6 +37,107 @@ ### Website
- Start the dev server: `cd apps/website && pnpm dev` - Start the dev server: `cd apps/website && pnpm dev`
- Build the static site: `cd apps/website && pnpm build` - Build the static site: `cd apps/website && pnpm build`
## Test Suite Governance
### Canonical Lane Commands
- Preferred repo-root wrappers:
- `./scripts/platform-test-lane fast-feedback`
- `./scripts/platform-test-lane confidence`
- `./scripts/platform-test-lane heavy-governance`
- `./scripts/platform-test-lane browser`
- `./scripts/platform-test-lane profiling`
- `./scripts/platform-test-lane junit`
- Regenerate the latest report artifacts without re-running the lane:
- `./scripts/platform-test-report fast-feedback`
- `./scripts/platform-test-report confidence`
- `./scripts/platform-test-report heavy-governance`
- `./scripts/platform-test-report browser`
- `./scripts/platform-test-report profiling`
- `./scripts/platform-test-report junit`
- Trend-aware report refresh options:
- `--history-file=/absolute/path/to/<lane>-latest.trend-history.json` seeds one prior comparable window explicitly.
- `--history-bundle=/absolute/path/to/bundle-or-zip` hydrates the newest matching `trend-history.json` from a staged artifact bundle.
- `--fetch-latest-history` asks the wrapper to download the most recent comparable bundle from Gitea when `TENANTATLAS_GITEA_TOKEN` or `GITEA_TOKEN` is available.
- `--skip-latest-history` keeps the run intentionally cold-start so the summary reports `unstable` instead of guessing at trend state.
- App-local equivalents remain available through Sail Composer scripts:
- `cd apps/platform && ./vendor/bin/sail composer run test`
- `cd apps/platform && ./vendor/bin/sail composer run test:confidence`
- `cd apps/platform && ./vendor/bin/sail composer run test:heavy`
- `cd apps/platform && ./vendor/bin/sail composer run test:browser`
- `cd apps/platform && ./vendor/bin/sail composer run test:profile`
- `cd apps/platform && ./vendor/bin/sail composer run test:junit`
- The root wrapper is the safer default for long lanes because it pins Composer to `--timeout=0`.
### Trend Summary Reading
- `healthy`: enough comparable samples exist, the lane is comfortably under budget, and recent variance stays inside the documented noise floor.
- `budget-near`: the lane is still within budget, but headroom has entered the lane's near-budget band and needs attention before it becomes a repeated blocker.
- `trending-worse`: multiple comparable samples are worsening above the lane variance floor even though the lane is not yet clearly over budget.
- `regressed`: the lane is over budget or repeatedly worsening enough that ordinary noise is no longer a credible explanation.
- `unstable`: the report intentionally refuses a stronger label because history is too short, the comparison fingerprint changed, or the recent window is noisy.
- Recalibration is separate from health. Reports can emit candidate, approved, or rejected baseline or budget decisions, but repository truth never moves automatically.
- Hotspot evidence may be unavailable on a given cycle. When that happens the summary must say so explicitly, and `profiling` or `junit` remain the preferred support-lane follow-up paths.
### Workflow Expectation
- Every runtime-changing spec, plan, and task set MUST record the target validation lane(s), fixture-cost risks, any heavy-governance or browser expansion, and any budget/baseline follow-up.
- Routine lane recalibration belongs inside the affecting feature spec or PR; open a dedicated follow-up spec only when recurring pain or structural lane changes justify it.
### CI Trigger Matrix
- Pull requests (`opened`, `reopened`, `synchronize`) run only `./scripts/platform-test-lane fast-feedback` through `.gitea/workflows/test-pr-fast-feedback.yml` and block on test, wrapper, artifact, and mature Fast Feedback budget failures.
- Pushes to `dev` run only `./scripts/platform-test-lane confidence` through `.gitea/workflows/test-main-confidence.yml`; test and artifact failures block, while budget drift remains warning-first.
- Heavy Governance runs live in `.gitea/workflows/test-heavy-governance.yml` and stay isolated to manual plus scheduled triggers. The schedule remains gated behind `TENANTATLAS_ENABLE_HEAVY_GOVERNANCE_SCHEDULE=1` until the first successful manual validation.
- Browser runs live in `.gitea/workflows/test-browser.yml` and stay isolated to manual plus scheduled triggers. The schedule remains gated behind `TENANTATLAS_ENABLE_BROWSER_SCHEDULE=1` until the first successful manual validation.
- Fast Feedback uses a documented CI variance allowance of `15s` before budget overrun becomes blocking. Confidence uses `30s`, Browser uses `20s`, and Heavy Governance keeps warning-first or trend-only handling while its CI baseline stabilizes.
### CI Artifact Bundles
- Lane-local artifacts are still generated in `apps/platform/storage/logs/test-lanes` as `*-latest.*` files.
- CI workflows stage deterministic upload bundles through `./scripts/platform-test-artifacts` into `.gitea-artifacts/<workflow-profile>` before upload.
- Every governed CI lane now publishes `summary.md`, `budget.json`, `report.json`, `junit.xml`, and `trend-history.json`. `profiling` may additionally publish `profile.txt`.
- The report refresh step hydrates the most recent comparable `trend-history.json` before regenerating the current summary when CI credentials allow it, then republishes the refreshed bounded history for the next run.
- Artifact publication failures are first-class blocking failures for pull request and `dev` workflows.
### Recorded Baselines
| Scope | Wall clock | Budget | Notes |
|-------|------------|--------|-------|
| Full suite baseline | `2624.60s` | reference only | Current broad-suite measurement used as the budget anchor |
| `fast-feedback` | `176.74s` | `200s` | More than 50% below the current full-suite baseline |
| `confidence` | `394.38s` | `450s` | Broader non-browser pre-merge lane |
| `heavy-governance` | `83.66s` | `120s` | Seed heavy family lane for architecture, deprecation, ops UX, and action-surface scans |
| `browser` | `128.87s` | `150s` | Dedicated browser smoke and workflow lane |
| `junit` | `380.14s` | `450s` | Parallel machine-readable report lane for the confidence scope |
| `profiling` | `2701.51s` | `3000s` | Serial slow-test drift lane with profile output |
Artifacts are written under `apps/platform/storage/logs/test-lanes` and kept out of git except for the checked-in skeleton `.gitignore`.
### Honest Taxonomy Rules
- `Unit`: isolated logic, helpers, and low-cost domain behavior.
- `Feature`: HTTP, Livewire, Filament, jobs, and non-browser integration slices.
- `Browser`: only end-to-end browser smoke and workflow coverage under `tests/Browser`.
- `heavy-governance`: intentionally expensive architecture, deprecation, ops UX, and wide contract scans. The first seeded batch is `tests/Architecture`, `tests/Deprecation`, `tests/Feature/078`, `tests/Feature/090`, `tests/Feature/144`, `tests/Feature/OpsUx`, `tests/Feature/Filament/Alerts/AlertsKpiHeaderTest.php`, `tests/Feature/Guards/ActionSurfaceContractTest.php`, `tests/Feature/Guards/OperationLifecycleOpsUxGuardTest.php`, and `tests/Feature/ProviderConnections/CredentialLeakGuardTest.php`.
### Fixture Cost Guidance
- `createUserWithTenant()` now defaults to the explicit cheap `minimal` profile.
- Use `createMinimalUserWithTenant()` in high-usage callers that only need tenant membership and workspace/session wiring.
- Use `createStandardUserWithTenant()` or `fixtureProfile: 'standard'` when a test needs a default Microsoft provider connection without credentials, cache resets, or UI context.
- Use `createFullUserWithTenant()` or `fixtureProfile: 'full'` when a test intentionally needs provider, credential, cache-reset, and UI-context side effects together.
- Use `OperationRun::factory()->minimal()` for system-style runs and `OperationRun::factory()->withUser($user)` only when the initiator identity is materially part of the assertion.
- Use `BackupSet::factory()->full()` only when the test really needs backup items; the default backup-set factory path now stays item-free.
- `provider-enabled`, `credential-enabled`, `ui-context`, and `heavy` remain available only as temporary transition aliases while the first migration packs are landing.
### DB Reset and Seed Rules
- Default lanes use SQLite `:memory:` with `RefreshDatabase` as the reset strategy.
- The isolated PostgreSQL coverage remains the `Pgsql` suite and is reserved for schema or foreign-key assertions.
- Keep seeds out of default lanes. Opt into seeded fixtures only inside the test that needs business-truth seed data.
- Schema-baseline or dump-based acceleration remains a follow-up investigation, not a default requirement for the current lane model.
## Port Overrides ## Port Overrides
- Platform HTTP and Vite ports: set `APP_PORT` and or `VITE_PORT` before `corepack pnpm dev:platform` or `cd apps/platform && ./vendor/bin/sail up -d` - Platform HTTP and Vite ports: set `APP_PORT` and or `VITE_PORT` before `corepack pnpm dev:platform` or `cd apps/platform && ./vendor/bin/sail up -d`

View File

@ -22,6 +22,8 @@ class Tenant extends Model implements HasName
use HasFactory; use HasFactory;
use SoftDeletes; use SoftDeletes;
protected static bool $skipTestWorkspaceProvisioning = false;
public const STATUS_DRAFT = 'draft'; public const STATUS_DRAFT = 'draft';
public const STATUS_ONBOARDING = 'onboarding'; public const STATUS_ONBOARDING = 'onboarding';
@ -81,7 +83,7 @@ protected static function booted(): void
$tenant->status = self::STATUS_ACTIVE; $tenant->status = self::STATUS_ACTIVE;
} }
if ($tenant->workspace_id === null && app()->runningUnitTests()) { if ($tenant->workspace_id === null && app()->runningUnitTests() && ! static::$skipTestWorkspaceProvisioning) {
$workspace = Workspace::query()->create([ $workspace = Workspace::query()->create([
'name' => 'Test Workspace', 'name' => 'Test Workspace',
'slug' => 'test-'.Str::lower(Str::random(10)), 'slug' => 'test-'.Str::lower(Str::random(10)),
@ -118,6 +120,11 @@ public static function activeQuery(): Builder
->where('status', TenantLifecycle::Active->value); ->where('status', TenantLifecycle::Active->value);
} }
public static function skipTestWorkspaceProvisioning(bool $skip = true): void
{
static::$skipTestWorkspaceProvisioning = $skip;
}
public function makeCurrent(): void public function makeCurrent(): void
{ {
if (! $this->isSelectableAsContext()) { if (! $this->isSelectableAsContext()) {

View File

@ -54,7 +54,49 @@
], ],
"test": [ "test": [
"@php artisan config:clear --ansi", "@php artisan config:clear --ansi",
"@php artisan test" "@php -r \"require 'vendor/autoload.php'; exit(\\Tests\\Support\\TestLaneManifest::runLane('fast-feedback'));\""
],
"test:fast": [
"@php artisan config:clear --ansi",
"@php -r \"require 'vendor/autoload.php'; exit(\\Tests\\Support\\TestLaneManifest::runLane('fast-feedback'));\""
],
"test:confidence": [
"@php artisan config:clear --ansi",
"@php -r \"require 'vendor/autoload.php'; exit(\\Tests\\Support\\TestLaneManifest::runLane('confidence'));\""
],
"test:browser": [
"@php artisan config:clear --ansi",
"@php -r \"require 'vendor/autoload.php'; exit(\\Tests\\Support\\TestLaneManifest::runLane('browser'));\""
],
"test:heavy": [
"@php artisan config:clear --ansi",
"@php -r \"require 'vendor/autoload.php'; exit(\\Tests\\Support\\TestLaneManifest::runLane('heavy-governance'));\""
],
"test:profile": [
"@php artisan config:clear --ansi",
"@php -r \"require 'vendor/autoload.php'; exit(\\Tests\\Support\\TestLaneManifest::runLane('profiling'));\""
],
"test:junit": [
"@php artisan config:clear --ansi",
"@php -r \"require 'vendor/autoload.php'; exit(\\Tests\\Support\\TestLaneManifest::runLane('junit'));\""
],
"test:report": [
"@php -r \"require 'vendor/autoload.php'; exit(\\Tests\\Support\\TestLaneManifest::renderLatestReport('fast-feedback', 'shared-test-fixture-slimming'));\""
],
"test:report:confidence": [
"@php -r \"require 'vendor/autoload.php'; exit(\\Tests\\Support\\TestLaneManifest::renderLatestReport('confidence', 'shared-test-fixture-slimming'));\""
],
"test:report:browser": [
"@php -r \"require 'vendor/autoload.php'; exit(\\Tests\\Support\\TestLaneManifest::renderLatestReport('browser', ''));\""
],
"test:report:heavy": [
"@php -r \"require 'vendor/autoload.php'; exit(\\Tests\\Support\\TestLaneManifest::renderLatestReport('heavy-governance', ''));\""
],
"test:report:profile": [
"@php -r \"require 'vendor/autoload.php'; exit(\\Tests\\Support\\TestLaneManifest::renderLatestReport('profiling', ''));\""
],
"test:report:junit": [
"@php -r \"require 'vendor/autoload.php'; exit(\\Tests\\Support\\TestLaneManifest::renderLatestReport('junit', ''));\""
], ],
"test:pgsql": [ "test:pgsql": [
"Composer\\Config::disableProcessTimeout", "Composer\\Config::disableProcessTimeout",
@ -67,7 +109,7 @@
"./vendor/bin/sail stop" "./vendor/bin/sail stop"
], ],
"sail:test": [ "sail:test": [
"./vendor/bin/sail artisan test --compact" "./vendor/bin/sail composer run test"
], ],
"sail:test:pgsql": [ "sail:test:pgsql": [
"./vendor/bin/sail php vendor/bin/pest -c phpunit.pgsql.xml" "./vendor/bin/sail php vendor/bin/pest -c phpunit.pgsql.xml"

View File

@ -23,12 +23,47 @@ public function definition(): array
'name' => fake()->words(3, true), 'name' => fake()->words(3, true),
'created_by' => fake()->email(), 'created_by' => fake()->email(),
'status' => 'completed', 'status' => 'completed',
'item_count' => fake()->numberBetween(0, 100), 'item_count' => 0,
'completed_at' => now(), 'completed_at' => now(),
'metadata' => [], 'metadata' => [],
]; ];
} }
public function minimal(): static
{
return $this->state(fn (): array => [
'item_count' => 0,
'metadata' => [],
]);
}
/**
* @param array<string, mixed> $itemAttributes
*/
public function withItems(int $count = 1, array $itemAttributes = []): static
{
$count = max(1, $count);
return $this->state(fn (): array => [
'item_count' => $count,
])->afterCreating(function ($backupSet) use ($count, $itemAttributes): void {
BackupItem::factory()
->count($count)
->for($backupSet->tenant)
->for($backupSet)
->create(array_merge([
'payload' => ['id' => 'backup-item-'.fake()->uuid()],
'metadata' => [],
'assignments' => [],
], $itemAttributes));
});
}
public function full(): static
{
return $this->recentCompleted()->withItems();
}
public function recentCompleted(): static public function recentCompleted(): static
{ {
return $this->state(fn (): array => [ return $this->state(fn (): array => [

View File

@ -44,8 +44,8 @@ public function definition(): array
return (int) $tenant->workspace_id; return (int) $tenant->workspace_id;
}, },
'user_id' => User::factory(), 'user_id' => null,
'initiator_name' => fake()->name(), 'initiator_name' => 'System',
'type' => fake()->randomElement(OperationRunType::values()), 'type' => fake()->randomElement(OperationRunType::values()),
'status' => OperationRunStatus::Queued->value, 'status' => OperationRunStatus::Queued->value,
'outcome' => OperationRunOutcome::Pending->value, 'outcome' => OperationRunOutcome::Pending->value,
@ -58,6 +58,22 @@ public function definition(): array
]; ];
} }
public function minimal(): static
{
return $this->state(fn (): array => [
'user_id' => null,
'initiator_name' => 'System',
]);
}
public function withUser(?User $user = null): static
{
return $this->state(fn (): array => [
'user_id' => $user?->getKey() ?? User::factory(),
'initiator_name' => $user?->name ?? fake()->name(),
]);
}
public function forTenant(Tenant $tenant): static public function forTenant(Tenant $tenant): static
{ {
return $this->state(fn (): array => [ return $this->state(fn (): array => [

View File

@ -3,10 +3,13 @@
namespace Database\Factories; namespace Database\Factories;
use App\Models\ProviderConnection; use App\Models\ProviderConnection;
use App\Models\ProviderCredential;
use App\Models\Tenant; use App\Models\Tenant;
use App\Models\Workspace; use App\Models\Workspace;
use App\Support\Providers\ProviderConnectionType; use App\Support\Providers\ProviderConnectionType;
use App\Support\Providers\ProviderConsentStatus; use App\Support\Providers\ProviderConsentStatus;
use App\Support\Providers\ProviderCredentialKind;
use App\Support\Providers\ProviderCredentialSource;
use App\Support\Providers\ProviderVerificationStatus; use App\Support\Providers\ProviderVerificationStatus;
use Illuminate\Database\Eloquent\Factories\Factory; use Illuminate\Database\Eloquent\Factories\Factory;
@ -72,6 +75,14 @@ public function platform(): static
]); ]);
} }
public function minimal(): static
{
return $this->platform()->state(fn (): array => [
'is_default' => false,
'verification_status' => ProviderVerificationStatus::Unknown->value,
]);
}
public function dedicated(): static public function dedicated(): static
{ {
return $this->state(fn (): array => [ return $this->state(fn (): array => [
@ -79,6 +90,15 @@ public function dedicated(): static
]); ]);
} }
public function standard(): static
{
return $this->dedicated()
->verifiedHealthy()
->state(fn (): array => [
'is_default' => true,
]);
}
public function consentGranted(): static public function consentGranted(): static
{ {
return $this->state(fn (): array => [ return $this->state(fn (): array => [
@ -107,4 +127,38 @@ public function disabled(): static
'is_enabled' => false, 'is_enabled' => false,
]); ]);
} }
public function withCredential(): static
{
return $this->dedicated()
->verifiedHealthy()
->state(fn (): array => [
'is_default' => true,
])
->afterCreating(function (ProviderConnection $connection): void {
if ($connection->credential()->exists()) {
return;
}
ProviderCredential::factory()->create([
'provider_connection_id' => (int) $connection->getKey(),
'type' => ProviderCredentialKind::ClientSecret->value,
'credential_kind' => ProviderCredentialKind::ClientSecret->value,
'source' => ProviderCredentialSource::DedicatedManual->value,
'last_rotated_at' => now(),
'expires_at' => now()->addYear(),
'payload' => [
'client_id' => fake()->uuid(),
'client_secret' => fake()->sha1(),
],
]);
$connection->refresh();
});
}
public function full(): static
{
return $this->withCredential();
}
} }

View File

@ -31,6 +31,18 @@ public function definition(): array
]; ];
} }
public function standard(): static
{
return $this->state(fn (): array => [
'provider_connection_id' => ProviderConnection::factory()->standard(),
]);
}
public function verifiedConnection(): static
{
return $this->standard();
}
public function legacyMigrated(): static public function legacyMigrated(): static
{ {
return $this->state(fn (): array => [ return $this->state(fn (): array => [

View File

@ -11,10 +11,12 @@
*/ */
class TenantFactory extends Factory class TenantFactory extends Factory
{ {
protected bool $provisionsWorkspace = true;
public function configure(): static public function configure(): static
{ {
return $this->afterCreating(function (Tenant $tenant): void { return $this->afterCreating(function (Tenant $tenant): void {
if ($tenant->workspace_id !== null) { if (! $this->provisionsWorkspace || $tenant->workspace_id !== null) {
return; return;
} }
@ -83,4 +85,24 @@ public function archived(): static
'is_current' => false, 'is_current' => false,
]); ]);
} }
public function minimal(): static
{
Tenant::skipTestWorkspaceProvisioning();
$factory = clone $this;
$factory->provisionsWorkspace = false;
return $factory
->afterCreating(function (Tenant $tenant): void {
if ($tenant->workspace_id !== null) {
$workspaceId = (int) $tenant->workspace_id;
$tenant->forceFill(['workspace_id' => null])->saveQuietly();
Workspace::query()->whereKey($workspaceId)->delete();
}
Tenant::skipTestWorkspaceProvisioning(false);
});
}
} }

View File

@ -39,6 +39,7 @@
<env name="MAIL_MAILER" value="array"/> <env name="MAIL_MAILER" value="array"/>
<env name="QUEUE_CONNECTION" value="sync"/> <env name="QUEUE_CONNECTION" value="sync"/>
<env name="SESSION_DRIVER" value="array"/> <env name="SESSION_DRIVER" value="array"/>
<env name="TEST_LANE_ARTIFACT_DIRECTORY" value="storage/logs/test-lanes"/>
<env name="PULSE_ENABLED" value="false"/> <env name="PULSE_ENABLED" value="false"/>
<env name="TELESCOPE_ENABLED" value="false"/> <env name="TELESCOPE_ENABLED" value="false"/>

View File

@ -34,6 +34,7 @@
<env name="MAIL_MAILER" value="array"/> <env name="MAIL_MAILER" value="array"/>
<env name="QUEUE_CONNECTION" value="sync"/> <env name="QUEUE_CONNECTION" value="sync"/>
<env name="SESSION_DRIVER" value="array"/> <env name="SESSION_DRIVER" value="array"/>
<env name="TEST_LANE_ARTIFACT_DIRECTORY" value="storage/logs/test-lanes"/>
<env name="PULSE_ENABLED" value="false"/> <env name="PULSE_ENABLED" value="false"/>
<env name="TELESCOPE_ENABLED" value="false"/> <env name="TELESCOPE_ENABLED" value="false"/>
<env name="NIGHTWATCH_ENABLED" value="false"/> <env name="NIGHTWATCH_ENABLED" value="false"/>

View File

@ -16,7 +16,7 @@
use Carbon\CarbonImmutable; use Carbon\CarbonImmutable;
it('Baseline finding fidelity is content when both baseline and current are content', function () { it('Baseline finding fidelity is content when both baseline and current are content', function () {
[$user, $tenant] = createUserWithTenant(); [$user, $tenant] = createMinimalUserWithTenant();
$profile = BaselineProfile::factory()->active()->create([ $profile = BaselineProfile::factory()->active()->create([
'workspace_id' => $tenant->workspace_id, 'workspace_id' => $tenant->workspace_id,
@ -154,7 +154,7 @@
}); });
it('Baseline finding fidelity is meta when baseline evidence is meta (even if current content exists)', function () { it('Baseline finding fidelity is meta when baseline evidence is meta (even if current content exists)', function () {
[$user, $tenant] = createUserWithTenant(); [$user, $tenant] = createMinimalUserWithTenant();
$profile = BaselineProfile::factory()->active()->create([ $profile = BaselineProfile::factory()->active()->create([
'workspace_id' => $tenant->workspace_id, 'workspace_id' => $tenant->workspace_id,

View File

@ -41,7 +41,7 @@ protected function makeBaselineCompareMatrixFixture(
string $viewerRole = 'owner', string $viewerRole = 'owner',
?string $workspaceRole = null, ?string $workspaceRole = null,
): array { ): array {
[$user, $visibleTenant] = createUserWithTenant(role: $viewerRole, workspaceRole: $workspaceRole ?? $viewerRole); [$user, $visibleTenant] = createMinimalUserWithTenant(role: $viewerRole, workspaceRole: $workspaceRole ?? $viewerRole);
$workspace = Workspace::query()->findOrFail((int) $visibleTenant->workspace_id); $workspace = Workspace::query()->findOrFail((int) $visibleTenant->workspace_id);

View File

@ -36,7 +36,7 @@ protected function makePortfolioTriageActor(
'name' => $tenantName, 'name' => $tenantName,
]); ]);
[$user, $tenant] = createUserWithTenant( [$user, $tenant] = createMinimalUserWithTenant(
tenant: $tenant, tenant: $tenant,
role: $role, role: $role,
workspaceRole: $workspaceRole, workspaceRole: $workspaceRole,
@ -55,7 +55,7 @@ protected function makePortfolioTriagePeer(User $user, Tenant $workspaceTenant,
'name' => $name, 'name' => $name,
]); ]);
createUserWithTenant( createMinimalUserWithTenant(
tenant: $tenant, tenant: $tenant,
user: $user, user: $user,
role: 'owner', role: 'owner',

View File

@ -29,11 +29,10 @@
$startedAt = CarbonImmutable::parse('2026-01-01 00:00:00', 'UTC'); $startedAt = CarbonImmutable::parse('2026-01-01 00:00:00', 'UTC');
$operationRun = OperationRun::create([ $operationRun = OperationRun::factory()
'workspace_id' => (int) $tenant->workspace_id, ->minimal()
'tenant_id' => $tenant->id, ->forTenant($tenant)
'user_id' => null, ->create([
'initiator_name' => 'System',
'type' => 'backup_schedule_run', 'type' => 'backup_schedule_run',
'status' => 'running', 'status' => 'running',
'outcome' => 'pending', 'outcome' => 'pending',

View File

@ -10,12 +10,9 @@
uses(RefreshDatabase::class); uses(RefreshDatabase::class);
it('reconciles stale covered runs from the console command', function (): void { it('reconciles stale covered runs from the console command', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner'); [$user, $tenant] = createMinimalUserWithTenant(role: 'owner');
$run = OperationRun::factory()->create([ $run = OperationRun::factory()->withUser($user)->forTenant($tenant)->create([
'tenant_id' => (int) $tenant->getKey(),
'workspace_id' => (int) $tenant->workspace_id,
'user_id' => (int) $user->getKey(),
'type' => 'policy.sync', 'type' => 'policy.sync',
'status' => OperationRunStatus::Queued->value, 'status' => OperationRunStatus::Queued->value,
'outcome' => OperationRunOutcome::Pending->value, 'outcome' => OperationRunOutcome::Pending->value,
@ -34,12 +31,9 @@
}); });
it('supports dry-run mode without mutating runs', function (): void { it('supports dry-run mode without mutating runs', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner'); [$user, $tenant] = createMinimalUserWithTenant(role: 'owner');
$run = OperationRun::factory()->create([ $run = OperationRun::factory()->withUser($user)->forTenant($tenant)->create([
'tenant_id' => (int) $tenant->getKey(),
'workspace_id' => (int) $tenant->workspace_id,
'user_id' => (int) $user->getKey(),
'type' => 'policy.sync', 'type' => 'policy.sync',
'status' => OperationRunStatus::Queued->value, 'status' => OperationRunStatus::Queued->value,
'outcome' => OperationRunOutcome::Pending->value, 'outcome' => OperationRunOutcome::Pending->value,

View File

@ -13,7 +13,7 @@
uses(RefreshDatabase::class); uses(RefreshDatabase::class);
test('cached groups can be listed, searched, and filtered (DB-only)', function () { test('cached groups can be listed, searched, and filtered (DB-only)', function () {
[$user, $tenant] = createUserWithTenant(role: 'owner'); [$user, $tenant] = createUserWithTenant(role: 'owner', fixtureProfile: 'credential-enabled');
$this->actingAs($user); $this->actingAs($user);
$otherTenant = Tenant::factory()->create(); $otherTenant = Tenant::factory()->create();
@ -117,7 +117,7 @@
]); ]);
$user = User::factory()->create(); $user = User::factory()->create();
[$user, $tenantA] = createUserWithTenant(tenant: $tenantA, user: $user, role: 'owner'); [$user, $tenantA] = createUserWithTenant(tenant: $tenantA, user: $user, role: 'owner', fixtureProfile: 'credential-enabled');
$this->actingAs($user) $this->actingAs($user)
->get(EntraGroupResource::getUrl('view', ['record' => $groupB], tenant: $tenantA)) ->get(EntraGroupResource::getUrl('view', ['record' => $groupB], tenant: $tenantA))

View File

@ -18,7 +18,7 @@
]); ]);
$this->user = User::factory()->create(); $this->user = User::factory()->create();
[$this->user, $this->tenant] = createUserWithTenant(tenant: $this->tenant, user: $this->user, role: 'owner'); [$this->user, $this->tenant] = createUserWithTenant(tenant: $this->tenant, user: $this->user, role: 'owner', fixtureProfile: 'credential-enabled');
}); });
it('renders policy version view without any Graph calls during render', function () { it('renders policy version view without any Graph calls during render', function () {

View File

@ -20,7 +20,7 @@
$mock->shouldReceive('request')->never(); $mock->shouldReceive('request')->never();
}); });
[$user, $tenant] = createUserWithTenant(role: 'owner'); [$user, $tenant] = createUserWithTenant(role: 'owner', fixtureProfile: 'credential-enabled');
$this->actingAs($user); $this->actingAs($user);
$tenant->makeCurrent(); $tenant->makeCurrent();
@ -51,7 +51,7 @@
it('disables group sync start action for readonly users', function () { it('disables group sync start action for readonly users', function () {
Queue::fake(); Queue::fake();
[$user, $tenant] = createUserWithTenant(role: 'readonly'); [$user, $tenant] = createUserWithTenant(role: 'readonly', fixtureProfile: 'credential-enabled');
$this->actingAs($user); $this->actingAs($user);
$tenant->makeCurrent(); $tenant->makeCurrent();

View File

@ -8,7 +8,7 @@
it('starts a manual group sync by creating a run and dispatching a job', function () { it('starts a manual group sync by creating a run and dispatching a job', function () {
Queue::fake(); Queue::fake();
[$user, $tenant] = createUserWithTenant(role: 'owner'); [$user, $tenant] = createUserWithTenant(role: 'owner', fixtureProfile: 'credential-enabled');
$service = app(EntraGroupSyncService::class); $service = app(EntraGroupSyncService::class);

View File

@ -10,7 +10,7 @@
it('sync job upserts groups and updates run counters', function () { it('sync job upserts groups and updates run counters', function () {
[$user, $tenant] = createUserWithTenant(role: 'owner'); [$user, $tenant] = createUserWithTenant(role: 'owner', fixtureProfile: 'credential-enabled');
EntraGroup::factory()->create([ EntraGroup::factory()->create([
'tenant_id' => $tenant->getKey(), 'tenant_id' => $tenant->getKey(),

View File

@ -10,7 +10,7 @@
use Illuminate\Support\Facades\Config; use Illuminate\Support\Facades\Config;
it('purges cached groups older than the retention window', function () { it('purges cached groups older than the retention window', function () {
[$user, $tenant] = createUserWithTenant(role: 'owner'); [$user, $tenant] = createUserWithTenant(role: 'owner', fixtureProfile: 'credential-enabled');
Config::set('directory_groups.retention_days', 90); Config::set('directory_groups.retention_days', 90);

View File

@ -86,7 +86,7 @@ function makeGenerator(): EntraAdminRolesFindingGenerator
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
it('creates findings for high-privilege assignments with correct attributes', function (): void { it('creates findings for high-privilege assignments with correct attributes', function (): void {
[$user, $tenant] = createUserWithTenant(); [$user, $tenant] = createMinimalUserWithTenant();
$measuredAt = '2026-02-24T10:00:00Z'; $measuredAt = '2026-02-24T10:00:00Z';
$payload = buildPayload( $payload = buildPayload(
@ -132,7 +132,7 @@ function makeGenerator(): EntraAdminRolesFindingGenerator
}); });
it('maps severity: GA is critical, others are high', function (): void { it('maps severity: GA is critical, others are high', function (): void {
[$user, $tenant] = createUserWithTenant(); [$user, $tenant] = createMinimalUserWithTenant();
$payload = buildPayload( $payload = buildPayload(
[gaRoleDef(), secAdminRoleDef()], [gaRoleDef(), secAdminRoleDef()],
@ -157,7 +157,7 @@ function makeGenerator(): EntraAdminRolesFindingGenerator
}); });
it('is idempotent — same data produces no duplicates', function (): void { it('is idempotent — same data produces no duplicates', function (): void {
[$user, $tenant] = createUserWithTenant(); [$user, $tenant] = createMinimalUserWithTenant();
$payload1 = buildPayload( $payload1 = buildPayload(
[gaRoleDef()], [gaRoleDef()],
@ -196,7 +196,7 @@ function makeGenerator(): EntraAdminRolesFindingGenerator
}); });
it('auto-resolves when assignment is removed', function (): void { it('auto-resolves when assignment is removed', function (): void {
[$user, $tenant] = createUserWithTenant(); [$user, $tenant] = createMinimalUserWithTenant();
$generator = makeGenerator(); $generator = makeGenerator();
@ -224,7 +224,7 @@ function makeGenerator(): EntraAdminRolesFindingGenerator
}); });
it('re-opens resolved finding when role is re-assigned', function (): void { it('re-opens resolved finding when role is re-assigned', function (): void {
[$user, $tenant] = createUserWithTenant(); [$user, $tenant] = createMinimalUserWithTenant();
$generator = makeGenerator(); $generator = makeGenerator();
@ -264,7 +264,7 @@ function makeGenerator(): EntraAdminRolesFindingGenerator
}); });
it('creates aggregate finding when GA count exceeds threshold', function (): void { it('creates aggregate finding when GA count exceeds threshold', function (): void {
[$user, $tenant] = createUserWithTenant(); [$user, $tenant] = createMinimalUserWithTenant();
$assignments = []; $assignments = [];
@ -293,7 +293,7 @@ function makeGenerator(): EntraAdminRolesFindingGenerator
}); });
it('auto-resolves aggregate finding when GA count drops within threshold', function (): void { it('auto-resolves aggregate finding when GA count drops within threshold', function (): void {
[$user, $tenant] = createUserWithTenant(); [$user, $tenant] = createMinimalUserWithTenant();
$generator = makeGenerator(); $generator = makeGenerator();
@ -322,7 +322,7 @@ function makeGenerator(): EntraAdminRolesFindingGenerator
}); });
it('produces alert events for new and re-opened findings with severity >= high', function (): void { it('produces alert events for new and re-opened findings with severity >= high', function (): void {
[$user, $tenant] = createUserWithTenant(); [$user, $tenant] = createMinimalUserWithTenant();
$generator = makeGenerator(); $generator = makeGenerator();
@ -342,7 +342,7 @@ function makeGenerator(): EntraAdminRolesFindingGenerator
}); });
it('produces no alert events for unchanged or resolved findings', function (): void { it('produces no alert events for unchanged or resolved findings', function (): void {
[$user, $tenant] = createUserWithTenant(); [$user, $tenant] = createMinimalUserWithTenant();
$generator = makeGenerator(); $generator = makeGenerator();
@ -363,7 +363,7 @@ function makeGenerator(): EntraAdminRolesFindingGenerator
}); });
it('evidence contains all required fields', function (): void { it('evidence contains all required fields', function (): void {
[$user, $tenant] = createUserWithTenant(); [$user, $tenant] = createMinimalUserWithTenant();
$payload = buildPayload( $payload = buildPayload(
[gaRoleDef()], [gaRoleDef()],
@ -400,7 +400,7 @@ function makeGenerator(): EntraAdminRolesFindingGenerator
}); });
it('handles all principal types correctly', function (): void { it('handles all principal types correctly', function (): void {
[$user, $tenant] = createUserWithTenant(); [$user, $tenant] = createMinimalUserWithTenant();
$payload = buildPayload( $payload = buildPayload(
[gaRoleDef()], [gaRoleDef()],
@ -426,7 +426,7 @@ function makeGenerator(): EntraAdminRolesFindingGenerator
}); });
it('subject_type and subject_external_id set on every finding', function (): void { it('subject_type and subject_external_id set on every finding', function (): void {
[$user, $tenant] = createUserWithTenant(); [$user, $tenant] = createMinimalUserWithTenant();
$payload = buildPayload( $payload = buildPayload(
[gaRoleDef()], [gaRoleDef()],
@ -445,7 +445,7 @@ function makeGenerator(): EntraAdminRolesFindingGenerator
}); });
it('auto-resolve applies to acknowledged findings too', function (): void { it('auto-resolve applies to acknowledged findings too', function (): void {
[$user, $tenant] = createUserWithTenant(); [$user, $tenant] = createMinimalUserWithTenant();
$generator = makeGenerator(); $generator = makeGenerator();
@ -481,7 +481,7 @@ function makeGenerator(): EntraAdminRolesFindingGenerator
}); });
it('scoped assignments do not downgrade severity', function (): void { it('scoped assignments do not downgrade severity', function (): void {
[$user, $tenant] = createUserWithTenant(); [$user, $tenant] = createMinimalUserWithTenant();
$payload = buildPayload( $payload = buildPayload(
[gaRoleDef()], [gaRoleDef()],
@ -500,7 +500,7 @@ function makeGenerator(): EntraAdminRolesFindingGenerator
}); });
it('does not create findings for non-high-privilege roles', function (): void { it('does not create findings for non-high-privilege roles', function (): void {
[$user, $tenant] = createUserWithTenant(); [$user, $tenant] = createMinimalUserWithTenant();
$payload = buildPayload( $payload = buildPayload(
[readerRoleDef()], [readerRoleDef()],

View File

@ -4,9 +4,6 @@
use App\Filament\Pages\BaselineCompareLanding; use App\Filament\Pages\BaselineCompareLanding;
use App\Filament\Resources\BaselineProfileResource\Pages\ViewBaselineProfile; use App\Filament\Resources\BaselineProfileResource\Pages\ViewBaselineProfile;
use App\Models\BaselineProfile;
use App\Models\BaselineSnapshot;
use App\Models\BaselineTenantAssignment;
use App\Support\Workspaces\WorkspaceContext; use App\Support\Workspaces\WorkspaceContext;
use Filament\Facades\Filament; use Filament\Facades\Filament;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
@ -16,22 +13,7 @@
[$readonlyUser, $tenant] = createUserWithTenant(role: 'readonly'); [$readonlyUser, $tenant] = createUserWithTenant(role: 'readonly');
[$ownerUser] = createUserWithTenant(tenant: $tenant, role: 'owner'); [$ownerUser] = createUserWithTenant(tenant: $tenant, role: 'owner');
$profile = BaselineProfile::factory()->active()->create([ [$profile] = seedActiveBaselineForTenant($tenant);
'workspace_id' => (int) $tenant->workspace_id,
]);
$snapshot = BaselineSnapshot::factory()->complete()->create([
'workspace_id' => (int) $tenant->workspace_id,
'baseline_profile_id' => (int) $profile->getKey(),
]);
$profile->update(['active_snapshot_id' => (int) $snapshot->getKey()]);
BaselineTenantAssignment::factory()->create([
'workspace_id' => (int) $tenant->workspace_id,
'tenant_id' => (int) $tenant->getKey(),
'baseline_profile_id' => (int) $profile->getKey(),
]);
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id); session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id);
@ -50,22 +32,7 @@
[$readonlyUser, $tenant] = createUserWithTenant(role: 'readonly'); [$readonlyUser, $tenant] = createUserWithTenant(role: 'readonly');
[$ownerUser] = createUserWithTenant(tenant: $tenant, role: 'owner'); [$ownerUser] = createUserWithTenant(tenant: $tenant, role: 'owner');
$profile = BaselineProfile::factory()->active()->create([ seedActiveBaselineForTenant($tenant);
'workspace_id' => (int) $tenant->workspace_id,
]);
$snapshot = BaselineSnapshot::factory()->complete()->create([
'workspace_id' => (int) $tenant->workspace_id,
'baseline_profile_id' => (int) $profile->getKey(),
]);
$profile->update(['active_snapshot_id' => (int) $snapshot->getKey()]);
BaselineTenantAssignment::factory()->create([
'workspace_id' => (int) $tenant->workspace_id,
'tenant_id' => (int) $tenant->getKey(),
'baseline_profile_id' => (int) $profile->getKey(),
]);
$tenant->makeCurrent(); $tenant->makeCurrent();
Filament::setTenant($tenant, true); Filament::setTenant($tenant, true);
@ -83,22 +50,7 @@
[$readonlyUser, $tenant] = createUserWithTenant(role: 'readonly'); [$readonlyUser, $tenant] = createUserWithTenant(role: 'readonly');
[$ownerUser] = createUserWithTenant(tenant: $tenant, role: 'owner'); [$ownerUser] = createUserWithTenant(tenant: $tenant, role: 'owner');
$profile = BaselineProfile::factory()->active()->create([ [$profile] = seedActiveBaselineForTenant($tenant);
'workspace_id' => (int) $tenant->workspace_id,
]);
$snapshot = BaselineSnapshot::factory()->complete()->create([
'workspace_id' => (int) $tenant->workspace_id,
'baseline_profile_id' => (int) $profile->getKey(),
]);
$profile->update(['active_snapshot_id' => (int) $snapshot->getKey()]);
BaselineTenantAssignment::factory()->create([
'workspace_id' => (int) $tenant->workspace_id,
'tenant_id' => (int) $tenant->getKey(),
'baseline_profile_id' => (int) $profile->getKey(),
]);
DB::table('baseline_profiles') DB::table('baseline_profiles')
->where('id', (int) $profile->getKey()) ->where('id', (int) $profile->getKey())

View File

@ -7,6 +7,7 @@
use App\Jobs\CaptureBaselineSnapshotJob; use App\Jobs\CaptureBaselineSnapshotJob;
use App\Models\BaselineProfile; use App\Models\BaselineProfile;
use App\Models\OperationRun; use App\Models\OperationRun;
use App\Models\Tenant;
use App\Support\Baselines\BaselineCaptureMode; use App\Support\Baselines\BaselineCaptureMode;
use App\Support\Workspaces\WorkspaceContext; use App\Support\Workspaces\WorkspaceContext;
use Filament\Actions\Action; use Filament\Actions\Action;
@ -30,12 +31,21 @@ function baselineProfileCaptureHeaderActions(Testable $component): array
return $instance->getCachedHeaderActions(); return $instance->getCachedHeaderActions();
} }
function seedCaptureProfileForTenant(
Tenant $tenant,
BaselineCaptureMode $captureMode = BaselineCaptureMode::FullContent,
array $attributes = [],
): BaselineProfile {
return BaselineProfile::factory()->active()->create(array_merge([
'workspace_id' => (int) $tenant->workspace_id,
'capture_mode' => $captureMode->value,
], $attributes));
}
it('redirects unauthenticated users (302) when accessing the capture start surface', function (): void { it('redirects unauthenticated users (302) when accessing the capture start surface', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner'); [$user, $tenant] = createUserWithTenant(role: 'owner');
$profile = BaselineProfile::factory()->active()->create([ $profile = seedCaptureProfileForTenant($tenant, BaselineCaptureMode::Opportunistic);
'workspace_id' => (int) $tenant->workspace_id,
]);
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id); session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id);
@ -47,9 +57,7 @@ function baselineProfileCaptureHeaderActions(Testable $component): array
[$user, $tenant] = createUserWithTenant(role: 'owner'); [$user, $tenant] = createUserWithTenant(role: 'owner');
[$otherUser, $otherTenant] = createUserWithTenant(role: 'owner'); [$otherUser, $otherTenant] = createUserWithTenant(role: 'owner');
$profile = BaselineProfile::factory()->active()->create([ $profile = seedCaptureProfileForTenant($tenant, BaselineCaptureMode::Opportunistic);
'workspace_id' => (int) $tenant->workspace_id,
]);
session()->put(WorkspaceContext::SESSION_KEY, (int) $otherTenant->workspace_id); session()->put(WorkspaceContext::SESSION_KEY, (int) $otherTenant->workspace_id);
@ -63,9 +71,7 @@ function baselineProfileCaptureHeaderActions(Testable $component): array
[$user, $tenant] = createUserWithTenant(role: 'readonly'); [$user, $tenant] = createUserWithTenant(role: 'readonly');
$profile = BaselineProfile::factory()->active()->create([ $profile = seedCaptureProfileForTenant($tenant, BaselineCaptureMode::Opportunistic);
'workspace_id' => (int) $tenant->workspace_id,
]);
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id); session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id);
@ -86,10 +92,7 @@ function baselineProfileCaptureHeaderActions(Testable $component): array
[$user, $tenant] = createUserWithTenant(role: 'owner'); [$user, $tenant] = createUserWithTenant(role: 'owner');
$profile = BaselineProfile::factory()->active()->create([ $profile = seedCaptureProfileForTenant($tenant);
'workspace_id' => (int) $tenant->workspace_id,
'capture_mode' => BaselineCaptureMode::FullContent->value,
]);
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id); session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id);
@ -129,10 +132,7 @@ function baselineProfileCaptureHeaderActions(Testable $component): array
[$user, $tenant] = createUserWithTenant(role: 'owner'); [$user, $tenant] = createUserWithTenant(role: 'owner');
$profile = BaselineProfile::factory()->active()->create([ $profile = seedCaptureProfileForTenant($tenant);
'workspace_id' => (int) $tenant->workspace_id,
'capture_mode' => BaselineCaptureMode::FullContent->value,
]);
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id); session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id);
@ -152,8 +152,7 @@ function baselineProfileCaptureHeaderActions(Testable $component): array
it('shows readiness copy without exposing raw canonical scope json on the capture start surface', function (): void { it('shows readiness copy without exposing raw canonical scope json on the capture start surface', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner'); [$user, $tenant] = createUserWithTenant(role: 'owner');
$profile = BaselineProfile::factory()->active()->create([ $profile = seedCaptureProfileForTenant($tenant, BaselineCaptureMode::Opportunistic, [
'workspace_id' => (int) $tenant->workspace_id,
'scope_jsonb' => ['policy_types' => ['deviceConfiguration'], 'foundation_types' => []], 'scope_jsonb' => ['policy_types' => ['deviceConfiguration'], 'foundation_types' => []],
]); ]);
@ -172,9 +171,7 @@ function baselineProfileCaptureHeaderActions(Testable $component): array
[$user, $tenant] = createUserWithTenant(role: 'owner'); [$user, $tenant] = createUserWithTenant(role: 'owner');
$profile = BaselineProfile::factory()->active()->create([ $profile = seedCaptureProfileForTenant($tenant, BaselineCaptureMode::Opportunistic);
'workspace_id' => (int) $tenant->workspace_id,
]);
DB::table('baseline_profiles') DB::table('baseline_profiles')
->where('id', (int) $profile->getKey()) ->where('id', (int) $profile->getKey())

View File

@ -8,6 +8,7 @@
use App\Models\BaselineProfile; use App\Models\BaselineProfile;
use App\Models\BaselineSnapshot; use App\Models\BaselineSnapshot;
use App\Models\BaselineTenantAssignment; use App\Models\BaselineTenantAssignment;
use App\Models\Tenant;
use App\Models\OperationRun; use App\Models\OperationRun;
use App\Support\Baselines\BaselineCaptureMode; use App\Support\Baselines\BaselineCaptureMode;
use App\Support\Baselines\Compare\CompareStrategyRegistry; use App\Support\Baselines\Compare\CompareStrategyRegistry;
@ -34,29 +35,27 @@ function baselineProfileHeaderActions(Testable $component): array
return $instance->getCachedHeaderActions(); return $instance->getCachedHeaderActions();
} }
/**
* @return array{0: BaselineProfile, 1: BaselineSnapshot}
*/
function seedComparableBaselineProfileForTenant(Tenant $tenant, BaselineCaptureMode $captureMode = BaselineCaptureMode::FullContent): array
{
[$profile, $snapshot] = seedActiveBaselineForTenant($tenant);
$profile->forceFill([
'capture_mode' => $captureMode->value,
])->save();
return [$profile->fresh(), $snapshot];
}
it('does not start baseline compare for workspace members missing tenant.sync', function (): void { it('does not start baseline compare for workspace members missing tenant.sync', function (): void {
Queue::fake(); Queue::fake();
config()->set('tenantpilot.baselines.full_content_capture.enabled', true); config()->set('tenantpilot.baselines.full_content_capture.enabled', true);
[$user, $tenant] = createUserWithTenant(role: 'readonly'); [$user, $tenant] = createUserWithTenant(role: 'readonly');
$profile = BaselineProfile::factory()->active()->create([ [$profile] = seedComparableBaselineProfileForTenant($tenant);
'workspace_id' => (int) $tenant->workspace_id,
'capture_mode' => BaselineCaptureMode::FullContent->value,
]);
$snapshot = BaselineSnapshot::factory()->create([
'workspace_id' => (int) $tenant->workspace_id,
'baseline_profile_id' => (int) $profile->getKey(),
]);
$profile->update(['active_snapshot_id' => (int) $snapshot->getKey()]);
BaselineTenantAssignment::factory()->create([
'workspace_id' => (int) $tenant->workspace_id,
'tenant_id' => (int) $tenant->getKey(),
'baseline_profile_id' => (int) $profile->getKey(),
]);
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id); session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id);
@ -77,23 +76,7 @@ function baselineProfileHeaderActions(Testable $component): array
[$user, $tenant] = createUserWithTenant(role: 'owner'); [$user, $tenant] = createUserWithTenant(role: 'owner');
$profile = BaselineProfile::factory()->active()->create([ [$profile] = seedComparableBaselineProfileForTenant($tenant);
'workspace_id' => (int) $tenant->workspace_id,
'capture_mode' => BaselineCaptureMode::FullContent->value,
]);
$snapshot = BaselineSnapshot::factory()->create([
'workspace_id' => (int) $tenant->workspace_id,
'baseline_profile_id' => (int) $profile->getKey(),
]);
$profile->update(['active_snapshot_id' => (int) $snapshot->getKey()]);
BaselineTenantAssignment::factory()->create([
'workspace_id' => (int) $tenant->workspace_id,
'tenant_id' => (int) $tenant->getKey(),
'baseline_profile_id' => (int) $profile->getKey(),
]);
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id); session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id);
@ -123,23 +106,7 @@ function baselineProfileHeaderActions(Testable $component): array
[$user, $tenant] = createUserWithTenant(role: 'owner'); [$user, $tenant] = createUserWithTenant(role: 'owner');
$profile = BaselineProfile::factory()->active()->create([ [$profile] = seedComparableBaselineProfileForTenant($tenant);
'workspace_id' => (int) $tenant->workspace_id,
'capture_mode' => BaselineCaptureMode::FullContent->value,
]);
$snapshot = BaselineSnapshot::factory()->create([
'workspace_id' => (int) $tenant->workspace_id,
'baseline_profile_id' => (int) $profile->getKey(),
]);
$profile->update(['active_snapshot_id' => (int) $snapshot->getKey()]);
BaselineTenantAssignment::factory()->create([
'workspace_id' => (int) $tenant->workspace_id,
'tenant_id' => (int) $tenant->getKey(),
'baseline_profile_id' => (int) $profile->getKey(),
]);
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id); session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id);
@ -167,7 +134,9 @@ function baselineProfileHeaderActions(Testable $component): array
app(FakeCompareStrategy::class), app(FakeCompareStrategy::class),
])); ]));
$profile = BaselineProfile::factory()->active()->create([ [$profile] = seedComparableBaselineProfileForTenant($tenant, BaselineCaptureMode::Opportunistic);
$profile->forceFill([
'workspace_id' => (int) $tenant->workspace_id, 'workspace_id' => (int) $tenant->workspace_id,
'scope_jsonb' => [ 'scope_jsonb' => [
'version' => 2, 'version' => 2,
@ -186,20 +155,7 @@ function baselineProfileHeaderActions(Testable $component): array
], ],
], ],
], ],
]); ])->save();
$snapshot = BaselineSnapshot::factory()->create([
'workspace_id' => (int) $tenant->workspace_id,
'baseline_profile_id' => (int) $profile->getKey(),
]);
$profile->update(['active_snapshot_id' => (int) $snapshot->getKey()]);
BaselineTenantAssignment::factory()->create([
'workspace_id' => (int) $tenant->workspace_id,
'tenant_id' => (int) $tenant->getKey(),
'baseline_profile_id' => (int) $profile->getKey(),
]);
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id); session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id);
@ -218,22 +174,7 @@ function baselineProfileHeaderActions(Testable $component): array
[$user, $tenant] = createUserWithTenant(role: 'owner'); [$user, $tenant] = createUserWithTenant(role: 'owner');
$this->actingAs($user); $this->actingAs($user);
$profile = BaselineProfile::factory()->active()->create([ [$profile] = seedComparableBaselineProfileForTenant($tenant, BaselineCaptureMode::Opportunistic);
'workspace_id' => (int) $tenant->workspace_id,
]);
$snapshot = BaselineSnapshot::factory()->create([
'workspace_id' => (int) $tenant->workspace_id,
'baseline_profile_id' => (int) $profile->getKey(),
]);
$profile->update(['active_snapshot_id' => (int) $snapshot->getKey()]);
BaselineTenantAssignment::factory()->create([
'workspace_id' => (int) $tenant->workspace_id,
'tenant_id' => (int) $tenant->getKey(),
'baseline_profile_id' => (int) $profile->getKey(),
]);
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id); session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id);
@ -268,22 +209,7 @@ function baselineProfileHeaderActions(Testable $component): array
it('keeps compare-assigned-tenants visible but disabled for readonly workspace members after the navigation move', function (): void { it('keeps compare-assigned-tenants visible but disabled for readonly workspace members after the navigation move', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'readonly'); [$user, $tenant] = createUserWithTenant(role: 'readonly');
$profile = BaselineProfile::factory()->active()->create([ [$profile] = seedComparableBaselineProfileForTenant($tenant, BaselineCaptureMode::Opportunistic);
'workspace_id' => (int) $tenant->workspace_id,
]);
$snapshot = BaselineSnapshot::factory()->create([
'workspace_id' => (int) $tenant->workspace_id,
'baseline_profile_id' => (int) $profile->getKey(),
]);
$profile->update(['active_snapshot_id' => (int) $snapshot->getKey()]);
BaselineTenantAssignment::factory()->create([
'workspace_id' => (int) $tenant->workspace_id,
'tenant_id' => (int) $tenant->getKey(),
'baseline_profile_id' => (int) $profile->getKey(),
]);
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id); session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id);
@ -299,23 +225,11 @@ function baselineProfileHeaderActions(Testable $component): array
it('shows readiness copy without exposing raw canonical scope json on the compare start surface', function (): void { it('shows readiness copy without exposing raw canonical scope json on the compare start surface', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner'); [$user, $tenant] = createUserWithTenant(role: 'owner');
$profile = BaselineProfile::factory()->active()->create([ [$profile] = seedComparableBaselineProfileForTenant($tenant, BaselineCaptureMode::Opportunistic);
'workspace_id' => (int) $tenant->workspace_id,
$profile->forceFill([
'scope_jsonb' => ['policy_types' => ['deviceConfiguration'], 'foundation_types' => []], 'scope_jsonb' => ['policy_types' => ['deviceConfiguration'], 'foundation_types' => []],
]); ])->save();
$snapshot = BaselineSnapshot::factory()->create([
'workspace_id' => (int) $tenant->workspace_id,
'baseline_profile_id' => (int) $profile->getKey(),
]);
$profile->update(['active_snapshot_id' => (int) $snapshot->getKey()]);
BaselineTenantAssignment::factory()->create([
'workspace_id' => (int) $tenant->workspace_id,
'tenant_id' => (int) $tenant->getKey(),
'baseline_profile_id' => (int) $profile->getKey(),
]);
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id); session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id);
@ -332,22 +246,7 @@ function baselineProfileHeaderActions(Testable $component): array
[$user, $tenant] = createUserWithTenant(role: 'owner'); [$user, $tenant] = createUserWithTenant(role: 'owner');
$profile = BaselineProfile::factory()->active()->create([ [$profile] = seedComparableBaselineProfileForTenant($tenant, BaselineCaptureMode::Opportunistic);
'workspace_id' => (int) $tenant->workspace_id,
]);
$snapshot = BaselineSnapshot::factory()->create([
'workspace_id' => (int) $tenant->workspace_id,
'baseline_profile_id' => (int) $profile->getKey(),
]);
$profile->update(['active_snapshot_id' => (int) $snapshot->getKey()]);
BaselineTenantAssignment::factory()->create([
'workspace_id' => (int) $tenant->workspace_id,
'tenant_id' => (int) $tenant->getKey(),
'baseline_profile_id' => (int) $profile->getKey(),
]);
DB::table('baseline_profiles') DB::table('baseline_profiles')
->where('id', (int) $profile->getKey()) ->where('id', (int) $profile->getKey())

View File

@ -18,7 +18,7 @@
}); });
test('readonly users may switch current tenant via ChooseTenant', function () { test('readonly users may switch current tenant via ChooseTenant', function () {
[$user, $tenantA] = createUserWithTenant(role: 'readonly'); [$user, $tenantA] = createUserWithTenant(role: 'readonly', fixtureProfile: 'credential-enabled');
$tenantB = Tenant::factory()->create([ $tenantB = Tenant::factory()->create([
'status' => 'active', 'status' => 'active',
@ -41,7 +41,7 @@
}); });
test('users cannot switch to a tenant they are not a member of', function () { test('users cannot switch to a tenant they are not a member of', function () {
[$user] = createUserWithTenant(role: 'readonly'); [$user] = createUserWithTenant(role: 'readonly', fixtureProfile: 'credential-enabled');
$tenant = Tenant::factory()->create([ $tenant = Tenant::factory()->create([
'status' => 'active', 'status' => 'active',
@ -54,7 +54,7 @@
}); });
test('readonly users cannot archive tenants', function () { test('readonly users cannot archive tenants', function () {
[$user, $tenant] = createUserWithTenant(role: 'readonly'); [$user, $tenant] = createUserWithTenant(role: 'readonly', fixtureProfile: 'credential-enabled');
Filament::setTenant($tenant, true); Filament::setTenant($tenant, true);
@ -72,7 +72,7 @@
}); });
test('readonly users cannot force delete tenants', function () { test('readonly users cannot force delete tenants', function () {
[$user, $tenant] = createUserWithTenant(role: 'readonly'); [$user, $tenant] = createUserWithTenant(role: 'readonly', fixtureProfile: 'credential-enabled');
$tenant->delete(); $tenant->delete();
@ -90,7 +90,7 @@
}); });
test('readonly users cannot verify tenant configuration', function () { test('readonly users cannot verify tenant configuration', function () {
[$user, $tenant] = createUserWithTenant(role: 'readonly'); [$user, $tenant] = createUserWithTenant(role: 'readonly', fixtureProfile: 'credential-enabled');
Filament::setTenant($tenant, true); Filament::setTenant($tenant, true);
@ -104,7 +104,7 @@
}); });
test('readonly users cannot setup intune rbac', function () { test('readonly users cannot setup intune rbac', function () {
[$user, $tenant] = createUserWithTenant(role: 'readonly'); [$user, $tenant] = createUserWithTenant(role: 'readonly', fixtureProfile: 'credential-enabled');
Filament::setTenant($tenant, true); Filament::setTenant($tenant, true);
@ -116,7 +116,7 @@
}); });
test('readonly users cannot edit tenants', function () { test('readonly users cannot edit tenants', function () {
[$user, $tenant] = createUserWithTenant(role: 'readonly'); [$user, $tenant] = createUserWithTenant(role: 'readonly', fixtureProfile: 'credential-enabled');
Filament::setTenant($tenant, true); Filament::setTenant($tenant, true);
@ -129,7 +129,7 @@
}); });
test('readonly users cannot open admin consent', function () { test('readonly users cannot open admin consent', function () {
[$user, $tenant] = createUserWithTenant(role: 'readonly'); [$user, $tenant] = createUserWithTenant(role: 'readonly', fixtureProfile: 'credential-enabled');
Filament::setTenant($tenant, true); Filament::setTenant($tenant, true);
@ -143,7 +143,7 @@
}); });
test('readonly users cannot start tenant sync from tenant menu', function () { test('readonly users cannot start tenant sync from tenant menu', function () {
[$user, $tenant] = createUserWithTenant(role: 'readonly'); [$user, $tenant] = createUserWithTenant(role: 'readonly', fixtureProfile: 'credential-enabled');
Filament::setTenant($tenant, true); Filament::setTenant($tenant, true);

View File

@ -12,29 +12,41 @@
uses(RefreshDatabase::class); uses(RefreshDatabase::class);
function findingBulkAuditResourceIds(int $tenantId, string $action): array
{
return AuditLog::query()
->where('tenant_id', $tenantId)
->where('action', $action)
->pluck('resource_id')
->map(static fn (string $resourceId): int => (int) $resourceId)
->sort()
->values()
->all();
}
it('supports bulk workflow actions and audits each record', function (): void { it('supports bulk workflow actions and audits each record', function (): void {
[$manager, $tenant] = createUserWithTenant(role: 'manager'); [$manager, $tenant] = createUserWithTenant(role: 'manager');
$this->actingAs($manager); $this->actingAs($manager);
Filament::setTenant($tenant, true); Filament::setTenant($tenant, true);
$findings = Finding::factory() $findings = Finding::factory()
->count(101) ->count(6)
->for($tenant) ->for($tenant)
->create([ ->create([
'status' => Finding::STATUS_NEW, 'status' => Finding::STATUS_NEW,
'triaged_at' => null, 'triaged_at' => null,
]); ]);
Livewire::test(ListFindings::class) $component = Livewire::test(ListFindings::class);
$component
->callTableBulkAction('triage_selected', $findings) ->callTableBulkAction('triage_selected', $findings)
->assertHasNoTableBulkActionErrors(); ->assertHasNoTableBulkActionErrors();
$findings->each(fn (Finding $finding) => expect($finding->refresh()->status)->toBe(Finding::STATUS_TRIAGED)); $findings->each(fn (Finding $finding) => expect($finding->refresh()->status)->toBe(Finding::STATUS_TRIAGED));
expect(AuditLog::query() expect(findingBulkAuditResourceIds((int) $tenant->getKey(), 'finding.triaged'))
->where('tenant_id', (int) $tenant->getKey()) ->toEqual($findings->pluck('id')->sort()->values()->all());
->where('action', 'finding.triaged')
->count())->toBe(101);
$assignee = User::factory()->create(); $assignee = User::factory()->create();
createUserWithTenant(tenant: $tenant, user: $assignee, role: 'operator'); createUserWithTenant(tenant: $tenant, user: $assignee, role: 'operator');
@ -48,7 +60,7 @@
'owner_user_id' => null, 'owner_user_id' => null,
]); ]);
Livewire::test(ListFindings::class) $component
->callTableBulkAction('assign_selected', $assignFindings, data: [ ->callTableBulkAction('assign_selected', $assignFindings, data: [
'assignee_user_id' => (int) $assignee->getKey(), 'assignee_user_id' => (int) $assignee->getKey(),
'owner_user_id' => (int) $manager->getKey(), 'owner_user_id' => (int) $manager->getKey(),
@ -62,10 +74,8 @@
->and((int) $finding->owner_user_id)->toBe((int) $manager->getKey()); ->and((int) $finding->owner_user_id)->toBe((int) $manager->getKey());
}); });
expect(AuditLog::query() expect(findingBulkAuditResourceIds((int) $tenant->getKey(), 'finding.assigned'))
->where('tenant_id', (int) $tenant->getKey()) ->toEqual($assignFindings->pluck('id')->sort()->values()->all());
->where('action', 'finding.assigned')
->count())->toBe(3);
$resolveFindings = Finding::factory() $resolveFindings = Finding::factory()
->count(2) ->count(2)
@ -76,7 +86,7 @@
'resolved_reason' => null, 'resolved_reason' => null,
]); ]);
Livewire::test(ListFindings::class) $component
->callTableBulkAction('resolve_selected', $resolveFindings, data: [ ->callTableBulkAction('resolve_selected', $resolveFindings, data: [
'resolved_reason' => 'fixed', 'resolved_reason' => 'fixed',
]) ])
@ -90,10 +100,8 @@
->and($finding->resolved_at)->not->toBeNull(); ->and($finding->resolved_at)->not->toBeNull();
}); });
expect(AuditLog::query() expect(findingBulkAuditResourceIds((int) $tenant->getKey(), 'finding.resolved'))
->where('tenant_id', (int) $tenant->getKey()) ->toEqual($resolveFindings->pluck('id')->sort()->values()->all());
->where('action', 'finding.resolved')
->count())->toBe(2);
$closeFindings = Finding::factory() $closeFindings = Finding::factory()
->count(2) ->count(2)
@ -104,7 +112,7 @@
'closed_reason' => null, 'closed_reason' => null,
]); ]);
Livewire::test(ListFindings::class) $component
->callTableBulkAction('close_selected', $closeFindings, data: [ ->callTableBulkAction('close_selected', $closeFindings, data: [
'closed_reason' => 'not applicable', 'closed_reason' => 'not applicable',
]) ])
@ -119,9 +127,6 @@
->and($finding->closed_by_user_id)->not->toBeNull(); ->and($finding->closed_by_user_id)->not->toBeNull();
}); });
expect(AuditLog::query() expect(findingBulkAuditResourceIds((int) $tenant->getKey(), 'finding.closed'))
->where('tenant_id', (int) $tenant->getKey()) ->toEqual($closeFindings->pluck('id')->sort()->values()->all());
->where('action', 'finding.closed')
->count())->toBe(2);
}); });

View File

@ -19,10 +19,10 @@
uses(RefreshDatabase::class); uses(RefreshDatabase::class);
it('shows pending, active, and expired exceptions in the tenant register with lifecycle filters', function (): void { it('shows pending, active, and expired exceptions in the tenant register with lifecycle filters', function (): void {
[$viewer, $tenant] = createUserWithTenant(role: 'readonly'); [$viewer, $tenant] = createMinimalUserWithTenant(role: 'readonly');
[$requester] = createUserWithTenant(tenant: $tenant, role: 'owner'); [$requester] = createMinimalUserWithTenant(tenant: $tenant, role: 'owner');
$approver = User::factory()->create(); $approver = User::factory()->create();
createUserWithTenant(tenant: $tenant, user: $approver, role: 'owner'); createMinimalUserWithTenant(tenant: $tenant, user: $approver, role: 'owner');
$createException = function (array $attributes) use ($tenant, $requester, $approver): FindingException { $createException = function (array $attributes) use ($tenant, $requester, $approver): FindingException {
$finding = Finding::factory()->for($tenant)->create(); $finding = Finding::factory()->for($tenant)->create();
@ -79,10 +79,10 @@
}); });
it('renders exception detail with owner, approver, and validity context for tenant viewers', function (): void { it('renders exception detail with owner, approver, and validity context for tenant viewers', function (): void {
[$viewer, $tenant] = createUserWithTenant(role: 'readonly'); [$viewer, $tenant] = createMinimalUserWithTenant(role: 'readonly');
[$requester] = createUserWithTenant(tenant: $tenant, role: 'owner'); [$requester] = createMinimalUserWithTenant(tenant: $tenant, role: 'owner');
$approver = User::factory()->create(); $approver = User::factory()->create();
createUserWithTenant(tenant: $tenant, user: $approver, role: 'owner'); createMinimalUserWithTenant(tenant: $tenant, user: $approver, role: 'owner');
$finding = Finding::factory()->for($tenant)->create(); $finding = Finding::factory()->for($tenant)->create();
@ -118,7 +118,7 @@
}); });
it('shows a single clear empty-state action when no tenant exceptions match', function (): void { it('shows a single clear empty-state action when no tenant exceptions match', function (): void {
[$viewer, $tenant] = createUserWithTenant(role: 'readonly'); [$viewer, $tenant] = createMinimalUserWithTenant(role: 'readonly');
$this->actingAs($viewer); $this->actingAs($viewer);
$tenant->makeCurrent(); $tenant->makeCurrent();
@ -130,7 +130,7 @@
}); });
it('bridges tenant approval queue links into the admin workspace context', function (): void { it('bridges tenant approval queue links into the admin workspace context', function (): void {
[$viewer, $tenant] = createUserWithTenant(role: 'owner', workspaceRole: 'owner'); [$viewer, $tenant] = createMinimalUserWithTenant(role: 'owner', workspaceRole: 'owner');
$otherWorkspace = Workspace::factory()->create(); $otherWorkspace = Workspace::factory()->create();
@ -151,8 +151,8 @@
// --- Enterprise UX Hardening (Spec 166 Phase 6b) --- // --- Enterprise UX Hardening (Spec 166 Phase 6b) ---
it('shows finding severity badge in exception register table', function (): void { it('shows finding severity badge in exception register table', function (): void {
[$viewer, $tenant] = createUserWithTenant(role: 'readonly'); [$viewer, $tenant] = createMinimalUserWithTenant(role: 'readonly');
[$requester] = createUserWithTenant(tenant: $tenant, role: 'owner'); [$requester] = createMinimalUserWithTenant(tenant: $tenant, role: 'owner');
$finding = Finding::factory()->for($tenant)->create(['severity' => Finding::SEVERITY_HIGH]); $finding = Finding::factory()->for($tenant)->create(['severity' => Finding::SEVERITY_HIGH]);
@ -181,8 +181,8 @@
}); });
it('shows descriptive finding title instead of bare Finding #ID', function (): void { it('shows descriptive finding title instead of bare Finding #ID', function (): void {
[$viewer, $tenant] = createUserWithTenant(role: 'readonly'); [$viewer, $tenant] = createMinimalUserWithTenant(role: 'readonly');
[$requester] = createUserWithTenant(tenant: $tenant, role: 'owner'); [$requester] = createMinimalUserWithTenant(tenant: $tenant, role: 'owner');
$finding = Finding::factory()->for($tenant)->create([ $finding = Finding::factory()->for($tenant)->create([
'finding_type' => Finding::FINDING_TYPE_DRIFT, 'finding_type' => Finding::FINDING_TYPE_DRIFT,
@ -213,8 +213,8 @@
}); });
it('shows expires_at column with relative time description', function (): void { it('shows expires_at column with relative time description', function (): void {
[$viewer, $tenant] = createUserWithTenant(role: 'readonly'); [$viewer, $tenant] = createMinimalUserWithTenant(role: 'readonly');
[$requester] = createUserWithTenant(tenant: $tenant, role: 'owner'); [$requester] = createMinimalUserWithTenant(tenant: $tenant, role: 'owner');
$finding = Finding::factory()->for($tenant)->create(); $finding = Finding::factory()->for($tenant)->create();
@ -250,7 +250,7 @@
}); });
it('renders stats overview widget above exception register table', function (): void { it('renders stats overview widget above exception register table', function (): void {
[$viewer, $tenant] = createUserWithTenant(role: 'readonly'); [$viewer, $tenant] = createMinimalUserWithTenant(role: 'readonly');
$this->actingAs($viewer); $this->actingAs($viewer);
$tenant->makeCurrent(); $tenant->makeCurrent();
@ -262,8 +262,8 @@
}); });
it('returns correct stats counts for current tenant', function (): void { it('returns correct stats counts for current tenant', function (): void {
[$viewer, $tenant] = createUserWithTenant(role: 'readonly'); [$viewer, $tenant] = createMinimalUserWithTenant(role: 'readonly');
[$requester] = createUserWithTenant(tenant: $tenant, role: 'owner'); [$requester] = createMinimalUserWithTenant(tenant: $tenant, role: 'owner');
$createException = fn (string $status, string $validity) => FindingException::query()->create([ $createException = fn (string $status, string $validity) => FindingException::query()->create([
'workspace_id' => (int) $tenant->workspace_id, 'workspace_id' => (int) $tenant->workspace_id,
@ -301,8 +301,8 @@
}); });
it('segments exception register with quick-tabs for needs-action, active, and historical', function (): void { it('segments exception register with quick-tabs for needs-action, active, and historical', function (): void {
[$viewer, $tenant] = createUserWithTenant(role: 'readonly'); [$viewer, $tenant] = createMinimalUserWithTenant(role: 'readonly');
[$requester] = createUserWithTenant(tenant: $tenant, role: 'owner'); [$requester] = createMinimalUserWithTenant(tenant: $tenant, role: 'owner');
$createException = fn (string $status, string $validity) => FindingException::query()->create([ $createException = fn (string $status, string $validity) => FindingException::query()->create([
'workspace_id' => (int) $tenant->workspace_id, 'workspace_id' => (int) $tenant->workspace_id,

View File

@ -19,6 +19,38 @@
uses(RefreshDatabase::class); uses(RefreshDatabase::class);
/**
* @return array{0: User, 1: User, 2: \App\Models\Tenant, 3: Finding, 4: FindingExceptionService, 5: FindingException}
*/
function seedApprovedFindingExceptionWindow(): array
{
[$requester, $tenant] = createUserWithTenant(role: 'owner');
$approver = User::factory()->create();
createUserWithTenant(tenant: $tenant, user: $approver, role: 'owner', workspaceRole: 'manager');
$finding = Finding::factory()->for($tenant)->create([
'status' => Finding::STATUS_RISK_ACCEPTED,
]);
/** @var FindingExceptionService $service */
$service = app(FindingExceptionService::class);
$requested = $service->request($finding, $tenant, $requester, [
'owner_user_id' => (int) $requester->getKey(),
'request_reason' => 'Initial acceptance window',
'review_due_at' => now()->addDays(5)->toDateTimeString(),
'expires_at' => now()->addDays(20)->toDateTimeString(),
]);
$active = $service->approve($requested, $approver, [
'effective_from' => now()->subDay()->toDateTimeString(),
'expires_at' => now()->addDays(20)->toDateTimeString(),
'approval_reason' => 'Initial approval',
]);
return [$requester, $approver, $tenant, $finding, $service, $active];
}
it('requests and approves a renewal while preserving prior decision history and evidence references', function (): void { it('requests and approves a renewal while preserving prior decision history and evidence references', function (): void {
[$requester, $tenant] = createUserWithTenant(role: 'owner'); [$requester, $tenant] = createUserWithTenant(role: 'owner');
$approver = User::factory()->create(); $approver = User::factory()->create();
@ -156,29 +188,7 @@
}); });
it('rejects a pending renewal without erasing the prior approved governance window', function (): void { it('rejects a pending renewal without erasing the prior approved governance window', function (): void {
[$requester, $tenant] = createUserWithTenant(role: 'owner'); [$requester, $approver, $tenant, $finding, $service, $active] = seedApprovedFindingExceptionWindow();
$approver = User::factory()->create();
createUserWithTenant(tenant: $tenant, user: $approver, role: 'owner', workspaceRole: 'manager');
$finding = Finding::factory()->for($tenant)->create([
'status' => Finding::STATUS_RISK_ACCEPTED,
]);
/** @var FindingExceptionService $service */
$service = app(FindingExceptionService::class);
$requested = $service->request($finding, $tenant, $requester, [
'owner_user_id' => (int) $requester->getKey(),
'request_reason' => 'Initial acceptance window',
'review_due_at' => now()->addDays(5)->toDateTimeString(),
'expires_at' => now()->addDays(20)->toDateTimeString(),
]);
$active = $service->approve($requested, $approver, [
'effective_from' => now()->subDay()->toDateTimeString(),
'expires_at' => now()->addDays(20)->toDateTimeString(),
'approval_reason' => 'Initial approval',
]);
$service->renew($active, $requester, [ $service->renew($active, $requester, [
'owner_user_id' => (int) $requester->getKey(), 'owner_user_id' => (int) $requester->getKey(),

View File

@ -23,7 +23,9 @@
'status' => Finding::STATUS_NEW, 'status' => Finding::STATUS_NEW,
]); ]);
Livewire::test(ListFindings::class) $component = Livewire::test(ListFindings::class);
$component
->callTableAction('triage', $finding) ->callTableAction('triage', $finding)
->assertHasNoTableActionErrors(); ->assertHasNoTableActionErrors();
@ -31,7 +33,7 @@
expect($finding->status)->toBe(Finding::STATUS_TRIAGED) expect($finding->status)->toBe(Finding::STATUS_TRIAGED)
->and($finding->triaged_at)->not->toBeNull(); ->and($finding->triaged_at)->not->toBeNull();
Livewire::test(ListFindings::class) $component
->callTableAction('start_progress', $finding) ->callTableAction('start_progress', $finding)
->assertHasNoTableActionErrors(); ->assertHasNoTableActionErrors();
@ -39,7 +41,7 @@
expect($finding->status)->toBe(Finding::STATUS_IN_PROGRESS) expect($finding->status)->toBe(Finding::STATUS_IN_PROGRESS)
->and($finding->in_progress_at)->not->toBeNull(); ->and($finding->in_progress_at)->not->toBeNull();
Livewire::test(ListFindings::class) $component
->callTableAction('resolve', $finding, [ ->callTableAction('resolve', $finding, [
'resolved_reason' => 'patched', 'resolved_reason' => 'patched',
]) ])
@ -50,7 +52,7 @@
->and($finding->resolved_reason)->toBe('patched') ->and($finding->resolved_reason)->toBe('patched')
->and($finding->resolved_at)->not->toBeNull(); ->and($finding->resolved_at)->not->toBeNull();
Livewire::test(ListFindings::class) $component
->filterTable('open', false) ->filterTable('open', false)
->callTableAction('reopen', $finding, [ ->callTableAction('reopen', $finding, [
'reopen_reason' => 'The issue recurred in a later scan.', 'reopen_reason' => 'The issue recurred in a later scan.',
@ -76,13 +78,15 @@
'status' => Finding::STATUS_NEW, 'status' => Finding::STATUS_NEW,
]); ]);
Livewire::test(ListFindings::class) $component = Livewire::test(ListFindings::class);
$component
->callTableAction('close', $closeFinding, [ ->callTableAction('close', $closeFinding, [
'closed_reason' => 'duplicate ticket', 'closed_reason' => 'duplicate ticket',
]) ])
->assertHasNoTableActionErrors(); ->assertHasNoTableActionErrors();
Livewire::test(ListFindings::class) $component
->callTableAction('request_exception', $exceptionFinding, [ ->callTableAction('request_exception', $exceptionFinding, [
'owner_user_id' => (int) $user->getKey(), 'owner_user_id' => (int) $user->getKey(),
'request_reason' => 'accepted by security', 'request_reason' => 'accepted by security',
@ -117,7 +121,9 @@
'status' => Finding::STATUS_NEW, 'status' => Finding::STATUS_NEW,
]); ]);
Livewire::test(ListFindings::class) $component = Livewire::test(ListFindings::class);
$component
->callTableAction('assign', $finding, [ ->callTableAction('assign', $finding, [
'assignee_user_id' => (int) $assignee->getKey(), 'assignee_user_id' => (int) $assignee->getKey(),
'owner_user_id' => (int) $manager->getKey(), 'owner_user_id' => (int) $manager->getKey(),
@ -128,7 +134,7 @@
expect((int) $finding->assignee_user_id)->toBe((int) $assignee->getKey()) expect((int) $finding->assignee_user_id)->toBe((int) $assignee->getKey())
->and((int) $finding->owner_user_id)->toBe((int) $manager->getKey()); ->and((int) $finding->owner_user_id)->toBe((int) $manager->getKey());
Livewire::test(ListFindings::class) $component
->callTableAction('assign', $finding, [ ->callTableAction('assign', $finding, [
'assignee_user_id' => (int) $outsider->getKey(), 'assignee_user_id' => (int) $outsider->getKey(),
'owner_user_id' => (int) $manager->getKey(), 'owner_user_id' => (int) $manager->getKey(),

View File

@ -54,7 +54,9 @@
$finding = Finding::factory()->for($tenant)->create(['status' => Finding::STATUS_NEW]); $finding = Finding::factory()->for($tenant)->create(['status' => Finding::STATUS_NEW]);
Livewire::test(ViewFinding::class, ['record' => $finding->getKey()]) $component = Livewire::test(ViewFinding::class, ['record' => $finding->getKey()]);
$component
->callAction('triage') ->callAction('triage')
->assertHasNoActionErrors() ->assertHasNoActionErrors()
->callAction('assign', [ ->callAction('assign', [

View File

@ -11,6 +11,16 @@
uses(RefreshDatabase::class); uses(RefreshDatabase::class);
function actingAsFindingsManagerForFilters(): array
{
[$user, $tenant] = createUserWithTenant(role: 'manager');
test()->actingAs($user);
Filament::setTenant($tenant, true);
return [$user, $tenant];
}
function findingFilterIndicatorLabels($component): array function findingFilterIndicatorLabels($component): array
{ {
return collect($component->instance()->getTable()->getFilterIndicators()) return collect($component->instance()->getTable()->getFilterIndicators())
@ -19,9 +29,7 @@ function findingFilterIndicatorLabels($component): array
} }
it('filters findings by overdue quick filter using open statuses only', function (): void { it('filters findings by overdue quick filter using open statuses only', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'manager'); [, $tenant] = actingAsFindingsManagerForFilters();
$this->actingAs($user);
Filament::setTenant($tenant, true);
$overdueOpen = Finding::factory()->for($tenant)->create([ $overdueOpen = Finding::factory()->for($tenant)->create([
'status' => Finding::STATUS_NEW, 'status' => Finding::STATUS_NEW,
@ -45,14 +53,11 @@ function findingFilterIndicatorLabels($component): array
}); });
it('filters findings by workflow family and governance validity', function (): void { it('filters findings by workflow family and governance validity', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'manager'); [, $tenant] = actingAsFindingsManagerForFilters();
[$requester] = createUserWithTenant(tenant: $tenant, role: 'owner'); [$requester] = createUserWithTenant(tenant: $tenant, role: 'owner');
$approver = User::factory()->create(); $approver = User::factory()->create();
createUserWithTenant(tenant: $tenant, user: $approver, role: 'owner', workspaceRole: 'manager'); createUserWithTenant(tenant: $tenant, user: $approver, role: 'owner', workspaceRole: 'manager');
$this->actingAs($user);
Filament::setTenant($tenant, true);
$active = Finding::factory()->for($tenant)->create([ $active = Finding::factory()->for($tenant)->create([
'status' => Finding::STATUS_NEW, 'status' => Finding::STATUS_NEW,
]); ]);
@ -105,9 +110,7 @@ function findingFilterIndicatorLabels($component): array
}); });
it('filters findings by high severity quick filter', function (): void { it('filters findings by high severity quick filter', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'manager'); [, $tenant] = actingAsFindingsManagerForFilters();
$this->actingAs($user);
Filament::setTenant($tenant, true);
$critical = Finding::factory()->for($tenant)->create([ $critical = Finding::factory()->for($tenant)->create([
'severity' => Finding::SEVERITY_CRITICAL, 'severity' => Finding::SEVERITY_CRITICAL,
@ -131,9 +134,7 @@ function findingFilterIndicatorLabels($component): array
}); });
it('filters findings by my assigned quick filter', function (): void { it('filters findings by my assigned quick filter', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'manager'); [$user, $tenant] = actingAsFindingsManagerForFilters();
$this->actingAs($user);
Filament::setTenant($tenant, true);
$otherUser = User::factory()->create(); $otherUser = User::factory()->create();
createUserWithTenant(tenant: $tenant, user: $otherUser, role: 'operator'); createUserWithTenant(tenant: $tenant, user: $otherUser, role: 'operator');
@ -160,9 +161,7 @@ function findingFilterIndicatorLabels($component): array
}); });
it('persists findings search, sort, and filter state while keeping the resource pagination profile', function (): void { it('persists findings search, sort, and filter state while keeping the resource pagination profile', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'manager'); actingAsFindingsManagerForFilters();
$this->actingAs($user);
Filament::setTenant($tenant, true);
$component = Livewire::test(ListFindings::class) $component = Livewire::test(ListFindings::class)
->searchTable('drift') ->searchTable('drift')
@ -182,9 +181,7 @@ function findingFilterIndicatorLabels($component): array
}); });
it('composes status and created date filters with active indicators', function (): void { it('composes status and created date filters with active indicators', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'manager'); [, $tenant] = actingAsFindingsManagerForFilters();
$this->actingAs($user);
Filament::setTenant($tenant, true);
$matching = Finding::factory()->for($tenant)->create([ $matching = Finding::factory()->for($tenant)->create([
'status' => Finding::STATUS_NEW, 'status' => Finding::STATUS_NEW,
@ -214,9 +211,7 @@ function findingFilterIndicatorLabels($component): array
}); });
it('clears findings filters back to the default open view', function (): void { it('clears findings filters back to the default open view', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'manager'); [, $tenant] = actingAsFindingsManagerForFilters();
$this->actingAs($user);
Filament::setTenant($tenant, true);
$openFinding = Finding::factory()->for($tenant)->create([ $openFinding = Finding::factory()->for($tenant)->create([
'status' => Finding::STATUS_NEW, 'status' => Finding::STATUS_NEW,
@ -250,9 +245,7 @@ function findingFilterIndicatorLabels($component): array
}); });
it('prefilters dashboard open-drift drill-throughs to the named findings subset', function (): void { it('prefilters dashboard open-drift drill-throughs to the named findings subset', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'manager'); [, $tenant] = actingAsFindingsManagerForFilters();
$this->actingAs($user);
Filament::setTenant($tenant, true);
$openDrift = Finding::factory()->for($tenant)->create([ $openDrift = Finding::factory()->for($tenant)->create([
'finding_type' => Finding::FINDING_TYPE_DRIFT, 'finding_type' => Finding::FINDING_TYPE_DRIFT,

View File

@ -126,9 +126,19 @@
use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Queue; use Illuminate\Support\Facades\Queue;
use Livewire\Livewire; use Livewire\Livewire;
use Tests\Support\TestLaneManifest;
uses(RefreshDatabase::class); uses(RefreshDatabase::class);
it('keeps the retained action-surface contract family anchored in heavy-governance inventory', function (): void {
$inventoryRecord = collect(TestLaneManifest::heavyGovernanceHotspotInventory())
->firstWhere('familyId', 'action-surface-contract');
expect($inventoryRecord)->not->toBeNull()
->and($inventoryRecord['classificationId'])->toBe('surface-guard')
->and($inventoryRecord['status'])->toBe('retained');
});
function actionSurfaceSystemPanelContext(array $capabilities): PlatformUser function actionSurfaceSystemPanelContext(array $capabilities): PlatformUser
{ {
Filament::setCurrentPanel('system'); Filament::setCurrentPanel('system');
@ -230,7 +240,7 @@ function actionSurfaceSystemPanelContext(array $capabilities): PlatformUser
}); });
it('keeps compare-assigned-tenants visibly disabled with helper text on the matrix for readonly members', function (): void { it('keeps compare-assigned-tenants visibly disabled with helper text on the matrix for readonly members', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'readonly'); [$user, $tenant] = createMinimalUserWithTenant(role: 'readonly');
$profile = BaselineProfile::factory()->active()->create([ $profile = BaselineProfile::factory()->active()->create([
'workspace_id' => (int) $tenant->workspace_id, 'workspace_id' => (int) $tenant->workspace_id,
@ -261,7 +271,7 @@ function actionSurfaceSystemPanelContext(array $capabilities): PlatformUser
}); });
it('keeps BaselineProfile archive under the More menu and declares it in the action surface slots', function (): void { it('keeps BaselineProfile archive under the More menu and declares it in the action surface slots', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner'); [$user, $tenant] = createMinimalUserWithTenant(role: 'owner');
$profile = BaselineProfile::factory()->active()->create([ $profile = BaselineProfile::factory()->active()->create([
'workspace_id' => (int) $tenant->workspace_id, 'workspace_id' => (int) $tenant->workspace_id,
@ -310,7 +320,7 @@ function actionSurfaceSystemPanelContext(array $capabilities): PlatformUser
}); });
it('keeps backup schedules on clickable-row edit without duplicate Edit actions in More', function (): void { it('keeps backup schedules on clickable-row edit without duplicate Edit actions in More', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner'); [$user, $tenant] = createMinimalUserWithTenant(role: 'owner');
$schedule = BackupSchedule::query()->create([ $schedule = BackupSchedule::query()->create([
'tenant_id' => (int) $tenant->getKey(), 'tenant_id' => (int) $tenant->getKey(),
@ -374,7 +384,7 @@ function actionSurfaceSystemPanelContext(array $capabilities): PlatformUser
}); });
it('uses clickable rows without extra row actions on backup schedule executions', function (): void { it('uses clickable rows without extra row actions on backup schedule executions', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner'); [$user, $tenant] = createMinimalUserWithTenant(role: 'owner');
$schedule = BackupSchedule::query()->create([ $schedule = BackupSchedule::query()->create([
'tenant_id' => (int) $tenant->getKey(), 'tenant_id' => (int) $tenant->getKey(),
@ -412,7 +422,7 @@ function actionSurfaceSystemPanelContext(array $capabilities): PlatformUser
}); });
it('uses clickable rows while keeping remove grouped under More on backup items', function (): void { it('uses clickable rows while keeping remove grouped under More on backup items', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner'); [$user, $tenant] = createMinimalUserWithTenant(role: 'owner');
$policy = Policy::factory()->create([ $policy = Policy::factory()->create([
'tenant_id' => (int) $tenant->getKey(), 'tenant_id' => (int) $tenant->getKey(),
@ -479,7 +489,7 @@ function actionSurfaceSystemPanelContext(array $capabilities): PlatformUser
}); });
it('keeps tenant memberships inline without a separate inspect affordance', function (): void { it('keeps tenant memberships inline without a separate inspect affordance', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner'); [$user, $tenant] = createMinimalUserWithTenant(role: 'owner');
$member = User::factory()->create(); $member = User::factory()->create();
$member->tenants()->syncWithoutDetaching([ $member->tenants()->syncWithoutDetaching([
@ -564,7 +574,7 @@ function actionSurfaceSystemPanelContext(array $capabilities): PlatformUser
}); });
it('renders the policy versions relation manager on the policy detail page', function (): void { it('renders the policy versions relation manager on the policy detail page', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner'); [$user, $tenant] = createMinimalUserWithTenant(role: 'owner');
$policy = Policy::factory()->create([ $policy = Policy::factory()->create([
'tenant_id' => (int) $tenant->getKey(), 'tenant_id' => (int) $tenant->getKey(),
@ -587,7 +597,7 @@ function actionSurfaceSystemPanelContext(array $capabilities): PlatformUser
}); });
it('renders tenant memberships only on the dedicated memberships page', function (): void { it('renders tenant memberships only on the dedicated memberships page', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner'); [$user, $tenant] = createMinimalUserWithTenant(role: 'owner');
$member = User::factory()->create([ $member = User::factory()->create([
'email' => 'tenant-members-surface@example.test', 'email' => 'tenant-members-surface@example.test',
@ -620,7 +630,7 @@ function actionSurfaceSystemPanelContext(array $capabilities): PlatformUser
}); });
it('keeps the tenant registry action surface on row inspect plus one safe dashboard shortcut for active tenants', function (): void { it('keeps the tenant registry action surface on row inspect plus one safe dashboard shortcut for active tenants', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner'); [$user, $tenant] = createMinimalUserWithTenant(role: 'owner');
$this->actingAs($user); $this->actingAs($user);
Filament::setCurrentPanel('admin'); Filament::setCurrentPanel('admin');
@ -640,7 +650,7 @@ function actionSurfaceSystemPanelContext(array $capabilities): PlatformUser
it('keeps tenant detail header actions aligned with the shared administrative family while preserving workflow-heavy exceptions', function (): void { it('keeps tenant detail header actions aligned with the shared administrative family while preserving workflow-heavy exceptions', function (): void {
$tenant = Tenant::factory()->active()->create(); $tenant = Tenant::factory()->active()->create();
[$user, $tenant] = createUserWithTenant( [$user, $tenant] = createMinimalUserWithTenant(
tenant: $tenant, tenant: $tenant,
role: 'owner', role: 'owner',
ensureDefaultMicrosoftProviderConnection: false, ensureDefaultMicrosoftProviderConnection: false,
@ -725,7 +735,7 @@ function actionSurfaceSystemPanelContext(array $capabilities): PlatformUser
}); });
it('renders the backup items relation manager on the backup set detail page', function (): void { it('renders the backup items relation manager on the backup set detail page', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner'); [$user, $tenant] = createMinimalUserWithTenant(role: 'owner');
$policy = Policy::factory()->create([ $policy = Policy::factory()->create([
'tenant_id' => (int) $tenant->getKey(), 'tenant_id' => (int) $tenant->getKey(),
@ -779,7 +789,7 @@ function actionSurfaceSystemPanelContext(array $capabilities): PlatformUser
}); });
it('keeps inventory coverage as derived metadata without inspect or row action affordances', function (): void { it('keeps inventory coverage as derived metadata without inspect or row action affordances', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner'); [$user, $tenant] = createMinimalUserWithTenant(role: 'owner');
$conditionalAccessKey = 'policy:conditionalAccessPolicy'; $conditionalAccessKey = 'policy:conditionalAccessPolicy';
@ -1166,7 +1176,7 @@ function actionSurfaceSystemPanelContext(array $capabilities): PlatformUser
}); });
it('keeps finding exception v1 list exemptions explicit and omits grouped or bulk mutations', function (): void { it('keeps finding exception v1 list exemptions explicit and omits grouped or bulk mutations', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner'); [$user, $tenant] = createMinimalUserWithTenant(role: 'owner');
$declaration = FindingExceptionResource::actionSurfaceDeclaration(); $declaration = FindingExceptionResource::actionSurfaceDeclaration();
@ -1199,7 +1209,7 @@ function actionSurfaceSystemPanelContext(array $capabilities): PlatformUser
}); });
it('uses More grouping conventions and exposes empty-state CTA on representative CRUD list', function (): void { it('uses More grouping conventions and exposes empty-state CTA on representative CRUD list', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner'); [$user, $tenant] = createMinimalUserWithTenant(role: 'owner');
$this->actingAs($user); $this->actingAs($user);
$tenant->makeCurrent(); $tenant->makeCurrent();
@ -1230,7 +1240,7 @@ function actionSurfaceSystemPanelContext(array $capabilities): PlatformUser
}); });
it('keeps evidence snapshots on the declared clickable-row, two-action surface', function (): void { it('keeps evidence snapshots on the declared clickable-row, two-action surface', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner'); [$user, $tenant] = createMinimalUserWithTenant(role: 'owner');
$this->actingAs($user); $this->actingAs($user);
$tenant->makeCurrent(); $tenant->makeCurrent();
@ -1276,7 +1286,7 @@ function actionSurfaceSystemPanelContext(array $capabilities): PlatformUser
}); });
it('uses clickable rows without a duplicate View action on the tenant reviews list', function (): void { it('uses clickable rows without a duplicate View action on the tenant reviews list', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner'); [$user, $tenant] = createMinimalUserWithTenant(role: 'owner');
$review = composeTenantReviewForTest($tenant, $user); $review = composeTenantReviewForTest($tenant, $user);
$this->actingAs($user); $this->actingAs($user);
@ -1298,7 +1308,7 @@ function actionSurfaceSystemPanelContext(array $capabilities): PlatformUser
}); });
it('uses clickable rows while keeping direct download and expire shortcuts on the review packs list', function (): void { it('uses clickable rows while keeping direct download and expire shortcuts on the review packs list', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner'); [$user, $tenant] = createMinimalUserWithTenant(role: 'owner');
$pack = ReviewPack::factory()->ready()->create([ $pack = ReviewPack::factory()->ready()->create([
'tenant_id' => (int) $tenant->getKey(), 'tenant_id' => (int) $tenant->getKey(),
@ -1326,7 +1336,7 @@ function actionSurfaceSystemPanelContext(array $capabilities): PlatformUser
}); });
it('uses clickable rows while grouping restore-run maintenance actions under More', function (): void { it('uses clickable rows while grouping restore-run maintenance actions under More', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner'); [$user, $tenant] = createMinimalUserWithTenant(role: 'owner');
$backupSet = BackupSet::factory()->create([ $backupSet = BackupSet::factory()->create([
'tenant_id' => (int) $tenant->getKey(), 'tenant_id' => (int) $tenant->getKey(),
@ -1382,7 +1392,7 @@ function actionSurfaceSystemPanelContext(array $capabilities): PlatformUser
}); });
it('keeps findings on clickable-row inspection with a single related drill-down and grouped workflow actions', function (): void { it('keeps findings on clickable-row inspection with a single related drill-down and grouped workflow actions', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner'); [$user, $tenant] = createMinimalUserWithTenant(role: 'owner');
$finding = Finding::factory()->for($tenant)->create([ $finding = Finding::factory()->for($tenant)->create([
'status' => Finding::STATUS_NEW, 'status' => Finding::STATUS_NEW,
@ -1445,7 +1455,7 @@ function actionSurfaceSystemPanelContext(array $capabilities): PlatformUser
}); });
it('uses clickable rows with restore as the only inline shortcut on the policy versions relation manager', function (): void { it('uses clickable rows with restore as the only inline shortcut on the policy versions relation manager', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner'); [$user, $tenant] = createMinimalUserWithTenant(role: 'owner');
$policy = Policy::factory()->create([ $policy = Policy::factory()->create([
'tenant_id' => (int) $tenant->getKey(), 'tenant_id' => (int) $tenant->getKey(),
@ -1500,7 +1510,7 @@ function actionSurfaceSystemPanelContext(array $capabilities): PlatformUser
}); });
it('uses clickable rows without a lone View action on the monitoring operations list', function (): void { it('uses clickable rows without a lone View action on the monitoring operations list', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner'); [$user, $tenant] = createMinimalUserWithTenant(role: 'owner');
$run = OperationRun::factory()->create([ $run = OperationRun::factory()->create([
'tenant_id' => (int) $tenant->getKey(), 'tenant_id' => (int) $tenant->getKey(),
@ -1530,7 +1540,7 @@ function actionSurfaceSystemPanelContext(array $capabilities): PlatformUser
}); });
it('keeps review and evidence references on clickable-row open without duplicate inspect actions', function (): void { it('keeps review and evidence references on clickable-row open without duplicate inspect actions', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner'); [$user, $tenant] = createMinimalUserWithTenant(role: 'owner');
$review = composeTenantReviewForTest($tenant, $user)->load('evidenceSnapshot'); $review = composeTenantReviewForTest($tenant, $user)->load('evidenceSnapshot');
$snapshot = $review->evidenceSnapshot; $snapshot = $review->evidenceSnapshot;
@ -1567,7 +1577,7 @@ function actionSurfaceSystemPanelContext(array $capabilities): PlatformUser
}); });
it('keeps audit and queue references on explicit inspect without row-click navigation', function (): void { it('keeps audit and queue references on explicit inspect without row-click navigation', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner', workspaceRole: 'manager'); [$user, $tenant] = createMinimalUserWithTenant(role: 'owner', workspaceRole: 'manager');
$audit = AuditLog::query()->create([ $audit = AuditLog::query()->create([
'workspace_id' => (int) $tenant->workspace_id, 'workspace_id' => (int) $tenant->workspace_id,
@ -1670,7 +1680,7 @@ function actionSurfaceSystemPanelContext(array $capabilities): PlatformUser
}); });
it('keeps tenant diagnostics as a singleton repair surface with header actions only', function (): void { it('keeps tenant diagnostics as a singleton repair surface with header actions only', function (): void {
[$manager, $tenant] = createUserWithTenant(role: 'manager'); [$manager, $tenant] = createMinimalUserWithTenant(role: 'manager');
$this->actingAs($manager); $this->actingAs($manager);
Filament::setTenant($tenant, true); Filament::setTenant($tenant, true);
@ -1705,7 +1715,7 @@ function actionSurfaceSystemPanelContext(array $capabilities): PlatformUser
}); });
it('keeps required permissions as a guided diagnostic page with inline filters and empty-state guidance', function (): void { it('keeps required permissions as a guided diagnostic page with inline filters and empty-state guidance', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'readonly'); [$user, $tenant] = createMinimalUserWithTenant(role: 'readonly');
$response = $this->actingAs($user) $response = $this->actingAs($user)
->get("/admin/tenants/{$tenant->external_id}/required-permissions"); ->get("/admin/tenants/{$tenant->external_id}/required-permissions");
@ -1889,7 +1899,7 @@ function actionSurfaceSystemPanelContext(array $capabilities): PlatformUser
}); });
it('removes lone View buttons and uses clickable rows on the inventory items list', function (): void { it('removes lone View buttons and uses clickable rows on the inventory items list', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner'); [$user, $tenant] = createMinimalUserWithTenant(role: 'owner');
$this->actingAs($user); $this->actingAs($user);
$tenant->makeCurrent(); $tenant->makeCurrent();
@ -1911,7 +1921,7 @@ function actionSurfaceSystemPanelContext(array $capabilities): PlatformUser
}); });
it('uses clickable rows without a lone View action on the workspaces list', function (): void { it('uses clickable rows without a lone View action on the workspaces list', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner'); [$user, $tenant] = createMinimalUserWithTenant(role: 'owner');
$this->actingAs($user); $this->actingAs($user);
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id); session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id);
@ -1946,7 +1956,7 @@ function actionSurfaceSystemPanelContext(array $capabilities): PlatformUser
}); });
it('uses clickable rows without a lone View action on the policies list', function (): void { it('uses clickable rows without a lone View action on the policies list', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner'); [$user, $tenant] = createMinimalUserWithTenant(role: 'owner');
$policy = Policy::query()->create([ $policy = Policy::query()->create([
'tenant_id' => (int) $tenant->getKey(), 'tenant_id' => (int) $tenant->getKey(),
@ -1997,7 +2007,7 @@ function actionSurfaceSystemPanelContext(array $capabilities): PlatformUser
}); });
it('uses clickable rows without a duplicate Edit action on the alert rules list', function (): void { it('uses clickable rows without a duplicate Edit action on the alert rules list', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner'); [$user, $tenant] = createMinimalUserWithTenant(role: 'owner');
$workspaceId = (int) $tenant->workspace_id; $workspaceId = (int) $tenant->workspace_id;
$rule = AlertRule::factory()->create([ $rule = AlertRule::factory()->create([
@ -2036,7 +2046,7 @@ function actionSurfaceSystemPanelContext(array $capabilities): PlatformUser
}); });
it('uses clickable rows without a duplicate Edit action on the alert destinations list', function (): void { it('uses clickable rows without a duplicate Edit action on the alert destinations list', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner'); [$user, $tenant] = createMinimalUserWithTenant(role: 'owner');
$workspaceId = (int) $tenant->workspace_id; $workspaceId = (int) $tenant->workspace_id;
$destination = AlertDestination::factory()->create([ $destination = AlertDestination::factory()->create([
@ -2075,7 +2085,7 @@ function actionSurfaceSystemPanelContext(array $capabilities): PlatformUser
}); });
it('uses clickable-row view with all secondary provider connection actions grouped under More', function (): void { it('uses clickable-row view with all secondary provider connection actions grouped under More', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner', ensureDefaultMicrosoftProviderConnection: false); [$user, $tenant] = createMinimalUserWithTenant(role: 'owner', ensureDefaultMicrosoftProviderConnection: false);
$connection = ProviderConnection::factory()->platform()->consentGranted()->create([ $connection = ProviderConnection::factory()->platform()->consentGranted()->create([
'tenant_id' => (int) $tenant->getKey(), 'tenant_id' => (int) $tenant->getKey(),
@ -2129,7 +2139,7 @@ function actionSurfaceSystemPanelContext(array $capabilities): PlatformUser
}); });
it('keeps provider connection detail secondary actions aligned under More', function (): void { it('keeps provider connection detail secondary actions aligned under More', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner', ensureDefaultMicrosoftProviderConnection: false); [$user, $tenant] = createMinimalUserWithTenant(role: 'owner', ensureDefaultMicrosoftProviderConnection: false);
$connection = ProviderConnection::factory()->platform()->consentGranted()->create([ $connection = ProviderConnection::factory()->platform()->consentGranted()->create([
'tenant_id' => (int) $tenant->getKey(), 'tenant_id' => (int) $tenant->getKey(),
@ -2186,7 +2196,7 @@ function actionSurfaceSystemPanelContext(array $capabilities): PlatformUser
}); });
it('uses clickable rows without extra row actions on the alert deliveries list', function (): void { it('uses clickable rows without extra row actions on the alert deliveries list', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner'); [$user, $tenant] = createMinimalUserWithTenant(role: 'owner');
$workspaceId = (int) $tenant->workspace_id; $workspaceId = (int) $tenant->workspace_id;
$rule = AlertRule::factory()->create([ $rule = AlertRule::factory()->create([
@ -2219,7 +2229,7 @@ function actionSurfaceSystemPanelContext(array $capabilities): PlatformUser
Queue::fake(); Queue::fake();
bindFailHardGraphClient(); bindFailHardGraphClient();
[$user, $tenant] = createUserWithTenant(role: 'owner'); [$user, $tenant] = createMinimalUserWithTenant(role: 'owner');
$this->actingAs($user); $this->actingAs($user);
$tenant->makeCurrent(); $tenant->makeCurrent();
@ -2314,7 +2324,7 @@ function actionSurfaceSystemPanelContext(array $capabilities): PlatformUser
}); });
it('keeps spec 193 hierarchy work from expanding confirmation, reason capture, or compare-start semantics', function (): void { it('keeps spec 193 hierarchy work from expanding confirmation, reason capture, or compare-start semantics', function (): void {
[$approver, $tenant] = createUserWithTenant(role: 'owner', workspaceRole: 'manager'); [$approver, $tenant] = createMinimalUserWithTenant(role: 'owner', workspaceRole: 'manager');
$mountedActionFieldNames = static function (mixed $component): array { $mountedActionFieldNames = static function (mixed $component): array {
$method = new \ReflectionMethod($component->instance(), 'getMountedActionForm'); $method = new \ReflectionMethod($component->instance(), 'getMountedActionForm');

View File

@ -0,0 +1,56 @@
<?php
declare(strict_types=1);
use Illuminate\Support\Collection;
use Tests\Support\TestLaneManifest;
use Tests\Support\TestLaneBudget;
it('keeps browser tests isolated behind their dedicated lane and class', function (): void {
$lane = TestLaneManifest::lane('browser');
$files = new Collection(TestLaneManifest::discoverFiles('browser'));
$validation = TestLaneManifest::validateLanePlacement(
laneId: 'browser',
filePath: 'tests/Browser/Spec190BaselineCompareMatrixSmokeTest.php',
);
expect($lane['includedFamilies'])->toContain('browser')
->and($lane['defaultEntryPoint'])->toBeFalse()
->and($files)->not->toBeEmpty()
->and($files->every(static fn (string $path): bool => str_starts_with($path, 'tests/Browser/')))->toBeTrue()
->and($validation['valid'])->toBeTrue()
->and($validation['resolvedClassificationId'])->toBe('browser')
->and($validation['familyId'])->toBe('browser-smoke');
});
it('rejects browser placement in non-browser lanes and keeps the default loops clean', function (): void {
$misplaced = TestLaneManifest::validateLanePlacement(
laneId: 'confidence',
filePath: 'tests/Browser/Spec190BaselineCompareMatrixSmokeTest.php',
);
$configurationPath = TestLaneManifest::laneConfigurationPath('browser');
$configurationContents = (string) file_get_contents(TestLaneManifest::absolutePath($configurationPath));
expect(TestLaneManifest::buildCommand('browser'))->toContain('--configuration='.$configurationPath)
->and($configurationContents)->toContain('tests/Browser/Spec190BaselineCompareMatrixSmokeTest.php')
->and($misplaced['valid'])->toBeFalse()
->and($misplaced['allowance'])->toBe('forbidden');
foreach (TestLaneManifest::discoverFiles('fast-feedback') as $path) {
expect($path)->not->toStartWith('tests/Browser/');
}
foreach (TestLaneManifest::discoverFiles('confidence') as $path) {
expect($path)->not->toStartWith('tests/Browser/');
}
});
it('keeps browser manual and scheduled budget profiles separate from the default contributor loops', function (): void {
$manualProfile = TestLaneBudget::enforcementProfile('browser', 'manual');
$scheduledProfile = TestLaneBudget::enforcementProfile('browser', 'scheduled');
expect($manualProfile['enforcementMode'])->toBe('soft-warn')
->and($scheduledProfile['enforcementMode'])->toBe('trend-only')
->and($manualProfile['effectiveThresholdSeconds'])->toBe(170)
->and($scheduledProfile['effectiveThresholdSeconds'])->toBe(170);
});

View File

@ -0,0 +1,25 @@
<?php
declare(strict_types=1);
use Tests\Support\TestLaneManifest;
it('wires the dev push workflow to the confidence lane and publishes the lane-owned JUnit artifact bundle', function (): void {
$workflowProfile = TestLaneManifest::workflowProfile('main-confidence');
$workflowContents = (string) file_get_contents(repo_path($workflowProfile['filePath']));
expect(file_exists(repo_path($workflowProfile['filePath'])))->toBeTrue()
->and($workflowProfile['triggerClass'])->toBe('mainline-push')
->and($workflowProfile['branchFilters'])->toBe(['dev'])
->and($workflowContents)->toContain('push:')
->and($workflowContents)->toContain('- dev')
->and($workflowContents)->toContain('permissions:')
->and($workflowContents)->toContain('actions: read')
->and($workflowContents)->toContain('contents: read')
->and($workflowContents)->toContain('./scripts/platform-test-lane confidence --workflow-id=main-confidence --trigger-class=mainline-push')
->and($workflowContents)->toContain('TENANTATLAS_GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}')
->and($workflowContents)->toContain('./scripts/platform-test-report confidence --workflow-id=main-confidence --trigger-class=mainline-push --fetch-latest-history')
->and($workflowContents)->toContain('./scripts/platform-test-artifacts confidence .gitea-artifacts/main-confidence --workflow-id=main-confidence --trigger-class=mainline-push')
->and($workflowContents)->toContain('name: confidence-artifacts')
->and($workflowContents)->not->toContain('test:junit', './scripts/platform-test-lane fast-feedback', './scripts/platform-test-lane heavy-governance');
});

View File

@ -0,0 +1,25 @@
<?php
declare(strict_types=1);
use Tests\Support\TestLaneManifest;
it('wires the pull-request workflow only to the fast-feedback lane with the checked-in wrappers and artifact staging helper', function (): void {
$workflowProfile = TestLaneManifest::workflowProfile('pr-fast-feedback');
$workflowContents = (string) file_get_contents(repo_path($workflowProfile['filePath']));
expect(file_exists(repo_path($workflowProfile['filePath'])))->toBeTrue()
->and($workflowProfile['triggerClass'])->toBe('pull-request')
->and($workflowProfile['laneBindings'])->toBe(['fast-feedback'])
->and($workflowContents)->toContain('pull_request:')
->and($workflowContents)->toContain('permissions:')
->and($workflowContents)->toContain('actions: read')
->and($workflowContents)->toContain('contents: read')
->and($workflowContents)->toContain('opened', 'reopened', 'synchronize')
->and($workflowContents)->toContain('./scripts/platform-test-lane fast-feedback --workflow-id=pr-fast-feedback --trigger-class=pull-request')
->and($workflowContents)->toContain('TENANTATLAS_GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}')
->and($workflowContents)->toContain('./scripts/platform-test-report fast-feedback --workflow-id=pr-fast-feedback --trigger-class=pull-request --fetch-latest-history')
->and($workflowContents)->toContain('./scripts/platform-test-artifacts fast-feedback .gitea-artifacts/pr-fast-feedback --workflow-id=pr-fast-feedback --trigger-class=pull-request')
->and($workflowContents)->toContain('name: fast-feedback-artifacts')
->and($workflowContents)->not->toContain('confidence --workflow-id=pr-fast-feedback', 'heavy-governance', 'browser --workflow-id=pr-fast-feedback');
});

View File

@ -0,0 +1,55 @@
<?php
declare(strict_types=1);
use Tests\Support\TestLaneManifest;
it('keeps heavy-governance manual and scheduled execution inside the dedicated workflow file with schedule gating', function (): void {
$manualProfile = TestLaneManifest::workflowProfile('heavy-governance-manual');
$scheduledProfile = TestLaneManifest::workflowProfile('heavy-governance-scheduled');
$workflowContents = (string) file_get_contents(repo_path($manualProfile['filePath']));
expect(file_exists(repo_path($manualProfile['filePath'])))->toBeTrue()
->and($manualProfile['filePath'])->toBe($scheduledProfile['filePath'])
->and($manualProfile['laneBindings'])->toBe(['heavy-governance'])
->and($scheduledProfile['scheduleCron'])->toBe('17 4 * * 1-5')
->and($workflowContents)->toContain('workflow_dispatch:')
->and($workflowContents)->toContain('schedule:')
->and($workflowContents)->toContain('permissions:')
->and($workflowContents)->toContain('actions: read')
->and($workflowContents)->toContain('contents: read')
->and($workflowContents)->toContain('17 4 * * 1-5')
->and($workflowContents)->toContain("vars.TENANTATLAS_ENABLE_HEAVY_GOVERNANCE_SCHEDULE == '1'")
->and($workflowContents)->toContain('workflow_id=heavy-governance-manual')
->and($workflowContents)->toContain('workflow_id=heavy-governance-scheduled')
->and($workflowContents)->toContain('./scripts/platform-test-lane heavy-governance --workflow-id=${{ steps.context.outputs.workflow_id }} --trigger-class=${{ steps.context.outputs.trigger_class }}')
->and($workflowContents)->toContain('TENANTATLAS_GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}')
->and($workflowContents)->toContain('./scripts/platform-test-report heavy-governance --workflow-id=${{ steps.context.outputs.workflow_id }} --trigger-class=${{ steps.context.outputs.trigger_class }} --fetch-latest-history')
->and($workflowContents)->toContain('./scripts/platform-test-artifacts heavy-governance .gitea-artifacts/heavy-governance --workflow-id=${{ steps.context.outputs.workflow_id }} --trigger-class=${{ steps.context.outputs.trigger_class }}')
->and($workflowContents)->not->toContain('pull_request:', './scripts/platform-test-lane browser');
});
it('keeps browser manual and scheduled execution isolated from pull-request and confidence validation', function (): void {
$manualProfile = TestLaneManifest::workflowProfile('browser-manual');
$scheduledProfile = TestLaneManifest::workflowProfile('browser-scheduled');
$workflowContents = (string) file_get_contents(repo_path($manualProfile['filePath']));
expect(file_exists(repo_path($manualProfile['filePath'])))->toBeTrue()
->and($manualProfile['filePath'])->toBe($scheduledProfile['filePath'])
->and($manualProfile['laneBindings'])->toBe(['browser'])
->and($scheduledProfile['scheduleCron'])->toBe('43 4 * * 1-5')
->and($workflowContents)->toContain('workflow_dispatch:')
->and($workflowContents)->toContain('schedule:')
->and($workflowContents)->toContain('permissions:')
->and($workflowContents)->toContain('actions: read')
->and($workflowContents)->toContain('contents: read')
->and($workflowContents)->toContain('43 4 * * 1-5')
->and($workflowContents)->toContain("vars.TENANTATLAS_ENABLE_BROWSER_SCHEDULE == '1'")
->and($workflowContents)->toContain('workflow_id=browser-manual')
->and($workflowContents)->toContain('workflow_id=browser-scheduled')
->and($workflowContents)->toContain('./scripts/platform-test-lane browser --workflow-id=${{ steps.context.outputs.workflow_id }} --trigger-class=${{ steps.context.outputs.trigger_class }}')
->and($workflowContents)->toContain('TENANTATLAS_GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}')
->and($workflowContents)->toContain('./scripts/platform-test-report browser --workflow-id=${{ steps.context.outputs.workflow_id }} --trigger-class=${{ steps.context.outputs.trigger_class }} --fetch-latest-history')
->and($workflowContents)->toContain('./scripts/platform-test-artifacts browser .gitea-artifacts/browser --workflow-id=${{ steps.context.outputs.workflow_id }} --trigger-class=${{ steps.context.outputs.trigger_class }}')
->and($workflowContents)->not->toContain('pull_request:', './scripts/platform-test-lane confidence');
});

View File

@ -0,0 +1,73 @@
<?php
declare(strict_types=1);
use Tests\Support\TestLaneBudget;
use Tests\Support\TestLaneReport;
it('classifies wrapper or manifest drift before lower-level lane failures', function (): void {
$primaryFailureClassId = TestLaneReport::classifyPrimaryFailure(
exitCode: 1,
artifactPublicationStatus: ['complete' => true],
budgetOutcome: ['budgetStatus' => 'over-budget'],
entryPointResolved: false,
workflowLaneMatched: false,
);
expect($primaryFailureClassId)->toBe('wrapper-failure');
});
it('keeps confidence budget overruns visible without converting them into blocking failures', function (): void {
$budgetOutcome = TestLaneBudget::evaluateLaneForTrigger('confidence', 'mainline-push', 481.0);
$summary = TestLaneReport::buildCiSummary(
report: [
'laneId' => 'confidence',
'budgetStatus' => 'within-budget',
'ciContext' => ['workflowId' => 'main-confidence'],
],
exitCode: 0,
budgetOutcome: $budgetOutcome,
artifactPublicationStatus: ['complete' => true, 'publishedArtifacts' => []],
);
expect($budgetOutcome['budgetStatus'])->toBe('over-budget')
->and($summary['primaryFailureClassId'])->toBe('budget-breach')
->and($summary['blockingStatus'])->toBe('non-blocking-warning');
});
it('treats mature fast-feedback budget overruns as blocking when they exceed the CI tolerance', function (): void {
$budgetOutcome = TestLaneBudget::evaluateLaneForTrigger('fast-feedback', 'pull-request', 216.0);
$summary = TestLaneReport::buildCiSummary(
report: [
'laneId' => 'fast-feedback',
'budgetStatus' => 'within-budget',
'ciContext' => ['workflowId' => 'pr-fast-feedback'],
],
exitCode: 0,
budgetOutcome: $budgetOutcome,
artifactPublicationStatus: ['complete' => true, 'publishedArtifacts' => []],
);
expect($budgetOutcome['budgetStatus'])->toBe('over-budget')
->and($summary['primaryFailureClassId'])->toBe('budget-breach')
->and($summary['blockingStatus'])->toBe('blocking');
});
it('classifies incomplete artifact bundles independently from test and budget status', function (): void {
$summary = TestLaneReport::buildCiSummary(
report: [
'laneId' => 'fast-feedback',
'budgetStatus' => 'within-budget',
'ciContext' => ['workflowId' => 'pr-fast-feedback'],
],
exitCode: 0,
budgetOutcome: null,
artifactPublicationStatus: [
'complete' => false,
'publishedArtifacts' => [],
],
);
expect($summary['primaryFailureClassId'])->toBe('artifact-publication-failure')
->and($summary['blockingStatus'])->toBe('blocking');
});

View File

@ -0,0 +1,86 @@
<?php
declare(strict_types=1);
use Tests\Support\TestLaneManifest;
use Tests\Support\TestLaneBudget;
it('keeps confidence broader than fast-feedback while excluding browser and moved heavy families', function (): void {
$lane = TestLaneManifest::lane('confidence');
$command = TestLaneManifest::buildCommand('confidence');
$configurationPath = TestLaneManifest::laneConfigurationPath('confidence');
$configurationContents = (string) file_get_contents(TestLaneManifest::absolutePath($configurationPath));
expect($lane['parallelMode'])->toBe('required')
->and($lane['includedFamilies'])->toContain('unit', 'ui-light', 'ui-workflow')
->and($lane['excludedFamilies'])->toContain('browser', 'surface-guard', 'discovery-heavy')
->and($lane['budget']['thresholdSeconds'])->toBeLessThan(TestLaneManifest::fullSuiteBaselineSeconds())
->and($command)->toContain('--parallel')
->and($command)->toContain('--configuration='.$configurationPath)
->and($command)->toContain('--testsuite=Lane')
->and($configurationContents)->toContain('tests/Feature/Baselines/BaselineCompareMatrixCompareAllActionTest.php')
->and($configurationContents)->toContain('tests/Feature/Rbac/OnboardingWizardUiEnforcementTest.php')
->and($configurationContents)->not->toContain('tests/Feature/Drift/DriftBulkAcknowledgeAllMatchingConfirmationTest.php')
->and($configurationContents)->not->toContain('tests/Feature/Filament/BaselineActionAuthorizationTest.php')
->and($configurationContents)->not->toContain('tests/Feature/Filament/BaselineProfileCaptureStartSurfaceTest.php')
->and($configurationContents)->not->toContain('tests/Feature/Filament/BaselineProfileCompareStartSurfaceTest.php')
->and($configurationContents)->not->toContain('tests/Feature/Findings/FindingBulkActionsTest.php')
->and($configurationContents)->not->toContain('tests/Feature/Findings/FindingExceptionRenewalTest.php')
->and($configurationContents)->not->toContain('tests/Feature/Findings/FindingsListFiltersTest.php')
->and($configurationContents)->not->toContain('tests/Feature/Findings/FindingWorkflowRowActionsTest.php')
->and($configurationContents)->not->toContain('tests/Feature/Findings/FindingWorkflowViewActionsTest.php')
->and($configurationContents)->not->toContain('tests/Feature/Filament/WorkspaceOnlySurfaceTenantIndependenceTest.php')
->and($configurationContents)->not->toContain('tests/Feature/SettingsFoundation/WorkspaceSettingsManageTest.php')
->and($configurationContents)->not->toContain('tests/Feature/Guards/ActionSurfaceContractTest.php')
->and($configurationContents)->not->toContain('tests/Feature/Filament/PolicyResourceAdminSearchParityTest.php');
});
it('retains only documented ui-light and selected ui-workflow families in confidence discovery', function (): void {
$files = TestLaneManifest::discoverFiles('confidence');
$confidenceFamilies = TestLaneManifest::familiesByTargetLane('confidence');
expect($files)->toContain(
'tests/Feature/Filament/BackupSetAdminTenantParityTest.php',
'tests/Feature/Baselines/BaselineCompareMatrixCompareAllActionTest.php',
'tests/Feature/Baselines/BaselineCompareMatrixBuilderTest.php',
'tests/Feature/Rbac/OnboardingWizardUiEnforcementTest.php',
)
->and($files)->not->toContain(
'tests/Feature/Drift/DriftBulkAcknowledgeAllMatchingConfirmationTest.php',
'tests/Feature/Filament/BaselineActionAuthorizationTest.php',
'tests/Feature/Filament/BaselineProfileCaptureStartSurfaceTest.php',
'tests/Feature/Filament/BaselineProfileCompareStartSurfaceTest.php',
'tests/Feature/Findings/FindingBulkActionsTest.php',
'tests/Feature/Findings/FindingExceptionRenewalTest.php',
'tests/Feature/Findings/FindingsListFiltersTest.php',
'tests/Feature/Findings/FindingWorkflowRowActionsTest.php',
'tests/Feature/Findings/FindingWorkflowViewActionsTest.php',
'tests/Feature/Guards/ActionSurfaceContractTest.php',
'tests/Feature/Filament/PolicyResourceAdminSearchParityTest.php',
'tests/Feature/Filament/PolicyVersionAdminSearchParityTest.php',
'tests/Feature/Filament/WorkspaceOnlySurfaceTenantIndependenceTest.php',
'tests/Feature/Rbac/BackupItemsRelationManagerUiEnforcementTest.php',
'tests/Feature/Rbac/WorkspaceMembershipsRelationManagerUiEnforcementTest.php',
'tests/Feature/SettingsFoundation/WorkspaceSettingsManageTest.php',
'tests/Feature/Filament/TenantReviewHeaderDisciplineTest.php',
'tests/Feature/Filament/PanelNavigationSegregationTest.php',
);
foreach ($confidenceFamilies as $family) {
expect($family['classificationId'])->toBeIn(['ui-light', 'ui-workflow'])
->and(trim((string) ($family['confidenceRationale'] ?? '')))->not->toBe('');
}
});
it('keeps the dev confidence budget warning-first so runtime drift stays visible without blocking on budget alone', function (): void {
$profile = TestLaneBudget::enforcementProfile('confidence', 'mainline-push');
$withinVariance = TestLaneBudget::evaluateLaneForTrigger('confidence', 'mainline-push', 470.0);
$overBudget = TestLaneBudget::evaluateLaneForTrigger('confidence', 'mainline-push', 481.0);
expect($profile['enforcementMode'])->toBe('soft-warn')
->and($profile['effectiveThresholdSeconds'])->toBe(480)
->and($withinVariance['budgetStatus'])->toBe('warning')
->and($withinVariance['blockingStatus'])->toBe('non-blocking-warning')
->and($overBudget['budgetStatus'])->toBe('over-budget')
->and($overBudget['blockingStatus'])->toBe('non-blocking-warning');
});

View File

@ -0,0 +1,56 @@
<?php
declare(strict_types=1);
use Tests\Support\TestLaneManifest;
use Tests\Support\TestLaneBudget;
it('keeps fast-feedback as the default parallel contributor loop with explicit heavy exclusions', function (): void {
$lane = TestLaneManifest::lane('fast-feedback');
$command = TestLaneManifest::buildCommand('fast-feedback');
$configurationPath = TestLaneManifest::laneConfigurationPath('fast-feedback');
$configurationContents = (string) file_get_contents(TestLaneManifest::absolutePath($configurationPath));
expect($lane['defaultEntryPoint'])->toBeTrue()
->and($lane['parallelMode'])->toBe('required')
->and($lane['includedFamilies'])->toContain('unit', 'ui-light')
->and($lane['excludedFamilies'])->toContain('browser', 'surface-guard', 'discovery-heavy')
->and($lane['budget']['baselineDeltaTargetPercent'])->toBe(50)
->and(TestLaneManifest::commandRef('fast-feedback'))->toBe('test')
->and($command)->toContain('--parallel')
->and($command)->toContain('--configuration='.$configurationPath)
->and($command)->toContain('--testsuite=Lane')
->and($configurationContents)->toContain('tests/Feature/Auth/BreakGlassWorkspaceOwnerRecoveryTest.php')
->and($configurationContents)->not->toContain('tests/Feature/Drift/DriftBulkAcknowledgeAllMatchingConfirmationTest.php')
->and($configurationContents)->not->toContain('tests/Feature/Findings/FindingBulkActionsTest.php')
->and($configurationContents)->not->toContain('tests/Feature/Guards/ActionSurfaceContractTest.php')
->and($configurationContents)->not->toContain('tests/Feature/Filament/PolicyResourceAdminSearchParityTest.php');
});
it('keeps fast-feedback narrower than confidence and rejects broad surface or discovery families', function (): void {
$fastTargets = TestLaneManifest::discoverFiles('fast-feedback');
$confidenceTargets = TestLaneManifest::discoverFiles('confidence');
$validation = TestLaneManifest::validateLanePlacement(
laneId: 'fast-feedback',
filePath: 'tests/Feature/Guards/ActionSurfaceContractTest.php',
);
expect($fastTargets)->not->toBeEmpty()
->and(count($fastTargets))->toBeLessThan(count($confidenceTargets))
->and($fastTargets)->not->toContain('tests/Feature/Guards/ActionSurfaceContractTest.php')
->and($validation['valid'])->toBeFalse()
->and($validation['resolvedClassificationId'])->toBe('surface-guard');
});
it('treats pull-request fast-feedback budget policy as hard-fail only after the documented CI tolerance is exceeded', function (): void {
$profile = TestLaneBudget::enforcementProfile('fast-feedback', 'pull-request');
$withinTolerance = TestLaneBudget::evaluateLaneForTrigger('fast-feedback', 'pull-request', 210.0);
$overTolerance = TestLaneBudget::evaluateLaneForTrigger('fast-feedback', 'pull-request', 216.0);
expect($profile['enforcementMode'])->toBe('hard-fail')
->and($profile['effectiveThresholdSeconds'])->toBe(215)
->and($withinTolerance['budgetStatus'])->toBe('warning')
->and($withinTolerance['blockingStatus'])->toBe('non-blocking-warning')
->and($overTolerance['budgetStatus'])->toBe('over-budget')
->and($overTolerance['blockingStatus'])->toBe('blocking');
});

View File

@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
use Illuminate\Support\Collection;
use Tests\Support\TestLaneManifest;
it('excludes browser, discovery-heavy, and surface-guard families from fast-feedback discovery', function (): void {
$files = collect(TestLaneManifest::discoverFiles('fast-feedback'));
$discoveryValidation = TestLaneManifest::validateLanePlacement(
laneId: 'fast-feedback',
filePath: 'tests/Feature/Filament/PolicyResourceAdminSearchParityTest.php',
);
expect($files->contains(static fn (string $path): bool => str_starts_with($path, 'tests/Browser/')))->toBeFalse()
->and($files->contains(static fn (string $path): bool => str_starts_with($path, 'tests/Feature/OpsUx/')))->toBeFalse()
->and($files)->not->toContain('tests/Feature/Guards/ActionSurfaceContractTest.php')
->and($files->contains(static fn (string $path): bool => str_starts_with($path, 'tests/Architecture/')))->toBeFalse()
->and($files->contains(static fn (string $path): bool => str_starts_with($path, 'tests/Deprecation/')))->toBeFalse()
->and($discoveryValidation['valid'])->toBeFalse()
->and($discoveryValidation['resolvedClassificationId'])->toBe('discovery-heavy');
});
it('keeps fast-feedback focused on the quick-edit families the manifest declares', function (): void {
$files = new Collection(TestLaneManifest::discoverFiles('fast-feedback'));
expect($files->contains(static fn (string $path): bool => str_starts_with($path, 'tests/Unit/')))->toBeTrue()
->and($files->contains(static fn (string $path): bool => str_starts_with($path, 'tests/Feature/Guards/')))->toBeTrue()
->and($files)->not->toContain(
'tests/Feature/Baselines/BaselineCompareMatrixCompareAllActionTest.php',
'tests/Feature/Drift/DriftBulkAcknowledgeAllMatchingConfirmationTest.php',
'tests/Feature/Findings/FindingBulkActionsTest.php',
'tests/Feature/Rbac/OnboardingWizardUiEnforcementTest.php',
);
});

View File

@ -0,0 +1,81 @@
<?php
declare(strict_types=1);
it('keeps the canonical shared tenant helper profile catalog explicit and reviewable', function (): void {
$profiles = createUserWithTenantProfileCatalog();
expect($profiles)->toHaveKeys([
'minimal',
'standard',
'full',
])
->and($profiles['minimal'])->toMatchArray([
'workspace' => true,
'membership' => true,
'session' => true,
'provider' => false,
'credential' => false,
'cache' => false,
'uiContext' => false,
])
->and($profiles['standard'])->toMatchArray([
'workspace' => true,
'membership' => true,
'session' => true,
'provider' => true,
'credential' => false,
'cache' => false,
'uiContext' => false,
])
->and($profiles['full'])->toMatchArray([
'workspace' => true,
'membership' => true,
'session' => true,
'provider' => true,
'credential' => true,
'cache' => true,
'uiContext' => true,
]);
});
it('keeps the temporary legacy fixture aliases explicit and reviewable', function (): void {
$aliases = createUserWithTenantLegacyProfileAliases();
expect($aliases)->toHaveKeys([
'provider-enabled',
'credential-enabled',
'ui-context',
'heavy',
])
->and($aliases['provider-enabled']['profile'])->toBe('standard')
->and($aliases['credential-enabled']['profile'])->toBe('full')
->and($aliases['ui-context']['profile'])->toBe('full')
->and($aliases['heavy']['profile'])->toBe('full')
->and($aliases['provider-enabled']['removalTrigger'])->not->toBe('')
->and($aliases['credential-enabled']['removalTrigger'])->not->toBe('')
->and($aliases['ui-context']['removalTrigger'])->not->toBe('')
->and($aliases['heavy']['removalTrigger'])->not->toBe('');
});
it('resolves legacy aliases to the promised side-effect bundles', function (): void {
$credentialEnabled = resolveCreateUserWithTenantProfile('credential-enabled');
$uiContext = resolveCreateUserWithTenantProfile('ui-context');
expect($credentialEnabled['canonicalProfile'])->toBe('full')
->and($credentialEnabled['legacyAlias'])->toBe('credential-enabled')
->and($credentialEnabled['sideEffects'])->toMatchArray([
'provider' => true,
'credential' => true,
'cache' => false,
'uiContext' => false,
])
->and($uiContext['canonicalProfile'])->toBe('full')
->and($uiContext['legacyAlias'])->toBe('ui-context')
->and($uiContext['sideEffects'])->toMatchArray([
'provider' => false,
'credential' => false,
'cache' => true,
'uiContext' => true,
]);
});

View File

@ -0,0 +1,111 @@
<?php
declare(strict_types=1);
use Tests\Support\TestLaneManifest;
use Tests\Support\TestLaneReport;
it('keeps the shared fixture slimming pre-migration baselines recorded for the standard lanes', function (): void {
$fastFeedback = TestLaneManifest::comparisonBaseline('shared-test-fixture-slimming', 'fast-feedback');
$confidence = TestLaneManifest::comparisonBaseline('shared-test-fixture-slimming', 'confidence');
expect($fastFeedback)->toMatchArray([
'laneId' => 'fast-feedback',
'wallClockSeconds' => 176.73623,
'targetImprovementPercent' => 10,
'maxRegressionPercent' => 5,
])
->and($confidence)->toMatchArray([
'laneId' => 'confidence',
'wallClockSeconds' => 394.383441,
'targetImprovementPercent' => 10,
'maxRegressionPercent' => 5,
]);
});
it('classifies lane-impact comparison status against the recorded fixture slimming baseline', function (): void {
$improved = TestLaneReport::buildReport(
laneId: 'fast-feedback',
wallClockSeconds: 150.0,
slowestEntries: [],
durationsByFile: [],
comparisonProfile: 'shared-test-fixture-slimming',
);
$stable = TestLaneReport::buildReport(
laneId: 'fast-feedback',
wallClockSeconds: 180.0,
slowestEntries: [],
durationsByFile: [],
comparisonProfile: 'shared-test-fixture-slimming',
);
$regressed = TestLaneReport::buildReport(
laneId: 'fast-feedback',
wallClockSeconds: 190.0,
slowestEntries: [],
durationsByFile: [],
comparisonProfile: 'shared-test-fixture-slimming',
);
expect(data_get($improved, 'sharedFixtureSlimmingComparison.status'))->toBe('improved')
->and(data_get($stable, 'sharedFixtureSlimmingComparison.status'))->toBe('stable')
->and(data_get($regressed, 'sharedFixtureSlimmingComparison.status'))->toBe('regressed');
});
it('defines lane, classification, and family budget targets for heavy-governance attribution', function (): void {
$budgetTargets = collect(TestLaneManifest::budgetTargets());
$contract = TestLaneManifest::heavyGovernanceBudgetContract();
$laneBudgetTarget = $budgetTargets
->first(static fn (array $target): bool => $target['targetType'] === 'lane' && $target['targetId'] === 'heavy-governance');
expect($laneBudgetTarget)->not->toBeNull()
->and($contract['summaryThresholdSeconds'])->toBe(300.0)
->and($contract['evaluationThresholdSeconds'])->toBe(200.0)
->and($contract['normalizedThresholdSeconds'])->toBeGreaterThanOrEqual(300.0)
->and($laneBudgetTarget['thresholdSeconds'])->toBe($contract['normalizedThresholdSeconds'])
->and($laneBudgetTarget['lifecycleState'])->toBe($contract['lifecycleState'])
->and($budgetTargets->contains(static fn (array $target): bool => $target['targetType'] === 'classification' && $target['targetId'] === 'surface-guard'))->toBeTrue()
->and($budgetTargets->contains(static fn (array $target): bool => $target['targetType'] === 'classification' && $target['targetId'] === 'discovery-heavy'))->toBeTrue()
->and($budgetTargets->contains(static fn (array $target): bool => $target['targetType'] === 'family' && $target['targetId'] === 'action-surface-contract'))->toBeTrue()
->and($budgetTargets->contains(static fn (array $target): bool => $target['targetType'] === 'family' && $target['targetId'] === 'ops-ux-governance'))->toBeTrue();
});
it('evaluates heavy-governance budgets against named class and family totals', function (): void {
$currentRunContract = TestLaneManifest::heavyGovernanceBudgetContract(110.0);
$durationsByFile = [
'tests/Feature/Guards/ActionSurfaceContractTest.php' => 31.2,
'tests/Feature/Filament/PolicyResourceAdminSearchParityTest.php' => 17.4,
'tests/Feature/Filament/PolicyVersionAdminSearchParityTest.php' => 16.1,
'tests/Feature/Filament/Alerts/AlertsKpiHeaderTest.php' => 9.8,
'tests/Feature/Guards/OperationLifecycleOpsUxGuardTest.php' => 8.7,
];
$slowestEntries = collect($durationsByFile)
->map(static fn (float $seconds, string $file): array => [
'label' => $file.'::synthetic',
'subject' => $file.'::synthetic',
'filePath' => $file,
'durationSeconds' => $seconds,
'wallClockSeconds' => $seconds,
'laneId' => 'heavy-governance',
])
->values()
->all();
$report = TestLaneReport::buildReport(
laneId: 'heavy-governance',
wallClockSeconds: 110.0,
slowestEntries: $slowestEntries,
durationsByFile: $durationsByFile,
);
expect(collect($report['budgetEvaluations'])->pluck('targetType')->unique()->values()->all())
->toEqualCanonicalizing(['lane', 'classification', 'family'])
->and(collect($report['budgetEvaluations'])->contains(static fn (array $evaluation): bool => $evaluation['targetType'] === 'lane' && $evaluation['targetId'] === 'heavy-governance'))->toBeTrue()
->and(collect($report['budgetEvaluations'])->contains(static fn (array $evaluation): bool => $evaluation['targetType'] === 'classification' && $evaluation['targetId'] === 'surface-guard'))->toBeTrue()
->and(collect($report['budgetEvaluations'])->contains(static fn (array $evaluation): bool => $evaluation['targetType'] === 'family' && $evaluation['targetId'] === 'action-surface-contract'))->toBeTrue()
->and($report['budgetContract']['normalizedThresholdSeconds'])->toBe($currentRunContract['normalizedThresholdSeconds'])
->and($report['budgetOutcome']['decisionStatus'])->toBe($currentRunContract['decisionStatus']);
});

View File

@ -0,0 +1,110 @@
<?php
declare(strict_types=1);
use Illuminate\Support\Collection;
use Tests\Support\TestLaneManifest;
use Tests\Support\TestLaneBudget;
it('routes escalated workflow, discovery-heavy, and broad surface-guard families into heavy-governance', function (): void {
$lane = TestLaneManifest::lane('heavy-governance');
$files = new Collection(TestLaneManifest::discoverFiles('heavy-governance'));
expect($lane['includedFamilies'])->toContain('ui-workflow', 'surface-guard', 'discovery-heavy')
->and($files)->toContain(
'tests/Architecture/PlatformVocabularyBoundaryGuardTest.php',
'tests/Architecture/ReasonTranslationPrimarySurfaceGuardTest.php',
'tests/Deprecation/IsPlatformSuperadminDeprecationTest.php',
'tests/Feature/Drift/DriftBulkAcknowledgeAllMatchingConfirmationTest.php',
'tests/Feature/Filament/BaselineActionAuthorizationTest.php',
'tests/Feature/Filament/BaselineProfileCaptureStartSurfaceTest.php',
'tests/Feature/Filament/BaselineProfileCompareStartSurfaceTest.php',
'tests/Feature/Findings/FindingBulkActionsTest.php',
'tests/Feature/Findings/FindingExceptionRenewalTest.php',
'tests/Feature/Findings/FindingsListFiltersTest.php',
'tests/Feature/Findings/FindingWorkflowRowActionsTest.php',
'tests/Feature/Findings/FindingWorkflowViewActionsTest.php',
'tests/Feature/Filament/WorkspaceOnlySurfaceTenantIndependenceTest.php',
'tests/Feature/Guards/ActionSurfaceContractTest.php',
'tests/Feature/Filament/PolicyResourceAdminSearchParityTest.php',
'tests/Feature/Filament/PolicyVersionAdminSearchParityTest.php',
'tests/Feature/Rbac/BackupItemsRelationManagerUiEnforcementTest.php',
'tests/Feature/Rbac/WorkspaceMembershipsRelationManagerUiEnforcementTest.php',
'tests/Feature/SettingsFoundation/WorkspaceSettingsManageTest.php',
'tests/Feature/Filament/TenantReviewHeaderDisciplineTest.php',
'tests/Feature/Filament/PanelNavigationSegregationTest.php',
);
});
it('keeps the heavy-governance command config-driven and free of retained confidence workflows', function (): void {
$command = TestLaneManifest::buildCommand('heavy-governance');
$files = TestLaneManifest::discoverFiles('heavy-governance');
$configurationPath = TestLaneManifest::laneConfigurationPath('heavy-governance');
$configurationContents = (string) file_get_contents(TestLaneManifest::absolutePath($configurationPath));
expect(TestLaneManifest::commandRef('heavy-governance'))->toBe('test:heavy')
->and($command)->toContain('--configuration='.$configurationPath)
->and($command)->toContain('--testsuite=Lane')
->and($configurationContents)->toContain('tests/Feature/Drift/DriftBulkAcknowledgeAllMatchingConfirmationTest.php')
->and($configurationContents)->toContain('tests/Feature/Filament/BaselineActionAuthorizationTest.php')
->and($configurationContents)->toContain('tests/Feature/Filament/BaselineProfileCaptureStartSurfaceTest.php')
->and($configurationContents)->toContain('tests/Feature/Filament/BaselineProfileCompareStartSurfaceTest.php')
->and($configurationContents)->toContain('tests/Feature/Findings/FindingBulkActionsTest.php')
->and($configurationContents)->toContain('tests/Feature/Findings/FindingExceptionRenewalTest.php')
->and($configurationContents)->toContain('tests/Feature/Findings/FindingsListFiltersTest.php')
->and($configurationContents)->toContain('tests/Feature/Findings/FindingWorkflowRowActionsTest.php')
->and($configurationContents)->toContain('tests/Feature/Findings/FindingWorkflowViewActionsTest.php')
->and($configurationContents)->toContain('tests/Feature/Filament/WorkspaceOnlySurfaceTenantIndependenceTest.php')
->and($configurationContents)->toContain('tests/Feature/SettingsFoundation/WorkspaceSettingsManageTest.php')
->and($configurationContents)->toContain('tests/Feature/Guards/ActionSurfaceContractTest.php')
->and($configurationContents)->toContain('tests/Feature/Filament/PolicyResourceAdminSearchParityTest.php')
->and($files)->not->toContain(
'tests/Feature/Baselines/BaselineCompareMatrixCompareAllActionTest.php',
'tests/Feature/Baselines/BaselineCompareMatrixBuilderTest.php',
'tests/Feature/Rbac/OnboardingWizardUiEnforcementTest.php',
'tests/Feature/Filament/BackupSetAdminTenantParityTest.php',
);
});
it('keeps heavy-governance hotspot ownership honest across slimmed, retained, and follow-up families', function (): void {
$inventory = collect(TestLaneManifest::heavyGovernanceHotspotInventory())->keyBy('familyId');
$decomposition = collect(TestLaneManifest::heavyGovernanceDecompositionRecords())->keyBy('familyId');
$decisions = collect(TestLaneManifest::heavyGovernanceSlimmingDecisions())->keyBy('familyId');
$outcome = TestLaneManifest::heavyGovernanceBudgetOutcome();
expect($inventory->keys()->all())->toEqual([
'baseline-profile-start-surfaces',
'action-surface-contract',
'ops-ux-governance',
'findings-workflow-surfaces',
'finding-bulk-actions-workflow',
'workspace-settings-slice-management',
])
->and($inventory->get('baseline-profile-start-surfaces')['status'])->toBe('slimmed')
->and($inventory->get('findings-workflow-surfaces')['status'])->toBe('slimmed')
->and($inventory->get('finding-bulk-actions-workflow')['status'])->toBe('slimmed')
->and($inventory->get('action-surface-contract')['status'])->toBe('retained')
->and($inventory->get('ops-ux-governance')['status'])->toBe('retained')
->and($inventory->get('workspace-settings-slice-management')['status'])->toBe('follow-up')
->and($decomposition->get('finding-bulk-actions-workflow')['recommendedAction'])->toBe('narrow-assertions')
->and($decisions->get('finding-bulk-actions-workflow')['decisionType'])->toBe('trim-duplicate-work')
->and($decisions->get('workspace-settings-slice-management')['decisionType'])->toBe('follow-up')
->and($outcome['remainingOpenFamilies'])->toEqualCanonicalizing([
'action-surface-contract',
'ops-ux-governance',
'workspace-settings-slice-management',
]);
});
it('keeps heavy-governance manual runs warning-first and scheduled runs trend-only while schedules mature', function (): void {
$manualProfile = TestLaneBudget::enforcementProfile('heavy-governance', 'manual');
$scheduledProfile = TestLaneBudget::enforcementProfile('heavy-governance', 'scheduled');
$manualEvaluation = TestLaneBudget::evaluateLaneForTrigger('heavy-governance', 'manual', (float) $manualProfile['effectiveThresholdSeconds'] + 1.0);
$scheduledEvaluation = TestLaneBudget::evaluateLaneForTrigger('heavy-governance', 'scheduled', (float) $scheduledProfile['effectiveThresholdSeconds'] + 1.0);
expect($manualProfile['thresholdSource'])->toBe('governance-contract')
->and($manualProfile['enforcementMode'])->toBe('soft-warn')
->and($scheduledProfile['enforcementMode'])->toBe('trend-only')
->and($manualEvaluation['blockingStatus'])->toBe('non-blocking-warning')
->and($scheduledEvaluation['blockingStatus'])->toBe('informational');
});

View File

@ -4,6 +4,16 @@
use App\Services\Operations\OperationLifecyclePolicyValidator; use App\Services\Operations\OperationLifecyclePolicyValidator;
use Tests\Support\OpsUx\SourceFileScanner; use Tests\Support\OpsUx\SourceFileScanner;
use Tests\Support\TestLaneManifest;
it('keeps ops ux governance retained in the heavy-governance inventory', function (): void {
$inventoryRecord = collect(TestLaneManifest::heavyGovernanceHotspotInventory())
->firstWhere('familyId', 'ops-ux-governance');
expect($inventoryRecord)->not->toBeNull()
->and($inventoryRecord['classificationId'])->toBe('surface-guard')
->and($inventoryRecord['status'])->toBe('retained');
});
it('keeps lifecycle bridge ownership and initiator-null notification discipline intact', function (): void { it('keeps lifecycle bridge ownership and initiator-null notification discipline intact', function (): void {
$validator = app(OperationLifecyclePolicyValidator::class); $validator = app(OperationLifecyclePolicyValidator::class);

View File

@ -0,0 +1,78 @@
<?php
declare(strict_types=1);
use Tests\Support\TestLaneManifest;
use Tests\Support\TestLaneReport;
it('keeps profiling serial and artifact-rich for slow-test drift analysis', function (): void {
$lane = TestLaneManifest::lane('profiling');
$command = TestLaneManifest::buildCommand('profiling');
$configurationPath = TestLaneManifest::laneConfigurationPath('profiling');
$configurationContents = (string) file_get_contents(TestLaneManifest::absolutePath($configurationPath));
expect($lane['governanceClass'])->toBe('support')
->and($lane['parallelMode'])->toBe('forbidden')
->and($lane['artifacts'])->toContain('profile-top', 'junit-xml', 'summary', 'budget-report')
->and($command)->toContain('--profile')
->and($command)->not->toContain('--parallel')
->and($command)->toContain('--configuration='.$configurationPath)
->and($configurationContents)->toContain('tests/Feature/Filament/PolicyResourceAdminSearchParityTest.php')
->and($configurationContents)->not->toContain('tests/Browser/Spec190BaselineCompareMatrixSmokeTest.php');
});
it('builds top 10 attribution-rich profiling reports for mixed workflow and governance files', function (): void {
$durationsByFile = [
'tests/Feature/Baselines/BaselineCompareMatrixCompareAllActionTest.php' => 22.4,
'tests/Feature/Baselines/BaselineCompareMatrixBuilderTest.php' => 20.1,
'tests/Feature/Rbac/OnboardingWizardUiEnforcementTest.php' => 18.5,
'tests/Feature/Guards/ActionSurfaceContractTest.php' => 17.3,
'tests/Feature/Filament/PolicyResourceAdminSearchParityTest.php' => 16.2,
'tests/Feature/Filament/PolicyVersionAdminSearchParityTest.php' => 15.1,
'tests/Feature/Rbac/BackupItemsRelationManagerUiEnforcementTest.php' => 13.7,
'tests/Feature/Rbac/WorkspaceMembershipsRelationManagerUiEnforcementTest.php' => 12.8,
'tests/Feature/Filament/TenantReviewHeaderDisciplineTest.php' => 11.4,
'tests/Feature/Filament/BackupSetAdminTenantParityTest.php' => 9.6,
];
$slowestEntries = collect($durationsByFile)
->map(static fn (float $seconds, string $file): array => [
'label' => $file.'::synthetic',
'subject' => $file.'::synthetic',
'filePath' => $file,
'durationSeconds' => $seconds,
'wallClockSeconds' => $seconds,
'laneId' => 'profiling',
])
->values()
->all();
$report = TestLaneReport::buildReport(
laneId: 'profiling',
wallClockSeconds: 181.7,
slowestEntries: $slowestEntries,
durationsByFile: $durationsByFile,
);
expect($report['slowestEntries'])->toHaveCount(10)
->and($report['slowestEntries'][0]['wallClockSeconds'])->toBeGreaterThanOrEqual($report['slowestEntries'][1]['wallClockSeconds'])
->and(collect($report['classificationAttribution'])->pluck('classificationId')->all())
->toContain('ui-light', 'ui-workflow', 'surface-guard', 'discovery-heavy')
->and(collect($report['familyAttribution'])->pluck('familyId')->all())
->toContain('baseline-compare-matrix-workflow', 'action-surface-contract', 'backup-set-admin-tenant-parity');
});
it('keeps heavy-governance snapshot artifact paths rooted in the canonical lane directory', function (): void {
$report = TestLaneReport::buildReport(
laneId: 'heavy-governance',
wallClockSeconds: 140.0,
slowestEntries: [],
durationsByFile: [],
);
expect($report['budgetSnapshots'])->toHaveCount(2)
->and($report['budgetSnapshots'][0]['artifactPaths']['summary'])->toStartWith('storage/logs/test-lanes/heavy-governance-')
->and($report['budgetSnapshots'][1]['artifactPaths']['summary'])->toBe('storage/logs/test-lanes/heavy-governance-latest.summary.md')
->and($report['budgetSnapshots'][1]['artifactPaths']['budget'])->toBe('storage/logs/test-lanes/heavy-governance-latest.budget.json')
->and($report['budgetSnapshots'][1]['artifactPaths']['report'])->toBe('storage/logs/test-lanes/heavy-governance-latest.report.json');
});

View File

@ -0,0 +1,196 @@
<?php
declare(strict_types=1);
use Tests\Support\TestLaneManifest;
use Tests\Support\TestLaneReport;
function heavyGovernanceSyntheticHotspots(): array
{
return [
['file' => 'tests/Feature/Filament/BaselineProfileCaptureStartSurfaceTest.php', 'seconds' => 22.4],
['file' => 'tests/Feature/Filament/BaselineProfileCompareStartSurfaceTest.php', 'seconds' => 21.1],
['file' => 'tests/Feature/Filament/BaselineActionAuthorizationTest.php', 'seconds' => 19.5],
['file' => 'tests/Feature/Findings/FindingBulkActionsTest.php', 'seconds' => 18.2],
['file' => 'tests/Feature/Findings/FindingWorkflowRowActionsTest.php', 'seconds' => 14.8],
['file' => 'tests/Feature/Findings/FindingWorkflowViewActionsTest.php', 'seconds' => 13.6],
['file' => 'tests/Feature/SettingsFoundation/WorkspaceSettingsManageTest.php', 'seconds' => 12.7],
['file' => 'tests/Feature/Guards/ActionSurfaceContractTest.php', 'seconds' => 11.9],
['file' => 'tests/Feature/Guards/OperationLifecycleOpsUxGuardTest.php', 'seconds' => 10.4],
['file' => 'tests/Feature/Filament/PolicyResourceAdminSearchParityTest.php', 'seconds' => 4.2],
];
}
it('keeps lane artifact paths app-root relative under storage/logs/test-lanes', function (): void {
$artifacts = TestLaneReport::artifactPaths('fast-feedback');
$artifactContract = TestLaneManifest::artifactPublicationContract('fast-feedback');
expect($artifacts)->toHaveKeys(['junit', 'summary', 'budget', 'report', 'profile', 'trendHistory']);
expect($artifactContract['requiredFiles'])->toEqualCanonicalizing(['summary.md', 'budget.json', 'report.json', 'junit.xml', 'trend-history.json'])
->and($artifactContract['stagedNamePattern'])->toBe('{laneId}.{artifactFile}');
foreach (array_values($artifacts) as $relativePath) {
expect($relativePath)->toStartWith('storage/logs/test-lanes/');
}
});
it('keeps only the skeleton file checked into the lane artifact directory', function (): void {
$gitignore = base_path('storage/logs/test-lanes/.gitignore');
expect(file_exists($gitignore))->toBeTrue()
->and((string) file_get_contents($gitignore))->toContain('*')
->and((string) file_get_contents($gitignore))->toContain('!.gitignore');
});
it('publishes heavy attribution, contract, and coverage payloads under the canonical artifact root', function (): void {
$durationsByFile = collect(heavyGovernanceSyntheticHotspots())
->mapWithKeys(static fn (array $entry): array => [$entry['file'] => $entry['seconds']])
->all();
$slowestEntries = collect(heavyGovernanceSyntheticHotspots())
->map(static fn (array $entry): array => [
'label' => $entry['file'].'::synthetic',
'subject' => $entry['file'].'::synthetic',
'filePath' => $entry['file'],
'durationSeconds' => $entry['seconds'],
'wallClockSeconds' => $entry['seconds'],
'laneId' => 'heavy-governance',
])
->values()
->all();
$report = TestLaneReport::buildReport(
laneId: 'heavy-governance',
wallClockSeconds: 118.4,
slowestEntries: $slowestEntries,
durationsByFile: $durationsByFile,
);
expect($report['artifactDirectory'])->toBe('storage/logs/test-lanes')
->and($report['slowestEntries'])->toHaveCount(10)
->and($report)->toHaveKeys([
'artifactPublicationContract',
'knownWorkflowProfiles',
'failureClasses',
'trendHistoryArtifact',
'trendCurrentAssessment',
'trendHotspotSnapshot',
'trendRecalibrationDecisions',
'trendWarnings',
'budgetContract',
'hotspotInventory',
'decompositionRecords',
'slimmingDecisions',
'authorGuidance',
'inventoryCoverage',
'budgetSnapshots',
'budgetOutcome',
'remainingOpenFamilies',
'stabilizedFamilies',
])
->and(collect($report['classificationAttribution'])->pluck('classificationId')->all())
->toContain('ui-workflow', 'surface-guard', 'discovery-heavy')
->and(collect($report['familyAttribution'])->pluck('familyId')->all())
->toContain(
'baseline-profile-start-surfaces',
'findings-workflow-surfaces',
'finding-bulk-actions-workflow',
'workspace-settings-slice-management',
'action-surface-contract',
'ops-ux-governance',
)
->and(collect($report['budgetEvaluations'])->pluck('targetType')->unique()->values()->all())
->toEqualCanonicalizing(['lane', 'classification', 'family'])
->and($report['familyBudgetEvaluations'])->not->toBeEmpty()
->and($report['inventoryCoverage']['meetsInclusionRule'])->toBeTrue()
->and($report['inventoryCoverage']['inventoryFamilyCount'])->toBe(6)
->and($report['budgetSnapshots'])->toHaveCount(2)
->and($report['budgetOutcome'])->toHaveKeys([
'decisionStatus',
'finalThresholdSeconds',
'remainingOpenFamilies',
'followUpDebt',
])
->and($report['knownWorkflowProfiles'])->toContain('heavy-governance-manual', 'heavy-governance-scheduled');
});
it('stages deterministic CI artifact bundles from the canonical lane outputs', function (): void {
$artifactDirectory = 'storage/logs/test-lanes/contract-stage-test';
$stagingDirectory = base_path('storage/logs/test-lanes/contract-stage-test/staged');
$report = TestLaneReport::buildReport(
laneId: 'fast-feedback',
wallClockSeconds: 182.4,
slowestEntries: [],
durationsByFile: [],
artifactDirectory: $artifactDirectory,
ciContext: [
'workflowId' => 'pr-fast-feedback',
'triggerClass' => 'pull-request',
'entryPointResolved' => true,
'workflowLaneMatched' => true,
],
);
TestLaneReport::writeArtifacts(
laneId: 'fast-feedback',
report: $report,
profileOutput: null,
artifactDirectory: $artifactDirectory,
exitCode: 0,
);
$artifactPaths = TestLaneReport::artifactPaths('fast-feedback', $artifactDirectory);
file_put_contents(
TestLaneManifest::absolutePath($artifactPaths['junit']),
<<<'XML'
<?xml version="1.0" encoding="UTF-8"?>
<testsuites>
<testsuite name="fast-feedback" tests="1" assertions="1" errors="0" failures="0" skipped="0" time="0.1">
<testcase name="synthetic" file="tests/Feature/Guards/TestLaneArtifactsContractTest.php" time="0.1" />
</testsuite>
</testsuites>
XML,
);
$stagingResult = TestLaneReport::stageArtifacts(
laneId: 'fast-feedback',
stagingDirectory: $stagingDirectory,
artifactDirectory: $artifactDirectory,
);
expect($stagingResult['complete'])->toBeTrue()
->and(collect($stagingResult['stagedArtifacts'])->pluck('artifactType')->all())
->toEqualCanonicalizing(['summary.md', 'budget.json', 'report.json', 'junit.xml', 'trend-history.json'])
->and(collect($stagingResult['stagedArtifacts'])->pluck('relativePath')->all())
->toContain(
$stagingDirectory.'/fast-feedback.summary.md',
$stagingDirectory.'/fast-feedback.budget.json',
$stagingDirectory.'/fast-feedback.report.json',
$stagingDirectory.'/fast-feedback.junit.xml',
$stagingDirectory.'/fast-feedback.trend-history.json',
);
});
it('publishes the shared fixture slimming comparison only for the governed standard lanes', function (): void {
$fastFeedback = TestLaneReport::buildReport(
laneId: 'fast-feedback',
wallClockSeconds: 176.73623,
slowestEntries: [],
durationsByFile: [],
comparisonProfile: 'shared-test-fixture-slimming',
);
$heavyGovernance = TestLaneReport::buildReport(
laneId: 'heavy-governance',
wallClockSeconds: 83.66,
slowestEntries: [],
durationsByFile: [],
comparisonProfile: 'shared-test-fixture-slimming',
);
expect($fastFeedback)->toHaveKey('sharedFixtureSlimmingComparison')
->and($heavyGovernance)->not->toHaveKey('sharedFixtureSlimmingComparison');
});

View File

@ -0,0 +1,130 @@
<?php
declare(strict_types=1);
use Tests\Support\TestLaneManifest;
it('keeps composer lane commands wired to the checked-in lane inventory', function (): void {
$composer = json_decode((string) file_get_contents(base_path('composer.json')), true, 512, JSON_THROW_ON_ERROR);
$scripts = $composer['scripts'] ?? [];
expect($scripts)->toHaveKeys([
'test',
'test:fast',
'test:confidence',
'test:browser',
'test:heavy',
'test:profile',
'test:junit',
'test:report',
'test:report:confidence',
'test:report:browser',
'test:report:heavy',
'test:report:profile',
'test:report:junit',
'sail:test',
])
->and(TestLaneManifest::commandRef('fast-feedback'))->toBe('test')
->and(TestLaneManifest::commandRef('confidence'))->toBe('test:confidence')
->and(TestLaneManifest::commandRef('browser'))->toBe('test:browser')
->and(TestLaneManifest::commandRef('heavy-governance'))->toBe('test:heavy')
->and(TestLaneManifest::commandRef('profiling'))->toBe('test:profile')
->and(TestLaneManifest::commandRef('junit'))->toBe('test:junit');
});
it('keeps the host-side lane runner and report scripts checked in at repo root', function (): void {
expect(file_exists(repo_path('scripts/platform-test-lane')))->toBeTrue()
->and(file_exists(repo_path('scripts/platform-test-report')))->toBeTrue()
->and(file_exists(repo_path('scripts/platform-test-artifacts')))->toBeTrue();
});
it('keeps heavy-governance baseline capture support inside the checked-in wrappers', function (): void {
$laneRunner = (string) file_get_contents(repo_path('scripts/platform-test-lane'));
$reportRunner = (string) file_get_contents(repo_path('scripts/platform-test-report'));
expect($laneRunner)->toContain('--capture-baseline', 'copy_heavy_baseline_artifacts', 'heavy-governance-baseline.${suffix}')
->and($reportRunner)->toContain('--capture-baseline', 'copy_heavy_baseline_artifacts', 'heavy-governance-baseline.${suffix}')
->and($reportRunner)->toContain('junit)', 'test:report:junit')
->and($reportRunner)->toContain('--history-file=')
->and($reportRunner)->toContain('--history-bundle=')
->and($reportRunner)->toContain('--fetch-latest-history')
->and($reportRunner)->toContain('TENANTATLAS_GITEA_TOKEN')
->and($reportRunner)->toContain('trend-history.json');
});
it('avoids expanding an empty forwarded-argument array in the lane runner', function (): void {
$laneRunner = (string) file_get_contents(repo_path('scripts/platform-test-lane'));
expect($laneRunner)
->toContain('if [[ ${#remaining_args[@]} -gt 0 ]]; then')
->and($laneRunner)->toContain('./vendor/bin/sail composer run --timeout=0 "${COMPOSER_SCRIPT}" -- "${remaining_args[@]}"')
->and($laneRunner)->toContain('./vendor/bin/sail composer run --timeout=0 "${COMPOSER_SCRIPT}"')
->and($laneRunner)->toContain('--workflow-id=')
->and($laneRunner)->toContain('--trigger-class=');
});
it('keeps CI workflow validation and wrong-lane drift detection in the checked-in manifest', function (): void {
$validExecution = TestLaneManifest::validateWorkflowExecution('pr-fast-feedback', 'fast-feedback');
$wrongLaneExecution = TestLaneManifest::validateWorkflowExecution('pr-fast-feedback', 'confidence');
expect($validExecution['valid'])->toBeTrue()
->and($validExecution['primaryFailureClassId'])->toBeNull()
->and($wrongLaneExecution['valid'])->toBeFalse()
->and($wrongLaneExecution['workflowLaneMatched'])->toBeFalse()
->and($wrongLaneExecution['primaryFailureClassId'])->toBe('wrapper-failure');
});
it('degrades invalid CI workflow context to wrapper-failure metadata instead of crashing the lane context lookup', function (): void {
$originalWorkflowId = getenv('TENANTATLAS_CI_WORKFLOW_ID');
$originalTriggerClass = getenv('TENANTATLAS_CI_TRIGGER_CLASS');
putenv('TENANTATLAS_CI_WORKFLOW_ID=missing-workflow-profile');
putenv('TENANTATLAS_CI_TRIGGER_CLASS=pull-request');
try {
$context = TestLaneManifest::currentCiContext('fast-feedback');
expect($context['entryPointResolved'])->toBeFalse()
->and($context['workflowLaneMatched'])->toBeFalse()
->and($context['primaryFailureClassId'])->toBe('wrapper-failure')
->and($context['unresolvedEntryPoints'])->toContain('workflow-profile');
} finally {
putenv($originalWorkflowId === false ? 'TENANTATLAS_CI_WORKFLOW_ID' : sprintf('TENANTATLAS_CI_WORKFLOW_ID=%s', $originalWorkflowId));
putenv($originalTriggerClass === false ? 'TENANTATLAS_CI_TRIGGER_CLASS' : sprintf('TENANTATLAS_CI_TRIGGER_CLASS=%s', $originalTriggerClass));
}
});
it('routes the foundational lane commands through stable artisan arguments', function (): void {
$fastFeedbackConfig = TestLaneManifest::laneConfigurationPath('fast-feedback');
$fastFeedbackContents = (string) file_get_contents(TestLaneManifest::absolutePath($fastFeedbackConfig));
$confidenceConfig = TestLaneManifest::laneConfigurationPath('confidence');
$confidenceContents = (string) file_get_contents(TestLaneManifest::absolutePath($confidenceConfig));
$browserConfig = TestLaneManifest::laneConfigurationPath('browser');
$browserContents = (string) file_get_contents(TestLaneManifest::absolutePath($browserConfig));
$heavyConfig = TestLaneManifest::laneConfigurationPath('heavy-governance');
$heavyContents = (string) file_get_contents(TestLaneManifest::absolutePath($heavyConfig));
expect(TestLaneManifest::buildCommand('fast-feedback'))->toContain('--parallel')
->and(TestLaneManifest::buildCommand('fast-feedback'))->toContain('--configuration='.$fastFeedbackConfig)
->and($fastFeedbackContents)->not->toContain('tests/Feature/Drift/DriftBulkAcknowledgeAllMatchingConfirmationTest.php')
->and($fastFeedbackContents)->not->toContain('tests/Feature/Findings/FindingBulkActionsTest.php')
->and($fastFeedbackContents)->not->toContain('tests/Feature/Guards/ActionSurfaceContractTest.php')
->and(TestLaneManifest::buildCommand('confidence'))->toContain('--configuration='.$confidenceConfig)
->and($confidenceContents)->toContain('tests/Feature/Baselines/BaselineCompareMatrixCompareAllActionTest.php')
->and($confidenceContents)->not->toContain('tests/Feature/Drift/DriftBulkAcknowledgeAllMatchingConfirmationTest.php')
->and($confidenceContents)->not->toContain('tests/Feature/Filament/BaselineActionAuthorizationTest.php')
->and($confidenceContents)->not->toContain('tests/Feature/Filament/BaselineProfileCaptureStartSurfaceTest.php')
->and($confidenceContents)->not->toContain('tests/Feature/Filament/BaselineProfileCompareStartSurfaceTest.php')
->and($confidenceContents)->not->toContain('tests/Feature/Findings/FindingBulkActionsTest.php')
->and($confidenceContents)->not->toContain('tests/Feature/Guards/ActionSurfaceContractTest.php')
->and(TestLaneManifest::buildCommand('browser'))->toContain('--configuration='.$browserConfig)
->and($browserContents)->toContain('tests/Browser/Spec190BaselineCompareMatrixSmokeTest.php')
->and(TestLaneManifest::buildCommand('heavy-governance'))->toContain('--configuration='.$heavyConfig)
->and($heavyContents)->toContain('tests/Feature/Drift/DriftBulkAcknowledgeAllMatchingConfirmationTest.php')
->and($heavyContents)->toContain('tests/Feature/Filament/BaselineActionAuthorizationTest.php')
->and($heavyContents)->toContain('tests/Feature/Filament/BaselineProfileCaptureStartSurfaceTest.php')
->and($heavyContents)->toContain('tests/Feature/Filament/BaselineProfileCompareStartSurfaceTest.php')
->and($heavyContents)->toContain('tests/Feature/Findings/FindingBulkActionsTest.php')
->and($heavyContents)->toContain('tests/Feature/Guards/ActionSurfaceContractTest.php')
->and(TestLaneManifest::buildCommand('junit'))->toContain('--parallel');
});

View File

@ -0,0 +1,107 @@
<?php
declare(strict_types=1);
use Tests\Support\TestLaneManifest;
use Tests\Support\TestLaneReport;
use Tests\Support\TestLaneTrendFixtures;
it('hydrates an explicit prior history file into the canonical lane artifact path', function (): void {
$sourceArtifactDirectory = TestLaneTrendFixtures::artifactDirectory('trend-history-hydration/source');
$targetArtifactDirectory = TestLaneTrendFixtures::artifactDirectory('trend-history-hydration/target');
$report = TestLaneTrendFixtures::buildReport(
laneId: 'fast-feedback',
wallClockSeconds: 182.4,
durationsByFile: [
'tests/Feature/Guards/TestLaneHistoryHydrationContractTest.php' => 14.2,
'tests/Feature/Guards/TestLaneArtifactsContractTest.php' => 9.8,
],
artifactDirectory: $sourceArtifactDirectory,
ciContext: [
'workflowId' => 'pr-fast-feedback',
'triggerClass' => 'pull-request',
'entryPointResolved' => true,
'workflowLaneMatched' => true,
],
comparisonProfile: 'shared-test-fixture-slimming',
);
$sourcePath = TestLaneManifest::absolutePath($sourceArtifactDirectory.'/seeded-fast-feedback.trend-history.json');
if (! is_dir(dirname($sourcePath))) {
mkdir(dirname($sourcePath), 0777, true);
}
file_put_contents($sourcePath, json_encode($report['trendHistoryArtifact'], JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR));
$result = TestLaneReport::hydrateTrendHistory(
laneId: 'fast-feedback',
historyFile: $sourcePath,
artifactDirectory: $targetArtifactDirectory,
);
$hydratedArtifact = TestLaneTrendFixtures::readTrendHistory('fast-feedback', $targetArtifactDirectory);
expect($result['hydrated'])->toBeTrue()
->and($result['sourceType'])->toBe('history-file')
->and($hydratedArtifact['laneId'])->toBe('fast-feedback')
->and($hydratedArtifact['workflowProfile'])->toBe('pr-fast-feedback')
->and($hydratedArtifact['history'][0]['artifactRefs']['trendHistory'])->toBe($sourceArtifactDirectory.'/fast-feedback-latest.trend-history.json');
});
it('hydrates staged bundle directories and zip bundles using the canonical trend-history artifact name', function (): void {
$sourceArtifactDirectory = TestLaneTrendFixtures::artifactDirectory('trend-history-hydration/bundle-source');
$bundleDirectory = TestLaneManifest::absolutePath($sourceArtifactDirectory.'/bundle');
$targetArtifactDirectory = TestLaneTrendFixtures::artifactDirectory('trend-history-hydration/bundle-target');
$report = TestLaneTrendFixtures::buildReport(
laneId: 'confidence',
wallClockSeconds: 431.2,
durationsByFile: [
'tests/Feature/Baselines/BaselineCompareMatrixCompareAllActionTest.php' => 24.7,
'tests/Feature/Baselines/BaselineCompareMatrixBuilderTest.php' => 20.4,
],
artifactDirectory: $sourceArtifactDirectory,
ciContext: [
'workflowId' => 'main-confidence',
'triggerClass' => 'mainline-push',
'entryPointResolved' => true,
'workflowLaneMatched' => true,
],
);
if (! is_dir($bundleDirectory)) {
mkdir($bundleDirectory, 0777, true);
}
file_put_contents(
$bundleDirectory.'/confidence.trend-history.json',
json_encode($report['trendHistoryArtifact'], JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR),
);
$directoryResult = TestLaneReport::hydrateTrendHistory(
laneId: 'confidence',
bundlePath: $bundleDirectory,
artifactDirectory: $targetArtifactDirectory,
);
$zipPath = $bundleDirectory.'/confidence-artifacts.zip';
$zip = new ZipArchive();
$zip->open($zipPath, ZipArchive::CREATE | ZipArchive::OVERWRITE);
$zip->addFile($bundleDirectory.'/confidence.trend-history.json', 'confidence.trend-history.json');
$zip->close();
$zipResult = TestLaneReport::hydrateTrendHistory(
laneId: 'confidence',
bundlePath: $zipPath,
artifactDirectory: $targetArtifactDirectory,
);
$hydratedArtifact = TestLaneTrendFixtures::readTrendHistory('confidence', $targetArtifactDirectory);
expect($directoryResult['hydrated'])->toBeTrue()
->and($directoryResult['sourceType'])->toBe('bundle-directory')
->and($zipResult['hydrated'])->toBeTrue()
->and($zipResult['sourceType'])->toBe('bundle-zip')
->and($hydratedArtifact['laneId'])->toBe('confidence')
->and($hydratedArtifact['history'][0]['workflowId'])->toBe('main-confidence');
});

View File

@ -0,0 +1,94 @@
<?php
declare(strict_types=1);
use Tests\Support\TestLaneManifest;
use Tests\Support\TestLaneReport;
use Tests\Support\TestLaneTrendFixtures;
it('surfaces top family and file hotspot deltas together with new and dropped hotspot detection', function (): void {
$artifactDirectory = TestLaneTrendFixtures::artifactDirectory('trend-hotspots/available');
$previousDurations = [
'tests/Feature/Baselines/BaselineCompareMatrixCompareAllActionTest.php' => 24.0,
'tests/Feature/Baselines/BaselineCompareMatrixBuilderTest.php' => 18.0,
'tests/Feature/Rbac/OnboardingWizardUiEnforcementTest.php' => 12.0,
];
$currentDurations = [
'tests/Feature/Baselines/BaselineCompareMatrixCompareAllActionTest.php' => 41.0,
'tests/Feature/Filament/BackupSetAdminTenantParityTest.php' => 15.0,
];
$previousReport = TestLaneTrendFixtures::buildReport(
laneId: 'confidence',
wallClockSeconds: 424.0,
durationsByFile: $previousDurations,
artifactDirectory: $artifactDirectory,
ciContext: [
'workflowId' => 'main-confidence',
'triggerClass' => 'mainline-push',
'entryPointResolved' => true,
'workflowLaneMatched' => true,
],
comparisonProfile: 'shared-test-fixture-slimming',
);
TestLaneTrendFixtures::writeTrendHistory('confidence', $previousReport['trendHistoryArtifact'], $artifactDirectory);
$report = TestLaneTrendFixtures::buildReport(
laneId: 'confidence',
wallClockSeconds: 438.0,
durationsByFile: $currentDurations,
artifactDirectory: $artifactDirectory,
ciContext: [
'workflowId' => 'main-confidence',
'triggerClass' => 'mainline-push',
'entryPointResolved' => true,
'workflowLaneMatched' => true,
],
comparisonProfile: 'shared-test-fixture-slimming',
);
$hotspotSnapshot = $report['trendHotspotSnapshot'];
expect($hotspotSnapshot['evidenceAvailability'])->toBe('available')
->and($hotspotSnapshot['familyDeltas'])->not->toBeEmpty()
->and(collect($hotspotSnapshot['familyDeltas'])->pluck('name')->all())
->toContain('baseline-compare-matrix-workflow', 'backup-set-admin-tenant-parity')
->and(collect($hotspotSnapshot['fileHotspots'])->pluck('name')->all())
->toContain(
'tests/Feature/Baselines/BaselineCompareMatrixCompareAllActionTest.php',
'tests/Feature/Filament/BackupSetAdminTenantParityTest.php',
)
->and($hotspotSnapshot['newEntrants'])->toContain('tests/Feature/Filament/BackupSetAdminTenantParityTest.php')
->and($hotspotSnapshot['droppedEntrants'])->toContain('tests/Feature/Rbac/OnboardingWizardUiEnforcementTest.php');
});
it('discloses unavailable hotspot evidence instead of silently omitting the hotspot section', function (): void {
$artifactDirectory = TestLaneTrendFixtures::artifactDirectory('trend-hotspots/unavailable');
$report = TestLaneTrendFixtures::buildReport(
laneId: 'fast-feedback',
wallClockSeconds: 189.0,
durationsByFile: [],
artifactDirectory: $artifactDirectory,
ciContext: [
'workflowId' => 'pr-fast-feedback',
'triggerClass' => 'pull-request',
'entryPointResolved' => true,
'workflowLaneMatched' => true,
],
comparisonProfile: 'shared-test-fixture-slimming',
);
TestLaneReport::writeArtifacts(
laneId: 'fast-feedback',
report: $report,
artifactDirectory: $artifactDirectory,
);
$summary = (string) file_get_contents(TestLaneManifest::absolutePath(
TestLaneReport::artifactPaths('fast-feedback', $artifactDirectory)['summary'],
));
expect($report['trendHotspotSnapshot']['evidenceAvailability'])->toBe('unavailable')
->and($report['trendWarnings'])->toContain('Hotspot evidence is unavailable for this cycle.')
->and($summary)->toContain('Hotspot evidence: unavailable');
});

View File

@ -0,0 +1,198 @@
<?php
declare(strict_types=1);
use Tests\Support\TestLaneManifest;
it('declares the six checked-in lanes with a single fast-feedback default and the spec 208 plus 209 metadata surfaces', function (): void {
$manifest = TestLaneManifest::manifest();
$laneIds = array_column($manifest['lanes'], 'id');
$defaultLanes = array_values(array_filter(
$manifest['lanes'],
static fn (array $lane): bool => $lane['defaultEntryPoint'] === true,
));
expect($manifest['version'])->toBe(3)
->and($manifest['artifactDirectory'])->toBe('storage/logs/test-lanes')
->and($manifest['mainlineBranch'])->toBe('dev')
->and($manifest)->toHaveKeys([
'classifications',
'families',
'mixedFileResolutions',
'placementRules',
'driftGuards',
'budgetTargets',
'lanes',
'workflowProfiles',
'laneBindings',
'budgetEnforcementProfiles',
'artifactPublicationContracts',
'trendContractVersion',
'laneTrendPolicies',
'failureClasses',
'familyBudgets',
'heavyGovernanceBudgetContract',
'heavyGovernanceHotspotInventory',
'heavyGovernanceDecompositionRecords',
'heavyGovernanceSlimmingDecisions',
'heavyGovernanceBudgetSnapshots',
'heavyGovernanceBudgetOutcome',
'heavyGovernanceAuthorGuidance',
])
->and($laneIds)->toEqualCanonicalizing([
'fast-feedback',
'confidence',
'browser',
'heavy-governance',
'profiling',
'junit',
])
->and($defaultLanes)->toHaveCount(1)
->and($defaultLanes[0]['id'])->toBe('fast-feedback');
});
it('publishes the CI workflow matrix, lane bindings, artifact contracts, and failure classes from repo truth', function (): void {
$workflowProfiles = collect(TestLaneManifest::workflowProfiles())->keyBy('workflowId');
$laneBindings = collect(TestLaneManifest::laneBindings())->keyBy('laneId');
$artifactContracts = collect(TestLaneManifest::artifactPublicationContracts())->keyBy('laneId');
$failureClasses = collect(TestLaneManifest::failureClasses())->keyBy('failureClassId');
expect($workflowProfiles->keys()->all())->toEqualCanonicalizing([
'pr-fast-feedback',
'main-confidence',
'heavy-governance-manual',
'heavy-governance-scheduled',
'browser-manual',
'browser-scheduled',
])
->and($workflowProfiles->get('pr-fast-feedback')['laneBindings'])->toBe(['fast-feedback'])
->and($workflowProfiles->get('main-confidence')['branchFilters'])->toBe(['dev'])
->and($workflowProfiles->get('heavy-governance-scheduled')['scheduleCron'])->toBe('17 4 * * 1-5')
->and($workflowProfiles->get('browser-scheduled')['scheduleCron'])->toBe('43 4 * * 1-5')
->and($laneBindings->get('fast-feedback')['executionWrapper'])->toBe('scripts/platform-test-lane')
->and($laneBindings->get('confidence')['requiredArtifacts'])->toEqualCanonicalizing(['summary.md', 'budget.json', 'report.json', 'junit.xml', 'trend-history.json'])
->and($artifactContracts->get('fast-feedback')['retentionClass'])->toBe('pr-short')
->and($artifactContracts->get('fast-feedback')['requiredFiles'])->toContain('trend-history.json')
->and($artifactContracts->get('browser')['uploadGroupName'])->toBe('browser-artifacts')
->and($failureClasses->keys()->all())->toEqualCanonicalizing([
'test-failure',
'wrapper-failure',
'budget-breach',
'artifact-publication-failure',
'infrastructure-failure',
]);
});
it('keeps every lane declaration populated with governance metadata, selectors, and segmented family expectations', function (): void {
foreach (TestLaneManifest::manifest()['lanes'] as $lane) {
expect(trim($lane['description']))->not->toBe('')
->and(trim($lane['intendedAudience']))->not->toBe('')
->and($lane['includedFamilies'])->not->toBeEmpty()
->and($lane['ownershipExpectations'])->not->toBe('')
->and($lane['artifacts'])->not->toBeEmpty()
->and($lane['budget']['thresholdSeconds'])->toBeGreaterThan(0)
->and($lane['budget']['baselineSource'])->toBeString()
->and($lane['dbStrategy']['connectionMode'])->toBeString();
$selectors = $lane['selectors'];
foreach ([
'includeSuites',
'includePaths',
'includeGroups',
'includeFiles',
'excludeSuites',
'excludePaths',
'excludeGroups',
'excludeFiles',
] as $selectorKey) {
expect($selectors)->toHaveKey($selectorKey);
}
}
expect(TestLaneManifest::lane('confidence')['includedFamilies'])->toContain('ui-light', 'ui-workflow')
->and(TestLaneManifest::lane('confidence')['excludedFamilies'])->toContain('surface-guard', 'discovery-heavy')
->and(TestLaneManifest::lane('heavy-governance')['includedFamilies'])->toContain('surface-guard', 'discovery-heavy');
});
it('exposes the spec 208 classification catalog and seeded family inventory with required metadata', function (): void {
$classifications = collect(TestLaneManifest::classifications())->keyBy('classificationId');
$families = collect(TestLaneManifest::families())->keyBy('familyId');
expect($classifications->keys()->all())->toEqualCanonicalizing([
'ui-light',
'ui-workflow',
'surface-guard',
'discovery-heavy',
'browser',
])
->and($classifications->get('browser')['allowedLaneIds'])->toBe(['browser'])
->and($classifications->get('surface-guard')['defaultLaneId'])->toBe('heavy-governance')
->and($classifications->get('discovery-heavy')['forbiddenLaneIds'])->toContain('fast-feedback', 'confidence')
->and($families->has('baseline-profile-start-surfaces'))->toBeTrue()
->and($families->has('findings-workflow-surfaces'))->toBeTrue()
->and($families->has('finding-bulk-actions-workflow'))->toBeTrue()
->and($families->has('drift-bulk-triage-all-matching'))->toBeTrue()
->and($families->has('policy-resource-admin-search-parity'))->toBeTrue()
->and($families->has('workspace-only-admin-surface-independence'))->toBeTrue()
->and($families->has('workspace-settings-slice-management'))->toBeTrue()
->and($families->has('baseline-compare-matrix-workflow'))->toBeTrue()
->and($families->has('browser-smoke'))->toBeTrue();
foreach (TestLaneManifest::families() as $family) {
expect(trim($family['purpose']))->not->toBe('')
->and(trim($family['currentLaneId']))->not->toBe('')
->and(trim($family['targetLaneId']))->not->toBe('')
->and($family['selectors'])->not->toBeEmpty()
->and($family['hotspotFiles'])->not->toBeEmpty()
->and(trim($family['validationStatus']))->not->toBe('');
if ($family['targetLaneId'] === 'confidence') {
expect(trim((string) ($family['confidenceRationale'] ?? '')))->not->toBe('');
}
}
});
it('keeps family budgets derived from the generic budget targets for report consumers', function (): void {
$familyBudgets = TestLaneManifest::familyBudgets();
expect($familyBudgets)->not->toBeEmpty()
->and($familyBudgets[0])->toHaveKeys(['familyId', 'targetType', 'targetId', 'selectors', 'thresholdSeconds'])
->and(collect($familyBudgets)->pluck('familyId')->all())
->toContain('action-surface-contract', 'browser-smoke', 'baseline-compare-matrix-workflow', 'baseline-profile-start-surfaces', 'drift-bulk-triage-all-matching', 'finding-bulk-actions-workflow', 'findings-workflow-surfaces', 'workspace-only-admin-surface-independence', 'workspace-settings-slice-management');
});
it('publishes the heavy-governance contract, inventory, and guidance surfaces needed for honest rerun review', function (): void {
$contract = TestLaneManifest::heavyGovernanceBudgetContract();
$inventory = collect(TestLaneManifest::heavyGovernanceHotspotInventory());
expect($contract['summaryThresholdSeconds'])->toBe(300.0)
->and($contract['evaluationThresholdSeconds'])->toBe(200.0)
->and($contract['normalizedThresholdSeconds'])->toBeGreaterThanOrEqual(300.0)
->and($contract['decisionStatus'])->toBeIn(['recovered', 'recalibrated'])
->and($inventory)->toHaveCount(6)
->and($inventory->pluck('familyId')->all())->toEqual([
'baseline-profile-start-surfaces',
'action-surface-contract',
'ops-ux-governance',
'findings-workflow-surfaces',
'finding-bulk-actions-workflow',
'workspace-settings-slice-management',
])
->and(collect(TestLaneManifest::heavyGovernanceBudgetSnapshots()))->toHaveCount(2)
->and(TestLaneManifest::heavyGovernanceBudgetOutcome())->toHaveKeys([
'decisionStatus',
'finalThresholdSeconds',
'deltaSeconds',
'remainingOpenFamilies',
'followUpDebt',
])
->and(collect(TestLaneManifest::heavyGovernanceAuthorGuidance())->pluck('ruleId')->all())
->toEqualCanonicalizing([
'heavy-family-reuse-before-creation',
'heavy-family-create-only-for-new-trust',
'split-discovery-workflow-surface-concerns',
'retain-intentional-heavy-depth-explicitly',
'record-helper-or-fixture-residuals',
]);
});

View File

@ -0,0 +1,127 @@
<?php
declare(strict_types=1);
use Tests\Support\TestLaneBudget;
use Tests\Support\TestLaneManifest;
use Tests\Support\TestLaneReport;
use Tests\Support\TestLaneTrendFixtures;
function reportWithSeededHistory(string $laneId, string $suffix, array $seededHistory, float $currentSeconds): array
{
$artifactDirectory = TestLaneTrendFixtures::artifactDirectory('trend-recalibration/'.$suffix);
$durationsByFile = [
'tests/Feature/Guards/TestLaneRecalibrationEvidenceContractTest.php' => 11.0,
];
$workflowId = $laneId === 'confidence' ? 'main-confidence' : 'pr-fast-feedback';
$triggerClass = $laneId === 'confidence' ? 'mainline-push' : 'pull-request';
$comparisonProfile = in_array($laneId, ['fast-feedback', 'confidence'], true)
? 'shared-test-fixture-slimming'
: null;
$baseReport = TestLaneTrendFixtures::buildReport(
laneId: $laneId,
wallClockSeconds: $seededHistory[0],
durationsByFile: $durationsByFile,
artifactDirectory: $artifactDirectory,
ciContext: [
'workflowId' => $workflowId,
'triggerClass' => $triggerClass,
'entryPointResolved' => true,
'workflowLaneMatched' => true,
],
comparisonProfile: $comparisonProfile,
);
$artifact = $baseReport['trendHistoryArtifact'];
$templateRecord = $artifact['history'][0];
$artifact['history'] = array_values(array_map(
static function (float $seconds, int $index) use ($templateRecord): array {
$record = $templateRecord;
$record['runRef'] = sprintf('%s-recalibration-%d', $templateRecord['laneId'], $index + 1);
$record['generatedAt'] = sprintf('2026-04-%02dT08:30:00+00:00', $index + 1);
$record['wallClockSeconds'] = round($seconds, 6);
return $record;
},
$seededHistory,
array_keys($seededHistory),
));
TestLaneTrendFixtures::writeTrendHistory($laneId, $artifact, $artifactDirectory);
return TestLaneTrendFixtures::buildReport(
laneId: $laneId,
wallClockSeconds: $currentSeconds,
durationsByFile: $durationsByFile,
artifactDirectory: $artifactDirectory,
ciContext: [
'workflowId' => $workflowId,
'triggerClass' => $triggerClass,
'entryPointResolved' => true,
'workflowLaneMatched' => true,
],
comparisonProfile: $comparisonProfile,
);
}
it('emits candidate, approved, and rejected recalibration records with explicit summary disclosure', function (): void {
$candidateReport = reportWithSeededHistory('confidence', 'candidate', [420.0, 380.0, 340.0, 300.0, 260.0], 460.0);
$approvedReport = reportWithSeededHistory('fast-feedback', 'approved', [176.0, 176.3, 176.1, 176.4, 176.2], 176.3);
$rejectedReport = reportWithSeededHistory('fast-feedback', 'rejected', [176.0, 191.0, 177.0, 192.0, 178.0], 193.0);
$approvedDecision = TestLaneBudget::buildRecalibrationDecisionRecord(
laneId: 'fast-feedback',
targetType: 'baseline',
assessment: ['recalibrationRecommendation' => 'review-baseline'],
historyRecords: $approvedReport['trendHistoryArtifact']['history'],
decisionStatus: 'approved',
rationaleCode: 'post-improvement-reset',
recordedIn: 'specs/211-runtime-trend-recalibration/spec.md',
proposedValueSeconds: 182.0,
notes: 'Approved baseline reset after the suite stabilized following a deliberate improvement pass.',
);
$candidateDecision = $candidateReport['trendRecalibrationDecisions'][0] ?? null;
$rejectedDecision = $rejectedReport['trendRecalibrationDecisions'][0] ?? null;
$approvedReport['trendRecalibrationDecisions'][] = $approvedDecision;
$approvedReport['trendHistoryArtifact']['recalibrationDecisions'][] = $approvedDecision;
TestLaneReport::writeArtifacts(
laneId: 'confidence',
report: $candidateReport,
artifactDirectory: $candidateReport['artifactDirectory'],
);
TestLaneReport::writeArtifacts(
laneId: 'fast-feedback',
report: $approvedReport,
artifactDirectory: $approvedReport['artifactDirectory'],
);
TestLaneReport::writeArtifacts(
laneId: 'fast-feedback',
report: $rejectedReport,
artifactDirectory: $rejectedReport['artifactDirectory'],
);
$candidateSummary = (string) file_get_contents(TestLaneManifest::absolutePath(
TestLaneReport::artifactPaths('confidence', $candidateReport['artifactDirectory'])['summary'],
));
$approvedSummary = (string) file_get_contents(TestLaneManifest::absolutePath(
TestLaneReport::artifactPaths('fast-feedback', $approvedReport['artifactDirectory'])['summary'],
));
$rejectedSummary = (string) file_get_contents(TestLaneManifest::absolutePath(
TestLaneReport::artifactPaths('fast-feedback', $rejectedReport['artifactDirectory'])['summary'],
));
expect($candidateDecision)->toBeArray()
->and($candidateDecision['decisionStatus'])->toBe('candidate')
->and($candidateDecision['targetType'])->toBe('budget')
->and($approvedDecision['decisionStatus'])->toBe('approved')
->and($approvedDecision['targetType'])->toBe('baseline')
->and($rejectedDecision)->toBeArray()
->and($rejectedDecision['decisionStatus'])->toBe('rejected')
->and($rejectedDecision['rationaleCode'])->toBe('noise-rejected')
->and($candidateSummary)->toContain('Recalibration: budget candidate')
->and($approvedSummary)->toContain('Recalibration: baseline approved')
->and($rejectedSummary)->toContain('Recalibration: budget rejected', 'noise-rejected');
});

View File

@ -0,0 +1,111 @@
<?php
declare(strict_types=1);
use Tests\Support\TestLaneBudget;
use Tests\Support\TestLaneTrendFixtures;
function recalibrationEvidenceHistory(string $laneId, int $count): array
{
$report = TestLaneTrendFixtures::buildReport(
laneId: $laneId,
wallClockSeconds: $laneId === 'confidence' ? 438.0 : 188.0,
durationsByFile: [
'tests/Feature/Guards/TestLaneRecalibrationPolicyTest.php' => 12.0,
],
ciContext: [
'workflowId' => $laneId === 'confidence' ? 'main-confidence' : 'pr-fast-feedback',
'triggerClass' => $laneId === 'confidence' ? 'mainline-push' : 'pull-request',
'entryPointResolved' => true,
'workflowLaneMatched' => true,
],
comparisonProfile: in_array($laneId, ['fast-feedback', 'confidence'], true) ? 'shared-test-fixture-slimming' : null,
);
$templateRecord = $report['trendHistoryArtifact']['history'][0];
return array_values(array_map(
static function (int $index) use ($templateRecord): array {
$record = $templateRecord;
$record['runRef'] = sprintf('%s-evidence-%d', $templateRecord['laneId'], $index + 1);
$record['generatedAt'] = sprintf('2026-04-%02dT10:00:00+00:00', $index + 1);
return $record;
},
range(0, $count - 1),
));
}
it('keeps baseline and budget recalibration rules separate and enforces the stronger budget evidence window', function (): void {
$assessment = [
'recalibrationRecommendation' => 'review-baseline',
];
$baselineHistory = recalibrationEvidenceHistory('fast-feedback', 3);
$budgetHistory = recalibrationEvidenceHistory('confidence', 5);
$baselineDecision = TestLaneBudget::buildRecalibrationDecisionRecord(
laneId: 'fast-feedback',
targetType: 'baseline',
assessment: $assessment,
historyRecords: $baselineHistory,
decisionStatus: 'approved',
rationaleCode: 'lane-scope-change',
recordedIn: 'specs/211-runtime-trend-recalibration/spec.md',
proposedValueSeconds: 184.0,
);
$budgetDecision = TestLaneBudget::buildRecalibrationDecisionRecord(
laneId: 'confidence',
targetType: 'budget',
assessment: ['recalibrationRecommendation' => 'review-budget'],
historyRecords: $budgetHistory,
decisionStatus: 'approved',
rationaleCode: 'sustained-erosion',
recordedIn: 'specs/211-runtime-trend-recalibration/spec.md',
proposedValueSeconds: 470.0,
);
expect($baselineDecision['targetType'])->toBe('baseline')
->and($baselineDecision['decisionStatus'])->toBe('approved')
->and($budgetDecision['targetType'])->toBe('budget')
->and($budgetDecision['decisionStatus'])->toBe('approved')
->and($budgetDecision['evidenceRunRefs'])->toHaveCount(5);
expect(static fn () => TestLaneBudget::buildRecalibrationDecisionRecord(
laneId: 'confidence',
targetType: 'budget',
assessment: ['recalibrationRecommendation' => 'review-budget'],
historyRecords: recalibrationEvidenceHistory('confidence', 4),
decisionStatus: 'approved',
rationaleCode: 'sustained-erosion',
recordedIn: 'specs/211-runtime-trend-recalibration/spec.md',
proposedValueSeconds: 470.0,
))->toThrow(InvalidArgumentException::class);
});
it('requires approved versus rejected rationale handling that matches the policy', function (): void {
$history = recalibrationEvidenceHistory('fast-feedback', 3);
$rejectedDecision = TestLaneBudget::buildRecalibrationDecisionRecord(
laneId: 'fast-feedback',
targetType: 'budget',
assessment: ['recalibrationRecommendation' => 'investigate'],
historyRecords: [$history[0]],
decisionStatus: 'rejected',
rationaleCode: 'noise-rejected',
recordedIn: 'specs/211-runtime-trend-recalibration/spec.md',
);
expect($rejectedDecision['decisionStatus'])->toBe('rejected')
->and($rejectedDecision['rationaleCode'])->toBe('noise-rejected');
expect(static fn () => TestLaneBudget::buildRecalibrationDecisionRecord(
laneId: 'fast-feedback',
targetType: 'baseline',
assessment: ['recalibrationRecommendation' => 'review-baseline'],
historyRecords: $history,
decisionStatus: 'approved',
rationaleCode: 'sustained-erosion',
recordedIn: 'specs/211-runtime-trend-recalibration/spec.md',
proposedValueSeconds: 184.0,
))->toThrow(InvalidArgumentException::class);
});

View File

@ -0,0 +1,78 @@
<?php
declare(strict_types=1);
use Tests\Support\TestLaneTrendFixtures;
function classifiedTrendReport(string $laneId, string $suffix, array $seededHistory, float $currentSeconds): array
{
$artifactDirectory = TestLaneTrendFixtures::artifactDirectory('trend-classification/'.$suffix);
$durationsByFile = [
'tests/Feature/Guards/TestLaneTrendClassificationTest.php' => 10.0,
'tests/Feature/Guards/TestLaneTrendSummaryContractTest.php' => 8.0,
];
$workflowId = $laneId === 'confidence' ? 'main-confidence' : 'pr-fast-feedback';
$triggerClass = $laneId === 'confidence' ? 'mainline-push' : 'pull-request';
$comparisonProfile = in_array($laneId, ['fast-feedback', 'confidence'], true)
? 'shared-test-fixture-slimming'
: null;
$baseReport = TestLaneTrendFixtures::buildReport(
laneId: $laneId,
wallClockSeconds: $seededHistory[0],
durationsByFile: $durationsByFile,
artifactDirectory: $artifactDirectory,
ciContext: [
'workflowId' => $workflowId,
'triggerClass' => $triggerClass,
'entryPointResolved' => true,
'workflowLaneMatched' => true,
],
comparisonProfile: $comparisonProfile,
);
$artifact = $baseReport['trendHistoryArtifact'];
$templateRecord = $artifact['history'][0];
$artifact['history'] = array_values(array_map(
static function (float $seconds, int $index) use ($templateRecord): array {
$record = $templateRecord;
$record['runRef'] = sprintf('%s-history-%d', $templateRecord['laneId'], $index + 1);
$record['generatedAt'] = sprintf('2026-04-%02dT09:00:00+00:00', $index + 1);
$record['wallClockSeconds'] = round($seconds, 6);
return $record;
},
$seededHistory,
array_keys($seededHistory),
));
TestLaneTrendFixtures::writeTrendHistory($laneId, $artifact, $artifactDirectory);
return TestLaneTrendFixtures::buildReport(
laneId: $laneId,
wallClockSeconds: $currentSeconds,
durationsByFile: $durationsByFile,
artifactDirectory: $artifactDirectory,
ciContext: [
'workflowId' => $workflowId,
'triggerClass' => $triggerClass,
'entryPointResolved' => true,
'workflowLaneMatched' => true,
],
comparisonProfile: $comparisonProfile,
);
}
it('classifies healthy, budget-near, trending-worse, regressed, and unstable runtime states', function (): void {
$healthy = classifiedTrendReport('fast-feedback', 'healthy', [176.1, 175.6, 176.2, 175.4, 176.0], 176.4);
$budgetNear = classifiedTrendReport('confidence', 'budget-near', [433.0, 430.0, 427.0, 424.0, 420.0], 438.5);
$trendingWorse = classifiedTrendReport('fast-feedback', 'trending-worse', [175.0, 150.0, 125.0, 100.0, 75.0], 195.0);
$regressed = classifiedTrendReport('fast-feedback', 'regressed', [200.0, 180.0, 160.0, 140.0, 120.0], 225.0);
$unstable = classifiedTrendReport('fast-feedback', 'unstable', [170.0, 195.0, 168.0, 193.0, 166.0], 194.0);
expect($healthy['trendCurrentAssessment']['healthClass'])->toBe('healthy')
->and($budgetNear['trendCurrentAssessment']['healthClass'])->toBe('budget-near')
->and($trendingWorse['trendCurrentAssessment']['healthClass'])->toBe('trending-worse')
->and($regressed['trendCurrentAssessment']['healthClass'])->toBe('regressed')
->and($unstable['trendCurrentAssessment']['healthClass'])->toBe('unstable')
->and($unstable['trendCurrentAssessment']['windowStatus'])->toBe('noisy');
});

View File

@ -0,0 +1,137 @@
<?php
declare(strict_types=1);
use Tests\Support\TestLaneTrendFixtures;
function validateTrendSchema(mixed $value, array $schema, array $defs, string $path = '$'): array
{
if (isset($schema['$ref']) && is_string($schema['$ref'])) {
$ref = $schema['$ref'];
$definitionKey = str_replace('#/$defs/', '', $ref);
return validateTrendSchema($value, $defs[$definitionKey] ?? [], $defs, $path);
}
$errors = [];
$types = $schema['type'] ?? null;
if (isset($schema['const']) && $value !== $schema['const']) {
$errors[] = sprintf('%s must equal %s.', $path, var_export($schema['const'], true));
}
if (isset($schema['enum']) && ! in_array($value, $schema['enum'], true)) {
$errors[] = sprintf('%s must be one of [%s].', $path, implode(', ', array_map(static fn (mixed $item): string => var_export($item, true), $schema['enum'])));
}
if (is_string($types)) {
$types = [$types];
}
if (is_array($types)) {
$typeValid = false;
foreach ($types as $type) {
$typeValid = match ($type) {
'array' => is_array($value) && array_is_list($value),
'object' => is_array($value) && ! array_is_list($value),
'string' => is_string($value),
'integer' => is_int($value),
'number' => is_int($value) || is_float($value),
'boolean' => is_bool($value),
'null' => $value === null,
default => false,
};
if ($typeValid) {
break;
}
}
if (! $typeValid) {
$errors[] = sprintf('%s has the wrong type.', $path);
return $errors;
}
}
if (is_array($value) && array_is_list($value)) {
if (isset($schema['minItems']) && count($value) < (int) $schema['minItems']) {
$errors[] = sprintf('%s must contain at least %d item(s).', $path, (int) $schema['minItems']);
}
if (isset($schema['items']) && is_array($schema['items'])) {
foreach ($value as $index => $item) {
$errors = array_merge($errors, validateTrendSchema($item, $schema['items'], $defs, sprintf('%s[%d]', $path, $index)));
}
}
return $errors;
}
if (is_array($value) && ! array_is_list($value)) {
foreach ($schema['required'] ?? [] as $requiredKey) {
if (! array_key_exists((string) $requiredKey, $value)) {
$errors[] = sprintf('%s is missing required key [%s].', $path, $requiredKey);
}
}
if (($schema['additionalProperties'] ?? true) === false) {
$allowedKeys = array_keys($schema['properties'] ?? []);
foreach (array_keys($value) as $key) {
if (! in_array($key, $allowedKeys, true)) {
$errors[] = sprintf('%s contains unsupported key [%s].', $path, $key);
}
}
}
foreach ($schema['properties'] ?? [] as $key => $propertySchema) {
if (! array_key_exists($key, $value)) {
continue;
}
$errors = array_merge($errors, validateTrendSchema($value[$key], $propertySchema, $defs, $path.'.'.$key));
}
return $errors;
}
if ((is_int($value) || is_float($value)) && isset($schema['minimum']) && $value < $schema['minimum']) {
$errors[] = sprintf('%s must be >= %s.', $path, $schema['minimum']);
}
return $errors;
}
it('keeps the generated trend-history artifact synchronized with the checked-in JSON schema contract', function (): void {
$schemaPath = repo_path('specs/211-runtime-trend-recalibration/contracts/test-runtime-trend-history.schema.json');
$artifactDirectory = TestLaneTrendFixtures::artifactDirectory('trend-schema-contract');
$report = TestLaneTrendFixtures::buildReport(
laneId: 'fast-feedback',
wallClockSeconds: 184.6,
durationsByFile: [
'tests/Feature/Guards/TestLaneTrendContractSchemaTest.php' => 16.4,
'tests/Feature/Guards/TestLaneArtifactsContractTest.php' => 11.2,
],
artifactDirectory: $artifactDirectory,
ciContext: [
'workflowId' => 'pr-fast-feedback',
'triggerClass' => 'pull-request',
'entryPointResolved' => true,
'workflowLaneMatched' => true,
],
comparisonProfile: 'shared-test-fixture-slimming',
);
/** @var array<string, mixed> $schema */
$schema = json_decode((string) file_get_contents($schemaPath), true, 512, JSON_THROW_ON_ERROR);
$artifact = $report['trendHistoryArtifact'];
$errors = validateTrendSchema($artifact, $schema, $schema['$defs'] ?? []);
expect($artifact['schemaVersion'])->toBe('1.0.0')
->and($artifact['laneId'])->toBe('fast-feedback')
->and($artifact['workflowProfile'])->toBe('pr-fast-feedback')
->and($artifact['history'][0]['artifactRefs']['trendHistory'])->toBe($artifactDirectory.'/fast-feedback-latest.trend-history.json')
->and($errors)->toBe([]);
});

View File

@ -0,0 +1,71 @@
<?php
declare(strict_types=1);
use Symfony\Component\Yaml\Yaml;
use Tests\Support\TestLaneTrendFixtures;
it('keeps the logical OpenAPI contract synchronized with the generated trend assessment vocabulary', function (): void {
$contractPath = repo_path('specs/211-runtime-trend-recalibration/contracts/test-runtime-trend.logical.openapi.yaml');
$artifactDirectory = TestLaneTrendFixtures::artifactDirectory('trend-logical-contract');
$report = TestLaneTrendFixtures::buildReport(
laneId: 'confidence',
wallClockSeconds: 438.1,
durationsByFile: [
'tests/Feature/Baselines/BaselineCompareMatrixCompareAllActionTest.php' => 22.8,
'tests/Feature/Baselines/BaselineCompareMatrixBuilderTest.php' => 19.4,
'tests/Feature/Rbac/OnboardingWizardUiEnforcementTest.php' => 17.6,
],
artifactDirectory: $artifactDirectory,
ciContext: [
'workflowId' => 'main-confidence',
'triggerClass' => 'mainline-push',
'entryPointResolved' => true,
'workflowLaneMatched' => true,
],
);
/** @var array<string, mixed> $contract */
$contract = Yaml::parseFile($contractPath);
$paths = $contract['paths'] ?? [];
$schemas = $contract['components']['schemas'] ?? [];
$assessment = $report['trendCurrentAssessment'];
$decision = $report['trendRecalibrationDecisions'][0] ?? null;
expect(array_keys($paths))->toEqualCanonicalizing([
'/test-governance/lanes/{laneId}/trend-history',
'/test-governance/lanes/{laneId}/trend-assessment',
'/test-governance/lanes/{laneId}/recalibration',
'/test-governance/cycles/{cycleId}/summary',
])
->and($paths['/test-governance/lanes/{laneId}/trend-history']['post']['operationId'])->toBe('updateLaneTrendHistory')
->and($paths['/test-governance/lanes/{laneId}/trend-assessment']['post']['operationId'])->toBe('evaluateLaneTrendAssessment')
->and($paths['/test-governance/lanes/{laneId}/recalibration']['post']['operationId'])->toBe('evaluateLaneRecalibration')
->and($paths['/test-governance/cycles/{cycleId}/summary']['get']['operationId'])->toBe('getTrendSummaryCycle')
->and($schemas['DriftAssessment']['properties']['healthClass']['enum'])->toEqualCanonicalizing([
'healthy',
'budget-near',
'trending-worse',
'regressed',
'unstable',
])
->and($schemas['DriftAssessment']['properties']['recalibrationRecommendation']['enum'])->toEqualCanonicalizing([
'none',
'investigate',
'review-baseline',
'review-budget',
])
->and($schemas['RecalibrationDecision']['properties']['decisionStatus']['enum'])->toEqualCanonicalizing([
'candidate',
'approved',
'rejected',
])
->and($schemas['LaneTrendHistoryArtifact']['required'])->toContain('schemaVersion', 'policy', 'history', 'currentAssessment')
->and($assessment['healthClass'])->toBeIn($schemas['DriftAssessment']['properties']['healthClass']['enum'])
->and($assessment['recalibrationRecommendation'])->toBeIn($schemas['DriftAssessment']['properties']['recalibrationRecommendation']['enum']);
if (is_array($decision)) {
expect($decision['decisionStatus'])->toBeIn($schemas['RecalibrationDecision']['properties']['decisionStatus']['enum'])
->and($decision['targetType'])->toBeIn($schemas['RecalibrationDecision']['properties']['targetType']['enum']);
}
});

View File

@ -0,0 +1,97 @@
<?php
declare(strict_types=1);
use Tests\Support\TestLaneTrendFixtures;
function seededTrendHistoryArtifact(array $baseArtifact, array $seededWallClockSeconds): array
{
$templateRecord = $baseArtifact['history'][0];
$seededHistory = [];
foreach ($seededWallClockSeconds as $index => $seconds) {
$record = $templateRecord;
$record['runRef'] = sprintf('%s-seeded-%d', $templateRecord['laneId'], $index + 1);
$record['generatedAt'] = sprintf('2026-04-%02dT12:00:00+00:00', $index + 1);
$record['wallClockSeconds'] = round((float) $seconds, 6);
$seededHistory[] = $record;
}
$baseArtifact['history'] = $seededHistory;
return $baseArtifact;
}
it('keeps the trend summary bounded while exposing current, previous, baseline, and budget fields for fast-feedback and confidence', function (): void {
$scenarios = [
'fast-feedback' => [
'artifactDirectory' => TestLaneTrendFixtures::artifactDirectory('trend-summary/fast-feedback'),
'workflowId' => 'pr-fast-feedback',
'triggerClass' => 'pull-request',
'comparisonProfile' => 'shared-test-fixture-slimming',
'durationsByFile' => [
'tests/Feature/Guards/TestLaneTrendSummaryContractTest.php' => 12.6,
'tests/Feature/Guards/TestLaneArtifactsContractTest.php' => 8.4,
],
'seededHistory' => [176.73, 178.91, 181.22, 183.41, 185.07, 186.64],
'currentSeconds' => 188.18,
],
'confidence' => [
'artifactDirectory' => TestLaneTrendFixtures::artifactDirectory('trend-summary/confidence'),
'workflowId' => 'main-confidence',
'triggerClass' => 'mainline-push',
'comparisonProfile' => 'shared-test-fixture-slimming',
'durationsByFile' => [
'tests/Feature/Baselines/BaselineCompareMatrixCompareAllActionTest.php' => 24.3,
'tests/Feature/Baselines/BaselineCompareMatrixBuilderTest.php' => 20.4,
'tests/Feature/Rbac/OnboardingWizardUiEnforcementTest.php' => 16.1,
],
'seededHistory' => [394.38, 401.12, 407.05, 411.61, 419.24, 424.77],
'currentSeconds' => 431.81,
],
];
foreach ($scenarios as $laneId => $scenario) {
$baseReport = TestLaneTrendFixtures::buildReport(
laneId: $laneId,
wallClockSeconds: $scenario['seededHistory'][0],
durationsByFile: $scenario['durationsByFile'],
artifactDirectory: $scenario['artifactDirectory'],
ciContext: [
'workflowId' => $scenario['workflowId'],
'triggerClass' => $scenario['triggerClass'],
'entryPointResolved' => true,
'workflowLaneMatched' => true,
],
comparisonProfile: $scenario['comparisonProfile'],
);
TestLaneTrendFixtures::writeTrendHistory(
$laneId,
seededTrendHistoryArtifact($baseReport['trendHistoryArtifact'], $scenario['seededHistory']),
$scenario['artifactDirectory'],
);
$report = TestLaneTrendFixtures::buildReport(
laneId: $laneId,
wallClockSeconds: $scenario['currentSeconds'],
durationsByFile: $scenario['durationsByFile'],
artifactDirectory: $scenario['artifactDirectory'],
ciContext: [
'workflowId' => $scenario['workflowId'],
'triggerClass' => $scenario['triggerClass'],
'entryPointResolved' => true,
'workflowLaneMatched' => true,
],
comparisonProfile: $scenario['comparisonProfile'],
);
expect($report['trendHistoryArtifact']['history'])->toHaveCount(7)
->and($report['trendCurrentAssessment']['sampleCount'])->toBe(5)
->and($report['trendCurrentAssessment']['previousComparableRunRef'])->not->toBeNull()
->and($report['trendHistoryArtifact']['history'][0]['baselineSeconds'])->not->toBeNull()
->and($report['trendHistoryArtifact']['history'][0]['budgetSeconds'])->toEqual((float) $report['budgetThresholdSeconds'])
->and($report['trendCurrentAssessment']['summaryLine'])->not->toBe('')
->and($report['trendWarnings'])->toBeArray();
}
});

View File

@ -0,0 +1,100 @@
<?php
declare(strict_types=1);
use Tests\Support\TestLaneManifest;
it('declares the spec 208 five-class catalog with the expected lane semantics', function (): void {
$classifications = collect(TestLaneManifest::classifications())->keyBy('classificationId');
expect($classifications->keys()->all())->toEqualCanonicalizing([
'ui-light',
'ui-workflow',
'surface-guard',
'discovery-heavy',
'browser',
])
->and($classifications->get('ui-light')['allowedLaneIds'])->toContain('fast-feedback', 'confidence')
->and($classifications->get('ui-workflow')['defaultLaneId'])->toBe('confidence')
->and($classifications->get('surface-guard')['forbiddenLaneIds'])->toContain('fast-feedback', 'confidence')
->and($classifications->get('discovery-heavy')['defaultLaneId'])->toBe('heavy-governance')
->and($classifications->get('browser')['allowedLaneIds'])->toBe(['browser']);
});
it('keeps the seeded heavy batch out of confidence and inside heavy-governance', function (): void {
$confidenceFiles = TestLaneManifest::discoverFiles('confidence');
$heavyFiles = TestLaneManifest::discoverFiles('heavy-governance');
expect($confidenceFiles)->not->toContain(
'tests/Feature/Drift/DriftBulkAcknowledgeAllMatchingConfirmationTest.php',
'tests/Feature/Filament/BaselineActionAuthorizationTest.php',
'tests/Feature/Filament/BaselineProfileCaptureStartSurfaceTest.php',
'tests/Feature/Filament/BaselineProfileCompareStartSurfaceTest.php',
'tests/Feature/Findings/FindingBulkActionsTest.php',
'tests/Feature/Findings/FindingExceptionRenewalTest.php',
'tests/Feature/Findings/FindingsListFiltersTest.php',
'tests/Feature/Findings/FindingWorkflowRowActionsTest.php',
'tests/Feature/Findings/FindingWorkflowViewActionsTest.php',
'tests/Feature/Filament/WorkspaceOnlySurfaceTenantIndependenceTest.php',
'tests/Feature/Guards/ActionSurfaceContractTest.php',
'tests/Feature/Filament/PolicyResourceAdminSearchParityTest.php',
'tests/Feature/Filament/PolicyVersionAdminSearchParityTest.php',
'tests/Feature/Rbac/BackupItemsRelationManagerUiEnforcementTest.php',
'tests/Feature/Rbac/WorkspaceMembershipsRelationManagerUiEnforcementTest.php',
'tests/Feature/SettingsFoundation/WorkspaceSettingsManageTest.php',
'tests/Feature/Filament/TenantReviewHeaderDisciplineTest.php',
'tests/Feature/Filament/PanelNavigationSegregationTest.php',
)
->and($heavyFiles)->toContain(
'tests/Feature/Drift/DriftBulkAcknowledgeAllMatchingConfirmationTest.php',
'tests/Feature/Filament/BaselineActionAuthorizationTest.php',
'tests/Feature/Filament/BaselineProfileCaptureStartSurfaceTest.php',
'tests/Feature/Filament/BaselineProfileCompareStartSurfaceTest.php',
'tests/Feature/Findings/FindingBulkActionsTest.php',
'tests/Feature/Findings/FindingExceptionRenewalTest.php',
'tests/Feature/Findings/FindingsListFiltersTest.php',
'tests/Feature/Findings/FindingWorkflowRowActionsTest.php',
'tests/Feature/Findings/FindingWorkflowViewActionsTest.php',
'tests/Feature/Filament/WorkspaceOnlySurfaceTenantIndependenceTest.php',
'tests/Feature/Guards/ActionSurfaceContractTest.php',
'tests/Feature/Filament/PolicyResourceAdminSearchParityTest.php',
'tests/Feature/Filament/PolicyVersionAdminSearchParityTest.php',
'tests/Feature/Rbac/BackupItemsRelationManagerUiEnforcementTest.php',
'tests/Feature/Rbac/WorkspaceMembershipsRelationManagerUiEnforcementTest.php',
'tests/Feature/SettingsFoundation/WorkspaceSettingsManageTest.php',
'tests/Feature/Filament/TenantReviewHeaderDisciplineTest.php',
'tests/Feature/Filament/PanelNavigationSegregationTest.php',
);
});
it('describes mixed-file and wrong-lane placement with actionable output', function (): void {
$validation = TestLaneManifest::validateLanePlacement(
laneId: 'confidence',
filePath: 'tests/Feature/Filament/PolicyResourceAdminSearchParityTest.php',
);
expect($validation['valid'])->toBeFalse()
->and($validation['resolvedClassificationId'])->toBe('discovery-heavy')
->and(implode(' ', $validation['reasons']))->toContain('policy-resource-admin-search-parity');
expect(TestLaneManifest::describeFilePlacement('tests/Feature/Baselines/BaselineCompareMatrixBuilderTest.php'))
->toContain('baseline-compare-matrix-workflow', 'ui-workflow', 'Mixed-file');
});
it('publishes heavy-governance author guidance alongside the canonical hotspot family ownership', function (): void {
$inventoryFamilies = collect(TestLaneManifest::heavyGovernanceHotspotInventory())->pluck('familyId')->all();
$guidanceRuleIds = collect(TestLaneManifest::heavyGovernanceAuthorGuidance())->pluck('ruleId')->all();
foreach ($inventoryFamilies as $familyId) {
expect(TestLaneManifest::family($familyId)['targetLaneId'])->toBe('heavy-governance')
->and(TestLaneManifest::family($familyId)['currentLaneId'])->toBeIn(['confidence', 'heavy-governance']);
}
expect($guidanceRuleIds)->toEqualCanonicalizing([
'heavy-family-reuse-before-creation',
'heavy-family-create-only-for-new-trust',
'split-discovery-workflow-surface-concerns',
'retain-intentional-heavy-depth-explicitly',
'record-helper-or-fixture-residuals',
]);
});

View File

@ -22,7 +22,7 @@
uses(RefreshDatabase::class); uses(RefreshDatabase::class);
it('reuses finding related navigation and caches deterministic negative results', function (): void { it('reuses finding related navigation and caches deterministic negative results', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner'); [$user, $tenant] = createMinimalUserWithTenant(role: 'owner');
$this->actingAs($user); $this->actingAs($user);
Filament::setTenant($tenant, true); Filament::setTenant($tenant, true);
@ -45,8 +45,7 @@
'policy_id' => (int) $policy->getKey(), 'policy_id' => (int) $policy->getKey(),
]); ]);
$run = OperationRun::factory()->for($tenant)->create([ $run = OperationRun::factory()->minimal()->forTenant($tenant)->create([
'workspace_id' => (int) $tenant->workspace_id,
'type' => 'baseline_compare', 'type' => 'baseline_compare',
]); ]);
@ -103,12 +102,11 @@
}); });
it('reuses operation-run related context across detail and header consumers', function (): void { it('reuses operation-run related context across detail and header consumers', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner'); [$user, $tenant] = createMinimalUserWithTenant(role: 'owner');
$this->actingAs($user); $this->actingAs($user);
Filament::setTenant($tenant, true); Filament::setTenant($tenant, true);
$run = OperationRun::factory()->for($tenant)->create([ $run = OperationRun::factory()->minimal()->forTenant($tenant)->create([
'workspace_id' => (int) $tenant->workspace_id,
'type' => 'backup_set.add_policies', 'type' => 'backup_set.add_policies',
'context' => [ 'context' => [
'backup_set_id' => 123, 'backup_set_id' => 123,
@ -130,7 +128,7 @@
it('keeps related-navigation target routes tenant-safe for non-members and capability-limited members', function (): void { it('keeps related-navigation target routes tenant-safe for non-members and capability-limited members', function (): void {
$workspaceTenant = \App\Models\Tenant::factory()->create(); $workspaceTenant = \App\Models\Tenant::factory()->create();
[$member, $workspaceTenant] = createUserWithTenant(tenant: $workspaceTenant, role: 'readonly'); [$member, $workspaceTenant] = createMinimalUserWithTenant(tenant: $workspaceTenant, role: 'readonly');
$nonMember = \App\Models\User::factory()->create(); $nonMember = \App\Models\User::factory()->create();
$profile = BaselineProfile::factory()->active()->create([ $profile = BaselineProfile::factory()->active()->create([

View File

@ -20,7 +20,7 @@
use Illuminate\Support\Facades\Bus; use Illuminate\Support\Facades\Bus;
it('renders operate hub pages DB-only with no outbound HTTP and no queued jobs', function (): void { it('renders operate hub pages DB-only with no outbound HTTP and no queued jobs', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner'); [$user, $tenant] = createMinimalUserWithTenant(role: 'owner');
$run = OperationRun::factory()->create([ $run = OperationRun::factory()->create([
'tenant_id' => (int) $tenant->getKey(), 'tenant_id' => (int) $tenant->getKey(),
@ -75,7 +75,7 @@
})->group('ops-ux'); })->group('ops-ux');
it('shows back to tenant on run detail when tenant context is active and entitled', function (): void { it('shows back to tenant on run detail when tenant context is active and entitled', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner'); [$user, $tenant] = createMinimalUserWithTenant(role: 'owner');
$run = OperationRun::factory()->create([ $run = OperationRun::factory()->create([
'tenant_id' => (int) $tenant->getKey(), 'tenant_id' => (int) $tenant->getKey(),
@ -109,7 +109,7 @@
})->group('ops-ux'); })->group('ops-ux');
it('shows back to tenant when filament tenant is absent but last tenant memory exists', function (): void { it('shows back to tenant when filament tenant is absent but last tenant memory exists', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner'); [$user, $tenant] = createMinimalUserWithTenant(role: 'owner');
$run = OperationRun::factory()->create([ $run = OperationRun::factory()->create([
'tenant_id' => (int) $tenant->getKey(), 'tenant_id' => (int) $tenant->getKey(),
@ -139,7 +139,7 @@
})->group('ops-ux'); })->group('ops-ux');
it('shows no tenant return affordance when active and last tenant contexts are not entitled', function (): void { it('shows no tenant return affordance when active and last tenant contexts are not entitled', function (): void {
[$user, $entitledTenant] = createUserWithTenant(role: 'owner'); [$user, $entitledTenant] = createMinimalUserWithTenant(role: 'owner');
$nonEntitledTenant = Tenant::factory()->create([ $nonEntitledTenant = Tenant::factory()->create([
'workspace_id' => (int) $entitledTenant->workspace_id, 'workspace_id' => (int) $entitledTenant->workspace_id,
@ -171,7 +171,7 @@
})->group('ops-ux'); })->group('ops-ux');
it('renders shared scope and return copy as secondary monitoring context on operations surfaces', function (): void { it('renders shared scope and return copy as secondary monitoring context on operations surfaces', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner'); [$user, $tenant] = createMinimalUserWithTenant(role: 'owner');
$run = OperationRun::factory()->create([ $run = OperationRun::factory()->create([
'tenant_id' => (int) $tenant->getKey(), 'tenant_id' => (int) $tenant->getKey(),
@ -229,7 +229,7 @@
})->group('ops-ux'); })->group('ops-ux');
it('keeps member-without-capability workflow start denial as 403 with no run side effects', function (): void { it('keeps member-without-capability workflow start denial as 403 with no run side effects', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'operator'); [$user, $tenant] = createMinimalUserWithTenant(role: 'operator');
$this->actingAs($user); $this->actingAs($user);
Filament::setTenant($tenant, true); Filament::setTenant($tenant, true);
@ -244,7 +244,7 @@
})->group('ops-ux'); })->group('ops-ux');
it('does not mutate workspace or last-tenant session memory on /admin/operations', function (): void { it('does not mutate workspace or last-tenant session memory on /admin/operations', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner'); [$user, $tenant] = createMinimalUserWithTenant(role: 'owner');
$this->actingAs($user); $this->actingAs($user);
Filament::setTenant($tenant, true); Filament::setTenant($tenant, true);
@ -264,13 +264,13 @@
it('prefers the filament tenant over remembered workspace tenant state when both are entitled', function (): void { it('prefers the filament tenant over remembered workspace tenant state when both are entitled', function (): void {
$tenantA = Tenant::factory()->create(); $tenantA = Tenant::factory()->create();
[$user, $tenantA] = createUserWithTenant(tenant: $tenantA, role: 'owner'); [$user, $tenantA] = createMinimalUserWithTenant(tenant: $tenantA, role: 'owner');
$tenantB = Tenant::factory()->create([ $tenantB = Tenant::factory()->create([
'workspace_id' => (int) $tenantA->workspace_id, 'workspace_id' => (int) $tenantA->workspace_id,
]); ]);
createUserWithTenant(tenant: $tenantB, user: $user, role: 'owner'); createMinimalUserWithTenant(tenant: $tenantB, user: $user, role: 'owner');
$this->actingAs($user); $this->actingAs($user);
Filament::setTenant($tenantB, true); Filament::setTenant($tenantB, true);
@ -289,13 +289,13 @@
$runTenant = Tenant::factory()->create([ $runTenant = Tenant::factory()->create([
'workspace_id' => null, 'workspace_id' => null,
]); ]);
[$user, $runTenant] = createUserWithTenant(tenant: $runTenant, role: 'owner'); [$user, $runTenant] = createMinimalUserWithTenant(tenant: $runTenant, role: 'owner');
$currentTenant = Tenant::factory()->create([ $currentTenant = Tenant::factory()->create([
'workspace_id' => (int) $runTenant->workspace_id, 'workspace_id' => (int) $runTenant->workspace_id,
]); ]);
createUserWithTenant(tenant: $currentTenant, user: $user, role: 'owner'); createMinimalUserWithTenant(tenant: $currentTenant, user: $user, role: 'owner');
$run = OperationRun::factory()->create([ $run = OperationRun::factory()->create([
'workspace_id' => (int) $runTenant->workspace_id, 'workspace_id' => (int) $runTenant->workspace_id,
@ -328,14 +328,14 @@
$runTenant = Tenant::factory()->active()->create([ $runTenant = Tenant::factory()->active()->create([
'name' => 'Canonical Run Tenant', 'name' => 'Canonical Run Tenant',
]); ]);
[$user, $runTenant] = createUserWithTenant(tenant: $runTenant, role: 'owner'); [$user, $runTenant] = createMinimalUserWithTenant(tenant: $runTenant, role: 'owner');
$currentTenant = Tenant::factory()->onboarding()->create([ $currentTenant = Tenant::factory()->onboarding()->create([
'workspace_id' => (int) $runTenant->workspace_id, 'workspace_id' => (int) $runTenant->workspace_id,
'name' => 'Current Onboarding Tenant', 'name' => 'Current Onboarding Tenant',
]); ]);
createUserWithTenant( createMinimalUserWithTenant(
tenant: $currentTenant, tenant: $currentTenant,
user: $user, user: $user,
role: 'owner', role: 'owner',
@ -371,7 +371,7 @@
})->group('ops-ux'); })->group('ops-ux');
it('clears stale remembered tenant ids when the remembered tenant is no longer entitled', function (): void { it('clears stale remembered tenant ids when the remembered tenant is no longer entitled', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner'); [$user, $tenant] = createMinimalUserWithTenant(role: 'owner');
$staleTenant = Tenant::factory()->create([ $staleTenant = Tenant::factory()->create([
'workspace_id' => (int) $tenant->workspace_id, 'workspace_id' => (int) $tenant->workspace_id,
@ -400,7 +400,7 @@
'environment' => 'dev', 'environment' => 'dev',
'status' => 'active', 'status' => 'active',
]); ]);
[$user, $rememberedTenant] = createUserWithTenant(tenant: $rememberedTenant, role: 'owner'); [$user, $rememberedTenant] = createMinimalUserWithTenant(tenant: $rememberedTenant, role: 'owner');
$routedTenant = Tenant::factory()->create([ $routedTenant = Tenant::factory()->create([
'workspace_id' => (int) $rememberedTenant->workspace_id, 'workspace_id' => (int) $rememberedTenant->workspace_id,
@ -409,7 +409,7 @@
'status' => 'active', 'status' => 'active',
]); ]);
createUserWithTenant(tenant: $routedTenant, user: $user, role: 'owner'); createMinimalUserWithTenant(tenant: $routedTenant, user: $user, role: 'owner');
$this->actingAs($user); $this->actingAs($user);
Filament::setTenant(null, true); Filament::setTenant(null, true);
@ -438,7 +438,7 @@
'environment' => 'dev', 'environment' => 'dev',
'status' => 'active', 'status' => 'active',
]); ]);
[$user, $rememberedTenant] = createUserWithTenant(tenant: $rememberedTenant, role: 'owner'); [$user, $rememberedTenant] = createMinimalUserWithTenant(tenant: $rememberedTenant, role: 'owner');
$routedTenant = Tenant::factory()->create([ $routedTenant = Tenant::factory()->create([
'workspace_id' => (int) $rememberedTenant->workspace_id, 'workspace_id' => (int) $rememberedTenant->workspace_id,
@ -447,7 +447,7 @@
'status' => Tenant::STATUS_ONBOARDING, 'status' => Tenant::STATUS_ONBOARDING,
]); ]);
createUserWithTenant(tenant: $routedTenant, user: $user, role: 'owner'); createMinimalUserWithTenant(tenant: $routedTenant, user: $user, role: 'owner');
$this->actingAs($user); $this->actingAs($user);
Filament::setTenant($rememberedTenant, true); Filament::setTenant($rememberedTenant, true);
@ -471,7 +471,7 @@
})->group('ops-ux'); })->group('ops-ux');
it('shows tenant filter label when tenant context is active', function (): void { it('shows tenant filter label when tenant context is active', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner'); [$user, $tenant] = createMinimalUserWithTenant(role: 'owner');
$this->actingAs($user); $this->actingAs($user);
Filament::setTenant($tenant, true); Filament::setTenant($tenant, true);
@ -487,7 +487,7 @@
})->group('ops-ux'); })->group('ops-ux');
it('does not create audit entries when viewing operate hub pages', function (): void { it('does not create audit entries when viewing operate hub pages', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner'); [$user, $tenant] = createMinimalUserWithTenant(role: 'owner');
$run = OperationRun::factory()->create([ $run = OperationRun::factory()->create([
'tenant_id' => (int) $tenant->getKey(), 'tenant_id' => (int) $tenant->getKey(),
@ -527,7 +527,7 @@
})->group('ops-ux'); })->group('ops-ux');
it('suppresses tenant indicator on alert rules list page (manage page)', function (): void { it('suppresses tenant indicator on alert rules list page (manage page)', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner'); [$user, $tenant] = createMinimalUserWithTenant(role: 'owner');
$this->actingAs($user); $this->actingAs($user);
Filament::setTenant($tenant, true); Filament::setTenant($tenant, true);
@ -542,7 +542,7 @@
})->group('ops-ux'); })->group('ops-ux');
it('suppresses tenant indicator on alert destinations list page (manage page)', function (): void { it('suppresses tenant indicator on alert destinations list page (manage page)', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner'); [$user, $tenant] = createMinimalUserWithTenant(role: 'owner');
$this->actingAs($user); $this->actingAs($user);
Filament::setTenant($tenant, true); Filament::setTenant($tenant, true);
@ -557,7 +557,7 @@
})->group('ops-ux'); })->group('ops-ux');
it('suppresses tenant indicator on alert rules list with lastTenantId fallback', function (): void { it('suppresses tenant indicator on alert rules list with lastTenantId fallback', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner'); [$user, $tenant] = createMinimalUserWithTenant(role: 'owner');
$this->actingAs($user); $this->actingAs($user);
Filament::setTenant(null, true); Filament::setTenant(null, true);
@ -578,14 +578,14 @@
$runTenant = Tenant::factory()->active()->create([ $runTenant = Tenant::factory()->active()->create([
'name' => 'Canonical Run Tenant', 'name' => 'Canonical Run Tenant',
]); ]);
[$user, $runTenant] = createUserWithTenant(tenant: $runTenant, role: 'owner'); [$user, $runTenant] = createMinimalUserWithTenant(tenant: $runTenant, role: 'owner');
$rememberedTenant = Tenant::factory()->onboarding()->create([ $rememberedTenant = Tenant::factory()->onboarding()->create([
'workspace_id' => (int) $runTenant->workspace_id, 'workspace_id' => (int) $runTenant->workspace_id,
'name' => 'Stale Onboarding Tenant', 'name' => 'Stale Onboarding Tenant',
]); ]);
createUserWithTenant( createMinimalUserWithTenant(
tenant: $rememberedTenant, tenant: $rememberedTenant,
user: $user, user: $user,
role: 'owner', role: 'owner',

View File

@ -29,7 +29,7 @@ function buildJobComparison(array $permissions = [], string $overallStatus = 'mi
// (1) Successful run creates OperationRun with correct type and outcome // (1) Successful run creates OperationRun with correct type and outcome
it('creates OperationRun with correct type and outcome on success', function (): void { it('creates OperationRun with correct type and outcome on success', function (): void {
[$user, $tenant] = createUserWithTenant(); [$user, $tenant] = createUserWithTenant(fixtureProfile: 'provider-enabled');
$comparison = buildJobComparison([ $comparison = buildJobComparison([
['key' => 'Perm.A', 'type' => 'application', 'status' => 'missing', 'features' => ['a']], ['key' => 'Perm.A', 'type' => 'application', 'status' => 'missing', 'features' => ['a']],
@ -79,7 +79,7 @@ function buildJobComparison(array $permissions = [], string $overallStatus = 'mi
// (3) Records summary counts on OperationRun // (3) Records summary counts on OperationRun
it('records summary counts on OperationRun', function (): void { it('records summary counts on OperationRun', function (): void {
[$user, $tenant] = createUserWithTenant(); [$user, $tenant] = createUserWithTenant(fixtureProfile: 'provider-enabled');
$comparison = buildJobComparison([ $comparison = buildJobComparison([
['key' => 'Perm.A', 'type' => 'application', 'status' => 'missing', 'features' => ['a']], ['key' => 'Perm.A', 'type' => 'application', 'status' => 'missing', 'features' => ['a']],
@ -107,7 +107,7 @@ function buildJobComparison(array $permissions = [], string $overallStatus = 'mi
// (4) Handles generator exceptions gracefully // (4) Handles generator exceptions gracefully
it('marks OperationRun as failed on exception', function (): void { it('marks OperationRun as failed on exception', function (): void {
[$user, $tenant] = createUserWithTenant(); [$user, $tenant] = createUserWithTenant(fixtureProfile: 'provider-enabled');
$this->mock(FindingGeneratorContract::class, function (MockInterface $mock): void { $this->mock(FindingGeneratorContract::class, function (MockInterface $mock): void {
$mock->shouldReceive('generate')->andThrow(new RuntimeException('Test error')); $mock->shouldReceive('generate')->andThrow(new RuntimeException('Test error'));
@ -140,7 +140,7 @@ function buildJobComparison(array $permissions = [], string $overallStatus = 'mi
it('dispatches posture job from health check job', function (): void { it('dispatches posture job from health check job', function (): void {
Queue::fake([GeneratePermissionPostureFindingsJob::class]); Queue::fake([GeneratePermissionPostureFindingsJob::class]);
[$user, $tenant] = createUserWithTenant(); [$user, $tenant] = createUserWithTenant(fixtureProfile: 'provider-enabled');
// The actual dispatch is tested by verifying the hook exists in the source // The actual dispatch is tested by verifying the hook exists in the source
// (integration test will cover the full flow in T033) // (integration test will cover the full flow in T033)

View File

@ -15,7 +15,7 @@
uses(RefreshDatabase::class); uses(RefreshDatabase::class);
it('full posture check flow: findings, report, score, operations', function (): void { it('full posture check flow: findings, report, score, operations', function (): void {
[$user, $tenant] = createUserWithTenant(); [$user, $tenant] = createUserWithTenant(fixtureProfile: 'provider-enabled');
$comparison = [ $comparison = [
'overall_status' => 'missing', 'overall_status' => 'missing',
@ -77,7 +77,7 @@
}); });
it('second run auto-resolves findings for newly granted permissions', function (): void { it('second run auto-resolves findings for newly granted permissions', function (): void {
[$user, $tenant] = createUserWithTenant(); [$user, $tenant] = createUserWithTenant(fixtureProfile: 'provider-enabled');
$generator = app(FindingGeneratorContract::class); $generator = app(FindingGeneratorContract::class);
@ -124,7 +124,7 @@
}); });
it('alert events are produced for new missing permission findings', function (): void { it('alert events are produced for new missing permission findings', function (): void {
[$user, $tenant] = createUserWithTenant(); [$user, $tenant] = createUserWithTenant(fixtureProfile: 'provider-enabled');
$generator = app(FindingGeneratorContract::class); $generator = app(FindingGeneratorContract::class);

View File

@ -7,7 +7,7 @@
describe('Provider connections create action UI enforcement', function () { describe('Provider connections create action UI enforcement', function () {
it('shows create action as visible but disabled for readonly members', function () { it('shows create action as visible but disabled for readonly members', function () {
[$user, $tenant] = createUserWithTenant(role: 'readonly'); [$user, $tenant] = createUserWithTenant(role: 'readonly', fixtureProfile: 'provider-enabled');
$this->actingAs($user); $this->actingAs($user);
$tenant->makeCurrent(); $tenant->makeCurrent();
@ -22,7 +22,7 @@
}); });
it('shows create action as enabled for owner members', function () { it('shows create action as enabled for owner members', function () {
[$user, $tenant] = createUserWithTenant(role: 'owner'); [$user, $tenant] = createUserWithTenant(role: 'owner', fixtureProfile: 'provider-enabled');
$this->actingAs($user); $this->actingAs($user);
$tenant->makeCurrent(); $tenant->makeCurrent();
@ -34,7 +34,7 @@
}); });
it('hides create action after membership is revoked mid-session', function () { it('hides create action after membership is revoked mid-session', function () {
[$user, $tenant] = createUserWithTenant(role: 'owner'); [$user, $tenant] = createUserWithTenant(role: 'owner', fixtureProfile: 'provider-enabled');
$this->actingAs($user); $this->actingAs($user);
$tenant->makeCurrent(); $tenant->makeCurrent();

View File

@ -2,7 +2,7 @@
use App\Models\TenantPermission; use App\Models\TenantPermission;
it('renders required permissions overview with missing-first ordering and clickable feature cards', function (): void { it('renders required permissions overview with missing-first ordering and feature cards', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'readonly'); [$user, $tenant] = createUserWithTenant(role: 'readonly');
$configured = config('intune_permissions.permissions', []); $configured = config('intune_permissions.permissions', []);
@ -29,6 +29,5 @@
->get("/admin/tenants/{$tenant->external_id}/required-permissions?status=all") ->get("/admin/tenants/{$tenant->external_id}/required-permissions?status=all")
->assertSuccessful() ->assertSuccessful()
->assertSee('Blocked', false) ->assertSee('Blocked', false)
->assertSee('applyFeatureFilter', false)
->assertSeeInOrder([$missingKey, $grantedKey], false); ->assertSeeInOrder([$missingKey, $grantedKey], false);
}); });

View File

@ -24,7 +24,7 @@
}); });
it('allows workspace members to open the workspace-managed tenants index', function (): void { it('allows workspace members to open the workspace-managed tenants index', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner'); [$user, $tenant] = createMinimalUserWithTenant(role: 'owner');
$this->actingAs($user) $this->actingAs($user)
->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id]) ->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id])
@ -43,7 +43,7 @@
}); });
it('allows workspace members to open the workspace-managed tenant view route', function (): void { it('allows workspace members to open the workspace-managed tenant view route', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner'); [$user, $tenant] = createMinimalUserWithTenant(role: 'owner');
$this->actingAs($user) $this->actingAs($user)
->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id]) ->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id])
@ -52,7 +52,7 @@
}); });
it('exposes a provider connections link from the workspace-managed tenant view page', function (): void { it('exposes a provider connections link from the workspace-managed tenant view page', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner'); [$user, $tenant] = createMinimalUserWithTenant(role: 'owner');
$this->actingAs($user) $this->actingAs($user)
->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id]) ->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id])
@ -72,7 +72,7 @@
}); });
it('exposes memberships management under workspace scope', function (): void { it('exposes memberships management under workspace scope', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner'); [$user, $tenant] = createMinimalUserWithTenant(role: 'owner');
$this->actingAs($user) $this->actingAs($user)
->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id]) ->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id])
@ -89,7 +89,7 @@
'tenant_id' => '11111111-1111-1111-1111-111111111111', 'tenant_id' => '11111111-1111-1111-1111-111111111111',
]); ]);
[$entitledUser] = createUserWithTenant($tenant, role: 'readonly'); [$entitledUser] = createMinimalUserWithTenant(tenant: $tenant, role: 'readonly');
$nonEntitledUser = User::factory()->create(); $nonEntitledUser = User::factory()->create();
WorkspaceMembership::factory()->create([ WorkspaceMembership::factory()->create([
@ -120,7 +120,7 @@
}); });
it('keeps tenant panel route shape canonical and rejects duplicated /t prefixes', function (): void { it('keeps tenant panel route shape canonical and rejects duplicated /t prefixes', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner'); [$user, $tenant] = createMinimalUserWithTenant(role: 'owner');
$this->actingAs($user) $this->actingAs($user)
->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id]) ->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id])
@ -134,7 +134,7 @@
}); });
it('removes tenant-scoped management routes', function (): void { it('removes tenant-scoped management routes', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner'); [$user, $tenant] = createMinimalUserWithTenant(role: 'owner');
$this->actingAs($user) $this->actingAs($user)
->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id]) ->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id])
@ -153,7 +153,7 @@
}); });
it('serves provider connection management under workspace-managed tenant routes only', function (): void { it('serves provider connection management under workspace-managed tenant routes only', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner'); [$user, $tenant] = createMinimalUserWithTenant(role: 'owner');
$connection = ProviderConnection::factory()->create([ $connection = ProviderConnection::factory()->create([
'workspace_id' => (int) $tenant->workspace_id, 'workspace_id' => (int) $tenant->workspace_id,
@ -174,7 +174,7 @@
}); });
it('returns 403 for workspace members missing mutation capability on provider connections', function (): void { it('returns 403 for workspace members missing mutation capability on provider connections', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'readonly', workspaceRole: 'readonly'); [$user, $tenant] = createMinimalUserWithTenant(role: 'readonly', workspaceRole: 'readonly');
$this->followingRedirects() $this->followingRedirects()
->actingAs($user) ->actingAs($user)
@ -190,7 +190,7 @@
}); });
it('writes canonical membership audit entries for membership mutations', function (): void { it('writes canonical membership audit entries for membership mutations', function (): void {
[$owner, $tenant] = createUserWithTenant(role: 'owner'); [$owner, $tenant] = createMinimalUserWithTenant(role: 'owner');
$member = User::factory()->create(); $member = User::factory()->create();
/** @var TenantMembershipManager $manager */ /** @var TenantMembershipManager $manager */
@ -233,7 +233,7 @@
}); });
it('keeps workspace navigation entries after panel split', function (): void { it('keeps workspace navigation entries after panel split', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner'); [$user, $tenant] = createMinimalUserWithTenant(role: 'owner');
$this->actingAs($user) $this->actingAs($user)
->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id]) ->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id])
@ -251,7 +251,7 @@
expect($tenantPanelResources)->not->toContain(TenantResource::class); expect($tenantPanelResources)->not->toContain(TenantResource::class);
expect($tenantPanelResources)->not->toContain(ProviderConnectionResource::class); expect($tenantPanelResources)->not->toContain(ProviderConnectionResource::class);
[$user, $tenant] = createUserWithTenant(role: 'owner'); [$user, $tenant] = createMinimalUserWithTenant(role: 'owner');
$this->actingAs($user) $this->actingAs($user)
->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id]) ->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id])
@ -262,7 +262,7 @@
}); });
it('keeps global search scoped to workspace-managed tenant resources only', function (): void { it('keeps global search scoped to workspace-managed tenant resources only', function (): void {
[$workspaceUser, $tenant] = createUserWithTenant(role: 'owner'); [$workspaceUser, $tenant] = createMinimalUserWithTenant(role: 'owner');
Filament::setCurrentPanel('admin'); Filament::setCurrentPanel('admin');
Filament::setTenant(null, true); Filament::setTenant(null, true);

View File

@ -75,6 +75,51 @@
pest()->extend(Tests\TestCase::class) pest()->extend(Tests\TestCase::class)
->in('Deprecation'); ->in('Deprecation');
pest()->group('browser')
->in('Browser');
pest()->group('ui-light')
->in(
'Feature/Filament/BackupSetAdminTenantParityTest.php',
);
pest()->group('ui-workflow')
->in(
'Feature/Baselines/BaselineCompareMatrixCompareAllActionTest.php',
'Feature/Baselines/BaselineCompareMatrixBuilderTest.php',
'Feature/Drift/DriftBulkAcknowledgeAllMatchingConfirmationTest.php',
'Feature/Findings/FindingExceptionRenewalTest.php',
'Feature/Findings/FindingsListFiltersTest.php',
'Feature/Findings/FindingWorkflowRowActionsTest.php',
'Feature/Findings/FindingWorkflowViewActionsTest.php',
'Feature/Filament/BaselineActionAuthorizationTest.php',
'Feature/Filament/BaselineProfileCaptureStartSurfaceTest.php',
'Feature/Filament/BaselineProfileCompareStartSurfaceTest.php',
'Feature/Findings/FindingBulkActionsTest.php',
'Feature/SettingsFoundation/WorkspaceSettingsManageTest.php',
'Feature/Rbac/OnboardingWizardUiEnforcementTest.php',
);
pest()->group('surface-guard')
->in(
'Feature/OpsUx',
'Feature/Filament/Alerts/AlertsKpiHeaderTest.php',
'Feature/Filament/PanelNavigationSegregationTest.php',
'Feature/Filament/TenantReviewHeaderDisciplineTest.php',
'Feature/Filament/WorkspaceOnlySurfaceTenantIndependenceTest.php',
'Feature/Guards/ActionSurfaceContractTest.php',
'Feature/Guards/OperationLifecycleOpsUxGuardTest.php',
'Feature/ProviderConnections/CredentialLeakGuardTest.php',
'Feature/Rbac/BackupItemsRelationManagerUiEnforcementTest.php',
'Feature/Rbac/WorkspaceMembershipsRelationManagerUiEnforcementTest.php',
);
pest()->group('discovery-heavy')
->in(
'Feature/Filament/PolicyResourceAdminSearchParityTest.php',
'Feature/Filament/PolicyVersionAdminSearchParityTest.php',
);
beforeEach(function () { beforeEach(function () {
putenv('INTUNE_TENANT_ID'); putenv('INTUNE_TENANT_ID');
unset($_ENV['INTUNE_TENANT_ID'], $_SERVER['INTUNE_TENANT_ID']); unset($_ENV['INTUNE_TENANT_ID'], $_SERVER['INTUNE_TENANT_ID']);
@ -333,6 +378,327 @@ function createInventorySyncOperationRunWithCoverage(
return createInventorySyncOperationRun($tenant, $attributes); return createInventorySyncOperationRun($tenant, $attributes);
} }
/**
* @return array<string, array{workspace: bool, membership: bool, session: bool, provider: bool, credential: bool, cache: bool, uiContext: bool}>
*/
function createUserWithTenantProfileCatalog(): array
{
return [
'minimal' => [
'workspace' => true,
'membership' => true,
'session' => true,
'provider' => false,
'credential' => false,
'cache' => false,
'uiContext' => false,
],
'standard' => [
'workspace' => true,
'membership' => true,
'session' => true,
'provider' => true,
'credential' => false,
'cache' => false,
'uiContext' => false,
],
'full' => [
'workspace' => true,
'membership' => true,
'session' => true,
'provider' => true,
'credential' => true,
'cache' => true,
'uiContext' => true,
],
];
}
/**
* @return array<string, array{profile: string, overrides?: array{workspace?: bool, membership?: bool, session?: bool, provider?: bool, credential?: bool, cache?: bool, uiContext?: bool}, removalTrigger: string}>
*/
function createUserWithTenantLegacyProfileAliases(): array
{
return [
'provider-enabled' => [
'profile' => 'standard',
'removalTrigger' => 'Retire after the first fast-feedback and confidence migration packs use the canonical standard profile directly.',
],
'credential-enabled' => [
'profile' => 'full',
'overrides' => [
'cache' => false,
'uiContext' => false,
],
'removalTrigger' => 'Retire after the credential-dependent caller pack adopts createFullUserWithTenant() or fixtureProfile: full plus local overrides.',
],
'ui-context' => [
'profile' => 'full',
'overrides' => [
'provider' => false,
'credential' => false,
],
'removalTrigger' => 'Retire after UI-context callers switch to the canonical full profile or an explicit local UI-context helper.',
],
'heavy' => [
'profile' => 'full',
'removalTrigger' => 'Retire after legacy heavy callers migrate to the canonical full profile.',
],
];
}
/**
* @return array{requestedProfile: string, canonicalProfile: string, sideEffects: array{workspace: bool, membership: bool, session: bool, provider: bool, credential: bool, cache: bool, uiContext: bool}, legacyAlias: ?string, removalTrigger: ?string}
*/
function resolveCreateUserWithTenantProfile(string $fixtureProfile): array
{
$catalog = createUserWithTenantProfileCatalog();
if (array_key_exists($fixtureProfile, $catalog)) {
return [
'requestedProfile' => $fixtureProfile,
'canonicalProfile' => $fixtureProfile,
'sideEffects' => $catalog[$fixtureProfile],
'legacyAlias' => null,
'removalTrigger' => null,
];
}
$aliases = createUserWithTenantLegacyProfileAliases();
if (! array_key_exists($fixtureProfile, $aliases)) {
throw new \InvalidArgumentException(sprintf('Unknown fixture profile [%s].', $fixtureProfile));
}
$resolution = $aliases[$fixtureProfile];
$canonicalProfile = $resolution['profile'];
if (! array_key_exists($canonicalProfile, $catalog)) {
throw new \InvalidArgumentException(sprintf('Unknown canonical fixture profile [%s].', $canonicalProfile));
}
return [
'requestedProfile' => $fixtureProfile,
'canonicalProfile' => $canonicalProfile,
'sideEffects' => array_replace($catalog[$canonicalProfile], $resolution['overrides'] ?? []),
'legacyAlias' => $fixtureProfile,
'removalTrigger' => $resolution['removalTrigger'],
];
}
/**
* @return array<string, array{workspace: bool, membership: bool, session: bool, provider: bool, credential: bool, cache: bool, uiContext: bool}>
*/
function createUserWithTenantProfiles(): array
{
$profiles = createUserWithTenantProfileCatalog();
foreach (createUserWithTenantLegacyProfileAliases() as $alias => $resolution) {
$profiles[$alias] = array_replace(
$profiles[$resolution['profile']],
$resolution['overrides'] ?? [],
);
}
return $profiles;
}
/**
* @return array{0: User, 1: Tenant}
*/
function createMinimalUserWithTenant(
?Tenant $tenant = null,
?User $user = null,
string $role = 'owner',
?string $workspaceRole = null,
bool $ensureDefaultMicrosoftProviderConnection = false,
string $defaultProviderConnectionType = ProviderConnectionType::Dedicated->value,
bool $ensureDefaultCredential = false,
?bool $clearCapabilityCaches = null,
?bool $setUiContext = null,
): array {
return createUserWithTenant(
tenant: $tenant,
user: $user,
role: $role,
workspaceRole: $workspaceRole,
ensureDefaultMicrosoftProviderConnection: $ensureDefaultMicrosoftProviderConnection,
defaultProviderConnectionType: $defaultProviderConnectionType,
fixtureProfile: 'minimal',
ensureDefaultCredential: $ensureDefaultCredential,
clearCapabilityCaches: $clearCapabilityCaches,
setUiContext: $setUiContext,
);
}
/**
* @return array{0: User, 1: Tenant}
*/
function createStandardUserWithTenant(
?Tenant $tenant = null,
?User $user = null,
string $role = 'owner',
?string $workspaceRole = null,
bool $ensureDefaultMicrosoftProviderConnection = false,
string $defaultProviderConnectionType = ProviderConnectionType::Dedicated->value,
bool $ensureDefaultCredential = false,
?bool $clearCapabilityCaches = null,
?bool $setUiContext = null,
): array {
return createUserWithTenant(
tenant: $tenant,
user: $user,
role: $role,
workspaceRole: $workspaceRole,
ensureDefaultMicrosoftProviderConnection: $ensureDefaultMicrosoftProviderConnection,
defaultProviderConnectionType: $defaultProviderConnectionType,
fixtureProfile: 'standard',
ensureDefaultCredential: $ensureDefaultCredential,
clearCapabilityCaches: $clearCapabilityCaches,
setUiContext: $setUiContext,
);
}
/**
* @return array{0: User, 1: Tenant}
*/
function createFullUserWithTenant(
?Tenant $tenant = null,
?User $user = null,
string $role = 'owner',
?string $workspaceRole = null,
bool $ensureDefaultMicrosoftProviderConnection = false,
string $defaultProviderConnectionType = ProviderConnectionType::Dedicated->value,
bool $ensureDefaultCredential = false,
?bool $clearCapabilityCaches = null,
?bool $setUiContext = null,
): array {
return createUserWithTenant(
tenant: $tenant,
user: $user,
role: $role,
workspaceRole: $workspaceRole,
ensureDefaultMicrosoftProviderConnection: $ensureDefaultMicrosoftProviderConnection,
defaultProviderConnectionType: $defaultProviderConnectionType,
fixtureProfile: 'full',
ensureDefaultCredential: $ensureDefaultCredential,
clearCapabilityCaches: $clearCapabilityCaches,
setUiContext: $setUiContext,
);
}
/**
* @return array{0: User, 1: Tenant}
*/
function createProviderEnabledUserWithTenant(
?Tenant $tenant = null,
?User $user = null,
string $role = 'owner',
?string $workspaceRole = null,
bool $ensureDefaultMicrosoftProviderConnection = false,
string $defaultProviderConnectionType = ProviderConnectionType::Dedicated->value,
bool $ensureDefaultCredential = false,
?bool $clearCapabilityCaches = null,
?bool $setUiContext = null,
): array {
return createUserWithTenant(
tenant: $tenant,
user: $user,
role: $role,
workspaceRole: $workspaceRole,
ensureDefaultMicrosoftProviderConnection: $ensureDefaultMicrosoftProviderConnection,
defaultProviderConnectionType: $defaultProviderConnectionType,
fixtureProfile: 'provider-enabled',
ensureDefaultCredential: $ensureDefaultCredential,
clearCapabilityCaches: $clearCapabilityCaches,
setUiContext: $setUiContext,
);
}
/**
* @return array{0: User, 1: Tenant}
*/
function createCredentialEnabledUserWithTenant(
?Tenant $tenant = null,
?User $user = null,
string $role = 'owner',
?string $workspaceRole = null,
bool $ensureDefaultMicrosoftProviderConnection = false,
string $defaultProviderConnectionType = ProviderConnectionType::Dedicated->value,
bool $ensureDefaultCredential = false,
?bool $clearCapabilityCaches = null,
?bool $setUiContext = null,
): array {
return createUserWithTenant(
tenant: $tenant,
user: $user,
role: $role,
workspaceRole: $workspaceRole,
ensureDefaultMicrosoftProviderConnection: $ensureDefaultMicrosoftProviderConnection,
defaultProviderConnectionType: $defaultProviderConnectionType,
fixtureProfile: 'credential-enabled',
ensureDefaultCredential: $ensureDefaultCredential,
clearCapabilityCaches: $clearCapabilityCaches,
setUiContext: $setUiContext,
);
}
/**
* @return array{0: User, 1: Tenant}
*/
function createUiContextUserWithTenant(
?Tenant $tenant = null,
?User $user = null,
string $role = 'owner',
?string $workspaceRole = null,
bool $ensureDefaultMicrosoftProviderConnection = false,
string $defaultProviderConnectionType = ProviderConnectionType::Dedicated->value,
bool $ensureDefaultCredential = false,
?bool $clearCapabilityCaches = null,
?bool $setUiContext = null,
): array {
return createUserWithTenant(
tenant: $tenant,
user: $user,
role: $role,
workspaceRole: $workspaceRole,
ensureDefaultMicrosoftProviderConnection: $ensureDefaultMicrosoftProviderConnection,
defaultProviderConnectionType: $defaultProviderConnectionType,
fixtureProfile: 'ui-context',
ensureDefaultCredential: $ensureDefaultCredential,
clearCapabilityCaches: $clearCapabilityCaches,
setUiContext: $setUiContext,
);
}
/**
* @return array{0: User, 1: Tenant}
*/
function createHeavyUserWithTenant(
?Tenant $tenant = null,
?User $user = null,
string $role = 'owner',
?string $workspaceRole = null,
bool $ensureDefaultMicrosoftProviderConnection = false,
string $defaultProviderConnectionType = ProviderConnectionType::Dedicated->value,
bool $ensureDefaultCredential = false,
?bool $clearCapabilityCaches = null,
?bool $setUiContext = null,
): array {
return createUserWithTenant(
tenant: $tenant,
user: $user,
role: $role,
workspaceRole: $workspaceRole,
ensureDefaultMicrosoftProviderConnection: $ensureDefaultMicrosoftProviderConnection,
defaultProviderConnectionType: $defaultProviderConnectionType,
fixtureProfile: 'heavy',
ensureDefaultCredential: $ensureDefaultCredential,
clearCapabilityCaches: $clearCapabilityCaches,
setUiContext: $setUiContext,
);
}
/** /**
* @return array{0: User, 1: Tenant} * @return array{0: User, 1: Tenant}
*/ */
@ -341,9 +707,15 @@ function createUserWithTenant(
?User $user = null, ?User $user = null,
string $role = 'owner', string $role = 'owner',
?string $workspaceRole = null, ?string $workspaceRole = null,
bool $ensureDefaultMicrosoftProviderConnection = true, bool $ensureDefaultMicrosoftProviderConnection = false,
string $defaultProviderConnectionType = ProviderConnectionType::Dedicated->value, string $defaultProviderConnectionType = ProviderConnectionType::Dedicated->value,
string $fixtureProfile = 'minimal',
bool $ensureDefaultCredential = false,
?bool $clearCapabilityCaches = null,
?bool $setUiContext = null,
): array { ): array {
$resolvedProfile = resolveCreateUserWithTenantProfile($fixtureProfile);
$profile = $resolvedProfile['sideEffects'];
$user ??= User::factory()->create(); $user ??= User::factory()->create();
$tenant ??= Tenant::factory()->create(); $tenant ??= Tenant::factory()->create();
@ -372,25 +744,48 @@ function createUserWithTenant(
])->save(); ])->save();
} }
if ($profile['membership']) {
WorkspaceMembership::query()->updateOrCreate([ WorkspaceMembership::query()->updateOrCreate([
'workspace_id' => (int) $workspace->getKey(), 'workspace_id' => (int) $workspace->getKey(),
'user_id' => (int) $user->getKey(), 'user_id' => (int) $user->getKey(),
], [ ], [
'role' => $workspaceRole, 'role' => $workspaceRole,
]); ]);
}
if ($profile['session']) {
session()->put(WorkspaceContext::SESSION_KEY, (int) $workspace->getKey()); session()->put(WorkspaceContext::SESSION_KEY, (int) $workspace->getKey());
$user->forceFill(['last_workspace_id' => (int) $workspace->getKey()])->save(); $user->forceFill(['last_workspace_id' => (int) $workspace->getKey()])->save();
}
$user->tenants()->syncWithoutDetaching([ $user->tenants()->syncWithoutDetaching([
$tenant->getKey() => ['role' => $role], $tenant->getKey() => ['role' => $role],
]); ]);
$shouldClearCapabilityCaches = $clearCapabilityCaches ?? $profile['cache'];
if ($shouldClearCapabilityCaches) {
app(CapabilityResolver::class)->clearCache(); app(CapabilityResolver::class)->clearCache();
app(WorkspaceCapabilityResolver::class)->clearCache(); app(WorkspaceCapabilityResolver::class)->clearCache();
}
if ($ensureDefaultMicrosoftProviderConnection) { $shouldEnsureProviderConnection = $ensureDefaultMicrosoftProviderConnection || $profile['provider'];
ensureDefaultProviderConnection($tenant, 'microsoft', $defaultProviderConnectionType); $shouldEnsureCredential = $ensureDefaultCredential || $profile['credential'];
if ($shouldEnsureProviderConnection) {
ensureDefaultProviderConnection(
$tenant,
'microsoft',
$defaultProviderConnectionType,
$shouldEnsureCredential,
);
}
$shouldSetUiContext = $setUiContext ?? $profile['uiContext'];
if ($shouldSetUiContext) {
$tenant->makeCurrent();
Filament::setTenant($tenant, true);
} }
return [$user, $tenant]; return [$user, $tenant];
@ -543,6 +938,7 @@ function ensureDefaultProviderConnection(
Tenant $tenant, Tenant $tenant,
string $provider = 'microsoft', string $provider = 'microsoft',
string $connectionType = ProviderConnectionType::Dedicated->value, string $connectionType = ProviderConnectionType::Dedicated->value,
bool $ensureCredential = true,
): ProviderConnection { ): ProviderConnection {
$resolvedConnectionType = ProviderConnectionType::tryFrom($connectionType) ?? ProviderConnectionType::Dedicated; $resolvedConnectionType = ProviderConnectionType::tryFrom($connectionType) ?? ProviderConnectionType::Dedicated;
@ -638,6 +1034,15 @@ function ensureDefaultProviderConnection(
return $connection; return $connection;
} }
if (! $ensureCredential) {
if ($credential instanceof ProviderCredential) {
$credential->delete();
$connection->refresh();
}
return $connection;
}
if (! $credential instanceof ProviderCredential) { if (! $credential instanceof ProviderCredential) {
ProviderCredential::factory()->create([ ProviderCredential::factory()->create([
'provider_connection_id' => (int) $connection->getKey(), 'provider_connection_id' => (int) $connection->getKey(),

View File

@ -0,0 +1,624 @@
<?php
declare(strict_types=1);
namespace Tests\Support;
use InvalidArgumentException;
final class TestLaneBudget
{
public function __construct(
public readonly int $thresholdSeconds,
public readonly string $baselineSource,
public readonly string $enforcement,
public readonly string $lifecycleState,
public readonly ?int $baselineDeltaTargetPercent = null,
public readonly ?string $notes = null,
public readonly ?string $reviewCadence = null,
) {
}
/**
* @param array<string, mixed> $budget
*/
public static function fromArray(array $budget): self
{
if (! isset($budget['thresholdSeconds'], $budget['baselineSource'], $budget['enforcement'], $budget['lifecycleState'])) {
throw new InvalidArgumentException('Budget declarations must define thresholdSeconds, baselineSource, enforcement, and lifecycleState.');
}
return new self(
thresholdSeconds: (int) $budget['thresholdSeconds'],
baselineSource: (string) $budget['baselineSource'],
enforcement: (string) $budget['enforcement'],
lifecycleState: (string) $budget['lifecycleState'],
baselineDeltaTargetPercent: isset($budget['baselineDeltaTargetPercent']) ? (int) $budget['baselineDeltaTargetPercent'] : null,
notes: isset($budget['notes']) ? (string) $budget['notes'] : null,
reviewCadence: isset($budget['reviewCadence']) ? (string) $budget['reviewCadence'] : null,
);
}
/**
* @return array<string, int|float|string>
*/
public function evaluate(float $measuredSeconds): array
{
$budgetStatus = 'within-budget';
if ($measuredSeconds > $this->thresholdSeconds) {
$budgetStatus = in_array($this->enforcement, ['report-only', 'warn'], true)
? 'warning'
: 'over-budget';
}
return array_filter([
'thresholdSeconds' => $this->thresholdSeconds,
'baselineSource' => $this->baselineSource,
'enforcement' => $this->enforcement,
'lifecycleState' => $this->lifecycleState,
'baselineDeltaTargetPercent' => $this->baselineDeltaTargetPercent,
'measuredSeconds' => round($measuredSeconds, 6),
'budgetStatus' => $budgetStatus,
'notes' => $this->notes,
'reviewCadence' => $this->reviewCadence,
], static fn (mixed $value): bool => $value !== null);
}
/**
* @param array<string, mixed> $budgetTarget
* @return array<string, int|float|string>
*/
public static function evaluateBudgetTarget(array $budgetTarget, float $measuredSeconds): array
{
if (! isset($budgetTarget['targetType'], $budgetTarget['targetId'])) {
throw new InvalidArgumentException('Budget targets must define targetType and targetId.');
}
$evaluation = self::fromArray($budgetTarget)->evaluate($measuredSeconds);
return array_merge([
'budgetId' => (string) ($budgetTarget['budgetId'] ?? sprintf('%s-%s', $budgetTarget['targetType'], $budgetTarget['targetId'])),
'targetType' => (string) $budgetTarget['targetType'],
'targetId' => (string) $budgetTarget['targetId'],
], $evaluation);
}
/**
* @param list<array<string, mixed>> $budgetTargets
* @param array<string, float> $classificationTotals
* @param array<string, float> $familyTotals
* @return list<array<string, int|float|string>>
*/
public static function evaluateBudgetTargets(
array $budgetTargets,
float $laneSeconds,
array $classificationTotals,
array $familyTotals,
): array {
$evaluations = [];
foreach ($budgetTargets as $budgetTarget) {
$targetType = (string) ($budgetTarget['targetType'] ?? '');
$targetId = (string) ($budgetTarget['targetId'] ?? '');
if ($targetType === '' || $targetId === '') {
continue;
}
$measuredSeconds = match ($targetType) {
'lane' => $laneSeconds,
'classification' => (float) ($classificationTotals[$targetId] ?? 0.0),
'family' => (float) ($familyTotals[$targetId] ?? 0.0),
default => 0.0,
};
$evaluations[] = self::evaluateBudgetTarget($budgetTarget, $measuredSeconds);
}
return $evaluations;
}
/**
* @param array<string, mixed> $contract
* @return array<string, int|float|string>
*/
public static function evaluateGovernanceContract(array $contract, float $measuredSeconds): array
{
foreach (['laneId', 'summaryThresholdSeconds', 'evaluationThresholdSeconds', 'normalizedThresholdSeconds'] as $requiredKey) {
if (! array_key_exists($requiredKey, $contract)) {
throw new InvalidArgumentException(sprintf('Governance contracts must define [%s].', $requiredKey));
}
}
$normalizedThresholdSeconds = (float) $contract['normalizedThresholdSeconds'];
if ($normalizedThresholdSeconds <= 0) {
throw new InvalidArgumentException('Governance contracts must define a positive normalizedThresholdSeconds value.');
}
$enforcementLevel = (string) ($contract['enforcementLevel'] ?? 'warn');
$budgetStatus = 'within-budget';
if ($measuredSeconds > $normalizedThresholdSeconds) {
$budgetStatus = in_array($enforcementLevel, ['report-only', 'warn'], true)
? 'warning'
: 'over-budget';
}
return array_filter([
'laneId' => (string) $contract['laneId'],
'summaryThresholdSeconds' => (float) $contract['summaryThresholdSeconds'],
'evaluationThresholdSeconds' => (float) $contract['evaluationThresholdSeconds'],
'normalizedThresholdSeconds' => $normalizedThresholdSeconds,
'baselineSource' => (string) ($contract['baselineSource'] ?? 'measured-lane'),
'enforcementLevel' => $enforcementLevel,
'lifecycleState' => (string) ($contract['lifecycleState'] ?? 'draft'),
'decisionStatus' => (string) ($contract['decisionStatus'] ?? 'pending'),
'measuredSeconds' => round($measuredSeconds, 6),
'budgetStatus' => $budgetStatus,
'reconciliationRationale' => isset($contract['reconciliationRationale']) ? (string) $contract['reconciliationRationale'] : null,
], static fn (mixed $value): bool => $value !== null);
}
/**
* @return list<array<string, int|float|string>>
*/
public static function enforcementProfiles(): array
{
$fastFeedbackBudget = TestLaneManifest::lane('fast-feedback')['budget'];
$confidenceBudget = TestLaneManifest::lane('confidence')['budget'];
$browserBudget = TestLaneManifest::lane('browser')['budget'];
$heavyGovernanceContract = TestLaneManifest::heavyGovernanceBudgetContract();
return [
[
'policyId' => 'pr-fast-feedback-budget',
'laneId' => 'fast-feedback',
'triggerClass' => 'pull-request',
'thresholdSource' => 'lane-budget',
'baseThresholdSeconds' => (int) $fastFeedbackBudget['thresholdSeconds'],
'varianceAllowanceSeconds' => 15,
'effectiveThresholdSeconds' => (int) $fastFeedbackBudget['thresholdSeconds'] + 15,
'enforcementMode' => 'hard-fail',
'lifecycleState' => (string) $fastFeedbackBudget['lifecycleState'],
'reviewCadence' => 'revisit after two stable CI pull request runs',
],
[
'policyId' => 'main-confidence-budget',
'laneId' => 'confidence',
'triggerClass' => 'mainline-push',
'thresholdSource' => 'lane-budget',
'baseThresholdSeconds' => (int) $confidenceBudget['thresholdSeconds'],
'varianceAllowanceSeconds' => 30,
'effectiveThresholdSeconds' => (int) $confidenceBudget['thresholdSeconds'] + 30,
'enforcementMode' => 'soft-warn',
'lifecycleState' => (string) $confidenceBudget['lifecycleState'],
'reviewCadence' => 'tighten after two stable dev runs',
],
[
'policyId' => 'heavy-governance-manual-budget',
'laneId' => 'heavy-governance',
'triggerClass' => 'manual',
'thresholdSource' => 'governance-contract',
'baseThresholdSeconds' => (int) round((float) $heavyGovernanceContract['normalizedThresholdSeconds']),
'varianceAllowanceSeconds' => 15,
'effectiveThresholdSeconds' => (int) round((float) $heavyGovernanceContract['normalizedThresholdSeconds']) + 15,
'enforcementMode' => 'soft-warn',
'lifecycleState' => (string) $heavyGovernanceContract['lifecycleState'],
'reviewCadence' => 'manual heavy validation must stabilize before schedule enablement',
],
[
'policyId' => 'heavy-governance-scheduled-budget',
'laneId' => 'heavy-governance',
'triggerClass' => 'scheduled',
'thresholdSource' => 'governance-contract',
'baseThresholdSeconds' => (int) round((float) $heavyGovernanceContract['normalizedThresholdSeconds']),
'varianceAllowanceSeconds' => 15,
'effectiveThresholdSeconds' => (int) round((float) $heavyGovernanceContract['normalizedThresholdSeconds']) + 15,
'enforcementMode' => 'trend-only',
'lifecycleState' => (string) $heavyGovernanceContract['lifecycleState'],
'reviewCadence' => 'convert from trend-only only after stable scheduled evidence exists',
],
[
'policyId' => 'browser-manual-budget',
'laneId' => 'browser',
'triggerClass' => 'manual',
'thresholdSource' => 'lane-budget',
'baseThresholdSeconds' => (int) $browserBudget['thresholdSeconds'],
'varianceAllowanceSeconds' => 20,
'effectiveThresholdSeconds' => (int) $browserBudget['thresholdSeconds'] + 20,
'enforcementMode' => 'soft-warn',
'lifecycleState' => (string) $browserBudget['lifecycleState'],
'reviewCadence' => 'tighten after two stable browser validation runs',
],
[
'policyId' => 'browser-scheduled-budget',
'laneId' => 'browser',
'triggerClass' => 'scheduled',
'thresholdSource' => 'lane-budget',
'baseThresholdSeconds' => (int) $browserBudget['thresholdSeconds'],
'varianceAllowanceSeconds' => 20,
'effectiveThresholdSeconds' => (int) $browserBudget['thresholdSeconds'] + 20,
'enforcementMode' => 'trend-only',
'lifecycleState' => (string) $browserBudget['lifecycleState'],
'reviewCadence' => 'convert from trend-only only after stable scheduled evidence exists',
],
];
}
/**
* @return array<string, int|float|string>
*/
public static function enforcementProfile(string $laneId, string $triggerClass): array
{
foreach (self::enforcementProfiles() as $profile) {
if ($profile['laneId'] === $laneId && $profile['triggerClass'] === $triggerClass) {
return $profile;
}
}
throw new InvalidArgumentException(sprintf('Unknown trigger-aware budget profile for lane [%s] and trigger [%s].', $laneId, $triggerClass));
}
/**
* @param array<string, int|float|string> $profile
* @return array<string, int|float|string|null>
*/
public static function evaluateTriggerAwareBudgetProfile(array $profile, float $measuredSeconds): array
{
foreach (['laneId', 'triggerClass', 'baseThresholdSeconds', 'varianceAllowanceSeconds', 'effectiveThresholdSeconds', 'enforcementMode', 'lifecycleState'] as $requiredKey) {
if (! array_key_exists($requiredKey, $profile)) {
throw new InvalidArgumentException(sprintf('Trigger-aware budget profiles must define [%s].', $requiredKey));
}
}
$baseThresholdSeconds = (float) $profile['baseThresholdSeconds'];
$effectiveThresholdSeconds = (float) $profile['effectiveThresholdSeconds'];
$budgetStatus = 'within-budget';
if ($measuredSeconds > $effectiveThresholdSeconds) {
$budgetStatus = 'over-budget';
} elseif ($measuredSeconds > $baseThresholdSeconds) {
$budgetStatus = 'warning';
}
$blockingStatus = match ((string) $profile['enforcementMode']) {
'hard-fail' => match ($budgetStatus) {
'over-budget' => 'blocking',
'warning' => 'non-blocking-warning',
default => 'informational',
},
'soft-warn' => $budgetStatus === 'within-budget' ? 'informational' : 'non-blocking-warning',
'trend-only' => 'informational',
default => throw new InvalidArgumentException(sprintf('Unknown enforcement mode [%s].', $profile['enforcementMode'])),
};
return array_merge($profile, [
'measuredSeconds' => round($measuredSeconds, 6),
'budgetStatus' => $budgetStatus,
'blockingStatus' => $blockingStatus,
'primaryFailureClassId' => $budgetStatus === 'within-budget' ? null : 'budget-breach',
]);
}
/**
* @return array<string, int|float|string|null>
*/
public static function evaluateLaneForTrigger(string $laneId, string $triggerClass, float $measuredSeconds): array
{
return self::evaluateTriggerAwareBudgetProfile(
self::enforcementProfile($laneId, $triggerClass),
$measuredSeconds,
);
}
/**
* @param array<string, mixed> $baselineSnapshot
* @param array<string, mixed> $currentSnapshot
* @return array<string, float>
*/
public static function compareSnapshots(array $baselineSnapshot, array $currentSnapshot): array
{
$baselineSeconds = round((float) ($baselineSnapshot['wallClockSeconds'] ?? 0.0), 6);
$currentSeconds = round((float) ($currentSnapshot['wallClockSeconds'] ?? 0.0), 6);
$deltaSeconds = round($currentSeconds - $baselineSeconds, 6);
$deltaPercent = $baselineSeconds > 0.0
? round(($deltaSeconds / $baselineSeconds) * 100, 6)
: 0.0;
return [
'baselineSeconds' => $baselineSeconds,
'currentSeconds' => $currentSeconds,
'deltaSeconds' => $deltaSeconds,
'deltaPercent' => $deltaPercent,
];
}
/**
* @param array<string, mixed> $contract
* @param array<string, mixed> $baselineSnapshot
* @param array<string, mixed> $currentSnapshot
* @param list<string> $remainingOpenFamilies
* @param list<string> $followUpDebt
* @return array<string, mixed>
*/
public static function buildOutcomeRecord(
array $contract,
array $baselineSnapshot,
array $currentSnapshot,
array $remainingOpenFamilies,
string $justification,
array $followUpDebt = [],
): array {
$comparison = self::compareSnapshots($baselineSnapshot, $currentSnapshot);
return array_filter([
'outcomeId' => sprintf('%s-final-outcome', (string) ($contract['laneId'] ?? 'heavy-governance')),
'decisionStatus' => (string) ($contract['decisionStatus'] ?? 'pending'),
'finalThresholdSeconds' => round((float) ($contract['normalizedThresholdSeconds'] ?? 0.0), 6),
'finalMeasuredSeconds' => $comparison['currentSeconds'],
'deltaSeconds' => $comparison['deltaSeconds'],
'deltaPercent' => $comparison['deltaPercent'],
'remainingOpenFamilies' => array_values($remainingOpenFamilies),
'justification' => $justification,
'followUpDebt' => $followUpDebt !== [] ? array_values($followUpDebt) : null,
], static fn (mixed $value): bool => $value !== null);
}
public static function trendVarianceFloorSeconds(string $laneId, ?string $triggerClass = null): int
{
$matchingProfiles = array_values(array_filter(
self::enforcementProfiles(),
static fn (array $profile): bool => $profile['laneId'] === $laneId
&& ($triggerClass === null || $profile['triggerClass'] === $triggerClass),
));
if ($matchingProfiles === [] && $triggerClass !== null) {
$matchingProfiles = array_values(array_filter(
self::enforcementProfiles(),
static fn (array $profile): bool => $profile['laneId'] === $laneId,
));
}
if ($matchingProfiles !== []) {
return (int) max(array_map(
static fn (array $profile): int => (int) ($profile['varianceAllowanceSeconds'] ?? 0),
$matchingProfiles,
));
}
return match ($laneId) {
'junit' => 30,
'profiling' => 45,
default => 15,
};
}
public static function nearBudgetHeadroomSeconds(string $laneId): int
{
return match ($laneId) {
'fast-feedback' => 20,
'confidence', 'junit' => 45,
'browser' => 25,
'heavy-governance' => 30,
'profiling' => 120,
default => max(self::trendVarianceFloorSeconds($laneId), 15),
};
}
/**
* @return array<string, mixed>
*/
public static function recalibrationPolicy(string $laneId): array
{
return [
'laneId' => $laneId,
'baselineRequiresExplicitReview' => true,
'budgetRequiresExplicitReview' => true,
'minimumBaselineEvidenceSamples' => 3,
'minimumBudgetEvidenceSamples' => $laneId === 'fast-feedback' ? 4 : 5,
'baselineAllowedRationales' => [
'lane-scope-change',
'infrastructure-shift',
'post-improvement-reset',
'manual-hold',
],
'approvedBaselineRationales' => [
'lane-scope-change',
'infrastructure-shift',
'post-improvement-reset',
],
'budgetAllowedRationales' => [
'infrastructure-shift',
'sustained-erosion',
'manual-hold',
],
'approvedBudgetRationales' => [
'infrastructure-shift',
'sustained-erosion',
],
'rejectedRationales' => [
'noise-rejected',
'manual-hold',
],
];
}
/**
* @param list<array<string, mixed>> $historyRecords
* @return array<string, mixed>
*/
public static function buildRecalibrationDecisionRecord(
string $laneId,
string $targetType,
array $assessment,
array $historyRecords,
string $decisionStatus,
string $rationaleCode,
string $recordedIn,
?float $proposedValueSeconds = null,
?string $notes = null,
): array {
if (! in_array($targetType, ['baseline', 'budget'], true)) {
throw new InvalidArgumentException(sprintf('Unknown recalibration target type [%s].', $targetType));
}
if (! in_array($decisionStatus, ['candidate', 'approved', 'rejected'], true)) {
throw new InvalidArgumentException(sprintf('Unknown recalibration decision status [%s].', $decisionStatus));
}
$policy = self::recalibrationPolicy($laneId);
$minimumEvidenceSamples = $targetType === 'budget'
? (int) $policy['minimumBudgetEvidenceSamples']
: (int) $policy['minimumBaselineEvidenceSamples'];
$minimumEvidenceSamples = $decisionStatus === 'rejected'
? 1
: $minimumEvidenceSamples;
$evidenceRunRefs = array_values(array_filter(array_map(
static fn (array $record): ?string => is_string($record['runRef'] ?? null) && $record['runRef'] !== ''
? (string) $record['runRef']
: null,
$historyRecords,
)));
$evidenceRunRefs = array_slice(array_values(array_unique($evidenceRunRefs)), 0, $minimumEvidenceSamples);
if (count($evidenceRunRefs) < $minimumEvidenceSamples) {
throw new InvalidArgumentException(sprintf(
'Recalibration decisions for [%s] require at least %d evidence samples.',
$targetType,
$minimumEvidenceSamples,
));
}
if ($decisionStatus === 'approved') {
$allowedRationales = $targetType === 'baseline'
? $policy['approvedBaselineRationales']
: $policy['approvedBudgetRationales'];
if (! in_array($rationaleCode, $allowedRationales, true)) {
throw new InvalidArgumentException(sprintf(
'Approved %s recalibration decisions must use one of [%s].',
$targetType,
implode(', ', $allowedRationales),
));
}
} elseif ($decisionStatus === 'rejected') {
if (! in_array($rationaleCode, $policy['rejectedRationales'], true)) {
throw new InvalidArgumentException('Rejected recalibration decisions must use a rejected rationale.');
}
} else {
$allowedRationales = $targetType === 'baseline'
? $policy['baselineAllowedRationales']
: $policy['budgetAllowedRationales'];
if (! in_array($rationaleCode, $allowedRationales, true)) {
throw new InvalidArgumentException(sprintf(
'Candidate %s recalibration decisions must use one of [%s].',
$targetType,
implode(', ', $allowedRationales),
));
}
}
$currentRecord = $historyRecords[0] ?? [];
$previousValueSeconds = $targetType === 'baseline'
? (float) ($currentRecord['baselineSeconds'] ?? $currentRecord['wallClockSeconds'] ?? 0.0)
: (float) ($currentRecord['budgetSeconds'] ?? 0.0);
$defaultNotes = match ($decisionStatus) {
'approved' => sprintf(
'Approved %s recalibration for lane [%s] after reviewing %d comparable samples.',
$targetType,
$laneId,
count($evidenceRunRefs),
),
'rejected' => sprintf(
'Rejected %s recalibration for lane [%s] because current evidence is not strong enough to move repository truth.',
$targetType,
$laneId,
),
default => sprintf(
'Candidate %s recalibration for lane [%s]. Review the active spec or PR before changing repository truth.',
$targetType,
$laneId,
),
};
return [
'targetType' => $targetType,
'decisionStatus' => $decisionStatus,
'evidenceRunRefs' => $evidenceRunRefs,
'previousValueSeconds' => round($previousValueSeconds, 6),
'proposedValueSeconds' => $proposedValueSeconds !== null ? round($proposedValueSeconds, 6) : null,
'rationaleCode' => $rationaleCode,
'recordedIn' => $recordedIn,
'notes' => $notes ?? $defaultNotes,
];
}
/**
* @param list<array<string, mixed>> $historyRecords
* @return list<array<string, mixed>>
*/
public static function automaticRecalibrationDecisions(
string $laneId,
array $assessment,
array $historyRecords,
string $recordedIn,
): array {
$recommendation = (string) ($assessment['recalibrationRecommendation'] ?? 'none');
$windowStatus = (string) ($assessment['windowStatus'] ?? 'stable');
$currentRecord = $historyRecords[0] ?? [];
$decisionRecords = [];
if ($recommendation === 'review-baseline') {
$decisionRecords[] = self::buildRecalibrationDecisionRecord(
laneId: $laneId,
targetType: 'baseline',
assessment: $assessment,
historyRecords: $historyRecords,
decisionStatus: 'candidate',
rationaleCode: 'manual-hold',
recordedIn: $recordedIn,
proposedValueSeconds: isset($currentRecord['wallClockSeconds']) ? (float) $currentRecord['wallClockSeconds'] : null,
notes: 'Candidate baseline review. Confirm lane-scope, infrastructure, or post-improvement evidence before approving any baseline reset.',
);
}
if ($recommendation === 'review-budget') {
$proposedBudgetSeconds = isset($currentRecord['wallClockSeconds'])
? (float) $currentRecord['wallClockSeconds'] + self::nearBudgetHeadroomSeconds($laneId)
: null;
$decisionRecords[] = self::buildRecalibrationDecisionRecord(
laneId: $laneId,
targetType: 'budget',
assessment: $assessment,
historyRecords: $historyRecords,
decisionStatus: 'candidate',
rationaleCode: 'sustained-erosion',
recordedIn: $recordedIn,
proposedValueSeconds: $proposedBudgetSeconds,
notes: 'Candidate budget review. Only approve after sustained erosion is confirmed and the active spec or PR records why the budget should move.',
);
}
if ($decisionRecords === [] && in_array($windowStatus, ['insufficient-history', 'noisy', 'scope-changed'], true)) {
$decisionRecords[] = self::buildRecalibrationDecisionRecord(
laneId: $laneId,
targetType: 'budget',
assessment: $assessment,
historyRecords: $historyRecords,
decisionStatus: 'rejected',
rationaleCode: $windowStatus === 'noisy' ? 'noise-rejected' : 'manual-hold',
recordedIn: $recordedIn,
notes: 'Recalibration is rejected for this cycle because the comparison window is not stable enough to justify moving repository truth.',
);
}
return $decisionRecords;
}
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,96 @@
<?php
declare(strict_types=1);
namespace Tests\Support;
final class TestLaneTrendFixtures
{
public static function artifactDirectory(string $suffix): string
{
return sprintf('storage/logs/test-lanes/%s', trim($suffix, '/'));
}
/**
* @param array<string, float> $durationsByFile
* @return list<array<string, mixed>>
*/
public static function slowestEntries(array $durationsByFile, string $laneId): array
{
$entries = array_values(array_map(
static fn (float $seconds, string $filePath): array => [
'label' => $filePath.'::synthetic',
'subject' => $filePath.'::synthetic',
'filePath' => $filePath,
'durationSeconds' => $seconds,
'wallClockSeconds' => $seconds,
'laneId' => $laneId,
],
$durationsByFile,
array_keys($durationsByFile),
));
usort($entries, static fn (array $left, array $right): int => $right['wallClockSeconds'] <=> $left['wallClockSeconds']);
return $entries;
}
/**
* @param array<string, float> $durationsByFile
* @param array<string, mixed>|null $ciContext
* @return array<string, mixed>
*/
public static function buildReport(
string $laneId,
float $wallClockSeconds,
array $durationsByFile = [],
?string $artifactDirectory = null,
?array $ciContext = null,
?string $comparisonProfile = null,
): array {
return TestLaneReport::buildReport(
laneId: $laneId,
wallClockSeconds: $wallClockSeconds,
slowestEntries: self::slowestEntries($durationsByFile, $laneId),
durationsByFile: $durationsByFile,
artifactDirectory: $artifactDirectory,
comparisonProfile: $comparisonProfile,
ciContext: $ciContext,
);
}
/**
* @param array<string, mixed> $artifact
*/
public static function writeTrendHistory(string $laneId, array $artifact, ?string $artifactDirectory = null): string
{
$artifactPaths = TestLaneReport::artifactPaths($laneId, $artifactDirectory);
$absolutePath = TestLaneManifest::absolutePath($artifactPaths['trendHistory']);
$directory = dirname($absolutePath);
if (! is_dir($directory)) {
mkdir($directory, 0777, true);
}
file_put_contents($absolutePath, json_encode($artifact, JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR));
return $absolutePath;
}
/**
* @return array<string, mixed>
*/
public static function readTrendHistory(string $laneId, ?string $artifactDirectory = null): array
{
$artifactPaths = TestLaneReport::artifactPaths($laneId, $artifactDirectory);
$absolutePath = TestLaneManifest::absolutePath($artifactPaths['trendHistory']);
if (! is_file($absolutePath)) {
return [];
}
$decoded = json_decode((string) file_get_contents($absolutePath), true);
return is_array($decoded) ? $decoded : [];
}
}

View File

@ -0,0 +1,30 @@
<?php
declare(strict_types=1);
use App\Models\BackupSet;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
it('keeps the default backup set factory path free of backup item side effects', function (): void {
$backupSet = BackupSet::factory()->create();
expect($backupSet->item_count)->toBe(0)
->and($backupSet->items()->count())->toBe(0);
});
it('can opt into a fuller backup graph with explicit items', function (): void {
$backupSet = BackupSet::factory()->full()->create();
expect($backupSet->item_count)->toBe(1)
->and($backupSet->items()->count())->toBe(1);
});
it('keeps stale and degraded backup item graphs behind explicit named states', function (): void {
$stale = BackupSet::factory()->staleCompleted()->create();
$degraded = BackupSet::factory()->degradedCompleted()->create();
expect($stale->items()->count())->toBe(1)
->and($degraded->items()->count())->toBe(1);
});

View File

@ -0,0 +1,40 @@
<?php
declare(strict_types=1);
use App\Models\OperationRun;
use App\Models\User;
use App\Models\Workspace;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
it('keeps the default operation run factory path lean by avoiding implicit user creation', function (): void {
$run = OperationRun::factory()->create();
expect($run->tenant_id)->not->toBeNull()
->and($run->workspace_id)->not->toBeNull()
->and($run->user_id)->toBeNull()
->and($run->initiator_name)->toBe('System');
});
it('can opt into an interactive operation context with an explicit user state', function (): void {
$user = User::factory()->create();
$run = OperationRun::factory()->withUser($user)->create();
expect($run->user_id)->toBe((int) $user->getKey())
->and($run->initiator_name)->toBe($user->name);
});
it('keeps tenantless workspace runs available through the explicit tenantless state', function (): void {
$workspace = Workspace::factory()->create();
$run = OperationRun::factory()
->minimal()
->tenantlessForWorkspace($workspace)
->create();
expect($run->tenant_id)->toBeNull()
->and($run->workspace_id)->toBe((int) $workspace->getKey());
});

View File

@ -0,0 +1,28 @@
<?php
declare(strict_types=1);
use App\Models\ProviderConnection;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
it('keeps the explicit minimal provider connection state free of credential side effects', function (): void {
$connection = ProviderConnection::factory()->minimal()->create();
expect($connection->credential()->exists())->toBeFalse();
});
it('keeps the explicit standard provider connection state healthy without credential side effects', function (): void {
$connection = ProviderConnection::factory()->standard()->create();
expect($connection->is_default)->toBeTrue()
->and($connection->credential()->exists())->toBeFalse();
});
it('can opt into a heavier provider graph with a checked-in full factory state', function (): void {
$connection = ProviderConnection::factory()->full()->create();
expect($connection->credential()->exists())->toBeTrue()
->and($connection->refresh()->is_default)->toBeTrue();
});

View File

@ -0,0 +1,37 @@
<?php
declare(strict_types=1);
use App\Models\ProviderCredential;
use App\Support\Providers\ProviderConnectionType;
use App\Support\Providers\ProviderCredentialSource;
use App\Support\Providers\ProviderVerificationStatus;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
it('keeps the default provider credential path tied to a dedicated connection without extra health side effects', function (): void {
$credential = ProviderCredential::factory()->create();
$connection = $credential->providerConnection()->first();
expect($connection)->not->toBeNull()
->and($connection?->connection_type->value)->toBe(ProviderConnectionType::Dedicated->value)
->and($connection?->is_default)->toBeFalse();
});
it('can opt into a verified dedicated connection graph explicitly', function (): void {
$credential = ProviderCredential::factory()->verifiedConnection()->create();
$connection = $credential->providerConnection()->first();
expect($connection)->not->toBeNull()
->and($connection?->is_default)->toBeTrue()
->and($connection?->verification_status->value)->toBe(ProviderVerificationStatus::Healthy->value)
->and($connection?->credential?->is($credential))->toBeTrue();
});
it('keeps legacy migrated credentials available through an explicit named state', function (): void {
$credential = ProviderCredential::factory()->legacyMigrated()->create();
expect($credential->source->value)->toBe(ProviderCredentialSource::LegacyMigrated->value)
->and($credential->expires_at)->toBeNull();
});

View File

@ -0,0 +1,28 @@
<?php
declare(strict_types=1);
use App\Models\Tenant;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
it('can create tenants without provisioning a workspace graph when the minimal state is explicit', function (): void {
$tenant = Tenant::factory()->minimal()->create();
expect($tenant->workspace_id)->toBeNull();
});
it('keeps the default tenant factory path workspace-ready for existing callers', function (): void {
$tenant = Tenant::factory()->create();
expect($tenant->workspace_id)->not->toBeNull();
});
it('restores default workspace provisioning after an explicit minimal tenant is created', function (): void {
Tenant::factory()->minimal()->create();
$tenant = Tenant::factory()->create();
expect($tenant->workspace_id)->not->toBeNull();
});

View File

@ -0,0 +1,122 @@
<?php
declare(strict_types=1);
use App\Models\ProviderConnection;
use App\Models\ProviderCredential;
use App\Models\WorkspaceMembership;
use App\Services\Auth\CapabilityResolver;
use App\Services\Auth\WorkspaceCapabilityResolver;
use App\Support\Workspaces\WorkspaceContext;
use Filament\Facades\Filament;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
beforeEach(function (): void {
Filament::setTenant(null, true);
});
it('keeps the default tenant helper profile cheap by skipping provider setup and cache clears', function (): void {
$capabilities = \Mockery::mock(CapabilityResolver::class);
$workspaceCapabilities = \Mockery::mock(WorkspaceCapabilityResolver::class);
$capabilities->shouldNotReceive('clearCache');
$workspaceCapabilities->shouldNotReceive('clearCache');
app()->instance(CapabilityResolver::class, $capabilities);
app()->instance(WorkspaceCapabilityResolver::class, $workspaceCapabilities);
[$user, $tenant] = createUserWithTenant();
expect(WorkspaceMembership::query()
->where('workspace_id', (int) $tenant->workspace_id)
->where('user_id', (int) $user->getKey())
->exists())->toBeTrue()
->and(session(WorkspaceContext::SESSION_KEY))->toBe((int) $tenant->workspace_id)
->and(ProviderConnection::query()->where('tenant_id', (int) $tenant->getKey())->exists())->toBeFalse();
});
it('opt-ins a standard provider context only when the canonical standard profile asks for it', function (): void {
$capabilities = \Mockery::mock(CapabilityResolver::class);
$workspaceCapabilities = \Mockery::mock(WorkspaceCapabilityResolver::class);
$capabilities->shouldNotReceive('clearCache');
$workspaceCapabilities->shouldNotReceive('clearCache');
app()->instance(CapabilityResolver::class, $capabilities);
app()->instance(WorkspaceCapabilityResolver::class, $workspaceCapabilities);
[, $tenant] = createStandardUserWithTenant();
$connection = ProviderConnection::query()
->where('tenant_id', (int) $tenant->getKey())
->where('is_default', true)
->first();
expect($connection)->not->toBeNull()
->and($connection?->credential()->exists())->toBeFalse()
->and(Filament::getTenant())->toBeNull();
});
it('keeps the credential-enabled alias explicit without forcing cache or ui side effects', function (): void {
$capabilities = \Mockery::mock(CapabilityResolver::class);
$workspaceCapabilities = \Mockery::mock(WorkspaceCapabilityResolver::class);
$capabilities->shouldNotReceive('clearCache');
$workspaceCapabilities->shouldNotReceive('clearCache');
app()->instance(CapabilityResolver::class, $capabilities);
app()->instance(WorkspaceCapabilityResolver::class, $workspaceCapabilities);
[, $tenant] = createCredentialEnabledUserWithTenant();
$connection = ProviderConnection::query()
->where('tenant_id', (int) $tenant->getKey())
->where('is_default', true)
->first();
expect($connection)->not->toBeNull()
->and($connection?->credential()->exists())->toBeTrue()
->and(ProviderCredential::query()->where('provider_connection_id', (int) $connection?->getKey())->exists())->toBeTrue()
->and(Filament::getTenant())->toBeNull();
});
it('keeps the ui-context alias explicit without provider or credential side effects', function (): void {
$capabilities = \Mockery::mock(CapabilityResolver::class);
$workspaceCapabilities = \Mockery::mock(WorkspaceCapabilityResolver::class);
$capabilities->shouldReceive('clearCache')->once();
$workspaceCapabilities->shouldReceive('clearCache')->once();
app()->instance(CapabilityResolver::class, $capabilities);
app()->instance(WorkspaceCapabilityResolver::class, $workspaceCapabilities);
[, $tenant] = createUiContextUserWithTenant();
expect(ProviderConnection::query()->where('tenant_id', (int) $tenant->getKey())->exists())->toBeFalse()
->and(Filament::getTenant()?->is($tenant))->toBeTrue();
});
it('opt-ins provider, credential, ui-context, and cache resets only when the canonical full profile asks for them', function (): void {
$capabilities = \Mockery::mock(CapabilityResolver::class);
$workspaceCapabilities = \Mockery::mock(WorkspaceCapabilityResolver::class);
$capabilities->shouldReceive('clearCache')->once();
$workspaceCapabilities->shouldReceive('clearCache')->once();
app()->instance(CapabilityResolver::class, $capabilities);
app()->instance(WorkspaceCapabilityResolver::class, $workspaceCapabilities);
[, $tenant] = createFullUserWithTenant();
$connection = ProviderConnection::query()
->where('tenant_id', (int) $tenant->getKey())
->where('is_default', true)
->first();
expect($connection)->not->toBeNull()
->and($connection?->credential()->exists())->toBeTrue()
->and(ProviderCredential::query()->where('provider_connection_id', (int) $connection?->getKey())->exists())->toBeTrue()
->and(Filament::getTenant()?->is($tenant))->toBeTrue();
});

View File

@ -0,0 +1,57 @@
<?php
declare(strict_types=1);
use Tests\Support\TestLaneBudget;
use Tests\Support\TestLaneManifest;
it('evaluates lane budgets into within-budget, warning, and over-budget states', function (): void {
$warnBudget = TestLaneBudget::fromArray([
'thresholdSeconds' => 30,
'baselineSource' => 'measured-current-suite',
'enforcement' => 'warn',
'lifecycleState' => 'documented',
]);
$hardFailBudget = TestLaneBudget::fromArray([
'thresholdSeconds' => 30,
'baselineSource' => 'measured-current-suite',
'enforcement' => 'hard-fail',
'lifecycleState' => 'documented',
]);
expect($warnBudget->evaluate(24.5)['budgetStatus'])->toBe('within-budget')
->and($warnBudget->evaluate(31.2)['budgetStatus'])->toBe('warning')
->and($hardFailBudget->evaluate(31.2)['budgetStatus'])->toBe('over-budget');
});
it('evaluates family targets through the generic budget target path', function (): void {
$familyTargets = array_values(array_filter(
TestLaneManifest::budgetTargets(),
static fn (array $target): bool => $target['targetType'] === 'family',
));
$evaluations = TestLaneBudget::evaluateBudgetTargets(
$familyTargets,
0.0,
[],
[
'ops-ux-governance' => 18.4,
'action-surface-contract' => 7.8,
'browser-smoke' => 14.2,
],
);
expect($evaluations)->not->toBeEmpty()
->and($evaluations[0])->toHaveKeys([
'budgetId',
'targetType',
'targetId',
'thresholdSeconds',
'baselineSource',
'enforcement',
'lifecycleState',
'measuredSeconds',
'budgetStatus',
]);
});

View File

@ -0,0 +1,76 @@
<?php
declare(strict_types=1);
use Tests\Support\TestLaneReport;
it('extracts at least the top ten slowest entries from a junit artifact', function (): void {
$junitPath = sys_get_temp_dir().'/tenantatlas-test-lane-junit.xml';
$testcases = [];
for ($index = 1; $index <= 12; $index++) {
$duration = number_format(0.5 + ($index / 10), 6, '.', '');
$testcases[] = sprintf(
'<testsuite name="Suite%d" file="tests/Feature/Fake/Fake%dTest.php" tests="1" assertions="1" errors="0" failures="0" skipped="0" time="%s"><testcase name="fake %d" file="tests/Feature/Fake/Fake%dTest.php::fake %d" class="Tests\\Feature\\Fake\\Fake%dTest" classname="Tests.Feature.Fake.Fake%dTest" assertions="1" time="%s"/></testsuite>',
$index,
$index,
$duration,
$index,
$index,
$index,
$index,
$index,
$duration,
);
}
file_put_contents($junitPath, "<?xml version=\"1.0\" encoding=\"UTF-8\"?><testsuites>".implode('', $testcases).'</testsuites>');
$parsed = TestLaneReport::parseJUnit($junitPath, 'junit');
expect($parsed['slowestEntries'])->toHaveCount(10)
->and($parsed['slowestEntries'][0]['durationSeconds'])->toBeGreaterThanOrEqual($parsed['slowestEntries'][9]['durationSeconds'])
->and($parsed['durationsByFile'])->toHaveCount(12);
});
it('builds a report payload and writes the summary plus budget artifacts under a target directory', function (): void {
$artifactDirectory = 'storage/logs/test-lanes-test';
$report = TestLaneReport::buildReport(
laneId: 'junit',
wallClockSeconds: 24.5,
slowestEntries: array_map(
static fn (int $index): array => [
'subject' => sprintf('tests/Feature/Fake/Fake%dTest.php', $index),
'durationSeconds' => 1 + ($index / 10),
'laneId' => 'junit',
],
range(1, 10),
),
durationsByFile: [
'tests/Feature/OpsUx/OperateHubShellTest.php' => 9.8,
'tests/Feature/Guards/ActionSurfaceContractTest.php' => 4.4,
],
);
$written = TestLaneReport::writeArtifacts('junit', $report, null, $artifactDirectory);
expect($report)->toHaveKeys([
'laneId',
'finishedAt',
'wallClockSeconds',
'budgetThresholdSeconds',
'budgetBaselineSource',
'budgetEnforcement',
'budgetLifecycleState',
'budgetStatus',
'slowestEntries',
'familyBudgetEvaluations',
'artifacts',
])
->and($report['slowestEntries'])->toHaveCount(10)
->and($written['summary'])->toStartWith($artifactDirectory.'/')
->and($written['budget'])->toStartWith($artifactDirectory.'/')
->and(file_exists(base_path($written['summary'])))->toBeTrue()
->and(file_exists(base_path($written['budget'])))->toBeTrue();
});

View File

@ -3,7 +3,7 @@ # Product Roadmap
> Strategic thematic blocks and release trajectory. > Strategic thematic blocks and release trajectory.
> This is the "big picture" — not individual specs. > This is the "big picture" — not individual specs.
**Last updated**: 2026-04-12 **Last updated**: 2026-04-17
--- ---
@ -76,6 +76,15 @@ ### R2 Completion — Evidence & Exception Workflows
- Formal "evidence pack" entity → **Not yet specced** - Formal "evidence pack" entity → **Not yet specced**
- Workspace-level PII override for review packs → deferred from 109 - Workspace-level PII override for review packs → deferred from 109
### Findings Workflow v2 / Execution Layer
Turn findings from a reviewable register into an accountable operating flow with clear ownership, personal queues, intake, hygiene, and minimal escalation.
**Scope direction**:
- Clarify owner versus assignee semantics and accountability language first
- Add operator work surfaces such as "Assigned to me" and an unassigned intake queue with basic claim flow
- Harden assignment hygiene, stale-work detection, and resolved-versus-verified outcome semantics
- Reuse the existing alerting foundation for assignment, reopen, due-soon, and overdue notification flows
- Keep comments, external ticket handoff, and cross-tenant workboards as later slices instead of forcing them into the first workflow iteration
### Policy Lifecycle / Ghost Policies ### Policy Lifecycle / Ghost Policies
Soft delete detection, automatic restore, "Deleted" badge, restore from backup. Soft delete detection, automatic restore, "Deleted" badge, restore from backup.
Draft exists (Spec 900). Needs spec refresh and prioritization. Draft exists (Spec 900). Needs spec refresh and prioritization.
@ -101,6 +110,7 @@ ### MSP Portfolio & Operations (Multi-Tenant)
Multi-tenant health dashboard, SLA/compliance reports (PDF), cross-tenant troubleshooting center. Multi-tenant health dashboard, SLA/compliance reports (PDF), cross-tenant troubleshooting center.
**Source**: 0800-future-features brainstorming, identified as highest priority pillar. **Source**: 0800-future-features brainstorming, identified as highest priority pillar.
**Prerequisites**: Decision-Based Operating Foundations, Cross-tenant compare (Spec 043 — draft only). **Prerequisites**: Decision-Based Operating Foundations, Cross-tenant compare (Spec 043 — draft only).
**Later expansion**: portfolio operations should eventually include a cross-tenant findings workboard once tenant-level inbox and intake flows are stable.
### Human-in-the-Loop Autonomous Governance (Decision-Based Operating) ### Human-in-the-Loop Autonomous Governance (Decision-Based Operating)
Continuous detection, triage, decision drafting, approval-driven execution, and closed-loop evidence for governance actions across the workspace portfolio. Continuous detection, triage, decision drafting, approval-driven execution, and closed-loop evidence for governance actions across the workspace portfolio.
@ -118,6 +128,10 @@ ### Standardization & Policy Quality ("Intune Linting")
Policy linter (naming, scope tag requirements, no All-Users on high-risk), company standards as templates, policy hygiene (duplicate finder, unassigned, orphaned, stale). Policy linter (naming, scope tag requirements, no All-Users on high-risk), company standards as templates, policy hygiene (duplicate finder, unassigned, orphaned, stale).
**Source**: 0800-future-features brainstorming. **Source**: 0800-future-features brainstorming.
### PSA / Ticketing Handoff
Outbound handoff from findings into external service-desk or PSA systems with visible `ticket_ref` linkage and auditable "ticket created/linked" events.
**Scope direction**: start with one-way handoff and internal visibility, not full bidirectional sync or full ITSM modeling.
### Compliance Readiness & Executive Review Packs ### Compliance Readiness & Executive Review Packs
On-demand review packs that combine governance findings, accepted risks, evidence, baseline/drift posture, and key security signals into one coherent deliverable. BSI-/NIS2-/CIS-oriented readiness views (without certification claims). Executive / CISO / customer-facing report surfaces alongside operator-facing detail views. Exportable auditor-ready and management-ready outputs. On-demand review packs that combine governance findings, accepted risks, evidence, baseline/drift posture, and key security signals into one coherent deliverable. BSI-/NIS2-/CIS-oriented readiness views (without certification claims). Executive / CISO / customer-facing report surfaces alongside operator-facing detail views. Exportable auditor-ready and management-ready outputs.
**Goal**: Make TenantPilot sellable as an MSP-facing governance and review platform for German midmarket and compliance-oriented customers who want structured tenant reviews and management-ready outputs on demand. **Goal**: Make TenantPilot sellable as an MSP-facing governance and review platform for German midmarket and compliance-oriented customers who want structured tenant reviews and management-ready outputs on demand.

View File

@ -5,7 +5,7 @@ # Spec Candidates
> >
> **Flow**: Inbox → Qualified → Planned → Spec created → moved to `Promoted to Spec` > **Flow**: Inbox → Qualified → Planned → Spec created → moved to `Promoted to Spec`
**Last reviewed**: 2026-04-12 (added decision-based operating foundations and autonomous governance track) **Last reviewed**: 2026-04-17 (added findings execution layer cluster)
--- ---
@ -579,6 +579,113 @@ ### Exception / Risk-Acceptance Workflow for Findings
- **Dependencies**: Findings workflow (Spec 111) complete, audit log foundation (Spec 134) - **Dependencies**: Findings workflow (Spec 111) complete, audit log foundation (Spec 134)
- **Priority**: high - **Priority**: high
> Findings execution layer cluster: complementary to the existing risk-acceptance candidate. Keep these split so prioritization can pull workflow semantics, operator work surfaces, alerts, external handoff, and later portfolio operating slices independently instead of collapsing them into one oversized "Findings v2" spec.
### Finding Ownership Semantics Clarification
- **Type**: domain semantics / workflow hardening
- **Source**: findings execution layer candidate pack 2026-04-17; current Finding owner/assignee overlap analysis
- **Problem**: Finding already models `owner` and `assignee`, but the semantic split is not crisp enough to support inboxes, escalation, stale-work detection, or consistent audit language. Accountability and active execution responsibility can currently blur together in UI copy, policies, and workflow rules.
- **Why it matters**: Without a shared contract, every downstream workflow slice will invent its own meaning for owner versus assignee. That produces ambiguous queues, brittle escalation rules, and inconsistent governance language.
- **Proposed direction**: Define canonical semantics for accountability owner versus active assignee; align labels, policies, audit/event vocabulary, and permission expectations around that split; encode expected lifecycle states for unassigned, assigned, reassigned, and orphaned work without introducing team-queue abstractions yet.
- **Explicit non-goals**: Team-/queue-based ownership, ticketing, comments, and notification delivery.
- **Dependencies**: Existing Findings model and workflow state machine, Findings UI surfaces, audit vocabulary.
- **Roadmap fit**: Findings Workflow v2 hardening lane.
- **Strategic sequencing**: First. The rest of the findings execution layer consumes this decision.
- **Priority**: high
### Findings Operator Inbox v1
- **Type**: operator surface / workflow execution
- **Source**: findings execution layer candidate pack 2026-04-17; gap between assignment auditability and day-to-day operator flow
- **Problem**: Findings can be assigned, but TenantPilot lacks a personal work surface that turns assignment into a real operator queue. Assigned work is still discovered indirectly through broader tenant or findings lists.
- **Why it matters**: Without a dedicated personal queue, assignment remains metadata instead of operational flow. A "My Work" surface is the simplest bridge from governance data to daily execution.
- **Proposed direction**: Add a workspace-level or otherwise permission-safe "My Findings / My Work" surface for the current user; emphasize open, due, overdue, high-severity, and reopened findings; provide fast drilldown into the finding record; add a small "assigned to me" dashboard signal; reuse existing RBAC and finding visibility rules instead of inventing a second permission system.
- **Explicit non-goals**: Team queues, complex routing rules, external ticketing, and multi-step approval chains.
- **Dependencies**: Ownership semantics, `assignee_user_id`, `due_at`, finding status logic, RBAC on finding read/open.
- **Roadmap fit**: Findings Workflow v2; feeds later governance inbox work.
- **Strategic sequencing**: Second, ideally immediately after ownership semantics.
- **Priority**: high
### Findings Intake & Team Queue v1
- **Type**: workflow execution / team operations
- **Source**: findings execution layer candidate pack 2026-04-17; missing intake surface before personal assignment
- **Problem**: A personal inbox does not solve how new or unassigned findings enter the workflow. Operators need an intake surface before work is personally assigned.
- **Why it matters**: Without intake, backlog triage stays hidden in general-purpose lists and unassigned work becomes easy to ignore or duplicate.
- **Proposed direction**: Introduce unassigned and needs-triage views, an optional claim action, and basic shared-worklist conventions; use filters or tabs that clearly separate intake from active execution; make the difference between unowned backlog and personally assigned work explicit.
- **Explicit non-goals**: Full team model, capacity planning, auto-routing, and load-balancing logic.
- **Dependencies**: Ownership semantics, findings filters/tabs, open-status definitions.
- **Roadmap fit**: Findings Workflow v2; prerequisite for a broader team operating model.
- **Strategic sequencing**: Third, after personal inbox foundations exist.
- **Priority**: high
### Findings Notifications & Escalation v1
- **Type**: alerts / workflow execution
- **Source**: findings execution layer candidate pack 2026-04-17; gap between assignment metadata and actionable control loop
- **Problem**: Assignment, reopen, due, and overdue states currently risk becoming silent metadata unless operators keep polling findings views.
- **Why it matters**: Due dates without reminders or escalation are visibility, not control. Existing alert foundations only create operator value if findings workflow emits actionable events.
- **Proposed direction**: Add notifications for assignment, system-driven reopen, due-soon, and overdue states; introduce minimal escalation to owner or a defined role; explicitly consume the existing alert and notification infrastructure rather than building a findings-specific delivery system.
- **Explicit non-goals**: Multi-stage escalation chains, a large notification-preference center, and bidirectional ticket synchronization.
- **Dependencies**: Ownership semantics, operator inbox/intake surfaces, due/SLA logic, alert plumbing.
- **Roadmap fit**: Findings workflow hardening on top of the existing alerting foundation.
- **Strategic sequencing**: After inbox and intake exist so notifications land on meaningful destinations.
- **Priority**: high
### Assignment Hygiene & Stale Work Detection
- **Type**: workflow hardening / operations hygiene
- **Source**: findings execution layer candidate pack 2026-04-17; assignment lifecycle hygiene gap analysis
- **Problem**: Assignments can silently rot when memberships change, assignees lose access, or findings remain stuck in `in_progress` indefinitely.
- **Why it matters**: Stale and orphaned assignments erode trust in queues and create hidden backlog. Hygiene reporting is a prerequisite for later auto-reassign logic.
- **Proposed direction**: Detect orphaned assignments, invalid or inactive assignees, and stale `in_progress` work; provide an admin/operator hygiene report; define what counts as stale versus active; stop short of automatic redistribution in v1.
- **Explicit non-goals**: Full reassignment workflows, automatic load distribution, and absence management.
- **Dependencies**: Tenant membership / RBAC model, scheduler or job layer, ownership semantics, open status logic.
- **Roadmap fit**: Findings Workflow v2 hardening.
- **Strategic sequencing**: Shortly after ownership semantics, ideally alongside or immediately after notifications.
- **Priority**: high
### Finding Outcome Taxonomy & Verification Semantics
- **Type**: workflow semantics / reporting hardening
- **Source**: findings execution layer candidate pack 2026-04-17; status/outcome reporting gap analysis
- **Problem**: Resolve and close reasoning is too free-form, and the product does not cleanly separate operator-resolved from system-verified or confirmed-cleared states.
- **Why it matters**: Reporting, audit, reopening logic, and governance review packs need structured outcomes rather than ad hoc prose. Otherwise outcome meaning drifts between operators and surfaces.
- **Proposed direction**: Define structured reason codes for resolve, close, and reopen transitions; distinguish resolved, verified or confirmed cleared, closed, false positive, duplicate, and no-longer-applicable semantics; make reporting and filter UI consume the taxonomy instead of relying on free text.
- **Explicit non-goals**: Comments, full narrative case notes, and complex multi-reason models.
- **Dependencies**: Finding status transitions, audit payloads, reporting and filter UI.
- **Roadmap fit**: Findings Workflow v2 hardening and downstream review/reporting quality.
- **Strategic sequencing**: After the first operator work surfaces unless reporting pressure pulls it earlier.
- **Priority**: high
### Finding Comments & Decision Log v1
- **Type**: collaboration / audit depth
- **Source**: findings execution layer candidate pack 2026-04-17; operator handoff and context gap analysis
- **Problem**: Audit can show that a transition happened, but not the day-to-day operator reasoning or handover context behind triage, resolve, close, or risk-accept decisions.
- **Why it matters**: Human workflow needs concise contextual notes that do not belong in status fields or reason-code taxonomies. Without them, operator handover quality stays low.
- **Proposed direction**: Add comments and lightweight decision-log entries on findings; surface them in a timeline alongside immutable audit events; use them to support triage, handover, and rationale capture without turning findings into a chat product.
- **Explicit non-goals**: `@mentions`, attachments, chat, and realtime collaboration.
- **Dependencies**: Finding detail surface, audit/timeline rendering, RBAC.
- **Roadmap fit**: Spec-candidate only for now; not required as a standalone roadmap theme.
- **Priority**: medium
### Findings External Ticket Handoff v1
- **Type**: external integration / execution handoff
- **Source**: findings execution layer candidate pack 2026-04-17; MSP/enterprise workflow alignment gap
- **Problem**: Enterprise and MSP operators often need to hand findings into external service-desk or PSA workflows, but the current findings model has no first-class outbound ticket link or handoff state.
- **Why it matters**: Outbound handoff is a sellable bridge between TenantPilot governance and existing customer operating models. Without it, findings remain operationally isolated from the systems where remediation work actually gets tracked.
- **Proposed direction**: Add an external ticket reference and link on findings, support simple outbound handoff or "ticket created/linked" flows, and audit those transitions explicitly; make internal versus external execution state visible without promising full synchronization.
- **Explicit non-goals**: Bidirectional sync, OAuth-native integrations, and full ITSM domain modeling.
- **Dependencies**: Findings UI, workspace settings or handoff target configuration, audit events, stable ownership semantics.
- **Roadmap fit**: Future PSA/ticketing lane.
- **Priority**: medium
### Cross-Tenant Findings Workboard v1
- **Type**: MSP / portfolio operations
- **Source**: findings execution layer candidate pack 2026-04-17; portfolio-scale findings operations gap
- **Problem**: Once operators manage many tenants, tenant-local inboxes and queues stop scaling. There is no cross-tenant work surface for open findings across a workspace or portfolio.
- **Why it matters**: MSP portfolio work requires cross-tenant prioritization by severity, due date, assignee, and tenant. This is the operational complement to a portfolio dashboard.
- **Proposed direction**: Add a cross-tenant findings workboard or queue for open findings with filters for severity, due date, assignee, tenant, and status; preserve tenant drilldown and RBAC boundaries; position it as the portfolio-operating surface next to the dashboard, not a replacement for per-tenant detail.
- **Explicit non-goals**: Rollout orchestration, full portfolio remediation planning, and team capacity views.
- **Dependencies**: Operator inbox, intake queue, notifications/escalation, workspace-level finding visibility, cross-tenant RBAC semantics.
- **Roadmap fit**: MSP portfolio and operations.
- **Priority**: medium-low
### Compliance Control Catalog & Interpretation Foundation ### Compliance Control Catalog & Interpretation Foundation
- **Type**: foundation - **Type**: foundation
- **Source**: roadmap/principles alignment 2026-04-10, compliance modeling discussion, future framework-oriented readiness planning - **Source**: roadmap/principles alignment 2026-04-10, compliance modeling discussion, future framework-oriented readiness planning

73
scripts/platform-test-artifacts Executable file
View File

@ -0,0 +1,73 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
ROOT_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)"
APP_DIR="${ROOT_DIR}/apps/platform"
LANE="${1:-}"
STAGING_DIRECTORY="${2:-}"
ARTIFACT_DIRECTORY=""
WORKFLOW_ID="${TENANTATLAS_CI_WORKFLOW_ID:-}"
TRIGGER_CLASS="${TENANTATLAS_CI_TRIGGER_CLASS:-}"
if [[ -z "${LANE}" || -z "${STAGING_DIRECTORY}" ]]; then
echo "Usage: ./scripts/platform-test-artifacts <lane-id> <staging-directory> [--artifact-directory=storage/logs/test-lanes] [--workflow-id=<id>] [--trigger-class=<trigger>]" >&2
exit 1
fi
shift 2 || true
for arg in "$@"; do
if [[ "${arg}" == --artifact-directory=* ]]; then
ARTIFACT_DIRECTORY="${arg#--artifact-directory=}"
continue
fi
if [[ "${arg}" == --workflow-id=* ]]; then
WORKFLOW_ID="${arg#--workflow-id=}"
continue
fi
if [[ "${arg}" == --trigger-class=* ]]; then
TRIGGER_CLASS="${arg#--trigger-class=}"
continue
fi
echo "Unknown option: ${arg}" >&2
exit 1
done
if [[ -n "${WORKFLOW_ID}" ]]; then
export TENANTATLAS_CI_WORKFLOW_ID="${WORKFLOW_ID}"
fi
if [[ -n "${TRIGGER_CLASS}" ]]; then
export TENANTATLAS_CI_TRIGGER_CLASS="${TRIGGER_CLASS}"
fi
cd "${APP_DIR}"
LANE_ID="${LANE}" \
STAGING_DIRECTORY="${STAGING_DIRECTORY}" \
ARTIFACT_DIRECTORY="${ARTIFACT_DIRECTORY}" \
./vendor/bin/sail php <<'PHP'
<?php
declare(strict_types=1);
require 'vendor/autoload.php';
$laneId = (string) getenv('LANE_ID');
$stagingDirectory = (string) getenv('STAGING_DIRECTORY');
$artifactDirectory = getenv('ARTIFACT_DIRECTORY');
$artifactDirectory = is_string($artifactDirectory) && trim($artifactDirectory) !== ''
? $artifactDirectory
: null;
$result = \Tests\Support\TestLaneReport::stageArtifacts($laneId, $stagingDirectory, $artifactDirectory);
echo json_encode($result, JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR).PHP_EOL;
exit(($result['complete'] ?? false) === true ? 0 : 1);
PHP

98
scripts/platform-test-lane Executable file
View File

@ -0,0 +1,98 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
ROOT_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)"
APP_DIR="${ROOT_DIR}/apps/platform"
LANE="${1:-fast-feedback}"
CAPTURE_BASELINE=false
WORKFLOW_ID=""
TRIGGER_CLASS=""
copy_heavy_baseline_artifacts() {
local artifact_root="${APP_DIR}/storage/logs/test-lanes"
local suffix
for suffix in junit.xml summary.md budget.json report.json profile.txt; do
local latest_path="${artifact_root}/heavy-governance-latest.${suffix}"
local baseline_path="${artifact_root}/heavy-governance-baseline.${suffix}"
if [[ -f "${latest_path}" ]]; then
cp "${latest_path}" "${baseline_path}"
fi
done
}
case "${LANE}" in
fast-feedback|fast|default)
COMPOSER_SCRIPT="test"
;;
confidence)
COMPOSER_SCRIPT="test:confidence"
;;
browser)
COMPOSER_SCRIPT="test:browser"
;;
heavy-governance|heavy)
COMPOSER_SCRIPT="test:heavy"
;;
profiling|profile)
COMPOSER_SCRIPT="test:profile"
;;
junit)
COMPOSER_SCRIPT="test:junit"
;;
*)
echo "Unknown test lane: ${LANE}" >&2
exit 1
;;
esac
shift || true
remaining_args=()
for arg in "$@"; do
if [[ "${arg}" == "--capture-baseline" ]]; then
CAPTURE_BASELINE=true
continue
fi
if [[ "${arg}" == --workflow-id=* ]]; then
WORKFLOW_ID="${arg#--workflow-id=}"
continue
fi
if [[ "${arg}" == --trigger-class=* ]]; then
TRIGGER_CLASS="${arg#--trigger-class=}"
continue
fi
remaining_args+=("${arg}")
done
if [[ "${CAPTURE_BASELINE}" == true && "${LANE}" != "heavy-governance" && "${LANE}" != "heavy" ]]; then
echo "--capture-baseline is only supported for heavy-governance" >&2
exit 1
fi
if [[ -n "${WORKFLOW_ID}" ]]; then
export TENANTATLAS_CI_WORKFLOW_ID="${WORKFLOW_ID}"
fi
if [[ -n "${TRIGGER_CLASS}" ]]; then
export TENANTATLAS_CI_TRIGGER_CLASS="${TRIGGER_CLASS}"
fi
cd "${APP_DIR}"
if [[ ${#remaining_args[@]} -gt 0 ]]; then
./vendor/bin/sail composer run --timeout=0 "${COMPOSER_SCRIPT}" -- "${remaining_args[@]}"
else
./vendor/bin/sail composer run --timeout=0 "${COMPOSER_SCRIPT}"
fi
if [[ "${CAPTURE_BASELINE}" == true ]]; then
copy_heavy_baseline_artifacts
fi

359
scripts/platform-test-report Executable file
View File

@ -0,0 +1,359 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
ROOT_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)"
APP_DIR="${ROOT_DIR}/apps/platform"
LANE="${1:-fast-feedback}"
CAPTURE_BASELINE=false
WORKFLOW_ID=""
TRIGGER_CLASS=""
HISTORY_FILE=""
HISTORY_BUNDLE=""
FETCH_LATEST_HISTORY=auto
copy_heavy_baseline_artifacts() {
local artifact_root="${APP_DIR}/storage/logs/test-lanes"
local suffix
for suffix in summary.md budget.json report.json trend-history.json; do
local latest_path="${artifact_root}/heavy-governance-latest.${suffix}"
local baseline_path="${artifact_root}/heavy-governance-baseline.${suffix}"
if [[ -f "${latest_path}" ]]; then
cp "${latest_path}" "${baseline_path}"
fi
done
}
case "${LANE}" in
fast-feedback|fast|default)
COMPOSER_SCRIPT="test:report"
;;
confidence)
COMPOSER_SCRIPT="test:report:confidence"
;;
browser)
COMPOSER_SCRIPT="test:report:browser"
;;
heavy-governance|heavy)
COMPOSER_SCRIPT="test:report:heavy"
;;
profiling|profile)
COMPOSER_SCRIPT="test:report:profile"
;;
junit)
COMPOSER_SCRIPT="test:report:junit"
;;
*)
echo "Unknown test lane: ${LANE}" >&2
exit 1
;;
esac
shift || true
for arg in "$@"; do
if [[ "${arg}" == "--capture-baseline" ]]; then
CAPTURE_BASELINE=true
continue
fi
if [[ "${arg}" == --workflow-id=* ]]; then
WORKFLOW_ID="${arg#--workflow-id=}"
continue
fi
if [[ "${arg}" == --trigger-class=* ]]; then
TRIGGER_CLASS="${arg#--trigger-class=}"
continue
fi
if [[ "${arg}" == --history-file=* ]]; then
HISTORY_FILE="${arg#--history-file=}"
continue
fi
if [[ "${arg}" == --history-bundle=* ]]; then
HISTORY_BUNDLE="${arg#--history-bundle=}"
continue
fi
if [[ "${arg}" == "--fetch-latest-history" ]]; then
FETCH_LATEST_HISTORY=true
continue
fi
if [[ "${arg}" == "--skip-latest-history" ]]; then
FETCH_LATEST_HISTORY=false
continue
fi
echo "Unknown option: ${arg}" >&2
exit 1
done
if [[ "${CAPTURE_BASELINE}" == true && "${LANE}" != "heavy-governance" && "${LANE}" != "heavy" ]]; then
echo "--capture-baseline is only supported for heavy-governance" >&2
exit 1
fi
if [[ -n "${WORKFLOW_ID}" ]]; then
export TENANTATLAS_CI_WORKFLOW_ID="${WORKFLOW_ID}"
fi
if [[ -n "${TRIGGER_CLASS}" ]]; then
export TENANTATLAS_CI_TRIGGER_CLASS="${TRIGGER_CLASS}"
fi
trend_history_target_path() {
echo "${APP_DIR}/storage/logs/test-lanes/${LANE}-latest.trend-history.json"
}
resolve_input_path() {
local path="${1:-}"
if [[ -z "${path}" ]]; then
return 1
fi
if [[ "${path}" = /* ]]; then
echo "${path}"
return 0
fi
echo "${ROOT_DIR}/${path#./}"
}
hydrate_trend_history_from_file() {
local source_path="${1:-}"
local target_path
target_path="$(trend_history_target_path)"
if [[ -z "${source_path}" || ! -f "${source_path}" ]]; then
return 1
fi
mkdir -p "$(dirname "${target_path}")"
cp "${source_path}" "${target_path}"
return 0
}
hydrate_trend_history_from_bundle() {
local bundle_path="${1:-}"
local target_path
local candidate
target_path="$(trend_history_target_path)"
if [[ -z "${bundle_path}" ]]; then
return 1
fi
if [[ -d "${bundle_path}" ]]; then
for candidate in \
"${bundle_path}/${LANE}.trend-history.json" \
"${bundle_path}/${LANE}-latest.trend-history.json"
do
if [[ -f "${candidate}" ]]; then
mkdir -p "$(dirname "${target_path}")"
cp "${candidate}" "${target_path}"
return 0
fi
done
candidate="$(find "${bundle_path}" -type f \( -name "${LANE}.trend-history.json" -o -name "${LANE}-latest.trend-history.json" \) | head -n 1)"
if [[ -n "${candidate}" && -f "${candidate}" ]]; then
mkdir -p "$(dirname "${target_path}")"
cp "${candidate}" "${target_path}"
return 0
fi
fi
if [[ -f "${bundle_path}" && "${bundle_path,,}" == *.zip ]]; then
candidate="$(python3 - "${bundle_path}" "${LANE}" <<'PY'
import sys
import zipfile
bundle_path, lane = sys.argv[1], sys.argv[2]
candidates = [f"{lane}.trend-history.json", f"{lane}-latest.trend-history.json"]
with zipfile.ZipFile(bundle_path) as archive:
for name in archive.namelist():
if any(name.endswith(candidate) for candidate in candidates):
print(name)
break
PY
)"
if [[ -n "${candidate}" ]]; then
mkdir -p "$(dirname "${target_path}")"
unzip -p "${bundle_path}" "${candidate}" > "${target_path}"
return 0
fi
fi
return 1
}
parse_remote_origin() {
local origin_url
origin_url="$(git -C "${ROOT_DIR}" config --get remote.origin.url 2>/dev/null || true)"
if [[ -z "${origin_url}" ]]; then
return 1
fi
python3 - "${origin_url}" <<'PY'
import re
import sys
origin = sys.argv[1].strip()
patterns = [
re.compile(r'^(https?://[^/]+)/([^/]+)/([^/]+?)(?:\.git)?$'),
re.compile(r'^git@([^:]+):([^/]+)/([^/]+?)(?:\.git)?$'),
re.compile(r'^ssh://git@([^/]+)/([^/]+)/([^/]+?)(?:\.git)?$'),
]
for pattern in patterns:
match = pattern.match(origin)
if not match:
continue
groups = match.groups()
if origin.startswith("http://") or origin.startswith("https://"):
host, owner, repo = groups
else:
host, owner, repo = groups
host = f"https://{host}"
print(host)
print(owner)
print(repo)
sys.exit(0)
sys.exit(1)
PY
}
download_latest_history_bundle() {
local token
local artifact_name
local remote_parts
local host
local owner
local repo
local listing_path
local artifact_id
local bundle_dir
local bundle_path
token="${TENANTATLAS_GITEA_TOKEN:-${GITEA_TOKEN:-}}"
if [[ -z "${token}" ]]; then
return 1
fi
mapfile -t remote_parts < <(parse_remote_origin) || return 1
if [[ "${#remote_parts[@]}" -ne 3 ]]; then
return 1
fi
host="${remote_parts[0]}"
owner="${remote_parts[1]}"
repo="${remote_parts[2]}"
artifact_name="${LANE}-artifacts"
listing_path="${ROOT_DIR}/.gitea-artifacts/_history/${LANE}-artifacts.json"
mkdir -p "${ROOT_DIR}/.gitea-artifacts/_history"
curl --fail --silent --show-error \
-H "Authorization: token ${token}" \
-H "Accept: application/json" \
"${host}/api/v1/repos/${owner}/${repo}/actions/artifacts?name=${artifact_name}" \
-o "${listing_path}" || return 1
artifact_id="$(php -r '
$data = json_decode((string) file_get_contents($argv[1]), true);
$currentRunId = getenv("GITEA_RUN_ID") ?: getenv("GITHUB_RUN_ID") ?: "";
foreach (($data["artifacts"] ?? []) as $artifact) {
if (($artifact["expired"] ?? false) === true) {
continue;
}
$artifactRunId = (string) ($artifact["workflow_run"]["id"] ?? "");
if ($currentRunId !== "" && $artifactRunId === (string) $currentRunId) {
continue;
}
echo (string) ($artifact["id"] ?? "");
break;
}
' "${listing_path}")"
if [[ -z "${artifact_id}" ]]; then
return 1
fi
bundle_dir="${ROOT_DIR}/.gitea-artifacts/_history/${LANE}"
bundle_path="${bundle_dir}/artifact.zip"
rm -rf "${bundle_dir}"
mkdir -p "${bundle_dir}"
curl --fail --silent --show-error --location \
-H "Authorization: token ${token}" \
"${host}/api/v1/repos/${owner}/${repo}/actions/artifacts/${artifact_id}/zip" \
-o "${bundle_path}" || return 1
echo "${bundle_path}"
return 0
}
hydrate_trend_history() {
local resolved_history_file=""
local resolved_history_bundle=""
local downloaded_bundle=""
if [[ -n "${HISTORY_FILE}" ]]; then
resolved_history_file="$(resolve_input_path "${HISTORY_FILE}")"
fi
if [[ -n "${HISTORY_BUNDLE}" ]]; then
resolved_history_bundle="$(resolve_input_path "${HISTORY_BUNDLE}")"
fi
if [[ -n "${resolved_history_file}" ]] && hydrate_trend_history_from_file "${resolved_history_file}"; then
return 0
fi
if [[ -n "${resolved_history_bundle}" ]] && hydrate_trend_history_from_bundle "${resolved_history_bundle}"; then
return 0
fi
if [[ "${FETCH_LATEST_HISTORY}" == true || ( "${FETCH_LATEST_HISTORY}" == auto && -n "${WORKFLOW_ID}" ) ]]; then
downloaded_bundle="$(download_latest_history_bundle || true)"
if [[ -n "${downloaded_bundle}" ]] && hydrate_trend_history_from_bundle "${downloaded_bundle}"; then
return 0
fi
fi
return 0
}
cd "${APP_DIR}"
hydrate_trend_history
./vendor/bin/sail composer run --timeout=0 "${COMPOSER_SCRIPT}"
if [[ "${CAPTURE_BASELINE}" == true ]]; then
copy_heavy_baseline_artifacts
fi

View File

@ -0,0 +1,37 @@
# Specification Quality Checklist: Test Suite Governance & Performance Foundation
**Purpose**: Validate specification completeness and quality before proceeding to planning
**Created**: 2026-04-16
**Feature**: [spec.md](../spec.md)
## Content Quality
- [x] No implementation details (languages, frameworks, APIs)
- [x] Focused on user value and business needs
- [x] Written for non-technical stakeholders
- [x] All mandatory sections completed
## Requirement Completeness
- [x] No [NEEDS CLARIFICATION] markers remain
- [x] Requirements are testable and unambiguous
- [x] Success criteria are measurable
- [x] Success criteria are technology-agnostic (no implementation details)
- [x] All acceptance scenarios are defined
- [x] Edge cases are identified
- [x] Scope is clearly bounded
- [x] Dependencies and assumptions identified
## Feature Readiness
- [x] All functional requirements have clear acceptance criteria
- [x] User scenarios cover primary flows
- [x] Feature meets measurable outcomes defined in Success Criteria
- [x] No implementation details leak into specification
## Notes
- Validation run: 2026-04-16
- The spec stays focused on repository governance outcomes: lane definitions, honest taxonomy, cheap shared defaults, slow-test visibility, and runtime budgets.
- Runtime budgets are intentionally expressed as measurable outcomes and documented governance expectations rather than hardwired implementation details.
- No clarification markers were needed; the user description supplied the required scope, non-goals, and rollout boundaries.

View File

@ -0,0 +1,694 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://tenantatlas.local/schemas/test-lane-manifest.schema.json",
"title": "TestLaneManifest",
"type": "object",
"additionalProperties": false,
"required": [
"version",
"lanes",
"artifactDirectory",
"familyBudgets"
],
"properties": {
"version": {
"type": "integer",
"minimum": 1
},
"artifactDirectory": {
"type": "string",
"const": "storage/logs/test-lanes"
},
"lanes": {
"type": "array",
"minItems": 6,
"maxItems": 6,
"items": {
"$ref": "#/$defs/lane"
},
"allOf": [
{
"contains": {
"type": "object",
"required": [
"id"
],
"properties": {
"id": {
"const": "fast-feedback"
}
}
}
},
{
"contains": {
"type": "object",
"required": [
"id"
],
"properties": {
"id": {
"const": "confidence"
}
}
}
},
{
"contains": {
"type": "object",
"required": [
"id"
],
"properties": {
"id": {
"const": "browser"
}
}
}
},
{
"contains": {
"type": "object",
"required": [
"id"
],
"properties": {
"id": {
"const": "heavy-governance"
}
}
}
},
{
"contains": {
"type": "object",
"required": [
"id"
],
"properties": {
"id": {
"const": "profiling"
}
}
}
},
{
"contains": {
"type": "object",
"required": [
"id"
],
"properties": {
"id": {
"const": "junit"
}
}
}
}
]
},
"familyBudgets": {
"type": "array",
"minItems": 1,
"items": {
"$ref": "#/$defs/familyBudget"
}
}
},
"$defs": {
"lane": {
"type": "object",
"additionalProperties": false,
"required": [
"id",
"governanceClass",
"description",
"intendedAudience",
"includedFamilies",
"excludedFamilies",
"ownershipExpectations",
"defaultEntryPoint",
"parallelMode",
"selectors",
"artifacts",
"budget",
"dbStrategy"
],
"properties": {
"id": {
"type": "string",
"pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$"
},
"governanceClass": {
"type": "string",
"enum": [
"fast",
"confidence",
"heavy",
"support"
]
},
"description": {
"type": "string",
"minLength": 1
},
"intendedAudience": {
"type": "string",
"minLength": 1
},
"includedFamilies": {
"$ref": "#/$defs/stringArray"
},
"excludedFamilies": {
"$ref": "#/$defs/stringArray"
},
"ownershipExpectations": {
"type": "string",
"minLength": 1
},
"defaultEntryPoint": {
"type": "boolean"
},
"parallelMode": {
"type": "string",
"enum": [
"required",
"optional",
"forbidden"
]
},
"selectors": {
"$ref": "#/$defs/selectors"
},
"artifacts": {
"type": "array",
"minItems": 1,
"items": {
"$ref": "#/$defs/artifactType"
},
"uniqueItems": true
},
"budget": {
"$ref": "#/$defs/budget"
},
"dbStrategy": {
"$ref": "#/$defs/dbStrategy"
},
"notes": {
"type": "string"
}
},
"allOf": [
{
"if": {
"properties": {
"artifacts": {
"contains": {
"const": "profile-top"
}
}
}
},
"then": {
"properties": {
"parallelMode": {
"const": "forbidden"
}
}
}
},
{
"if": {
"properties": {
"id": {
"const": "fast-feedback"
}
}
},
"then": {
"properties": {
"defaultEntryPoint": {
"const": true
},
"parallelMode": {
"const": "required"
},
"budget": {
"required": [
"baselineDeltaTargetPercent"
],
"properties": {
"baselineDeltaTargetPercent": {
"const": 50
}
}
},
"excludedFamilies": {
"allOf": [
{
"contains": {
"const": "browser"
}
},
{
"contains": {
"const": "heavy-governance"
}
}
]
}
}
}
},
{
"if": {
"properties": {
"id": {
"const": "confidence"
}
}
},
"then": {
"properties": {
"parallelMode": {
"const": "required"
},
"includedFamilies": {
"allOf": [
{
"contains": {
"const": "unit"
}
},
{
"contains": {
"const": "non-browser-feature-integration"
}
}
]
},
"excludedFamilies": {
"allOf": [
{
"contains": {
"const": "browser"
}
},
{
"contains": {
"const": "heavy-governance"
}
}
]
},
"selectors": {
"properties": {
"includeSuites": {
"contains": {
"const": "Unit"
}
},
"excludeSuites": {
"contains": {
"const": "Browser"
}
}
},
"anyOf": [
{
"properties": {
"includePaths": {
"minItems": 1
}
}
},
{
"properties": {
"includeGroups": {
"minItems": 1
}
}
},
{
"properties": {
"includeFiles": {
"minItems": 1
}
}
}
]
},
"defaultEntryPoint": {
"const": false
}
}
}
},
{
"if": {
"properties": {
"id": {
"const": "browser"
}
}
},
"then": {
"properties": {
"governanceClass": {
"const": "heavy"
},
"includedFamilies": {
"contains": {
"const": "browser"
}
},
"selectors": {
"properties": {
"includeSuites": {
"contains": {
"const": "Browser"
}
}
},
"required": [
"includeSuites"
]
},
"defaultEntryPoint": {
"const": false
}
}
}
},
{
"if": {
"properties": {
"id": {
"const": "heavy-governance"
}
}
},
"then": {
"properties": {
"defaultEntryPoint": {
"const": false
}
}
}
},
{
"if": {
"properties": {
"id": {
"const": "profiling"
}
}
},
"then": {
"properties": {
"governanceClass": {
"const": "support"
},
"defaultEntryPoint": {
"const": false
},
"artifacts": {
"contains": {
"const": "profile-top"
}
}
}
}
},
{
"if": {
"properties": {
"id": {
"const": "junit"
}
}
},
"then": {
"properties": {
"governanceClass": {
"const": "support"
},
"defaultEntryPoint": {
"const": false
},
"artifacts": {
"contains": {
"const": "junit-xml"
}
}
}
}
}
]
},
"selectors": {
"type": "object",
"additionalProperties": false,
"required": [
"includeSuites",
"includePaths",
"includeGroups",
"includeFiles",
"excludeSuites",
"excludePaths",
"excludeGroups",
"excludeFiles"
],
"properties": {
"includeSuites": {
"$ref": "#/$defs/stringArray"
},
"includePaths": {
"$ref": "#/$defs/stringArray"
},
"includeGroups": {
"$ref": "#/$defs/stringArray"
},
"includeFiles": {
"$ref": "#/$defs/stringArray"
},
"excludeSuites": {
"$ref": "#/$defs/stringArray"
},
"excludePaths": {
"$ref": "#/$defs/stringArray"
},
"excludeGroups": {
"$ref": "#/$defs/stringArray"
},
"excludeFiles": {
"$ref": "#/$defs/stringArray"
}
},
"anyOf": [
{
"properties": {
"includeSuites": {
"minItems": 1
}
}
},
{
"properties": {
"includePaths": {
"minItems": 1
}
}
},
{
"properties": {
"includeGroups": {
"minItems": 1
}
}
},
{
"properties": {
"includeFiles": {
"minItems": 1
}
}
}
]
},
"budget": {
"type": "object",
"additionalProperties": false,
"required": [
"thresholdSeconds",
"baselineSource",
"enforcement",
"lifecycleState"
],
"properties": {
"thresholdSeconds": {
"type": "integer",
"minimum": 1
},
"baselineSource": {
"type": "string",
"enum": [
"measured-current-suite",
"measured-lane"
]
},
"enforcement": {
"type": "string",
"enum": [
"report-only",
"warn",
"hard-fail"
]
},
"lifecycleState": {
"type": "string",
"enum": [
"draft",
"measured",
"documented",
"enforced"
]
},
"baselineDeltaTargetPercent": {
"type": "integer",
"minimum": 1,
"maximum": 100
},
"notes": {
"type": "string"
},
"reviewCadence": {
"type": "string"
}
}
},
"familyBudget": {
"type": "object",
"additionalProperties": false,
"required": [
"familyId",
"selectorType",
"selectors",
"thresholdSeconds",
"baselineSource",
"enforcement",
"lifecycleState"
],
"properties": {
"familyId": {
"type": "string",
"minLength": 1
},
"selectorType": {
"type": "string",
"enum": [
"testsuite",
"path",
"group",
"file"
]
},
"selectors": {
"$ref": "#/$defs/stringArray",
"minItems": 1
},
"thresholdSeconds": {
"type": "integer",
"minimum": 1
},
"baselineSource": {
"type": "string",
"enum": [
"measured-current-suite",
"measured-lane"
]
},
"enforcement": {
"type": "string",
"enum": [
"report-only",
"warn",
"hard-fail"
]
},
"lifecycleState": {
"type": "string",
"enum": [
"draft",
"measured",
"documented",
"enforced"
]
},
"notes": {
"type": "string"
},
"reviewCadence": {
"type": "string"
}
}
},
"dbStrategy": {
"type": "object",
"additionalProperties": false,
"required": [
"connectionMode",
"resetStrategy",
"seedsPolicy"
],
"properties": {
"connectionMode": {
"type": "string",
"enum": [
"sqlite-memory",
"pgsql",
"mixed"
]
},
"resetStrategy": {
"type": "string",
"enum": [
"none",
"refresh-database",
"dedicated-suite"
]
},
"seedsPolicy": {
"type": "string",
"enum": [
"forbidden",
"restricted",
"allowed-by-exception"
]
},
"schemaBaselineCandidate": {
"type": "boolean",
"default": false
}
}
},
"artifactType": {
"type": "string",
"enum": [
"summary",
"junit-xml",
"profile-top",
"budget-report"
]
},
"stringArray": {
"type": "array",
"items": {
"type": "string",
"minLength": 1
},
"uniqueItems": true,
"default": []
}
}
}

View File

@ -0,0 +1,396 @@
openapi: 3.1.0
info:
title: Test Suite Governance Logical Contract
version: 1.0.0
summary: Logical run and reporting contract for checked-in test lanes.
description: |
This is a logical contract for repository tooling, not a promise that a new
HTTP service will be introduced. It documents the expected semantics of lane
execution and lane reporting so commands, tasks, or wrappers can remain
consistent as the implementation evolves.
x-logical-contract: true
servers:
- url: https://tenantatlas.local/logical
paths:
/test-lanes/{laneId}/runs:
post:
summary: Start a lane run through a checked-in command entry point.
operationId: startTestLaneRun
parameters:
- name: laneId
in: path
required: true
schema:
$ref: '#/components/schemas/LaneId'
responses:
'202':
description: Lane execution accepted and resolved to a checked-in command path.
content:
application/json:
schema:
$ref: '#/components/schemas/LaneRunAccepted'
/test-lanes/{laneId}/reports/latest:
get:
summary: Read the most recent report produced for a lane.
operationId: getLatestTestLaneReport
parameters:
- name: laneId
in: path
required: true
schema:
$ref: '#/components/schemas/LaneId'
responses:
'200':
description: Latest report summary for the requested lane.
content:
application/json:
schema:
$ref: '#/components/schemas/LaneReport'
components:
schemas:
LaneId:
type: string
enum:
- fast-feedback
- confidence
- heavy-governance
- browser
- profiling
- junit
GovernanceClass:
type: string
enum:
- fast
- confidence
- heavy
- support
ArtifactMode:
type: string
enum:
- summary
- junit-xml
- profile-top
- budget-report
BudgetStatus:
type: string
enum:
- within-budget
- warning
- over-budget
BudgetBaselineSource:
type: string
enum:
- measured-current-suite
- measured-lane
BudgetEnforcement:
type: string
enum:
- report-only
- warn
- hard-fail
BudgetLifecycleState:
type: string
enum:
- draft
- measured
- documented
- enforced
LaneRunAccepted:
type: object
additionalProperties: false
required:
- laneId
- governanceClass
- commandRef
- parallelMode
- artifactModes
- includedFamilies
- excludedFamilies
- budgetThresholdSeconds
- budgetBaselineSource
- budgetEnforcement
- budgetLifecycleState
properties:
laneId:
$ref: '#/components/schemas/LaneId'
governanceClass:
$ref: '#/components/schemas/GovernanceClass'
commandRef:
type: string
description: Checked-in command reference, such as a Composer script name.
parallelMode:
type: string
enum:
- required
- optional
- forbidden
artifactModes:
type: array
minItems: 1
items:
$ref: '#/components/schemas/ArtifactMode'
includedFamilies:
type: array
minItems: 1
items:
type: string
excludedFamilies:
type: array
items:
type: string
nonBrowserFeatureIntegrationSelectorCount:
type: integer
minimum: 0
includedSelectors:
type: array
items:
type: string
excludedSelectors:
type: array
items:
type: string
budgetThresholdSeconds:
type: integer
minimum: 1
budgetBaselineSource:
$ref: '#/components/schemas/BudgetBaselineSource'
budgetEnforcement:
$ref: '#/components/schemas/BudgetEnforcement'
budgetLifecycleState:
$ref: '#/components/schemas/BudgetLifecycleState'
baselineDeltaTargetPercent:
type: integer
minimum: 1
maximum: 100
artifactDirectory:
type: string
const: storage/logs/test-lanes
description: App-root relative directory for emitted artifacts. The first slice fixes this to storage/logs/test-lanes.
allOf:
- if:
properties:
laneId:
const: fast-feedback
then:
required:
- baselineDeltaTargetPercent
properties:
baselineDeltaTargetPercent:
const: 50
parallelMode:
const: required
excludedFamilies:
allOf:
- contains:
const: browser
- contains:
const: heavy-governance
- if:
properties:
laneId:
const: confidence
then:
properties:
parallelMode:
const: required
includedFamilies:
allOf:
- contains:
const: unit
- contains:
const: non-browser-feature-integration
nonBrowserFeatureIntegrationSelectorCount:
minimum: 1
excludedFamilies:
allOf:
- contains:
const: browser
- contains:
const: heavy-governance
- if:
properties:
laneId:
const: profiling
then:
properties:
governanceClass:
const: support
parallelMode:
const: forbidden
artifactModes:
contains:
const: profile-top
- if:
properties:
laneId:
const: junit
then:
properties:
governanceClass:
const: support
artifactModes:
contains:
const: junit-xml
- if:
properties:
laneId:
const: browser
then:
properties:
includedFamilies:
contains:
const: browser
LaneReport:
type: object
additionalProperties: false
required:
- laneId
- finishedAt
- wallClockSeconds
- budgetThresholdSeconds
- budgetBaselineSource
- budgetEnforcement
- budgetLifecycleState
- budgetStatus
- slowestEntries
- familyBudgetEvaluations
- artifacts
properties:
laneId:
$ref: '#/components/schemas/LaneId'
finishedAt:
type: string
format: date-time
wallClockSeconds:
type: number
minimum: 0
budgetThresholdSeconds:
type: integer
minimum: 1
budgetBaselineSource:
$ref: '#/components/schemas/BudgetBaselineSource'
budgetEnforcement:
$ref: '#/components/schemas/BudgetEnforcement'
budgetLifecycleState:
$ref: '#/components/schemas/BudgetLifecycleState'
baselineDeltaTargetPercent:
type: integer
minimum: 1
maximum: 100
budgetStatus:
$ref: '#/components/schemas/BudgetStatus'
slowestEntries:
type: array
minItems: 10
items:
$ref: '#/components/schemas/SlowEntry'
familyBudgetEvaluations:
type: array
minItems: 1
items:
$ref: '#/components/schemas/FamilyBudgetEvaluation'
artifacts:
type: array
minItems: 1
items:
$ref: '#/components/schemas/ArtifactRecord'
allOf:
- if:
properties:
laneId:
const: fast-feedback
then:
properties:
baselineDeltaTargetPercent:
const: 50
- if:
properties:
laneId:
const: profiling
then:
properties:
artifacts:
contains:
type: object
required:
- artifactMode
properties:
artifactMode:
const: profile-top
- if:
properties:
laneId:
const: junit
then:
properties:
artifacts:
contains:
type: object
required:
- artifactMode
properties:
artifactMode:
const: junit-xml
SlowEntry:
type: object
additionalProperties: false
required:
- subject
- durationSeconds
- laneId
properties:
subject:
type: string
durationSeconds:
type: number
minimum: 0
laneId:
$ref: '#/components/schemas/LaneId'
familyId:
type: string
ArtifactRecord:
type: object
additionalProperties: false
required:
- artifactMode
- relativePath
properties:
artifactMode:
$ref: '#/components/schemas/ArtifactMode'
relativePath:
type: string
pattern: ^storage/logs/test-lanes/
machineReadable:
type: boolean
FamilyBudgetEvaluation:
type: object
additionalProperties: false
required:
- familyId
- thresholdSeconds
- baselineSource
- enforcement
- lifecycleState
- measuredSeconds
- budgetStatus
properties:
familyId:
type: string
thresholdSeconds:
type: integer
minimum: 1
baselineSource:
$ref: '#/components/schemas/BudgetBaselineSource'
enforcement:
$ref: '#/components/schemas/BudgetEnforcement'
lifecycleState:
$ref: '#/components/schemas/BudgetLifecycleState'
measuredSeconds:
type: number
minimum: 0
budgetStatus:
$ref: '#/components/schemas/BudgetStatus'
matchedSelectors:
type: array
items:
type: string

View File

@ -0,0 +1,238 @@
# Data Model: Test Suite Governance & Performance Foundation
This feature does not introduce new runtime database tables. The data-model work formalizes repository-level governance objects that define how tests are grouped, how heavy setup is declared, and how runtime drift is reported. The first slice uses four operational lanes (`fast-feedback`, `confidence`, `browser`, and `heavy-governance`) plus two support-lane entries (`profiling` and `junit`) that share the same manifest and reporting contract shape while keeping narrower responsibilities. Field names in this document are conceptual and may appear in camelCase form in the checked-in manifest and logical reporting contracts. In particular, `lane_id` maps to manifest `id` and report `laneId`, `artifact_modes` maps to manifest `artifacts`, accepted-run `artifactModes`, and report `artifacts[].artifactMode`, and `enforcement_level` maps to manifest-contract `enforcement`.
## 1. Test Lane
### Purpose
Represents one checked-in execution path or support-lane entry for the suite.
### Fields
- `lane_id`: stable identifier such as `fast-feedback`, `confidence`, `heavy-governance`, `browser`, `profiling`, or `junit`
- `governance_class`: `fast`, `confidence`, `heavy`, or `support`
- `description`: contributor-facing statement of the lane's purpose
- `intended_audience`: the contributor or reviewer audience the lane is optimized for
- `included_families`: written list of the families the lane intentionally owns
- `excluded_families`: written list of the families the lane intentionally leaves out
- `ownership_expectations`: contributor-facing statement of when the lane is expected to be run and maintained
- `default_entry_point`: boolean
- `parallel_mode`: `required`, `optional`, or `forbidden`
- `selectors`: include and exclude rules for suites, directories, files, and groups
- `artifact_modes`: list of expected outputs such as `summary`, `junit-xml`, `profile-top`, or `budget-report`
- `budget`: runtime target and enforcement level
- `db_strategy`: expected connection and reset discipline
### Validation rules
- Exactly one lane may be the default contributor entry point.
- The first-slice manifest must include the four operational lanes and the two support runs.
- Support runs remain lane-shaped manifest entries with `governance_class = support` and the same reporting envelope as operational lanes.
- A lane that emits `profile-top` must use `parallel_mode = forbidden`.
- A lane with `lane_id = browser` must not be the default contributor lane.
- A lane must have at least one positive selector.
- Every lane declaration must record its purpose or description, intended audience, included families, excluded families, and ownership expectations.
- The `fast-feedback` lane budget must carry `baseline_delta_target_percent = 50` for the first slice.
## 2. Lane Selector
### Purpose
Represents one inclusion or exclusion rule that determines lane membership.
In the first-slice manifest contract, selector records are serialized into the flattened include or exclude arrays on each lane entry. The richer selector fields below remain conceptual metadata for planning, review, and future helper logic rather than required manifest fields.
### Fields
- `selector_type`: `testsuite`, `path`, `group`, or `file`
- `selector_value`: exact suite name, directory path, group name, or file path
- `inclusion_mode`: `include` or `exclude`
- `rationale`: short explanation of why the selector belongs to the lane
- `cost_class`: optional `normal`, `heavy`, or `special-case`
### Relationships
- Many selectors belong to one test lane.
- Selectors may be shared conceptually across lanes but are evaluated per lane declaration.
## 3. Test Classification Rule
### Purpose
Defines what makes a test legitimately belong to a particular classification.
### Fields
- `classification`: `unit`, `feature-integration`, `browser`, or `architecture-governance`
- `laravel_boot_allowed`: boolean
- `database_allowed`: boolean
- `browser_runtime_required`: boolean
- `livewire_or_filament_mount_allowed`: boolean
- `default_lane_target`: lane identifier
- `promotion_conditions`: list of conditions that force escalation into a heavier lane
### Validation rules
- `unit` classification must not require browser runtime.
- `browser` classification must require browser runtime.
- Tests that need broad file scans, whole-surface discovery, or smoke assertions across many pages must not default into the fast-feedback lane.
## 4. Shared Fixture Profile
### Purpose
Represents a named setup profile for a shared test helper.
### Fields
- `helper_name`: example `createUserWithTenant`
- `profile_name`: `minimal`, `full`, or another explicit cost-bearing name
- `default_profile`: boolean
- `creates_workspace`: boolean
- `creates_membership`: boolean
- `creates_credentials`: boolean
- `creates_provider_connection`: boolean
- `sets_session_context`: boolean
- `creates_ui_context`: boolean
- `clears_capability_caches`: boolean
- `notes`: contributor guidance on when this profile is appropriate
### Validation rules
- Each shared helper must have exactly one default profile.
- A default profile should not create workspace, membership, credentials, provider connection state, session context, cache-clearing side effects, or UI context unless the helper's primary job is specifically to set up that heavier behavior.
- Profiles with materially heavier behavior must use explicit names rather than boolean flags only.
## 5. Factory Cost Profile
### Purpose
Describes the cost posture of a factory state used in tests.
### Fields
- `factory_name`
- `state_name`
- `cost_class`: `minimal`, `standard`, or `heavy`
- `creates_relationships`: list of related models or graph expansions
- `intended_usage`: short explanation of the state's purpose
- `safe_default`: boolean
### Validation rules
- A heavy state must document which additional relationships it creates.
- Factories used in high-volume tests should expose at least one minimal or standard state that avoids surprising graph expansion.
## 6. Database Reset Policy
### Purpose
Formalizes the allowed DB behavior for a suite or lane.
### Fields
- `target_scope`: `unit`, `feature`, `browser`, `pgsql`, or `lane-specific`
- `connection_mode`: `sqlite-memory`, `pgsql`, or `mixed`
- `reset_strategy`: `none`, `refresh-database`, or `dedicated-suite`
- `seeds_policy`: `forbidden`, `restricted`, or `allowed-by-exception`
- `schema_baseline_candidate`: boolean
- `notes`
### Validation rules
- A policy using `connection_mode = sqlite-memory` should treat schema-baseline work as optional evidence rather than assumed default value.
- A policy using `reset_strategy = dedicated-suite` must name the target suite or command path that justifies the exception.
## 7. Runtime Budget
### Purpose
Represents the expected wall-clock target for a lane or known heavy family.
### Fields
- `budget_id`
- `target_type`: `lane` or `family`
- `target_id`: lane identifier or family identifier
- `threshold_seconds`
- `baseline_source`: `measured-current-suite` or `measured-lane`
- `enforcement_level`: `report-only`, `warn`, or `hard-fail`
- `baseline_delta_target_percent`: optional percentage reduction target versus the current full-suite baseline when applicable, such as the fast-feedback 50% target
- `lifecycle_state`: `draft`, `measured`, `documented`, or `enforced`
- `review_cadence`: short rule such as `tighten after two stable runs`
### State transitions
- `draft``measured`
- `measured``documented`
- `documented``enforced`
## 8. Lane Report Artifact
### Purpose
Represents one machine-readable or human-readable artifact emitted by a lane.
### Fields
- `artifact_type`: `summary`, `junit-xml`, `profile-top`, or `budget-report`
- `relative_path`: app-root relative path under `storage/logs/test-lanes`
- `machine_readable`: boolean
- `generated_by_lanes`: list of lane identifiers
- `retention_scope`: `local-ephemeral`
### Validation rules
- Artifact paths must be relative to `apps/platform` because `scripts/platform-sail` changes into the app directory.
- Machine-readable budget evaluation must include the measured duration, threshold, and status.
- First-slice artifact retention is local-only under `storage/logs/test-lanes`; CI upload is later hardening work, not part of this contract.
## 8a. Family Budget Threshold
### Purpose
Represents the budget contract for a heavy file, group, path, or suite family that is tracked alongside lane-level budgets.
### Fields
- `family_id`: stable identifier for the heavy family
- `selector_type`: `testsuite`, `path`, `group`, or `file`
- `selectors`: one or more selectors that identify the tracked family
- `threshold_seconds`: allowed wall-clock target for the family cluster
- `baseline_source`: `measured-current-suite` or `measured-lane`
- `enforcement_level`: `report-only`, `warn`, or `hard-fail`
- `notes`
- `lifecycle_state`: `draft`, `measured`, `documented`, or `enforced`
### Validation rules
- Every first-slice manifest must carry at least one family budget threshold in addition to lane-level budgets.
- Family budget thresholds must use the same baseline-backed source vocabulary as lane budgets.
## 9. First-Slice Governance Inventory
### Current suite footprint
- Approximate test file count: `1,122`
- Feature files: `873`
- Unit files: `234`
- Browser files: `12`
- Architecture files: `2`
- Deprecation files: `1`
### Current hotspots
- `createUserWithTenant()` is referenced by roughly `607` test files and currently provisions user, tenant, workspace, workspace membership, session context, capability cache clears, and provider connection state by default.
- `tests/Pest.php` applies `RefreshDatabase` broadly to `tests/Feature` and `tests/Browser`.
- Existing explicit Pest groups are sparse, with `ops-ux` as the largest visible group seam.
### First-slice target objects
- `fast-feedback` lane definition
- `confidence` lane definition
- `heavy-governance` lane definition
- `browser` execution target
- `profiling` and `junit` support targets
- `createUserWithTenant` minimal and full fixture profiles
- At least one additional factory or fixture cluster with explicit minimal and heavy cost profiles

View File

@ -0,0 +1,164 @@
# Implementation Plan: Test Suite Governance & Performance Foundation
**Branch**: `206-test-suite-governance` | **Date**: 2026-04-16 | **Spec**: `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/206-test-suite-governance/spec.md`
**Input**: Feature specification from `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/206-test-suite-governance/spec.md`
## Summary
Establish repo-wired test-suite governance on top of the current Laravel 12, Pest 4, and Sail stack by defining explicit fast-feedback, confidence, heavy-governance, and browser execution paths; using Sail-wrapped `artisan test` as the canonical checked-in runner; standardizing JUnit and slow-test reporting artifacts; slimming the most pervasive shared helper and factory defaults; and documenting runtime budgets and DB-reset rules without adding new runtime product surfaces.
## Technical Context
**Language/Version**: PHP 8.4.15
**Primary Dependencies**: Laravel 12, Pest v4, PHPUnit 12, Pest Browser plugin, Filament v5, Livewire v4, Laravel Sail
**Storage**: SQLite `:memory:` for the default test configuration, dedicated PostgreSQL config for the schema-level `Pgsql` suite, and local runner artifacts under the repo path `apps/platform/storage/logs/test-lanes` with the app-root contract value `storage/logs/test-lanes`
**Testing**: Pest unit, feature, browser, architecture, and guard-style suites run via `artisan test`; `RefreshDatabase` is currently auto-applied to `tests/Feature` and `tests/Browser`; profiling must run serially because Pest rejects `--profile` with `--parallel`
**Target Platform**: Laravel web application in `apps/platform`, executed locally through Sail on macOS/Linux with later CI hardening on shared runners
**Project Type**: Monorepo with a Laravel platform app and separate Astro website; this feature is scoped to platform test infrastructure and repo-level command entry points
**Performance Goals**: Fast-feedback lane at least 50% below the current full-suite baseline, confidence lane below the current full-suite baseline, browser and heavy-governance runs isolated from the default loop, and reporting lanes that surface the top 10 slowest tests or files
**Constraints**: Sail-first commands only; `scripts/platform-sail` changes the working directory to `apps/platform`; no new runtime routes, panels, assets, or dependencies; artifact paths must be app-root relative; `RefreshDatabase` remains the first-slice reset default unless a targeted carve-out is explicitly justified
**Scale/Scope**: Current suite footprint is approximately 1,122 test files (`873` feature, `234` unit, `12` browser, `2` architecture, `1` deprecation), existing Pest grouping is sparse (`ops-ux`, `spec081`), and `createUserWithTenant()` is referenced by roughly `607` test files
### Filament v5 Implementation Notes
- **Livewire v4.0+ compliance**: Preserved. This feature governs tests around existing Filament and Livewire surfaces but does not alter the runtime Filament stack.
- **Provider registration location**: Unchanged. Existing panel providers remain registered in `bootstrap/providers.php`.
- **Global search rule**: No globally searchable resources are added or changed.
- **Destructive actions**: Runtime destructive behavior is unchanged. Any new tests added by this feature continue to assert existing confirmation and authorization rules rather than introducing new action behavior.
- **Asset strategy**: No new panel or shared assets are introduced. Existing `filament:assets` deployment behavior remains unchanged.
- **Testing plan**: Add Pest coverage for lane manifest validity, command selection, helper minimal/full defaults, browser-lane isolation, artifact generation, and guard regressions that prevent lane drift.
## Constitution Check
*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.*
- Inventory-first: PASS. No inventory, backup, or snapshot truth is changed.
- Read/write separation: PASS. The feature governs repository test execution only and introduces no end-user write path.
- Graph contract path: PASS. No Graph calls, contract-registry changes, or provider runtime integrations are added.
- Deterministic capabilities: PASS. No capability model or authorization registry changes.
- RBAC-UX, workspace isolation, tenant isolation: PASS. No runtime route, policy, or tenant/workspace surface is changed.
- Run observability and Ops-UX: PASS. Reporting artifacts are local test-run outputs, not `OperationRun` records or operator notifications.
- Data minimization: PASS. Test artifacts stay in local log storage and contain runner metadata rather than secrets or customer payloads.
- Proportionality and bloat control: PASS WITH LIMITS. The only new semantic layer is a repo-local lane and taxonomy model bounded to test execution, reporting, and cheap-default discipline.
- TEST-TRUTH-001: PASS. The plan improves suite honesty and business-truth protection by making heavy setup and slow regressions more visible.
- Filament/UI constitutions: PASS / NOT APPLICABLE. No operator-facing UI, action surface, badge semantics, or information architecture is changed.
**Phase 0 Gate Result**: PASS
- The feature is bounded to repo test governance, reporting, and fixture discipline.
- No new runtime persistence, product routes, panels, assets, or Graph behavior is introduced.
- The new lane taxonomy is narrow enough to satisfy PROP-001 while still creating a reusable repo standard.
## Project Structure
### Documentation (this feature)
```text
specs/206-test-suite-governance/
├── plan.md
├── research.md
├── data-model.md
├── quickstart.md
├── contracts/
│ ├── test-lane-manifest.schema.json
│ └── test-suite-governance.logical.openapi.yaml
└── tasks.md
```
### Source Code (repository root)
```text
apps/
├── platform/
│ ├── composer.json
│ ├── phpunit.xml
│ ├── phpunit.pgsql.xml
│ ├── tests/
│ │ ├── Pest.php
│ │ ├── TestCase.php
│ │ ├── Unit/
│ │ ├── Feature/
│ │ ├── Browser/
│ │ ├── Architecture/
│ │ ├── Deprecation/
│ │ └── Support/
│ ├── database/factories/
│ └── storage/logs/
├── website/
└── ...
package.json
scripts/
└── platform-sail
```
**Structure Decision**: Use the existing monorepo and keep implementation concentrated in `apps/platform` test bootstrap/configuration plus checked-in command seams. The feature is expected to touch `apps/platform/composer.json`, `apps/platform/phpunit.xml`, `apps/platform/tests/Pest.php`, selected factory/helper files under `apps/platform/tests` and `apps/platform/database/factories`, and focused guard or performance tests under `apps/platform/tests/Feature` and `apps/platform/tests/Unit`.
## Phase 0 — Research (complete)
- Output: [research.md](./research.md)
- Resolved key decisions:
- Use Sail-wrapped `artisan test` as the canonical checked-in runner for fast, confidence, browser, heavy, profile, and JUnit paths.
- Model lane membership through a hybrid of existing PHPUnit suites, directory selectors, and curated Pest groups instead of a full folder reorganization.
- Keep fast-feedback and confidence lanes parallelized, but require serial profiling because Pest forbids `--profile` with `--parallel`.
- Keep `RefreshDatabase` as the first-slice default for feature and browser tests while targeting helper and factory slimming first.
- Split the dominant shared tenant-user helper into minimal and explicit heavy profiles because it currently provisions workspace, membership, session, cache, and provider connection state by default.
- Standardize machine-readable artifacts and budget summaries under the repo path `apps/platform/storage/logs/test-lanes`, which corresponds to the app-root contract value `storage/logs/test-lanes`, because `scripts/platform-sail` already runs from the app root.
- Treat schema dumps as a follow-up evaluation, not a first-slice requirement, because the default suite uses in-memory SQLite and the PostgreSQL suite is currently isolated.
## Phase 1 — Design & Contracts (complete)
- Output: [data-model.md](./data-model.md) formalizes lane, selector, helper-profile, factory-cost, DB-reset, artifact, and budget entities.
- Output: [contracts/test-lane-manifest.schema.json](./contracts/test-lane-manifest.schema.json) defines the checked-in manifest structure for lane membership, artifacts, budgets, and DB strategy.
- Output: [contracts/test-suite-governance.logical.openapi.yaml](./contracts/test-suite-governance.logical.openapi.yaml) captures the logical run/report contract for lane execution and artifact inspection.
- Output: [quickstart.md](./quickstart.md) provides the planned implementation order, focused validation commands, and rollout checkpoints.
### Post-design Constitution Re-check
- PASS: No runtime routes, panels, authorization planes, or Graph seams are introduced.
- PASS: The only new taxonomy is repo-local and directly justified by current suite cost and contributor workflow.
- PASS: The design prefers local selectors, helper splits, and manifest-backed commands over new generalized platform abstractions.
- PASS WITH WORK: Initial heavy-family selection must remain evidence-driven and should start with obviously separate seed families such as architecture, deprecation, browser-adjacent governance scans, and wide scan or guard suites, while browser itself remains its own dedicated lane, then be refined after the first profiling pass.
- PASS WITH WORK: Budget enforcement starts as documented reporting thresholds and can harden later once baseline measurements stabilize.
## Phase 2 — Implementation Planning
`tasks.md` should cover:
- Adding the checked-in lane command entry points for fast-feedback, confidence, browser, heavy-governance, profiling, and JUnit/reporting.
- Introducing a lane manifest or equivalent checked-in selector map that combines suites, directories, files, and groups without broad test relocation.
- Keeping the default contributor run aligned to fast-feedback rather than the current broad serial behavior.
- Creating artifact output conventions and summary generation under the repo path `apps/platform/storage/logs/test-lanes` and the app-root contract value `storage/logs/test-lanes`.
- Adding guard coverage that verifies browser exclusion from the fast lane, valid lane manifest shape, and stable budget/report semantics.
- Capturing the current full-suite baseline and the first measured lane baselines before final budget publication.
- Refactoring `createUserWithTenant()` into minimal and explicit heavy/provider-enabled paths, then migrating the first high-value callers.
- Introducing explicit minimal versus heavy factory states for at least one additional cascading fixture cluster touched in this slice, plus guidance that future touched heavy clusters must follow the same pattern.
- Documenting honest taxonomy rules for Unit, Feature or Integration, Browser, and Architecture or Governance tests, plus auditing and reclassifying the first obvious misfit batch surfaced during rollout.
- Defining and documenting initial runtime budgets for at least fast-feedback, confidence, browser, and the first identified heavy family, with heavy-governance refinement informed by the first profiling evidence.
- Capturing DB strategy guidance for SQLite-memory default runs, the isolated PostgreSQL suite, seeds policy, and later schema-baseline evaluation.
### Contract Implementation Note
- The lane manifest contract is schema-first and intentionally runner-agnostic. It defines what a checked-in lane declaration must contain even if the first implementation stores it in PHP arrays or config instead of a dedicated parser.
- The OpenAPI file is logical rather than transport-prescriptive. It documents the expected semantics of lane execution and report generation for commands, tasks, or wrappers that will remain in-process repository tooling.
- The plan intentionally avoids introducing a new runtime service or database table for lane reporting. Artifacts remain filesystem-based and ephemeral.
### Deployment Sequencing Note
- No database migration is planned.
- No asset publish step changes.
- The rollout should start with lane/report visibility, then cheap helper defaults, then lane reshaping of the heaviest families, and only afterward optional framework-level tuning such as schema-baseline evaluation.
## Complexity Tracking
| Violation | Why Needed | Simpler Alternative Rejected Because |
|-----------|------------|-------------------------------------|
| None | Not applicable | Not applicable |
## Proportionality Review
- **Current operator problem**: Contributors cannot reliably choose the right test lane, and maintainers cannot see slow-test drift or hidden heavy setup early enough to keep the default run healthy.
- **Existing structure is insufficient because**: Current execution relies on a broad serial default, sparse Pest groups, heavyweight shared defaults, and ad-hoc local reporting rather than checked-in governance.
- **Narrowest correct implementation**: A repo-local lane manifest, explicit checked-in commands, artifact/report conventions, and minimal/full fixture profiles solve the problem without adding runtime services or persistence.
- **Ownership cost created**: The repo must maintain lane selectors, helper profile rules, budgets, and a small set of guard tests that keep drift visible.
- **Alternative intentionally rejected**: Pure parallelization without taxonomy, or local-only helper workarounds without checked-in reporting and lane definitions.
- **Release truth**: Current-release truth and immediate prerequisite for larger suite growth and later CI hardening.

Some files were not shown because too many files have changed in this diff Show More