Files
Shade/packages/shade-core/tests/errors.test.ts

120 lines
3.6 KiB
TypeScript
Raw Normal View History

feat(hardening): M-Hard 1-5 — crypto, auth, rate limit, fingerprints, rotation M-Hard 1: Cryptographic Hardening - constantTimeEqual, zeroize, randomUint32 on CryptoProvider - Fix Math.random() → crypto.randomUint32() for registrationId - Zero message keys and chain keys after use in ratchet.ts - Constant-time trust comparison in MemoryStorage + SQLiteStorage - Timing variance test catches early-exit regressions M-Hard 2: Self-Authenticated Prekey Server - Ed25519 signature verification on all write routes - signPayload/verifyPayload with canonical JSON, ±5 min replay window - Address validation (NFKC, alphanumeric + :_-.) - Global ShadeError → HTTP status mapping - ShadeFetchTransport signs requests when signingPrivateKey provided - Anonymous bundle fetches still allowed (read-only) M-Hard 3: Rate Limiting + DoS Protection - Token bucket rate limiter with pluggable store - Per-route limits: register 5/h/IP, fetch 60/min/IP, replenish 10/min/id - 64KB body size limit on POST - Retry-After header on 429 responses M-Hard 4: Auto-replenish + Fingerprints + Session Reset - Safety numbers (12 groups × 5 digits, Signal-style) - ensurePreKeyStock, resetSession, acceptIdentityChange - verifyRemoteIdentity for out-of-band comparison M-Hard 5: Identity Rotation with Grace Period - rotateIdentity archives old identity, generates fresh signed prekey - RetiredIdentity storage with addRetired/getRetired/pruneRetired - 7-day default grace period for decrypting old sessions - pruneExpiredIdentities for cleanup M-Hard 8: Error Hierarchy - New error types: Network, Storage, Validation, Timeout, RateLimit, Configuration, Unauthorized, Replay, IdentityRotation - All errors have stable SHADE_* codes - errorToHttpStatus for consistent HTTP mapping - toJSON() for network serialization 188 tests passing, zero failures. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 17:45:34 +02:00
import { describe, test, expect } from 'bun:test';
import {
ShadeError,
InvalidSignatureError,
DecryptionError,
NoSessionError,
MaxSkipExceededError,
DuplicateMessageError,
UntrustedIdentityError,
PreKeyNotFoundError,
ReplayError,
IdentityRotationError,
NetworkError,
StorageError,
ValidationError,
TimeoutError,
RateLimitError,
ConfigurationError,
UnauthorizedError,
errorToHttpStatus,
} from '../src/errors.js';
describe('Error hierarchy', () => {
test('all errors extend ShadeError', () => {
const errors = [
new InvalidSignatureError(),
new DecryptionError(),
new NoSessionError('addr'),
new MaxSkipExceededError(1001, 1000),
new DuplicateMessageError(),
new UntrustedIdentityError('addr'),
new PreKeyNotFoundError(1, 'signed'),
new ReplayError(),
new IdentityRotationError(),
new NetworkError('net'),
new StorageError('storage'),
new ValidationError('val'),
new TimeoutError(),
new RateLimitError(),
new ConfigurationError('cfg'),
new UnauthorizedError(),
];
for (const e of errors) {
expect(e).toBeInstanceOf(ShadeError);
expect(e).toBeInstanceOf(Error);
expect(e.code).toMatch(/^SHADE_/);
expect(e.name).not.toBe('ShadeError');
}
});
test('toJSON produces serializable form', () => {
const e = new InvalidSignatureError('bad sig');
const json = e.toJSON();
expect(json.name).toBe('InvalidSignatureError');
expect(json.code).toBe('SHADE_INVALID_SIGNATURE');
expect(json.message).toBe('bad sig');
const str = JSON.stringify(e);
const parsed = JSON.parse(str);
expect(parsed.code).toBe('SHADE_INVALID_SIGNATURE');
});
test('NetworkError carries status code', () => {
const e = new NetworkError('server error', 503);
expect(e.statusCode).toBe(503);
});
test('RateLimitError carries retry-after', () => {
const e = new RateLimitError('too many', 60);
expect(e.retryAfterSeconds).toBe(60);
});
test('ValidationError carries field name', () => {
const e = new ValidationError('must be positive', 'count');
expect(e.field).toBe('count');
});
});
describe('errorToHttpStatus', () => {
test('unknown error → 500', () => {
expect(errorToHttpStatus(new Error('plain'))).toBe(500);
expect(errorToHttpStatus('string error')).toBe(500);
expect(errorToHttpStatus(null)).toBe(500);
});
test('auth errors → 401/403', () => {
expect(errorToHttpStatus(new InvalidSignatureError())).toBe(401);
expect(errorToHttpStatus(new UnauthorizedError())).toBe(401);
expect(errorToHttpStatus(new UntrustedIdentityError('x'))).toBe(403);
});
test('not-found errors → 404', () => {
expect(errorToHttpStatus(new NoSessionError('x'))).toBe(404);
expect(errorToHttpStatus(new PreKeyNotFoundError(1, 'signed'))).toBe(404);
});
test('validation → 400', () => {
expect(errorToHttpStatus(new ValidationError('bad'))).toBe(400);
});
test('replay/duplicate → 409', () => {
expect(errorToHttpStatus(new ReplayError())).toBe(409);
expect(errorToHttpStatus(new DuplicateMessageError())).toBe(409);
});
test('rate limit → 429', () => {
expect(errorToHttpStatus(new RateLimitError())).toBe(429);
});
test('infra errors → 503', () => {
expect(errorToHttpStatus(new NetworkError('x'))).toBe(503);
expect(errorToHttpStatus(new StorageError('x'))).toBe(503);
expect(errorToHttpStatus(new ConfigurationError('x'))).toBe(503);
});
test('timeout → 504', () => {
expect(errorToHttpStatus(new TimeoutError())).toBe(504);
});
});