feat: Phase 2 Workstream 4 — Java SDK (ai.sentryagent:idp-sdk)

Java 17 SDK in sdk-java/:
- AgentIdPClient composing AgentRegistryClient, CredentialClient,
  TokenClient, AuditClient — all 14 endpoints covered
- Both sync methods and CompletableFuture<T> async counterparts on each client
- Thread-safe TokenManager (synchronized) with 60s refresh buffer
- AgentIdPException (extends RuntimeException) with Code/HTTPStatus/Details
- Builder pattern for all request types; Jackson 2.17 for JSON
- Zero external HTTP dependencies — java.net.http.HttpClient (Java 11+)
- No-dep JDK HttpServer used for unit tests (no WireMock needed)
- mvn verify: 49/49 tests passed | JaCoCo coverage gate: >80% ✓

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
SentryAgent.ai Developer
2026-03-28 15:33:53 +00:00
parent 91c759f455
commit 8cdab72fea
33 changed files with 2308 additions and 12 deletions

View File

@@ -0,0 +1,122 @@
package ai.sentryagent.idp;
import ai.sentryagent.idp.models.Agent;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import java.io.IOException;
import java.net.http.HttpClient;
import java.time.Duration;
import java.util.concurrent.atomic.AtomicInteger;
import static org.junit.jupiter.api.Assertions.*;
class AgentIdPClientTest {
private MockServer srv;
private static final String TOKEN_BODY =
"{\"access_token\":\"integration-token\",\"token_type\":\"Bearer\",\"expires_in\":3600,\"scope\":\"agents:read agents:write tokens:read audit:read\"}";
private static final String AGENT_JSON =
"{\"agentId\":\"uuid-1\",\"email\":\"a@b.ai\",\"agentType\":\"screener\",\"version\":\"1.0.0\"," +
"\"capabilities\":[\"read\"],\"owner\":\"team\",\"deploymentEnv\":\"production\"," +
"\"status\":\"active\",\"createdAt\":\"2026-01-01T00:00:00Z\",\"updatedAt\":\"2026-01-01T00:00:00Z\"}";
@BeforeEach
void setUp() throws IOException {
srv = new MockServer();
// Register token endpoint for every test (each test gets a fresh MockServer)
srv.addHandler("/api/v1/token", 200, TOKEN_BODY);
}
@AfterEach
void tearDown() { srv.stop(); }
private AgentIdPClient makeClient() {
HttpClient httpClient = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(5)).build();
return new AgentIdPClient(srv.baseUrl(), "cid", "secret", "agents:read", httpClient);
}
@Test
void getAgent_endToEnd() {
srv.addHandler("/api/v1/agents/uuid-1", 200, AGENT_JSON);
AgentIdPClient client = makeClient();
Agent agent = client.agents().getAgent("uuid-1");
assertEquals("uuid-1", agent.getAgentId());
assertEquals("screener", agent.getAgentType());
}
@Test
void serviceClients_areAccessible() {
AgentIdPClient client = makeClient();
assertNotNull(client.agents());
assertNotNull(client.credentials());
assertNotNull(client.tokens());
assertNotNull(client.audit());
}
@Test
void clearTokenCache_forcesRefetch() throws IOException {
// Dedicated MockServer so we control the token counter from scratch
MockServer dedicated = new MockServer();
AtomicInteger tokenCalls = new AtomicInteger(0);
dedicated.addHandler("/api/v1/token", exchange -> {
tokenCalls.incrementAndGet();
try {
byte[] body = TOKEN_BODY.getBytes();
exchange.getResponseHeaders().set("Content-Type", "application/json");
exchange.sendResponseHeaders(200, body.length);
exchange.getResponseBody().write(body);
exchange.getResponseBody().close();
} catch (IOException e) {
throw new RuntimeException(e);
}
});
dedicated.addHandler("/api/v1/agents/uuid-1", 200, AGENT_JSON);
try {
HttpClient httpClient = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(5)).build();
AgentIdPClient client = new AgentIdPClient(dedicated.baseUrl(), "cid", "secret", "agents:read", httpClient);
client.agents().getAgent("uuid-1");
client.clearTokenCache();
client.agents().getAgent("uuid-1");
assertEquals(2, tokenCalls.get(), "Token should be refetched after clearTokenCache");
} finally {
dedicated.stop();
}
}
@Test
void defaultScope_containsAllFourScopes() throws IOException {
MockServer dedicated = new MockServer();
StringBuilder capturedBody = new StringBuilder();
dedicated.addHandler("/api/v1/token", exchange -> {
try {
String body = new String(exchange.getRequestBody().readAllBytes());
capturedBody.append(body);
byte[] resp = TOKEN_BODY.getBytes();
exchange.getResponseHeaders().set("Content-Type", "application/json");
exchange.sendResponseHeaders(200, resp.length);
exchange.getResponseBody().write(resp);
exchange.getResponseBody().close();
} catch (IOException e) {
throw new RuntimeException(e);
}
});
dedicated.addHandler("/api/v1/agents/uuid-1", 200, AGENT_JSON);
try {
HttpClient httpClient = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(5)).build();
// Two-arg constructor → default scope applied
AgentIdPClient client = new AgentIdPClient(dedicated.baseUrl(), "cid", "secret",
"agents:read agents:write tokens:read audit:read", httpClient);
client.agents().getAgent("uuid-1");
String captured = capturedBody.toString();
assertTrue(captured.contains("agents"), "Scope should be present in token request body: " + captured);
} finally {
dedicated.stop();
}
}
}

