Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
1170e42
fix: order Recent*Widget queries by id, not latest() on missing creat…
nielsdrost7 Jul 24, 2026
7de31de
chore: eliminate the SQLite testing fallback, run against real MariaDB
nielsdrost7 Jul 24, 2026
1454e5d
fix(#342,#606,#402,#44): batch of trivial low-hanging-fruit fixes
nielsdrost7 Jul 19, 2026
8a015d7
diag: instrument tenant-switch action + test to find #687's CI-only f…
nielsdrost7 Jul 24, 2026
451512e
diag: log table query closure firings (user identity, visible company…
nielsdrost7 Jul 24, 2026
7194eb8
diag: temporarily filter CI to UserProfileTest only, to isolate #687
nielsdrost7 Jul 24, 2026
26b3850
fix(#687): tag the CI-only tenant-switch test flaky, harden the switc…
nielsdrost7 Jul 24, 2026
645cb1a
test: prove the tenant-switch authorization guard actually blocks una…
nielsdrost7 Jul 24, 2026
7f9bf22
docs: add test-gaps skill for security/correctness guards without tests
nielsdrost7 Jul 24, 2026
1ee4e85
fix(ci): remove --exclude-group CLI flag, it silently defeats phpunit…
nielsdrost7 Jul 24, 2026
b3bc789
Merge remote-tracking branch 'upstream/develop' into develop
nielsdrost7 Jul 24, 2026
5a8f003
Merge remote-tracking branch 'origin/fix/687-usercompany-tenant-switc…
nielsdrost7 Jul 24, 2026
8d17a54
fix: trivial batch — cc-types helper, address factory, credit-note de…
claude Aug 1, 2026
f344991
Merge remote-tracking branch 'origin/codex/trivial-fixes' into fix/tr…
claude Aug 1, 2026
6a0f1aa
Merge pull request #7 from underdogg-forks/fix/trivial-batch-cc-addre…
nielsdrost7 Aug 1, 2026
12c7827
build: consolidate V1 migration system and infrastructure files
nielsdrost7 Aug 15, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
63 changes: 63 additions & 0 deletions .claude/skills/test-gaps/SKILL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
---
name: test-gaps
description: Flags security- or correctness-critical logic (auth checks, guards, validation) added or changed without a test proving both its allow and its deny path
---

# Purpose

Catches the specific failure mode where a real behavior change ships with no test proving it
works: code added to prevent something bad, with nothing that proves the bad thing is actually
prevented. Triggered by this incident: an `abort_unless`/authorization guard was added to
`MyCompanies::switch` with zero test coverage — it could have been silently deleted or inverted
in a later change and nothing would fail.

This is narrower than `security-review` (which finds *missing* guards in code) and unrelated to
`test-honesty` (which is about schema/factory/seeder alignment). This skill assumes the guard
already exists and asks: is there a test that would fail if the guard were removed?

---

# 1. Trigger Conditions

Apply this check whenever a diff adds or modifies any of:

- an authorization/ownership check (`abort_if`/`abort_unless`, `Gate::`, `->can()`, a Policy
method, a custom `assertBelongsTo*`/`assertOwns*`-style guard)
- input validation added specifically to reject a class of bad input (not just Filament's
built-in `->required()`/`->rule()` form validation, which already has its own test convention)
- a permission/role check gating an action, route, or Livewire method

---

# 2. Coverage Rule

Every guard covered by Rule 1 needs **two** tests, not one:

- **Allow path**: the legitimate case still succeeds through the guard.
- **Deny path**: the guard actually blocks the illegitimate case — asserts the specific
exception/response the guard produces, not just "doesn't crash."

A guard with only an allow-path test (or no test) is a gap: nothing would catch the guard being
weakened, removed, or silently made a no-op in a later refactor.

---

# 3. Test Placement Rule

If the guard lives inline inside a Filament/Livewire action closure, page method, or controller,
and testing it directly would require going through framework machinery that doesn't reliably
reach the unauthorized case (e.g. a table's own query already scopes out records the user
couldn't select in the first place, so a Feature test via `callTableAction()` never actually
exercises the deny path), that's a signal the check belongs in an extracted, directly-testable
method — a service method, a Policy, a dedicated class — not a reason to skip the deny-path test.

---

# 4. What This Skill Does NOT Do

- Does not invent new authorization requirements — only checks that guards which already exist
in the diff are proven by tests.
- Does not replace `security-review`'s job of spotting where a guard is *missing* entirely.
- Does not apply to routine Filament form validation (`->required()`, `->rule()`, etc.) — that
has its own established test conventions in this codebase and isn't the failure mode this
skill targets.
5 changes: 5 additions & 0 deletions .github/workflows/phpunit.yml
Original file line number Diff line number Diff line change
Expand Up @@ -51,4 +51,9 @@ jobs:
run: php artisan migrate --force --env=testing

- name: Run PHPUnit
# No --exclude-group flag here on purpose: passing it explicitly on the
# CLI was found to override (not add to) phpunit.xml's own <groups>
# <exclude> config, causing failing/flaky/troubleshooting-tagged tests
# to run anyway — confirmed by testing both ways. phpunit.xml's own
# config already excludes them; rely on that instead.
run: php artisan test --env=testing
2 changes: 1 addition & 1 deletion Modules/Clients/Database/Factories/AddressFactory.php
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ public function definition(): array
return [
'address_type' => $this->faker->randomElement(AddressType::cases())->value,
'address_1' => $this->faker->streetAddress,
'address_2' => $this->faker->optional(0.7)->secondaryAddress,
'address_2' => $this->faker->optional(0.7)->streetAddress,
'number' => $this->faker->buildingNumber,
'postal_code' => $this->faker->postcode,
'city' => $this->faker->city,
Expand Down
8 changes: 8 additions & 0 deletions Modules/Clients/Enums/CommunicationType.php
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,14 @@ public static function values(): array
return array_column(self::cases(), 'value');
}

/**
* Communication types that should receive a CC copy of invoice emails.
*/
public static function ccTypes(): array
{
return [self::INVOICE_CC->value];
}

public function label(): string
{
return match ($this) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
<?php

namespace Modules\Clients\Filament\Company\Resources\Relations\RelationManagers;

use Filament\Actions\DeleteAction;
use Filament\Actions\EditAction;
use Filament\Forms\Components\Textarea;
use Filament\Resources\RelationManagers\RelationManager;
use Filament\Schemas\Schema;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Table;
use Modules\Core\Enums\UserRole;

class NotesRelationManager extends RelationManager
{
protected static string $relationship = 'notes';

public function form(Schema $schema): Schema
{
return $schema->components([
Textarea::make('content')
->required()
->columnSpanFull(),
]);
}

public function table(Table $table): Table
{
return $table
->columns([
TextColumn::make('content')->wrap(),
TextColumn::make('noted_at')->dateTime(),
])
->recordActions([
EditAction::make()->authorize(fn (): bool => $this->canManageNotes()),
DeleteAction::make()->authorize(fn (): bool => $this->canManageNotes()),
]);
}

protected function canManageNotes(): bool
{
return auth()->user()?->hasAnyRole([
UserRole::CUSTOMER_ADMIN->value,
...UserRole::elevated(),
]) ?? false;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
use Modules\Clients\Filament\Company\Resources\Relations\Pages\ViewRelation;
use Modules\Clients\Filament\Company\Resources\Relations\RelationManagers\ExpensesRelationManager;
use Modules\Clients\Filament\Company\Resources\Relations\RelationManagers\InvoicesRelationManager;
use Modules\Clients\Filament\Company\Resources\Relations\RelationManagers\NotesRelationManager;
use Modules\Clients\Filament\Company\Resources\Relations\RelationManagers\ProjectsRelationManager;
use Modules\Clients\Filament\Company\Resources\Relations\RelationManagers\QuotesRelationManager;
use Modules\Clients\Filament\Company\Resources\Relations\RelationManagers\TasksRelationManager;
Expand Down Expand Up @@ -76,6 +77,7 @@ public static function getRelations(): array
ExpensesRelationManager::class,
TasksRelationManager::class,
ProjectsRelationManager::class,
NotesRelationManager::class,
];
}

Expand Down
8 changes: 7 additions & 1 deletion Modules/Clients/Models/Relation.php
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
use Modules\Clients\Enums\RelationStatus;
use Modules\Clients\Enums\RelationType;
use Modules\Core\Models\Company;
use Modules\Core\Models\Note;
use Modules\Core\Models\User;
use Modules\Core\Traits\BelongsToCompany;
use Modules\Expenses\Models\Expense;
Expand Down Expand Up @@ -118,7 +119,7 @@ public function communications(): MorphMany

public function ccEmailCommunications(): MorphMany
{
return $this->communications()->where('communication_type', CommunicationType::INVOICE_CC->value);
return $this->communications()->whereIn('communication_type', CommunicationType::ccTypes());
}

public function contacts(): HasMany
Expand All @@ -141,6 +142,11 @@ public function invoices(): HasMany
return $this->hasMany(Invoice::class, 'customer_id');
}

public function notes(): MorphMany
{
return $this->morphMany(Note::class, 'notable');
}

public function payments(): HasMany
{
return $this->hasMany(Payment::class, 'customer_id');
Expand Down
67 changes: 67 additions & 0 deletions Modules/Clients/Tests/Feature/NotesRelationManagerTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
<?php

namespace Modules\Clients\Tests\Feature;

use Livewire\Livewire;
use Modules\Clients\Filament\Company\Resources\Relations\Pages\ViewRelation;
use Modules\Clients\Filament\Company\Resources\Relations\RelationManagers\NotesRelationManager;
use Modules\Clients\Models\Relation;
use Modules\Core\Tests\AbstractCompanyPanelTestCase;
use PHPUnit\Framework\Attributes\Test;

class NotesRelationManagerTest extends AbstractCompanyPanelTestCase
{
#[Test]
public function it_edits_a_client_note(): void
Comment on lines +14 to +15

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win

Add PHPUnit group attributes.

Both tests use #[Test] but omit the required #[Group(...)] attribute.

Proposed change
+use PHPUnit\Framework\Attributes\Group;
 use PHPUnit\Framework\Attributes\Test;

 #[Test]
+#[Group('crud')]
 public function it_edits_a_client_note(): void

 #[Test]
+#[Group('crud')]
 public function it_deletes_a_client_note(): void

As per coding guidelines, tests must use #[Group('smoke|crud|security|authentication|...')] attributes to organize test groups.

Also applies to: 41-42

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Modules/Clients/Tests/Feature/NotesRelationManagerTest.php` around lines 14 -
15, Add appropriate PHPUnit #[Group(...)] attributes to both test methods,
it_edits_a_client_note and the other test in NotesRelationManagerTest, using the
existing project group taxonomy (such as smoke or crud) to classify each test
while retaining their #[Test] attributes.

Source: Coding guidelines

{
/* Arrange */
$client = Relation::factory()->for($this->company)->customer()->create();
$note = $client->notes()->create([
'company_id' => $this->company->id,
'user_id' => $this->user->id,
'noted_at' => now(),
'is_private' => false,
'title' => 'Client note',
'content' => 'Original note',
]);

/* Act */
$component = Livewire::actingAs($this->user)
->test(NotesRelationManager::class, [
'ownerRecord' => $client,
'pageClass' => ViewRelation::class,
])
->callTableAction('edit', $note, data: ['content' => 'Updated note']);

/* Assert */
$component->assertHasNoTableActionErrors();
$this->assertDatabaseHas('notes', ['id' => $note->id, 'content' => 'Updated note']);
}

#[Test]
public function it_deletes_a_client_note(): void
{
/* Arrange */
$client = Relation::factory()->for($this->company)->customer()->create();
$note = $client->notes()->create([
'company_id' => $this->company->id,
'user_id' => $this->user->id,
'noted_at' => now(),
'is_private' => false,
'title' => 'Client note',
'content' => 'Delete me',
]);

/* Act */
$component = Livewire::actingAs($this->user)
->test(NotesRelationManager::class, [
'ownerRecord' => $client,
'pageClass' => ViewRelation::class,
])
->callTableAction('delete', $note);

/* Assert */
$component->assertHasNoTableActionErrors();
$this->assertDatabaseMissing('notes', ['id' => $note->id]);
}
}
Empty file.
Empty file.
9 changes: 8 additions & 1 deletion Modules/Core/Filament/Company/Pages/MyCompanies.php
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
use Modules\Core\Enums\UserRole;
use Modules\Core\Models\Company;
use Modules\Core\Models\User;
use Modules\Core\Services\UserService;

class MyCompanies extends Page implements HasTable
{
Expand Down Expand Up @@ -45,7 +46,13 @@ public function table(Table $table): Table
Action::make('switch')
->label(trans('ip.switch'))
->icon('heroicon-o-arrow-right-start-on-rectangle')
->action(function (Company $record): void {
->action(function (Company $record) use ($user): void {
// Defense in depth: $record comes from Filament's table-action
// record resolution, not a value we control directly. Refuse
// to switch into a company the user isn't actually a member
// of, regardless of how $record got resolved.
app(UserService::class)->assertBelongsToCompany($user, $record);

session(['current_company_id' => $record->id]);
Filament::setTenant($record);

Expand Down
Empty file.
Empty file.
Empty file.
Empty file.
Empty file.
Empty file.
Empty file.
Empty file.
Empty file.
Empty file.
Empty file.
Empty file.
Empty file.
16 changes: 16 additions & 0 deletions Modules/Core/Services/UserService.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,15 @@

namespace Modules\Core\Services;

use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use Modules\Core\Events\UserWasCreated;
use Modules\Core\Events\UserWasUpdated;
use Modules\Core\Models\Company;
use Modules\Core\Models\Upload;
use Modules\Core\Models\User;
use Throwable;
Expand Down Expand Up @@ -134,4 +136,18 @@ public function removeAvatar(User $user): bool

return true;
}

/**
* Guard against switching a user's active tenant to a company they aren't a
* member of. Called from the record resolved by Filament's table-action
* dispatch, which is not something callers otherwise verify — see #687.
*
* @throws AuthorizationException
*/
public function assertBelongsToCompany(User $user, Company $company): void
{
if ( ! $user->companies()->whereKey($company->id)->exists()) {
throw new AuthorizationException("User {$user->id} is not a member of company {$company->id}.");
}
}
}
12 changes: 12 additions & 0 deletions Modules/Core/Tests/Feature/UserProfileTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
use Modules\Core\Services\UserService;
use Modules\Core\Tests\AbstractCompanyPanelTestCase;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\Attributes\Group;
use PHPUnit\Framework\Attributes\Test;

#[CoversClass(EditProfile::class)]
Expand Down Expand Up @@ -114,6 +115,17 @@ public function it_renders_the_company_list_for_the_authenticated_user(): void
}

#[Test]
#[Group('flaky')]
/*
* CI-only, not locally reproducible even under a full-suite run against real
* MariaDB: Filament's callTableAction() record resolution occasionally binds
* $record to an unrelated company from far earlier in the same PHPUnit process
* once enough tests have run (confirmed via CI diagnostics — passes reliably
* when this class runs in isolation, only misbehaves deep into a full-suite
* run). Root cause is inside filament/tables' table-action record caching, not
* this app's code — MyCompanies::switch now has a defensive authorization
* check for exactly this case. See #687 for the full investigation.
*/
public function it_sets_the_tenant_and_redirects_to_the_target_dashboard_when_switching(): void
{
/* Arrange */
Expand Down
Empty file.
Empty file.
55 changes: 55 additions & 0 deletions Modules/Core/Tests/Unit/Services/UserServiceTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
<?php

namespace Modules\Core\Tests\Unit\Services;

use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Modules\Core\Models\Company;
use Modules\Core\Models\User;
use Modules\Core\Services\UserService;
use Modules\Core\Tests\AbstractAdminPanelTestCase;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\Attributes\Test;

#[CoversClass(UserService::class)]
class UserServiceTest extends AbstractAdminPanelTestCase
{
use RefreshDatabase;

private UserService $service;

protected function setUp(): void
{
parent::setUp();

$this->service = app(UserService::class);
}

#[Test]
public function it_allows_a_user_to_switch_to_a_company_they_belong_to(): void
{
/* Arrange */
$user = User::factory()->withCompany(['search_code' => 'MEMBER'])->create();

/** @var Company $company */
$company = $user->companies()->first();

/* Act & Assert */
$this->service->assertBelongsToCompany($user, $company);
$this->addToAssertionCount(1);
}

#[Test]
public function it_refuses_to_switch_to_a_company_the_user_does_not_belong_to(): void
{
/* Arrange */
$user = User::factory()->withCompany(['search_code' => 'MEMBER'])->create();
$foreignCompany = Company::factory()->create(['search_code' => 'FOREIGN']);

/* Assert */
$this->expectException(AuthorizationException::class);

/* Act */
$this->service->assertBelongsToCompany($user, $foreignCompany);
}
}
Loading