Some checks failed
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / update Nix release metadata (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
test / lint-and-test (push) Has been cancelled
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
build-et-binaries / build-linux-x64 (push) Has been cancelled
build-et-binaries / build-linux-arm64 (push) Has been cancelled
build-et-binaries / build-macos-universal (push) Has been cancelled
build-et-binaries / build-windows-x64 (push) Has been cancelled
build-et-binaries / release (push) Has been cancelled
418 lines
14 KiB
TypeScript
418 lines
14 KiB
TypeScript
import assert from 'node:assert/strict';
|
|
import { describe, it } from 'node:test';
|
|
import type { SyncedFile } from '../../../domain/sync';
|
|
import type { CloudAdapter } from './index';
|
|
import {
|
|
cloudAdapterAsEncryptedObjectStorage,
|
|
encryptedObjectStorageAsCloudAdapter,
|
|
webdavEncryptedObjectCapabilities,
|
|
} from './encryptedObjectStorageBridge';
|
|
import { DEFAULT_ENCRYPTED_SYNC_OBJECT_KEY } from '../../../domain/encryptedObjectStorage';
|
|
import { createPluginSyncObjectStorage } from './pluginSyncObjectStorage';
|
|
|
|
function makeSyncedFile(version: number, payload = 'cipher'): SyncedFile {
|
|
return {
|
|
meta: {
|
|
version,
|
|
updatedAt: 1,
|
|
deviceId: 'device',
|
|
appVersion: '0.0.0',
|
|
iv: 'iv',
|
|
salt: 'salt',
|
|
algorithm: 'AES-256-GCM',
|
|
kdf: 'PBKDF2',
|
|
},
|
|
payload,
|
|
};
|
|
}
|
|
|
|
function memoryCloudAdapter(initial: SyncedFile | null = null): CloudAdapter & { store: SyncedFile | null } {
|
|
const adapter = {
|
|
store: initial,
|
|
isAuthenticated: true,
|
|
accountInfo: { id: 'webdav-host', name: 'user@host' },
|
|
resourceId: null as string | null,
|
|
signOut() {
|
|
adapter.isAuthenticated = false;
|
|
adapter.accountInfo = null;
|
|
adapter.store = null;
|
|
},
|
|
async initializeSync() {
|
|
adapter.resourceId = DEFAULT_ENCRYPTED_SYNC_OBJECT_KEY;
|
|
return adapter.resourceId;
|
|
},
|
|
async upload(file: SyncedFile) {
|
|
adapter.store = file;
|
|
adapter.resourceId = DEFAULT_ENCRYPTED_SYNC_OBJECT_KEY;
|
|
return adapter.resourceId;
|
|
},
|
|
async download() {
|
|
return adapter.store;
|
|
},
|
|
async deleteSync() {
|
|
adapter.store = null;
|
|
},
|
|
getTokens() {
|
|
return null;
|
|
},
|
|
};
|
|
return adapter;
|
|
}
|
|
|
|
describe('encryptedObjectStorageBridge', () => {
|
|
it('adapts WebDAV-style CloudAdapter through the encrypted-object interface', async () => {
|
|
const adapter = memoryCloudAdapter(makeSyncedFile(3, 'remote-cipher'));
|
|
const storage = cloudAdapterAsEncryptedObjectStorage(adapter, 'webdav', {
|
|
capabilities: webdavEncryptedObjectCapabilities(),
|
|
});
|
|
|
|
const caps = await storage.getCapabilities();
|
|
assert.equal(caps.atomicReplacement, true);
|
|
assert.equal(caps.revisions, false);
|
|
|
|
const connected = await storage.connect();
|
|
assert.equal(connected.account.id, 'webdav-host');
|
|
|
|
const read = await storage.readObject(DEFAULT_ENCRYPTED_SYNC_OBJECT_KEY);
|
|
assert.equal(read.found, true);
|
|
assert.ok(read.bytes);
|
|
assert.equal(read.revision, '3');
|
|
|
|
const next = makeSyncedFile(4, 'next-cipher');
|
|
const encoded = new TextEncoder().encode(JSON.stringify(next));
|
|
const written = await storage.writeObject(DEFAULT_ENCRYPTED_SYNC_OBJECT_KEY, encoded);
|
|
assert.equal(written.created, false);
|
|
assert.equal(written.revision, '4');
|
|
|
|
const reloaded = await adapter.download();
|
|
assert.equal(reloaded?.payload, 'next-cipher');
|
|
|
|
const deleted = await storage.deleteObject(DEFAULT_ENCRYPTED_SYNC_OBJECT_KEY);
|
|
assert.equal(deleted.deleted, true);
|
|
assert.equal(await adapter.download(), null);
|
|
});
|
|
|
|
it('adapts EncryptedObjectStorage back to CloudAdapter for manager upload/download', async () => {
|
|
const memory = new Map<string, Uint8Array>();
|
|
let backingResourceId: string | null = '/persisted/path.json';
|
|
const storage = {
|
|
providerId: 'com.example.sync',
|
|
async connect() {
|
|
backingResourceId = '/after-connect/path.json';
|
|
return { account: { id: 'plugin-acct' } };
|
|
},
|
|
async disconnect() {},
|
|
async getAccount() {
|
|
return { id: 'plugin-acct' };
|
|
},
|
|
async getCapabilities() {
|
|
return {
|
|
revisions: true,
|
|
conditionalWrites: true,
|
|
atomicReplacement: true,
|
|
maxObjectBytes: 1024,
|
|
};
|
|
},
|
|
async readObject(key: string) {
|
|
const bytes = memory.get(key) ?? null;
|
|
return bytes
|
|
? { found: true as const, key, bytes, revision: 'r1' }
|
|
: { found: false as const, key, bytes: null };
|
|
},
|
|
async writeObject(key: string, bytes: Uint8Array) {
|
|
const created = !memory.has(key);
|
|
memory.set(key, bytes);
|
|
return { created, revision: 'r2' };
|
|
},
|
|
async deleteObject(key: string) {
|
|
const deleted = memory.delete(key);
|
|
return { deleted };
|
|
},
|
|
};
|
|
|
|
const adapter = encryptedObjectStorageAsCloudAdapter(storage, {
|
|
initiallyAuthenticated: true,
|
|
resourceId: '/persisted/path.json',
|
|
resolveResourceId: () => backingResourceId,
|
|
});
|
|
assert.equal(adapter.isAuthenticated, true);
|
|
assert.equal(adapter.resourceId, '/persisted/path.json');
|
|
|
|
await adapter.initializeSync();
|
|
assert.equal(adapter.accountInfo?.id, 'plugin-acct');
|
|
assert.equal(adapter.resourceId, '/after-connect/path.json');
|
|
|
|
const file = makeSyncedFile(9, 'plugin-cipher');
|
|
await adapter.upload(file);
|
|
const downloaded = await adapter.download();
|
|
assert.equal(downloaded?.payload, 'plugin-cipher');
|
|
assert.equal(downloaded?.meta.version, 9);
|
|
});
|
|
|
|
// note: upload() re-reads for verification; memory map above is sufficient
|
|
|
|
it('rebindSession re-issues connect after a prior session for plugin runtime replacement', async () => {
|
|
const ops: string[] = [];
|
|
const storage = {
|
|
providerId: 'com.example.sync',
|
|
async connect() {
|
|
ops.push('connect');
|
|
return { account: { id: 'a1' } };
|
|
},
|
|
async disconnect() {},
|
|
async getAccount() {
|
|
return { id: 'a1' };
|
|
},
|
|
async getCapabilities() {
|
|
return { revisions: false, conditionalWrites: false, atomicReplacement: true };
|
|
},
|
|
async readObject(key: string) {
|
|
return { found: false as const, key, bytes: null };
|
|
},
|
|
async writeObject() {
|
|
return { created: true as const };
|
|
},
|
|
async deleteObject() {
|
|
return { deleted: false as const };
|
|
},
|
|
};
|
|
|
|
const adapter = encryptedObjectStorageAsCloudAdapter(storage, {
|
|
initiallyAuthenticated: true,
|
|
rebindSession: true,
|
|
});
|
|
await adapter.initializeSync();
|
|
await adapter.download();
|
|
await adapter.download();
|
|
assert.ok(ops.filter((op) => op === 'connect').length >= 3,
|
|
'rebindSession must call connect on each ensureConnected path');
|
|
});
|
|
|
|
it('re-issues connect after an I/O failure so replaced runtimes can recover', async () => {
|
|
const ops: string[] = [];
|
|
let failNextRead = true;
|
|
const storage = {
|
|
providerId: 'com.example.sync',
|
|
async connect() {
|
|
ops.push('connect');
|
|
return { account: { id: 'a1' } };
|
|
},
|
|
async disconnect() {},
|
|
async getAccount() {
|
|
return { id: 'a1' };
|
|
},
|
|
async getCapabilities() {
|
|
return { revisions: false, conditionalWrites: false, atomicReplacement: true };
|
|
},
|
|
async readObject(key: string) {
|
|
if (failNextRead) {
|
|
failNextRead = false;
|
|
throw new Error('runtime gone');
|
|
}
|
|
return { found: false as const, key, bytes: null };
|
|
},
|
|
async writeObject() {
|
|
return { created: true as const };
|
|
},
|
|
async deleteObject() {
|
|
return { deleted: false as const };
|
|
},
|
|
};
|
|
|
|
const adapter = encryptedObjectStorageAsCloudAdapter(storage, {
|
|
initiallyAuthenticated: true,
|
|
});
|
|
await adapter.initializeSync();
|
|
assert.equal(ops.filter((op) => op === 'connect').length, 1);
|
|
await assert.rejects(() => adapter.download(), /runtime gone/);
|
|
await adapter.download();
|
|
assert.equal(
|
|
ops.filter((op) => op === 'connect').length,
|
|
2,
|
|
'failed I/O must stale the session so the next ensureConnected rebinds',
|
|
);
|
|
});
|
|
|
|
it('lazy-connects before first I/O and passes revisions for conditional writes', async () => {
|
|
const ops: string[] = [];
|
|
let revision: string | undefined = 'rev-1';
|
|
let stored: Uint8Array | null = new TextEncoder().encode(JSON.stringify(makeSyncedFile(1, 'c')));
|
|
const storage = {
|
|
providerId: 'com.example.sync',
|
|
async connect() {
|
|
ops.push('connect');
|
|
return { account: { id: 'a1' } };
|
|
},
|
|
async disconnect() {
|
|
ops.push('disconnect');
|
|
},
|
|
async getAccount() {
|
|
return { id: 'a1' };
|
|
},
|
|
async getCapabilities() {
|
|
return { revisions: true, conditionalWrites: true, atomicReplacement: true };
|
|
},
|
|
async readObject(key: string) {
|
|
ops.push(`read:${key}:${revision ?? ''}`);
|
|
if (!stored) return { found: false as const, key, bytes: null };
|
|
return {
|
|
found: true as const,
|
|
key,
|
|
bytes: stored,
|
|
revision,
|
|
};
|
|
},
|
|
async writeObject(key: string, bytes: Uint8Array, options?: { expectedRevision?: string | null }) {
|
|
ops.push(`write:${key}:${options?.expectedRevision === null ? 'null' : (options?.expectedRevision ?? '')}`);
|
|
stored = bytes;
|
|
revision = revision ? 'rev-2' : 'rev-new';
|
|
return { created: !revision || revision === 'rev-new', revision };
|
|
},
|
|
async deleteObject() {
|
|
return { deleted: true };
|
|
},
|
|
};
|
|
|
|
const adapter = encryptedObjectStorageAsCloudAdapter(storage, {
|
|
initiallyAuthenticated: true,
|
|
});
|
|
// Restored session: authenticated for cache reuse, but not yet connected.
|
|
assert.equal(adapter.isAuthenticated, true);
|
|
const downloaded = await adapter.download();
|
|
assert.equal(downloaded?.payload, 'c');
|
|
assert.deepEqual(ops[0], 'connect');
|
|
assert.ok(ops.some((op) => op.startsWith('read:')));
|
|
|
|
await adapter.upload(makeSyncedFile(2, 'next'));
|
|
assert.ok(
|
|
ops.some((op) => op === 'write:netcatty-vault.json:rev-1'),
|
|
`expected conditional write with rev-1, got ${JSON.stringify(ops)}`,
|
|
);
|
|
|
|
// Confirmed absence → must-not-exist (expectedRevision null)
|
|
stored = null;
|
|
revision = undefined;
|
|
const adapter2 = encryptedObjectStorageAsCloudAdapter(storage, {
|
|
initiallyAuthenticated: true,
|
|
});
|
|
assert.equal(await adapter2.download(), null);
|
|
await adapter2.upload(makeSyncedFile(3, 'fresh'));
|
|
assert.ok(
|
|
ops.some((op) => op === 'write:netcatty-vault.json:null'),
|
|
`expected must-not-exist write, got ${JSON.stringify(ops)}`,
|
|
);
|
|
});
|
|
|
|
it('plugin sync object storage only forwards encrypted bytes to the host', async () => {
|
|
const calls: string[] = [];
|
|
const host = {
|
|
async connectSync(params: { providerId: string; configuration?: unknown }) {
|
|
calls.push(`connect:${params.providerId}`);
|
|
assert.deepEqual(params.configuration, { endpoint: 'https://example.test' });
|
|
return { account: { id: 'a1', name: 'Plugin' } };
|
|
},
|
|
async disconnectSync() {
|
|
calls.push('disconnect');
|
|
return null;
|
|
},
|
|
async getSyncAccount() {
|
|
return { account: { id: 'a1' } };
|
|
},
|
|
async getSyncCapabilities() {
|
|
return {
|
|
revisions: true,
|
|
conditionalWrites: true,
|
|
atomicReplacement: false,
|
|
maxObjectBytes: 64,
|
|
};
|
|
},
|
|
async readSyncObject(params: { key: string }) {
|
|
calls.push(`read:${params.key}`);
|
|
return {
|
|
found: true,
|
|
key: params.key,
|
|
bytes: new Uint8Array([1, 2, 3]),
|
|
revision: 'rev-1',
|
|
};
|
|
},
|
|
async writeSyncObject(params: { key: string; bytes: Uint8Array; expectedRevision?: string | null }) {
|
|
calls.push(`write:${params.key}:${params.bytes.byteLength}:${params.expectedRevision ?? ''}`);
|
|
// Ensure bytes look like opaque ciphertext, not a vault JSON root.
|
|
assert.equal(params.bytes[0], 0x9b);
|
|
return { created: true, revision: 'rev-2' };
|
|
},
|
|
async deleteSyncObject(params: { key: string }) {
|
|
calls.push(`delete:${params.key}`);
|
|
return { deleted: true };
|
|
},
|
|
};
|
|
|
|
const storage = createPluginSyncObjectStorage({
|
|
providerId: 'com.example.sync',
|
|
host,
|
|
configuration: { endpoint: 'https://example.test' },
|
|
});
|
|
|
|
await storage.connect();
|
|
const caps = await storage.getCapabilities();
|
|
assert.equal(caps.conditionalWrites, true);
|
|
const read = await storage.readObject('vault');
|
|
assert.deepEqual([...read.bytes!], [1, 2, 3]);
|
|
await storage.writeObject('vault', new Uint8Array([0x9b, 0x01]), { expectedRevision: null });
|
|
await storage.deleteObject('vault');
|
|
await storage.disconnect();
|
|
|
|
assert.deepEqual(calls, [
|
|
'connect:com.example.sync',
|
|
'read:vault',
|
|
'write:vault:2:',
|
|
'delete:vault',
|
|
'disconnect',
|
|
]);
|
|
});
|
|
|
|
it('skips host re-read when assumeVerifiedWrites is set (WebDAV path)', async () => {
|
|
const ops: string[] = [];
|
|
let stored: Uint8Array | null = null;
|
|
const storage = {
|
|
providerId: 'webdav',
|
|
async connect() {
|
|
ops.push('connect');
|
|
return { account: { id: 'w1' } };
|
|
},
|
|
async disconnect() {},
|
|
async getAccount() {
|
|
return { id: 'w1' };
|
|
},
|
|
async getCapabilities() {
|
|
return webdavEncryptedObjectCapabilities();
|
|
},
|
|
async readObject(key: string) {
|
|
ops.push(`read:${key}`);
|
|
if (!stored) return { found: false as const, key, bytes: null };
|
|
return { found: true as const, key, bytes: stored, revision: '1' };
|
|
},
|
|
async writeObject(key: string, bytes: Uint8Array) {
|
|
ops.push(`write:${key}`);
|
|
stored = bytes;
|
|
return { created: true as const, revision: '1' };
|
|
},
|
|
async deleteObject() {
|
|
return { deleted: true as const };
|
|
},
|
|
};
|
|
|
|
const adapter = encryptedObjectStorageAsCloudAdapter(storage, {
|
|
initiallyAuthenticated: true,
|
|
assumeVerifiedWrites: true,
|
|
});
|
|
await adapter.upload(makeSyncedFile(1, 'body'));
|
|
assert.deepEqual(
|
|
ops.filter((op) => op.startsWith('read:')),
|
|
[],
|
|
'assumeVerifiedWrites must not re-read after write',
|
|
);
|
|
assert.ok(ops.includes('write:netcatty-vault.json'));
|
|
});
|
|
});
|