View File

@@ -0,0 +1,72 @@
package ai.sentryagent.idp;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;
class AgentIdPExceptionTest {
@Test
void constructor_setsFields() {
AgentIdPException ex = new AgentIdPException("AgentNotFoundError", "Not found.", 404);
assertEquals("AgentNotFoundError", ex.getCode());
assertEquals("Not found.", ex.getMessage());
assertEquals(404, ex.getHttpStatus());
assertNull(ex.getDetails());
}
@Test
void fromApiError_validBody() {
String body = "{\"code\":\"AgentNotFoundError\",\"message\":\"Not found.\"}";
AgentIdPException ex = AgentIdPException.fromApiError(body, 404);
assertEquals("AgentNotFoundError", ex.getCode());
assertEquals("Not found.", ex.getMessage());
assertEquals(404, ex.getHttpStatus());
}
@Test
void fromApiError_emptyCode_fallsBackToUnknown() {
String body = "{\"message\":\"oops\"}";
AgentIdPException ex = AgentIdPException.fromApiError(body, 503);
assertEquals("UNKNOWN_ERROR", ex.getCode());
assertEquals(503, ex.getHttpStatus());
}
@Test
void fromApiError_unparseable_fallsBackToUnknown() {
AgentIdPException ex = AgentIdPException.fromApiError("not json", 500);
assertEquals("UNKNOWN_ERROR", ex.getCode());
assertEquals(500, ex.getHttpStatus());
}
@Test
void fromOAuth2Error_validBody() {
String body = "{\"error\":\"invalid_client\",\"error_description\":\"Bad credentials.\"}";
AgentIdPException ex = AgentIdPException.fromOAuth2Error(body, 401);
assertEquals("invalid_client", ex.getCode());
assertEquals("Bad credentials.", ex.getMessage());
assertEquals(401, ex.getHttpStatus());
}
@Test
void fromOAuth2Error_unparseable_fallsBackToUnknown() {
AgentIdPException ex = AgentIdPException.fromOAuth2Error("garbage", 400);
assertEquals("unknown_error", ex.getCode());
}
@Test
void networkError_setsCodeAndCause() {
RuntimeException cause = new RuntimeException("connection refused");
AgentIdPException ex = AgentIdPException.networkError(cause);
assertEquals("NETWORK_ERROR", ex.getCode());
assertEquals(0, ex.getHttpStatus());
assertSame(cause, ex.getCause());
assertTrue(ex.getMessage().contains("connection refused"));
}
@Test
void toString_containsCodeAndStatus() {
AgentIdPException ex = new AgentIdPException("CODE", "msg", 400);
assertTrue(ex.toString().contains("CODE"));
assertTrue(ex.toString().contains("400"));
}
}

View File

