release(v4.0.0): Shade GA — V3.x consolidation + audit prep
Some checks failed
Test / test (push) Has been cancelled
Cross-platform vectors / TypeScript vectors (bun) (push) Has been cancelled
Cross-platform vectors / Kotlin vectors (gradle) (push) Has been cancelled
Docker build and publish / docker (push) Has been cancelled
Publish / publish (push) Has been cancelled
Some checks failed
Test / test (push) Has been cancelled
Cross-platform vectors / TypeScript vectors (bun) (push) Has been cancelled
Cross-platform vectors / Kotlin vectors (gradle) (push) Has been cancelled
Docker build and publish / docker (push) Has been cancelled
Publish / publish (push) Has been cancelled
V3.1 → V3.12 consolidated and tagged for the first GA release. Wire format unchanged from 0.4.x — 4.0 peers interoperate with 0.4.x peers byte-for-byte. The version bump is semantic: audit-cycle complete, opt-in surface fully exposed, threat model refreshed for every new surface. Highlights: - All 24 @shade/* packages bumped to 4.0.0 in lockstep. - CHANGELOG 4.0.0 section is the canonical manifest of what landed. - THREAT-MODEL extended (§10 fingerprint gates, §11 WebRTC P2P, §12 Web-Worker boundary) + residual-risks table refreshed. - OpenAPI now covers all 27 routes: prekey, transfer, KT, inbox, bridge, observer, /metrics, /healthz, /ready. - MIGRATION 0.3.x → 4.0 documented + smoke-tested against shade migrate-storage on a real SQLite DB. - docs/audit/REVIEW-BUNDLE.md + SCOPE.md ready for external reviewer. - scripts/soak.ts harness for the GA-stable 2-week soak window. - All V*.md plans archived under docs/archive/ with Status: Done. - Voice/Video carved out into V5.0; 4.0 audit focuses on the frozen non-realtime stack. Tests: TS 1000/1000 + Kotlin 11/11 cross-platform vectors green. Docker: gt.zyon.no/stian/shade-prekey:4.0.0 builds and reports version 4.0.0 on /health. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
149
packages/shade-sdk/tests/gates-unit.test.ts
Normal file
149
packages/shade-sdk/tests/gates-unit.test.ts
Normal file
@@ -0,0 +1,149 @@
|
||||
import { describe, test, expect, beforeEach, mock } from 'bun:test';
|
||||
import { MemoryStorage } from '@shade/crypto-web';
|
||||
import { FingerprintNotVerifiedError } from '@shade/core';
|
||||
import { FingerprintGateRegistry } from '../src/gates.js';
|
||||
|
||||
describe('FingerprintGateRegistry — unit', () => {
|
||||
let storage: MemoryStorage;
|
||||
let gates: FingerprintGateRegistry;
|
||||
|
||||
beforeEach(() => {
|
||||
storage = new MemoryStorage();
|
||||
gates = new FingerprintGateRegistry(storage);
|
||||
});
|
||||
|
||||
test('default first-large-file threshold is 10 MiB', () => {
|
||||
expect(gates.getFirstLargeFileThreshold()).toBe(10 * 1024 * 1024);
|
||||
});
|
||||
|
||||
test('threshold becomes the registered value', () => {
|
||||
gates.registerFirstLargeFile(2048, () => true);
|
||||
expect(gates.getFirstLargeFileThreshold()).toBe(2048);
|
||||
});
|
||||
|
||||
test('rejects negative thresholds', () => {
|
||||
expect(() => gates.registerFirstLargeFile(-1, () => true)).toThrow();
|
||||
});
|
||||
|
||||
test('checkFirstLargeFile is a no-op when size < threshold', async () => {
|
||||
const handler = mock(() => true);
|
||||
gates.registerFirstLargeFile(10_000, handler);
|
||||
await gates.checkFirstLargeFile('bob', 'fp', 1_000);
|
||||
expect(handler).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('checkFirstLargeFile invokes handler and approves on true', async () => {
|
||||
let called = false;
|
||||
gates.registerFirstLargeFile(10, (ctx) => {
|
||||
called = true;
|
||||
expect(ctx.gate).toBe('first-large-file');
|
||||
expect(ctx.fileSize).toBe(100);
|
||||
expect(ctx.peerAddress).toBe('bob');
|
||||
expect(ctx.fingerprint).toBe('FP');
|
||||
return true;
|
||||
});
|
||||
|
||||
await gates.checkFirstLargeFile('bob', 'FP', 100);
|
||||
expect(called).toBe(true);
|
||||
|
||||
// Subsequent calls: peer is verified, handler not consulted.
|
||||
let secondCalled = false;
|
||||
gates.registerFirstLargeFile(10, () => {
|
||||
secondCalled = true;
|
||||
return false;
|
||||
});
|
||||
await gates.checkFirstLargeFile('bob', 'FP', 100);
|
||||
expect(secondCalled).toBe(false);
|
||||
});
|
||||
|
||||
test('handler false → throws FingerprintNotVerifiedError', async () => {
|
||||
gates.registerFirstLargeFile(10, () => false);
|
||||
await expect(gates.checkFirstLargeFile('bob', 'FP', 100)).rejects.toBeInstanceOf(
|
||||
FingerprintNotVerifiedError,
|
||||
);
|
||||
});
|
||||
|
||||
test('handler throw → throws FingerprintNotVerifiedError', async () => {
|
||||
gates.registerFirstLargeFile(10, () => {
|
||||
throw new Error('user closed modal');
|
||||
});
|
||||
await expect(gates.checkFirstLargeFile('bob', 'FP', 100)).rejects.toBeInstanceOf(
|
||||
FingerprintNotVerifiedError,
|
||||
);
|
||||
});
|
||||
|
||||
test('no handler registered → TOFU + warn + persists verification', async () => {
|
||||
const originalWarn = console.warn;
|
||||
let warnings = 0;
|
||||
console.warn = () => {
|
||||
warnings += 1;
|
||||
};
|
||||
try {
|
||||
// backup-import always fires (no threshold)
|
||||
await gates.checkBackupImport('bob', 'FP');
|
||||
} finally {
|
||||
console.warn = originalWarn;
|
||||
}
|
||||
expect(warnings).toBe(1);
|
||||
|
||||
// The peer is now considered verified at FP under the
|
||||
// tofu-after-warning source.
|
||||
expect(await gates.isVerified('bob', 'FP')).toBe(true);
|
||||
const v = await storage.getPeerVerification('bob');
|
||||
expect(v?.verifiedBy).toBe('tofu-after-warning');
|
||||
});
|
||||
|
||||
test('warn fires only once per peer', async () => {
|
||||
const originalWarn = console.warn;
|
||||
let warnings = 0;
|
||||
console.warn = () => {
|
||||
warnings += 1;
|
||||
};
|
||||
try {
|
||||
await gates.checkBackupImport('bob', 'FP');
|
||||
await gates.checkBackupImport('bob', 'FP');
|
||||
await gates.checkBackupImport('bob', 'FP');
|
||||
} finally {
|
||||
console.warn = originalWarn;
|
||||
}
|
||||
expect(warnings).toBe(1);
|
||||
});
|
||||
|
||||
test('isVerified is fingerprint-sensitive', async () => {
|
||||
await gates.markVerified('bob', 'FP_OLD');
|
||||
expect(await gates.isVerified('bob', 'FP_OLD')).toBe(true);
|
||||
expect(await gates.isVerified('bob', 'FP_NEW')).toBe(false);
|
||||
});
|
||||
|
||||
test('identity-version bump invalidates verification', async () => {
|
||||
await gates.markVerified('bob', 'FP');
|
||||
expect(await gates.isVerified('bob', 'FP')).toBe(true);
|
||||
|
||||
await storage.bumpPeerIdentityVersion('bob');
|
||||
expect(await gates.isVerified('bob', 'FP')).toBe(false);
|
||||
});
|
||||
|
||||
test('revoke removes saved verification', async () => {
|
||||
await gates.markVerified('bob', 'FP');
|
||||
expect(await gates.isVerified('bob', 'FP')).toBe(true);
|
||||
await gates.revoke('bob');
|
||||
expect(await gates.isVerified('bob', 'FP')).toBe(false);
|
||||
});
|
||||
|
||||
test('backup-import and new-device are minimum-gates (no threshold bypass)', async () => {
|
||||
let backupCalled = false;
|
||||
let newDeviceCalled = false;
|
||||
gates.registerBackupImport(() => {
|
||||
backupCalled = true;
|
||||
return true;
|
||||
});
|
||||
gates.registerNewDeviceTrust(() => {
|
||||
newDeviceCalled = true;
|
||||
return true;
|
||||
});
|
||||
await gates.checkBackupImport('me', 'FP1');
|
||||
await gates.checkNewDeviceTrust('bob', 'FP2');
|
||||
expect(backupCalled).toBe(true);
|
||||
expect(newDeviceCalled).toBe(true);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user