Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions .github/openapi/redocly-agent-api.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
extends:
- recommended

rules:
# The agent API is a single-endpoint API (POST /api/server.php) that
# dispatches on the "action" field in the body. These rules are relaxed
# because they don't apply to this non-REST, non-JSON:API design.
info-license: off
info-contact: off
operation-tag-defined: off
security-defined: off
# The agent API always returns HTTP 200 — errors are in the JSON body
# (response:"ERROR" + message). No 4XX/5XX HTTP status codes are used.
operation-4xx-response: off
# No servers section (the spec is served at the same host as the API)
no-empty-servers: off
# The agent API uses application/json, not application/vnd.api+json
no-server-example.com: off
23 changes: 23 additions & 0 deletions .github/openapi/spectral-agent-api.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
extends:
- spectral:oas

formats:
- oas3.1

# Relaxed ruleset for the Hashtopolis agent API.
#
# The agent API is a single-endpoint JSON API (POST /api/server.php) that
# dispatches on the "action" field in the request body. It uses
# application/json (not application/vnd.api+json), so the JSON:API ruleset
# (spectral-jsonapi.yml) does NOT apply. This ruleset extends the base
# Spectral OAS rules and disables rules that don't fit the agent API design.

rules:
# Auth is via token in the JSON body, not via HTTP security schemes.
oas3-api-servers: off
oas3-operation-security-defined: off
# The single endpoint doesn't use tags.
operation-tag-defined: off
# The API doesn't define contact or license info.
info-contact: off
info-license: off
22 changes: 16 additions & 6 deletions .github/workflows/openapi-lint.yml
Original file line number Diff line number Diff line change
Expand Up @@ -32,9 +32,19 @@ jobs:
key: ${{ runner.os }}-npm-openapi-spectral6.15.1-redocly2.24.0
- name: Install OpenAPI tooling
run: npm install -g @stoplight/spectral-cli@6.15.1 @redocly/cli@2.24.0 --min-release-age=14
- name: Download OpenAPI schema
run: wget -q http://localhost:8080/api/v2/openapi.json -O openapi.json
- name: Lint OpenAPI schema with Redocly
run: redocly lint openapi.json
- name: Lint OpenAPI schema with Spectral
run: spectral lint openapi.json --ruleset .github/openapi/spectral-jsonapi.yml -D

# --- v2 API (JSON:API) ---
- name: Download v2 OpenAPI schema
run: wget -q http://localhost:8080/api/v2/openapi.json -O openapi-v2.json
- name: Lint v2 OpenAPI schema with Redocly
run: redocly lint openapi-v2.json
- name: Lint v2 OpenAPI schema with Spectral
run: spectral lint openapi-v2.json --ruleset .github/openapi/spectral-jsonapi.yml -D

# --- Agent API (JSON, not JSON:API) ---
- name: Download Agent API OpenAPI schema
run: wget -q http://localhost:8080/api/server_openapi.php -O openapi-agent.json
- name: Lint Agent API OpenAPI schema with Redocly
run: redocly lint openapi-agent.json --config .github/openapi/redocly-agent-api.yaml
- name: Lint Agent API OpenAPI schema with Spectral
run: spectral lint openapi-agent.json --ruleset .github/openapi/spectral-agent-api.yml -D
34 changes: 22 additions & 12 deletions ci/apiv2/test_agent_protocol.py
Original file line number Diff line number Diff line change
Expand Up @@ -339,10 +339,21 @@ def test_login_invalid_token(self):
self.assertEqual(parse_envelope(body)['message'], "Invalid token!")

def test_login_missing_fields(self):
"""Login without required fields (token, clientSignature) returns 'Invalid login query!'."""
"""Login without any fields (no token, no clientSignature) returns 'Invalid token!' (middleware handles missing token for PSR-7 controllers)."""
code, body = agent_request({"action": "login"})
assert_error_envelope(self, body, "login")
self.assertEqual(parse_envelope(body)['message'], "Invalid token!")

