diff --git a/docs/diagnostics.md b/docs/diagnostics.md new file mode 100644 index 0000000..c424a00 --- /dev/null +++ b/docs/diagnostics.md @@ -0,0 +1,87 @@ +# Diagnostics reporter + +The SDK can build a redacted diagnostics report of config and runtime state, +intended to be pasted into a support request or bug report without further +editing — though you should still read it before sharing (see +[Before you share it](#before-you-share-it)). + +## What it checks + +| Section | What it reports | +| ----------- | ---------------------------------------------------------------------------- | +| `config` | Whether `contractId`, `rpcUrl`, and `networkPassphrase` are present; which environment preset (if any) is in use; whether a signer is configured; whether the config resolves at all | +| `runtime` | Whether the RPC endpoint is reachable; the declared client role (if a role-aware client is passed); whether a signer is configured | +| `complianceFailure` | Optional — classifies a caught error's `code` into a domain (`portfolio` \| `role` \| `config` \| `unknown`) | + +## Build a report + +```ts +import { buildDiagnosticsReport } from '@aegis/sdk'; + +const config = { + contractId: 'C...', + environment: 'testnet', + keypair, // optional +}; +const aegis = createReadOnlyClient(config); + +const report = await buildDiagnosticsReport({ + config, // the same object passed to the client/factory + client: aegis, // a constructed AegisClient or role-aware client +}); + +console.log(JSON.stringify(report, null, 2)); +``` + +Pass `config` even when config is missing or invalid — `diagnoseConfig` +never throws, so a broken setup still produces a report: + +```ts +const report = await buildDiagnosticsReport({ config: userSuppliedConfig }); +console.log(report.config.status); // 'invalid' +console.log(report.config.errorCode); // e.g. 'MISSING_CONFIG' +``` + +To classify a compliance-related failure alongside the report, pass the +caught error as `complianceError`: + +```ts +try { + await aegis.compliance.checkWhitelist(address); +} catch (error) { + const report = await buildDiagnosticsReport({ config, client: aegis, complianceError: error }); + console.log(report.complianceFailure); // { domain: 'portfolio', code: 'COMPLIANCE_ERROR', classified: true } +} +``` + +The individual builders (`diagnoseConfig`, `buildRuntimeDiagnostics`, +`classifyComplianceFailure`) are also exported directly if you only need one +section. + +## What is never included + +- **Private keys, secret seeds, or the `Keypair` object itself.** Only + `signerConfigured: boolean` is reported — existence, not content. +- **Stellar addresses** (investor addresses, signer public keys) — these + identify an account/person and are treated as identity data, not config. +- **RPC URL query strings or fragments.** Only `origin` and `pathname` are + reported; a URL like `https://rpc.example.com/soroban?apiKey=...` is + reported as `{ origin: 'https://rpc.example.com', path: '/soroban' }`. +- **Raw error messages or causes.** Failure classification reads only an + error's closed `code` field (e.g. `COMPLIANCE_ERROR`, `RPC_UNAVAILABLE`) — + never `.message` or `.cause`, which elsewhere in the SDK may interpolate + raw RPC responses or addresses. +- **Any config field the reporter doesn't explicitly know about.** The + reporter is built as an allowlist: it reads named fields one at a time and + assembles the result from them. A field it has no code for — a custom + credential someone added to their config object, for example — is absent + from the report by default, not present until someone remembers to hide + it. + +## Before you share it + +This reporter is designed to be safe by default, but no automated redaction +is a substitute for a human check. Before pasting a report into a GitHub +issue, support ticket, or chat: read it once. If your config object had +anything unusual attached to it, or if you're unsure, don't share it until +you've confirmed the output looks right. diff --git a/src/diagnostics/compliance.ts b/src/diagnostics/compliance.ts new file mode 100644 index 0000000..a85fcce --- /dev/null +++ b/src/diagnostics/compliance.ts @@ -0,0 +1,35 @@ +import { PortfolioError } from '../errors/portfolio'; +import { RoleError } from '../errors/role'; +import { ConfigValidationError } from '../errors/config'; + +export type ComplianceFailureDomain = 'portfolio' | 'role' | 'config' | 'unknown'; + +export interface ComplianceFailureClassification { + domain: ComplianceFailureDomain; + code: string; + classified: boolean; +} + +/** + * Classifies a caught error into a safe, closed-vocabulary compliance + * failure summary for support diagnostics. + * + * Only the error's `code` (a closed enum on every SDK error class) is ever + * read — never `.message` or `.cause`, both of which interpolate raw values + * elsewhere in the SDK (RPC URLs, upstream error text) and could carry an + * investor address or other identity data. An error this function doesn't + * recognize is reported as `{ domain: 'unknown', classified: false }` rather + * than having its message inspected to guess a category. + */ +export function classifyComplianceFailure(error: unknown): ComplianceFailureClassification { + if (error instanceof PortfolioError) { + return Object.freeze({ domain: 'portfolio', code: error.code, classified: true }); + } + if (error instanceof RoleError) { + return Object.freeze({ domain: 'role', code: error.code, classified: true }); + } + if (error instanceof ConfigValidationError) { + return Object.freeze({ domain: 'config', code: error.code, classified: true }); + } + return Object.freeze({ domain: 'unknown', code: 'UNKNOWN', classified: false }); +} diff --git a/src/diagnostics/config.ts b/src/diagnostics/config.ts new file mode 100644 index 0000000..1feb74a --- /dev/null +++ b/src/diagnostics/config.ts @@ -0,0 +1,112 @@ +import { AegisClientConfig, resolveClientConfig } from '../config/validate'; +import { AEGIS_ENVIRONMENTS, AegisEnvironmentName } from '../config/environments'; +import { ConfigErrorCode, ConfigValidationError } from '../errors/config'; + +/** + * Safe, allowlisted description of an `AegisClientConfig`'s RPC endpoint. + * Only `origin` and `pathname` are ever included — query strings and hashes + * are the parts of a URL most likely to carry API keys or tokens, so they + * are never read into a diagnostic, redacted or otherwise. + */ +export interface RpcUrlDiagnostic { + present: boolean; + origin?: string; + path?: string; +} + +export type ConfigDiagnosticsStatus = 'ok' | 'invalid'; + +export interface ConfigDiagnostics { + contractId: { present: boolean }; + environment: { name: AegisEnvironmentName | 'custom' | 'unsupported' | 'unset' }; + rpcUrl: RpcUrlDiagnostic; + networkPassphrase: { present: boolean }; + signerConfigured: boolean; + status: ConfigDiagnosticsStatus; + errorCode?: ConfigErrorCode; +} + +/** + * Builds a safe, allowlisted diagnostic of an `AegisClientConfig`-shaped value. + * + * This never throws, even for missing, malformed, or entirely bogus input — + * invalid config is reported as `status: 'invalid'` with the validation + * `errorCode`, not as an exception. Every field is read explicitly by name; + * nothing from the input object is copied into the result wholesale, so a + * field the caller adds that this function doesn't know about (a stray + * secret, an unexpected credential) is silently absent from the report + * rather than silently included. + */ +export function diagnoseConfig(rawConfig: unknown): ConfigDiagnostics { + const candidate = isPlainObject(rawConfig) ? rawConfig : {}; + + let resolvedRpcUrl: string | undefined; + let resolvedNetworkPassphrase: string | undefined; + let errorCode: ConfigErrorCode | undefined; + + try { + const resolved = resolveClientConfig(candidate as unknown as AegisClientConfig); + resolvedRpcUrl = resolved.rpcUrl; + resolvedNetworkPassphrase = resolved.networkPassphrase; + } catch (error) { + errorCode = error instanceof ConfigValidationError ? error.code : 'MISSING_CONFIG'; + } + + const effectiveRpcUrl = resolvedRpcUrl ?? readString(candidate.rpcUrl); + const effectiveNetworkPassphrase = + resolvedNetworkPassphrase ?? readString(candidate.networkPassphrase); + + return Object.freeze({ + contractId: { present: isNonEmptyString(candidate.contractId) }, + environment: { name: describeEnvironment(candidate) }, + rpcUrl: describeRpcUrl(effectiveRpcUrl), + networkPassphrase: { present: effectiveNetworkPassphrase.length > 0 }, + signerConfigured: candidate.keypair !== undefined && candidate.keypair !== null, + status: errorCode ? 'invalid' : 'ok', + ...(errorCode ? { errorCode } : {}), + }); +} + +function describeEnvironment( + candidate: Record, +): AegisEnvironmentName | 'custom' | 'unsupported' | 'unset' { + if (typeof candidate.environment === 'string') { + // A named environment always takes priority over rpcUrl/networkPassphrase during + // resolution (see `resolveClientConfig`), so an unrecognized name is reported as + // 'unsupported' even when other fields are present — matching actual resolution + // behavior rather than the raw (and possibly mistaken) string itself. + return candidate.environment in AEGIS_ENVIRONMENTS + ? (candidate.environment as AegisEnvironmentName) + : 'unsupported'; + } + if (isNonEmptyString(candidate.rpcUrl) || isNonEmptyString(candidate.networkPassphrase)) { + return 'custom'; + } + return 'unset'; +} + +function describeRpcUrl(rpcUrl: string): RpcUrlDiagnostic { + if (!rpcUrl) { + return { present: false }; + } + try { + const parsed = new URL(rpcUrl); + return { present: true, origin: parsed.origin, path: parsed.pathname }; + } catch { + // Malformed value — report presence only. The raw string is never + // echoed back since a copy/paste mistake could put a token there. + return { present: true }; + } +} + +function isNonEmptyString(value: unknown): boolean { + return typeof value === 'string' && value.length > 0; +} + +function readString(value: unknown): string { + return typeof value === 'string' ? value : ''; +} + +function isPlainObject(value: unknown): value is Record { + return typeof value === 'object' && value !== null; +} diff --git a/src/diagnostics/report.ts b/src/diagnostics/report.ts new file mode 100644 index 0000000..7ab8b12 --- /dev/null +++ b/src/diagnostics/report.ts @@ -0,0 +1,77 @@ +import { AegisClient } from '../client'; +import type { AegisReadOnlyClient } from '../client-factory'; +import { diagnoseConfig, ConfigDiagnostics } from './config'; +import { buildRuntimeDiagnostics, RuntimeDiagnostics } from './runtime'; +import { classifyComplianceFailure, ComplianceFailureClassification } from './compliance'; + +export interface AegisDiagnosticsReport { + generatedAt: string; + config: ConfigDiagnostics; + runtime: RuntimeDiagnostics; + complianceFailure?: ComplianceFailureClassification; +} + +export interface DiagnosticsReportInput { + /** The same config object passed to the client constructor / factory function. */ + config?: unknown; + /** A constructed `AegisClient`, or a role-aware client from `client-factory`. */ + client?: AegisClient | AegisReadOnlyClient; + /** An error caught from a compliance-sensitive operation, to classify alongside the report. */ + complianceError?: unknown; +} + +const UNPROBED_RUNTIME: RuntimeDiagnostics = Object.freeze({ + rpcReachability: 'unknown', + role: 'unspecified', + signerConfigured: false, +}); + +/** + * Builds a full, redacted diagnostics report for Aegis SDK support requests: + * config validity, RPC reachability, declared role, signer presence, and + * (optionally) a classified compliance failure. + * + * Redaction is structural, not a final pass: `diagnoseConfig` and + * `buildRuntimeDiagnostics` each build their own allowlisted result, and this + * function only ever assembles those results — it never has access to the + * raw config object's unlisted fields or to the client's keypair, so there is + * no step here that could leak either. + */ +export async function buildDiagnosticsReport( + input: DiagnosticsReportInput, +): Promise { + const config = diagnoseConfig(input.config); + + let runtime = UNPROBED_RUNTIME; + if (input.client) { + const client = input.client; + runtime = isRoleAwareClient(client) + ? await buildRuntimeDiagnostics(client.client, { role: client.role }) + : await buildRuntimeDiagnostics(client); + } + + const complianceFailure = + input.complianceError !== undefined + ? classifyComplianceFailure(input.complianceError) + : undefined; + + return Object.freeze({ + generatedAt: new Date().toISOString(), + config, + runtime, + ...(complianceFailure ? { complianceFailure } : {}), + }); +} + +/** + * Distinguishes a role-aware client (from `client-factory`) from a plain + * `AegisClient`. Both classes have a `.role` property, but they mean + * different things: on `AegisClient` it's the `RoleModule` instance, on a + * role-aware client it's the declared `ClientRole` string — so the check + * must inspect the value's type, not just the property's presence. + */ +function isRoleAwareClient( + target: AegisClient | AegisReadOnlyClient, +): target is AegisReadOnlyClient { + return typeof (target as AegisReadOnlyClient).role === 'string'; +} diff --git a/src/diagnostics/runtime.ts b/src/diagnostics/runtime.ts new file mode 100644 index 0000000..c8460b9 --- /dev/null +++ b/src/diagnostics/runtime.ts @@ -0,0 +1,42 @@ +import { AegisClient } from '../client'; +import { ClientRole } from '../types/client-factory'; +import { buildNetworkFailureDiagnostic, NetworkFailureDiagnostic } from './network'; + +export type RpcReachability = 'reachable' | 'unreachable' | 'unknown'; + +export interface RuntimeDiagnostics { + rpcReachability: RpcReachability; + rpcFailure?: NetworkFailureDiagnostic; + role: ClientRole | 'unspecified'; + signerConfigured: boolean; +} + +/** + * Probes live runtime state for a constructed `AegisClient`: whether the RPC + * endpoint responds, the declared role (if known), and whether a signer is + * configured. Never returns the signer itself — only its presence — and any + * RPC failure is passed through the existing redacted network-failure + * diagnostic rather than the raw error. + */ +export async function buildRuntimeDiagnostics( + client: AegisClient, + opts: { role?: ClientRole } = {}, +): Promise { + let rpcReachability: RpcReachability = 'unknown'; + let rpcFailure: NetworkFailureDiagnostic | undefined; + + try { + await client.runNetworkOperation(() => client.rpcServer.getHealth()); + rpcReachability = 'reachable'; + } catch (error) { + rpcReachability = 'unreachable'; + rpcFailure = buildNetworkFailureDiagnostic(error); + } + + return Object.freeze({ + rpcReachability, + ...(rpcFailure ? { rpcFailure } : {}), + role: opts.role ?? 'unspecified', + signerConfigured: !!client.keypair, + }); +} diff --git a/src/index.ts b/src/index.ts index b498dcf..d93cca5 100644 --- a/src/index.ts +++ b/src/index.ts @@ -40,6 +40,14 @@ export { NetworkFailureDiagnostic, NetworkRecoveryAction, } from './diagnostics/network'; +export { diagnoseConfig, ConfigDiagnostics, ConfigDiagnosticsStatus, RpcUrlDiagnostic } from './diagnostics/config'; +export { buildRuntimeDiagnostics, RuntimeDiagnostics, RpcReachability } from './diagnostics/runtime'; +export { + classifyComplianceFailure, + ComplianceFailureClassification, + ComplianceFailureDomain, +} from './diagnostics/compliance'; +export { buildDiagnosticsReport, AegisDiagnosticsReport, DiagnosticsReportInput } from './diagnostics/report'; export { resolveClientConfig } from './config/validate'; export { AEGIS_ENVIRONMENTS, getEnvironmentPreset } from './config/environments'; export * from './types/portfolio'; diff --git a/tests/diagnostics.test.ts b/tests/diagnostics.test.ts new file mode 100644 index 0000000..c55945a --- /dev/null +++ b/tests/diagnostics.test.ts @@ -0,0 +1,257 @@ +import { Keypair, Networks } from '@stellar/stellar-sdk'; +import { AegisClient } from '../src/client'; +import { createReadOnlyClient, createInvestorClient } from '../src/client-factory'; +import { + diagnoseConfig, + buildRuntimeDiagnostics, + classifyComplianceFailure, + buildDiagnosticsReport, +} from '../src'; +import { PortfolioError } from '../src/errors/portfolio'; +import { RoleError } from '../src/errors/role'; +import { ConfigValidationError } from '../src/errors/config'; + +const mockContractId = 'CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABSC4'; +const KNOWN_SECRET = Keypair.random().secret(); +const KNOWN_PUBLIC = Keypair.random().publicKey(); + +describe('diagnoseConfig', () => { + it('never includes a known secret key, whole or partial, in the serialized report', () => { + const report = diagnoseConfig({ + contractId: mockContractId, + environment: 'testnet', + keypair: Keypair.fromSecret(KNOWN_SECRET), + }); + + const serialized = JSON.stringify(report); + expect(serialized).not.toContain(KNOWN_SECRET); + expect(serialized).not.toContain(KNOWN_SECRET.slice(0, 10)); + expect(report.signerConfigured).toBe(true); + }); + + it('does not include an unknown, unlisted sensitive field the reporter has never heard of', () => { + const report = diagnoseConfig({ + contractId: mockContractId, + environment: 'testnet', + // A field the reporter's allowlist has no knowledge of. If the reporter + // ever changed to a denylist / spread-then-strip approach, this would leak. + seedPhrase: 'legs galaxy inner rival fossil chalk energy nose demand oyster clip fatal', + apiSecret: 'sk-live-totally-secret-value', + } as unknown as Record); + + const serialized = JSON.stringify(report); + expect(serialized).not.toContain('legs galaxy'); + expect(serialized).not.toContain('sk-live-totally-secret-value'); + expect(Object.keys(report)).toEqual([ + 'contractId', + 'environment', + 'rpcUrl', + 'networkPassphrase', + 'signerConfigured', + 'status', + ]); + }); + + it('redacts credentials carried in an RPC URL query string, keeping only origin and path', () => { + const report = diagnoseConfig({ + contractId: mockContractId, + rpcUrl: 'https://rpc.example.com:8443/soroban/rpc?apiKey=super-secret-token', + networkPassphrase: Networks.TESTNET, + }); + + const serialized = JSON.stringify(report); + expect(serialized).not.toContain('super-secret-token'); + expect(serialized).not.toContain('apiKey'); + expect(report.rpcUrl).toEqual({ + present: true, + origin: 'https://rpc.example.com:8443', + path: '/soroban/rpc', + }); + }); + + it('reports incomplete/invalid config as a status, without throwing', () => { + expect(() => diagnoseConfig({ environment: 'devnet' })).not.toThrow(); + expect(() => diagnoseConfig(undefined)).not.toThrow(); + expect(() => diagnoseConfig(null)).not.toThrow(); + expect(() => diagnoseConfig('not an object')).not.toThrow(); + + const missingContractId = diagnoseConfig({ environment: 'testnet' }); + expect(missingContractId.status).toBe('invalid'); + expect(missingContractId.errorCode).toBe('MISSING_CONFIG'); + expect(missingContractId.contractId).toEqual({ present: false }); + + const empty = diagnoseConfig(undefined); + expect(empty.status).toBe('invalid'); + expect(empty.environment).toEqual({ name: 'unset' }); + expect(empty.rpcUrl).toEqual({ present: false }); + }); + + it('reports an unsupported network name as a distinct state rather than "unset"', () => { + const report = diagnoseConfig({ contractId: mockContractId, environment: 'devnet' }); + + expect(report.status).toBe('invalid'); + expect(report.errorCode).toBe('MISSING_CONFIG'); + expect(report.environment).toEqual({ name: 'unsupported' }); + }); + + it('reports the mainnet preset as unavailable when allowMainnet is not set', () => { + const report = diagnoseConfig({ contractId: mockContractId, environment: 'mainnet' }); + + expect(report.status).toBe('invalid'); + expect(report.errorCode).toBe('ENVIRONMENT_UNAVAILABLE'); + expect(report.environment).toEqual({ name: 'mainnet' }); + }); + + it('reports a valid resolved config as ok, including preset-derived rpcUrl', () => { + const report = diagnoseConfig({ contractId: mockContractId, environment: 'testnet' }); + + expect(report.status).toBe('ok'); + expect(report.errorCode).toBeUndefined(); + expect(report.contractId).toEqual({ present: true }); + expect(report.environment).toEqual({ name: 'testnet' }); + expect(report.rpcUrl.present).toBe(true); + expect(report.rpcUrl.origin).toBe('https://soroban-testnet.stellar.org'); + }); +}); + +describe('buildRuntimeDiagnostics', () => { + const makeClient = (keypair?: Keypair) => + new AegisClient({ + rpcUrl: 'https://soroban-testnet.stellar.org', + networkPassphrase: Networks.TESTNET, + contractId: mockContractId, + keypair, + }); + + it('reports reachable RPC, signer presence, and declared role', async () => { + const client = makeClient(Keypair.fromSecret(KNOWN_SECRET)); + jest.spyOn(client.rpcServer, 'getHealth').mockResolvedValue({ status: 'healthy' } as any); + + const runtime = await buildRuntimeDiagnostics(client, { role: 'investor' }); + + expect(runtime.rpcReachability).toBe('reachable'); + expect(runtime.signerConfigured).toBe(true); + expect(runtime.role).toBe('investor'); + expect(JSON.stringify(runtime)).not.toContain(KNOWN_SECRET); + }); + + it('reports unreachable RPC via the existing redacted network-failure diagnostic', async () => { + const client = makeClient(); + jest + .spyOn(client.rpcServer, 'getHealth') + .mockRejectedValue( + Object.assign(new Error('connect ECONNREFUSED, auth=secret-token'), { + code: 'ECONNREFUSED', + }), + ); + + const runtime = await buildRuntimeDiagnostics(client); + + expect(runtime.rpcReachability).toBe('unreachable'); + expect(runtime.rpcFailure?.code).toBe('RPC_UNAVAILABLE'); + expect(JSON.stringify(runtime)).not.toContain('secret-token'); + expect(runtime.role).toBe('unspecified'); + expect(runtime.signerConfigured).toBe(false); + }); +}); + +describe('classifyComplianceFailure', () => { + it('classifies a PortfolioError compliance failure by code, without message/cause text', () => { + const error = new PortfolioError( + `Compliance status query failed for ${KNOWN_PUBLIC}: private upstream detail`, + 'COMPLIANCE_ERROR', + ); + + const classification = classifyComplianceFailure(error); + + expect(classification).toEqual({ + domain: 'portfolio', + code: 'COMPLIANCE_ERROR', + classified: true, + }); + expect(JSON.stringify(classification)).not.toContain(KNOWN_PUBLIC); + expect(JSON.stringify(classification)).not.toContain('private upstream detail'); + }); + + it('classifies a RoleError compliance failure', () => { + const classification = classifyComplianceFailure( + new RoleError('Compliance status query failed', 'COMPLIANCE_ERROR'), + ); + + expect(classification).toEqual({ domain: 'role', code: 'COMPLIANCE_ERROR', classified: true }); + }); + + it('classifies a config validation failure', () => { + const classification = classifyComplianceFailure( + new ConfigValidationError('bad config', 'INVALID_RPC_URL'), + ); + + expect(classification).toEqual({ domain: 'config', code: 'INVALID_RPC_URL', classified: true }); + }); + + it('marks unrecognized errors as unclassified rather than guessing from message text', () => { + const classification = classifyComplianceFailure(new Error(`identity leak ${KNOWN_PUBLIC}`)); + + expect(classification).toEqual({ domain: 'unknown', code: 'UNKNOWN', classified: false }); + expect(JSON.stringify(classification)).not.toContain(KNOWN_PUBLIC); + }); +}); + +describe('buildDiagnosticsReport', () => { + it('combines config, runtime, and compliance sections with no secret material anywhere', async () => { + const keypair = Keypair.fromSecret(KNOWN_SECRET); + const client = createReadOnlyClient({ + contractId: mockContractId, + environment: 'testnet', + }); + jest + .spyOn(client.client.rpcServer, 'getHealth') + .mockResolvedValue({ status: 'healthy' } as any); + + const report = await buildDiagnosticsReport({ + config: { contractId: mockContractId, environment: 'testnet', keypair }, + client, + complianceError: new PortfolioError('failed', 'COMPLIANCE_ERROR'), + }); + + expect(report.config.status).toBe('ok'); + expect(report.runtime.rpcReachability).toBe('reachable'); + expect(report.runtime.role).toBe('read-only'); + expect(report.complianceFailure).toEqual({ + domain: 'portfolio', + code: 'COMPLIANCE_ERROR', + classified: true, + }); + expect(JSON.stringify(report)).not.toContain(KNOWN_SECRET); + }); + + it('reads the declared role from an investor (signer-capable) role-aware client', async () => { + const keypair = Keypair.fromSecret(KNOWN_SECRET); + const client = createInvestorClient({ + contractId: mockContractId, + environment: 'testnet', + keypair, + }); + jest + .spyOn(client.client.rpcServer, 'getHealth') + .mockResolvedValue({ status: 'healthy' } as any); + + const report = await buildDiagnosticsReport({ client }); + + expect(report.runtime.role).toBe('investor'); + expect(report.runtime.signerConfigured).toBe(true); + expect(JSON.stringify(report)).not.toContain(KNOWN_SECRET); + }); + + it('produces an unprobed runtime section when no client is given, without throwing', async () => { + const report = await buildDiagnosticsReport({ config: { environment: 'testnet' } }); + + expect(report.runtime).toEqual({ + rpcReachability: 'unknown', + role: 'unspecified', + signerConfigured: false, + }); + expect(report.config.status).toBe('invalid'); + expect(report.config.errorCode).toBe('MISSING_CONFIG'); + }); +});