Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions server/config/fleetops.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
40 changes: 36 additions & 4 deletions server/src/Http/Controllers/Api/v1/CustomerController.php
Original file line number Diff line number Diff line change
Expand Up @@ -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.');
}

Expand Down Expand Up @@ -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.');
}

Expand Down Expand Up @@ -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.');
}

Expand All @@ -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']);
}
Expand Down Expand Up @@ -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();
Expand Down
10 changes: 9 additions & 1 deletion server/src/Http/Requests/CreateCustomerRequest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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' => [
Expand Down
155 changes: 155 additions & 0 deletions server/tests/ApiCustomerControllerContractsTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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]);
});
5 changes: 4 additions & 1 deletion server/tests/CustomerEndpointTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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'");
});
Expand Down
2 changes: 1 addition & 1 deletion server/tests/RequestContractsTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand Down
Loading