def test_login_invalid_token_takes_priority_over_missing_fields(self):
"""When the token is present but invalid, 'Invalid token!' takes priority over missing-field errors.

The TokenAuthMiddleware checks the token before the controller validates
other fields, so an invalid token with a missing clientSignature returns
'Invalid token!' rather than 'Invalid login query!'.
"""
code, body = agent_request({"action": "login", "token": "x"})
assert_error_envelope(self, body, "login")
self.assertEqual(parse_envelope(body)['message'], "Invalid login query!")
self.assertEqual(parse_envelope(body)['message'], "Invalid token!")


# ---------------------------------------------------------------------------
Expand Down Expand Up @@ -658,10 +669,10 @@ def test_get_task_assigned(self):
self.assertIn(resp['benchType'], ("speed", "run"))

def test_get_task_missing_fields(self):
"""Sending getTask without a token returns 'Invalid task query!'."""
"""Sending getTask without a token returns 'Invalid token!' (middleware handles missing token for PSR-7 controllers)."""
code, body = agent_request({"action": "getTask"})
assert_error_envelope(self, body, "getTask")
self.assertEqual(parse_envelope(body)['message'], "Invalid task query!")
self.assertEqual(parse_envelope(body)['message'], "Invalid token!")

def test_get_task_invalid_token(self):
"""Sending getTask with a bogus token returns 'Invalid token!'."""
Expand Down Expand Up @@ -1586,11 +1597,10 @@ def test_send_progress_state_cracked(self):
self.assertIsInstance(resp['skipped'], int)

def test_send_progress_missing_fields(self):
"""Sending sendProgress without required fields returns 'Invalid progress query!'."""
dummy = self._dummy()
code, body = agent_request({"action": "sendProgress", "token": dummy.token})
"""Sending sendProgress without required fields returns 'Invalid token!' (middleware handles missing token for PSR-7 controllers)."""
code, body = agent_request({"action": "sendProgress"})
assert_error_envelope(self, body, "sendProgress")
self.assertEqual(parse_envelope(body)['message'], "Invalid progress query!")
self.assertEqual(parse_envelope(body)['message'], "Invalid token!")


# ---------------------------------------------------------------------------
Expand All @@ -1608,10 +1618,10 @@ def test_get_health_check_none_available(self):
self.assertEqual(parse_envelope(body)['message'], "No health check available for this agent!")

def test_get_health_check_missing_fields(self):
"""Sending getHealthCheck without a token returns 'Invalid get health check query!'."""
"""Sending getHealthCheck without a token returns 'Invalid token!' (middleware handles missing token for PSR-7 controllers)."""
code, body = agent_request({"action": "getHealthCheck"})
assert_error_envelope(self, body, "getHealthCheck")
self.assertEqual(parse_envelope(body)['message'], "Invalid get health check query!")
self.assertEqual(parse_envelope(body)['message'], "Invalid token!")

def test_get_health_check_invalid_token(self):
"""Sending getHealthCheck with a bogus token returns 'Invalid token!'."""
Expand Down Expand Up @@ -1768,7 +1778,7 @@ def test_deregister_invalid_token(self):
self.assertEqual(parse_envelope(body)['message'], "Invalid token!")

def test_deregister_missing_fields(self):
"""Sending deregister without a token returns 'Invalid de-registering query!'."""
"""Sending deregister without a token returns 'Invalid token!' (middleware handles missing token for PSR-7 controllers)."""
code, body = agent_request({"action": "deregister"})
assert_error_envelope(self, body, "deregister")
self.assertEqual(parse_envelope(body)['message'], "Invalid de-registering query!")
self.assertEqual(parse_envelope(body)['message'], "Invalid token!")
117 changes: 117 additions & 0 deletions ci/phpunit/agentapi/ActionRegistryTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
<?php

namespace Hashtopolis\agentapi;

