Add bnfexpress signed admin client and AI Agent Filament UI
- Modules\Shared\Bnfexpress\BnfexpressAdminClient: HMAC-signed HTTP client for bnfexpress's admin API (EV FAQs, agent instructions, chat history), with a bnfexpress:smoke-test command and full unit coverage. - New ai-agent module: Filament pages to manage EV FAQs, publish/roll back agent instruction versions, and browse EV chat history + transcripts. - New manage_ai_agent permission (super_admin/admin). - Recorded .ai/rules for the client's auth scheme and non-Resource Filament page/table testing gotchas.
This commit is contained in:
@@ -0,0 +1,87 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Modules\Shared\Bnfexpress\BnfexpressAdminClient;
|
||||
use Modules\Shared\Bnfexpress\Exceptions\BnfexpressApiException;
|
||||
|
||||
$config = [
|
||||
'ai_api_url' => 'https://bnfexpress.test',
|
||||
'client_id' => 'ev_admin',
|
||||
'client_secret' => 'shared-secret',
|
||||
];
|
||||
|
||||
function assertSignedCorrectly($request, string $method, string $path, string $rawBody, string $secret): bool
|
||||
{
|
||||
$timestamp = $request->header('X-Timestamp')[0] ?? null;
|
||||
$expected = hash_hmac('sha256', strtoupper($method)."\n".$path."\n".$timestamp."\n".$rawBody, $secret);
|
||||
|
||||
return $request->hasHeader('X-Client-Id', 'ev_admin')
|
||||
&& $timestamp !== null
|
||||
&& abs(time() - (int) $timestamp) < 5
|
||||
&& $request->header('X-Signature')[0] === $expected;
|
||||
}
|
||||
|
||||
test('listFaqs signs a GET request and excludes the query string from the signed path', function () use ($config) {
|
||||
Http::fake(['bnfexpress.test/*' => Http::response(['faqs' => []])]);
|
||||
|
||||
(new BnfexpressAdminClient($config))->listFaqs(q: 'range', search: 'semantic', limit: 10, offset: 0);
|
||||
|
||||
Http::assertSent(function ($request) use ($config) {
|
||||
return $request->url() === 'https://bnfexpress.test/admin/faqs?agent=ev&q=range&search=semantic&limit=10&offset=0'
|
||||
&& assertSignedCorrectly($request, 'GET', '/admin/faqs', '', $config['client_secret']);
|
||||
});
|
||||
});
|
||||
|
||||
test('listFaqs omits search when q is empty', function () use ($config) {
|
||||
Http::fake(['bnfexpress.test/*' => Http::response(['faqs' => []])]);
|
||||
|
||||
(new BnfexpressAdminClient($config))->listFaqs();
|
||||
|
||||
Http::assertSent(fn ($request) => $request->url() === 'https://bnfexpress.test/admin/faqs?agent=ev');
|
||||
});
|
||||
|
||||
test('createFaq signs the exact raw JSON body being sent', function () use ($config) {
|
||||
Http::fake(['bnfexpress.test/*' => Http::response(['id' => 1], 201)]);
|
||||
|
||||
(new BnfexpressAdminClient($config))->createFaq('How do I charge?', ['source' => 'admin']);
|
||||
|
||||
$rawBody = json_encode([
|
||||
'content' => 'How do I charge?',
|
||||
'agent' => 'ev',
|
||||
'metadata' => ['source' => 'admin'],
|
||||
]);
|
||||
|
||||
Http::assertSent(function ($request) use ($config, $rawBody) {
|
||||
return $request->url() === 'https://bnfexpress.test/admin/faqs'
|
||||
&& $request->method() === 'POST'
|
||||
&& $request->body() === $rawBody
|
||||
&& assertSignedCorrectly($request, 'POST', '/admin/faqs', $rawBody, $config['client_secret']);
|
||||
});
|
||||
});
|
||||
|
||||
test('getActiveInstruction requests the active instruction for the ev agent', function () use ($config) {
|
||||
Http::fake(['bnfexpress.test/*' => Http::response(['content' => 'You are the EV assistant.'])]);
|
||||
|
||||
$result = (new BnfexpressAdminClient($config))->getActiveInstruction();
|
||||
|
||||
expect($result)->toBe(['content' => 'You are the EV assistant.']);
|
||||
Http::assertSent(fn ($request) => $request->url() === 'https://bnfexpress.test/admin/agent-instructions/active?agent=ev');
|
||||
});
|
||||
|
||||
test('a non-2xx response surfaces the gateway detail message', function () use ($config) {
|
||||
Http::fake(['bnfexpress.test/*' => Http::response(['detail' => 'FAQ not found.'], 404)]);
|
||||
|
||||
(new BnfexpressAdminClient($config))->getFaq(999);
|
||||
})->throws(BnfexpressApiException::class, 'FAQ not found.');
|
||||
|
||||
test('deleteFaq signs a bodyless DELETE request', function () use ($config) {
|
||||
Http::fake(['bnfexpress.test/*' => Http::response(['deleted' => true])]);
|
||||
|
||||
(new BnfexpressAdminClient($config))->deleteFaq(5);
|
||||
|
||||
Http::assertSent(function ($request) use ($config) {
|
||||
return $request->url() === 'https://bnfexpress.test/admin/faqs/5'
|
||||
&& $request->method() === 'DELETE'
|
||||
&& assertSignedCorrectly($request, 'DELETE', '/admin/faqs/5', '', $config['client_secret']);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user