test(cli): add crypto roundtrip and invite parse tests
Cover encryptDirect/decryptDirect with three scenarios (happy path, wrong recipient, tampered ciphertext) and invite link parsing with round-trip, expiry rejection, and malformed input handling. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
42
apps/cli/src/__tests__/crypto-roundtrip.test.ts
Normal file
42
apps/cli/src/__tests__/crypto-roundtrip.test.ts
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { encryptDirect, decryptDirect } from "../crypto/envelope";
|
||||||
|
import { generateKeypair } from "../crypto/keypair";
|
||||||
|
|
||||||
|
describe("crypto roundtrip", () => {
|
||||||
|
it("Alice encrypts for Bob, Bob decrypts successfully", async () => {
|
||||||
|
const alice = await generateKeypair();
|
||||||
|
const bob = await generateKeypair();
|
||||||
|
|
||||||
|
const plaintext = "hello world";
|
||||||
|
const envelope = await encryptDirect(plaintext, bob.publicKey, alice.secretKey);
|
||||||
|
|
||||||
|
const decrypted = await decryptDirect(envelope, alice.publicKey, bob.secretKey);
|
||||||
|
expect(decrypted).toBe(plaintext);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Carol cannot decrypt a message encrypted for Bob", async () => {
|
||||||
|
const alice = await generateKeypair();
|
||||||
|
const bob = await generateKeypair();
|
||||||
|
const carol = await generateKeypair();
|
||||||
|
|
||||||
|
const envelope = await encryptDirect("hello world", bob.publicKey, alice.secretKey);
|
||||||
|
|
||||||
|
const decrypted = await decryptDirect(envelope, alice.publicKey, carol.secretKey);
|
||||||
|
expect(decrypted).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("tampered ciphertext returns null on decrypt", async () => {
|
||||||
|
const alice = await generateKeypair();
|
||||||
|
const bob = await generateKeypair();
|
||||||
|
|
||||||
|
const envelope = await encryptDirect("hello world", bob.publicKey, alice.secretKey);
|
||||||
|
|
||||||
|
// Flip a byte in the ciphertext
|
||||||
|
const raw = Buffer.from(envelope.ciphertext, "base64");
|
||||||
|
raw[0] = raw[0]! ^ 0xff;
|
||||||
|
const tampered = { nonce: envelope.nonce, ciphertext: raw.toString("base64") };
|
||||||
|
|
||||||
|
const decrypted = await decryptDirect(tampered, alice.publicKey, bob.secretKey);
|
||||||
|
expect(decrypted).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
67
apps/cli/src/__tests__/invite-parse.test.ts
Normal file
67
apps/cli/src/__tests__/invite-parse.test.ts
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import {
|
||||||
|
parseInviteLink,
|
||||||
|
buildSignedInvite,
|
||||||
|
extractInviteToken,
|
||||||
|
} from "../invite/parse";
|
||||||
|
import { generateKeypair } from "../crypto/keypair";
|
||||||
|
|
||||||
|
describe("invite parse", () => {
|
||||||
|
it("round-trips a signed invite through encode and parse", async () => {
|
||||||
|
const owner = await generateKeypair();
|
||||||
|
const expiresAt = Math.floor(Date.now() / 1000) + 3600; // 1 hour from now
|
||||||
|
|
||||||
|
const { link, payload } = await buildSignedInvite({
|
||||||
|
v: 1,
|
||||||
|
mesh_id: "mesh-abc-123",
|
||||||
|
mesh_slug: "test-mesh",
|
||||||
|
broker_url: "wss://broker.example.com",
|
||||||
|
expires_at: expiresAt,
|
||||||
|
mesh_root_key: "deadbeefcafebabe",
|
||||||
|
role: "member",
|
||||||
|
owner_pubkey: owner.publicKey,
|
||||||
|
owner_secret_key: owner.secretKey,
|
||||||
|
});
|
||||||
|
|
||||||
|
const parsed = await parseInviteLink(link);
|
||||||
|
expect(parsed.payload.mesh_id).toBe("mesh-abc-123");
|
||||||
|
expect(parsed.payload.mesh_slug).toBe("test-mesh");
|
||||||
|
expect(parsed.payload.broker_url).toBe("wss://broker.example.com");
|
||||||
|
expect(parsed.payload.expires_at).toBe(expiresAt);
|
||||||
|
expect(parsed.payload.role).toBe("member");
|
||||||
|
expect(parsed.payload.owner_pubkey).toBe(owner.publicKey);
|
||||||
|
expect(parsed.payload.signature).toBe(payload.signature);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects an expired invite", async () => {
|
||||||
|
const owner = await generateKeypair();
|
||||||
|
const expiredAt = Math.floor(Date.now() / 1000) - 60; // 1 minute ago
|
||||||
|
|
||||||
|
const { link } = await buildSignedInvite({
|
||||||
|
v: 1,
|
||||||
|
mesh_id: "mesh-expired",
|
||||||
|
mesh_slug: "expired-mesh",
|
||||||
|
broker_url: "wss://broker.example.com",
|
||||||
|
expires_at: expiredAt,
|
||||||
|
mesh_root_key: "deadbeef",
|
||||||
|
role: "member",
|
||||||
|
owner_pubkey: owner.publicKey,
|
||||||
|
owner_secret_key: owner.secretKey,
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(parseInviteLink(link)).rejects.toThrow("invite expired");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects malformed base64 in invite URL", async () => {
|
||||||
|
// Empty payload after ic://join/ should throw.
|
||||||
|
expect(() => extractInviteToken("ic://join/")).toThrow("invite link has no payload");
|
||||||
|
|
||||||
|
// Short garbage that doesn't match any format should throw.
|
||||||
|
expect(() => extractInviteToken("!!!not-valid!!!")).toThrow("invalid invite format");
|
||||||
|
|
||||||
|
// A sufficiently long but garbage base64url token that decodes to
|
||||||
|
// invalid JSON should throw at the JSON parse stage.
|
||||||
|
const garbage = "A".repeat(30); // valid base64url chars, decodes to binary
|
||||||
|
await expect(parseInviteLink(`ic://join/${garbage}`)).rejects.toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
7
apps/cli/vitest.config.ts
Normal file
7
apps/cli/vitest.config.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import { defineConfig } from "vitest/config";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
test: {
|
||||||
|
include: ["src/__tests__/**/*.test.ts"],
|
||||||
|
},
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user