@@ -0,0 +1,73 @@
package ai.sentryagent.idp;
import com.sun.net.httpserver.HttpExchange;
import com.sun.net.httpserver.HttpHandler;
import com.sun.net.httpserver.HttpServer;
import java.io.IOException;
import java.io.OutputStream;
import java.net.InetSocketAddress;
import java.nio.charset.StandardCharsets;
/**
* Lightweight in-process HTTP server for unit tests.
* Uses the JDK's built-in {@code com.sun.net.httpserver.HttpServer}.
*/
public final class MockServer {
private final HttpServer server;
private final int port;
public MockServer() throws IOException {
server = HttpServer.create(new InetSocketAddress(0), 0);
server.start();
port = server.getAddress().getPort();
}
/** Base URL of the mock server (e.g. {@code "http://localhost:PORT"}). */
public String baseUrl() {
return "http://localhost:" + port;
}
/**
* Registers a handler for an exact path.
*
* @param path URL path (e.g. {@code "/api/v1/agents"})
* @param statusCode HTTP status code to return
* @param responseBody JSON body to return (may be null for empty body)
*/
public void addHandler(String path, int statusCode, String responseBody) {
server.createContext(path, new StaticHandler(statusCode, responseBody));
}
/**
* Registers a custom handler for an exact path.
*/
public void addHandler(String path, HttpHandler handler) {
server.createContext(path, handler);
}
/** Stops the server. */
public void stop() {
server.stop(0);
}
private static final class StaticHandler implements HttpHandler {
private final int statusCode;
private final byte[] body;
StaticHandler(int statusCode, String body) {
this.statusCode = statusCode;
this.body = body != null ? body.getBytes(StandardCharsets.UTF_8) : new byte[0];
}
@Override
public void handle(HttpExchange exchange) throws IOException {
exchange.getResponseHeaders().set("Content-Type", "application/json");
exchange.sendResponseHeaders(statusCode, body.length);
try (OutputStream os = exchange.getResponseBody()) {
os.write(body);
}
}
}
}

View File

@@ -0,0 +1,102 @@
package ai.sentryagent.idp;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import java.io.IOException;
import java.net.http.HttpClient;
import java.time.Duration;
import java.util.concurrent.atomic.AtomicInteger;
import static org.junit.jupiter.api.Assertions.*;
class TokenManagerTest {
private MockServer srv;
private HttpClient httpClient;
private static final String TOKEN_BODY = """
{"access_token":"eyJ.abc.def","token_type":"Bearer","expires_in":3600,"scope":"agents:read"}
""";
@BeforeEach
void setUp() throws IOException {
srv = new MockServer();
httpClient = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(5)).build();
}
@AfterEach
void tearDown() { srv.stop(); }
@Test
void getToken_issuesToken() {
srv.addHandler("/api/v1/token", 200, TOKEN_BODY);
TokenManager tm = new TokenManager(srv.baseUrl(), "cid", "secret", "agents:read", httpClient);
assertEquals("eyJ.abc.def", tm.getToken());
}
@Test
void getToken_cachesToken() {
AtomicInteger calls = new AtomicInteger(0);
srv.addHandler("/api/v1/token", exchange -> {
calls.incrementAndGet();
byte[] body = TOKEN_BODY.getBytes();
exchange.getResponseHeaders().set("Content-Type", "application/json");
exchange.sendResponseHeaders(200, body.length);
exchange.getResponseBody().write(body);
exchange.getResponseBody().close();
});
TokenManager tm = new TokenManager(srv.baseUrl(), "cid", "secret", "agents:read", httpClient);
tm.getToken();
tm.getToken();
assertEquals(1, calls.get(), "Should only call the token endpoint once");
}
@Test
void getToken_authFailure_throwsAgentIdPException() {
srv.addHandler("/api/v1/token", 401,
"{\"error\":\"invalid_client\",\"error_description\":\"Bad credentials.\"}");
TokenManager tm = new TokenManager(srv.baseUrl(), "cid", "bad-secret", "agents:read", httpClient);
AgentIdPException ex = assertThrows(AgentIdPException.class, tm::getToken);
assertEquals("invalid_client", ex.getCode());
assertEquals(401, ex.getHttpStatus());
}
@Test
void clearCache_forcesRefetch() {
AtomicInteger calls = new AtomicInteger(0);
srv.addHandler("/api/v1/token", exchange -> {
calls.incrementAndGet();
byte[] body = TOKEN_BODY.getBytes();
exchange.getResponseHeaders().set("Content-Type", "application/json");
exchange.sendResponseHeaders(200, body.length);
exchange.getResponseBody().write(body);
exchange.getResponseBody().close();
});
TokenManager tm = new TokenManager(srv.baseUrl(), "cid", "secret", "agents:read", httpClient);
tm.getToken();
tm.clearCache();
tm.getToken();
assertEquals(2, calls.get(), "Should call token endpoint again after clearCache");
}
@Test
void getToken_threadSafe() throws InterruptedException {
srv.addHandler("/api/v1/token", 200, TOKEN_BODY);
TokenManager tm = new TokenManager(srv.baseUrl(), "cid", "secret", "agents:read", httpClient);
Thread[] threads = new Thread[10];
String[] results = new String[10];
for (int i = 0; i < threads.length; i++) {
int idx = i;
threads[idx] = new Thread(() -> results[idx] = tm.getToken());
}
for (Thread t : threads) t.start();
for (Thread t : threads) t.join();
for (String result : results) {
assertEquals("eyJ.abc.def", result);
}
}
}

