diff --git a/src/Data/Casts/FlexibleDateTimeCast.php b/src/Data/Casts/FlexibleDateTimeCast.php index 9c8eaf7d..00883be3 100644 --- a/src/Data/Casts/FlexibleDateTimeCast.php +++ b/src/Data/Casts/FlexibleDateTimeCast.php @@ -12,14 +12,17 @@ /** * Casts any date value accepted by the "date" validation rule, such as - * "2023-11-07 05:31:56" or ISO 8601 ("2023-11-07T05:31:56Z"), to Carbon. + * "2023-11-07 05:31:56" or ISO 8601 ("2023-11-07T05:31:56Z"), to Carbon, + * normalized to the application timezone. Values carrying an explicit UTC + * offset are converted to the equivalent application-timezone instant; + * offset-less values are interpreted as application-timezone wall clock. */ final class FlexibleDateTimeCast implements Cast { public function cast(DataProperty $property, mixed $value, array $properties, CreationContext $context): DateTimeInterface|Uncastable { if ($value instanceof DateTimeInterface) { - return Carbon::instance($value); + return Carbon::instance($value)->setTimezone(config('app.timezone')); } if (! is_string($value) && ! is_int($value) && ! is_float($value)) { @@ -27,7 +30,7 @@ public function cast(DataProperty $property, mixed $value, array $properties, Cr } try { - return Carbon::parse($value); + return Carbon::parse($value)->setTimezone(config('app.timezone')); } catch (InvalidFormatException) { return Uncastable::create(); } diff --git a/src/Data/Requests/Incident/CreateIncidentRequestData.php b/src/Data/Requests/Incident/CreateIncidentRequestData.php index d3123c04..fa85ba89 100644 --- a/src/Data/Requests/Incident/CreateIncidentRequestData.php +++ b/src/Data/Requests/Incident/CreateIncidentRequestData.php @@ -3,15 +3,18 @@ namespace Cachet\Data\Requests\Incident; use Cachet\Data\BaseData; +use Cachet\Data\Casts\FlexibleDateTimeCast; use Cachet\Enums\ComponentStatusEnum; use Cachet\Enums\IncidentStatusEnum; use Cachet\Models\Component; +use Carbon\Carbon; use Illuminate\Validation\Rule; use Spatie\LaravelData\Attributes\DataCollectionOf; use Spatie\LaravelData\Attributes\Validation\Enum; use Spatie\LaravelData\Attributes\Validation\Exists; use Spatie\LaravelData\Attributes\Validation\Max; use Spatie\LaravelData\Attributes\Validation\RequiredWithout; +use Spatie\LaravelData\Attributes\WithCast; use Spatie\LaravelData\Support\Validation\ValidationContext; final class CreateIncidentRequestData extends BaseData @@ -28,8 +31,10 @@ public function __construct( public readonly bool $visible = false, public readonly bool $stickied = false, public readonly bool $notifications = false, - public readonly ?string $occurredAt = null, - public readonly ?string $publishedAt = null, + #[WithCast(FlexibleDateTimeCast::class)] + public readonly ?Carbon $occurredAt = null, + #[WithCast(FlexibleDateTimeCast::class)] + public readonly ?Carbon $publishedAt = null, public readonly array $templateVars = [], #[Exists(Component::class, 'id')] public readonly ?int $componentId = null, diff --git a/src/Data/Requests/Incident/UpdateIncidentRequestData.php b/src/Data/Requests/Incident/UpdateIncidentRequestData.php index 08cfb1ef..ea8a8d74 100644 --- a/src/Data/Requests/Incident/UpdateIncidentRequestData.php +++ b/src/Data/Requests/Incident/UpdateIncidentRequestData.php @@ -3,8 +3,11 @@ namespace Cachet\Data\Requests\Incident; use Cachet\Data\BaseData; +use Cachet\Data\Casts\FlexibleDateTimeCast; use Cachet\Enums\IncidentStatusEnum; +use Carbon\Carbon; use Illuminate\Validation\Rule; +use Spatie\LaravelData\Attributes\WithCast; use Spatie\LaravelData\Optional; use Spatie\LaravelData\Support\Validation\ValidationContext; @@ -17,8 +20,10 @@ public function __construct( public readonly ?bool $visible = null, public readonly ?bool $stickied = null, public readonly ?bool $notifications = null, - public readonly ?string $occurredAt = null, - public readonly ?string $publishedAt = null, + #[WithCast(FlexibleDateTimeCast::class)] + public readonly ?Carbon $occurredAt = null, + #[WithCast(FlexibleDateTimeCast::class)] + public readonly ?Carbon $publishedAt = null, /** @var array|null */ public readonly ?array $meta = null, ) {} diff --git a/src/Data/Requests/Metric/CreateMetricPointRequestData.php b/src/Data/Requests/Metric/CreateMetricPointRequestData.php index c455cfa9..e3c4fa12 100644 --- a/src/Data/Requests/Metric/CreateMetricPointRequestData.php +++ b/src/Data/Requests/Metric/CreateMetricPointRequestData.php @@ -3,14 +3,18 @@ namespace Cachet\Data\Requests\Metric; use Cachet\Data\BaseData; +use Cachet\Data\Casts\FlexibleDateTimeCast; use Cachet\Rules\ValidTimestamp; +use Carbon\Carbon; +use Spatie\LaravelData\Attributes\WithCast; use Spatie\LaravelData\Support\Validation\ValidationContext; final class CreateMetricPointRequestData extends BaseData { public function __construct( public readonly float $value, - public readonly mixed $timestamp = null, + #[WithCast(FlexibleDateTimeCast::class)] + public readonly ?Carbon $timestamp = null, ) {} public static function rules(ValidationContext $context): array diff --git a/src/Models/MetricPoint.php b/src/Models/MetricPoint.php index 43c2f29c..c9098080 100644 --- a/src/Models/MetricPoint.php +++ b/src/Models/MetricPoint.php @@ -73,7 +73,7 @@ public function createdAt(): Attribute $timestamp = $createdAt->getTimestamp(); $timestamp = 30 * round($timestamp / 30); - return Carbon::createFromTimestamp($timestamp); + return Carbon::createFromTimestamp($timestamp, config('app.timezone')); } ); } diff --git a/tests/Feature/Api/ScheduleTest.php b/tests/Feature/Api/ScheduleTest.php index 5f9ec561..1675b331 100644 --- a/tests/Feature/Api/ScheduleTest.php +++ b/tests/Feature/Api/ScheduleTest.php @@ -347,6 +347,31 @@ ]); }); +it('can create a schedule with ISO 8601 dates carrying a non-UTC offset', function () { + Sanctum::actingAs(User::factory()->create(), ['schedules.manage']); + + $response = postJson('/status/api/schedules', [ + 'name' => 'New Scheduled Maintenance', + 'message' => 'Something will go wrong.', + 'scheduled_at' => '2033-11-07T05:31:56+05:30', + 'completed_at' => '2033-11-08T05:31:56+05:30', + ]); + + $response->assertCreated(); + $response->assertJson([ + 'data' => [ + 'attributes' => [ + 'scheduled' => [ + 'string' => '2033-11-07 00:01:56', + ], + 'completed' => [ + 'string' => '2033-11-08 00:01:56', + ], + ], + ], + ]); +}); + it('responds with a JSON validation error when the Accept header is not set', function () { Sanctum::actingAs(User::factory()->create(), ['schedules.manage']); diff --git a/tests/Feature/Api/TimezoneNormalizationTest.php b/tests/Feature/Api/TimezoneNormalizationTest.php new file mode 100644 index 00000000..7e8f7d84 --- /dev/null +++ b/tests/Feature/Api/TimezoneNormalizationTest.php @@ -0,0 +1,179 @@ +set('app.timezone', 'UTC'); + date_default_timezone_set('UTC'); +}); + +function useAppTimezone(string $timezone): void +{ + config()->set('app.timezone', $timezone); + date_default_timezone_set($timezone); +} + +it('normalizes incident occurred_at with a UTC designator to the app timezone', function () { + useAppTimezone('Europe/Berlin'); + + Sanctum::actingAs(User::factory()->create(), ['incidents.manage']); + + $response = postJson('/status/api/incidents', [ + 'name' => 'Incident', + 'message' => 'Something went wrong.', + 'status' => 1, + 'occurred_at' => '2026-08-06T18:31:37Z', + ]); + + $response->assertCreated(); + $response->assertJsonPath('data.attributes.occurred.string', '2026-08-06 20:31:37'); + $this->assertDatabaseHas('incidents', ['occurred_at' => '2026-08-06 20:31:37']); +}); + +it('normalizes incident occurred_at with an explicit offset to the app timezone', function () { + useAppTimezone('Europe/Berlin'); + + Sanctum::actingAs(User::factory()->create(), ['incidents.manage']); + + $response = postJson('/status/api/incidents', [ + 'name' => 'Incident', + 'message' => 'Something went wrong.', + 'status' => 1, + 'occurred_at' => '2026-08-06T18:31:37+05:30', + ]); + + $response->assertCreated(); + $response->assertJsonPath('data.attributes.occurred.string', '2026-08-06 15:01:37'); + $this->assertDatabaseHas('incidents', ['occurred_at' => '2026-08-06 15:01:37']); +}); + +it('interprets offset-less incident occurred_at as app timezone wall clock', function () { + useAppTimezone('Europe/Berlin'); + + Sanctum::actingAs(User::factory()->create(), ['incidents.manage']); + + $response = postJson('/status/api/incidents', [ + 'name' => 'Incident', + 'message' => 'Something went wrong.', + 'status' => 1, + 'occurred_at' => '2026-08-06 18:31:37', + ]); + + $response->assertCreated(); + $response->assertJsonPath('data.attributes.occurred.string', '2026-08-06 18:31:37'); + $this->assertDatabaseHas('incidents', ['occurred_at' => '2026-08-06 18:31:37']); +}); + +it('normalizes incident published_at with an explicit offset to the app timezone', function () { + useAppTimezone('Europe/Berlin'); + + Sanctum::actingAs(User::factory()->create(), ['incidents.manage']); + + $response = postJson('/status/api/incidents', [ + 'name' => 'Incident', + 'message' => 'Something went wrong.', + 'status' => 1, + 'published_at' => '2026-08-06T18:31:37+05:30', + ]); + + $response->assertCreated(); + $this->assertDatabaseHas('incidents', ['published_at' => '2026-08-06 15:01:37']); +}); + +it('normalizes occurred_at on incident update', function () { + useAppTimezone('Europe/Berlin'); + + Sanctum::actingAs(User::factory()->create(), ['incidents.manage']); + + $incident = Incident::factory()->create(); + + $response = putJson('/status/api/incidents/'.$incident->id, [ + 'occurred_at' => '2026-08-06T18:31:37+05:30', + ]); + + $response->assertOk(); + $this->assertDatabaseHas('incidents', [ + 'id' => $incident->id, + 'occurred_at' => '2026-08-06 15:01:37', + ]); +}); + +it('normalizes schedule scheduled_at and completed_at with explicit offsets to the app timezone', function () { + useAppTimezone('Europe/Berlin'); + + Sanctum::actingAs(User::factory()->create(), ['schedules.manage']); + + $response = postJson('/status/api/schedules', [ + 'name' => 'Maintenance', + 'message' => 'Something will go wrong.', + 'scheduled_at' => '2026-08-06T18:31:37Z', + 'completed_at' => '2026-08-06T22:31:37+05:30', + ]); + + $response->assertCreated(); + $response->assertJsonPath('data.attributes.scheduled.string', '2026-08-06 20:31:37'); + $response->assertJsonPath('data.attributes.completed.string', '2026-08-06 19:01:37'); + $this->assertDatabaseHas('schedules', [ + 'scheduled_at' => '2026-08-06 20:31:37', + 'completed_at' => '2026-08-06 19:01:37', + ]); +}); + +it('normalizes completed_at with an explicit offset on schedule update', function () { + useAppTimezone('Europe/Berlin'); + + Sanctum::actingAs(User::factory()->create(), ['schedules.manage']); + + $schedule = Schedule::factory()->create(); + + $response = putJson('/status/api/schedules/'.$schedule->id, [ + 'completed_at' => '2026-08-06T18:31:37+05:30', + ]); + + $response->assertOk(); + $this->assertDatabaseHas('schedules', [ + 'id' => $schedule->id, + 'completed_at' => '2026-08-06 15:01:37', + ]); +}); + +it('normalizes an ISO 8601 metric point timestamp with an explicit offset to the app timezone', function () { + useAppTimezone('Europe/Berlin'); + + Sanctum::actingAs(User::factory()->create(), ['metric-points.manage']); + + $metric = Metric::factory()->create(); + + $response = postJson('/status/api/metrics/'.$metric->id.'/points', [ + 'value' => 1, + 'timestamp' => '2026-08-06T18:31:30+05:30', + ]); + + $response->assertCreated(); + $this->assertDatabaseHas('metric_points', [ + 'metric_id' => $metric->id, + 'created_at' => '2026-08-06 15:01:30', + ]); +}); + +it('normalizes incident occurred_at with an explicit offset when the app timezone is UTC', function () { + Sanctum::actingAs(User::factory()->create(), ['incidents.manage']); + + $response = postJson('/status/api/incidents', [ + 'name' => 'Incident', + 'message' => 'Something went wrong.', + 'status' => 1, + 'occurred_at' => '2026-08-06T18:31:37+05:30', + ]); + + $response->assertCreated(); + $response->assertJsonPath('data.attributes.occurred.string', '2026-08-06 13:01:37'); + $this->assertDatabaseHas('incidents', ['occurred_at' => '2026-08-06 13:01:37']); +});