test(ccsp): add bidirectional cross-validation tests against ccsp-v1 schema
This commit is contained in:
@@ -34,6 +34,8 @@
|
||||
"USER_CANCELLED",
|
||||
"PROVIDER_NOT_AVAILABLE",
|
||||
"COMMUNICATION_ERROR",
|
||||
"AGENT_NOT_RUNNING",
|
||||
"TIMEOUT_ERROR",
|
||||
"BAD_REQUEST",
|
||||
"METHOD_NOT_ALLOWED"
|
||||
],
|
||||
@@ -149,11 +151,15 @@
|
||||
|
||||
"SealsResponse": {
|
||||
"type": "object",
|
||||
"required": ["version", "status", "themes"],
|
||||
"required": ["version", "status", "seals"],
|
||||
"properties": {
|
||||
"version": { "type": "string", "enum": ["1"] },
|
||||
"status": { "type": "string", "enum": ["success"] },
|
||||
"default_seal_id": { "type": ["string", "null"] },
|
||||
"seals": {
|
||||
"type": "array",
|
||||
"items": { "$ref": "#/definitions/SealThemeItem" }
|
||||
},
|
||||
"themes": {
|
||||
"type": "array",
|
||||
"items": { "$ref": "#/definitions/SealThemeItem" }
|
||||
@@ -351,6 +357,61 @@
|
||||
"document_name": { "type": ["string", "null"] }
|
||||
},
|
||||
"additionalProperties": true
|
||||
},
|
||||
|
||||
"AuthenticateRequest": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"version",
|
||||
"request_id",
|
||||
"application_id",
|
||||
"operation",
|
||||
"challenge",
|
||||
"nonce",
|
||||
"expires_at"
|
||||
],
|
||||
"properties": {
|
||||
"version": { "type": "string", "enum": ["1"] },
|
||||
"request_id": { "type": "string" },
|
||||
"application_id": { "type": "string" },
|
||||
"operation": { "type": "string", "enum": ["authenticate"] },
|
||||
"algorithm": { "type": "string" },
|
||||
"digest": { "type": ["string", "null"] },
|
||||
"challenge": { "type": "string" },
|
||||
"purpose": { "type": ["string", "null"] },
|
||||
"document_type": { "type": ["string", "null"] },
|
||||
"certificate_hint": { "type": ["string", "null"] },
|
||||
"nonce": { "type": "string" },
|
||||
"expires_at": { "type": "string" }
|
||||
},
|
||||
"additionalProperties": true
|
||||
},
|
||||
|
||||
"AuthenticateResponse": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"version",
|
||||
"request_id",
|
||||
"status",
|
||||
"signature",
|
||||
"certificate",
|
||||
"algorithm"
|
||||
],
|
||||
"properties": {
|
||||
"version": { "type": "string", "enum": ["1"] },
|
||||
"request_id": { "type": "string" },
|
||||
"status": { "type": "string", "enum": ["authenticated", "signed"] },
|
||||
"signature": { "type": "string" },
|
||||
"certificate": { "type": "string" },
|
||||
"certificate_chain": {
|
||||
"type": "array",
|
||||
"items": { "type": "string" }
|
||||
},
|
||||
"algorithm": { "type": "string" },
|
||||
"subject_name": { "type": ["string", "null"] },
|
||||
"subject_cpf_cnpj": { "type": ["string", "null"] }
|
||||
},
|
||||
"additionalProperties": true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+1
@@ -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);
|
||||
}
|
||||
|
||||
+425
@@ -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 -> {}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,245 @@
|
||||
import { describe, it, before } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { CyberCoreSigner } from '../src/signer-sdk.js';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
describe('@cybercore/signer-sdk - Validação Cruzada de Payloads do SDK contra JSON Schema CCSP/1', () => {
|
||||
let schema;
|
||||
|
||||
before(() => {
|
||||
const schemaPath = path.resolve(__dirname, '../../docs/ccsp-v1.schema.json');
|
||||
assert.ok(fs.existsSync(schemaPath), `Arquivo de schema não encontrado em: ${schemaPath}`);
|
||||
schema = JSON.parse(fs.readFileSync(schemaPath, 'utf8'));
|
||||
});
|
||||
|
||||
function validatePayloadAgainstDefinition(payload, definitionName) {
|
||||
const def = schema.definitions?.[definitionName];
|
||||
assert.ok(def, `Definição '${definitionName}' deve existir no JSON Schema.`);
|
||||
|
||||
// 1. Verifica campos obrigatórios declarados em 'required'
|
||||
if (Array.isArray(def.required)) {
|
||||
for (const field of def.required) {
|
||||
assert.ok(
|
||||
payload[field] !== undefined && payload[field] !== null,
|
||||
`Payload para '${definitionName}' não contém o campo obrigatório '${field}'`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Verifica tipos das propriedades declaradas
|
||||
if (def.properties) {
|
||||
for (const [key, value] of Object.entries(payload)) {
|
||||
if (value === null || value === undefined) continue;
|
||||
const propDef = def.properties[key];
|
||||
if (!propDef) continue;
|
||||
|
||||
const expectedType = propDef.type;
|
||||
if (typeof expectedType === 'string') {
|
||||
assertTypeMatches(value, expectedType, key, definitionName);
|
||||
} else if (Array.isArray(expectedType)) {
|
||||
// Tipos múltiplos (ex: ["string", "null"])
|
||||
const matchesAny = expectedType.some(t => checkType(value, t));
|
||||
assert.ok(matchesAny, `Campo '${key}' em '${definitionName}' não corresponde a nenhum dos tipos permitidos: ${expectedType.join(', ')}`);
|
||||
}
|
||||
|
||||
// Validação de enums
|
||||
if (Array.isArray(propDef.enum)) {
|
||||
assert.ok(
|
||||
propDef.enum.includes(value),
|
||||
`Campo '${key}' em '${definitionName}' possui valor '${value}' fora do enum permitido: [${propDef.enum.join(', ')}]`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function checkType(value, expectedType) {
|
||||
switch (expectedType) {
|
||||
case 'string': return typeof value === 'string';
|
||||
case 'boolean': return typeof value === 'boolean';
|
||||
case 'number': return typeof value === 'number';
|
||||
case 'integer': return Number.isInteger(value);
|
||||
case 'array': return Array.isArray(value);
|
||||
case 'object': return typeof value === 'object' && value !== null && !Array.isArray(value);
|
||||
case 'null': return value === null;
|
||||
default: return true;
|
||||
}
|
||||
}
|
||||
|
||||
function assertTypeMatches(value, expectedType, propName, defName) {
|
||||
assert.ok(
|
||||
checkType(value, expectedType),
|
||||
`Campo '${propName}' em '${defName}' deve ser do tipo '${expectedType}', recebido: ${typeof value}`
|
||||
);
|
||||
}
|
||||
|
||||
it('deve gerar payload válido para AuthorizeRequest no connect()', async () => {
|
||||
const signer = new CyberCoreSigner({ applicationName: 'App Teste Schema' });
|
||||
let capturedPayload = null;
|
||||
|
||||
signer._fetch = async (endpoint, options) => {
|
||||
if (endpoint === '/ccsp/v1/authorize') {
|
||||
capturedPayload = JSON.parse(options.body);
|
||||
return { version: '1', status: 'authorized', application_id: capturedPayload.application_id };
|
||||
}
|
||||
return {};
|
||||
};
|
||||
|
||||
await signer.connect();
|
||||
assert.ok(capturedPayload);
|
||||
validatePayloadAgainstDefinition(capturedPayload, 'AuthorizeRequest');
|
||||
assert.equal(capturedPayload.version, '1');
|
||||
assert.equal(capturedPayload.application_id, signer.applicationId);
|
||||
assert.equal(capturedPayload.name, 'App Teste Schema');
|
||||
});
|
||||
|
||||
it('deve gerar payload válido para SignRequest no sign()', async () => {
|
||||
const signer = new CyberCoreSigner();
|
||||
let capturedPayload = null;
|
||||
|
||||
signer._fetch = async (endpoint, options) => {
|
||||
if (endpoint === '/ccsp/v1/sign') {
|
||||
capturedPayload = JSON.parse(options.body);
|
||||
return {
|
||||
version: '1',
|
||||
request_id: capturedPayload.request_id,
|
||||
status: 'signed',
|
||||
signature: 'SIG_BASE64',
|
||||
certificate: 'CERT_BASE64',
|
||||
algorithm: capturedPayload.algorithm,
|
||||
document_name: capturedPayload.document_name
|
||||
};
|
||||
}
|
||||
return {};
|
||||
};
|
||||
|
||||
await signer.sign({
|
||||
certificateId: 'cert-1',
|
||||
digest: Buffer.alloc(32, 5).toString('base64'),
|
||||
documentName: 'documento_auditoria.pdf',
|
||||
documentType: 'PDF'
|
||||
});
|
||||
|
||||
assert.ok(capturedPayload);
|
||||
validatePayloadAgainstDefinition(capturedPayload, 'SignRequest');
|
||||
assert.equal(capturedPayload.version, '1');
|
||||
assert.equal(capturedPayload.operation, 'sign');
|
||||
assert.equal(capturedPayload.algorithm, 'SHA256withRSA');
|
||||
assert.ok(capturedPayload.request_id);
|
||||
assert.ok(capturedPayload.nonce);
|
||||
assert.ok(capturedPayload.expires_at);
|
||||
});
|
||||
|
||||
it('deve gerar payload válido para SignPdfRequest no signPdfDocument()', async () => {
|
||||
const signer = new CyberCoreSigner();
|
||||
let capturedPayload = null;
|
||||
|
||||
signer._fetch = async (endpoint, options) => {
|
||||
if (endpoint === '/ccsp/v1/sign-pdf') {
|
||||
capturedPayload = JSON.parse(options.body);
|
||||
return {
|
||||
version: '1',
|
||||
request_id: capturedPayload.request_id,
|
||||
status: 'signed',
|
||||
signed_pdf: 'MOCK_SIGNED_PDF_BASE64',
|
||||
certificate: 'CERT_BASE64',
|
||||
algorithm: 'SHA256withRSA',
|
||||
document_name: capturedPayload.document_name
|
||||
};
|
||||
}
|
||||
return {};
|
||||
};
|
||||
|
||||
await signer.signPdfDocument({
|
||||
pdf: Buffer.from('%PDF-1.4 Mock Raw PDF Content'),
|
||||
certificateId: 'token-a3-smartcard',
|
||||
documentName: 'termo_adesao.pdf',
|
||||
page: 1,
|
||||
x: 100,
|
||||
y: 200,
|
||||
scale: 1.0,
|
||||
sealThemeId: 'icp-brasil'
|
||||
});
|
||||
|
||||
assert.ok(capturedPayload);
|
||||
validatePayloadAgainstDefinition(capturedPayload, 'SignPdfRequest');
|
||||
assert.equal(capturedPayload.version, '1');
|
||||
assert.equal(capturedPayload.operation, 'sign_pdf');
|
||||
assert.equal(capturedPayload.page, 1);
|
||||
assert.equal(capturedPayload.seal_theme_id, 'icp-brasil');
|
||||
});
|
||||
|
||||
it('deve gerar payload válido para SignXmlRequest no signXmlDocument()', async () => {
|
||||
const signer = new CyberCoreSigner();
|
||||
let capturedPayload = null;
|
||||
|
||||
signer._fetch = async (endpoint, options) => {
|
||||
if (endpoint === '/ccsp/v1/sign-xml') {
|
||||
capturedPayload = JSON.parse(options.body);
|
||||
return {
|
||||
version: '1',
|
||||
request_id: capturedPayload.request_id,
|
||||
status: 'signed',
|
||||
signed_xml: '<nfeProc>...</nfeProc>',
|
||||
certificate: 'CERT_BASE64',
|
||||
algorithm: 'http://www.w3.org/2001/04/xmldsig-more#rsa-sha256',
|
||||
document_name: capturedPayload.document_name
|
||||
};
|
||||
}
|
||||
return {};
|
||||
};
|
||||
|
||||
await signer.signXmlDocument({
|
||||
xml: '<nfeProc><infNFe Id="NFe3524">Dados</infNFe></nfeProc>',
|
||||
certificateId: 'cert-a1-pj',
|
||||
documentName: 'nota_fiscal.xml',
|
||||
profile: 'XADES_BES',
|
||||
targetElementTag: 'infNFe',
|
||||
targetElementId: 'NFe3524'
|
||||
});
|
||||
|
||||
assert.ok(capturedPayload);
|
||||
validatePayloadAgainstDefinition(capturedPayload, 'SignXmlRequest');
|
||||
assert.equal(capturedPayload.version, '1');
|
||||
assert.equal(capturedPayload.operation, 'sign_xml');
|
||||
assert.equal(capturedPayload.profile, 'XADES_BES');
|
||||
assert.equal(capturedPayload.target_element_tag, 'infNFe');
|
||||
});
|
||||
|
||||
it('deve gerar payload válido para AuthenticateRequest no authenticate()', async () => {
|
||||
const signer = new CyberCoreSigner();
|
||||
let capturedPayload = null;
|
||||
|
||||
signer._fetch = async (endpoint, options) => {
|
||||
if (endpoint === '/ccsp/v1/authenticate') {
|
||||
capturedPayload = JSON.parse(options.body);
|
||||
return {
|
||||
version: '1',
|
||||
request_id: capturedPayload.request_id,
|
||||
status: 'authenticated',
|
||||
signature: 'SIG_BASE64',
|
||||
certificate: Buffer.from('CN=DOUTOR MARCOS:12345678900').toString('base64'),
|
||||
algorithm: 'SHA256withRSA',
|
||||
subject_name: 'DOUTOR MARCOS',
|
||||
subject_cpf_cnpj: '12345678900'
|
||||
};
|
||||
}
|
||||
return {};
|
||||
};
|
||||
|
||||
await signer.authenticate({
|
||||
certificateId: 'cert-oab',
|
||||
challenge: 'random-server-challenge-string'
|
||||
});
|
||||
|
||||
assert.ok(capturedPayload);
|
||||
validatePayloadAgainstDefinition(capturedPayload, 'AuthenticateRequest');
|
||||
assert.equal(capturedPayload.version, '1');
|
||||
assert.equal(capturedPayload.operation, 'authenticate');
|
||||
assert.equal(capturedPayload.challenge, 'random-server-challenge-string');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user