View File

@@ -0,0 +1,133 @@
package ai.sentryagent.idp.services;
import ai.sentryagent.idp.AgentIdPException;
import ai.sentryagent.idp.MockServer;
import ai.sentryagent.idp.internal.HttpHelper;
import ai.sentryagent.idp.models.*;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import java.io.IOException;
import java.net.http.HttpClient;
import java.time.Duration;
import java.util.List;
import static org.junit.jupiter.api.Assertions.*;
class AgentRegistryClientTest {
private MockServer srv;
private AgentRegistryClient client;
private static final String AGENT_JSON = """
{"agentId":"uuid-1","email":"a@b.ai","agentType":"screener","version":"1.0.0",
"capabilities":["read"],"owner":"team","deploymentEnv":"production",
"status":"active","createdAt":"2026-01-01T00:00:00Z","updatedAt":"2026-01-01T00:00:00Z"}
""";
private static final String PAGINATED_AGENTS = """
{"data":[%s],"total":1,"page":1,"limit":20}
""".formatted(AGENT_JSON.strip());
@BeforeEach
void setUp() throws IOException {
srv = new MockServer();
HttpClient httpClient = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(5)).build();
HttpHelper httpHelper = new HttpHelper(httpClient);
client = new AgentRegistryClient(srv.baseUrl(), () -> "test-token", httpHelper);
}
@AfterEach
void tearDown() { srv.stop(); }
@Test
void registerAgent_returns201() {
srv.addHandler("/api/v1/agents", 201, AGENT_JSON);
Agent agent = client.registerAgent(RegisterAgentRequest.builder()
.email("a@b.ai").agentType("screener").version("1.0.0")
.capabilities(List.of("read")).owner("team").deploymentEnv("production")
.build());
assertEquals("uuid-1", agent.getAgentId());
assertEquals("screener", agent.getAgentType());
}
@Test
void listAgents_returnsPaginated() {
srv.addHandler("/api/v1/agents", 200, PAGINATED_AGENTS);
PaginatedAgents result = client.listAgents(null);
assertEquals(1, result.getTotal());
assertEquals("uuid-1", result.getData().get(0).getAgentId());
}
@Test
void getAgent_returnsAgent() {
srv.addHandler("/api/v1/agents/uuid-1", 200, AGENT_JSON);
Agent agent = client.getAgent("uuid-1");
assertEquals("uuid-1", agent.getAgentId());
}
@Test
void getAgent_notFound_throwsAgentIdPException() {
srv.addHandler("/api/v1/agents/bad-id", 404,
"{\"code\":\"AgentNotFoundError\",\"message\":\"Not found.\"}");
AgentIdPException ex = assertThrows(AgentIdPException.class, () -> client.getAgent("bad-id"));
assertEquals("AgentNotFoundError", ex.getCode());
assertEquals(404, ex.getHttpStatus());
}
@Test
void updateAgent_returnsUpdated() {
srv.addHandler("/api/v1/agents/uuid-1", 200, AGENT_JSON);
Agent agent = client.updateAgent("uuid-1",
UpdateAgentRequest.builder().version("2.0.0").build());
assertNotNull(agent);
assertEquals("uuid-1", agent.getAgentId());
}
@Test
void decommissionAgent_returns204() {
srv.addHandler("/api/v1/agents/uuid-1", 204, null);
assertDoesNotThrow(() -> client.decommissionAgent("uuid-1"));
}
@Test
void registerAgentAsync_returnsCompletableFuture() throws Exception {
srv.addHandler("/api/v1/agents", 201, AGENT_JSON);
Agent agent = client.registerAgentAsync(RegisterAgentRequest.builder()
.email("a@b.ai").agentType("screener").version("1.0.0")
.capabilities(List.of("read")).owner("team").deploymentEnv("production")
.build()).get();
assertEquals("uuid-1", agent.getAgentId());
}
@Test
void getAgentAsync_returnsCompletableFuture() throws Exception {
srv.addHandler("/api/v1/agents/uuid-1", 200, AGENT_JSON);
Agent agent = client.getAgentAsync("uuid-1").get();
assertEquals("uuid-1", agent.getAgentId());
}
@Test
void listAgentsAsync_withParams() throws Exception {
srv.addHandler("/api/v1/agents", 200, PAGINATED_AGENTS);
PaginatedAgents result = client.listAgentsAsync(
ListAgentsParams.builder().status("active").page(1).limit(20).build()
).get();
assertEquals(1, result.getTotal());
}
@Test
void decommissionAgentAsync_completesSuccessfully() throws Exception {
srv.addHandler("/api/v1/agents/uuid-1", 204, null);
assertDoesNotThrow(() -> client.decommissionAgentAsync("uuid-1").get());
}
@Test
void updateAgentAsync_returnsCompletableFuture() throws Exception {
srv.addHandler("/api/v1/agents/uuid-1", 200, AGENT_JSON);
Agent agent = client.updateAgentAsync("uuid-1",
UpdateAgentRequest.builder().version("2.0.0").build()).get();
assertEquals("uuid-1", agent.getAgentId());
}
}

