150 lines
4.8 KiB
TypeScript
150 lines
4.8 KiB
TypeScript
|
|
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);
|
||
|
|
});
|
||
|
|
});
|