added unit tests
This commit is contained in:
109
tests/Unit/ApiLogTest.php
Normal file
109
tests/Unit/ApiLogTest.php
Normal file
@ -0,0 +1,109 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Unit;
|
||||
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
final class ApiLogTest extends TestCase
|
||||
{
|
||||
private string $logDir;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
require_once dirname(__DIR__, 2) . '/lib/api_log.php';
|
||||
$this->logDir = $_ENV['QDB_API_LOG_DIR'] ?? (QDB_TEST_UPLOADS . '/logs/api');
|
||||
if (!is_dir($this->logDir)) {
|
||||
mkdir($this->logDir, 0775, true);
|
||||
}
|
||||
$this->clearLogFiles();
|
||||
unset($GLOBALS['qdb_api_log_ctx']);
|
||||
}
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
$this->clearLogFiles();
|
||||
unset($GLOBALS['qdb_api_log_ctx']);
|
||||
}
|
||||
|
||||
public function testClassifyActivity(): void
|
||||
{
|
||||
$this->assertNull(qdb_api_log_classify_activity('OPTIONS', 'web', 'users'));
|
||||
$this->assertSame('web_change', qdb_api_log_classify_activity('POST', 'web', 'users'));
|
||||
$this->assertSame('app_change', qdb_api_log_classify_activity('POST', '', 'app_questionnaires'));
|
||||
$this->assertSame('app_sync', qdb_api_log_classify_activity('GET', '', 'app_questionnaires'));
|
||||
$this->assertSame('web_export', qdb_api_log_classify_activity('GET', 'web', 'export'));
|
||||
$this->assertSame('web_change', qdb_api_log_classify_activity('POST', 'web', 'backup'));
|
||||
$this->assertTrue(qdb_api_log_is_website_download('backup'));
|
||||
}
|
||||
|
||||
public function testRedactSensitiveFields(): void
|
||||
{
|
||||
$redacted = qdb_api_log_redact_value([
|
||||
'username' => 'coach1',
|
||||
'password' => 'secret',
|
||||
'encrypted' => str_repeat('x', 40),
|
||||
]);
|
||||
$this->assertSame('coach1', $redacted['username']);
|
||||
$this->assertSame('[REDACTED]', $redacted['password']);
|
||||
$this->assertSame('[ENCRYPTED:40 chars]', $redacted['encrypted']);
|
||||
}
|
||||
|
||||
public function testRedactQueryString(): void
|
||||
{
|
||||
$qs = qdb_api_log_redact_query_string('questionnaireID=qn1&token=abc&password=pw');
|
||||
parse_str((string)$qs, $params);
|
||||
$this->assertSame('qn1', $params['questionnaireID'] ?? null);
|
||||
$this->assertSame('[REDACTED]', $params['token'] ?? null);
|
||||
$this->assertSame('[REDACTED]', $params['password'] ?? null);
|
||||
}
|
||||
|
||||
public function testTokenHintMasksMiddle(): void
|
||||
{
|
||||
$hint = qdb_api_log_token_hint('0123456789abcdef');
|
||||
$this->assertStringContainsString('…', $hint);
|
||||
$this->assertStringNotContainsString('56789', $hint);
|
||||
}
|
||||
|
||||
public function testClientIpUsesForwardedFor(): void
|
||||
{
|
||||
$_SERVER['HTTP_X_FORWARDED_FOR'] = '203.0.113.50, 198.51.100.1';
|
||||
$_SERVER['REMOTE_ADDR'] = '127.0.0.1';
|
||||
$this->assertSame('203.0.113.50', qdb_api_log_client_ip());
|
||||
unset($_SERVER['HTTP_X_FORWARDED_FOR']);
|
||||
}
|
||||
|
||||
public function testBeginFinishWritesWebChangeEntry(): void
|
||||
{
|
||||
$_SERVER['HTTP_X_QDB_CLIENT'] = 'web';
|
||||
$_SERVER['REQUEST_URI'] = '/api/users';
|
||||
$_SERVER['QUERY_STRING'] = '';
|
||||
qdb_test_set_request_body(json_encode(['username' => 'new_coach'], JSON_THROW_ON_ERROR));
|
||||
http_response_code(200);
|
||||
|
||||
qdb_api_log_begin('POST', 'users');
|
||||
qdb_api_log_finish();
|
||||
|
||||
$date = date('Y-m-d');
|
||||
$read = qdb_api_log_read_entries($date, 'web_change', 10);
|
||||
$entries = $read['entries'];
|
||||
$this->assertNotEmpty($entries);
|
||||
$last = $entries[0];
|
||||
$this->assertSame('web_change', $last['activity']);
|
||||
$this->assertSame('POST', $last['method']);
|
||||
$this->assertSame('users', $last['route']);
|
||||
$changes = $last['changes'] ?? [];
|
||||
$fields = array_column($changes, 'field');
|
||||
$this->assertContains('username', $fields);
|
||||
|
||||
qdb_test_reset_http_state();
|
||||
}
|
||||
|
||||
private function clearLogFiles(): void
|
||||
{
|
||||
foreach (glob($this->logDir . '/api-*.log') ?: [] as $path) {
|
||||
@unlink($path);
|
||||
}
|
||||
}
|
||||
}
|
||||
65
tests/Unit/AppAnswersTest.php
Normal file
65
tests/Unit/AppAnswersTest.php
Normal file
@ -0,0 +1,65 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Unit;
|
||||
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
final class AppAnswersTest extends TestCase
|
||||
{
|
||||
protected function setUp(): void
|
||||
{
|
||||
require_once dirname(__DIR__, 2) . '/lib/app_answers.php';
|
||||
}
|
||||
|
||||
public function testEncodeMultiCheckDedupesAndSortsKeys(): void
|
||||
{
|
||||
$json = qdb_encode_multi_check_values(['b', 'a', 'b', '']);
|
||||
$decoded = json_decode($json, true, 512, JSON_THROW_ON_ERROR);
|
||||
$this->assertEqualsCanonicalizing(['a', 'b'], $decoded);
|
||||
}
|
||||
|
||||
public function testDecodeMultiCheckFromJsonArray(): void
|
||||
{
|
||||
$this->assertSame(['x', 'y'], qdb_decode_multi_check_values('["x","y"]'));
|
||||
}
|
||||
|
||||
public function testDecodeMultiCheckFromCommaSeparated(): void
|
||||
{
|
||||
$this->assertSame(['one', 'two'], qdb_decode_multi_check_values('one, two'));
|
||||
}
|
||||
|
||||
public function testGroupAppSubmitAnswersMergesMultiCheck(): void
|
||||
{
|
||||
$answers = [
|
||||
['questionID' => 'q1', 'answerOptionKey' => 'opt_a', 'answeredAt' => 100],
|
||||
['questionID' => 'q1', 'answerOptionKey' => 'opt_b', 'answeredAt' => 100],
|
||||
['questionID' => 'q2', 'freeTextValue' => 'hello'],
|
||||
];
|
||||
$grouped = qdb_group_app_submit_answers(
|
||||
$answers,
|
||||
['q1' => 'multi_check_box_question', 'q2' => 'text_question'],
|
||||
[]
|
||||
);
|
||||
$this->assertCount(2, $grouped);
|
||||
$this->assertSame('q1', $grouped[0]['questionID']);
|
||||
$decoded = json_decode((string)$grouped[0]['freeTextValue'], true, 512, JSON_THROW_ON_ERROR);
|
||||
$this->assertEqualsCanonicalizing(['opt_a', 'opt_b'], $decoded);
|
||||
$this->assertSame('hello', $grouped[1]['freeTextValue']);
|
||||
}
|
||||
|
||||
public function testGroupAppSubmitAnswersPreservesSymptomRows(): void
|
||||
{
|
||||
$answers = [
|
||||
['questionID' => 'fatigue', 'answerOptionKey' => 'moderate'],
|
||||
];
|
||||
$grouped = qdb_group_app_submit_answers(
|
||||
$answers,
|
||||
['fatigue' => 'glass_scale_question'],
|
||||
['fatigue' => 'glass_parent']
|
||||
);
|
||||
$this->assertCount(1, $grouped);
|
||||
$this->assertSame('moderate', $grouped[0]['answerOptionKey']);
|
||||
}
|
||||
}
|
||||
109
tests/Unit/AppSubmitValidateTest.php
Normal file
109
tests/Unit/AppSubmitValidateTest.php
Normal file
@ -0,0 +1,109 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Unit;
|
||||
|
||||
use Tests\Support\QdbTestCase;
|
||||
|
||||
final class AppSubmitValidateTest extends QdbTestCase
|
||||
{
|
||||
public function testEmptyAnswersRejected(): void
|
||||
{
|
||||
require_once dirname(__DIR__, 2) . '/lib/app_submit_validate.php';
|
||||
[$pdo, $tmpDb, $lockFp] = qdb_open(false);
|
||||
$f = $this->fixture();
|
||||
$errors = qdb_validate_app_submit_payload($pdo, $f->questionnaireId, [], [], [], [], []);
|
||||
qdb_discard($tmpDb, $lockFp);
|
||||
$this->assertNotEmpty($errors);
|
||||
$this->assertSame('ANSWERS_REQUIRED', $errors[0]['code']);
|
||||
}
|
||||
|
||||
public function testUnknownQuestionRejected(): void
|
||||
{
|
||||
require_once dirname(__DIR__, 2) . '/lib/app_submit_validate.php';
|
||||
[$pdo, $tmpDb, $lockFp] = qdb_open(false);
|
||||
$f = $this->fixture();
|
||||
$errors = qdb_validate_app_submit_payload(
|
||||
$pdo,
|
||||
$f->questionnaireId,
|
||||
[['questionID' => 'nope', 'answerOptionKey' => 'x']],
|
||||
[],
|
||||
[],
|
||||
[],
|
||||
[]
|
||||
);
|
||||
qdb_discard($tmpDb, $lockFp);
|
||||
$this->assertSame('UNKNOWN_QUESTION', $errors[0]['code'] ?? '');
|
||||
}
|
||||
|
||||
public function testValidSingleChoiceWithOptionKeyPasses(): void
|
||||
{
|
||||
require_once dirname(__DIR__, 2) . '/lib/app_submit_validate.php';
|
||||
[$pdo, $tmpDb, $lockFp] = qdb_open(false);
|
||||
$f = $this->fixture();
|
||||
$shortMap = [$f->questionShortId => $f->questionId];
|
||||
$typeMap = [$f->questionShortId => 'single_choice_question'];
|
||||
$optMap = [
|
||||
$f->questionId => [
|
||||
'yes' => ['answerOptionID' => $f->optionId, 'points' => 0],
|
||||
],
|
||||
];
|
||||
$errors = qdb_validate_app_submit_payload(
|
||||
$pdo,
|
||||
$f->questionnaireId,
|
||||
[['questionID' => $f->questionShortId, 'answerOptionKey' => 'yes']],
|
||||
$shortMap,
|
||||
$typeMap,
|
||||
[],
|
||||
$optMap,
|
||||
);
|
||||
qdb_discard($tmpDb, $lockFp);
|
||||
$this->assertSame([], $errors);
|
||||
}
|
||||
|
||||
public function testMultiCheckRequiresSelection(): void
|
||||
{
|
||||
require_once dirname(__DIR__, 2) . '/lib/app_submit_validate.php';
|
||||
[$pdo, $tmpDb, $lockFp] = qdb_open(false);
|
||||
$f = $this->fixture();
|
||||
$shortMap = [$f->multiCheckShortId => $f->multiCheckQuestionId];
|
||||
$typeMap = [$f->multiCheckShortId => 'multi_check_box_question'];
|
||||
$errors = qdb_validate_app_submit_payload(
|
||||
$pdo,
|
||||
$f->questionnaireId,
|
||||
[['questionID' => $f->multiCheckShortId, 'freeTextValue' => '']],
|
||||
$shortMap,
|
||||
$typeMap,
|
||||
[],
|
||||
[],
|
||||
);
|
||||
qdb_discard($tmpDb, $lockFp);
|
||||
$this->assertSame('EMPTY_ANSWER', $errors[0]['code'] ?? '');
|
||||
}
|
||||
|
||||
public function testInvalidOptionKeyRejected(): void
|
||||
{
|
||||
require_once dirname(__DIR__, 2) . '/lib/app_submit_validate.php';
|
||||
[$pdo, $tmpDb, $lockFp] = qdb_open(false);
|
||||
$f = $this->fixture();
|
||||
$shortMap = [$f->questionShortId => $f->questionId];
|
||||
$typeMap = [$f->questionShortId => 'single_choice_question'];
|
||||
$optMap = [
|
||||
$f->questionId => [
|
||||
'yes' => ['answerOptionID' => $f->optionId, 'points' => 0],
|
||||
],
|
||||
];
|
||||
$errors = qdb_validate_app_submit_payload(
|
||||
$pdo,
|
||||
$f->questionnaireId,
|
||||
[['questionID' => $f->questionShortId, 'answerOptionKey' => 'No']],
|
||||
$shortMap,
|
||||
$typeMap,
|
||||
[],
|
||||
$optMap,
|
||||
);
|
||||
qdb_discard($tmpDb, $lockFp);
|
||||
$this->assertSame('INVALID_OPTION', $errors[0]['code'] ?? '');
|
||||
}
|
||||
}
|
||||
62
tests/Unit/CommonBundleTest.php
Normal file
62
tests/Unit/CommonBundleTest.php
Normal file
@ -0,0 +1,62 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Unit;
|
||||
|
||||
use Tests\Support\JsonErrorResponse;
|
||||
use Tests\Support\QdbTestCase;
|
||||
|
||||
final class CommonBundleTest extends QdbTestCase
|
||||
{
|
||||
public function testImportQuestionnairesBundleRejectsEmptyList(): void
|
||||
{
|
||||
[$pdo, $tmpDb, $lockFp] = qdb_open(true);
|
||||
try {
|
||||
qdb_import_questionnaires_bundle($pdo, ['questionnaires' => []]);
|
||||
$this->fail('Expected JsonErrorResponse');
|
||||
} catch (JsonErrorResponse $e) {
|
||||
$this->assertSame('MISSING_FIELDS', $e->errorCode);
|
||||
} finally {
|
||||
qdb_discard($tmpDb, $lockFp);
|
||||
}
|
||||
}
|
||||
|
||||
public function testImportTranslationsBundleRoundTripOnPdo(): void
|
||||
{
|
||||
[$pdo, $tmpDb, $lockFp] = qdb_open(true);
|
||||
$f = $this->fixture();
|
||||
qdb_put_translation($pdo, 'question', $f->questionId, 'en', 'Hello EN');
|
||||
qdb_save($tmpDb, $lockFp);
|
||||
|
||||
[$pdo2, $tmp2, $lock2] = qdb_open(false);
|
||||
$bundle = qdb_export_translations_bundle($pdo2);
|
||||
qdb_discard($tmp2, $lock2);
|
||||
|
||||
[$pdo3, $tmp3, $lock3] = qdb_open(true);
|
||||
$pdo3->prepare('DELETE FROM question_translation WHERE questionID = :id AND languageCode = :lc')
|
||||
->execute([':id' => $f->questionId, ':lc' => 'en']);
|
||||
$result = qdb_import_translations_bundle($pdo3, $bundle);
|
||||
qdb_save($tmp3, $lock3);
|
||||
$this->assertGreaterThan(0, $result['imported'] ?? 0);
|
||||
|
||||
[$pdo4, $tmp4, $lock4] = qdb_open(false);
|
||||
$stmt = $pdo4->prepare(
|
||||
'SELECT text FROM question_translation WHERE questionID = :id AND languageCode = :lc'
|
||||
);
|
||||
$stmt->execute([':id' => $f->questionId, ':lc' => 'en']);
|
||||
$this->assertSame('Hello EN', $stmt->fetchColumn());
|
||||
qdb_discard($tmp4, $lock4);
|
||||
}
|
||||
|
||||
public function testExportQuestionnaireBundleIncludesQuestionKeys(): void
|
||||
{
|
||||
[$pdo, $tmpDb, $lockFp] = qdb_open(false);
|
||||
$f = $this->fixture();
|
||||
$item = qdb_export_questionnaire_bundle($pdo, $f->questionnaireId);
|
||||
qdb_discard($tmpDb, $lockFp);
|
||||
$this->assertNotNull($item);
|
||||
$keys = array_column($item['questions'], 'questionKey');
|
||||
$this->assertContains('consent_q', $keys);
|
||||
}
|
||||
}
|
||||
56
tests/Unit/CommonHelpersTest.php
Normal file
56
tests/Unit/CommonHelpersTest.php
Normal file
@ -0,0 +1,56 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Unit;
|
||||
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
final class CommonHelpersTest extends TestCase
|
||||
{
|
||||
protected function setUp(): void
|
||||
{
|
||||
require_once dirname(__DIR__, 2) . '/common.php';
|
||||
}
|
||||
|
||||
public function testIsStableKey(): void
|
||||
{
|
||||
$this->assertTrue(qdb_is_stable_key('question_key'));
|
||||
$this->assertTrue(qdb_is_stable_key('yes'));
|
||||
$this->assertFalse(qdb_is_stable_key('Question Key'));
|
||||
$this->assertFalse(qdb_is_stable_key(''));
|
||||
}
|
||||
|
||||
public function testNormalizeStableKeyCoercesSpaces(): void
|
||||
{
|
||||
$this->assertSame('My_Key', qdb_normalize_stable_key('My Key'));
|
||||
$this->assertSame('consent_q', qdb_normalize_stable_key('consent_q'));
|
||||
}
|
||||
|
||||
public function testValidateStableKeyReturnsNormalized(): void
|
||||
{
|
||||
$this->assertSame('My_Key', qdb_validate_stable_key('My Key', 'Question key'));
|
||||
}
|
||||
|
||||
public function testMessageKeyFromConditionJson(): void
|
||||
{
|
||||
$json = json_encode(['messageKey' => 'locked_hint'], JSON_THROW_ON_ERROR);
|
||||
$this->assertSame('locked_hint', qdb_message_key_from_condition_json($json));
|
||||
$this->assertSame('', qdb_message_key_from_condition_json('{}'));
|
||||
}
|
||||
|
||||
public function testMergeGlassSymptomJson(): void
|
||||
{
|
||||
$merged = qdb_merge_glass_symptom_json('{"pain":"mild"}', ['fatigue' => 'severe']);
|
||||
$data = json_decode($merged, true, 512, JSON_THROW_ON_ERROR);
|
||||
$this->assertSame('mild', $data['pain']);
|
||||
$this->assertSame('severe', $data['fatigue']);
|
||||
}
|
||||
|
||||
public function testGlassSymptomAnswerValue(): void
|
||||
{
|
||||
$json = json_encode(['pain' => 'moderate'], JSON_THROW_ON_ERROR);
|
||||
$this->assertSame('moderate', qdb_glass_symptom_answer_value($json, 'pain'));
|
||||
$this->assertSame('', qdb_glass_symptom_answer_value($json, 'missing'));
|
||||
}
|
||||
}
|
||||
53
tests/Unit/CryptoTest.php
Normal file
53
tests/Unit/CryptoTest.php
Normal file
@ -0,0 +1,53 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Unit;
|
||||
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
final class CryptoTest extends TestCase
|
||||
{
|
||||
public function testAesRoundTrip(): void
|
||||
{
|
||||
$key = str_repeat('K', 32);
|
||||
$plain = '{"clients":[{"clientCode":"C1"}]}';
|
||||
$enc = aes256_cbc_encrypt_bytes($plain, $key);
|
||||
$this->assertGreaterThan(16, strlen($enc));
|
||||
$dec = aes256_cbc_decrypt_bytes($enc, $key);
|
||||
$this->assertSame($plain, $dec);
|
||||
}
|
||||
|
||||
public function testHkdfDeterministicForToken(): void
|
||||
{
|
||||
$token = bin2hex(random_bytes(16));
|
||||
$k1 = hkdf_session_key_from_token($token);
|
||||
$k2 = hkdf_session_key_from_token($token);
|
||||
$this->assertSame(32, strlen($k1));
|
||||
$this->assertSame($k1, $k2);
|
||||
}
|
||||
|
||||
public function testHkdfDiffersForDifferentTokens(): void
|
||||
{
|
||||
$a = hkdf_session_key_from_token(bin2hex(random_bytes(16)));
|
||||
$b = hkdf_session_key_from_token(bin2hex(random_bytes(16)));
|
||||
$this->assertNotSame($a, $b);
|
||||
}
|
||||
|
||||
public function testDecryptFailsWithWrongKey(): void
|
||||
{
|
||||
$key = str_repeat('K', 32);
|
||||
$enc = aes256_cbc_encrypt_bytes('payload', $key);
|
||||
$this->expectException(\Exception::class);
|
||||
aes256_cbc_decrypt_bytes($enc, str_repeat('X', 32));
|
||||
}
|
||||
|
||||
public function testDecryptFailsWhenCiphertextTampered(): void
|
||||
{
|
||||
$key = str_repeat('K', 32);
|
||||
$enc = aes256_cbc_encrypt_bytes('payload', $key);
|
||||
$enc[20] = $enc[20] === 'a' ? 'b' : 'a';
|
||||
$this->expectException(\Exception::class);
|
||||
aes256_cbc_decrypt_bytes($enc, $key);
|
||||
}
|
||||
}
|
||||
37
tests/Unit/EncryptedPayloadTest.php
Normal file
37
tests/Unit/EncryptedPayloadTest.php
Normal file
@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Unit;
|
||||
|
||||
use InvalidArgumentException;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
final class EncryptedPayloadTest extends TestCase
|
||||
{
|
||||
public function testEnvelopeRoundTrip(): void
|
||||
{
|
||||
require_once dirname(__DIR__, 2) . '/lib/encrypted_payload.php';
|
||||
$token = bin2hex(random_bytes(32));
|
||||
$json = '{"clientCode":"X","answers":[]}';
|
||||
$env = qdb_sensitive_envelope($json, $token);
|
||||
$this->assertTrue($env['encrypted']);
|
||||
$plain = qdb_decrypt_sensitive_envelope($env, $token);
|
||||
$this->assertSame($json, $plain);
|
||||
}
|
||||
|
||||
public function testWrongTokenFailsDecrypt(): void
|
||||
{
|
||||
require_once dirname(__DIR__, 2) . '/lib/encrypted_payload.php';
|
||||
$env = qdb_sensitive_envelope('{}', bin2hex(random_bytes(32)));
|
||||
$this->expectException(\Exception::class);
|
||||
qdb_decrypt_sensitive_envelope($env, bin2hex(random_bytes(32)));
|
||||
}
|
||||
|
||||
public function testInvalidEnvelopeRejected(): void
|
||||
{
|
||||
require_once dirname(__DIR__, 2) . '/lib/encrypted_payload.php';
|
||||
$this->expectException(InvalidArgumentException::class);
|
||||
qdb_decrypt_sensitive_envelope(['encrypted' => false], bin2hex(random_bytes(8)));
|
||||
}
|
||||
}
|
||||
56
tests/Unit/ErrorsTest.php
Normal file
56
tests/Unit/ErrorsTest.php
Normal file
@ -0,0 +1,56 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Unit;
|
||||
|
||||
use Exception;
|
||||
use PDOException;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
final class ErrorsTest extends TestCase
|
||||
{
|
||||
protected function setUp(): void
|
||||
{
|
||||
require_once dirname(__DIR__, 2) . '/lib/errors.php';
|
||||
}
|
||||
|
||||
public function testLockExceptionDetection(): void
|
||||
{
|
||||
$this->assertTrue(qdb_is_lock_exception(new Exception('Could not acquire lock')));
|
||||
$this->assertTrue(qdb_is_lock_exception(new Exception('Could not open lock file')));
|
||||
$this->assertFalse(qdb_is_lock_exception(new Exception('other')));
|
||||
}
|
||||
|
||||
public function testPublicErrorMessageForLock(): void
|
||||
{
|
||||
$msg = qdb_public_error_message(new Exception('Could not acquire lock'), 'Save');
|
||||
$this->assertStringContainsString('busy', strtolower($msg));
|
||||
}
|
||||
|
||||
public function testPublicErrorMessageForDecrypt(): void
|
||||
{
|
||||
$msg = qdb_public_error_message(new Exception('decrypt failed'), 'Open');
|
||||
$this->assertStringContainsString('configuration', strtolower($msg));
|
||||
}
|
||||
|
||||
public function testPublicErrorMessageForPdo(): void
|
||||
{
|
||||
$msg = qdb_public_error_message(new PDOException('SQLITE_CONSTRAINT'), 'Query');
|
||||
$this->assertStringContainsString('database error', strtolower($msg));
|
||||
}
|
||||
|
||||
public function testApiErrorForLockUses503(): void
|
||||
{
|
||||
[$code, , $status] = qdb_api_error_for_exception(new Exception('Could not acquire lock'), 'Write');
|
||||
$this->assertSame('LOCKED', $code);
|
||||
$this->assertSame(503, $status);
|
||||
}
|
||||
|
||||
public function testApiErrorForGenericUses500(): void
|
||||
{
|
||||
[$code, , $status] = qdb_api_error_for_exception(new Exception('boom'), 'Op');
|
||||
$this->assertSame('SERVER_ERROR', $code);
|
||||
$this->assertSame(500, $status);
|
||||
}
|
||||
}
|
||||
58
tests/Unit/LoginRateLimitTest.php
Normal file
58
tests/Unit/LoginRateLimitTest.php
Normal file
@ -0,0 +1,58 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Unit;
|
||||
|
||||
use Tests\Support\QdbTestCase;
|
||||
|
||||
final class LoginRateLimitTest extends QdbTestCase
|
||||
{
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
require_once dirname(__DIR__, 2) . '/lib/login_rate_limit.php';
|
||||
$_SERVER['REMOTE_ADDR'] = '10.0.0.50';
|
||||
}
|
||||
|
||||
public function testAllowsUnderLimit(): void
|
||||
{
|
||||
$settings = [
|
||||
'login_max_attempts' => 3,
|
||||
'login_window_seconds' => 300,
|
||||
'login_lockout_seconds' => 600,
|
||||
];
|
||||
[$ok, $retry] = qdb_login_rate_limit_check('user_a', $settings);
|
||||
$this->assertTrue($ok);
|
||||
$this->assertSame(0, $retry);
|
||||
}
|
||||
|
||||
public function testLocksAfterMaxFailures(): void
|
||||
{
|
||||
$settings = [
|
||||
'login_max_attempts' => 2,
|
||||
'login_window_seconds' => 300,
|
||||
'login_lockout_seconds' => 600,
|
||||
];
|
||||
$user = 'lock_test_' . bin2hex(random_bytes(4));
|
||||
qdb_login_rate_limit_record_failure($user, $settings);
|
||||
qdb_login_rate_limit_record_failure($user, $settings);
|
||||
[$ok, $retry] = qdb_login_rate_limit_check($user, $settings);
|
||||
$this->assertFalse($ok);
|
||||
$this->assertGreaterThan(0, $retry);
|
||||
}
|
||||
|
||||
public function testClearResetsCounter(): void
|
||||
{
|
||||
$settings = [
|
||||
'login_max_attempts' => 1,
|
||||
'login_window_seconds' => 300,
|
||||
'login_lockout_seconds' => 600,
|
||||
];
|
||||
$user = 'clear_test_' . bin2hex(random_bytes(4));
|
||||
qdb_login_rate_limit_record_failure($user, $settings);
|
||||
qdb_login_rate_limit_clear($user);
|
||||
[$ok,] = qdb_login_rate_limit_check($user, $settings);
|
||||
$this->assertTrue($ok);
|
||||
}
|
||||
}
|
||||
44
tests/Unit/RbacTest.php
Normal file
44
tests/Unit/RbacTest.php
Normal file
@ -0,0 +1,44 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Unit;
|
||||
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
final class RbacTest extends TestCase
|
||||
{
|
||||
public function testAdminSeesAllClients(): void
|
||||
{
|
||||
[$clause, $params] = rbac_client_filter(['role' => 'admin', 'entityID' => 'a1'], 'cl');
|
||||
$this->assertSame('1=1', $clause);
|
||||
$this->assertSame([], $params);
|
||||
}
|
||||
|
||||
public function testSupervisorFilterBindsEntity(): void
|
||||
{
|
||||
[$clause, $params] = rbac_client_filter(
|
||||
['role' => 'supervisor', 'entityID' => 'sv-99'],
|
||||
'cl'
|
||||
);
|
||||
$this->assertStringContainsString('supervisorID', $clause);
|
||||
$this->assertSame([':rbac_eid' => 'sv-99'], $params);
|
||||
}
|
||||
|
||||
public function testCoachFilterBindsCoach(): void
|
||||
{
|
||||
[$clause, $params] = rbac_client_filter(
|
||||
['role' => 'coach', 'entityID' => 'coach-1'],
|
||||
'cl'
|
||||
);
|
||||
$this->assertStringContainsString('coachID', $clause);
|
||||
$this->assertSame([':rbac_eid' => 'coach-1'], $params);
|
||||
}
|
||||
|
||||
public function testWebRolesList(): void
|
||||
{
|
||||
$this->assertContains('admin', qdb_web_roles());
|
||||
$this->assertContains('supervisor', qdb_web_roles());
|
||||
$this->assertNotContains('coach', qdb_web_roles());
|
||||
}
|
||||
}
|
||||
54
tests/Unit/SettingsTest.php
Normal file
54
tests/Unit/SettingsTest.php
Normal file
@ -0,0 +1,54 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Unit;
|
||||
|
||||
use InvalidArgumentException;
|
||||
use Tests\Support\DatabaseSeeder;
|
||||
use Tests\Support\QdbTestCase;
|
||||
|
||||
final class SettingsTest extends QdbTestCase
|
||||
{
|
||||
public function testDefaults(): void
|
||||
{
|
||||
require_once dirname(__DIR__, 2) . '/lib/settings.php';
|
||||
$d = qdb_settings_defaults();
|
||||
$this->assertSame(10, $d['login_max_attempts']);
|
||||
$this->assertSame(30 * 24 * 60 * 60, $d['session_ttl_seconds']);
|
||||
}
|
||||
|
||||
public function testValidateRejectsOutOfRange(): void
|
||||
{
|
||||
require_once dirname(__DIR__, 2) . '/lib/settings.php';
|
||||
$this->expectException(InvalidArgumentException::class);
|
||||
qdb_settings_validate_and_merge(['login_max_attempts' => 0]);
|
||||
}
|
||||
|
||||
public function testSaveAndLoadRoundTrip(): void
|
||||
{
|
||||
require_once dirname(__DIR__, 2) . '/lib/settings.php';
|
||||
[$pdo, $tmpDb, $lockFp] = qdb_open(true);
|
||||
$custom = qdb_settings_validate_and_merge([
|
||||
'login_max_attempts' => 5,
|
||||
'login_window_seconds' => 600,
|
||||
'login_lockout_seconds' => 1200,
|
||||
'session_ttl_seconds' => 86400,
|
||||
'temp_session_ttl_seconds' => 900,
|
||||
]);
|
||||
qdb_settings_save_on_pdo($pdo, $custom);
|
||||
qdb_save($tmpDb, $lockFp);
|
||||
[$pdo2, $tmp2, $lock2] = qdb_open(false);
|
||||
$loaded = qdb_settings_get_on_pdo($pdo2);
|
||||
qdb_discard($tmp2, $lock2);
|
||||
$this->assertSame(5, $loaded['login_max_attempts']);
|
||||
$this->assertSame(86400, $loaded['session_ttl_seconds']);
|
||||
}
|
||||
|
||||
public function testSessionTtlHelper(): void
|
||||
{
|
||||
require_once dirname(__DIR__, 2) . '/lib/settings.php';
|
||||
$this->assertSame(600, qdb_session_ttl_seconds(['temp_session_ttl_seconds' => 600], true));
|
||||
$this->assertSame(3600, qdb_session_ttl_seconds(['session_ttl_seconds' => 3600], false));
|
||||
}
|
||||
}
|
||||
31
tests/Unit/TextSanitizeTest.php
Normal file
31
tests/Unit/TextSanitizeTest.php
Normal file
@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Unit;
|
||||
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
final class TextSanitizeTest extends TestCase
|
||||
{
|
||||
public function testStripsSqlLikePatterns(): void
|
||||
{
|
||||
require_once dirname(__DIR__, 2) . '/lib/text_sanitize.php';
|
||||
$out = sanitize_free_text("hello'; DROP TABLE users;--");
|
||||
$this->assertStringNotContainsString('DROP TABLE', $out);
|
||||
$this->assertStringNotContainsString('--', $out);
|
||||
}
|
||||
|
||||
public function testPreservesNewlines(): void
|
||||
{
|
||||
require_once dirname(__DIR__, 2) . '/lib/text_sanitize.php';
|
||||
$out = sanitize_free_text("line1\nline2");
|
||||
$this->assertStringContainsString("\n", $out);
|
||||
}
|
||||
|
||||
public function testNonStringReturnsEmpty(): void
|
||||
{
|
||||
require_once dirname(__DIR__, 2) . '/lib/text_sanitize.php';
|
||||
$this->assertSame('', sanitize_free_text(null));
|
||||
}
|
||||
}
|
||||
55
tests/Unit/ValidateTest.php
Normal file
55
tests/Unit/ValidateTest.php
Normal file
@ -0,0 +1,55 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Unit;
|
||||
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Tests\Support\JsonErrorResponse;
|
||||
|
||||
final class ValidateTest extends TestCase
|
||||
{
|
||||
protected function setUp(): void
|
||||
{
|
||||
if (!defined('QDB_TESTING')) {
|
||||
throw new \RuntimeException('PHPUnit bootstrap must define QDB_TESTING');
|
||||
}
|
||||
require_once dirname(__DIR__, 2) . '/lib/validate.php';
|
||||
}
|
||||
|
||||
public function testRequireFieldsPasses(): void
|
||||
{
|
||||
$body = ['a' => '1', 'b' => 'x'];
|
||||
require_fields($body, ['a', 'b']);
|
||||
$this->assertSame(['a' => '1', 'b' => 'x'], $body);
|
||||
}
|
||||
|
||||
public function testRequireFieldsMissing(): void
|
||||
{
|
||||
$this->expectException(JsonErrorResponse::class);
|
||||
$this->expectExceptionMessage('Required fields');
|
||||
try {
|
||||
require_fields(['a' => ''], ['a', 'b']);
|
||||
} catch (JsonErrorResponse $e) {
|
||||
$this->assertSame('MISSING_FIELDS', $e->errorCode);
|
||||
$this->assertSame(400, $e->httpStatus);
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
|
||||
public function testValidateStringTrims(): void
|
||||
{
|
||||
$s = validate_string(' hello ', 'field', 1, 20);
|
||||
$this->assertSame('hello', $s);
|
||||
}
|
||||
|
||||
public function testValidateEnum(): void
|
||||
{
|
||||
$this->assertSame('admin', validate_enum('admin', 'role', ['admin', 'coach']));
|
||||
}
|
||||
|
||||
public function testValidateIntBounds(): void
|
||||
{
|
||||
$this->assertSame(5, validate_int('5', 'n', 1, 10));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user