From 18dc8e6e9c31c9ab67cec74a2c88d61d13f39732 Mon Sep 17 00:00:00 2001 From: Jeppe B <2jepp9350@gmail.com> Date: Mon, 17 Aug 2026 13:54:04 +0200 Subject: [PATCH] feat(auth): API key foundation (TRU-143+144+145) (#396) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 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 ``` __<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 --- .../nginx/app/classes/api_key_generator.php | 227 +++++++++++++ .../nginx/app/classes/api_key_repository.php | 249 ++++++++++++++ .../app/classes/api_key_schema_bootstrap.php | 92 +++++ .../nginx/app/classes/auth/scope_registry.php | 221 ++++++++++++ ...026_08_17_000001_create_api_keys_table.php | 48 +++ .../tests/Unit/Auth/ApiKeyGeneratorTest.php | 127 +++++++ .../tests/Unit/Auth/ApiKeyRepositoryTest.php | 319 ++++++++++++++++++ .../app/tests/Unit/Auth/ScopeRegistryTest.php | 190 +++++++++++ 8 files changed, 1473 insertions(+) create mode 100644 services/nginx/app/classes/api_key_generator.php create mode 100644 services/nginx/app/classes/api_key_repository.php create mode 100644 services/nginx/app/classes/api_key_schema_bootstrap.php create mode 100644 services/nginx/app/classes/auth/scope_registry.php create mode 100644 services/nginx/app/database/migrations/2026_08_17_000001_create_api_keys_table.php create mode 100644 services/nginx/app/tests/Unit/Auth/ApiKeyGeneratorTest.php create mode 100644 services/nginx/app/tests/Unit/Auth/ApiKeyRepositoryTest.php create mode 100644 services/nginx/app/tests/Unit/Auth/ScopeRegistryTest.php diff --git a/services/nginx/app/classes/api_key_generator.php b/services/nginx/app/classes/api_key_generator.php new file mode 100644 index 00000000..d2b7ac42 --- /dev/null +++ b/services/nginx/app/classes/api_key_generator.php @@ -0,0 +1,227 @@ +__<22-char-base62>.<32-char-base62-secret> + * e.g. truck_live_aBcD1234XyZ5678mnOpQrSt.uVwXyZ0123456789aBcDeFgHiJkLmN + * + * The key_id (everything before the dot) is stored in plain text in + * the database as the lookup key. The secret is NEVER stored in plain + * text — only the argon2id hash is persisted. The full key is shown + * to the user exactly once at creation time. + */ +class api_key_generator +{ + /** Base62 alphabet (0-9, A-Z, a-z). Avoids + / = of base64. */ + public const ALPHABET = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz'; + + /** Characters permitted in the public key_id portion. */ + public const KEY_ID_RANDOM_LENGTH = 22; + + /** Characters in the secret portion. */ + public const SECRET_LENGTH = 32; + + /** + * Build the public key_id portion: __. + */ + public static function generateKeyId(string $env = 'live'): string + { + $env = self::normaliseEnv($env); + $prefix = self::prefix(); + $random = self::randomBase62(self::KEY_ID_RANDOM_LENGTH); + return $prefix . '_' . $env . '_' . $random; + } + + /** + * Generate the secret portion (32-char base62). + */ + public static function generateSecret(): string + { + return self::randomBase62(self::SECRET_LENGTH); + } + + /** + * Join key_id and secret with a single dot. + */ + public static function formatKey(string $keyId, string $secret): string + { + if ($keyId === '' || strpos($keyId, '.') !== false) { + throw new \InvalidArgumentException('key_id must not contain a dot'); + } + if ($secret === '' || strpos($secret, '.') !== false) { + throw new \InvalidArgumentException('secret must not contain a dot'); + } + return $keyId . '.' . $secret; + } + + /** + * Hash the full key (or just the secret) using argon2id. + */ + public static function hash(string $plain): string + { + if ($plain === '') { + throw new \InvalidArgumentException('Cannot hash an empty value'); + } + $hash = password_hash($plain, PASSWORD_ARGON2ID); + if ($hash === false) { + throw new \RuntimeException('Failed to hash with argon2id'); + } + return $hash; + } + + /** + * Verify a plaintext key against a stored argon2id hash. + */ + public static function verify(string $plain, string $hash): bool + { + if ($plain === '' || $hash === '') { + return false; + } + try { + return password_verify($plain, $hash); + } catch (\Throwable) { + return false; + } + } + + /** + * Split a full "key_id.secret" string back into its parts. + * + * The key_id may contain underscores (as separators between + * prefix/env/random) and must be base62 + underscores. The + * secret must be strictly base62 with no separators. + * + * @return array{key_id:string, secret:string}|null + * null if the input is malformed. + */ + public static function parseKey(string $full): ?array + { + $full = trim($full); + if ($full === '' || strpos($full, '.') === false) { + return null; + } + // Split on the FIRST dot only — secrets are base62 and contain + // no dots, so there's exactly one separator. + $parts = explode('.', $full, 2); + if (count($parts) !== 2) { + return null; + } + [$keyId, $secret] = $parts; + $keyId = trim($keyId); + $secret = trim($secret); + if ($keyId === '' || $secret === '') { + return null; + } + // The key_id is "__" — base62 with + // underscore separators. The secret is pure base62. + if (!self::isKeyId($keyId) || !self::isBase62($secret)) { + return null; + } + return ['key_id' => $keyId, 'secret' => $secret]; + } + + /** + * Validate a key_id string: base62 with optional underscore + * separators. Exposed for testing. + */ + public static function isKeyId(string $value): bool + { + if ($value === '') { + return false; + } + return preg_match('/^[0-9A-Za-z_]+$/', $value) === 1; + } + + /** + * Configurable prefix (default: "truck"). Reads from + * `config('api_key.prefix', 'truck')` if available, otherwise the + * default. Always lowercased and stripped of separators. + */ + public static function prefix(): string + { + $default = 'truck'; + $value = $default; + if (function_exists('config')) { + try { + $candidate = config('api_key.prefix', $default); + if (is_string($candidate) && $candidate !== '') { + $value = $candidate; + } + } catch (\Throwable) { + $value = $default; + } + } + $value = strtolower(trim((string)$value)); + $value = preg_replace('/[^a-z0-9_]/', '', $value) ?? ''; + if ($value === '') { + $value = $default; + } + return $value; + } + + /** + * @internal — exposed for testing. + */ + public static function randomBase62(int $length): string + { + if ($length < 1) { + throw new \InvalidArgumentException('Length must be positive'); + } + + $alphabet = self::ALPHABET; + $alphabetMax = strlen($alphabet) - 1; // 61 + + $out = ''; + $bytesNeeded = (int)ceil($length * 1.3) + 8; + $bytes = random_bytes($bytesNeeded); + $byteIndex = 0; + + while (strlen($out) < $length) { + if (!isset($bytes[$byteIndex])) { + $bytes = random_bytes($bytesNeeded); + $byteIndex = 0; + } + // Mask off 0xC0 to get a value 0-63, then reject > 61 to + // avoid modulo bias. + $byte = ord($bytes[$byteIndex]); + $byteIndex++; + $value = $byte & 0x3F; + if ($value > $alphabetMax) { + continue; + } + $out .= $alphabet[$value]; + } + + return $out; + } + + /** + * @internal — exposed for testing. + */ + public static function isBase62(string $value): bool + { + if ($value === '') { + return false; + } + return preg_match('/^[0-9A-Za-z]+$/', $value) === 1; + } + + private static function normaliseEnv(string $env): string + { + $trimmed = strtolower(trim($env)); + $sanitised = preg_replace('/[^a-z0-9_-]/', '', $trimmed) ?? ''; + // If the input contained characters outside the allowed + // set, the sanitised result will differ from the trimmed + // input — in that case fall back to "live" rather than + // echoing a mangled version. Empty / whitespace-only input + // also falls back to "live". + if ($sanitised === '' || $sanitised !== $trimmed) { + return 'live'; + } + return $sanitised; + } +} diff --git a/services/nginx/app/classes/api_key_repository.php b/services/nginx/app/classes/api_key_repository.php new file mode 100644 index 00000000..7723f39f --- /dev/null +++ b/services/nginx/app/classes/api_key_repository.php @@ -0,0 +1,249 @@ + $data + */ + public static function validate(array $data): void + { + $required = ['key_id', 'key_hash', 'name', 'role']; + foreach ($required as $field) { + if (!isset($data[$field]) || !is_string($data[$field]) || $data[$field] === '') { + throw new \InvalidArgumentException("Missing required field: {$field}"); + } + } + $allowedRoles = ['superuser', 'admin', 'customer', 'subuser']; + if (!in_array($data['role'], $allowedRoles, true)) { + throw new \InvalidArgumentException("Invalid role: {$data['role']}"); + } + } + + /** + * @param array $data + * @return int inserted id + */ + public static function create(array $data): int + { + self::validate($data); + + $db = self::db(); + $scopesJson = isset($data['scopes']) && $data['scopes'] !== null + ? (is_string($data['scopes']) ? $data['scopes'] : json_encode($data['scopes'], JSON_UNESCAPED_SLASHES)) + : null; + + $stmt = $db->conn()->prepare( + 'INSERT INTO api_keys (key_id, key_hash, name, role, scopes, customer_id, created_by, expires_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)' + ); + if ($stmt === false) { + throw new Exception('Failed to prepare insert: ' . $db->conn()->error); + } + + $customerId = isset($data['customer_id']) ? (int)$data['customer_id'] : null; + $createdBy = isset($data['created_by']) ? (int)$data['created_by'] : null; + $expiresAt = isset($data['expires_at']) && $data['expires_at'] !== null + ? (string)$data['expires_at'] + : null; + + $stmt->bind_param( + 'sssssiss', + $data['key_id'], + $data['key_hash'], + $data['name'], + $data['role'], + $scopesJson, + $customerId, + $createdBy, + $expiresAt + ); + + if (!$stmt->execute()) { + $err = $stmt->error; + $stmt->close(); + throw new Exception('Failed to insert api_key: ' . $err); + } + $id = $stmt->insert_id; + $stmt->close(); + return (int)$id; + } + + /** + * Find a non-revoked key by its public key_id. + * + * @return array|null + */ + public static function findActiveByKeyId(string $keyId): ?array + { + if ($keyId === '') { + return null; + } + $db = self::db(); + $stmt = $db->conn()->prepare( + 'SELECT * FROM api_keys WHERE key_id = ? AND revoked_at IS NULL LIMIT 1' + ); + if ($stmt === false) { + throw new Exception('Failed to prepare select: ' . $db->conn()->error); + } + $stmt->bind_param('s', $keyId); + if (!$stmt->execute()) { + $err = $stmt->error; + $stmt->close(); + throw new Exception('Failed to execute select: ' . $err); + } + $result = $stmt->get_result(); + $row = $result ? $result->fetch_assoc() : null; + $stmt->close(); + return $row ?: null; + } + + /** + * Find any key by id (including revoked). + * + * @return array|null + */ + public static function findById(int $id): ?array + { + $db = self::db(); + $stmt = $db->conn()->prepare('SELECT * FROM api_keys WHERE id = ? LIMIT 1'); + if ($stmt === false) { + throw new Exception('Failed to prepare select: ' . $db->conn()->error); + } + $stmt->bind_param('i', $id); + if (!$stmt->execute()) { + $err = $stmt->error; + $stmt->close(); + throw new Exception('Failed to execute select: ' . $err); + } + $result = $stmt->get_result(); + $row = $result ? $result->fetch_assoc() : null; + $stmt->close(); + return $row ?: null; + } + + /** + * Revoke a key (sets revoked_at = NOW()). Returns true on success. + */ + public static function revoke(int $id): bool + { + $db = self::db(); + $stmt = $db->conn()->prepare( + 'UPDATE api_keys SET revoked_at = CURRENT_TIMESTAMP WHERE id = ? AND revoked_at IS NULL' + ); + if ($stmt === false) { + throw new Exception('Failed to prepare revoke: ' . $db->conn()->error); + } + $stmt->bind_param('i', $id); + $ok = $stmt->execute(); + $affected = $stmt->affected_rows; + $stmt->close(); + return $ok && $affected > 0; + } + + /** + * Bump last_used_at for a key. Best-effort: failures are swallowed + * because this is a hot-path observability hook and must not + * break the request. + */ + public static function touchLastUsed(int $id): void + { + try { + $db = self::db(); + $stmt = $db->conn()->prepare( + 'UPDATE api_keys SET last_used_at = CURRENT_TIMESTAMP WHERE id = ?' + ); + if ($stmt === false) { + return; + } + $stmt->bind_param('i', $id); + $stmt->execute(); + $stmt->close(); + } catch (Throwable) { + // intentionally ignored + } + } + + /** + * List keys for a customer, newest first. + * + * @return array> + */ + public static function listForCustomer(int $customerId, bool $includeRevoked = false): array + { + $db = self::db(); + $sql = 'SELECT * FROM api_keys WHERE customer_id = ?'; + if (!$includeRevoked) { + $sql .= ' AND revoked_at IS NULL'; + } + $sql .= ' ORDER BY id DESC'; + $stmt = $db->conn()->prepare($sql); + if ($stmt === false) { + throw new Exception('Failed to prepare list: ' . $db->conn()->error); + } + $stmt->bind_param('i', $customerId); + if (!$stmt->execute()) { + $err = $stmt->error; + $stmt->close(); + throw new Exception('Failed to execute list: ' . $err); + } + $result = $stmt->get_result(); + $rows = $result ? $result->fetch_all(MYSQLI_ASSOC) : []; + $stmt->close(); + return is_array($rows) ? $rows : []; + } + + /** + * Delete a key by id. Returns true if a row was removed. + * Generally prefer `revoke()` over `delete()` so audit trails + * stay intact. + */ + public static function delete(int $id): bool + { + $db = self::db(); + $stmt = $db->conn()->prepare('DELETE FROM api_keys WHERE id = ?'); + if ($stmt === false) { + throw new Exception('Failed to prepare delete: ' . $db->conn()->error); + } + $stmt->bind_param('i', $id); + $ok = $stmt->execute(); + $affected = $stmt->affected_rows; + $stmt->close(); + return $ok && $affected > 0; + } +} diff --git a/services/nginx/app/classes/api_key_schema_bootstrap.php b/services/nginx/app/classes/api_key_schema_bootstrap.php new file mode 100644 index 00000000..377b693b --- /dev/null +++ b/services/nginx/app/classes/api_key_schema_bootstrap.php @@ -0,0 +1,92 @@ +_create_api_keys_table.php` + * is the human-readable source of truth / change record. + */ +class api_key_schema_bootstrap +{ + public const TABLE = 'api_keys'; + + private static bool $initialized = false; + + public static function ensureTables(): void + { + if (self::$initialized) { + return; + } + + global $db; + if (!isset($db) || !is_object($db)) { + // No DB connection in this process (e.g. unit test) — skip. + self::$initialized = true; + return; + } + + $queries = [ + "CREATE TABLE IF NOT EXISTS 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", + ]; + + foreach ($queries as $query) { + try { + $db->query($query); + } catch (\Throwable $e) { + // Swallow on first-failure in unit-test contexts; the + // migration companion file documents the canonical DDL. + if (function_exists('error_log')) { + @error_log('[api_key_schema_bootstrap] ' . $e->getMessage()); + } + } + } + + self::$initialized = true; + } + + public static function tableExists(): bool + { + global $db; + if (!isset($db) || !is_object($db) || !method_exists($db, 'getDatabase')) { + return false; + } + + try { + $database = $db->escape_string($db->getDatabase()); + $result = $db->query( + "SELECT COUNT(*) AS count + FROM information_schema.tables + WHERE table_schema = '{$database}' + AND table_name = 'api_keys'" + ); + $row = $result ? $result->fetch_assoc() : ['count' => 0]; + return (int)($row['count'] ?? 0) > 0; + } catch (\Throwable) { + return false; + } + } +} diff --git a/services/nginx/app/classes/auth/scope_registry.php b/services/nginx/app/classes/auth/scope_registry.php new file mode 100644 index 00000000..3d357c86 --- /dev/null +++ b/services/nginx/app/classes/auth/scope_registry.php @@ -0,0 +1,221 @@ + + */ + public static function all(): array + { + return [ + self::CUSTOMER_READ, self::CUSTOMER_WRITE, + self::BOOKING_READ, self::BOOKING_WRITE, + self::SUBUSER_READ, self::SUBUSER_WRITE, + self::INVOICE_READ, self::INVOICE_WRITE, + self::SUPERUSER_READ, self::SUPERUSER_WRITE, + ]; + } + + /** + * Return the default scope set carried by a role. Wildcards are + * returned as-is; resolve them with `expand()` before checking + * membership if you need a flat list. + * + * @return array + */ + public static function scopesForRole(string $role): array + { + switch (strtolower(trim($role))) { + case 'superuser': + return ['*']; + case 'admin': + return [ + 'customer:*', + 'booking:*', + 'subuser:*', + 'invoice:*', + ]; + case 'customer': + return [ + self::CUSTOMER_READ, + self::BOOKING_READ, + self::INVOICE_READ, + ]; + case 'subuser': + return [ + self::BOOKING_READ, + self::BOOKING_WRITE, + ]; + default: + return []; + } + } + + /** + * Does the granted scope (or wildcard) match the required scope? + * + * - "*" matches anything. + * - "customer:*" matches "customer:read" and "customer:write". + * - "customer:read" matches itself exactly. + * + * @param array $granted + */ + public static function hasScope(array $granted, string $required): bool + { + $required = trim($required); + if ($required === '') { + return false; + } + foreach ($granted as $candidate) { + if (!is_string($candidate)) { + continue; + } + if (self::matches($candidate, $required)) { + return true; + } + } + return false; + } + + /** + * Expand a list of scopes (which may include wildcards) into the + * full set of concrete scopes they grant. Useful for showing a + * user what their key can do, or for caching decisions. + * + * The wildcard "*" expands to the full `all()` set. A wildcard + * like "customer:*" expands to every concrete scope starting with + * "customer:". Duplicate entries are removed. + * + * @param array $scopes + * @return array + */ + public static function expand(array $scopes): array + { + $concrete = self::all(); + $expanded = []; + + foreach ($scopes as $scope) { + if (!is_string($scope)) { + continue; + } + $scope = trim($scope); + if ($scope === '') { + continue; + } + if ($scope === '*') { + $expanded = array_merge($expanded, $concrete); + continue; + } + if (str_ends_with($scope, ':*')) { + $prefix = substr($scope, 0, -2) . ':'; + foreach ($concrete as $candidate) { + if (str_starts_with($candidate, $prefix)) { + $expanded[] = $candidate; + } + } + continue; + } + // Already concrete — pass through if it looks canonical. + if (in_array($scope, $concrete, true)) { + $expanded[] = $scope; + } + } + + return array_values(array_unique($expanded)); + } + + /** + * Internal wildcard matcher — public for testing. + */ + public static function matches(string $granted, string $required): bool + { + $granted = trim($granted); + $required = trim($required); + if ($granted === '' || $required === '') { + return false; + } + if ($granted === '*') { + return true; + } + if (str_ends_with($granted, ':*')) { + $prefix = substr($granted, 0, -2); + return str_starts_with($required, $prefix . ':'); + } + return $granted === $required; + } + + /** + * Validate a scope string. Returns true iff the value is either + * a canonical concrete scope, "*", or a ":*" wildcard + * for a known resource. + */ + public static function isValid(string $scope): bool + { + $scope = trim($scope); + if ($scope === '' || $scope === '*') { + return $scope !== ''; + } + if (str_ends_with($scope, ':*')) { + $prefix = substr($scope, 0, -2); + foreach (self::all() as $concrete) { + if (str_starts_with($concrete, $prefix . ':')) { + return true; + } + } + return false; + } + return in_array($scope, self::all(), true); + } +} diff --git a/services/nginx/app/database/migrations/2026_08_17_000001_create_api_keys_table.php b/services/nginx/app/database/migrations/2026_08_17_000001_create_api_keys_table.php new file mode 100644 index 00000000..334b127c --- /dev/null +++ b/services/nginx/app/database/migrations/2026_08_17_000001_create_api_keys_table.php @@ -0,0 +1,48 @@ + -p < 2026_08_17_000001_create_api_keys_table.sql + */ + +return [ + 'id' => '2026_08_17_000001_create_api_keys_table', + 'issue' => 'TRU-143', + 'table' => 'api_keys', + 'engine' => 'InnoDB', + 'charset' => 'utf8mb4', + 'collation' => 'utf8mb4_unicode_ci', + 'up' => [ + "CREATE TABLE IF NOT EXISTS 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", + ], + 'down' => [ + 'DROP TABLE IF EXISTS api_keys', + ], +]; diff --git a/services/nginx/app/tests/Unit/Auth/ApiKeyGeneratorTest.php b/services/nginx/app/tests/Unit/Auth/ApiKeyGeneratorTest.php new file mode 100644 index 00000000..36b46981 --- /dev/null +++ b/services/nginx/app/tests/Unit/Auth/ApiKeyGeneratorTest.php @@ -0,0 +1,127 @@ +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(); +}); diff --git a/services/nginx/app/tests/Unit/Auth/ApiKeyRepositoryTest.php b/services/nginx/app/tests/Unit/Auth/ApiKeyRepositoryTest.php new file mode 100644 index 00000000..bfaeb14f --- /dev/null +++ b/services/nginx/app/tests/Unit/Auth/ApiKeyRepositoryTest.php @@ -0,0 +1,319 @@ + */ + public array $params = []; + public ?int $insertId = null; + public int $affectedRows = 0; + public string $error = ''; + public bool $executeResult = true; + /** @var array>|null */ + public ?array $rowsToReturn = null; + /** @var array */ + public array $types = [ + 'i' => 'i', 's' => 's', + ]; + + public function bind_param(string $types, &...$vars): bool + { + $this->params = $vars; + return true; + } + + public function execute(): bool + { + return $this->executeResult; + } + + public function close(): bool + { + return true; + } + + /** + * @return object{ fetch_assoc(): ?array, fetch_all(int): array> } + */ + public function get_result(): object + { + $rows = $this->rowsToReturn ?? []; + return new class($rows) { + /** @param array> $rows */ + public function __construct(private array $rows) + { + } + + public function fetch_assoc(): ?array + { + return $this->rows[0] ?? null; + } + + /** @return array> */ + public function fetch_all(int $mode = MYSQLI_ASSOC): array + { + return $this->rows; + } + }; + } + } +} + +if (!class_exists('ApiKeyRepositoryFakeMysqli')) { + class ApiKeyRepositoryFakeMysqli + { + public string $error = ''; + public ApiKeyRepositoryFakeStmt $lastStmt; + /** @var array> */ + public array $insertedRows = []; + public int $nextInsertId = 100; + /** @var array> */ + public array $rows = []; + + public function __construct() + { + $this->lastStmt = new ApiKeyRepositoryFakeStmt(); + } + + public function prepare(string $sql): object + { + $this->lastStmt = new ApiKeyRepositoryFakeStmt(); + $this->lastStmt->lastSql = $sql; + return $this->lastStmt; + } + + public function query(string $sql): object + { + // Used by the schema_bootstrap. Return an empty result stub. + $this->lastStmt = new ApiKeyRepositoryFakeStmt(); + $this->lastStmt->lastSql = $sql; + return $this->lastStmt; + } + } +} + +if (!class_exists('ApiKeyRepositoryFakeDb')) { + class ApiKeyRepositoryFakeDb + { + public ApiKeyRepositoryFakeMysqli $conn; + public string $databaseName = 'truckwash_test'; + /** @var array> */ + public array $rows = []; + public int $nextInsertId = 100; + + public function __construct() + { + $this->conn = new ApiKeyRepositoryFakeMysqli(); + } + + public function getDatabase(): string + { + return $this->databaseName; + } + + public function escape_string(string $value): string + { + return addslashes($value); + } + + public function query(string $sql): object + { + return $this->conn->query($sql); + } + + public function conn(): ApiKeyRepositoryFakeMysqli + { + return $this->conn; + } + } +} + +/** + * Wrap the repository's `find*` calls so they read from our in-memory + * `rows` table instead of going through real SQL. We override the + * static methods via a subclass. + */ +if (!class_exists('ApiKeyRepositoryFake')) { + class ApiKeyRepositoryFake extends api_key_repository + { + public static ?ApiKeyRepositoryFakeDb $bound = null; + public static ?array $findByKeyId = null; + public static ?array $findById = null; + public static ?array $listForCustomer = null; + public static bool $revokeOk = true; + public static bool $deleteOk = true; + public static int $nextInsertId = 100; + public static int $touchCount = 0; + + public static function create(array $data): int + { + // Delegate validation to the real method so the test + // exercises the same rules as production. + api_key_repository::validate($data); + $id = self::$nextInsertId++; + return $id; + } + + public static function findActiveByKeyId(string $keyId): ?array + { + return self::$findByKeyId; + } + + public static function findById(int $id): ?array + { + return self::$findById; + } + + public static function revoke(int $id): bool + { + return self::$revokeOk; + } + + public static function delete(int $id): bool + { + return self::$deleteOk; + } + + public static function listForCustomer(int $customerId, bool $includeRevoked = false): array + { + return self::$listForCustomer ?? []; + } + + public static function touchLastUsed(int $id): void + { + self::$touchCount++; + } + } +} + +beforeEach(function (): void { + $this->previousDb = $GLOBALS['db'] ?? null; + $GLOBALS['db'] = new ApiKeyRepositoryFakeDb(); + ApiKeyRepositoryFake::$bound = new ApiKeyRepositoryFakeDb(); + ApiKeyRepositoryFake::$findByKeyId = null; + ApiKeyRepositoryFake::$findById = null; + ApiKeyRepositoryFake::$listForCustomer = null; + ApiKeyRepositoryFake::$revokeOk = true; + ApiKeyRepositoryFake::$deleteOk = true; + ApiKeyRepositoryFake::$nextInsertId = 100; + ApiKeyRepositoryFake::$touchCount = 0; +}); + +afterEach(function (): void { + if ($this->previousDb !== null) { + $GLOBALS['db'] = $this->previousDb; + return; + } + unset($GLOBALS['db']); + ApiKeyRepositoryFake::$bound = null; +}); + +it('inserts an api key row with required fields', function (): void { + $id = ApiKeyRepositoryFake::create([ + 'key_id' => 'truck_live_abc', + 'key_hash'=> api_key_generator::hash('truck_live_abc.secretvalue'), + 'name' => 'Test Key', + 'role' => 'customer', + ]); + expect($id)->toBe(100); +}); + +it('rejects an api key insert missing required fields', function (): void { + expect(fn () => ApiKeyRepositoryFake::create([ + 'key_id' => 'truck_live_abc', + // key_hash missing + 'name' => 'Test Key', + 'role' => 'customer', + ]))->toThrow(InvalidArgumentException::class); + + expect(fn () => ApiKeyRepositoryFake::create([ + 'key_id' => 'truck_live_abc', + 'key_hash'=> 'hash', + 'name' => 'Test Key', + // role missing + ]))->toThrow(InvalidArgumentException::class); +}); + +it('finds an active key by key_id', function (): void { + ApiKeyRepositoryFake::$findByKeyId = [ + 'id' => 5, + 'key_id' => 'truck_live_abc', + 'role' => 'admin', + 'revoked_at' => null, + ]; + $row = ApiKeyRepositoryFake::findActiveByKeyId('truck_live_abc'); + expect($row) + ->toBeArray() + ->and($row['id'])->toBe(5) + ->and($row['key_id'])->toBe('truck_live_abc'); +}); + +it('returns null when finding an active key for an empty key_id', function (): void { + expect(ApiKeyRepositoryFake::findActiveByKeyId(''))->toBeNull(); +}); + +it('finds a key by id regardless of revocation state', function (): void { + ApiKeyRepositoryFake::$findById = [ + 'id' => 7, + 'key_id' => 'truck_live_xyz', + 'role' => 'subuser', + 'revoked_at' => '2026-08-17 00:00:00', + ]; + $row = ApiKeyRepositoryFake::findById(7); + expect($row) + ->toBeArray() + ->and($row['revoked_at'])->toBe('2026-08-17 00:00:00'); +}); + +it('revokes a key and returns true on success', function (): void { + expect(ApiKeyRepositoryFake::revoke(7))->toBeTrue(); + ApiKeyRepositoryFake::$revokeOk = false; + expect(ApiKeyRepositoryFake::revoke(7))->toBeFalse(); +}); + +it('lists keys for a customer', function (): void { + ApiKeyRepositoryFake::$listForCustomer = [ + ['id' => 1, 'key_id' => 'truck_live_a', 'role' => 'customer'], + ['id' => 2, 'key_id' => 'truck_live_b', 'role' => 'customer'], + ]; + $rows = ApiKeyRepositoryFake::listForCustomer(42); + expect($rows)->toHaveCount(2); + expect($rows[0]['key_id'])->toBe('truck_live_a'); +}); + +it('deletes a key and reports the result', function (): void { + expect(ApiKeyRepositoryFake::delete(7))->toBeTrue(); + ApiKeyRepositoryFake::$deleteOk = false; + expect(ApiKeyRepositoryFake::delete(7))->toBeFalse(); +}); + +it('touches last_used_at for a key', function (): void { + expect(ApiKeyRepositoryFake::$touchCount)->toBe(0); + ApiKeyRepositoryFake::touchLastUsed(7); + expect(ApiKeyRepositoryFake::$touchCount)->toBe(1); + ApiKeyRepositoryFake::touchLastUsed(7); + expect(ApiKeyRepositoryFake::$touchCount)->toBe(2); +}); + +it('ensureTables is idempotent and safe to call without a real DB', function (): void { + // The fake $db swallows queries; this should not throw. + api_key_schema_bootstrap::ensureTables(); + api_key_schema_bootstrap::ensureTables(); + expect(true)->toBeTrue(); +}); + +it('tableExists returns false when there is no DB', function (): void { + unset($GLOBALS['db']); + expect(api_key_schema_bootstrap::tableExists())->toBeFalse(); +}); diff --git a/services/nginx/app/tests/Unit/Auth/ScopeRegistryTest.php b/services/nginx/app/tests/Unit/Auth/ScopeRegistryTest.php new file mode 100644 index 00000000..90e492ba --- /dev/null +++ b/services/nginx/app/tests/Unit/Auth/ScopeRegistryTest.php @@ -0,0 +1,190 @@ +toBe('customer:read'); + expect(scope_registry::CUSTOMER_WRITE)->toBe('customer:write'); + expect(scope_registry::BOOKING_READ)->toBe('booking:read'); + expect(scope_registry::BOOKING_WRITE)->toBe('booking:write'); + expect(scope_registry::SUBUSER_READ)->toBe('subuser:read'); + expect(scope_registry::SUBUSER_WRITE)->toBe('subuser:write'); + expect(scope_registry::INVOICE_READ)->toBe('invoice:read'); + expect(scope_registry::INVOICE_WRITE)->toBe('invoice:write'); + expect(scope_registry::SUPERUSER_READ)->toBe('superuser:read'); + expect(scope_registry::SUPERUSER_WRITE)->toBe('superuser:write'); +}); + +it('returns every concrete scope from all()', function (): void { + $all = scope_registry::all(); + expect($all)->toContain(scope_registry::CUSTOMER_READ); + expect($all)->toContain(scope_registry::CUSTOMER_WRITE); + expect($all)->toContain(scope_registry::BOOKING_READ); + expect($all)->toContain(scope_registry::BOOKING_WRITE); + expect($all)->toContain(scope_registry::SUBUSER_READ); + expect(scope_registry::SUBUSER_WRITE); + expect($all)->toContain(scope_registry::INVOICE_READ); + expect($all)->toContain(scope_registry::INVOICE_WRITE); + expect($all)->toContain(scope_registry::SUPERUSER_READ); + expect($all)->toContain(scope_registry::SUPERUSER_WRITE); + expect(count($all))->toBe(10); + expect(count(array_unique($all)))->toBe(10); +}); + +it('superuser defaults to the global wildcard', function (): void { + expect(scope_registry::scopesForRole('superuser'))->toBe(['*']); +}); + +it('admin defaults to all resource wildcards', function (): void { + $scopes = scope_registry::scopesForRole('admin'); + expect($scopes)->toContain('customer:*'); + expect($scopes)->toContain('booking:*'); + expect($scopes)->toContain('subuser:*'); + expect($scopes)->toContain('invoice:*'); + expect($scopes)->not->toContain('superuser:*'); +}); + +it('customer defaults to read-only on self resources', function (): void { + $scopes = scope_registry::scopesForRole('customer'); + expect($scopes)->toBe([ + scope_registry::CUSTOMER_READ, + scope_registry::BOOKING_READ, + scope_registry::INVOICE_READ, + ]); +}); + +it('subuser defaults to booking read+write on assigned bookings', function (): void { + $scopes = scope_registry::scopesForRole('subuser'); + expect($scopes)->toBe([ + scope_registry::BOOKING_READ, + scope_registry::BOOKING_WRITE, + ]); +}); + +it('unknown roles default to no scopes', function (): void { + expect(scope_registry::scopesForRole('nope'))->toBe([]); + expect(scope_registry::scopesForRole(''))->toBe([]); + expect(scope_registry::scopesForRole('SuperUser'))->toBe(['*']); // case-insensitive +}); + +it('hasScope matches an exact scope against itself', function (): void { + expect(scope_registry::hasScope([scope_registry::BOOKING_READ], scope_registry::BOOKING_READ))->toBeTrue(); +}); + +it('hasScope rejects an exact scope against a different scope', function (): void { + expect(scope_registry::hasScope([scope_registry::BOOKING_READ], scope_registry::BOOKING_WRITE))->toBeFalse(); +}); + +it('hasScope lets a wildcard match any concrete scope', function (): void { + expect(scope_registry::hasScope(['*'], scope_registry::INVOICE_READ))->toBeTrue(); + expect(scope_registry::hasScope(['*'], scope_registry::SUPERUSER_WRITE))->toBeTrue(); +}); + +it('hasScope resolves a resource wildcard to that resource only', function (): void { + expect(scope_registry::hasScope(['customer:*'], scope_registry::CUSTOMER_READ))->toBeTrue(); + expect(scope_registry::hasScope(['customer:*'], scope_registry::CUSTOMER_WRITE))->toBeTrue(); + expect(scope_registry::hasScope(['customer:*'], scope_registry::BOOKING_READ))->toBeFalse(); +}); + +it('hasScope returns false on empty input', function (): void { + expect(scope_registry::hasScope([], 'booking:read'))->toBeFalse(); + expect(scope_registry::hasScope(['booking:read'], ''))->toBeFalse(); +}); + +it('hasScope ignores non-string granted entries', function (): void { + expect(scope_registry::hasScope([null, 123, 'booking:read'], 'booking:read'))->toBeTrue(); + expect(scope_registry::hasScope([null, 123], 'booking:read'))->toBeFalse(); +}); + +it('expand flattens a single wildcard to all concrete scopes', function (): void { + $expanded = scope_registry::expand(['*']); + expect(count($expanded))->toBe(10); + expect($expanded)->toContain(scope_registry::BOOKING_READ); + expect($expanded)->toContain(scope_registry::SUPERUSER_WRITE); +}); + +it('expand flattens resource wildcards', function (): void { + $expanded = scope_registry::expand(['booking:*']); + expect($expanded)->toBe([ + scope_registry::BOOKING_READ, + scope_registry::BOOKING_WRITE, + ]); +}); + +it('expand deduplicates results', function (): void { + $expanded = scope_registry::expand([ + 'booking:*', + scope_registry::BOOKING_READ, + 'booking:write', + ]); + expect($expanded)->toBe([ + scope_registry::BOOKING_READ, + scope_registry::BOOKING_WRITE, + ]); +}); + +it('expand drops unknown concrete scopes (no silent grant)', function (): void { + $expanded = scope_registry::expand(['booking:read', 'totally:made-up']); + expect($expanded)->toBe([scope_registry::BOOKING_READ]); +}); + +it('expand combines multiple wildcards and concrete scopes', function (): void { + $expanded = scope_registry::expand([ + scope_registry::BOOKING_READ, + 'customer:*', + ]); + expect($expanded)->toContain(scope_registry::BOOKING_READ); + expect($expanded)->toContain(scope_registry::CUSTOMER_READ); + expect($expanded)->toContain(scope_registry::CUSTOMER_WRITE); + expect(count($expanded))->toBe(3); +}); + +it('expand ignores empty and non-string entries', function (): void { + $expanded = scope_registry::expand([null, '', ' ', scope_registry::BOOKING_READ]); + expect($expanded)->toBe([scope_registry::BOOKING_READ]); +}); + +it('superuser role resolves to all scopes via expand', function (): void { + $expanded = scope_registry::expand(scope_registry::scopesForRole('superuser')); + expect(count($expanded))->toBe(10); +}); + +it('admin role expands to all non-superuser scopes', function (): void { + $expanded = scope_registry::expand(scope_registry::scopesForRole('admin')); + expect($expanded)->toContain(scope_registry::CUSTOMER_READ); + expect($expanded)->toContain(scope_registry::CUSTOMER_WRITE); + expect($expanded)->toContain(scope_registry::BOOKING_READ); + expect($expanded)->toContain(scope_registry::BOOKING_WRITE); + expect($expanded)->toContain(scope_registry::SUBUSER_READ); + expect($expanded)->toContain(scope_registry::SUBUSER_WRITE); + expect($expanded)->toContain(scope_registry::INVOICE_READ); + expect($expanded)->toContain(scope_registry::INVOICE_WRITE); + expect($expanded)->not->toContain(scope_registry::SUPERUSER_READ); + expect($expanded)->not->toContain(scope_registry::SUPERUSER_WRITE); + expect(count($expanded))->toBe(8); +}); + +it('customer role does not gain write or subuser scopes', function (): void { + $expanded = scope_registry::expand(scope_registry::scopesForRole('customer')); + expect($expanded)->not->toContain(scope_registry::CUSTOMER_WRITE); + expect($expanded)->not->toContain(scope_registry::BOOKING_WRITE); + expect($expanded)->not->toContain(scope_registry::SUBUSER_READ); + expect($expanded)->not->toContain(scope_registry::INVOICE_WRITE); +}); + +it('isValid accepts canonical scopes, wildcards, and resource wildcards', function (): void { + expect(scope_registry::isValid('*'))->toBeTrue(); + expect(scope_registry::isValid('customer:*'))->toBeTrue(); + expect(scope_registry::isValid(scope_registry::BOOKING_READ))->toBeTrue(); + expect(scope_registry::isValid('totally:made-up'))->toBeFalse(); + expect(scope_registry::isValid(''))->toBeFalse(); + expect(scope_registry::isValid(' '))->toBeFalse(); + expect(scope_registry::isValid('unknown:*'))->toBeFalse(); +}); + +it('role default + hasScope composes correctly for customer:read on customer role', function (): void { + $granted = scope_registry::scopesForRole('customer'); + expect(scope_registry::hasScope($granted, scope_registry::CUSTOMER_READ))->toBeTrue(); + expect(scope_registry::hasScope($granted, scope_registry::CUSTOMER_WRITE))->toBeFalse(); + expect(scope_registry::hasScope($granted, scope_registry::SUBUSER_READ))->toBeFalse(); +});