Aenderungen an Codeberg-Issues landeten bisher erst beim naechsten Lauf des sync-Services auf dem Board (MONITOR_SYNC_INTERVAL, Default 600s). Neu meldet Codeberg jede Aenderung aktiv per Webhook, der Weg aufs Board dauert damit wenige Sekunden (Webhook -> Sync -> SSE-Push ans Frontend). POST /api/v1/webhooks/codeberg wertet den Payload bewusst nicht aus, sondern fordert nur einen regulaeren Sync an - CodebergSyncService bleibt die einzige Stelle, die Issues auf Tickets abbildet, und ein verpasster Webhook ist folgenlos, weil der periodische Lauf denselben Stand ohnehin nachholt. Der Sync passiert nicht im Request: Forgejo bricht die Zustellung nach wenigen Sekunden ab (DELIVER_TIMEOUT, Default 5s), ein Sync macht aber mehrere Requests nach codeberg.org. Der Endpunkt setzt daher nur eine Trigger-Datei im gemeinsamen ./api-Volume (Sync\SyncTrigger) und antwortet sofort mit 202; der sync-Service laeuft jetzt im Watch-Modus und greift sie im Sekundentakt auf. Authentifiziert wird per HMAC-SHA256 ueber den rohen Request-Body (X-Forgejo-Signature bzw. X-Gitea-Signature) - Codeberg kennt unsere API-Keys nicht. Ohne gesetztes CODEBERG_WEBHOOK_SECRET weist der Endpunkt grundsaetzlich mit 401 ab, damit ein vergessenes Secret keinen offenen Sync-Ausloeser im Internet hinterlaesst. Request traegt dafuer neu den unveraenderten rawBody. Anders als der sync-Service braucht der api-Service damit eine eingehende Freigabe in Proxy und Firewall (POST /api/v1/webhooks/codeberg). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
176 lines
6 KiB
PHP
176 lines
6 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 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 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);
|
|
}
|
|
|
|
/**
|
|
* 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';
|
|
|
|
$app = new Application(Database::inMemory(), new Config([
|
|
'MONITOR_STATUS_FILE' => '/does/not/exist',
|
|
'CODEBERG_WEBHOOK_SECRET' => 'webhook-secret',
|
|
'MONITOR_SYNC_TRIGGER_FILE' => $triggerPath,
|
|
]));
|
|
|
|
$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));
|
|
}
|
|
}
|