View File

@@ -0,0 +1,93 @@
package ai.sentryagent.idp.services;
import ai.sentryagent.idp.AgentIdPException;
import ai.sentryagent.idp.MockServer;
import ai.sentryagent.idp.internal.HttpHelper;
import ai.sentryagent.idp.models.*;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import java.io.IOException;
import java.net.http.HttpClient;
import java.time.Duration;
import static org.junit.jupiter.api.Assertions.*;
class AuditClientTest {
private MockServer srv;
private AuditClient client;
private static final String AUDIT_EVENT = """
{"eventId":"ev-1","agentId":"uuid-1","action":"token.issued","outcome":"success",
"ipAddress":"1.2.3.4","userAgent":"curl","metadata":{},"timestamp":"2026-01-01T00:00:00Z"}
""";
private static final String PAGINATED_AUDIT = """
{"data":[%s],"total":1,"page":1,"limit":20}
""".formatted(AUDIT_EVENT.strip());
@BeforeEach
void setUp() throws IOException {
srv = new MockServer();
HttpClient httpClient = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(5)).build();
client = new AuditClient(srv.baseUrl(), () -> "test-token", new HttpHelper(httpClient));
}
@AfterEach
void tearDown() { srv.stop(); }
@Test
void queryAuditLog_returnsPaginated() {
srv.addHandler("/api/v1/audit", 200, PAGINATED_AUDIT);
PaginatedAuditEvents result = client.queryAuditLog(null);
assertEquals(1, result.getTotal());
assertEquals("ev-1", result.getData().get(0).getEventId());
}
@Test
void queryAuditLog_withParams() {
srv.addHandler("/api/v1/audit", 200, PAGINATED_AUDIT);
PaginatedAuditEvents result = client.queryAuditLog(
QueryAuditParams.builder()
.agentId("uuid-1")
.action("token.issued")
.fromDate("2026-01-01")
.build());
assertEquals(1, result.getTotal());
}
@Test
void getAuditEvent_returnsEvent() {
srv.addHandler("/api/v1/audit/ev-1", 200, AUDIT_EVENT);
AuditEvent event = client.getAuditEvent("ev-1");
assertEquals("ev-1", event.getEventId());
assertEquals("token.issued", event.getAction());
assertEquals("success", event.getOutcome());
}
@Test
void getAuditEvent_notFound_throwsAgentIdPException() {
srv.addHandler("/api/v1/audit/bad-id", 404,
"{\"code\":\"AuditEventNotFoundError\",\"message\":\"Event not found.\"}");
AgentIdPException ex = assertThrows(AgentIdPException.class,
() -> client.getAuditEvent("bad-id"));
assertEquals("AuditEventNotFoundError", ex.getCode());
assertEquals(404, ex.getHttpStatus());
}
@Test
void queryAuditLogAsync_returnsPaginated() throws Exception {
srv.addHandler("/api/v1/audit", 200, PAGINATED_AUDIT);
PaginatedAuditEvents result = client.queryAuditLogAsync(null).get();
assertEquals(1, result.getTotal());
}
@Test
void getAuditEventAsync_returnsEvent() throws Exception {
srv.addHandler("/api/v1/audit/ev-1", 200, AUDIT_EVENT);
AuditEvent event = client.getAuditEventAsync("ev-1").get();
assertEquals("ev-1", event.getEventId());
}
}

