Parcourir la source

fix(ui): default manual block expiry to end-of-day for date picks

The "Expires" field was a datetime-local input. Browsers like Chrome
require both date and time before the field has a value, so picking
only a date silently submitted an empty value.

Switch the input to type="date" (clearer UX — it really is a "date
selector" as the label implies) and have the controller expand the
bare YYYY-MM-DD into 23:59:59Z before forwarding to the api, so a
block stays in effect for the whole selected calendar day.

Add a request-history hook to AppTestCase so we can assert on the
outgoing API payload, and a regression test for the EOD expansion.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
chiappa il y a 1 semaine
Parent
commit
4647941abb

+ 2 - 1
ui/resources/views/pages/manual-blocks/index.twig

@@ -56,8 +56,9 @@
                 </div>
                 <div>
                     <label for="mb-expires" class="block text-xs font-medium text-slate-600 dark:text-slate-400">Expires (optional)</label>
-                    <input type="datetime-local" id="mb-expires" name="expires_at"
+                    <input type="date" id="mb-expires" name="expires_at"
                            class="mt-1 w-full rounded-md border border-slate-300 bg-white px-2 py-1.5 dark:border-slate-700 dark:bg-slate-950">
+                    <p class="mt-1 text-xs text-slate-400">Block ends at 23:59:59 UTC of the selected day.</p>
                 </div>
                 <div class="md:col-span-4 flex justify-end">
                     <button type="submit" class="rounded-md bg-indigo-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-indigo-500">Add block</button>

+ 9 - 2
ui/src/Controllers/ManualBlocksController.php

@@ -116,8 +116,15 @@ final class ManualBlocksController
             $payload['reason'] = trim($body['reason']);
         }
         if (isset($body['expires_at']) && is_string($body['expires_at']) && trim($body['expires_at']) !== '') {
-            // HTML datetime-local emits "YYYY-MM-DDTHH:MM"; the api accepts ISO-8601.
-            $payload['expires_at'] = trim($body['expires_at']) . 'Z';
+            $expires = trim($body['expires_at']);
+            // The form uses <input type="date"> which emits "YYYY-MM-DD";
+            // expand to end-of-day so the block stays effective for the
+            // whole selected calendar day. Existing datetime-local values
+            // ("YYYY-MM-DDTHH:MM") are passed through.
+            if (preg_match('/^\d{4}-\d{2}-\d{2}$/', $expires) === 1) {
+                $expires .= 'T23:59:59';
+            }
+            $payload['expires_at'] = $expires . 'Z';
         }
 
         try {

+ 28 - 0
ui/tests/Integration/Crud/CrudPagesTest.php

@@ -139,6 +139,34 @@ final class CrudPagesTest extends AppTestCase
         self::assertSame('success', $flash[0]['type']);
     }
 
+    public function testManualBlockCreateExpandsBareDateToEndOfDay(): void
+    {
+        // The form uses <input type="date">, which submits "YYYY-MM-DD".
+        // The controller must expand that to T23:59:59Z so the block
+        // covers the full selected calendar day.
+        $this->enqueueApiResponse(201, ['id' => 100, 'kind' => 'ip', 'ip' => '203.0.113.5']);
+        $token = $this->csrfFromManualBlocks();
+
+        $body = http_build_query([
+            'csrf_token' => $token,
+            'kind' => 'ip',
+            'ip' => '203.0.113.5',
+            'reason' => 'eod test',
+            'expires_at' => '2026-12-31',
+        ]);
+        $response = $this->request('POST', '/app/manual-blocks', [], $body, 'application/x-www-form-urlencoded');
+        self::assertSame(303, $response->getStatusCode());
+
+        // The last captured outgoing call is the POST to the api;
+        // earlier history entries are the GETs from csrfFromManualBlocks.
+        $last = end($this->apiHistory);
+        self::assertNotFalse($last);
+        self::assertSame('POST', $last['request']->getMethod());
+        $payload = json_decode((string) $last['request']->getBody(), true);
+        self::assertIsArray($payload);
+        self::assertSame('2026-12-31T23:59:59Z', $payload['expires_at']);
+    }
+
     public function testManualBlockCreateValidationErrorFlashesField(): void
     {
         // The MockHandler is FIFO: the GET inside csrfFromManualBlocks

+ 5 - 0
ui/tests/Integration/Support/AppTestCase.php

@@ -10,6 +10,7 @@ use App\Auth\OidcAuthenticator;
 use GuzzleHttp\ClientInterface as GuzzleClientInterface;
 use GuzzleHttp\Handler\MockHandler;
 use GuzzleHttp\HandlerStack;
+use GuzzleHttp\Middleware;
 use Monolog\Handler\NullHandler;
 use Monolog\Logger;
 use PHPUnit\Framework\TestCase;
@@ -32,6 +33,8 @@ abstract class AppTestCase extends TestCase
     protected ContainerInterface $container;
     protected App $app;
     protected MockHandler $mock;
+    /** @var list<array{request: \Psr\Http\Message\RequestInterface, response: \Psr\Http\Message\ResponseInterface, error: mixed, options: array<string, mixed>}> */
+    protected array $apiHistory = [];
 
     /**
      * @param array<string, mixed> $overrides
@@ -64,11 +67,13 @@ abstract class AppTestCase extends TestCase
 
         $this->container = Container::build($settings);
         $this->mock = new MockHandler();
+        $this->apiHistory = [];
 
         if (method_exists($this->container, 'set')) {
             /** @var \DI\Container $c */
             $c = $this->container;
             $handler = HandlerStack::create($this->mock);
+            $handler->push(Middleware::history($this->apiHistory));
             $c->set(GuzzleClientInterface::class, new \GuzzleHttp\Client(['handler' => $handler]));
             // ApiClient closure-builds from the container; refresh the
             // bound instance with our mocked Guzzle client.