Files
api/services/nginx/app/tests/Unit/Auth/ApiKeyGeneratorTest.php
T
Jeppe BandTRU-198 Subagent 18dc8e6e9c feat(auth): API key foundation (TRU-143+144+145) (#396)
## Summary

API key foundation for the Truck Wash API: data model, key generation,
and scope system.

Three atomic commits, one PR:
- **TRU-143** — `api_keys` table + repository wrapper
- **TRU-144** — Key generation + argon2id hashing
- **TRU-145** — Scope registry with role bindings

## Linear

- TRU-143 — [Backend] API key data model + storage schema
- TRU-144 — [Backend] Key generation + argon2id hashing
- TRU-145 — [Backend] Scope system + role bindings

## Files

**Production code:**
- `services/nginx/app/classes/api_key_schema_bootstrap.php` — idempotent
`CREATE TABLE IF NOT EXISTS` for `api_keys`
- `services/nginx/app/classes/api_key_repository.php` — `create`,
`findActiveByKeyId`, `findById`, `revoke`, `delete`, `listForCustomer`,
`touchLastUsed`
- `services/nginx/app/classes/api_key_generator.php` — `generateKeyId`,
`generateSecret`, `formatKey`, `hash` (argon2id), `verify`, `parseKey`
- `services/nginx/app/classes/auth/scope_registry.php` — canonical scope
constants + role defaults + `hasScope` / `expand` / `matches` /
`isValid`
-
`services/nginx/app/database/migrations/2026_08_17_000001_create_api_keys_table.php`
— human-readable migration record

**Tests (all new, all passing):**
- `tests/Unit/Auth/ApiKeyGeneratorTest.php` — 15 tests
- `tests/Unit/Auth/ApiKeyRepositoryTest.php` — 11 tests
- `tests/Unit/Auth/ScopeRegistryTest.php` — 24 tests

**Totals:** 50 new tests, 140 assertions. Full unit suite: 1329 passed
(up from 1279 baseline), 0 new failures (10 pre-existing unrelated
failures remain).

## Schema

```sql
CREATE TABLE api_keys (
    id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
    key_id VARCHAR(64) NOT NULL,
    key_hash VARCHAR(255) NOT NULL,
    name VARCHAR(255) NOT NULL,
    role VARCHAR(32) NOT NULL,
    scopes JSON NULL,
    customer_id BIGINT UNSIGNED NULL,
    created_by BIGINT UNSIGNED NULL,
    last_used_at TIMESTAMP NULL,
    expires_at TIMESTAMP NULL,
    revoked_at TIMESTAMP NULL,
    created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
    updated_at TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
    UNIQUE KEY uq_api_keys_key_id (key_id),
    INDEX idx_api_keys_customer (customer_id),
    INDEX idx_api_keys_key_hash (key_hash),
    INDEX idx_api_keys_revoked (revoked_at),
    INDEX idx_api_keys_role (role)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
```

## Key format

```
<prefix>_<env>_<22-char-base62>.<32-char-base62-secret>
e.g. truck_live_aBcD1234XyZ5678mnOpQrSt.uVwXyZ0123456789aBcDeFgHiJkLmN
```

- `key_id` is stored in plain text (the lookup key).
- The secret is **never** stored in plain text — only the argon2id hash.
- The full key is shown to the user exactly once at creation.
- Prefix is configurable via `config('api_key.prefix', 'truck')`.

## Role → scope defaults

| Role | Scopes |
|---|---|
| superuser | `*` |
| admin | `customer:*`, `booking:*`, `subuser:*`, `invoice:*` |
| customer | `customer:read`, `booking:read`, `invoice:read` |
| subuser | `booking:read`, `booking:write` |

Wildcard support: `*` matches everything; `resource:*` matches every
action on a resource.

## Design notes

- **No migration framework** — this codebase uses
`*_schema_bootstrap.php` files for idempotent table creation. The
`database/migrations/` file is kept as a human-readable change record.
- **No Eloquent** — the repository is a thin wrapper over the existing
mysqli `$db` global, matching the pattern in `classes/orders_o.php`,
`classes/invoice_store.php`, etc.
- **Coexistence with TRU-149** — `classes/auth/scope_registry.php`
(TRU-145) is the canonical implementation; the local `app\auth\Scope`
stub from `feat/TRU-149-route-scopes` is documented in the class header
as the thing this will replace once that branch merges. The two can
coexist in the meantime.
- **"Self" / "assigned" qualifiers** (e.g. "customer can only read their
own bookings") are intentionally **out of scope** here — they live in
the resolver layer that maps an authenticated principal to a
customer/subuser record. Scopes only encode "can the caller read
bookings at all".
- **No secrets in code** — the generator uses `random_bytes()` with
rejection sampling (no modulo bias). No test fixtures contain real keys.

## Checklist

- [x] All new tests pass (50/50)
- [x] No new test failures in the full unit suite
- [x] No PHP syntax errors
- [x] No committed secrets
- [x] No modifications to existing test files
- [x] No modifications to `openclaw.json` or any config files

---------

Co-authored-by: TRU-198 Subagent <subagent@openhands.dev>
2026-08-17 13:54:04 +02:00

128 lines
5.1 KiB
PHP

<?php
use classes\api_key_generator;
beforeEach(function (): void {
$this->previousDb = $GLOBALS['db'] ?? null;
unset($GLOBALS['db']);
});
afterEach(function (): void {
if ($this->previousDb !== null) {
$GLOBALS['db'] = $this->previousDb;
return;
}
unset($GLOBALS['db']);
});
it('generates a key_id with the default prefix and 22-char base62 random', function (): void {
$keyId = api_key_generator::generateKeyId();
expect($keyId)
->toStartWith('truck_live_')
->and(strlen($keyId))->toBe(strlen('truck_live_') + 22)
->and(api_key_generator::isBase62(substr($keyId, strlen('truck_live_'))))->toBeTrue();
});
it('honours a custom env tag in the key_id', function (): void {
$keyId = api_key_generator::generateKeyId('test');
expect($keyId)->toStartWith('truck_test_');
});
it('falls back to "live" for empty or invalid env tags', function (): void {
expect(api_key_generator::generateKeyId(''))->toStartWith('truck_live_');
expect(api_key_generator::generateKeyId(' '))->toStartWith('truck_live_');
expect(api_key_generator::generateKeyId('weird!@# chars'))->toStartWith('truck_live_');
});
it('generates a 32-char base62 secret with no dots', function (): void {
$secret = api_key_generator::generateSecret();
expect($secret)
->toHaveLength(32)
->and($secret)->not->toContain('.')
->and(api_key_generator::isBase62($secret))->toBeTrue();
});
it('generates unique values across many calls', function (): void {
$seen = [];
for ($i = 0; $i < 200; $i++) {
$seen[] = api_key_generator::generateKeyId() . '.' . api_key_generator::generateSecret();
}
expect(count(array_unique($seen)))->toBe(200);
});
it('formats a key as "key_id.secret"', function (): void {
$full = api_key_generator::formatKey('truck_live_abc', 'xyz');
expect($full)->toBe('truck_live_abc.xyz');
});
it('rejects formatted keys where either part contains a dot', function (): void {
expect(fn () => api_key_generator::formatKey('bad.dot', 'secret'))
->toThrow(InvalidArgumentException::class);
expect(fn () => api_key_generator::formatKey('key_id', 'bad.dot'))
->toThrow(InvalidArgumentException::class);
});
it('hashes with argon2id and verifies the same plaintext', function (): void {
$plain = 'truck_live_abc' . '.' . 'thisIsTheSecretPart';
$hash = api_key_generator::hash($plain);
expect($hash)
->toBeString()
->not->toBe($plain)
->toStartWith('$argon2id$');
expect(api_key_generator::verify($plain, $hash))->toBeTrue();
});
it('produces different hashes for the same plaintext (salt randomness)', function (): void {
$plain = 'truck_live_abc' . '.' . 'thisIsTheSecretPart';
$h1 = api_key_generator::hash($plain);
$h2 = api_key_generator::hash($plain);
expect($h1)->not->toBe($h2);
expect(api_key_generator::verify($plain, $h1))->toBeTrue();
expect(api_key_generator::verify($plain, $h2))->toBeTrue();
});
it('rejects an empty hash input', function (): void {
expect(fn () => api_key_generator::hash(''))
->toThrow(InvalidArgumentException::class);
});
it('verify returns false for empty inputs', function (): void {
expect(api_key_generator::verify('', '$argon2id$something'))->toBeFalse();
expect(api_key_generator::verify('plain', ''))->toBeFalse();
});
it('parses a well-formed full key', function (): void {
$full = 'truck_live_abcDEF1234567890xyz' . '.' . 'A1B2C3D4E5F6G7H8I9J0K1L2M3N4O5P6';
$parsed = api_key_generator::parseKey($full);
expect($parsed)
->toBe(['key_id' => 'truck_live_abcDEF1234567890xyz', 'secret' => 'A1B2C3D4E5F6G7H8I9J0K1L2M3N4O5P6']);
});
it('returns null when parsing a malformed key', function (): void {
expect(api_key_generator::parseKey(''))->toBeNull();
expect(api_key_generator::parseKey(' '))->toBeNull();
expect(api_key_generator::parseKey('no-dot-here'))->toBeNull();
expect(api_key_generator::parseKey('.only-secret'))->toBeNull();
expect(api_key_generator::parseKey('only-key.'))->toBeNull();
expect(api_key_generator::parseKey('has spaces.in-secret'))->toBeNull();
expect(api_key_generator::parseKey('has/slash.in-secret'))->toBeNull();
});
it('round-trips generateKeyId + generateSecret through formatKey + parseKey', function (): void {
$keyId = api_key_generator::generateKeyId('live');
$secret = api_key_generator::generateSecret();
$full = api_key_generator::formatKey($keyId, $secret);
$parsed = api_key_generator::parseKey($full);
expect($parsed)->toBe(['key_id' => $keyId, 'secret' => $secret]);
});
it('isBase62 accepts alphanumerics and rejects everything else', function (): void {
expect(api_key_generator::isBase62('abc123XYZ'))->toBeTrue();
expect(api_key_generator::isBase62(''))->toBeFalse();
expect(api_key_generator::isBase62('abc-123'))->toBeFalse();
expect(api_key_generator::isBase62('abc.123'))->toBeFalse();
expect(api_key_generator::isBase62('abc 123'))->toBeFalse();
expect(api_key_generator::isBase62('abc/123'))->toBeFalse();
expect(api_key_generator::isBase62('abc+123'))->toBeFalse();
});