update.sh meldet nach dem Neustart ein Event (POST /api/v1/events/system, Admin-Key). upsertPullRequest markiert bereits vorhandene Tickets als Pull Request, damit sie den /pulls/-Link bekommen. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
514 lines
19 KiB
PHP
514 lines
19 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace Monitor\Api\Tests;
|
|
|
|
use Monitor\Api\Application;
|
|
use Monitor\Api\Config;
|
|
use Monitor\Api\Db\Database;
|
|
use Monitor\Api\Http\Request;
|
|
use PHPUnit\Framework\TestCase;
|
|
|
|
final class ApplicationTest extends TestCase
|
|
{
|
|
private Application $app;
|
|
|
|
protected function setUp(): void
|
|
{
|
|
$config = new Config(['MONITOR_ADMIN_API_KEY' => 'admin-secret', 'MONITOR_STATUS_FILE' => '/does/not/exist']);
|
|
$this->app = new Application(Database::inMemory(), $config);
|
|
}
|
|
|
|
public function testUnknownRouteReturns404(): void
|
|
{
|
|
$response = $this->app->handle(Request::create('GET', '/nope'));
|
|
|
|
self::assertSame(404, $response->status);
|
|
}
|
|
|
|
public function testWrongMethodReturns405(): void
|
|
{
|
|
$response = $this->app->handle(Request::create('DELETE', '/api/v1/tickets'));
|
|
|
|
self::assertSame(405, $response->status);
|
|
}
|
|
|
|
public function testWritingWithoutAgentKeyReturns401(): void
|
|
{
|
|
$response = $this->app->handle(Request::create('POST', '/api/v1/tickets', body: ['title' => 'X']));
|
|
|
|
self::assertSame(401, $response->status);
|
|
}
|
|
|
|
public function testRegisterAgentRequiresAdminKey(): void
|
|
{
|
|
$response = $this->app->handle(Request::create('POST', '/api/v1/agents', body: [
|
|
'display_name' => 'Alice', 'role' => 'entwickler',
|
|
]));
|
|
|
|
self::assertSame(401, $response->status);
|
|
}
|
|
|
|
public function testSystemEventNeedsAdminKey(): void
|
|
{
|
|
$body = ['name' => 'Monitor', 'message' => 'Update eingespielt (abc1234)', 'severity' => 'warning'];
|
|
|
|
$denied = $this->app->handle(Request::create('POST', '/api/v1/events/system', body: $body));
|
|
self::assertSame(401, $denied->status);
|
|
|
|
$created = $this->app->handle(Request::create(
|
|
'POST',
|
|
'/api/v1/events/system',
|
|
body: $body,
|
|
headers: ['X-Admin-Key' => 'admin-secret']
|
|
));
|
|
self::assertSame(201, $created->status);
|
|
self::assertSame('Monitor', $created->body()['event']['name']);
|
|
}
|
|
|
|
public function testFullFlowRegisterAgentThenCreateTicket(): void
|
|
{
|
|
$registerResponse = $this->app->handle(Request::create(
|
|
'POST',
|
|
'/api/v1/agents',
|
|
body: ['display_name' => 'Alice', 'role' => 'entwickler'],
|
|
headers: ['X-Admin-Key' => 'admin-secret']
|
|
));
|
|
self::assertSame(201, $registerResponse->status);
|
|
$apiKey = $registerResponse->body()['api_key'];
|
|
|
|
$createResponse = $this->app->handle(Request::create(
|
|
'POST',
|
|
'/api/v1/tickets',
|
|
body: ['title' => 'Neues Ticket'],
|
|
headers: ['X-Api-Key' => $apiKey]
|
|
));
|
|
self::assertSame(201, $createResponse->status);
|
|
|
|
$listResponse = $this->app->handle(Request::create('GET', '/api/v1/tickets'));
|
|
self::assertCount(1, $listResponse->body()['tickets']);
|
|
}
|
|
|
|
/**
|
|
* Regression: Ein unbekanntes Ticket lief bis in die FOREIGN-KEY-Constraint und kam als
|
|
* ungefangene PDOException samt Stacktrace beim Client an.
|
|
*/
|
|
public function testActivityWithUnknownTicketReturns422WithoutInternals(): void
|
|
{
|
|
$registerResponse = $this->app->handle(Request::create(
|
|
'POST',
|
|
'/api/v1/agents',
|
|
body: ['display_name' => 'Alice', 'role' => 'entwickler'],
|
|
headers: ['X-Admin-Key' => 'admin-secret']
|
|
));
|
|
$apiKey = $registerResponse->body()['api_key'];
|
|
$agentId = $registerResponse->body()['agent']['id'];
|
|
|
|
$response = $this->app->handle(Request::create(
|
|
'POST',
|
|
'/api/v1/agents/' . $agentId . '/activity',
|
|
body: ['state' => 'working', 'current_ticket' => 999],
|
|
headers: ['X-Api-Key' => $apiKey]
|
|
));
|
|
|
|
self::assertSame(422, $response->status);
|
|
self::assertStringNotContainsString('PDOException', json_encode($response->body()));
|
|
}
|
|
|
|
public function testAgentsMeRequiresAgentKey(): void
|
|
{
|
|
$response = $this->app->handle(Request::create('GET', '/api/v1/agents/me'));
|
|
|
|
self::assertSame(401, $response->status);
|
|
}
|
|
|
|
public function testAgentsMeReturnsOwnId(): void
|
|
{
|
|
$registerResponse = $this->app->handle(Request::create(
|
|
'POST',
|
|
'/api/v1/agents',
|
|
body: ['display_name' => 'OPs', 'role' => 'ops'],
|
|
headers: ['X-Admin-Key' => 'admin-secret']
|
|
));
|
|
|
|
$response = $this->app->handle(Request::create(
|
|
'GET',
|
|
'/api/v1/agents/me',
|
|
headers: ['X-Api-Key' => $registerResponse->body()['api_key']]
|
|
));
|
|
|
|
self::assertSame(200, $response->status);
|
|
self::assertSame($registerResponse->body()['agent']['id'], $response->body()['agent']['id']);
|
|
}
|
|
|
|
/**
|
|
* Regression: ein check_fds.sh mit monitor-api.conf ohne MONITOR_AGENT_ID meldete alle fuenf
|
|
* Minuten Systemstatus mit dem OPs-Key, rief aber nie den Aktivitaets-Endpoint auf - der
|
|
* OPs-Agent blieb dadurch seit seiner Registrierung dauerhaft abwesend.
|
|
*/
|
|
public function testAnyAuthenticatedAgentRequestRefreshesPresence(): void
|
|
{
|
|
$db = Database::inMemory();
|
|
$app = new Application($db, new Config(['MONITOR_ADMIN_API_KEY' => 'admin-secret', 'MONITOR_STATUS_FILE' => '/does/not/exist']));
|
|
$registerResponse = $app->handle(Request::create(
|
|
'POST',
|
|
'/api/v1/agents',
|
|
body: ['display_name' => 'OPs', 'role' => 'ops'],
|
|
headers: ['X-Admin-Key' => 'admin-secret']
|
|
));
|
|
$agentId = $registerResponse->body()['agent']['id'];
|
|
|
|
$db->pdo()->prepare('UPDATE agents SET last_seen_at = :ts WHERE id = :id')
|
|
->execute(['ts' => gmdate('Y-m-d\TH:i:s\Z', time() - 601), 'id' => $agentId]);
|
|
self::assertFalse($app->handle(Request::create('GET', '/api/v1/agents'))->body()['agents'][0]['is_present']);
|
|
|
|
$statusResponse = $app->handle(Request::create(
|
|
'POST',
|
|
'/api/v1/systems/homeassistant/status',
|
|
body: ['status' => 'ok', 'name' => 'homeassistant'],
|
|
headers: ['X-Api-Key' => $registerResponse->body()['api_key']]
|
|
));
|
|
self::assertSame(200, $statusResponse->status);
|
|
|
|
self::assertTrue($app->handle(Request::create('GET', '/api/v1/agents'))->body()['agents'][0]['is_present']);
|
|
}
|
|
|
|
public function testPatchUnknownTicketReturns404(): void
|
|
{
|
|
$registerResponse = $this->app->handle(Request::create(
|
|
'POST',
|
|
'/api/v1/agents',
|
|
body: ['display_name' => 'Alice', 'role' => 'entwickler'],
|
|
headers: ['X-Admin-Key' => 'admin-secret']
|
|
));
|
|
$apiKey = $registerResponse->body()['api_key'];
|
|
|
|
$response = $this->app->handle(Request::create(
|
|
'PATCH',
|
|
'/api/v1/tickets/999',
|
|
body: ['column' => 'bereit'],
|
|
headers: ['X-Api-Key' => $apiKey]
|
|
));
|
|
|
|
self::assertSame(404, $response->status);
|
|
}
|
|
|
|
/**
|
|
* Issue #27: die Projektliste des Codeberg-Sync ist ueber ein Modal im Frontend
|
|
* editierbar - lesend oeffentlich (fuer das Modal beim Oeffnen), schreibend hinter dem
|
|
* Admin-Key wie die Agenten-Registrierung.
|
|
*/
|
|
public function testRepoConfigsIndexIsPublic(): void
|
|
{
|
|
$response = $this->app->handle(Request::create('GET', '/api/v1/repo-configs'));
|
|
|
|
self::assertSame(200, $response->status);
|
|
self::assertIsArray($response->body()['repos']);
|
|
}
|
|
|
|
/**
|
|
* Der X-Admin-Key-Header macht aus dem fetch() im Projekte-Modal einen preflighted
|
|
* request. Wird OPTIONS nicht mit 2xx beantwortet, sendet der Browser den POST nie und
|
|
* das Modal zeigt nur "Netzwerkfehler beim Abrufen der Ressource".
|
|
*/
|
|
public function testCorsPreflightOnAdminEndpointSucceeds(): void
|
|
{
|
|
$response = $this->app->handle(Request::create('OPTIONS', '/api/v1/repo-configs'));
|
|
|
|
self::assertSame(204, $response->status);
|
|
}
|
|
|
|
public function testCreateRepoConfigRequiresAdminKey(): void
|
|
{
|
|
$response = $this->app->handle(Request::create('POST', '/api/v1/repo-configs', body: [
|
|
'owner' => 'fds2610', 'repo' => 'monitor', 'color' => '#fbca04',
|
|
]));
|
|
|
|
self::assertSame(401, $response->status);
|
|
}
|
|
|
|
public function testFullFlowCreateUpdateAndDeleteRepoConfigWithAdminKey(): void
|
|
{
|
|
// Bewusst ein Repo, das die Migration nicht schon geseedet hat (monitor/Steuerkram/
|
|
// nc-gantt, siehe 003_repo_configs.sql) - sonst scheitert schon das Anlegen an der
|
|
// Dublettenpruefung, und der eigentliche Ablauf wird gar nicht geprueft.
|
|
$createResponse = $this->app->handle(Request::create(
|
|
'POST',
|
|
'/api/v1/repo-configs',
|
|
body: ['owner' => 'fds2610', 'repo' => 'weiteres-projekt', 'color' => '#fbca04'],
|
|
headers: ['X-Admin-Key' => 'admin-secret']
|
|
));
|
|
self::assertSame(201, $createResponse->status);
|
|
$id = $createResponse->body()['repo']['id'];
|
|
|
|
$updateResponse = $this->app->handle(Request::create(
|
|
'PATCH',
|
|
'/api/v1/repo-configs/' . $id,
|
|
body: ['owner' => 'fds2610', 'repo' => 'weiteres-projekt', 'color' => '#000000'],
|
|
headers: ['X-Admin-Key' => 'admin-secret']
|
|
));
|
|
self::assertSame(200, $updateResponse->status);
|
|
self::assertSame('#000000', $updateResponse->body()['repo']['color']);
|
|
|
|
$deleteResponse = $this->app->handle(Request::create(
|
|
'DELETE',
|
|
'/api/v1/repo-configs/' . $id,
|
|
headers: ['X-Admin-Key' => 'admin-secret']
|
|
));
|
|
self::assertSame(200, $deleteResponse->status);
|
|
|
|
$listResponse = $this->app->handle(Request::create('GET', '/api/v1/repo-configs'));
|
|
self::assertSame([], array_filter(
|
|
$listResponse->body()['repos'],
|
|
static fn (array $r): bool => (int) $r['id'] === (int) $id
|
|
));
|
|
}
|
|
|
|
/**
|
|
* Der SSE-Endpoint (F13) ist wie die anderen GET-Endpunkte oeffentlich und liefert eine
|
|
* Stream-Antwort - der eigentliche Polling-Loop wird hier bewusst NICHT ausgefuehrt
|
|
* (siehe StreamControllerTest fuer die Schleifenlogik selbst), das wuerde echte sleep()-
|
|
* Aufrufe ausloesen.
|
|
*/
|
|
public function testStreamRouteIsPublicAndReturnsAStreamResponse(): void
|
|
{
|
|
$response = $this->app->handle(Request::create('GET', '/api/v1/stream'));
|
|
|
|
self::assertSame(200, $response->status);
|
|
self::assertTrue($response->isStream());
|
|
}
|
|
|
|
/**
|
|
* Der Webhook-Endpunkt ist aus dem Internet erreichbar - ohne gueltige Signatur darf er
|
|
* nichts ausloesen. Die setUp()-Config setzt kein CODEBERG_WEBHOOK_SECRET, also weist
|
|
* er hier grundsaetzlich ab (siehe WebhookAuthenticatorTest fuer die Signaturlogik).
|
|
*/
|
|
public function testCodebergWebhookWithoutSignatureReturns401(): void
|
|
{
|
|
$response = $this->app->handle(Request::create(
|
|
'POST',
|
|
'/api/v1/webhooks/codeberg',
|
|
rawBody: '{"action":"opened"}'
|
|
));
|
|
|
|
self::assertSame(401, $response->status);
|
|
}
|
|
|
|
public function testCodebergWebhookWithValidSignatureIsAccepted(): void
|
|
{
|
|
$payload = '{"action":"opened","issue":{"number":1}}';
|
|
$triggerPath = sys_get_temp_dir() . '/monitor-app-webhook-' . uniqid() . '/sync-requested';
|
|
$hooksPath = sys_get_temp_dir() . '/monitor-app-webhook-' . uniqid() . '/hooks-count';
|
|
|
|
$app = new Application(Database::inMemory(), new Config([
|
|
'MONITOR_STATUS_FILE' => '/does/not/exist',
|
|
'CODEBERG_WEBHOOK_SECRET' => 'webhook-secret',
|
|
'MONITOR_SYNC_TRIGGER_FILE' => $triggerPath,
|
|
'MONITOR_HOOKS_COUNTER_FILE' => $hooksPath,
|
|
]));
|
|
|
|
$response = $app->handle(Request::create(
|
|
'POST',
|
|
'/api/v1/webhooks/codeberg',
|
|
headers: ['X-Forgejo-Signature' => hash_hmac('sha256', $payload, 'webhook-secret')],
|
|
rawBody: $payload
|
|
));
|
|
|
|
self::assertSame(202, $response->status);
|
|
self::assertFileExists($triggerPath);
|
|
|
|
unlink($triggerPath);
|
|
rmdir(dirname($triggerPath));
|
|
unlink($hooksPath);
|
|
rmdir(dirname($hooksPath));
|
|
}
|
|
|
|
/**
|
|
* Voller Ablauf einer Sprachaufnahme - Upload aus record.html (unauthentifiziert, ohne
|
|
* Agenten-Id), Abholen/Herunterladen/Markieren durch den Voice-Bot (hinter seinem
|
|
* X-Api-Key).
|
|
*/
|
|
public function testVoiceRecordingFullFlow(): void
|
|
{
|
|
$storageDir = sys_get_temp_dir() . '/monitor-app-recordings-' . uniqid();
|
|
$app = new Application(Database::inMemory(), new Config([
|
|
'MONITOR_STATUS_FILE' => '/does/not/exist',
|
|
'MONITOR_ADMIN_API_KEY' => 'admin-secret',
|
|
'MONITOR_RECORDINGS_PATH' => $storageDir,
|
|
]));
|
|
|
|
$registerResponse = $app->handle(Request::create(
|
|
'POST',
|
|
'/api/v1/agents',
|
|
body: ['display_name' => 'Vox', 'role' => 'voice'],
|
|
headers: ['X-Admin-Key' => 'admin-secret']
|
|
));
|
|
$apiKey = $registerResponse->body()['api_key'];
|
|
|
|
$tmpUploadFile = sys_get_temp_dir() . '/monitor-app-upload-' . uniqid();
|
|
file_put_contents($tmpUploadFile, 'echte audiodaten');
|
|
|
|
$uploadResponse = $app->handle(Request::create(
|
|
'POST',
|
|
'/api/v1/recordings',
|
|
files: ['audio' => [
|
|
'name' => 'aufnahme.webm',
|
|
'type' => 'audio/webm',
|
|
'tmp_name' => $tmpUploadFile,
|
|
'error' => UPLOAD_ERR_OK,
|
|
'size' => filesize($tmpUploadFile),
|
|
]]
|
|
));
|
|
self::assertSame(201, $uploadResponse->status);
|
|
$recordingId = $uploadResponse->body()['recording']['id'];
|
|
|
|
$agentsResponse = $app->handle(Request::create('GET', '/api/v1/agents'));
|
|
self::assertSame(1, $agentsResponse->body()['pending_recordings']);
|
|
|
|
$listResponse = $app->handle(Request::create(
|
|
'GET',
|
|
'/api/v1/recordings',
|
|
query: ['status' => 'new'],
|
|
headers: ['X-Api-Key' => $apiKey]
|
|
));
|
|
self::assertSame(200, $listResponse->status);
|
|
self::assertCount(1, $listResponse->body()['recordings']);
|
|
|
|
$downloadResponse = $app->handle(Request::create(
|
|
'GET',
|
|
'/api/v1/recordings/' . $recordingId . '/audio',
|
|
headers: ['X-Api-Key' => $apiKey]
|
|
));
|
|
self::assertSame(200, $downloadResponse->status);
|
|
self::assertSame('echte audiodaten', $downloadResponse->body());
|
|
|
|
$markResponse = $app->handle(Request::create(
|
|
'PATCH',
|
|
'/api/v1/recordings/' . $recordingId,
|
|
body: ['status' => 'done'],
|
|
headers: ['X-Api-Key' => $apiKey]
|
|
));
|
|
self::assertSame(200, $markResponse->status);
|
|
self::assertSame('done', $markResponse->body()['recording']['status']);
|
|
|
|
$agentsAfterResponse = $app->handle(Request::create('GET', '/api/v1/agents'));
|
|
self::assertSame(0, $agentsAfterResponse->body()['pending_recordings']);
|
|
|
|
unlink($tmpUploadFile);
|
|
foreach (glob($storageDir . '/*') ?: [] as $file) {
|
|
unlink($file);
|
|
}
|
|
rmdir($storageDir);
|
|
}
|
|
|
|
public function testVoiceRecordingUploadWithoutFileReturns422(): void
|
|
{
|
|
$response = $this->app->handle(Request::create('POST', '/api/v1/recordings'));
|
|
|
|
self::assertSame(422, $response->status);
|
|
}
|
|
|
|
public function testVoiceRecordingListRequiresAgentKey(): void
|
|
{
|
|
$response = $this->app->handle(Request::create('GET', '/api/v1/recordings'));
|
|
|
|
self::assertSame(401, $response->status);
|
|
}
|
|
|
|
/**
|
|
* Die Warteschlange gehoert keinem Agenten: ein zweiter Bot mit eigenem Key sieht
|
|
* dieselbe Liste. Frueher antwortete der Endpunkt hier mit 403, weil die Aufnahme an
|
|
* einen anderen Agenten adressiert war - genau daran blieben Aufnahmen haengen.
|
|
*/
|
|
public function testVoiceRecordingQueueIsSharedBetweenAgents(): void
|
|
{
|
|
$storageDir = sys_get_temp_dir() . '/monitor-app-recordings-shared-' . uniqid();
|
|
$app = new Application(Database::inMemory(), new Config([
|
|
'MONITOR_STATUS_FILE' => '/does/not/exist',
|
|
'MONITOR_ADMIN_API_KEY' => 'admin-secret',
|
|
'MONITOR_RECORDINGS_PATH' => $storageDir,
|
|
]));
|
|
|
|
$otherAgent = $app->handle(Request::create(
|
|
'POST',
|
|
'/api/v1/agents',
|
|
body: ['display_name' => 'OPs', 'role' => 'ops'],
|
|
headers: ['X-Admin-Key' => 'admin-secret']
|
|
));
|
|
$otherKey = $otherAgent->body()['api_key'];
|
|
|
|
$tmpUploadFile = sys_get_temp_dir() . '/monitor-app-upload-shared-' . uniqid();
|
|
file_put_contents($tmpUploadFile, 'audio');
|
|
$uploadResponse = $app->handle(Request::create(
|
|
'POST',
|
|
'/api/v1/recordings',
|
|
files: ['audio' => [
|
|
'name' => 'aufnahme.webm',
|
|
'type' => 'audio/webm',
|
|
'tmp_name' => $tmpUploadFile,
|
|
'error' => UPLOAD_ERR_OK,
|
|
'size' => filesize($tmpUploadFile),
|
|
]]
|
|
));
|
|
$recordingId = $uploadResponse->body()['recording']['id'];
|
|
|
|
$listResponse = $app->handle(Request::create(
|
|
'GET',
|
|
'/api/v1/recordings',
|
|
headers: ['X-Api-Key' => $otherKey]
|
|
));
|
|
self::assertSame(200, $listResponse->status);
|
|
self::assertCount(1, $listResponse->body()['recordings']);
|
|
|
|
$downloadResponse = $app->handle(Request::create(
|
|
'GET',
|
|
'/api/v1/recordings/' . $recordingId . '/audio',
|
|
headers: ['X-Api-Key' => $otherKey]
|
|
));
|
|
self::assertSame(200, $downloadResponse->status);
|
|
|
|
unlink($tmpUploadFile);
|
|
foreach (glob($storageDir . '/*') ?: [] as $file) {
|
|
unlink($file);
|
|
}
|
|
rmdir($storageDir);
|
|
}
|
|
|
|
/**
|
|
* Hooks-KPI (Issue #38): oeffentlich wie die anderen GET-Endpunkte, zaehlt akzeptierte
|
|
* Webhook-Zustellungen unabhaengig vom Event-Typ.
|
|
*/
|
|
public function testWebhookStatsRouteIsPublicAndCountsAcceptedWebhooks(): void
|
|
{
|
|
$payload = '{"action":"opened","issue":{"number":1}}';
|
|
$triggerPath = sys_get_temp_dir() . '/monitor-app-webhook-stats-' . uniqid() . '/sync-requested';
|
|
$hooksPath = sys_get_temp_dir() . '/monitor-app-webhook-stats-' . uniqid() . '/hooks-count';
|
|
|
|
$app = new Application(Database::inMemory(), new Config([
|
|
'MONITOR_STATUS_FILE' => '/does/not/exist',
|
|
'CODEBERG_WEBHOOK_SECRET' => 'webhook-secret',
|
|
'MONITOR_SYNC_TRIGGER_FILE' => $triggerPath,
|
|
'MONITOR_HOOKS_COUNTER_FILE' => $hooksPath,
|
|
]));
|
|
|
|
$emptyStatsResponse = $app->handle(Request::create('GET', '/api/v1/webhooks/stats'));
|
|
self::assertSame(200, $emptyStatsResponse->status);
|
|
self::assertSame(0, $emptyStatsResponse->body()['count']);
|
|
|
|
$app->handle(Request::create(
|
|
'POST',
|
|
'/api/v1/webhooks/codeberg',
|
|
headers: ['X-Forgejo-Signature' => hash_hmac('sha256', $payload, 'webhook-secret')],
|
|
rawBody: $payload
|
|
));
|
|
|
|
$statsResponse = $app->handle(Request::create('GET', '/api/v1/webhooks/stats'));
|
|
self::assertSame(1, $statsResponse->body()['count']);
|
|
|
|
unlink($triggerPath);
|
|
rmdir(dirname($triggerPath));
|
|
unlink($hooksPath);
|
|
rmdir(dirname($hooksPath));
|
|
}
|
|
}
|