Raiju/tests/agent-settings-smoke.php
2026-10-25 06:05:42 +03:00

135 lines
12 KiB
PHP

<?php
declare(strict_types=1);
// Agent settings in the AI settings: save, load, masking of keys, clamping, sections kept for older callers.
function settingsAssert(bool $condition, string $message): void {
if (!$condition) {
fwrite(STDERR, "FAIL: {$message}\n");
exit(1);
}
}
function settingsThrows(callable $work, string $contains, string $message): void {
try {
$work();
} catch (RuntimeException $e) {
settingsAssert(str_contains($e->getMessage(), $contains), $message . ' (got: ' . $e->getMessage() . ')');
return;
}
settingsAssert(false, $message . ' (no error)');
}
$root = dirname(__DIR__);
$site = sys_get_temp_dir() . '/raiju-agent-settings-' . bin2hex(random_bytes(4));
mkdir($site . '/content', 0755, true);
register_shutdown_function(static fn() => exec('rm -rf ' . escapeshellarg($site)));
foreach (['includes', 'admin', 'VERSION'] as $item) exec('cp -R ' . escapeshellarg($root . '/' . $item) . ' ' . escapeshellarg($site . '/'));
foreach (['admin/config.php', 'admin/smtp-config.php', 'includes/nav-config.php'] as $local) @unlink($site . '/' . $local);
define('SITE_LANG_DEFAULT', 'en');
$_SESSION = ['admin_role' => 'admin', 'admin_user_id' => 'u_admin', 'admin_username' => 'admin'];
require_once $site . '/includes/ai/agent/research.php';
require_once $site . '/includes/ai/agent/media.php';
const BRAVE = 'BSA-brave-secret-key-1234567890';
const PEXELS = 'pexels-secret-key-ABCDEFGH123456';
$base = ['enabled' => true, 'provider' => 'openai-compatible', 'baseUrl' => 'http://127.0.0.1:9', 'allowLocalProvider' => true, 'apiKey' => 'provider-secret-key-0000', 'chatModel' => 'gpt-4.1-mini',
'features' => ['backendAssistant' => true, 'seoTextGeneration' => true, 'imageGeneration' => true]];
$file = RAIJU_AI_SETTINGS_PATH;
$stored = static fn(): array => raijuAiReadStoredSettings(); // keys merged in, as the code reads them
// ---- Defaults ------------------------------------------------------------------------------------------
$public = raijuAiLoadSettings(true);
settingsAssert($public['agent']['enabled'] === false && $public['agent']['roles']['editor']['publish'] === false && $public['agent']['roles']['editor']['createNews'] === true, 'defaults: off, editors may not publish');
settingsAssert($public['research']['hasBraveApiKey'] === false && $public['stock']['hasPexelsApiKey'] === false && $public['research']['respectRobots'] === true, 'defaults: no keys, robots.txt respected');
// ---- Save and load ----------------------------------------------------------------------------------------
$input = $base + [
'agent' => ['enabled' => true, 'limits' => ['runsPerHour' => 0, 'maxTurns' => 999, 'maxToolCalls' => 25, 'maxCostCentsPerRun' => 50000, 'maxRuntimeSeconds' => 10, 'uploadsPer10Minutes' => 5, 'evil' => 1],
'roles' => ['editor' => ['publish' => true, 'research' => false, 'superuser' => true]], 'mediaFolder' => 'team-photos', 'creditInNews' => false, 'sourcesInNews' => true, 'extra' => 'x'],
'research' => ['braveApiKey' => BRAVE, 'allowDomains' => "News.Example.org\nhttps://www.good.test/path\nnot a domain", 'denyDomains' => ['blocked.test'], 'respectRobots' => false],
'stock' => ['pexelsApiKey' => PEXELS],
];
$saved = raijuAiSaveSettings($input);
$agent = $saved['agent'];
settingsAssert($agent['enabled'] === true && $agent['limits']['runsPerHour'] === 1 && $agent['limits']['maxTurns'] === 40 && $agent['limits']['maxToolCalls'] === 25
&& $agent['limits']['maxCostCentsPerRun'] === 10000 && $agent['limits']['maxRuntimeSeconds'] === 60 && $agent['limits']['uploadsPer10Minutes'] === 5, 'limits are clamped: ' . json_encode($agent['limits']));
settingsAssert($agent['roles']['editor']['publish'] === true && $agent['roles']['editor']['research'] === false && $agent['roles']['editor']['createPages'] === true && !isset($agent['roles']['editor']['superuser']), 'rights: only known ones, others keep their defaults');
settingsAssert($agent['mediaFolder'] === 'team-photos' && $agent['creditInNews'] === false && $agent['sourcesInNews'] === true && !isset($agent['extra']) && !isset($agent['limits']['evil']), 'only known settings are stored');
settingsAssert($saved['research']['allowDomains'] === ['news.example.org', 'good.test'] && $saved['research']['denyDomains'] === ['blocked.test'] && $saved['research']['respectRobots'] === false, 'domains are normalised: ' . json_encode($saved['research']));
settingsAssert($saved['research']['hasBraveApiKey'] === true && $saved['stock']['hasPexelsApiKey'] === true, 'the keys are reported as set');
$encoded = json_encode($saved);
settingsAssert(!str_contains($encoded, BRAVE) && !str_contains($encoded, PEXELS) && !str_contains($encoded, 'provider-secret'), 'the browser never gets a key');
$loaded = json_encode(raijuAiLoadSettings(true));
settingsAssert(!str_contains($loaded, BRAVE) && !str_contains($loaded, PEXELS) && !str_contains($loaded, 'provider-secret') && !str_contains($loaded, 'apiKey'), 'neither does a later load');
settingsAssert($stored()['research']['brave']['apiKey'] === BRAVE && $stored()['stock']['pexels']['apiKey'] === PEXELS, 'the keys are stored for the server');
// The keys live in content/ai-secrets.json; the settings file holds none and can be committed.
$secretsFile = dirname($file) . '/ai-secrets.json';
$settingsText = (string)file_get_contents($file);
settingsAssert(!str_contains($settingsText, BRAVE) && !str_contains($settingsText, PEXELS) && !str_contains($settingsText, 'provider-secret') && !str_contains($settingsText, 'apiKey'), 'the settings file holds no key');
$secretsData = json_decode((string)file_get_contents($secretsFile), true);
settingsAssert($secretsData['research']['brave']['apiKey'] === BRAVE && $secretsData['stock']['pexels']['apiKey'] === PEXELS && str_contains((string)json_encode($secretsData['providerCredentials']), 'provider-secret'), 'the secrets file holds the provider, Brave and Pexels keys');
settingsAssert(DIRECTORY_SEPARATOR === '\\' || (fileperms($secretsFile) & 0777) === 0600, 'the secrets file is for the owner only');
settingsAssert(!isset($secretsData['research']['allowDomains']) && !isset($secretsData['agent']), 'and holds nothing but keys');
// A settings file of an older version (keys inside) still works and is moved on request, once.
$legacy = json_decode($settingsText, true);
$legacy['apiKey'] = 'legacy-provider-key';
$legacy['providerCredentials']['openai-compatible']['apiKey'] = 'legacy-provider-key';
$legacy['research']['brave']['apiKey'] = 'BSA-legacy-brave-key-123';
$legacy['stock']['pexels']['apiKey'] = 'legacy-pexels-key-12345';
unlink($secretsFile);
file_put_contents($file, json_encode($legacy));
settingsAssert(raijuAiReadStoredSettings()['research']['brave']['apiKey'] === 'BSA-legacy-brave-key-123' && raijuAiLoadSettings(false)['apiKey'] === 'legacy-provider-key', 'an older file is read as before');
settingsAssert(raijuAiMigrateSecrets() === true && raijuAiMigrateSecrets() === false, 'the keys move once');
$movedText = (string)file_get_contents($file);
settingsAssert(!str_contains($movedText, 'legacy-') && !str_contains($movedText, 'BSA-legacy') && !str_contains($movedText, 'apiKey') && is_file($secretsFile), 'the settings file is clean afterwards');
settingsAssert(raijuAiLoadSettings(false)['apiKey'] === 'legacy-provider-key' && raijuAiReadStoredSettings()['stock']['pexels']['apiKey'] === 'legacy-pexels-key-12345' && raijuAiReadStoredSettings()['agent'] === $legacy['agent'], 'nothing is lost by the move');
// Restore the state the following checks expect
$restored = json_decode($movedText, true);
$restored['research']['brave']['apiKey'] = BRAVE;
$restored['stock']['pexels']['apiKey'] = PEXELS;
raijuAiWriteStoredSettings(array_replace_recursive(raijuAiReadStoredSettings(), ['research' => ['brave' => ['apiKey' => BRAVE]], 'stock' => ['pexels' => ['apiKey' => PEXELS]]]));
// The agent code reads what was saved
settingsAssert(raijuAgentSettings()['limits']['maxToolCalls'] === 25 && raijuAgentSettings()['mediaFolder'] === 'team-photos', 'raijuAgentSettings() sees the saved values');
settingsAssert(raijuAgentResearchSettings()['available'] === true && raijuAgentResearchSettings()['apiKey'] === BRAVE && raijuAgentResearchSettings()['respectRobots'] === false, 'research settings see the key');
settingsAssert(raijuAgentStockSettings()['available'] === true && raijuAgentStockSettings()['apiKey'] === PEXELS, 'stock settings see the key');
$_SESSION['admin_role'] = 'editor';
settingsAssert(raijuAgentCan('publish') === true && raijuAgentCan('research') === false && raijuAgentCan('createNews') === true, 'rights follow the saved settings');
$_SESSION['admin_role'] = 'admin';
// ---- Keys stay when the fields are empty, go when cleared ----------------------------------------------
$again = raijuAiSaveSettings($base + ['apiKey' => '', 'agent' => ['enabled' => true], 'research' => ['braveApiKey' => '', 'allowDomains' => 'news.example.org'], 'stock' => ['pexelsApiKey' => '']]);
settingsAssert($stored()['research']['brave']['apiKey'] === BRAVE && $stored()['stock']['pexels']['apiKey'] === PEXELS && $again['research']['hasBraveApiKey'] && $again['stock']['hasPexelsApiKey'], 'empty key fields keep the saved keys');
settingsAssert($again['research']['allowDomains'] === ['news.example.org'] && $again['research']['denyDomains'] === ['blocked.test'] && $again['research']['respectRobots'] === false, 'fields that were not sent keep their values');
settingsAssert($again['agent']['limits']['maxToolCalls'] === 25 && $again['agent']['roles']['editor']['publish'] === true && $again['agent']['mediaFolder'] === 'team-photos', 'agent values that were not sent stay as they were');
$replaced = raijuAiSaveSettings($base + ['apiKey' => '', 'research' => ['braveApiKey' => 'BSA-another-brave-key-999'], 'stock' => []]);
settingsAssert($stored()['research']['brave']['apiKey'] === 'BSA-another-brave-key-999' && $stored()['stock']['pexels']['apiKey'] === PEXELS, 'a new key replaces the old one, the other stays');
$cleared = raijuAiSaveSettings($base + ['apiKey' => '', 'research' => ['clearBraveApiKey' => true], 'stock' => ['clearPexelsApiKey' => true]]);
settingsAssert(($stored()['research']['brave']['apiKey'] ?? '') === '' && ($stored()['stock']['pexels']['apiKey'] ?? '') === '' && !str_contains((string)file_get_contents($secretsFile), 'BSA-') && !$cleared['research']['hasBraveApiKey'] && !$cleared['stock']['hasPexelsApiKey'], 'keys can be removed');
settingsAssert(!raijuAgentResearchSettings()['available'] && !raijuAgentStockSettings()['available'], 'without keys there is no research and no stock photos');
// ---- Sections survive callers that know nothing about them -----------------------------------------------------
raijuAiSaveSettings($base + ['apiKey' => '', 'agent' => ['enabled' => true, 'mediaFolder' => 'kept'], 'research' => ['braveApiKey' => BRAVE], 'stock' => ['pexelsApiKey' => PEXELS]]);
$before = $stored();
raijuAiSaveSettings(array_replace($base, ['apiKey' => '', 'chatModel' => 'gpt-4.1']));
$after = $stored();
settingsAssert($after['agent'] === $before['agent'] && $after['research'] === $before['research'] && $after['stock'] === $before['stock'] && $after['chatModel'] === 'gpt-4.1', 'a save without the agent sections keeps them (the classic settings form of older versions)');
// ---- Refusals ------------------------------------------------------------------------------------------------------------------
foreach ([['research' => ['braveApiKey' => 'short']], ['research' => ['braveApiKey' => 'has spaces in it 12345']], ['stock' => ['pexelsApiKey' => "line\nbreak-key-12345678"]], ['stock' => ['pexelsApiKey' => str_repeat('k', 201)]]] as $bad) {
settingsThrows(static fn() => raijuAiSaveSettings($base + ['apiKey' => ''] + $bad), 'API key', 'an invalid key is refused: ' . json_encode($bad));
}
foreach (['Bad Folder', '../up', 'UPPER', '', str_repeat('a', 41), 5] as $folder) {
settingsThrows(static fn() => raijuAiSaveSettings($base + ['apiKey' => '', 'agent' => ['mediaFolder' => $folder]]), 'media folder', 'an invalid media folder is refused: ' . json_encode($folder));
}
settingsAssert($stored()['agent']['mediaFolder'] === 'kept', 'a refused save changes nothing');
// ---- Admin status ---------------------------------------------------------------------------------------------------------------
$status = raijuAgentStatusForAdmin();
settingsAssert(is_bool($status['available']) && is_bool($status['gd']) && is_bool($status['webp']) && isset($status['limits']['maxInputTokens']) && !str_contains(json_encode($status), BRAVE), 'the status tells what the agent needs, without keys');
echo "PASS agent settings: save and load, keys masked and kept, clamping, domains, rights, sections kept for older callers, refusals\n";