mirror of
https://github.com/skysyaz/timesheet.git
synced 2026-07-14 08:40:50 +00:00
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:
parent
5bc545b596
commit
b4b7041b8e
@ -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 user’s 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([
|
||||
|
||||
@ -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']);
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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();
|
||||
}),
|
||||
];
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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;
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@ -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;
|
||||
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
66
app/Rules/ProjectMembershipForEmployee.php
Normal file
66
app/Rules/ProjectMembershipForEmployee.php
Normal 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;
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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']),
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -20,4 +20,9 @@ return new class extends Migration
|
||||
$table->timestamps();
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('activity_log');
|
||||
}
|
||||
};
|
||||
|
||||
@ -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();
|
||||
});
|
||||
}
|
||||
};
|
||||
@ -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');
|
||||
});
|
||||
}
|
||||
};
|
||||
@ -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'));
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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');
|
||||
}
|
||||
}
|
||||
|
||||
@ -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']);
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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'));
|
||||
}
|
||||
}
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user