test(ccsp): add bidirectional cross-validation tests against ccsp-v1 schema

This commit is contained in:
Claudecio Martins
2026-10-20 16:42:06 -03:00
parent 957f9c9abd
commit 9dcbac61ec
4 changed files with 733 additions and 1 deletions
@@ -927,6 +927,7 @@ public class CcspServer {
resp.put("status", "success");
resp.put("default_seal_id", PdfSealThemeRegistry.getDefaultTheme().id());
resp.put("seals", sealList);
resp.put("themes", sealList);
sendJsonResponse(exchange, 200, resp);
}
@@ -0,0 +1,425 @@
package br.com.cybercore.signer.core.protocol;
import br.com.cybercore.signer.core.crypto.SigningService;
import br.com.cybercore.signer.core.domain.CertificateStatus;
import br.com.cybercore.signer.core.domain.CertificateType;
import br.com.cybercore.signer.core.domain.ManagedCertificate;
import br.com.cybercore.signer.core.domain.SigningContext;
import br.com.cybercore.signer.core.domain.SigningKeyMaterial;
import br.com.cybercore.signer.core.domain.SigningResult;
import br.com.cybercore.signer.core.provider.CertificateManager;
import br.com.cybercore.signer.core.provider.CertificateProvider;
import br.com.cybercore.signer.core.provider.ProviderType;
import br.com.cybercore.signer.core.security.ApplicationRegistry;
import br.com.cybercore.signer.core.security.AuthorizedApplication;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.bouncycastle.asn1.x500.X500Name;
import org.bouncycastle.cert.X509v3CertificateBuilder;
import org.bouncycastle.cert.jcajce.JcaX509CertificateConverter;
import org.bouncycastle.cert.jcajce.JcaX509v3CertificateBuilder;
import org.bouncycastle.operator.ContentSigner;
import org.bouncycastle.operator.jcajce.JcaContentSignerBuilder;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import java.math.BigInteger;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.security.KeyPair;
import java.security.KeyPairGenerator;
import java.security.cert.X509Certificate;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
import java.util.ArrayList;
import java.util.Date;
import java.util.HashSet;
import java.util.List;
import java.util.Optional;
import java.util.Set;
import java.util.UUID;
import java.util.concurrent.CompletableFuture;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
@DisplayName("Testes de Validação Cruzada contra o JSON Schema CCSP/1 (ccsp-v1.schema.json)")
class CcspSchemaValidationTest {
private CcspServer server;
private HttpClient httpClient;
private String baseUrl;
private ObjectMapper objectMapper;
private JsonNode schemaRoot;
@BeforeEach
void setUp(@TempDir Path tempDir) throws Exception {
objectMapper = new ObjectMapper();
// Localiza e carrega docs/ccsp-v1.schema.json
Path schemaPath = Path.of("../../docs/ccsp-v1.schema.json");
if (!Files.exists(schemaPath)) {
schemaPath = Path.of("docs/ccsp-v1.schema.json");
}
assertThat(schemaPath).exists();
schemaRoot = objectMapper.readTree(Files.readString(schemaPath, StandardCharsets.UTF_8));
CertificateManager certManager = new CertificateManager();
SigningService signingService = mock(SigningService.class);
ApplicationRegistry appRegistry = new ApplicationRegistry(tempDir.resolve("apps.json"));
KeyPairGenerator kpg = KeyPairGenerator.getInstance("RSA");
kpg.initialize(2048);
KeyPair kp = kpg.generateKeyPair();
long now = System.currentTimeMillis();
Date notBefore = new Date(now - 100000);
Date notAfter = new Date(now + 365L * 24 * 3600 * 1000);
X500Name subject = new X500Name("CN=Schema Test ICP-Brasil:12345678901, O=CyberCore, C=BR");
X509v3CertificateBuilder certBuilder = new JcaX509v3CertificateBuilder(
subject,
BigInteger.valueOf(System.currentTimeMillis()),
notBefore,
notAfter,
subject,
kp.getPublic()
);
ContentSigner signer = new JcaContentSignerBuilder("SHA256withRSA").build(kp.getPrivate());
X509Certificate cert = new JcaX509CertificateConverter().getCertificate(certBuilder.build(signer));
appRegistry.register(new AuthorizedApplication(
"app-schema-test",
"Schema Validation Test App",
Set.of("http://localhost:3000", "http://127.0.0.1:3000"),
true,
Instant.now()
));
ManagedCertificate testCert = new ManagedCertificate(
"cert-schema-1",
"Certificado Schema Teste",
CertificateType.A1,
CertificateStatus.AVAILABLE,
"CN=Schema Test ICP-Brasil:12345678901, O=CyberCore, C=BR",
"CN=Schema Test ICP-Brasil:12345678901, O=CyberCore, C=BR",
"12345678",
"a1b2c3",
"d4e5f6",
Instant.now().minus(10, ChronoUnit.DAYS),
Instant.now().plus(300, ChronoUnit.DAYS),
"SHA256withRSA",
2048,
"Schema Test ICP-Brasil",
null,
null,
"provider-schema-test",
"Certificado Schema Teste"
);
CertificateProvider dummyProvider = new CertificateProvider() {
@Override public String getProviderId() { return "provider-schema-test"; }
@Override public String getDisplayName() { return "Provedor Teste Schema"; }
@Override public ProviderType getType() { return ProviderType.A1_FILE; }
@Override public boolean isAvailable() { return true; }
@Override public List<ManagedCertificate> listCertificates() { return List.of(testCert); }
@Override public SigningResult sign(SigningContext context) {
return new SigningResult(context.requestId(), new byte[]{1, 2, 3}, "mock-cert", List.of("mock-cert"), "SHA256withRSA", Instant.now());
}
@Override public Optional<SigningKeyMaterial> getKeyMaterial(String certificateId, char[] pinOrPassword) {
return Optional.of(new SigningKeyMaterial(kp.getPrivate(), new X509Certificate[]{cert}, null));
}
};
certManager.registerProvider(dummyProvider);
certManager.discoverCertificates();
when(signingService.sign(any())).thenReturn(
new SigningResult("req-1", new byte[]{10, 20, 30}, "base64-cert", List.of("base64-cert"), "SHA256withRSA", Instant.now())
);
server = new CcspServer(0, certManager, signingService, appRegistry, new CcspUserPromptListener() {
@Override
public CompletableFuture<Boolean> requestApplicationAuthorization(String origin, String appName, String applicationId) {
return CompletableFuture.completedFuture(true);
}
@Override
public CompletableFuture<Boolean> requestSignatureConfirmation(CcspRequest request, ManagedCertificate certificate) {
return CompletableFuture.completedFuture(true);
}
});
server.start();
int port = server.getPort();
baseUrl = "http://127.0.0.1:" + port;
httpClient = HttpClient.newHttpClient();
}
@AfterEach
void tearDown() {
if (server != null) {
server.stop();
}
}
@Test
@DisplayName("Garante integridade e completude do catálogo de ErrorCode no schema formal")
void shouldValidateErrorCodeCatalogInSchema() {
JsonNode errorCodeEnum = schemaRoot.path("definitions").path("ErrorCode").path("enum");
assertThat(errorCodeEnum.isArray()).isTrue();
Set<String> errorCodes = new HashSet<>();
errorCodeEnum.forEach(node -> errorCodes.add(node.asText()));
assertThat(errorCodes).contains(
"CERTIFICATE_NOT_FOUND",
"CERTIFICATE_EXPIRED",
"CERTIFICATE_INVALID",
"PRIVATE_KEY_NOT_AVAILABLE",
"TOKEN_NOT_FOUND",
"TOKEN_REMOVED",
"PIN_REQUIRED",
"PIN_INCORRECT",
"PKCS11_ERROR",
"SIGNATURE_FAILED",
"APPLICATION_NOT_AUTHORIZED",
"REQUEST_EXPIRED",
"REPLAY_DETECTED",
"INVALID_ORIGIN",
"UNSUPPORTED_ALGORITHM",
"USER_CANCELLED",
"PROVIDER_NOT_AVAILABLE",
"COMMUNICATION_ERROR",
"AGENT_NOT_RUNNING",
"TIMEOUT_ERROR",
"BAD_REQUEST"
);
}
@Test
@DisplayName("Valida resposta do endpoint /ccsp/v1/status contra a definição StatusResponse")
void shouldValidateStatusEndpointResponseAgainstSchema() throws Exception {
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(baseUrl + "/ccsp/v1/status"))
.GET()
.build();
HttpResponse<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString());
assertThat(response.statusCode()).isEqualTo(200);
JsonNode json = objectMapper.readTree(response.body());
validateAgainstDefinition(json, "StatusResponse");
assertThat(json.path("version").asText()).isEqualTo("1");
assertThat(json.path("status").asText()).isEqualTo("ready");
}
@Test
@DisplayName("Valida resposta do endpoint /ccsp/v1/authorize contra a definição AuthorizeResponse")
void shouldValidateAuthorizeEndpointResponseAgainstSchema() throws Exception {
String payload = """
{
"version": "1",
"application_id": "app-schema-test",
"name": "Schema Validation Test App"
}
""";
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(baseUrl + "/ccsp/v1/authorize"))
.header("Content-Type", "application/json")
.header("Origin", "http://localhost:3000")
.POST(HttpRequest.BodyPublishers.ofString(payload))
.build();
HttpResponse<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString());
assertThat(response.statusCode()).isEqualTo(200);
JsonNode json = objectMapper.readTree(response.body());
validateAgainstDefinition(json, "AuthorizeResponse");
assertThat(json.path("version").asText()).isEqualTo("1");
assertThat(json.path("status").asText()).isEqualTo("authorized");
assertThat(json.path("application_id").asText()).isEqualTo("app-schema-test");
}
@Test
@DisplayName("Valida resposta do endpoint /ccsp/v1/certificates contra CertificatesResponse e CertificateDto")
void shouldValidateCertificatesEndpointResponseAgainstSchema() throws Exception {
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(baseUrl + "/ccsp/v1/certificates?application_id=app-schema-test"))
.header("Origin", "http://localhost:3000")
.GET()
.build();
HttpResponse<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString());
assertThat(response.statusCode()).isEqualTo(200);
JsonNode json = objectMapper.readTree(response.body());
validateAgainstDefinition(json, "CertificatesResponse");
JsonNode certificates = json.path("certificates");
assertThat(certificates.isArray()).isTrue();
assertThat(certificates.size()).isGreaterThanOrEqualTo(1);
for (JsonNode certNode : certificates) {
validateAgainstDefinition(certNode, "CertificateItem");
}
}
@Test
@DisplayName("Valida resposta do endpoint /ccsp/v1/sign contra a definição SignResponse")
void shouldValidateSignEndpointResponseAgainstSchema() throws Exception {
String payload = """
{
"version": "1",
"request_id": "%s",
"application_id": "app-schema-test",
"operation": "sign",
"algorithm": "SHA256withRSA",
"digest": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=",
"certificate_hint": "cert-schema-1",
"document_name": "documento_teste.txt",
"nonce": "%s",
"expires_at": "%s"
}
""".formatted(
UUID.randomUUID(),
UUID.randomUUID(),
Instant.now().plus(60, ChronoUnit.SECONDS).toString()
);
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(baseUrl + "/ccsp/v1/sign"))
.header("Content-Type", "application/json")
.header("Origin", "http://localhost:3000")
.POST(HttpRequest.BodyPublishers.ofString(payload))
.build();
HttpResponse<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString());
assertThat(response.statusCode()).isEqualTo(200);
JsonNode json = objectMapper.readTree(response.body());
validateAgainstDefinition(json, "SignResponse");
assertThat(json.path("version").asText()).isEqualTo("1");
assertThat(json.path("status").asText()).isEqualTo("signed");
assertThat(json.path("signature").asText()).isNotEmpty();
assertThat(json.path("certificate").asText()).isNotEmpty();
}
@Test
@DisplayName("Valida resposta de erro contra a definição ErrorResponse e ErrorCode catalog")
void shouldValidateErrorResponseAgainstSchema() throws Exception {
String payload = """
{
"version": "1",
"request_id": "%s",
"application_id": "app-schema-test",
"operation": "sign",
"algorithm": "SHA256withRSA",
"digest": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=",
"certificate_hint": "non-existent-cert-id",
"nonce": "%s",
"expires_at": "%s"
}
""".formatted(
UUID.randomUUID(),
UUID.randomUUID(),
Instant.now().plus(60, ChronoUnit.SECONDS).toString()
);
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(baseUrl + "/ccsp/v1/sign"))
.header("Content-Type", "application/json")
.header("Origin", "http://localhost:3000")
.POST(HttpRequest.BodyPublishers.ofString(payload))
.build();
HttpResponse<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString());
assertThat(response.statusCode()).isGreaterThanOrEqualTo(400);
JsonNode json = objectMapper.readTree(response.body());
validateAgainstDefinition(json, "ErrorResponse");
String errorCode = json.path("error_code").asText();
JsonNode errorCodeEnum = schemaRoot.path("definitions").path("ErrorCode").path("enum");
List<String> validCodes = new ArrayList<>();
errorCodeEnum.forEach(node -> validCodes.add(node.asText()));
assertThat(validCodes).contains(errorCode);
}
@Test
@DisplayName("Valida resposta do endpoint /ccsp/v1/seals contra a definição SealsResponse")
void shouldValidateSealsEndpointResponseAgainstSchema() throws Exception {
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(baseUrl + "/ccsp/v1/seals"))
.GET()
.build();
HttpResponse<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString());
assertThat(response.statusCode()).isEqualTo(200);
JsonNode json = objectMapper.readTree(response.body());
validateAgainstDefinition(json, "SealsResponse");
}
private void validateAgainstDefinition(JsonNode json, String definitionName) {
JsonNode def = schemaRoot.path("definitions").path(definitionName);
assertThat(def.isMissingNode()).as("Definição %s deve existir no schema", definitionName).isFalse();
JsonNode required = def.path("required");
if (required.isArray()) {
for (JsonNode reqNode : required) {
String field = reqNode.asText();
assertThat(json.hasNonNull(field))
.as("Campo obrigatório '%s' da definição '%s' deve estar presente e não nulo", field, definitionName)
.isTrue();
}
}
JsonNode properties = def.path("properties");
if (properties.isObject()) {
properties.fieldNames().forEachRemaining(propName -> {
if (json.has(propName) && !json.get(propName).isNull()) {
JsonNode propDef = properties.get(propName);
String expectedType = propDef.path("type").asText(null);
if (expectedType != null) {
assertTypeMatches(json.get(propName), expectedType, propName, definitionName);
}
}
});
}
}
private void assertTypeMatches(JsonNode valueNode, String expectedType, String propName, String defName) {
switch (expectedType) {
case "string" -> assertThat(valueNode.isTextual())
.as("Propriedade '%s' em '%s' deve ser string", propName, defName).isTrue();
case "boolean" -> assertThat(valueNode.isBoolean())
.as("Propriedade '%s' em '%s' deve ser boolean", propName, defName).isTrue();
case "number" -> assertThat(valueNode.isNumber())
.as("Propriedade '%s' em '%s' deve ser number", propName, defName).isTrue();
case "integer" -> assertThat(valueNode.isIntegralNumber())
.as("Propriedade '%s' em '%s' deve ser integer", propName, defName).isTrue();
case "array" -> assertThat(valueNode.isArray())
.as("Propriedade '%s' em '%s' deve ser array", propName, defName).isTrue();
case "object" -> assertThat(valueNode.isObject())
.as("Propriedade '%s' em '%s' deve ser object", propName, defName).isTrue();
default -> {}
}
}
}