Test promotion flow - TRU-149 #1
@@ -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();
|
||||
});
|
||||
Reference in New Issue
Block a user