View File

@@ -0,0 +1,116 @@
package ai.sentryagent.idp.services;
import ai.sentryagent.idp.AgentIdPException;
import ai.sentryagent.idp.MockServer;
import ai.sentryagent.idp.internal.HttpHelper;
import ai.sentryagent.idp.models.*;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import java.io.IOException;
import java.net.http.HttpClient;
import java.time.Duration;
import static org.junit.jupiter.api.Assertions.*;
class CredentialClientTest {
private MockServer srv;
private CredentialClient client;
private static final String CRED_JSON = """
{"credentialId":"cred-1","clientId":"uuid-1","status":"active",
"createdAt":"2026-01-01T00:00:00Z","expiresAt":null,"revokedAt":null}
""";
private static final String CRED_WITH_SECRET = """
{"credentialId":"cred-1","clientId":"uuid-1","status":"active",
"createdAt":"2026-01-01T00:00:00Z","expiresAt":null,"revokedAt":null,
"clientSecret":"sk_live_abc"}
""";
private static final String PAGINATED_CREDS = """
{"data":[%s],"total":1,"page":1,"limit":20}
""".formatted(CRED_JSON.strip());
@BeforeEach
void setUp() throws IOException {
srv = new MockServer();
HttpClient httpClient = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(5)).build();
client = new CredentialClient(srv.baseUrl(), () -> "test-token", new HttpHelper(httpClient));
}
@AfterEach
void tearDown() { srv.stop(); }
@Test
void generateCredential_returnsSecret() {
srv.addHandler("/api/v1/agents/uuid-1/credentials", 201, CRED_WITH_SECRET);
CredentialWithSecret cred = client.generateCredential("uuid-1");
assertEquals("sk_live_abc", cred.getClientSecret());
assertEquals("cred-1", cred.getCredentialId());
}
@Test
void listCredentials_returnsPaginated() {
srv.addHandler("/api/v1/agents/uuid-1/credentials", 200, PAGINATED_CREDS);
PaginatedCredentials result = client.listCredentials("uuid-1", null, null);
assertEquals(1, result.getTotal());
assertEquals("cred-1", result.getData().get(0).getCredentialId());
}
@Test
void rotateCredential_returnsNewSecret() {
srv.addHandler("/api/v1/agents/uuid-1/credentials/cred-1/rotate", 200, CRED_WITH_SECRET);
CredentialWithSecret cred = client.rotateCredential("uuid-1", "cred-1");
assertEquals("sk_live_abc", cred.getClientSecret());
}
@Test
void revokeCredential_returnsRevoked() {
String revoked = """
{"credentialId":"cred-1","clientId":"uuid-1","status":"revoked",
"createdAt":"2026-01-01T00:00:00Z","expiresAt":null,
"revokedAt":"2026-01-02T00:00:00Z"}
""";
srv.addHandler("/api/v1/agents/uuid-1/credentials/cred-1", 200, revoked);
Credential cred = client.revokeCredential("uuid-1", "cred-1");
assertEquals("revoked", cred.getStatus());
}
@Test
void generateCredential_error_throwsAgentIdPException() {
srv.addHandler("/api/v1/agents/bad/credentials", 404,
"{\"code\":\"AgentNotFoundError\",\"message\":\"Not found.\"}");
AgentIdPException ex = assertThrows(AgentIdPException.class,
() -> client.generateCredential("bad"));
assertEquals(404, ex.getHttpStatus());
}
@Test
void generateCredentialAsync_returnsCompletableFuture() throws Exception {
srv.addHandler("/api/v1/agents/uuid-1/credentials", 201, CRED_WITH_SECRET);
CredentialWithSecret cred = client.generateCredentialAsync("uuid-1").get();
assertEquals("sk_live_abc", cred.getClientSecret());
}
@Test
void rotateCredentialAsync_returnsCompletableFuture() throws Exception {
srv.addHandler("/api/v1/agents/uuid-1/credentials/cred-1/rotate", 200, CRED_WITH_SECRET);
CredentialWithSecret cred = client.rotateCredentialAsync("uuid-1", "cred-1").get();
assertEquals("sk_live_abc", cred.getClientSecret());
}
@Test
void revokeCredentialAsync_returnsCompletableFuture() throws Exception {
String revoked = """
{"credentialId":"cred-1","clientId":"uuid-1","status":"revoked",
"createdAt":"2026-01-01T00:00:00Z","expiresAt":null,
"revokedAt":"2026-01-02T00:00:00Z"}
""";
srv.addHandler("/api/v1/agents/uuid-1/credentials/cred-1", 200, revoked);
Credential cred = client.revokeCredentialAsync("uuid-1", "cred-1").get();
assertEquals("revoked", cred.getStatus());
}
}

