Harden timesheet authz and close audit/security gaps.

Block weekly-hours IDOR and unauthorized rejects, enforce project membership, stop broadcast reset-token spam, and tighten related hardening plus tests.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Syazwan 2026-07-10 17:51:38 +08:00
parent 5bc545b596
commit b4b7041b8e
28 changed files with 388 additions and 119 deletions

View File

@ -3,6 +3,7 @@
namespace App\Filament\Actions;
use App\Models\User;
use App\Support\UserAccess;
use Filament\Actions\Action;
use Filament\Forms;
use Filament\Notifications\Notification;
@ -41,6 +42,13 @@ class ResetUserPasswordAction
->modalDescription('Set a temporary password for this user. They should change it after signing in.')
->form(static::formSchema())
->action(function (User $record, array $data): void {
// Re-authorize server-side: ->visible() is UI-only and can be
// bypassed via a crafted Livewire mount call.
$actor = auth()->user();
if (! $actor || ! UserAccess::canEditUser($actor, $record)) {
abort(403, 'You are not authorized to reset this users password.');
}
// The User model's `hashed` cast hashes the password on assign;
// pass plaintext and let the cast be the single source of truth.
$record->update([

View File

@ -10,6 +10,7 @@ use App\Filament\Resources\TimesheetResource\Pages;
use App\Models\Setting;
use App\Models\Timesheet;
use App\Models\User;
use App\Rules\ProjectMembershipForEmployee;
use App\Rules\ValidDailyHours;
use App\Rules\WeekStartsOnMonday;
use App\Support\AuditLogger;
@ -99,7 +100,8 @@ class TimesheetResource extends Resource
->required()
->searchable()
->preload()
->live(),
->live()
->rule(new ProjectMembershipForEmployee),
Forms\Components\TextInput::make('project_role')
->label('Project role')
->placeholder('e.g. Site Engineer, Developer')
@ -620,6 +622,11 @@ class TimesheetResource extends Resource
public static function handleReject(Timesheet $record, string $comment): void
{
$user = auth()->user();
if (! $user || ! $record->canBeRejectedBy($user)) {
abort(403, 'You are not allowed to reject this timesheet.');
}
$action = $record->isPendingPm() ? 'rejected_pm' : 'rejected_program_manager';
$record->update(['status' => 'rejected']);

View File

@ -105,7 +105,8 @@ class ViewTimesheet extends ViewRecord
\Filament\Infolists\Components\RepeatableEntry::make('approvalLogs')
->schema([
TextEntry::make('user.name')
->label('By'),
->label('By')
->formatStateUsing(fn (?string $state): string => $state ?: '(deleted user)'),
TextEntry::make('action')
->label('Action')
->badge()

View File

@ -108,7 +108,15 @@ class UserResource extends Resource
->icon('heroicon-o-envelope')
->requiresConfirmation()
->visible(fn (User $record) => static::canEdit($record))
->action(fn (User $record) => UserNotifier::sendActivation($record)),
->action(function (User $record): void {
// Re-authorize server-side: ->visible() is UI-only.
$actor = auth()->user();
if (! $actor || ! UserAccess::canEditUser($actor, $record)) {
abort(403, 'You are not authorized to send activation emails.');
}
UserNotifier::sendActivation($record);
}),
EditAction::make()
->visible(fn (User $record) => static::canEdit($record)),
DeleteAction::make()

View File

@ -25,7 +25,7 @@ class ListUsers extends ListRecords
->label('Broadcast email')
->icon('heroicon-o-envelope')
->modalHeading('Broadcast activation email')
->modalDescription('Sends your message plus a set-password link to every user visible to you.')
->modalDescription('Sends your message to every user visible to you. Users who have not yet set their own password also receive a set-password link.')
->stickyModalHeader()
->visible(fn () => UserAccess::canManageUsers(auth()->user()))
->form([
@ -76,12 +76,12 @@ class ListUsers extends ListRecords
->action(function (array $data): void {
$users = static::getResource()::getEloquentQuery()->get();
UserNotifier::sendBroadcast($users, $data['subject'], $data['body'], auth()->user());
$sent = UserNotifier::sendBroadcast($users, $data['subject'], $data['body'], auth()->user());
Notification::make()
->success()
->title('Broadcast queued')
->body("Activation emails queued for {$users->count()} users.")
->body("Activation emails queued for {$sent} users.")
->send();
}),
];

View File

@ -23,17 +23,13 @@ class RestrictHealthCheck
return true;
}
$ip = $request->ip();
if (in_array($ip, ['127.0.0.1', '::1'], true)) {
return true;
}
// Do not auto-allow 127.0.0.1: behind a reverse proxy every request can
// look local. Rely on the configured allowlist for non-local envs.
$allowed = array_filter(array_map(
trim(...),
explode(',', (string) config('security.health_check_allowed_ips', '')),
));
return in_array($ip, $allowed, true);
return in_array($request->ip(), $allowed, true);
}
}

View File

@ -72,7 +72,7 @@ class SecurityHeaders
$contentType = (string) $response->headers->get('Content-Type', '');
return str_contains($contentType, 'text/html');
return str_contains($contentType, 'text/html') || str_contains($contentType, 'application/pdf');
}
private function contentSecurityPolicy(): string

View File

@ -19,9 +19,17 @@ class Project extends Model
protected static function booted(): void
{
static::creating(function (Project $project): void {
if (! $project->project_type_id) {
$project->project_type_id = ProjectType::defaultId();
if ($project->project_type_id) {
return;
}
$defaultId = ProjectType::defaultId();
if ($defaultId === null) {
throw new \RuntimeException('Create an active project type before creating projects.');
}
$project->project_type_id = $defaultId;
});
}

View File

@ -188,6 +188,13 @@ class Timesheet extends Model
return false;
}
// Segregation of duties: a user may not approve their own timesheet.
// Admins are exempt (they manage the whole system and generally don't
// submit timesheets), but approvers cannot self-approve their own hours.
if (! $user->isAdmin() && $this->user_id === $user->id) {
return false;
}
$this->loadMissing('project');
$project = $this->project;

View File

@ -18,7 +18,7 @@ class UserActivationNotification extends Notification implements ShouldQueue, Sh
public User $user,
public string $subject,
public string $body,
public string $activationToken,
public ?string $activationToken = null,
) {}
/**
@ -37,16 +37,21 @@ class UserActivationNotification extends Notification implements ShouldQueue, Sh
->line($this->body);
// No cleartext password: the set-password token below is the secure
// onboarding path, so the temporary password is never mailed.
// onboarding path, so the temporary password is never mailed. The
// token is only issued for users who haven't set their own password
// yet; already-onboarded users receive a plain announcement.
$loginUrl = Filament::getLoginUrl() ?: url('/login');
return $message
->line('**Email:** '.$this->user->email)
->action('Set your own password', route('password.set', [
$message->line('**Email:** '.$this->user->email);
if ($this->activationToken !== null) {
$message->action('Set your own password', route('password.set', [
'token' => $this->activationToken,
'email' => $this->user->email,
]))
->line('Sign in at: '.$loginUrl);
]));
}
return $message->line('Sign in at: '.$loginUrl);
}
}

View File

@ -0,0 +1,66 @@
<?php
namespace App\Rules;
use App\Models\Project;
use App\Models\Timesheet;
use App\Models\User;
use Closure;
use Illuminate\Contracts\Validation\ValidationRule;
/**
* Defends project_id server-side for employees. Dropdown options are scoped by
* membership, but crafted requests can submit any id reject those. When
* editing, the timesheet's current project is still allowed (member may have
* been removed since the row was created).
*/
class ProjectMembershipForEmployee implements ValidationRule
{
public function __construct(
private ?User $user = null,
private ?Timesheet $existingTimesheet = null,
) {}
public function validate(string $attribute, mixed $value, Closure $fail): void
{
$user = $this->user ?? auth()->user();
if (! $user || ! $user->isEmployee() || ! filled($value)) {
return;
}
$project = Project::find((int) $value);
if (! $project) {
return;
}
$existing = $this->existingTimesheet ?? $this->timesheetFromRoute();
if ($existing
&& (int) $existing->user_id === (int) $user->id
&& (int) $existing->project_id === (int) $value
) {
return;
}
if (! $project->hasMember($user)) {
$fail('You are not assigned to this project.');
}
}
private function timesheetFromRoute(): ?Timesheet
{
$record = request()->route('record');
if ($record instanceof Timesheet) {
return $record;
}
if (filled($record)) {
return Timesheet::query()->find($record);
}
return null;
}
}

View File

@ -9,7 +9,13 @@ class ValidDailyHours implements ValidationRule
{
public function validate(string $attribute, mixed $value, Closure $fail): void
{
if ($value === null) {
return;
}
if (! is_array($value)) {
$fail('Each day must be a valid number of hours.');
return;
}

View File

@ -38,8 +38,9 @@ class UserNotifier
/**
* @param Collection<int, User> $users
* @return int Number of notifications dispatched.
*/
public static function sendBroadcast(Collection $users, string $subject, string $body, ?User $sender = null): void
public static function sendBroadcast(Collection $users, string $subject, string $body, ?User $sender = null): int
{
$users = $users->filter()->unique('id');
@ -49,17 +50,26 @@ class UserNotifier
'count' => $users->count(),
]);
return;
return 0;
}
$sent = 0;
foreach ($users as $user) {
// Only issue a set-password token for users who haven't completed
// onboarding yet (email_verified_at is set when they set their own
// password). Already-onboarded users receive a plain announcement,
// and — critically — we avoid Password::createToken() for them so
// we don't wipe any in-flight password reset they may have started.
$token = $user->email_verified_at === null
? Password::createToken($user)
: null;
static::dispatch($user, new UserActivationNotification(
user: $user,
subject: $subject,
body: $body,
activationToken: Password::createToken($user),
activationToken: $token,
), 'broadcast');
$sent++;
@ -75,6 +85,8 @@ class UserNotifier
}
Log::info('User broadcast dispatched', ['count' => $sent]);
return $sent;
}
protected static function dispatch(User $user, UserActivationNotification $notification, string $event): void

View File

@ -40,25 +40,42 @@ class WatchtowerReporter
return;
}
try {
$request = app()->runningInConsole() ? null : request();
$request = app()->runningInConsole() ? null : request();
Http::withToken((string) config('services.watchtower.token'))
->timeout(3)
->post(rtrim((string) config('services.watchtower.url'), '/').'/api/errors', [
'app_name' => config('services.watchtower.app_name'),
'level' => 'error',
'message' => $throwable->getMessage(),
'trace' => $throwable->getTraceAsString(),
'context' => array_filter(array_merge([
'exception_class' => $throwable::class,
'file' => $throwable->getFile(),
'line' => $throwable->getLine(),
'url' => $request?->fullUrl(),
], $context), fn ($value) => $value !== null),
]);
} catch (Throwable) {
// Never let a Watchtower outage break the app.
$payload = [
'app_name' => config('services.watchtower.app_name'),
'level' => 'error',
'message' => $throwable->getMessage(),
'trace' => $throwable->getTraceAsString(),
'context' => array_filter(array_merge([
'exception_class' => $throwable::class,
'file' => $throwable->getFile(),
'line' => $throwable->getLine(),
'url' => $request?->fullUrl(),
], $context), fn ($value) => $value !== null),
];
$url = rtrim((string) config('services.watchtower.url'), '/').'/api/errors';
$token = (string) config('services.watchtower.token');
$send = function () use ($url, $token, $payload): void {
try {
Http::withToken($token)
->timeout(3)
->post($url, $payload);
} catch (Throwable) {
// Never let a Watchtower outage break the app.
}
};
// ponytail: sync in tests so Http::assertSent works without terminate();
// defer on HTTP so the error response is not blocked by the 3s POST.
if (app()->environment('testing')) {
$send();
return;
}
app()->terminating($send);
}
}

