diff --git a/services/bin/octobus-tentacles.js b/services/bin/octobus-tentacles.js index 412b8988..dc3d0549 100644 --- a/services/bin/octobus-tentacles.js +++ b/services/bin/octobus-tentacles.js @@ -265,10 +265,9 @@ const services = { entryFile: "../wd__k01/bin/wd-k01.js", serviceModule: "../wd__k01/src/service.js", }, - "opencti": { - entryFile: "../filigran__opencti/bin/opencti.js", - serviceModule: "../filigran__opencti/src/service.js", - }, + "tencent-ssl": { + entryFile: "../tencent__ssl/bin/tencent-ssl.js", + serviceModule: "../tencent__ssl/src/service.js", }; const serviceNames = Object.keys(services); diff --git a/services/package.json b/services/package.json index 91438aeb..60ca6afe 100644 --- a/services/package.json +++ b/services/package.json @@ -70,7 +70,8 @@ "volcengine-cloud-firewall": "bin/volcengine-cloud-firewall.js", "wangsu-label-ip": "bin/wangsu-label-ip.js", "wd-k01": "bin/wd-k01.js", - "opencti": "bin/opencti.js" + "opencti": "bin/opencti.js", + "tencent-ssl": "bin/tencent-ssl.js" }, "files": [ "bin/aliyun-waf3.js", @@ -206,7 +207,9 @@ "wangsu__label-ip", "scripts", "aliyun__waf3", - "bin/octobus-tentacles.js" + "bin/octobus-tentacles.js", + "tencent__ssl", + "bin/tencent-ssl.js" ], "scripts": { "validate": "node scripts/validate-service-package.mjs", diff --git a/services/tencent__ssl/README.md b/services/tencent__ssl/README.md new file mode 100644 index 00000000..1fff5dd3 --- /dev/null +++ b/services/tencent__ssl/README.md @@ -0,0 +1,66 @@ +# Tencent SSL Certificate Service Package + +OctoBus package for Tencent Cloud SSL Certificate query APIs. + +## Device Version + +SSL Certificate, API version 2019-12-05, endpoint `ssl.tencentcloudapi.com` + +## Authentication + +Tencent Cloud API key (SecretId + SecretKey), TC3-HMAC-SHA256 signature. + +## Methods + +| RPC Method | API Action | Type | Description | +|------------|-----------|------|-------------| +| `ListCertificates` | `DescribeCertificates` | Read | List all SSL certificates in the account | +| `GetCertificate` | `DescribeCertificateDetail` | Read | Get detailed info for a specific certificate | + +## Configuration + +Config fields (non-sensitive, in `config`): + +- `region`: Tencent Cloud region (default: `ap-guangzhou`) +- `timeoutMs`: HTTP timeout in milliseconds (default: 10000) + +Secret fields (sensitive, in `secret`): + +- `secret_id`: Tencent Cloud API SecretId +- `secretKey` / `secret_key`: Tencent Cloud API SecretKey + +## Import + +```bash +octobus service import tencent-ssl /path/to/tencent__ssl +``` + +## Usage + +```bash +# Create instance +octobus instance create ssl-test \ + --service tencent-ssl \ + --secret-json '{"secret_id":"xxx","secretKey":"xxx"}' + +# Create capset +octobus capset create ssl-dev --name SSL-Dev +octobus capset add-instance ssl-dev ssl-test + +# List certificates via Connect RPC +curl -X POST \ + 'http://127.0.0.1:9000/capsets/ssl-dev/connect/ssl-test/Tencent_SSL.Tencent_SSL/ListCertificates' \ + -H 'Content-Type: application/json' \ + -d '{"limit":20}' + +# Get certificate detail +curl -X POST \ + 'http://127.0.0.1:9000/capsets/ssl-dev/connect/ssl-test/Tencent_SSL.Tencent_SSL/GetCertificate' \ + -H 'Content-Type: application/json' \ + -d '{"certificate_id":"xxx"}' +``` + +## Risk Notes + +- All methods are read-only. No certificates are modified. +- The service is free to use - SSL certificates can be applied for free in Tencent Cloud console. diff --git a/services/tencent__ssl/bin/tencent-ssl.js b/services/tencent__ssl/bin/tencent-ssl.js new file mode 100644 index 00000000..8e0066ef --- /dev/null +++ b/services/tencent__ssl/bin/tencent-ssl.js @@ -0,0 +1,6 @@ +#!/usr/bin/env node +import { runServiceMain } from '@chaitin-ai/octobus-sdk'; + +import { service } from '../src/service.js'; + +runServiceMain(service); diff --git a/services/tencent__ssl/config.schema.json b/services/tencent__ssl/config.schema.json new file mode 100644 index 00000000..dd478080 --- /dev/null +++ b/services/tencent__ssl/config.schema.json @@ -0,0 +1,17 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "additionalProperties": true, + "properties": { + "region": { + "type": "string", + "description": "Tencent Cloud region (SSL API uses ap-guangzhou by default)." + }, + "timeoutMs": { + "type": "integer", + "minimum": 1, + "default": 10000, + "description": "HTTP timeout in milliseconds." + } + } +} diff --git a/services/tencent__ssl/package.json b/services/tencent__ssl/package.json new file mode 100644 index 00000000..aa8d8749 --- /dev/null +++ b/services/tencent__ssl/package.json @@ -0,0 +1,12 @@ +{ + "name": "tencent-ssl", + "version": "0.0.0", + "private": true, + "type": "module", + "bin": { + "tencent-ssl": "bin/tencent-ssl.js" + }, + "dependencies": { + "@chaitin-ai/octobus-sdk": "^0.5.0" + } +} diff --git a/services/tencent__ssl/proto/tencent_ssl.proto b/services/tencent__ssl/proto/tencent_ssl.proto new file mode 100644 index 00000000..37ffb891 --- /dev/null +++ b/services/tencent__ssl/proto/tencent_ssl.proto @@ -0,0 +1,50 @@ +syntax = "proto3"; + +package Tencent_SSL; + +import "google/protobuf/wrappers.proto"; + +option go_package = "miner/grpc-service/Tencent_SSL"; + +service Tencent_SSL { + rpc ListCertificates(ListCertificatesRequest) returns (ListCertificatesResponse) {} + rpc GetCertificate(GetCertificateRequest) returns (GetCertificateResponse) {} +} + +message ListCertificatesRequest { + google.protobuf.Int64Value limit = 1; + google.protobuf.Int64Value offset = 2; +} + +message ListCertificatesResponse { + int32 code = 1; + string message = 2; + repeated CertificateItem data = 3; + int32 total = 4; +} + +message CertificateItem { + string id = 1; + string domain = 2; + string cert_type = 3; + string status = 4; + string create_time = 5; + string expire_time = 6; +} + +message GetCertificateRequest { + string certificate_id = 1; +} + +message GetCertificateResponse { + int32 code = 1; + string message = 2; + string id = 3; + string domain = 4; + string cert_type = 5; + string status = 6; + string create_time = 7; + string expire_time = 8; + string subject = 9; + repeated string san = 10; +} diff --git a/services/tencent__ssl/secret.schema.json b/services/tencent__ssl/secret.schema.json new file mode 100644 index 00000000..2885f7d0 --- /dev/null +++ b/services/tencent__ssl/secret.schema.json @@ -0,0 +1,19 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "additionalProperties": true, + "properties": { + "secret_id": { + "type": "string", + "description": "Tencent Cloud API SecretId." + }, + "secretKey": { + "type": "string", + "description": "Tencent Cloud API SecretKey." + }, + "secret_key": { + "type": "string", + "description": "Alias for secretKey." + } + } +} diff --git a/services/tencent__ssl/service.json b/services/tencent__ssl/service.json new file mode 100644 index 00000000..f1ac6bb6 --- /dev/null +++ b/services/tencent__ssl/service.json @@ -0,0 +1,33 @@ +{ + "schema": "chaitin.octobus.service.v1", + "name": "tencent-ssl", + "displayName": "Tencent SSL Certificate", + "description": "OctoBus package for Tencent Cloud SSL Certificate query APIs.", + "runtime": { + "mode": "long-running" + }, + "proto": { + "roots": [ + "proto" + ], + "files": [ + "proto/tencent_ssl.proto" + ] + }, + "configSchema": "config.schema.json", + "secretSchema": "secret.schema.json", + "sdk": { + "cli": { + "commands": { + "Tencent_SSL.Tencent_SSL/ListCertificates": { + "name": "list-certificates", + "description": "List all SSL certificates in the account." + }, + "Tencent_SSL.Tencent_SSL/GetCertificate": { + "name": "get-certificate", + "description": "Get detailed info for a specific certificate." + } + } + } + } +} diff --git a/services/tencent__ssl/src/service.js b/services/tencent__ssl/src/service.js new file mode 100644 index 00000000..c23aad25 --- /dev/null +++ b/services/tencent__ssl/src/service.js @@ -0,0 +1,7 @@ +import { defineService } from '@chaitin-ai/octobus-sdk'; + +import { handlers } from './tencent-ssl.js'; + +export { handlers } from './tencent-ssl.js'; + +export const service = defineService({ handlers }); diff --git a/services/tencent__ssl/src/tencent-ssl.js b/services/tencent__ssl/src/tencent-ssl.js new file mode 100644 index 00000000..58824dc1 --- /dev/null +++ b/services/tencent__ssl/src/tencent-ssl.js @@ -0,0 +1,291 @@ +import crypto from 'node:crypto'; + +import { GrpcError, grpcStatus } from '@chaitin-ai/octobus-sdk'; + +// ---- Method paths ---- + +const METHOD_LIST_PATH = '/Tencent_SSL.Tencent_SSL/ListCertificates'; +const METHOD_GET_PATH = '/Tencent_SSL.Tencent_SSL/GetCertificate'; + +const METHOD_LIST_FULL = 'Tencent_SSL.Tencent_SSL/ListCertificates'; +const METHOD_GET_FULL = 'Tencent_SSL.Tencent_SSL/GetCertificate'; + +// ---- Constants ---- + +const SERVICE = 'ssl'; +const API_VERSION = '2019-12-05'; +const ENDPOINT = `${SERVICE}.tencentcloudapi.com`; +const METHOD_POST = 'POST'; +const DEFAULT_TIMEOUT_MS = 10000; + +const SERVICE_NAME = 'Tencent_SSL'; + +// ---- Error helpers ---- + +const grpcCodeFor = (code) => ({ + FAILED_PRECONDITION: grpcStatus.FAILED_PRECONDITION, + INVALID_ARGUMENT: grpcStatus.INVALID_ARGUMENT, + PERMISSION_DENIED: grpcStatus.PERMISSION_DENIED, + UNAVAILABLE: grpcStatus.UNAVAILABLE, + UNKNOWN: grpcStatus.UNKNOWN, +})[code] ?? grpcStatus.UNKNOWN; + +const errorWithCode = (code, message) => { + const err = new GrpcError(grpcCodeFor(code), `${code}: ${message}`); + err.legacyCode = code; + return err; +}; + +// ---- Value helpers ---- + +const hasOwn = (obj, key) => Object.prototype.hasOwnProperty.call(obj ?? {}, key); +const firstDefined = (...values) => values.find((v) => v !== undefined && v !== null); + +const unwrapString = (source) => { + if (source === undefined || source === null) return ''; + if (typeof source === 'object' && source !== null && hasOwn(source, 'value')) return unwrapString(source.value); + return String(source); +}; + +const optionalUint32 = (value) => { + const raw = value && typeof value === 'object' && hasOwn(value, 'value') ? value.value : value; + if (raw === undefined || raw === null || raw === '') return undefined; + const num = Number(raw); + if (!Number.isFinite(num) || num <= 0) return undefined; + return Math.trunc(num); +}; + +const toBoolean = (value) => { + const raw = value && typeof value === 'object' && hasOwn(value, 'value') ? value.value : value; + if (typeof raw === 'boolean') return raw; + if (typeof raw === 'number') return Number.isFinite(raw) && raw !== 0; + if (typeof raw === 'string') { + const text = raw.trim().toLowerCase(); + if (['1', 'true', 'yes', 'y', 'on'].includes(text)) return true; + if (['0', 'false', 'no', 'n', 'off', ''].includes(text)) return false; + } + return false; +}; + +// ---- Signature (TC3-HMAC-SHA256) ---- + +const sha256hex = (message) => crypto.createHash('sha256').update(Buffer.from(message, 'utf-8')).digest('hex'); +const hmacSha256 = (key, message) => crypto.createHmac('sha256', key).update(Buffer.from(message, 'utf-8')).digest(); + +const formatDate = (timestamp) => { + const d = new Date(timestamp * 1000); + return `${d.getUTCFullYear()}-${String(d.getUTCMonth() + 1).padStart(2, '0')}-${String(d.getUTCDate()).padStart(2, '0')}`; +}; + +const signRequest = (secretId, secretKey, payload, timestamp) => { + const payloadJson = JSON.stringify(payload); + const date = formatDate(timestamp); + const canonicalHeaders = `content-type:application/json; charset=utf-8\nhost:${ENDPOINT}\n`; + const canonicalRequest = `${METHOD_POST}\n/\n\n${canonicalHeaders}\ncontent-type;host\n${sha256hex(payloadJson)}`; + const credentialScope = `${date}/${SERVICE}/tc3_request`; + const stringToSign = `TC3-HMAC-SHA256\n${timestamp}\n${credentialScope}\n${sha256hex(canonicalRequest)}`; + const secretDate = hmacSha256(`TC3${secretKey}`, date); + const secretService = hmacSha256(secretDate, SERVICE); + const secretSigning = hmacSha256(secretService, 'tc3_request'); + const signature = hmacSha256(secretSigning, stringToSign).toString('hex'); + const authorization = `TC3-HMAC-SHA256 Credential=${secretId}/${credentialScope}, SignedHeaders=content-type;host, Signature=${signature}`; + return { authorization, timestamp }; +}; + +// ---- Logging ---- + +const buildLogPrefix = (meta = {}, action) => { + const labels = []; + if (meta.instance_id || meta.instanceId) labels.push(`inst=${meta.instance_id || meta.instanceId}`); + if (meta.request_id || meta.requestId) labels.push(`req=${meta.request_id || meta.requestId}`); + return `[${SERVICE_NAME}][${action}]${labels.length ? `[${labels.join(' ')}]` : ''}`; +}; + +const logInfo = (meta, action, payload) => { + const prefix = buildLogPrefix(meta, action); + try { console.log(prefix, JSON.stringify(payload)); } catch { console.log(prefix, payload); } +}; + +const logError = (meta, action, payload) => { + const prefix = buildLogPrefix(meta, action); + try { console.error(prefix, JSON.stringify(payload)); } catch { console.error(prefix, payload); } +}; + +// ---- HTTP ---- + +const parseJson = (text) => { + if (!String(text || '').trim()) return null; + try { return JSON.parse(text); } catch { throw errorWithCode('UNKNOWN', 'response is not valid JSON'); } +}; + +const mapHttpError = (res, bodyText) => { + const text = String(bodyText || ''); + if (res.status === 401 || res.status === 403) throw errorWithCode('PERMISSION_DENIED', `upstream http ${res.status}: ${text}`); + if (res.status >= 400 && res.status < 500) throw errorWithCode('FAILED_PRECONDITION', `upstream http ${res.status}: ${text}`); + throw errorWithCode('UNAVAILABLE', `upstream http ${res.status}: ${text}`); +}; + +const buildTlsOptions = (bindings = {}) => { + if (!toBoolean(bindings.skipTlsVerify) && !toBoolean(bindings.tlsInsecureSkipVerify) && !toBoolean(bindings.insecureSkipVerify)) return {}; + return { insecureSkipVerify: true, tlsInsecureSkipVerify: true, skipTlsVerify: true }; +}; + +const fetchJson = async (url, init, { bindings = {}, timeoutMs }) => { + let res; + try { + res = await fetch(url, { ...init, timeoutMs, ...buildTlsOptions(bindings) }); + } catch (err) { + const reason = err?.cause?.message || err?.message || 'fetch failed'; + throw errorWithCode('UNAVAILABLE', reason); + } + const text = await res.text(); + if (!res.ok) mapHttpError(res, text); + return { json: parseJson(text), text }; +}; + +// ---- Context resolution ---- + +const mergedBindings = (ctx = {}) => ({ ...(ctx.config ?? {}), ...(ctx.secret ?? {}), ...(ctx.bindings ?? {}) }); + +const resolveCallContext = (ctx = {}) => ({ + ...ctx, bindings: mergedBindings(ctx), limits: ctx.limits ?? {}, meta: ctx.meta ?? {}, req: ctx.req ?? ctx.request ?? {}, +}); + +const resolveTimeoutMs = (ctx = {}, bindings = {}) => + firstDefined(optionalUint32(ctx.limits?.timeoutMs), optionalUint32(bindings.timeoutMs), DEFAULT_TIMEOUT_MS); + +// ---- SSL API call ---- + +const callSSLAPI = async (action, params, { meta, bindings, timeoutMs }) => { + const secretId = unwrapString(firstDefined(bindings.secret_id, bindings.secretId)).trim(); + const secretKey = unwrapString(firstDefined(bindings.secret_key, bindings.secretKey)).trim(); + if (!secretId) throw errorWithCode('PERMISSION_DENIED', 'secret_id is required in bindings'); + if (!secretKey) throw errorWithCode('PERMISSION_DENIED', 'secret_key is required in bindings'); + + const region = unwrapString(bindings.region).trim() || 'ap-guangzhou'; + + // Only business params in body + const payload = { ...params }; + for (const key of Object.keys(payload)) { + if (payload[key] === undefined || payload[key] === null) delete payload[key]; + } + + const timestamp = Math.floor(Date.now() / 1000); + const { authorization } = signRequest(secretId, secretKey, payload, timestamp); + + const url = `https://${ENDPOINT}`; + const headers = { + 'Content-Type': 'application/json; charset=utf-8', + Host: ENDPOINT, + 'X-TC-Action': action, + 'X-TC-Version': API_VERSION, + 'X-TC-Region': region, + 'X-TC-Timestamp': String(timestamp), + Authorization: authorization, + }; + + logInfo(meta, `${action}:start`, { action, region }); + + let result; + try { + result = await fetchJson(url, { method: METHOD_POST, headers, body: JSON.stringify(payload) }, { bindings, timeoutMs }); + } catch (err) { + logError(meta, `${action}:http-error`, { action, error: err.message }); + throw err; + } + + const response = result.json?.Response; + if (!response) { + logError(meta, `${action}:invalid-response`, { body: result.text }); + throw errorWithCode('UNKNOWN', 'empty or invalid API response'); + } + + if (response.Error) { + logError(meta, `${action}:api-error`, { code: response.Error.Code, message: response.Error.Message }); + if (response.Error.Code === 'UnauthorizedOperation' || response.Error.Code === 'AuthFailure') { + throw errorWithCode('PERMISSION_DENIED', `API error: ${response.Error.Code} - ${response.Error.Message}`); + } + throw errorWithCode('FAILED_PRECONDITION', `API error: ${response.Error.Code} - ${response.Error.Message}`); + } + + logInfo(meta, `${action}:success`, { action }); + return response; +}; + +// ---- Handlers ---- + +const makeRuntime = (ctx = {}) => { + const callCtx = resolveCallContext(ctx); + const bindings = callCtx.bindings || {}; + const meta = callCtx.meta || {}; + const timeoutMs = resolveTimeoutMs(callCtx, bindings); + + const runList = async (req = {}) => { + const limit = optionalUint32(firstDefined(req.limit)) || 20; + const offset = optionalUint32(firstDefined(req.offset)) || 0; + + const params = { Limit: limit, Offset: offset }; + const response = await callSSLAPI('DescribeCertificates', params, { meta, bindings, timeoutMs }); + + const items = (response.Certificates || []).map((cert) => ({ + id: cert.CertificateId || '', + domain: cert.Domain || '', + cert_type: cert.CertificateType || '', + status: cert.StatusName || String(cert.Status ?? ''), + create_time: cert.InsertTime || '', + expire_time: cert.CertEndTime || '', + })); + + return { code: 0, message: response.RequestId || 'ok', data: items, total: response.TotalCount || items.length }; + }; + + const runGet = async (req = {}) => { + const certificateId = unwrapString(firstDefined(req.certificate_id, req.certificateId, req.CertificateId)).trim(); + if (!certificateId) throw errorWithCode('INVALID_ARGUMENT', 'certificate_id is required'); + + const params = { CertificateId: certificateId }; + const response = await callSSLAPI('DescribeCertificateDetail', params, { meta, bindings, timeoutMs }); + + return { + code: 0, + message: response.RequestId || 'ok', + id: response.CertificateId || '', + domain: response.Domain || '', + cert_type: response.CertificateType || '', + status: response.StatusName || String(response.Status ?? ''), + create_time: response.InsertTime || '', + expire_time: response.CertEndTime || '', + subject: response.Subject || response.Domain || '', + san: response.SubjectAltName || response.SAN || [], + }; + }; + + return { runList, runGet }; +}; + +// ---- Exports ---- + +export const handlers = { + [METHOD_LIST_FULL]: (ctx) => makeRuntime(ctx).runList(ctx.request ?? {}), + [METHOD_GET_FULL]: (ctx) => makeRuntime(ctx).runGet(ctx.request ?? {}), +}; + +export const _test = { + errorWithCode, + firstDefined, + hasOwn, + logInfo, + logError, + makeRuntime, + mapHttpError, + mergedBindings, + optionalUint32, + parseJson, + resolveCallContext, + resolveTimeoutMs, + toBoolean, + unwrapString, + sha256hex, + hmacSha256, + signRequest, +}; diff --git a/services/tencent__ssl/test/tencent-ssl.test.js b/services/tencent__ssl/test/tencent-ssl.test.js new file mode 100644 index 00000000..e5b6aa17 --- /dev/null +++ b/services/tencent__ssl/test/tencent-ssl.test.js @@ -0,0 +1,205 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { GrpcError } from '@chaitin-ai/octobus-sdk'; + +import { + handlers, + _test, +} from '../src/tencent-ssl.js'; +import { service } from '../src/service.js'; + +const originalFetch = globalThis.fetch; +const originalConsoleLog = console.log; +const originalConsoleError = console.error; + +const SSL_LIST_RESP = { + TotalCount: 2, + Certificates: [ + { CertificateId: 'cert-1', Domain: 'example.com', CertificateType: 'SVR', StatusName: '已签发', InsertTime: '2026-06-25 10:00:00', CertEndTime: '2026-09-25 10:00:00' }, + { CertificateId: 'cert-2', Domain: 'test.org', CertificateType: 'SVR', StatusName: '审核中', InsertTime: '2026-06-24 08:00:00', CertEndTime: '' }, + ], + RequestId: 'req-list-1', +}; + +const SSL_DETAIL_RESP = { + CertificateId: 'cert-1', + Domain: 'example.com', + CertificateType: 'SVR', + StatusName: '已签发', + Status: 1, + InsertTime: '2026-06-25 10:00:00', + CertEndTime: '2026-09-25 10:00:00', + Subject: 'example.com', + SubjectAltName: ['example.com', 'www.example.com'], + RequestId: 'req-detail-1', +}; + +// Context factory: returns a flat context object with config/secret/bindings +const ctx = (overrides = {}) => ({ + config: { ...(overrides.config || {}) }, + secret: { + secretId: 'test-secret-id', + secretKey: 'test-secret-key', + ...(overrides.secret || {}), + }, + bindings: { ...(overrides.bindings || {}) }, + limits: { timeoutMs: 2000, ...(overrides.limits || {}) }, + meta: { instance_id: 'inst', request_id: 'req', ...(overrides.meta || {}) }, +}); + +const response = (status, body) => ({ + ok: status >= 200 && status < 300, + status, + headers: { get: (name) => (String(name).toLowerCase() === 'content-type' ? 'application/json' : '') }, + text: async () => (typeof body === 'string' ? body : JSON.stringify(body)), +}); + +const setFetch = (impl) => { globalThis.fetch = impl; }; + +const expectGrpcError = async (fn, legacyCode) => { + let caught; + try { await fn(); } catch (err) { caught = err; } + assert.ok(caught, 'expected function to reject'); + assert.ok(caught instanceof GrpcError); + assert.equal(caught.legacyCode, legacyCode); + assert.match(caught.message, new RegExp(`^${legacyCode}:`)); +}; + +test.beforeEach(() => { + console.log = () => {}; + console.error = () => {}; +}); + +test.afterEach(() => { + globalThis.fetch = originalFetch; + console.log = originalConsoleLog; + console.error = originalConsoleError; +}); + +test('service exports handlers', () => { + assert.equal(typeof service, 'object'); + assert.equal(typeof handlers['Tencent_SSL.Tencent_SSL/ListCertificates'], 'function'); + assert.equal(typeof handlers['Tencent_SSL.Tencent_SSL/GetCertificate'], 'function'); +}); + +test('ListCertificates returns certificate list', async () => { + setFetch(async (url, init) => { + assert.ok(url.startsWith('https://ssl.tencentcloudapi.com')); + assert.equal(init.method, 'POST'); + assert.equal(init.headers['X-TC-Action'], 'DescribeCertificates'); + return response(200, { Response: SSL_LIST_RESP }); + }); + + const result = await handlers['Tencent_SSL.Tencent_SSL/ListCertificates']({ ...ctx(), request: { limit: 20 } }); + assert.equal(result.code, 0); + assert.equal(result.total, 2); + assert.equal(result.data.length, 2); + assert.equal(result.data[0].id, 'cert-1'); + assert.equal(result.data[0].domain, 'example.com'); + assert.equal(result.data[0].status, '已签发'); +}); + +test('GetCertificate returns certificate detail', async () => { + setFetch(async () => response(200, { Response: SSL_DETAIL_RESP })); + + const result = await handlers['Tencent_SSL.Tencent_SSL/GetCertificate']({ ...ctx(), request: { certificate_id: 'cert-1' } }); + assert.equal(result.code, 0); + assert.equal(result.id, 'cert-1'); + assert.equal(result.domain, 'example.com'); + assert.equal(result.status, '已签发'); + assert.ok(result.san.includes('example.com')); +}); + +test('validates required secret_id and certificate_id', async () => { + await expectGrpcError( + () => handlers['Tencent_SSL.Tencent_SSL/ListCertificates']({ ...ctx({ secret: { secretId: '' } }), request: {} }), + 'PERMISSION_DENIED', + ); + await expectGrpcError( + () => handlers['Tencent_SSL.Tencent_SSL/ListCertificates']({ ...ctx({ secret: { secretId: 'ok', secretKey: '' } }), request: {} }), + 'PERMISSION_DENIED', + ); + await expectGrpcError( + () => handlers['Tencent_SSL.Tencent_SSL/GetCertificate']({ ...ctx(), request: {} }), + 'INVALID_ARGUMENT', + ); +}); + +test('maps API errors correctly', async () => { + setFetch(async () => response(200, { Response: { Error: { Code: 'UnauthorizedOperation', Message: 'no permission' } } })); + await expectGrpcError( + () => handlers['Tencent_SSL.Tencent_SSL/ListCertificates']({ ...ctx(), request: {} }), + 'PERMISSION_DENIED', + ); + + setFetch(async () => response(200, { Response: { Error: { Code: 'FailedOperation', Message: 'failed' } } })); + await expectGrpcError( + () => handlers['Tencent_SSL.Tencent_SSL/ListCertificates']({ ...ctx(), request: {} }), + 'FAILED_PRECONDITION', + ); +}); + +test('handles HTTP transport errors', async () => { + setFetch(async () => response(403, 'forbidden')); + await expectGrpcError( + () => handlers['Tencent_SSL.Tencent_SSL/ListCertificates']({ ...ctx(), request: {} }), + 'PERMISSION_DENIED', + ); + + setFetch(async () => response(500, 'server error')); + await expectGrpcError( + () => handlers['Tencent_SSL.Tencent_SSL/ListCertificates']({ ...ctx(), request: {} }), + 'UNAVAILABLE', + ); + + setFetch(async () => { throw Object.assign(new Error('outer'), { cause: new Error('timeout') }); }); + await expectGrpcError( + () => handlers['Tencent_SSL.Tencent_SSL/ListCertificates']({ ...ctx(), request: {} }), + 'UNAVAILABLE', + ); +}); + +test('handles empty response', async () => { + setFetch(async () => ({ ok: true, status: 200, headers: { get: () => 'text/plain' }, text: async () => '' })); + await expectGrpcError( + () => handlers['Tencent_SSL.Tencent_SSL/ListCertificates']({ ...ctx(), request: {} }), + 'UNKNOWN', + ); +}); + +test('helper functions cover value utilities', () => { + assert.equal(_test.firstDefined(undefined, null, 'x'), 'x'); + assert.equal(_test.unwrapString({ value: { value: 'nested' } }), 'nested'); + assert.equal(_test.unwrapString(null), ''); + assert.equal(_test.toBoolean({ value: 'yes' }), true); + assert.equal(_test.toBoolean('off'), false); + assert.equal(_test.optionalUint32({ value: '10.9' }), 10); + assert.equal(_test.optionalUint32('bad'), undefined); +}); + +test('resolveCallContext merges config secret and bindings', () => { + const result = _test.resolveCallContext({ + config: { region: 'ap-shanghai' }, + secret: { secretId: 'sec-id', secretKey: 'sec-key' }, + bindings: { secretId: 'override-id' }, + request: { limit: 10 }, + }); + assert.equal(result.bindings.region, 'ap-shanghai'); + assert.equal(result.bindings.secretId, 'override-id'); + assert.equal(result.bindings.secretKey, 'sec-key'); + assert.deepEqual(result.req, { limit: 10 }); +}); + +test('logging falls back when JSON stringify fails', () => { + const logCalls = []; + const errorCalls = []; + console.log = (...args) => logCalls.push(args); + console.error = (...args) => errorCalls.push(args); + const circular = {}; + circular.self = circular; + _test.logInfo({ instanceId: 'inst', requestId: 'req' }, 'Test', circular); + _test.logError({ instance_id: 'inst', request_id: 'req' }, 'Test', circular); + assert.equal(logCalls[0][0], '[Tencent_SSL][Test][inst=inst req=req]'); + assert.equal(errorCalls[0][0], '[Tencent_SSL][Test][inst=inst req=req]'); +});