View File

@@ -0,0 +1,86 @@
package ai.sentryagent.idp.services;
import ai.sentryagent.idp.AgentIdPException;
import ai.sentryagent.idp.MockServer;
import ai.sentryagent.idp.models.IntrospectResponse;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import java.io.IOException;
import java.net.http.HttpClient;
import java.time.Duration;
import static org.junit.jupiter.api.Assertions.*;
class TokenClientTest {
private MockServer srv;
private TokenClient client;
@BeforeEach
void setUp() throws IOException {
srv = new MockServer();
HttpClient httpClient = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(5)).build();
client = new TokenClient(srv.baseUrl(), () -> "test-token", httpClient);
}
@AfterEach
void tearDown() { srv.stop(); }
@Test
void introspectToken_active() {
srv.addHandler("/api/v1/token/introspect", 200,
"{\"active\":true,\"sub\":\"uuid-1\",\"exp\":9999999999}");
IntrospectResponse result = client.introspectToken("some-token");
assertTrue(result.isActive());
assertEquals("uuid-1", result.getSub());
}
@Test
void introspectToken_inactive() {
srv.addHandler("/api/v1/token/introspect", 200, "{\"active\":false}");
IntrospectResponse result = client.introspectToken("expired-token");
assertFalse(result.isActive());
assertNull(result.getSub());
}
@Test
void revokeToken_succeeds() {
srv.addHandler("/api/v1/token/revoke", 200, "{}");
assertDoesNotThrow(() -> client.revokeToken("some-token"));
}
@Test
void introspectToken_error_throwsAgentIdPException() {
srv.addHandler("/api/v1/token/introspect", 401,
"{\"code\":\"UnauthorizedError\",\"message\":\"Invalid token.\"}");
AgentIdPException ex = assertThrows(AgentIdPException.class,
() -> client.introspectToken("bad-token"));
assertEquals(401, ex.getHttpStatus());
assertEquals("UnauthorizedError", ex.getCode());
}
@Test
void revokeToken_error_throwsAgentIdPException() {
srv.addHandler("/api/v1/token/revoke", 401,
"{\"code\":\"UnauthorizedError\",\"message\":\"Invalid token.\"}");
AgentIdPException ex = assertThrows(AgentIdPException.class,
() -> client.revokeToken("bad-token"));
assertEquals(401, ex.getHttpStatus());
}
@Test
void introspectTokenAsync_active() throws Exception {
srv.addHandler("/api/v1/token/introspect", 200,
"{\"active\":true,\"sub\":\"uuid-1\",\"exp\":9999999999}");
IntrospectResponse result = client.introspectTokenAsync("some-token").get();
assertTrue(result.isActive());
}
@Test
void revokeTokenAsync_succeeds() throws Exception {
srv.addHandler("/api/v1/token/revoke", 200, "{}");
assertDoesNotThrow(() -> client.revokeTokenAsync("some-token").get());
}
}