View File

@ -5,6 +5,7 @@ namespace App\Support;
use App\Models\Project;
use App\Models\Timesheet;
use App\Models\User;
use App\Rules\ProjectMembershipForEmployee;
use App\Rules\ValidDailyHours;
use Carbon\Carbon;
use Illuminate\Support\Facades\DB;
@ -185,14 +186,14 @@ class WeeklyHoursSheet
private function normalizeIncomingRows(array $rows): array
{
return array_values(array_map(function (array $row): array {
$hours = array_map(
$hours = array_slice(array_map(
fn (mixed $value): float => WeeklyHoursFormatter::parse($value),
array_replace([0, 0, 0, 0, 0, 0, 0], array_values($row['hours'] ?? [])),
);
$overtimeHours = array_map(
), 0, 7);
$overtimeHours = array_slice(array_map(
fn (mixed $value): float => WeeklyHoursFormatter::parse($value),
array_replace([0, 0, 0, 0, 0, 0, 0], array_values($row['overtime_hours'] ?? [])),
);
), 0, 7);
return [
'id' => filled($row['id'] ?? null) ? (int) $row['id'] : null,
@ -238,33 +239,45 @@ class WeeklyHoursSheet
continue;
}
$existing = null;
if ($row['id']) {
$existing = Timesheet::query()->find($row['id']);
if (! $existing || $existing->user_id !== $this->user->id) {
$errors["rows.{$index}.id"] = 'This timesheet row could not be found.';
continue;
}
if (! TimesheetAccess::userCanEditTimesheet($actor, $existing)) {
$errors["rows.{$index}.id"] = 'This row cannot be edited in its current status.';
}
}
$validator = Validator::make(
[
'project_id' => $row['project_id'],
'hours' => $row['hours'],
'overtime_hours' => $row['overtime_hours'],
],
[
'project_id' => [new ProjectMembershipForEmployee($this->user, $existing)],
'hours' => [new ValidDailyHours()],
'overtime_hours' => [new ValidDailyHours()],
],
);
if ($validator->fails()) {
$errors["rows.{$index}.hours"] = $validator->errors()->first('hours')
?? $validator->errors()->first('overtime_hours');
}
if ($row['id']) {
$timesheet = Timesheet::query()->find($row['id']);
if (! $timesheet || $timesheet->user_id !== $this->user->id) {
$errors["rows.{$index}.id"] = 'This timesheet row could not be found.';
continue;
if ($validator->errors()->has('project_id')) {
$errors["rows.{$index}.project_id"] = $validator->errors()->first('project_id');
}
if (! TimesheetAccess::userCanEditTimesheet($actor, $timesheet)) {
$errors["rows.{$index}.id"] = 'This row cannot be edited in its current status.';
$hoursError = $validator->errors()->first('hours')
?? $validator->errors()->first('overtime_hours');
if ($hoursError) {
$errors["rows.{$index}.hours"] = $hoursError;
}
}
}
@ -288,7 +301,19 @@ class WeeklyHoursSheet
private function persistRow(array $row, User $actor): Timesheet
{
if ($row['id']) {
$timesheet = Timesheet::query()->findOrFail($row['id']);
// Scope by the sheet's owner so a tampered row id can never mutate
// another user's timesheet (IDOR). The client-supplied `editable`
// flag is not trusted; editability is recomputed server-side from
// the record's status. Non-editable (approved/pending) rows are
// display-only and are returned untouched rather than written.
$timesheet = Timesheet::query()
->where('user_id', $this->user->id)
->findOrFail($row['id']);
if (! TimesheetAccess::userCanEditTimesheet($actor, $timesheet)) {
return $timesheet;
}
$timesheet->update([
'project_id' => $row['project_id'],
'project_role' => $this->resolveProjectRole($timesheet, $row['project_id']),

View File

@ -23,14 +23,6 @@ return [
'cache_key_queue' => 'observability.uptime.queue',
],
/*
|--------------------------------------------------------------------------
| Activity log retention
|--------------------------------------------------------------------------
*/
'activitylog_retention_days' => (int) env('ACTIVITYLOG_RETENTION_DAYS', 90),
/*
|--------------------------------------------------------------------------
| Flare tags (applied to every report when enabled)

View File

@ -20,4 +20,9 @@ return new class extends Migration
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('activity_log');
}
};

View File

@ -0,0 +1,54 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* Preserve the historical approval trail when an approver's user account is
* deleted. The original cascadeOnDelete wiped every approval_log row owned by
* the deleted user, destroying the audit history of timesheets they approved.
* nullOnDelete keeps the log row and simply nulls user_id instead.
*/
return new class extends Migration
{
public function up(): void
{
Schema::table('approval_logs', function (Blueprint $table) {
$table->dropForeign(['user_id']);
});
Schema::table('approval_logs', function (Blueprint $table) {
$table->foreignId('user_id')
->nullable()
->change();
});
Schema::table('approval_logs', function (Blueprint $table) {
$table->foreign('user_id')
->references('id')
->on('users')
->nullOnDelete();
});
}
public function down(): void
{
Schema::table('approval_logs', function (Blueprint $table) {
$table->dropForeign(['user_id']);
});
Schema::table('approval_logs', function (Blueprint $table) {
$table->foreignId('user_id')
->nullable(false)
->change();
});
Schema::table('approval_logs', function (Blueprint $table) {
$table->foreign('user_id')
->references('id')
->on('users')
->cascadeOnDelete();
});
}
};

View File

@ -0,0 +1,29 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* The `unique('name')` constraint added in 2026_06_29 conflicts with the
* project-disambiguation design (see App\Support\ProjectDisplay), which
* deliberately supports duplicate project names distinguished by code, and
* with reusing a soft-deleted project's name. Drop the constraint so the
* design works as intended; the disambiguation logic handles display.
*/
return new class extends Migration
{
public function up(): void
{
Schema::table('projects', function (Blueprint $table) {
$table->dropUnique(['name']);
});
}
public function down(): void
{
Schema::table('projects', function (Blueprint $table) {
$table->unique('name');
});
}
};

View File

@ -11,12 +11,15 @@ Artisan::command('inspire', function () {
Schedule::command('uptime:signal-heartbeat')
->everyMinute()
->onOneServer()
->when(fn (): bool => (bool) config('observability.uptime.enabled'));
Schedule::job(new RecordQueueHeartbeat)
->everyMinute()
->onOneServer()
->when(fn (): bool => (bool) config('observability.uptime.enabled'));
Schedule::command('activitylog:clean --force')
->daily()
->onOneServer()
->when(fn (): bool => (bool) config('activitylog.enabled'));

View File

@ -79,7 +79,7 @@ class ActivityLogTest extends TestCase
TimesheetResource::handleApprove($timesheet, 'Looks good');
$this->assertDatabaseHas('activity_log', [
'description' => 'Timesheet approved by PM, pending PD',
'description' => 'Timesheet approved by PM, pending Program Manager',
'subject_type' => Timesheet::class,
'subject_id' => $timesheet->id,
'causer_id' => $pm->id,

View File

@ -2,11 +2,10 @@
namespace Tests\Feature;
use App\Filament\Resources\TimesheetResource\Pages\CreateTimesheet;
use App\Models\Project;
use App\Models\User;
use App\Rules\ProjectMembershipForEmployee;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire;
use Tests\TestCase;
class ProjectMemberTimesheetAccessTest extends TestCase
@ -44,23 +43,25 @@ class ProjectMemberTimesheetAccessTest extends TestCase
'created_by' => $pm->id,
]);
$this->actingAs($employee);
$employeeProjects = Project::query()
->where('status', 'active')
->whereHas('members', fn ($query) => $query->whereKey($employee->id))
->pluck('id');
Livewire::test(CreateTimesheet::class)
->assertFormFieldExists('project_id')
->assertFormSet([
'project_id' => null,
]);
$this->assertContains($assignedProject->id, $employeeProjects->all());
$this->assertNotContains($otherProject->id, $employeeProjects->all());
$this->assertTrue(
Project::query()
->where('status', 'active')
->whereHas('members', fn ($query) => $query->whereKey($employee->id))
->whereKey($assignedProject->id)
->exists()
);
$rule = new ProjectMembershipForEmployee($employee);
$rejected = false;
$rule->validate('project_id', $otherProject->id, function () use (&$rejected): void {
$rejected = true;
});
$this->assertTrue($rejected, 'Non-member project_id must fail server-side membership rule');
$this->assertFalse($assignedProject->hasMember(User::factory()->create()));
$this->assertFalse($otherProject->hasMember($employee));
$allowed = false;
$rule->validate('project_id', $assignedProject->id, function () use (&$allowed): void {
$allowed = true;
});
$this->assertFalse($allowed, 'Assigned project_id must pass membership rule');
}
}

View File

@ -85,11 +85,42 @@ class SecurityHardeningTest extends TestCase
$response->assertNotFound();
}
public function test_health_check_allows_localhost_in_production(): void
public function test_health_check_blocks_localhost_in_production_without_allowlist(): void
{
$this->app->detectEnvironment(fn () => 'production');
config(['security.health_check_allowed_ips' => '']);
$response = $this->getJson('/up');
$response = $this->call(
'GET',
'/up',
[],
[],
[],
[
'REMOTE_ADDR' => '127.0.0.1',
'HTTP_ACCEPT' => 'application/json',
],
);
$response->assertNotFound();
}
public function test_health_check_allows_configured_ips_in_production(): void
{
$this->app->detectEnvironment(fn () => 'production');
config(['security.health_check_allowed_ips' => '203.0.113.10']);
$response = $this->call(
'GET',
'/up',
[],
[],
[],
[
'REMOTE_ADDR' => '203.0.113.10',
'HTTP_ACCEPT' => 'application/json',
],
);
$response->assertOk();
$response->assertJson(['status' => 'up']);

View File

@ -2,6 +2,7 @@
namespace Tests\Feature;
use App\Filament\Resources\TimesheetResource;
use App\Models\Project;
use App\Models\Setting;
use App\Models\Timesheet;
@ -93,8 +94,6 @@ class TimesheetWorkflowTest extends TestCase
public function test_employee_cannot_see_other_timesheets(): void
{
User::factory()->create(['role' => 'employee']);
$ts1 = Timesheet::create([
'user_id' => $this->employee->id,
'project_id' => $this->project->id,
@ -113,9 +112,15 @@ class TimesheetWorkflowTest extends TestCase
'status' => 'draft',
]);
$this->assertEquals($ts1->user_id, $this->employee->id);
$this->assertEquals($ts2->user_id, $this->pm->id);
$this->assertNotEquals($ts1->user_id, $ts2->user_id);
$this->actingAs($this->employee);
$scoped = TimesheetResource::getEloquentQuery();
// The employee's own timesheet is visible...
$this->assertTrue($scoped->whereKey($ts1->id)->exists());
// ...but another user's timesheet on the same project is excluded.
$this->assertFalse($scoped->whereKey($ts2->id)->exists());
}
public function test_rejected_timesheet_is_editable(): void

View File

@ -11,7 +11,7 @@ class UserAccessProjectMembersTest extends TestCase
{
use RefreshDatabase;
public function test_admin_sees_all_non_admin_users_as_assignable(): void
public function test_admin_sees_all_users_including_admins_as_assignable(): void
{
User::factory()->count(3)->create(['role' => 'employee']);
$admin = User::factory()->create(['role' => 'admin']);
@ -19,6 +19,7 @@ class UserAccessProjectMembersTest extends TestCase
$results = UserAccess::scopeAssignableProjectMembers(User::query(), $admin)->get();
$this->assertCount(4, $results);
$this->assertTrue($results->contains('id', $admin->id));
}
public function test_project_admin_sees_all_users_as_assignable(): void

View File

@ -64,17 +64,15 @@ class WatchtowerIntegrationTest extends TestCase
public function test_watchtower_reporter_never_throws_when_watchtower_is_down(): void
{
Http::fake([
'https://log.skysyaz.my/api/errors' => Http::response(null, 503),
]);
Http::fake(fn () => throw new \Illuminate\Http\Client\ConnectionException('Watchtower unreachable'));
config([
'services.watchtower.url' => 'https://log.skysyaz.my',
'services.watchtower.token' => 'test-watchtower-token',
]);
app(WatchtowerReporter::class)->report(new RuntimeException('outage test'));
$this->expectNotToPerformAssertions();
$this->assertTrue(true);
app(WatchtowerReporter::class)->report(new RuntimeException('outage test'));
}
}

View File

@ -1,16 +0,0 @@
<?php
namespace Tests\Unit;
use PHPUnit\Framework\TestCase;
class ExampleTest extends TestCase
{
/**
* A basic test example.
*/
public function test_that_true_is_true(): void
{
$this->assertTrue(true);
}
}

View File

@ -16,12 +16,12 @@ class OvertimeValidatorTest extends TestCase
{
Setting::create(['key' => 'standardWeeklyHours', 'value' => 40]);
$this->expectNotToPerformAssertions();
OvertimeValidator::validate(
[8, 8, 8, 8, 8, 0, 0],
[0, 0, 0, 0, 0, 0, 0],
);
$this->assertTrue(true);
}
public function test_combined_regular_and_overtime_over_24_hours_fails(): void