use Hashtopolis\inc\agent\PActions;
use Hashtopolis\inc\agentapi\common\ActionRegistry;
use Hashtopolis\inc\agentapi\model\CheckClientVersionAction;
use Hashtopolis\inc\agentapi\model\ClientErrorAction;
use Hashtopolis\inc\agentapi\model\DeregisterAction;
use Hashtopolis\inc\agentapi\model\DownloadBinaryAction;
use Hashtopolis\inc\agentapi\model\GetFileAction;
use Hashtopolis\inc\agentapi\model\GetFileStatusAction;
use Hashtopolis\inc\agentapi\model\GetChunkAction;
use Hashtopolis\inc\agentapi\model\GetFoundAction;
use Hashtopolis\inc\agentapi\model\GetHashlistAction;
use Hashtopolis\inc\agentapi\model\GetHealthCheckAction;
use Hashtopolis\inc\agentapi\model\GetTaskAction;
use Hashtopolis\inc\agentapi\model\LoginAction;
use Hashtopolis\inc\agentapi\model\RegisterAgentAction;
use Hashtopolis\inc\agentapi\model\SendBenchmarkAction;
use Hashtopolis\inc\agentapi\model\SendHealthCheckAction;
use Hashtopolis\inc\agentapi\model\SendKeyspaceAction;
use Hashtopolis\inc\agentapi\model\SendProgressAction;
use Hashtopolis\inc\agentapi\model\TestConnectionAction;
use Hashtopolis\inc\agentapi\model\UpdateInformationAction;
use PHPUnit\Framework\TestCase;

require_once(dirname(__FILE__) . '/../TestBase.php');

/**
* Unit tests for {@see ActionRegistry} — verifies that all 19 agent API action
* strings are correctly mapped to their handler classes and that unknown /
* missing actions return null.
*/
final class ActionRegistryTest extends TestCase {

/**
* Every action string in PActions must be registered in the ActionRegistry.
*/
public function testAllPActionsAreRegistered(): void {
$registered = ActionRegistry::getActions();
$expected = [
PActions::TEST_CONNECTION,
PActions::REGISTER,
PActions::UPDATE_CLIENT_INFORMATION,
PActions::LOGIN,
PActions::CHECK_CLIENT_VERSION,
PActions::DOWNLOAD_BINARY,
PActions::CLIENT_ERROR,
PActions::GET_FILE,
PActions::GET_HASHLIST,
PActions::GET_TASK,
PActions::GET_CHUNK,
PActions::SEND_KEYSPACE,
PActions::SEND_BENCHMARK,
PActions::SEND_PROGRESS,
PActions::GET_FILE_STATUS,
PActions::GET_HEALTH_CHECK,
PActions::SEND_HEALTH_CHECK,
PActions::GET_FOUND,
PActions::DEREGISTER,
];
foreach ($expected as $action) {
$this->assertContains($action, $registered, "Action '$action' should be registered");
}
$this->assertCount(19, $registered, 'All 19 actions should be registered');
}

/**
* Each action string maps to the correct handler class.
*/
public function testGetHandlerReturnsCorrectClass(): void {
$this->assertEquals(TestConnectionAction::class, ActionRegistry::getHandler(PActions::TEST_CONNECTION));
$this->assertEquals(RegisterAgentAction::class, ActionRegistry::getHandler(PActions::REGISTER));
$this->assertEquals(UpdateInformationAction::class, ActionRegistry::getHandler(PActions::UPDATE_CLIENT_INFORMATION));
$this->assertEquals(LoginAction::class, ActionRegistry::getHandler(PActions::LOGIN));
$this->assertEquals(CheckClientVersionAction::class, ActionRegistry::getHandler(PActions::CHECK_CLIENT_VERSION));
$this->assertEquals(DownloadBinaryAction::class, ActionRegistry::getHandler(PActions::DOWNLOAD_BINARY));
$this->assertEquals(ClientErrorAction::class, ActionRegistry::getHandler(PActions::CLIENT_ERROR));
$this->assertEquals(GetFileAction::class, ActionRegistry::getHandler(PActions::GET_FILE));
$this->assertEquals(GetHashlistAction::class, ActionRegistry::getHandler(PActions::GET_HASHLIST));
$this->assertEquals(GetTaskAction::class, ActionRegistry::getHandler(PActions::GET_TASK));
$this->assertEquals(GetChunkAction::class, ActionRegistry::getHandler(PActions::GET_CHUNK));
$this->assertEquals(SendKeyspaceAction::class, ActionRegistry::getHandler(PActions::SEND_KEYSPACE));
$this->assertEquals(SendBenchmarkAction::class, ActionRegistry::getHandler(PActions::SEND_BENCHMARK));
$this->assertEquals(SendProgressAction::class, ActionRegistry::getHandler(PActions::SEND_PROGRESS));
$this->assertEquals(GetFileStatusAction::class, ActionRegistry::getHandler(PActions::GET_FILE_STATUS));
$this->assertEquals(GetHealthCheckAction::class, ActionRegistry::getHandler(PActions::GET_HEALTH_CHECK));
$this->assertEquals(SendHealthCheckAction::class, ActionRegistry::getHandler(PActions::SEND_HEALTH_CHECK));
$this->assertEquals(GetFoundAction::class, ActionRegistry::getHandler(PActions::GET_FOUND));
$this->assertEquals(DeregisterAction::class, ActionRegistry::getHandler(PActions::DEREGISTER));
}

/**
* An unknown action string returns null.
*/
public function testGetHandlerReturnsNullForUnknownAction(): void {
$this->assertNull(ActionRegistry::getHandler('nonexistent'));
$this->assertNull(ActionRegistry::getHandler(''));
$this->assertNull(ActionRegistry::getHandler('INV'));
}

/**
* A null action returns null.
*/
public function testGetHandlerReturnsNullForNullAction(): void {
$this->assertNull(ActionRegistry::getHandler(null));
}

/**
* getActions returns a list of all registered action strings.
*/
public function testGetActionsReturnsList(): void {
$actions = ActionRegistry::getActions();
$this->assertCount(19, $actions);
}
}
68 changes: 68 additions & 0 deletions ci/phpunit/agentapi/AgentEnvelopeTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
<?php

