From 9b4ebba36457e28a8a0b03430d53c506c220f635 Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Sat, 8 Aug 2026 23:27:30 +0800 Subject: [PATCH 1/2] feat(customers): add a non-production verification-code bypass Signing up a customer locally means waiting on a real email or paying for a real SMS, because the three code-checking endpoints (POST /v1/customers, /customers/verify-code, /customers/reset-password) match against real VerificationCode rows and there is no way to short-circuit them. Navigator already has fleetops.navigator.bypass_verification_code for exactly this need on the driver side; this is the customer equivalent. Adds fleetops.customers.verification_bypass_code, read from FLEETOPS_CUSTOMER_VERIFICATION_BYPASS_CODE, guarded by the same three conditions the console uses in AuthController::authenticateWithVerificationCode: a code must be configured, the app must not be in production, and the comparison is constant-time. With the variable unset -- the default -- the bypass cannot fire, and config/app.php resolves `env` to production when neither APP_ENV nor ENVIRONMENT is set, so it fails safe. Deliberately a distinct env var, not SMS_AUTH_BYPASS_CODE: that one already gates operator console login and driver login, and sharing it would make a single leaked value unlock three privilege tiers. The guard is intentionally NOT folded into verificationCodeExists() / findVerificationCode(). Those are test seams the controller contract tests override, so a policy living inside them would be stubbed away precisely where it needs asserting. CreateCustomerRequest's `code` rule is relaxed from `exists:verification_codes,code` to `required|string`. This is not a weakening: the controller matches code + for + meta->identity, whereas the `exists` rule accepts any live code issued for any purpose to any user. The rule is also already unenforced on the proxy path, since verifyCode() with for=fleetops_create_customer calls create(CreateCustomerRequest::createFrom()), which never runs validateResolved(). Left in place it would block the bypass before the controller ever sees the request. Tests cover: inert when unset and when empty; accepted for all three endpoints when configured and matching; a non-matching code still rejected while the bypass is live; rejected in production even when configured; and resetPassword surviving the null VerificationCode on the bypass path while still revoking sessions. 10/10 in ApiCustomerControllerContractsTest, 21/21 in RequestContractsTest. Co-Authored-By: Claude Opus 5 --- server/config/fleetops.php | 22 +++ .../Controllers/Api/v1/CustomerController.php | 40 ++++- .../Http/Requests/CreateCustomerRequest.php | 10 +- .../ApiCustomerControllerContractsTest.php | 155 ++++++++++++++++++ server/tests/RequestContractsTest.php | 2 +- 5 files changed, 223 insertions(+), 6 deletions(-) diff --git a/server/config/fleetops.php b/server/config/fleetops.php index 2aba7e97d..b5bb41357 100644 --- a/server/config/fleetops.php +++ b/server/config/fleetops.php @@ -100,6 +100,28 @@ 'app_identifier' => env('NAVIGATOR_APP_IDENTIFIER', 'io.fleetbase.navigator'), ], + /* + |-------------------------------------------------------------------------- + | Customers + |-------------------------------------------------------------------------- + | + | Testing-only verification-code bypass for the customer auth flows + | (POST /v1/customers, /customers/verify-code, /customers/reset-password). + | Intended for local development and staging QA, where signing up means + | waiting on a real email or paying for a real SMS. + | + | MUST be left unset in production. It is ignored outright when the app + | environment is `production`, and when unset no bypass is possible. + | + | Deliberately NOT wired to SMS_AUTH_BYPASS_CODE: that variable already + | gates operator console login and driver login, and reusing it here would + | make one leaked value unlock three different privilege tiers. + | + */ + 'customers' => [ + 'verification_bypass_code' => env('FLEETOPS_CUSTOMER_VERIFICATION_BYPASS_CODE'), + ], + /* |-------------------------------------------------------------------------- | API Events diff --git a/server/src/Http/Controllers/Api/v1/CustomerController.php b/server/src/Http/Controllers/Api/v1/CustomerController.php index 149554d5d..7b54739dd 100644 --- a/server/src/Http/Controllers/Api/v1/CustomerController.php +++ b/server/src/Http/Controllers/Api/v1/CustomerController.php @@ -144,7 +144,7 @@ public function create(CreateCustomerRequest $request) 'for' => 'fleetops_create_customer', 'meta->identity' => $identity, ]); - if (!$verificationCode) { + if (!$verificationCode && !$this->verificationBypassMatches($code)) { return response()->apiError('Invalid verification code provided.'); } @@ -417,7 +417,7 @@ public function verifyCode(Request $request) 'code' => $code, 'for' => $for, ]); - if (!$verificationCode) { + if (!$verificationCode && !$this->verificationBypassMatches($code)) { return response()->apiError('Invalid verification code.'); } @@ -503,7 +503,7 @@ public function resetPassword(Request $request) 'for' => 'fleetops_customer_password_reset', 'meta->identity' => $needle, ]); - if (!$verificationCode) { + if (!$verificationCode && !$this->verificationBypassMatches($code)) { return response()->apiError('Invalid reset code.'); } @@ -517,7 +517,10 @@ public function resetPassword(Request $request) $user->save(); // Invalidate all existing sessions for this user after a password reset. $this->deleteUserTokens($user); - $verificationCode->delete(); + // Null on the testing-bypass path — there is no row to consume. + if ($verificationCode) { + $verificationCode->delete(); + } return response()->json(['status' => 'ok']); } @@ -918,6 +921,35 @@ protected function generateSmsVerification(User $user, string $for, array $optio return VerificationCode::generateSmsVerificationFor($user, $for, $options); } + /** + * Whether the supplied code matches the configured testing bypass code. + * + * Three conditions, all required, mirroring the console equivalent in + * Fleetbase\Http\Controllers\Internal\v1\AuthController::authenticateWithVerificationCode: + * a bypass code must actually be configured, the app must not be running in + * production, and the comparison is constant-time. + * + * Fails safe by default: config/app.php resolves `env` to `production` when + * neither APP_ENV nor ENVIRONMENT is set, so an unconfigured install cannot + * be bypassed even accidentally. + * + * `!== null && !== ''` rather than `!empty()` — `!empty('0')` is false, so a + * configured bypass code of "0" would otherwise be silently ignored. + * + * Kept out of verificationCodeExists()/findVerificationCode() on purpose: + * those two are test seams that the controller contract tests override, so a + * policy living inside them would be stubbed away exactly where it matters. + */ + protected function verificationBypassMatches(?string $code): bool + { + $bypassCode = config('fleetops.customers.verification_bypass_code'); + + return $bypassCode !== null + && $bypassCode !== '' + && !app()->environment('production') + && hash_equals((string) $bypassCode, (string) $code); + } + protected function verificationCodeExists(array $attributes): bool { return VerificationCode::where($attributes)->exists(); diff --git a/server/src/Http/Requests/CreateCustomerRequest.php b/server/src/Http/Requests/CreateCustomerRequest.php index bd4265d80..761606858 100644 --- a/server/src/Http/Requests/CreateCustomerRequest.php +++ b/server/src/Http/Requests/CreateCustomerRequest.php @@ -23,7 +23,15 @@ public function rules(): array { return [ 'identity' => 'required|string', - 'code' => 'required|exists:verification_codes,code', + // Presence only — authorizing the code is the controller's job, and its + // check is strictly stronger: it matches code + for + meta->identity, + // whereas `exists:verification_codes,code` accepts any live code issued + // for any purpose to any user. The rule is also already unenforced on the + // proxy path, since verifyCode() with for=fleetops_create_customer calls + // create(CreateCustomerRequest::createFrom($request)), which never runs + // validateResolved(). Keeping it here only blocks the configured + // non-production testing bypass (fleetops.customers.verification_bypass_code). + 'code' => 'required|string', 'name' => 'required|string', 'password' => 'required|string|min:8', 'email' => [ diff --git a/server/tests/ApiCustomerControllerContractsTest.php b/server/tests/ApiCustomerControllerContractsTest.php index e0c9da3e2..f25e60959 100644 --- a/server/tests/ApiCustomerControllerContractsTest.php +++ b/server/tests/ApiCustomerControllerContractsTest.php @@ -960,3 +960,158 @@ function fleetopsApiCustomerJson($response): array 'name' => 'explode', ]))))->toBe(['error' => 'update failed']); }); + +/** + * Run a callback with an app container that supports environment(). + * + * The harness binds a bare Illuminate\Container\Container, which has no + * environment() — so CustomerController::verificationBypassMatches fatals with + * "Call to undefined method" without this. Mirrors the swap in + * NotificationAndMailContractsTest, but carries every existing binding across so + * the controller can still resolve config/request/db. + */ +function fleetopsApiCustomerWithEnvironment(string $environment, callable $callback): mixed +{ + $previousApp = Illuminate\Container\Container::getInstance(); + $app = new class extends Illuminate\Container\Container { + public string $fleetopsEnvironment = 'testing'; + + public function environment(...$environments) + { + if (empty($environments)) { + return $this->fleetopsEnvironment; + } + + $environments = is_array($environments[0]) ? $environments[0] : $environments; + + return in_array($this->fleetopsEnvironment, $environments, true); + } + + public function hasDebugModeEnabled() + { + return false; + } + }; + $app->fleetopsEnvironment = $environment; + + $reflection = new ReflectionClass(Illuminate\Container\Container::class); + foreach (['bindings', 'instances', 'aliases', 'abstractAliases', 'resolved', 'scopedInstances'] as $property) { + if (!$reflection->hasProperty($property)) { + continue; + } + $handle = $reflection->getProperty($property); + $handle->setAccessible(true); + $handle->setValue($app, $handle->getValue($previousApp)); + } + + Illuminate\Container\Container::setInstance($app); + + try { + return $callback(); + } finally { + Illuminate\Container\Container::setInstance($previousApp); + } +} + +test('api customer controller ignores the verification bypass unless it is configured', function () { + // Unset and empty-string are both inert. Without this the bypass would be a + // standing hole in every default install, which is the entire risk of shipping one. + foreach ([null, ''] as $bypassCode) { + config(['fleetops.customers.verification_bypass_code' => $bypassCode]); + + fleetopsApiCustomerWithEnvironment('local', function () { + $create = fleetopsApiCustomerController(); + $create->verificationExists = false; + $verify = fleetopsApiCustomerController(); + $verify->verificationExists = false; + $reset = fleetopsApiCustomerController(); + $reset->verificationCode = null; + + expect(fleetopsApiCustomerJson($create->create(new CreateCustomerRequest([ + 'code' => '000000', + 'identity' => 'jane@example.test', + ]))))->toBe(['error' => 'Invalid verification code provided.']) + ->and(fleetopsApiCustomerJson($verify->verifyCode(Request::create('/v1/customers/verify-code', 'POST', [ + 'identity' => 'jane@example.test', + 'code' => '000000', + ]))))->toBe(['error' => 'Invalid verification code.']) + ->and(fleetopsApiCustomerJson($reset->resetPassword(Request::create('/v1/customers/reset-password', 'POST', [ + 'identity' => 'jane@example.test', + 'code' => '000000', + 'password' => 'password-secret', + ]))))->toBe(['error' => 'Invalid reset code.']); + }); + } + + config(['fleetops.customers.verification_bypass_code' => null]); +}); + +test('api customer controller accepts a configured verification bypass outside production', function () { + config(['fleetops.customers.verification_bypass_code' => '000000']); + + fleetopsApiCustomerWithEnvironment('local', function () { + $create = fleetopsApiCustomerController(); + $create->verificationExists = false; + $verify = fleetopsApiCustomerController(); + $verify->verificationExists = false; + // No VerificationCode row on the bypass path — resetPassword must not fatal + // calling ->delete() on null, and must still revoke existing sessions. + $reset = fleetopsApiCustomerController(); + $reset->verificationCode = null; + // A non-matching code is still rejected while the bypass is live. + $wrong = fleetopsApiCustomerController(); + $wrong->verificationExists = false; + + expect($create->create(new CreateCustomerRequest([ + 'code' => '000000', + 'identity' => 'jane@example.test', + 'name' => 'Jane', + 'password' => 'password-secret', + ])))->toMatchArray(['resource' => 'customer', 'token' => 'plain-token']) + ->and($verify->verifyCode(Request::create('/v1/customers/verify-code', 'POST', [ + 'identity' => 'jane@example.test', + 'code' => '000000', + ])))->toMatchArray(['resource' => 'customer', 'token' => 'plain-token']) + ->and(fleetopsApiCustomerJson($reset->resetPassword(Request::create('/v1/customers/reset-password', 'POST', [ + 'identity' => 'jane@example.test', + 'code' => '000000', + 'password' => 'password-secret', + ]))))->toBe(['status' => 'ok']) + ->and($reset->genericUser->tokensDeleted)->toBeTrue() + ->and(fleetopsApiCustomerJson($wrong->create(new CreateCustomerRequest([ + 'code' => '999999', + 'identity' => 'jane@example.test', + ]))))->toBe(['error' => 'Invalid verification code provided.']); + }); + + config(['fleetops.customers.verification_bypass_code' => null]); +}); + +test('api customer controller refuses the verification bypass in production', function () { + config(['fleetops.customers.verification_bypass_code' => '000000']); + + fleetopsApiCustomerWithEnvironment('production', function () { + $create = fleetopsApiCustomerController(); + $create->verificationExists = false; + $verify = fleetopsApiCustomerController(); + $verify->verificationExists = false; + $reset = fleetopsApiCustomerController(); + $reset->verificationCode = null; + + expect(fleetopsApiCustomerJson($create->create(new CreateCustomerRequest([ + 'code' => '000000', + 'identity' => 'jane@example.test', + ]))))->toBe(['error' => 'Invalid verification code provided.']) + ->and(fleetopsApiCustomerJson($verify->verifyCode(Request::create('/v1/customers/verify-code', 'POST', [ + 'identity' => 'jane@example.test', + 'code' => '000000', + ]))))->toBe(['error' => 'Invalid verification code.']) + ->and(fleetopsApiCustomerJson($reset->resetPassword(Request::create('/v1/customers/reset-password', 'POST', [ + 'identity' => 'jane@example.test', + 'code' => '000000', + 'password' => 'password-secret', + ]))))->toBe(['error' => 'Invalid reset code.']); + }); + + config(['fleetops.customers.verification_bypass_code' => null]); +}); diff --git a/server/tests/RequestContractsTest.php b/server/tests/RequestContractsTest.php index e0fcf72d2..d6e1051fb 100644 --- a/server/tests/RequestContractsTest.php +++ b/server/tests/RequestContractsTest.php @@ -621,7 +621,7 @@ protected function canUpdateDriver(): bool expect($request->authorize())->toBeTrue() ->and($rules['identity'])->toBe('required|string') - ->and($rules['code'])->toBe('required|exists:verification_codes,code') + ->and($rules['code'])->toBe('required|string') ->and($rules['name'])->toBe('required|string') ->and($rules['password'])->toBe('required|string|min:8') ->and(ruleStrings($rules['email']))->toContain('email', 'nullable', 'unique:contacts') From a0de4f9d706ab82fa4bdb78f613a72694f41a833 Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Mon, 10 Aug 2026 13:07:44 +0800 Subject: [PATCH 2/2] test(customers): realign the CreateCustomerRequest source assertion CustomerEndpointTest matches against the literal source text of CreateCustomerRequest, so relaxing the `code` rule from `required|exists:verification_codes,code` to `required|string` failed the suite even though the behaviour under test was unchanged: Test Failed (CustomerEndpointTest::__pest_evaluable_FormRequest_validators _are_present_and_authorize_via_api_credential) Update the expected string and note the coupling, so the next rule change tells the reader why this file has to move with it. Co-Authored-By: Claude Opus 5 --- server/tests/CustomerEndpointTest.php | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/server/tests/CustomerEndpointTest.php b/server/tests/CustomerEndpointTest.php index ce9610526..38670211b 100644 --- a/server/tests/CustomerEndpointTest.php +++ b/server/tests/CustomerEndpointTest.php @@ -231,8 +231,11 @@ function fleetopsCustomerEndpointJson(JsonResponse $response): array $create = file_get_contents(dirname(__DIR__) . '/src/Http/Requests/CreateCustomerRequest.php'); $verify = file_get_contents(dirname(__DIR__) . '/src/Http/Requests/VerifyCreateCustomerRequest.php'); + // These match against source text, so they have to be updated whenever the rule + // strings change. `code` is presence-only by design — the controller's own check + // (code + for + meta->identity) is strictly stronger than exists:verification_codes. expect($create) - ->toContain("'code' => 'required|exists:verification_codes,code'") + ->toContain("'code' => 'required|string'") ->toContain("'password' => 'required|string|min:8'") ->and($verify)->toContain("'mode' => 'required|in:email,sms'"); });