Files
vercel__workflow/packages/errors/src/runtime-decryption-error.test.ts
Nathan Rajlich 8d0928b2a2 fix(core,errors): classify SDK encryption failures as RUNTIME_ERROR (#2145)
* fix(core,errors): classify SDK encryption failures as RUNTIME_ERROR

SDK-level AES-GCM encrypt/decrypt failures are never the user's fault,
but the run-failure classifier was tagging them as USER_ERROR because
the native Web Crypto OperationError (most commonly raised by
AESCipherJob.onDone on GCM auth-tag mismatch) does not match any
RUNTIME_ERROR_CHECKS entry.

Introduce a new RuntimeDecryptionError (subclass of WorkflowRuntimeError)
that the encryption module throws when subtle.encrypt/subtle.decrypt
fails, with the original DOMException as cause plus diagnostic context
(operation, byteLength, printable/hex format prefix of the input
header). classifyRunError now picks it up via RUNTIME_ERROR_CHECKS, so
these failures surface as RUNTIME_ERROR with a proper named class for
dashboards and triage.

* Trim changeset description to one sentence

* Trim historical-context comments

* docs: add runtime-decryption-failed troubleshooting page (v4 + v5)

* fix(core): round-trip RuntimeDecryptionError context, fix formatPrefix, propagate through serialization wrappers

Addresses review feedback on #2145:

- Add a RuntimeDecryptionError reducer/reviver (+ SerializableSpecial
  entry + globalThis registration) so its `context` (operation,
  byteLength, formatPrefix) survives the dehydrate/hydrate run-error
  round trip instead of being dropped by the generic Error reducer.

- Stop capturing `formatPrefix` in the low-level encryption layer, which
  only sees the stripped AES payload (nonce bytes), not the outer `encr`
  marker. The serialization layer now attaches the real envelope prefix.

- Rethrow RuntimeDecryptionError unchanged from the serialize/dehydrate
  catch blocks instead of reframing it as a SerializationError, so an
  encryption failure during dehydration stays a RUNTIME_ERROR rather than
  being misclassified as USER_ERROR.

* fix(core): enrich stream decrypt errors with envelope prefix + fix lint

- Mirror the catch/enrich/rethrow block from serialization/encryption.ts
  around the stream-path aesGcmDecrypt() call so auth-tag failures on
  encrypted stream frames also carry context.formatPrefix = 'encr'
  (addresses review feedback). Add a tampered-frame test.
- Fix all auto-fixable Biome lint findings in the touched files
  (template literals, useless try/catch wrappers, optional chaining,
  non-null assertions).
2026-05-29 18:53:17 +00:00

63 lines
2.2 KiB
TypeScript

import { describe, expect, test } from 'vitest';
import {
RuntimeDecryptionError,
WorkflowError,
WorkflowRuntimeError,
} from './index.js';
describe('RuntimeDecryptionError', () => {
test('sets the name and extends WorkflowRuntimeError', () => {
const err = new RuntimeDecryptionError('decrypt failed');
expect(err.name).toBe('RuntimeDecryptionError');
expect(err).toBeInstanceOf(WorkflowError);
expect(err).toBeInstanceOf(WorkflowRuntimeError);
expect(err).toBeInstanceOf(RuntimeDecryptionError);
});
test('adds the runtime-decryption-failed docs link', () => {
const err = new RuntimeDecryptionError('decrypt failed');
expect(err.message).toContain(
'https://workflow-sdk.dev/err/runtime-decryption-failed'
);
});
test('preserves cause for debugging', () => {
const cause = new Error('underlying OperationError');
const err = new RuntimeDecryptionError('decrypt failed', { cause });
expect(err.cause).toBe(cause);
});
test('records optional diagnostic context', () => {
const err = new RuntimeDecryptionError('decrypt failed', {
context: {
operation: 'decrypt',
byteLength: 42,
formatPrefix: 'encr',
},
});
expect(err.context).toEqual({
operation: 'decrypt',
byteLength: 42,
formatPrefix: 'encr',
});
});
test('omits the context property when not provided', () => {
const err = new RuntimeDecryptionError('decrypt failed');
expect('context' in err).toBe(false);
});
test('RuntimeDecryptionError.is discriminates by name', () => {
const err = new RuntimeDecryptionError('decrypt failed');
const other = new Error('decrypt failed');
const runtimeOnly = new WorkflowRuntimeError('decrypt failed');
expect(RuntimeDecryptionError.is(err)).toBe(true);
// .is() is a name-based duck check, so a plain WorkflowRuntimeError
// does NOT pass — subclassing alone is not enough.
expect(RuntimeDecryptionError.is(runtimeOnly)).toBe(false);
expect(RuntimeDecryptionError.is(other)).toBe(false);
expect(RuntimeDecryptionError.is(null)).toBe(false);
expect(RuntimeDecryptionError.is(undefined)).toBe(false);
});
});