Merge branch 'master' into feat/TRU-149-route-scopes

This commit is contained in:
Jeppe B
2026-08-17 13:55:32 +02:00
committed by GitHub
8 changed files with 1473 additions and 0 deletions
@@ -0,0 +1,227 @@
<?php
namespace classes;
/**
* Static utility for generating, formatting, hashing, and parsing
* API keys.
*
* Key format: <prefix>_<env>_<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: <prefix>_<env>_<random>.
*/
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 "<prefix>_<env>_<random>" — 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;
}
}
@@ -0,0 +1,249 @@
<?php
namespace classes;
use Exception;
use Throwable;
/**
* Repository for the `api_keys` table.
*
* This is a thin procedural wrapper that uses the project's existing
* `$db` global (mysqli) — no Eloquent, no ORM. The pattern matches
* other repositories in this codebase (see `classes/orders_o.php`,
* `classes/invoice_store.php`, etc.).
*
* Records are returned as associative arrays. The caller is expected
* to interact with them as plain dicts; there is no dedicated model
* class for api keys.
*/
class api_key_repository
{
public const TABLE = 'api_keys';
private static function db()
{
global $db;
if (!isset($db) || !is_object($db)) {
throw new Exception('Database connection ($db) is not available');
}
// Lazy-create the table on first use so callers don't have to
// remember to call ensureTables().
if (class_exists(api_key_schema_bootstrap::class)) {
api_key_schema_bootstrap::ensureTables();
}
return $db;
}
/**
* Validate the input data for create(). Exposed so test doubles
* can exercise the same validation without touching a real DB.
*
* @param array<string, mixed> $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<string, mixed> $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<string, mixed>|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<string, mixed>|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<int, array<string, mixed>>
*/
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;
}
}
@@ -0,0 +1,92 @@
<?php
namespace classes;
/**
* Schema bootstrap for the api_keys table.
*
* This codebase does NOT use a migration framework; new tables are
* added via `*_schema_bootstrap.php` files that run idempotent
* `CREATE TABLE IF NOT EXISTS` statements on first use. The companion
* SQL file at `database/migrations/<TIMESTAMP>_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;
}
}
}
@@ -0,0 +1,221 @@
<?php
namespace classes\auth;
/**
* Scope registry: the source of truth for API key scopes and
* role → scope defaults.
*
* This class is the canonical implementation that the parallel
* `app\auth\Scope` stub (introduced by TRU-149 / branch
* feat/TRU-149-route-scopes) will be replaced with once
* `feat/api-key-foundation` is merged. Until then the two can
* coexist; the middleware in `scope_middleware.php` continues
* to use the legacy stub.
*
* Scopes follow a "resource:action" pattern (e.g. `booking:read`).
* Two wildcard forms are recognised:
* - `*` — matches every scope.
* - `resource:*` — matches every action on a resource.
*
* Role defaults:
* - superuser: every scope (via "*" wildcard).
* - admin: customer:*, booking:*, subuser:*, invoice:*
* - customer: customer:read, booking:read, invoice:read
* - subuser: booking:read, booking:write
*
* The "self" / "assigned" qualifiers from the spec are *enforcement
* layer* concerns, not scope concerns — they live in the resolver
* that maps an authenticated principal to a customer/subuser record.
* Scopes only encode "can the caller read bookings at all", not
* "which bookings".
*/
final class scope_registry
{
// --- Customer resource ---
public const CUSTOMER_READ = 'customer:read';
public const CUSTOMER_WRITE = 'customer:write';
// --- Booking resource ---
public const BOOKING_READ = 'booking:read';
public const BOOKING_WRITE = 'booking:write';
// --- Subuser resource ---
public const SUBUSER_READ = 'subuser:read';
public const SUBUSER_WRITE = 'subuser:write';
// --- Invoice resource ---
public const INVOICE_READ = 'invoice:read';
public const INVOICE_WRITE = 'invoice:write';
// --- Superuser / admin resource ---
public const SUPERUSER_READ = 'superuser:read';
public const SUPERUSER_WRITE = 'superuser:write';
/**
* Canonical list of every concrete scope (no wildcards).
*
* @return array<int, string>
*/
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<int, string>
*/
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<int, string> $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<int, string> $scopes
* @return array<int, string>
*/
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 "<resource>:*" 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);
}
}
@@ -0,0 +1,48 @@
<?php
/**
* Migration: create_api_keys_table
* Issue: TRU-143 — [Backend] API key data model + storage schema
* Date: 2026-08-17
*
* NOTE: This codebase does not run a migration framework; the
* canonical DDL is applied idempotently at runtime by
* `classes/api_key_schema_bootstrap.php`. This file is the
* human-readable change record / source of truth for the schema.
*
* To apply manually:
* mysql -u <user> -p <database> < 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',
],
];
@@ -0,0 +1,127 @@
<?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();
});
@@ -0,0 +1,319 @@
<?php
use classes\api_key_repository;
use classes\api_key_generator;
use classes\api_key_schema_bootstrap;
/**
* Fake mysqli stmt used by api_key_repository unit tests. We mimic
* just enough of the surface area (`bind_param`, `execute`,
* `get_result`, `close`, `insert_id`, `affected_rows`, `error`) to
* exercise the repository without a real database.
*/
if (!class_exists('ApiKeyRepositoryFakeStmt')) {
class ApiKeyRepositoryFakeStmt
{
public string $lastSql = '';
/** @var array<int, mixed> */
public array $params = [];
public ?int $insertId = null;
public int $affectedRows = 0;
public string $error = '';
public bool $executeResult = true;
/** @var array<int, array<string, mixed>>|null */
public ?array $rowsToReturn = null;
/** @var array<string, string> */
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<string, mixed>, fetch_all(int): array<int, array<string, mixed>> }
*/
public function get_result(): object
{
$rows = $this->rowsToReturn ?? [];
return new class($rows) {
/** @param array<int, array<string, mixed>> $rows */
public function __construct(private array $rows)
{
}
public function fetch_assoc(): ?array
{
return $this->rows[0] ?? null;
}
/** @return array<int, array<string, mixed>> */
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<int, array<string, mixed>> */
public array $insertedRows = [];
public int $nextInsertId = 100;
/** @var array<int, array<string, mixed>> */
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<int, array<string, mixed>> */
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();
});
@@ -0,0 +1,190 @@
<?php
use classes\auth\scope_registry;
it('exposes the canonical scope constants', function (): void {
expect(scope_registry::CUSTOMER_READ)->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();
});