253 lines
7.9 KiB
TypeScript
253 lines
7.9 KiB
TypeScript
import { describe, expect, it } from "@suaveplan/testing/runner";
|
|
import { VAULT_CODES } from "../src/errors/codes.js";
|
|
import { VaultError } from "../src/errors/vault-error.js";
|
|
import type {
|
|
DriveLike,
|
|
DriveSharingLike,
|
|
SyncDelegationResult,
|
|
SyncSnapshot,
|
|
VaultSyncLike,
|
|
} from "../src/types/adapters.js";
|
|
import type { VaultConfig } from "../src/types/types.js";
|
|
import { Vault } from "../src/vault/vault.js";
|
|
|
|
function memoryDrive(): DriveLike & { blobs: Map<string, Uint8Array> } {
|
|
const blobs = new Map<string, Uint8Array>();
|
|
return {
|
|
blobs,
|
|
async read(path) {
|
|
const v = blobs.get(path);
|
|
if (v === undefined) throw new Error(`missing ${path}`);
|
|
return v;
|
|
},
|
|
async write(path, data) {
|
|
blobs.set(path, new Uint8Array(data));
|
|
},
|
|
async delete(path) {
|
|
return blobs.delete(path);
|
|
},
|
|
async exists(path) {
|
|
return blobs.has(path);
|
|
},
|
|
};
|
|
}
|
|
|
|
function noopSharing(): DriveSharingLike {
|
|
return {
|
|
async createLink() {
|
|
return { id: "x", url: "https://x.example/x" };
|
|
},
|
|
async revokeLink() {},
|
|
async isActive() {
|
|
return true;
|
|
},
|
|
};
|
|
}
|
|
|
|
class CountingSync implements VaultSyncLike {
|
|
public lastSnapshot: SyncSnapshot = { items: [], snapshotAt: 0 };
|
|
public hydrateCount = 0;
|
|
public hydrate(s: SyncSnapshot): void {
|
|
this.hydrateCount += 1;
|
|
this.lastSnapshot = s;
|
|
}
|
|
public async pull(): Promise<SyncDelegationResult> {
|
|
return {
|
|
merged: this.lastSnapshot.items.map((i) => i.id),
|
|
conflicts: [],
|
|
deleted: [],
|
|
durationMs: 0,
|
|
};
|
|
}
|
|
public async push(): Promise<SyncDelegationResult> {
|
|
return {
|
|
merged: this.lastSnapshot.items.map((i) => i.id),
|
|
conflicts: [],
|
|
deleted: [],
|
|
durationMs: 0,
|
|
};
|
|
}
|
|
public async sync(): Promise<SyncDelegationResult> {
|
|
return {
|
|
merged: this.lastSnapshot.items.map((i) => i.id),
|
|
conflicts: [],
|
|
deleted: [],
|
|
durationMs: 0,
|
|
};
|
|
}
|
|
public currentSnapshot(): SyncSnapshot {
|
|
return this.lastSnapshot;
|
|
}
|
|
}
|
|
|
|
function makeConfig(
|
|
drive = memoryDrive(),
|
|
sync: VaultSyncLike = new CountingSync()
|
|
): VaultConfig {
|
|
return {
|
|
vaultId: "personal",
|
|
email: "user@example.com",
|
|
containerPath: "/personal.vault",
|
|
attachmentsPrefix: "/personal/att",
|
|
argon2id: {
|
|
memoryKB: 65536,
|
|
iterations: 3,
|
|
parallelism: 1,
|
|
outputLength: 32,
|
|
},
|
|
argon2idSalt: new Uint8Array(16),
|
|
drive,
|
|
driveSharing: noopSharing(),
|
|
sync,
|
|
};
|
|
}
|
|
|
|
describe("vault — integration", () => {
|
|
it("end-to-end: unlock, create item with concealed field, lock, re-unlock, get item", {
|
|
timeout: 30000,
|
|
}, async () => {
|
|
const drive = memoryDrive();
|
|
const config = makeConfig(drive);
|
|
const vault = Vault.open(config);
|
|
await vault.unlock("pass", "secret");
|
|
|
|
const created = vault.items.create({
|
|
title: "GitHub",
|
|
category: "login",
|
|
fields: { username: "alice", url: "https://github.com" },
|
|
concealedFields: { password: "p@ssw0rd" },
|
|
tags: ["work"],
|
|
favorite: true,
|
|
});
|
|
expect(created.concealedFields.password).toBe("p@ssw0rd");
|
|
|
|
await vault.flush();
|
|
await vault.lock();
|
|
expect(vault.isLocked).toBe(true);
|
|
|
|
const v2 = Vault.open(config);
|
|
await v2.unlock("pass", "secret");
|
|
const fetched = v2.items.get(created.id);
|
|
expect(fetched).not.toBeNull();
|
|
expect(fetched?.title).toBe("GitHub");
|
|
expect(fetched?.concealedFields.password).toBe("p@ssw0rd");
|
|
await v2.lock();
|
|
});
|
|
|
|
it("wrong password rejects with DECRYPT_FAILED, vault stays locked", {
|
|
timeout: 30000,
|
|
}, async () => {
|
|
const drive = memoryDrive();
|
|
const config = makeConfig(drive);
|
|
const v1 = Vault.open(config);
|
|
await v1.unlock("pass", "secret");
|
|
await v1.flush();
|
|
await v1.lock();
|
|
|
|
const v2 = Vault.open(config);
|
|
try {
|
|
await v2.unlock("wrong", "secret");
|
|
throw new Error("should have thrown");
|
|
} catch (err) {
|
|
expect(VaultError.is(err)).toBe(true);
|
|
expect((err as VaultError).code).toBe(VAULT_CODES.DECRYPT_FAILED);
|
|
}
|
|
expect(v2.isLocked).toBe(true);
|
|
});
|
|
|
|
it("tampered drive blob rejects with DECRYPT_FAILED", {
|
|
timeout: 30000,
|
|
}, async () => {
|
|
const drive = memoryDrive();
|
|
const config = makeConfig(drive);
|
|
const v1 = Vault.open(config);
|
|
await v1.unlock("pass", "secret");
|
|
await v1.flush();
|
|
await v1.lock();
|
|
|
|
const blob = drive.blobs.get(config.containerPath) as Uint8Array;
|
|
const tampered = new Uint8Array(blob);
|
|
tampered[tampered.length - 5] ^= 0x42;
|
|
drive.blobs.set(config.containerPath, tampered);
|
|
|
|
const v2 = Vault.open(config);
|
|
try {
|
|
await v2.unlock("pass", "secret");
|
|
throw new Error("should have thrown");
|
|
} catch (err) {
|
|
expect(VaultError.is(err)).toBe(true);
|
|
expect((err as VaultError).code).toBe(VAULT_CODES.DECRYPT_FAILED);
|
|
}
|
|
});
|
|
|
|
it("attachments survive lock + re-unlock through drive", {
|
|
timeout: 30000,
|
|
}, async () => {
|
|
const drive = memoryDrive();
|
|
const config = makeConfig(drive);
|
|
const vault = Vault.open(config);
|
|
await vault.unlock("pass", "secret");
|
|
const data = new TextEncoder().encode("hello attachment");
|
|
const meta = await vault.attachments.upload({
|
|
itemId: "i",
|
|
filename: "f.txt",
|
|
mimeType: "text/plain",
|
|
data,
|
|
});
|
|
await vault.flush();
|
|
await vault.lock();
|
|
|
|
const v2 = Vault.open(config);
|
|
await v2.unlock("pass", "secret");
|
|
// Attachment metadata is per-runtime; re-injecting is the
|
|
// caller's job. We still verify the drive blob is decrypted
|
|
// correctly when metadata is known.
|
|
(
|
|
v2.attachments as unknown as {
|
|
metadata: Map<string, typeof meta>;
|
|
}
|
|
).metadata.set(meta.id, meta);
|
|
const recovered = await v2.attachments.download(meta.id);
|
|
expect(new TextDecoder().decode(recovered)).toBe("hello attachment");
|
|
await v2.lock();
|
|
});
|
|
|
|
it("sync delegation works end-to-end through the orchestrator", {
|
|
timeout: 30000,
|
|
}, async () => {
|
|
const sync = new CountingSync();
|
|
const drive = memoryDrive();
|
|
const vault = Vault.open(makeConfig(drive, sync));
|
|
await vault.unlock("pass", "secret");
|
|
vault.items.create({
|
|
title: "x",
|
|
category: "login",
|
|
fields: {},
|
|
concealedFields: {},
|
|
tags: [],
|
|
favorite: false,
|
|
});
|
|
const result = await vault.sync.sync();
|
|
expect(result.merged.length).toBe(1);
|
|
expect(sync.hydrateCount).toBeGreaterThan(0);
|
|
await vault.lock();
|
|
});
|
|
|
|
it("shares delegation works end-to-end", { timeout: 30000 }, async () => {
|
|
const drive = memoryDrive();
|
|
const vault = Vault.open(makeConfig(drive));
|
|
await vault.unlock("pass", "secret");
|
|
const link = await vault.shares.createLink({
|
|
itemId: "i",
|
|
itemPlaintext: new TextEncoder().encode("payload"),
|
|
expiresAt: null,
|
|
maxViews: null,
|
|
});
|
|
expect(link.url).toContain("https://");
|
|
expect(await vault.shares.isActive(link.id)).toBe(true);
|
|
await vault.shares.revokeLink(link.id);
|
|
expect(await vault.shares.isActive(link.id)).toBe(false);
|
|
await vault.lock();
|
|
});
|
|
});
|