namespace Hashtopolis\agentapi;

use Hashtopolis\inc\agent\PActions;
use Hashtopolis\inc\agent\PResponse;
use Hashtopolis\inc\agent\PResponseErrorMessage;
use Hashtopolis\inc\agent\PValues;
use Hashtopolis\inc\agentapi\common\AgentEnvelope;
use PHPUnit\Framework\TestCase;

require_once(dirname(__FILE__) . '/../TestBase.php');

/**
* Unit tests for {@see AgentEnvelope} — verifies the wire-level shape of the
* success and error envelopes produced by the agent API.
*/
final class AgentEnvelopeTest extends TestCase {

/**
* A success envelope contains the action string and response='SUCCESS'.
*/
public function testSuccessEnvelopeHasActionAndResponse(): void {
$envelope = AgentEnvelope::success(PActions::TEST_CONNECTION);
$this->assertEquals(PActions::TEST_CONNECTION, $envelope[PResponse::ACTION]);
$this->assertEquals(PValues::SUCCESS, $envelope[PResponse::RESPONSE]);
}

/**
* A success envelope with no extra fields has exactly 2 keys.
*/
public function testSuccessEnvelopeNoExtraFields(): void {
$envelope = AgentEnvelope::success(PActions::TEST_CONNECTION);
$this->assertCount(2, $envelope);
}

/**
* A success envelope with extra fields includes them alongside the base keys.
*/
public function testSuccessEnvelopeWithExtraFields(): void {
$envelope = AgentEnvelope::success(PActions::REGISTER, ['token' => 'abc123']);
$this->assertEquals(PActions::REGISTER, $envelope[PResponse::ACTION]);
$this->assertEquals(PValues::SUCCESS, $envelope[PResponse::RESPONSE]);
$this->assertEquals('abc123', $envelope['token']);
$this->assertCount(3, $envelope);
}

/**
* An error envelope has action, response='ERROR', and a message.
*/
public function testErrorEnvelopeShape(): void {
$envelope = AgentEnvelope::error(PActions::LOGIN, 'Invalid token!');
$this->assertEquals(PActions::LOGIN, $envelope[PResponse::ACTION]);
$this->assertEquals(PValues::ERROR, $envelope[PResponse::RESPONSE]);
$this->assertEquals('Invalid token!', $envelope[PResponseErrorMessage::MESSAGE]);
$this->assertCount(3, $envelope);
}

/**
* The 'INV' action string is used for unknown/missing actions.
*/
public function testErrorEnvelopeWithInvAction(): void {
$envelope = AgentEnvelope::error('INV', 'Invalid query!');
$this->assertEquals('INV', $envelope[PResponse::ACTION]);
$this->assertEquals(PValues::ERROR, $envelope[PResponse::RESPONSE]);
$this->assertEquals('Invalid query!', $envelope[PResponseErrorMessage::MESSAGE]);
}
}
Loading
Loading