'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 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)); } }