diff --git a/sdk/package.json b/sdk/package.json index 0e8dae0..fe7c39f 100644 --- a/sdk/package.json +++ b/sdk/package.json @@ -5,8 +5,25 @@ "type": "module", "main": "src/signer-sdk.js", "module": "src/signer-sdk.js", + "types": "src/signer-sdk.d.ts", + "exports": { + ".": { + "types": "./src/signer-sdk.d.ts", + "import": "./src/signer-sdk.js", + "default": "./src/signer-sdk.js" + }, + "./errors": { + "types": "./src/errors.d.ts", + "import": "./src/errors.js", + "default": "./src/errors.js" + } + }, + "files": [ + "src", + "README.md" + ], "scripts": { - "test": "node --test test/signer-sdk.test.js" + "test": "node --test test/*.test.js" }, "author": "CyberCore", "license": "Proprietary", diff --git a/sdk/src/errors.d.ts b/sdk/src/errors.d.ts new file mode 100644 index 0000000..e7bbc6d --- /dev/null +++ b/sdk/src/errors.d.ts @@ -0,0 +1,114 @@ +/** + * Definições TypeScript para os erros estruturados do protocolo CCSP/1 (@cybercore/signer-sdk). + */ + +export type CcspErrorCode = + | 'AGENT_NOT_RUNNING' + | 'COMMUNICATION_ERROR' + | 'APPLICATION_NOT_AUTHORIZED' + | 'USER_CANCELLED' + | 'CERTIFICATE_NOT_FOUND' + | 'CERTIFICATE_EXPIRED' + | 'CERTIFICATE_INVALID' + | 'PRIVATE_KEY_NOT_AVAILABLE' + | 'PIN_REQUIRED' + | 'PIN_INCORRECT' + | 'TOKEN_NOT_FOUND' + | 'TOKEN_REMOVED' + | 'PKCS11_ERROR' + | 'REQUEST_EXPIRED' + | 'REPLAY_DETECTED' + | 'INVALID_ORIGIN' + | 'UNSUPPORTED_ALGORITHM' + | 'PROVIDER_NOT_AVAILABLE' + | 'SIGNATURE_FAILED' + | 'TIMEOUT_ERROR' + | 'BAD_REQUEST' + | 'SIGN_ERROR' + | 'SIGN_PDF_ERROR' + | 'SIGN_XML_ERROR' + | 'AUTH_ERROR' + | 'CONNECTION_FAILED' + | string; + +export class CyberCoreError extends Error { + code: CcspErrorCode; + originalError?: unknown; + constructor(message: string, code?: CcspErrorCode, originalError?: unknown); +} + +export interface AgentNotRunningOptions { + host?: string; + port?: number; +} + +export class AgentNotRunningError extends CyberCoreError { + host: string; + port: number; + actionableGuidance: string; + constructor(message?: string, options?: AgentNotRunningOptions); +} + +export class ApplicationNotAuthorizedError extends CyberCoreError { + constructor(message?: string); +} + +export class UserCancelledError extends CyberCoreError { + constructor(message?: string); +} + +export class CertificateNotFoundError extends CyberCoreError { + constructor(message?: string); +} + +export class CertificateExpiredError extends CyberCoreError { + constructor(message?: string); +} + +export class CertificateInvalidError extends CyberCoreError { + constructor(message?: string); +} + +export class PrivateKeyNotAvailableError extends CyberCoreError { + constructor(message?: string); +} + +export class PinRequiredError extends CyberCoreError { + constructor(message?: string); +} + +export class PinIncorrectError extends CyberCoreError { + constructor(message?: string); +} + +export class TokenNotFoundError extends CyberCoreError { + constructor(message?: string); +} + +export class RequestExpiredError extends CyberCoreError { + constructor(message?: string); +} + +export class ReplayDetectedError extends CyberCoreError { + constructor(message?: string); +} + +export class InvalidOriginError extends CyberCoreError { + constructor(message?: string); +} + +export class UnsupportedAlgorithmError extends CyberCoreError { + constructor(message?: string); +} + +export class ProviderNotAvailableError extends CyberCoreError { + constructor(message?: string); +} + +export class SignatureFailedError extends CyberCoreError { + constructor(message?: string); +} + +export class TimeoutError extends CyberCoreError { + constructor(message?: string); +} diff --git a/sdk/src/errors.js b/sdk/src/errors.js index 74067d2..35ea55e 100644 --- a/sdk/src/errors.js +++ b/sdk/src/errors.js @@ -12,9 +12,19 @@ export class CyberCoreError extends Error { } export class AgentNotRunningError extends CyberCoreError { - constructor(message = 'O agente desktop CyberCore Signer não está em execução ou não foi detectado em 127.0.0.1:21325.') { + constructor( + message = 'O agente desktop CyberCore Signer não está em execução ou não foi detectado em 127.0.0.1:21325. Certifique-se de que o aplicativo está instalado e aberto na bandeja do sistema (system tray).', + options = {} + ) { super(message, 'AGENT_NOT_RUNNING'); this.name = 'AgentNotRunningError'; + this.host = options.host || '127.0.0.1'; + this.port = options.port || 21325; + this.actionableGuidance = [ + '1. Verifique se o ícone do CyberCore Signer está ativo na barra de tarefas / bandeja do sistema.', + '2. Se o aplicativo ainda não estiver instalado, baixe e execute o instalador oficial do CyberCore Signer.', + '3. Certifique-se de que a porta local 21325 não está bloqueada por firewall ou softwares antivírus.' + ].join('\n'); } } @@ -26,21 +36,42 @@ export class ApplicationNotAuthorizedError extends CyberCoreError { } export class UserCancelledError extends CyberCoreError { - constructor(message = 'A operação de assinatura foi cancelada pelo usuário.') { + constructor(message = 'A operação de assinatura foi cancelada pelo usuário no diálogo nativo.') { super(message, 'USER_CANCELLED'); this.name = 'UserCancelledError'; } } export class CertificateNotFoundError extends CyberCoreError { - constructor(message = 'Certificado digital não encontrado ou não selecionado.') { + constructor(message = 'Certificado digital não encontrado ou não selecionado no agente.') { super(message, 'CERTIFICATE_NOT_FOUND'); this.name = 'CertificateNotFoundError'; } } +export class CertificateExpiredError extends CyberCoreError { + constructor(message = 'O certificado digital selecionado expirou e não pode ser utilizado para assinatura.') { + super(message, 'CERTIFICATE_EXPIRED'); + this.name = 'CertificateExpiredError'; + } +} + +export class CertificateInvalidError extends CyberCoreError { + constructor(message = 'O certificado digital é inválido, foi revogado ou possui estrutura X.509 corrompida.') { + super(message, 'CERTIFICATE_INVALID'); + this.name = 'CertificateInvalidError'; + } +} + +export class PrivateKeyNotAvailableError extends CyberCoreError { + constructor(message = 'A chave privada do certificado não está acessível no computador ou o dispositivo físico está ausente.') { + super(message, 'PRIVATE_KEY_NOT_AVAILABLE'); + this.name = 'PrivateKeyNotAvailableError'; + } +} + export class PinRequiredError extends CyberCoreError { - constructor(message = 'PIN do token ou smart card A3 é obrigatório.') { + constructor(message = 'O PIN do token USB ou smart card A3 é obrigatório para autorizar a chave privada.') { super(message, 'PIN_REQUIRED'); this.name = 'PinRequiredError'; } @@ -54,14 +85,14 @@ export class PinIncorrectError extends CyberCoreError { } export class TokenNotFoundError extends CyberCoreError { - constructor(message = 'Token USB ou smart card não foi detectado no computador.') { + constructor(message = 'Token USB ou smart card não foi detectado no computador ou foi removido.') { super(message, 'TOKEN_NOT_FOUND'); this.name = 'TokenNotFoundError'; } } export class RequestExpiredError extends CyberCoreError { - constructor(message = 'A solicitação de assinatura expirou.') { + constructor(message = 'A solicitação de assinatura expirou antes de ser processada.') { super(message, 'REQUEST_EXPIRED'); this.name = 'RequestExpiredError'; } @@ -73,3 +104,38 @@ export class ReplayDetectedError extends CyberCoreError { this.name = 'ReplayDetectedError'; } } + +export class InvalidOriginError extends CyberCoreError { + constructor(message = 'A origem web solicitante não confere com os domínios homologados para esta aplicação.') { + super(message, 'INVALID_ORIGIN'); + this.name = 'InvalidOriginError'; + } +} + +export class UnsupportedAlgorithmError extends CyberCoreError { + constructor(message = 'O algoritmo criptográfico solicitado não é suportado pelo provedor de certificados.') { + super(message, 'UNSUPPORTED_ALGORITHM'); + this.name = 'UnsupportedAlgorithmError'; + } +} + +export class ProviderNotAvailableError extends CyberCoreError { + constructor(message = 'O provedor criptográfico necessário não está disponível no sistema operacional.') { + super(message, 'PROVIDER_NOT_AVAILABLE'); + this.name = 'ProviderNotAvailableError'; + } +} + +export class SignatureFailedError extends CyberCoreError { + constructor(message = 'Falha durante o cálculo da assinatura digital.') { + super(message, 'SIGNATURE_FAILED'); + this.name = 'SignatureFailedError'; + } +} + +export class TimeoutError extends CyberCoreError { + constructor(message = 'Tempo limite esgotado na comunicação com o agente local.') { + super(message, 'TIMEOUT_ERROR'); + this.name = 'TimeoutError'; + } +} diff --git a/sdk/src/index.d.ts b/sdk/src/index.d.ts new file mode 100644 index 0000000..b650401 --- /dev/null +++ b/sdk/src/index.d.ts @@ -0,0 +1,2 @@ +export * from './signer-sdk.js'; +export { default } from './signer-sdk.js'; diff --git a/sdk/src/signer-sdk.d.ts b/sdk/src/signer-sdk.d.ts new file mode 100644 index 0000000..f1d3a6d --- /dev/null +++ b/sdk/src/signer-sdk.d.ts @@ -0,0 +1,246 @@ +/** + * CyberCore Signer Web SDK (@cybercore/signer-sdk) + * Protocolo: CCSP/1 (CyberCore Signing Protocol v1) + */ + +export * from './errors.js'; + +export interface CyberCoreSignerOptions { + /** URL base completa do agente local (prevalece sobre host e port). Ex: 'http://127.0.0.1:21325' */ + baseUrl?: string; + /** Host do agente local (padrão: '127.0.0.1') */ + host?: string; + /** Porta HTTP do agente local (padrão: 21325) */ + port?: number; + /** Timeout padrão para requisições em milissegundos (padrão: 30000) */ + timeoutMs?: number; + /** UUID persistido da aplicação web */ + applicationId?: string; + /** Nome legível da aplicação exibido na confirmação de assinatura */ + applicationName?: string; +} + +export interface StatusResponse { + version: string; + status: string; + agent_version?: string; + os?: string; + ready?: boolean; + [key: string]: unknown; +} + +export interface VersionInfo { + protocolVersion: string; + agentVersion: string; + os: string; +} + +export interface CapabilitiesResponse { + protocolVersion: string; + supportedAlgorithms: string[]; + supportedHashAlgorithms: string[]; + certificateTypes: ('A1' | 'A3')[]; + hardwareSupport: string[]; + padesSupport: boolean; + [key: string]: unknown; +} + +export interface CertificateDto { + id: string; + alias?: string; + subject_name: string; + cpf_cnpj?: string; + issuer_name?: string; + serial_number?: string; + valid_from?: string; + valid_to?: string; + certificate_type: 'A1' | 'A3'; + has_private_key: boolean; + key_algorithm?: string; + provider_type?: string; + [key: string]: unknown; +} + +export interface SignParams { + certificateId?: string; + certificateHint?: string; + digest?: string; + data?: string | Uint8Array | ArrayBuffer; + algorithm?: 'SHA256withRSA' | 'SHA1withRSA' | string; + operation?: 'sign' | string; + documentName?: string; + fileName?: string; + documentType?: string; + fileType?: string; + purpose?: string; + expiresInSeconds?: number; +} + +export interface SignResult { + requestId: string; + status: 'signed' | 'authenticated'; + signature: string; + certificate: string; + certificateChain: string[]; + algorithm: string; + digest: string; + subjectName: string | null; + subjectCpfCnpj: string | null; + documentName: string | null; + documentType: string | null; +} + +export interface SignPdfDocumentParams { + pdf: string | File | Blob | ArrayBuffer | Uint8Array; + certificateId?: string; + certificateHint?: string; + documentName?: string; + fileName?: string; + page?: number; + pageChoice?: 'LAST_PAGE' | 'FIRST_PAGE' | null; + position?: 'BOTTOM_RIGHT' | 'BOTTOM_LEFT' | 'TOP_RIGHT' | 'TOP_LEFT' | 'CUSTOM' | string; + x?: number; + y?: number; + scale?: number; + visibleSeal?: boolean; + sealThemeId?: string; + sealCustom?: Record; + reason?: string; + location?: string; + cpfDisplayMode?: 'FULL' | 'MASKED' | 'NONE'; + includeDateTime?: boolean; + customExtraInfo?: string; + expiresInSeconds?: number; +} + +export interface SignPdfDocumentResult { + requestId: string; + status: 'signed'; + signedPdf: string; + signed_pdf: string; + sha256?: string; + signature?: string; + certificate: string; + certificateChain: string[]; + algorithm: string; + subjectName: string | null; + subjectCpfCnpj: string | null; + documentName: string; + getSignedPdfBlob(): Blob; + getSignedPdfBytes(): Uint8Array; + download(filename?: string): void; +} + +export interface SignXmlDocumentParams { + xml: string | File | Blob | ArrayBuffer; + certificateId?: string; + certificateHint?: string; + profile?: 'XADES_BES' | 'XMLDSIG' | string; + targetElementTag?: string; + targetElementId?: string; + documentName?: string; + fileName?: string; + algorithm?: string; + expiresInSeconds?: number; +} + +export interface SignXmlDocumentResult { + requestId: string; + status: 'signed'; + signedXml: string; + sha256?: string; + signature?: string; + certificate: string; + certificateChain: string[]; + algorithm: string; + subjectName: string | null; + subjectCpfCnpj: string | null; + documentName: string; + getSignedXmlBlob(): Blob; + getSignedXmlBytes(): Uint8Array; + download(filename?: string): void; +} + +export interface AuthenticateParams { + certificateId?: string; + certificateHint?: string; + challenge?: string; + algorithm?: string; + expiresInSeconds?: number; +} + +export interface AuthenticateResult { + requestId: string; + status: 'authenticated'; + signature: string; + certificate: string; + certificateChain: string[]; + algorithm: string; + challenge: string; + subjectName: string; + subjectCpfCnpj: string; +} + +export interface VerifyParams { + signature: string; + digest: string; + certificate: string; +} + +export interface SealThemesResponse { + version: string; + status: string; + default_seal_id?: string; + seals: Array<{ id: string; name: string; [key: string]: unknown }>; +} + +export interface SubjectInfo { + name: string; + cpfCnpj: string; +} + +export class CyberCoreSigner { + baseUrl: string; + host: string; + port: number; + timeoutMs: number; + applicationId: string; + applicationName: string; + connected: boolean; + + constructor(options?: CyberCoreSignerOptions); + + isInstalled(timeoutMs?: number): Promise; + getStatus(timeoutMs?: number): Promise; + getVersion(): Promise; + getCapabilities(): Promise; + connect(): Promise; + getCertificates(): Promise; + sign(params: SignParams): Promise; + signFile(file: File | Blob | ArrayBuffer | Uint8Array, options?: Partial): Promise; + signPdf(pdfFile: File | Blob | ArrayBuffer | Uint8Array, options?: Partial): Promise; + signXml(xmlContent: string | File | Blob | ArrayBuffer, options?: Partial): Promise; + getSealThemes(): Promise; + signPdfDocument(params: SignPdfDocumentParams): Promise; + signXmlDocument(params: SignXmlDocumentParams): Promise; + authenticate(options?: AuthenticateParams): Promise; + login(options?: AuthenticateParams): Promise; + extractSubjectInfo(certificateBase64: string): SubjectInfo; + verify(params: VerifyParams): Promise; + + _fetch(endpoint: string, options?: Record): Promise; + _calculateSha256(data: string | ArrayBuffer | ArrayBufferView): Promise; + _throwMappedError(code: string, message?: string): never; +} + +export class PdfStampPicker { + constructor(options?: unknown); + open(options?: unknown): Promise; +} + +export class PdfSealDesigner { + constructor(options?: unknown); + open(options?: unknown): Promise; +} + +export default CyberCoreSigner; diff --git a/sdk/src/signer-sdk.js b/sdk/src/signer-sdk.js index 1588c9c..2abbe70 100644 --- a/sdk/src/signer-sdk.js +++ b/sdk/src/signer-sdk.js @@ -9,29 +9,66 @@ import { ApplicationNotAuthorizedError, UserCancelledError, CertificateNotFoundError, + CertificateExpiredError, + CertificateInvalidError, + PrivateKeyNotAvailableError, PinRequiredError, PinIncorrectError, TokenNotFoundError, RequestExpiredError, - ReplayDetectedError + ReplayDetectedError, + InvalidOriginError, + UnsupportedAlgorithmError, + ProviderNotAvailableError, + SignatureFailedError, + TimeoutError } from './errors.js'; import { PdfStampPicker } from './pdf-stamp-picker.js'; import { PdfSealDesigner } from './pdf-seal-designer.js'; -export { PdfStampPicker, PdfSealDesigner }; +export { + PdfStampPicker, + PdfSealDesigner, + CyberCoreError, + AgentNotRunningError, + ApplicationNotAuthorizedError, + UserCancelledError, + CertificateNotFoundError, + CertificateExpiredError, + CertificateInvalidError, + PrivateKeyNotAvailableError, + PinRequiredError, + PinIncorrectError, + TokenNotFoundError, + RequestExpiredError, + ReplayDetectedError, + InvalidOriginError, + UnsupportedAlgorithmError, + ProviderNotAvailableError, + SignatureFailedError, + TimeoutError +}; export class CyberCoreSigner { /** * Inicializa o cliente do SDK. * - * @param {Object} options Configurações do SDK. - * @param {string} [options.baseUrl='http://127.0.0.1:21325'] URL base do agente local. - * @param {string} [options.applicationId] UUID único da aplicação web (gerado se não fornecido). + * @param {Object} [options={}] Configurações do SDK. + * @param {string} [options.baseUrl] URL base completa do agente local (prevalece sobre host e port). + * @param {string} [options.host='127.0.0.1'] Host do agente local. + * @param {number} [options.port=21325] Porta HTTP do agente local. + * @param {number} [options.timeoutMs=30000] Timeout padrão das operações em milissegundos. + * @param {string} [options.applicationId] UUID único da aplicação web (gerado e persistido se omitido). * @param {string} [options.applicationName] Nome legível da aplicação apresentado ao usuário. */ constructor(options = {}) { - this.baseUrl = (options.baseUrl || 'http://127.0.0.1:21325').replace(/\/+$/, ''); + const host = options.host || '127.0.0.1'; + const port = options.port !== undefined ? options.port : 21325; + this.host = host; + this.port = port; + this.baseUrl = (options.baseUrl || `http://${host}:${port}`).replace(/\/+$/, ''); + this.timeoutMs = options.timeoutMs !== undefined ? options.timeoutMs : 30000; this.applicationId = options.applicationId || this._getOrCreateApplicationId(); this.applicationName = options.applicationName || (typeof document !== 'undefined' ? document.title : 'Aplicação Web'); this.connected = false; @@ -63,8 +100,15 @@ export class CyberCoreSigner { const resp = await this._fetch('/ccsp/v1/status', { method: 'GET', timeoutMs }); return resp; } catch (err) { - if (err.name === 'AbortError' || err.message?.includes('Failed to fetch') || err.message?.includes('NetworkError')) { - throw new AgentNotRunningError(); + if ( + err.name === 'AbortError' || + err instanceof TimeoutError || + err instanceof AgentNotRunningError || + err.message?.includes('Failed to fetch') || + err.message?.includes('NetworkError') || + err.code === 'ECONNREFUSED' + ) { + throw new AgentNotRunningError(undefined, { host: this.host, port: this.port }); } throw err; } @@ -702,7 +746,7 @@ export class CyberCoreSigner { */ async _fetch(endpoint, options = {}) { const url = `${this.baseUrl}${endpoint}`; - const timeout = options.timeoutMs || 15000; + const timeout = options.timeoutMs !== undefined ? options.timeoutMs : (this.timeoutMs || 30000); const controller = typeof AbortController !== 'undefined' ? new AbortController() : null; const timeoutId = controller ? setTimeout(() => controller.abort(), timeout) : null; @@ -738,12 +782,15 @@ export class CyberCoreSigner { return data; } catch (err) { if (err.name === 'AbortError') { - throw new CyberCoreError(`Tempo limite esgotado (${timeout}ms) na comunicação com o agente.`, 'TIMEOUT_ERROR'); + throw new TimeoutError(`Tempo limite esgotado (${timeout}ms) na comunicação com o agente local.`); } if (err instanceof CyberCoreError) { throw err; } - throw new AgentNotRunningError(`Falha ao conectar com o CyberCore Signer em ${this.baseUrl}: ${err.message}`); + throw new AgentNotRunningError( + `Falha ao conectar com o CyberCore Signer em ${this.baseUrl}: ${err.message}`, + { host: this.host, port: this.port } + ); } finally { if (timeoutId) { clearTimeout(timeoutId); @@ -829,6 +876,12 @@ export class CyberCoreSigner { throw new UserCancelledError(message); case 'CERTIFICATE_NOT_FOUND': throw new CertificateNotFoundError(message); + case 'CERTIFICATE_EXPIRED': + throw new CertificateExpiredError(message); + case 'CERTIFICATE_INVALID': + throw new CertificateInvalidError(message); + case 'PRIVATE_KEY_NOT_AVAILABLE': + throw new PrivateKeyNotAvailableError(message); case 'APPLICATION_NOT_AUTHORIZED': throw new ApplicationNotAuthorizedError(message); case 'PIN_REQUIRED': @@ -836,11 +889,22 @@ export class CyberCoreSigner { case 'PIN_INCORRECT': throw new PinIncorrectError(message); case 'TOKEN_NOT_FOUND': + case 'TOKEN_REMOVED': throw new TokenNotFoundError(message); case 'REQUEST_EXPIRED': throw new RequestExpiredError(message); case 'REPLAY_DETECTED': throw new ReplayDetectedError(message); + case 'INVALID_ORIGIN': + throw new InvalidOriginError(message); + case 'UNSUPPORTED_ALGORITHM': + throw new UnsupportedAlgorithmError(message); + case 'PROVIDER_NOT_AVAILABLE': + throw new ProviderNotAvailableError(message); + case 'SIGNATURE_FAILED': + throw new SignatureFailedError(message); + case 'TIMEOUT_ERROR': + throw new TimeoutError(message); default: throw new CyberCoreError(message || `Erro do protocolo: ${code}`, code); } diff --git a/sdk/test/mock-server.test.js b/sdk/test/mock-server.test.js new file mode 100644 index 0000000..30ccd53 --- /dev/null +++ b/sdk/test/mock-server.test.js @@ -0,0 +1,256 @@ +import { describe, it, before, after } from 'node:test'; +import assert from 'node:assert/strict'; +import http from 'node:http'; +import { + CyberCoreSigner, + AgentNotRunningError, + ApplicationNotAuthorizedError, + CertificateExpiredError, + UserCancelledError, + TimeoutError +} from '../src/signer-sdk.js'; + +describe('@cybercore/signer-sdk - Testes de Integração com Mock HTTP Server', () => { + let server; + let serverPort; + let baseUrl; + + // Servidor Mock HTTP emulando o protocolo CCSP/1 + before(async () => { + server = http.createServer((req, res) => { + // CORS & Preflight + res.setHeader('Access-Control-Allow-Origin', '*'); + res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS'); + res.setHeader('Access-Control-Allow-Headers', 'Content-Type, X-CyberCore-AppId'); + res.setHeader('Access-Control-Allow-Private-Network', 'true'); + + if (req.method === 'OPTIONS') { + res.writeHead(204); + res.end(); + return; + } + + let body = ''; + req.on('data', chunk => { body += chunk; }); + req.on('end', () => { + const url = new URL(req.url, `http://127.0.0.1:${serverPort}`); + + if (url.pathname === '/ccsp/v1/status') { + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ + version: '1', + status: 'ready', + agent_version: '0.1.0', + os: 'Linux x86_64' + })); + return; + } + + if (url.pathname === '/ccsp/v1/delay') { + // Endpoint propositalmente lento para testar timeout + setTimeout(() => { + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ status: 'slow_response' })); + }, 300); + return; + } + + if (url.pathname === '/ccsp/v1/authorize') { + const payload = JSON.parse(body || '{}'); + if (payload.application_id === 'blocked-app') { + res.writeHead(403, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ + version: '1', + status: 'rejected', + error_code: 'APPLICATION_NOT_AUTHORIZED', + message: 'Aplicação bloqueada pelo usuário.' + })); + return; + } + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ + version: '1', + status: 'authorized', + application_id: payload.application_id + })); + return; + } + + if (url.pathname === '/ccsp/v1/certificates') { + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ + version: '1', + status: 'success', + certificates: [ + { + id: 'cert-a1-1', + alias: 'Certificado A1 Teste', + subject_name: 'JOAO DA SILVA:12345678900', + certificate_type: 'A1', + has_private_key: true + } + ] + })); + return; + } + + if (url.pathname === '/ccsp/v1/sign') { + const payload = JSON.parse(body || '{}'); + if (payload.certificate_hint === 'expired-cert') { + res.writeHead(400, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ + version: '1', + request_id: payload.request_id, + status: 'error', + error_code: 'CERTIFICATE_EXPIRED', + message: 'Certificado expirou em 2025.' + })); + return; + } + + if (payload.certificate_hint === 'cancel-cert') { + res.writeHead(400, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ + version: '1', + request_id: payload.request_id, + status: 'error', + error_code: 'USER_CANCELLED', + message: 'Assinatura cancelada no diálogo.' + })); + return; + } + + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ + version: '1', + request_id: payload.request_id, + status: 'signed', + signature: 'MOCK_SIGNATURE_BASE64', + certificate: 'MOCK_CERT_BASE64', + algorithm: payload.algorithm || 'SHA256withRSA', + document_name: payload.document_name + })); + return; + } + + res.writeHead(404, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error_code: 'NOT_FOUND', message: 'Endpoint não encontrado' })); + }); + }); + + await new Promise((resolve, reject) => { + server.listen(0, '127.0.0.1', () => { + serverPort = server.address().port; + baseUrl = `http://127.0.0.1:${serverPort}`; + resolve(); + }); + server.on('error', reject); + }); + }); + + after(async () => { + if (server) { + await new Promise(resolve => server.close(resolve)); + } + }); + + it('deve confirmar que o agente está ativo com isInstalled()', async () => { + const signer = new CyberCoreSigner({ baseUrl }); + const installed = await signer.isInstalled(); + assert.equal(installed, true); + }); + + it('deve retornar dados de status corretos em getStatus()', async () => { + const signer = new CyberCoreSigner({ baseUrl }); + const status = await signer.getStatus(); + assert.equal(status.version, '1'); + assert.equal(status.status, 'ready'); + assert.equal(status.agent_version, '0.1.0'); + }); + + it('deve realizar handshake com connect() com sucesso', async () => { + const signer = new CyberCoreSigner({ baseUrl, applicationName: 'ERP Teste' }); + const connected = await signer.connect(); + assert.equal(connected, true); + assert.equal(signer.connected, true); + }); + + it('deve lançar ApplicationNotAuthorizedError se o agente rejeitar autorização', async () => { + const signer = new CyberCoreSigner({ baseUrl, applicationId: 'blocked-app' }); + await assert.rejects( + async () => signer.connect(), + ApplicationNotAuthorizedError + ); + }); + + it('deve listar certificados disponíveis em getCertificates()', async () => { + const signer = new CyberCoreSigner({ baseUrl }); + const certs = await signer.getCertificates(); + assert.ok(Array.isArray(certs)); + assert.equal(certs.length, 1); + assert.equal(certs[0].id, 'cert-a1-1'); + }); + + it('deve assinar com sucesso via sign()', async () => { + const signer = new CyberCoreSigner({ baseUrl }); + const result = await signer.sign({ + certificateId: 'cert-a1-1', + digest: Buffer.alloc(32, 7).toString('base64'), + documentName: 'relatorio.pdf' + }); + + assert.equal(result.status, 'signed'); + assert.equal(result.signature, 'MOCK_SIGNATURE_BASE64'); + assert.equal(result.documentName, 'relatorio.pdf'); + }); + + it('deve tratar erro CERTIFICATE_EXPIRED lançando CertificateExpiredError', async () => { + const signer = new CyberCoreSigner({ baseUrl }); + await assert.rejects( + async () => signer.sign({ + certificateId: 'expired-cert', + digest: Buffer.alloc(32, 1).toString('base64') + }), + CertificateExpiredError + ); + }); + + it('deve tratar cancelamento de usuário lançando UserCancelledError', async () => { + const signer = new CyberCoreSigner({ baseUrl }); + await assert.rejects( + async () => signer.sign({ + certificateId: 'cancel-cert', + digest: Buffer.alloc(32, 2).toString('base64') + }), + UserCancelledError + ); + }); + + it('deve lançar TimeoutError quando a chamada exceder o timeoutMs configurado', async () => { + const signer = new CyberCoreSigner({ baseUrl, timeoutMs: 50 }); + await assert.rejects( + async () => signer._fetch('/ccsp/v1/delay'), + TimeoutError + ); + }); + + it('deve lançar AgentNotRunningError com mensagem acionável se porta estiver inativa', async () => { + // Porta inativa aleatória + const deadPort = 64999; + const signer = new CyberCoreSigner({ host: '127.0.0.1', port: deadPort }); + + const installed = await signer.isInstalled(300); + assert.equal(installed, false); + + await assert.rejects( + async () => signer.getStatus(300), + err => { + assert.ok(err instanceof AgentNotRunningError); + assert.equal(err.code, 'AGENT_NOT_RUNNING'); + assert.equal(err.port, deadPort); + assert.ok(err.actionableGuidance.includes('bandeja do sistema')); + return true; + } + ); + }); +}); diff --git a/sdk/test/signer-sdk.test.js b/sdk/test/signer-sdk.test.js index 0c4453e..a54d22a 100644 --- a/sdk/test/signer-sdk.test.js +++ b/sdk/test/signer-sdk.test.js @@ -3,10 +3,22 @@ import assert from 'node:assert/strict'; import { CyberCoreSigner } from '../src/signer-sdk.js'; import { CyberCoreError, + AgentNotRunningError, UserCancelledError, CertificateNotFoundError, + CertificateExpiredError, + CertificateInvalidError, + PrivateKeyNotAvailableError, + PinRequiredError, PinIncorrectError, TokenNotFoundError, + RequestExpiredError, + ReplayDetectedError, + InvalidOriginError, + UnsupportedAlgorithmError, + ProviderNotAvailableError, + SignatureFailedError, + TimeoutError, ApplicationNotAuthorizedError } from '../src/errors.js'; @@ -25,12 +37,25 @@ describe('@cybercore/signer-sdk - Testes Unitários do SDK', () => { const signer = new CyberCoreSigner({ baseUrl: 'http://127.0.0.1:21325/', applicationId: customAppId, - applicationName: 'Portal Minha Empresa' + applicationName: 'Portal Minha Empresa', + timeoutMs: 45000 }); assert.equal(signer.baseUrl, 'http://127.0.0.1:21325'); assert.equal(signer.applicationId, customAppId); assert.equal(signer.applicationName, 'Portal Minha Empresa'); + assert.equal(signer.timeoutMs, 45000); + }); + + it('deve montar baseUrl automaticamente quando host e port forem especificados', () => { + const signer = new CyberCoreSigner({ + host: 'localhost', + port: 29999 + }); + + assert.equal(signer.host, 'localhost'); + assert.equal(signer.port, 29999); + assert.equal(signer.baseUrl, 'http://localhost:29999'); }); it('deve retornar as capacidades criptográficas do agente em getCapabilities()', async () => { @@ -69,6 +94,26 @@ describe('@cybercore/signer-sdk - Testes Unitários do SDK', () => { CertificateNotFoundError ); + assert.throws( + () => signer._throwMappedError('CERTIFICATE_EXPIRED', 'Certificado expirado'), + CertificateExpiredError + ); + + assert.throws( + () => signer._throwMappedError('CERTIFICATE_INVALID', 'Certificado inválido'), + CertificateInvalidError + ); + + assert.throws( + () => signer._throwMappedError('PRIVATE_KEY_NOT_AVAILABLE', 'Sem chave privada'), + PrivateKeyNotAvailableError + ); + + assert.throws( + () => signer._throwMappedError('PIN_REQUIRED', 'PIN obrigatório'), + PinRequiredError + ); + assert.throws( () => signer._throwMappedError('PIN_INCORRECT', 'PIN errado'), PinIncorrectError @@ -79,6 +124,46 @@ describe('@cybercore/signer-sdk - Testes Unitários do SDK', () => { TokenNotFoundError ); + assert.throws( + () => signer._throwMappedError('TOKEN_REMOVED', 'Token removido'), + TokenNotFoundError + ); + + assert.throws( + () => signer._throwMappedError('REQUEST_EXPIRED', 'Expirou'), + RequestExpiredError + ); + + assert.throws( + () => signer._throwMappedError('REPLAY_DETECTED', 'Replay'), + ReplayDetectedError + ); + + assert.throws( + () => signer._throwMappedError('INVALID_ORIGIN', 'Origem inválida'), + InvalidOriginError + ); + + assert.throws( + () => signer._throwMappedError('UNSUPPORTED_ALGORITHM', 'Algoritmo inválido'), + UnsupportedAlgorithmError + ); + + assert.throws( + () => signer._throwMappedError('PROVIDER_NOT_AVAILABLE', 'Provider indisponível'), + ProviderNotAvailableError + ); + + assert.throws( + () => signer._throwMappedError('SIGNATURE_FAILED', 'Falha na assinatura'), + SignatureFailedError + ); + + assert.throws( + () => signer._throwMappedError('TIMEOUT_ERROR', 'Timeout'), + TimeoutError + ); + assert.throws( () => signer._throwMappedError('APPLICATION_NOT_AUTHORIZED', 'Não autorizada'), ApplicationNotAuthorizedError @@ -90,6 +175,16 @@ describe('@cybercore/signer-sdk - Testes Unitários do SDK', () => { ); }); + it('deve fornecer orientação acionável rica em AgentNotRunningError', () => { + const err = new AgentNotRunningError(undefined, { host: '127.0.0.1', port: 21325 }); + assert.equal(err.code, 'AGENT_NOT_RUNNING'); + assert.equal(err.host, '127.0.0.1'); + assert.equal(err.port, 21325); + assert.ok(err.actionableGuidance.includes('bandeja do sistema')); + assert.ok(err.actionableGuidance.includes('instalador oficial')); + assert.ok(err.actionableGuidance.includes('21325')); + }); + it('deve validar parâmetros obrigatórios no método sign()', async () => { const signer = new CyberCoreSigner();