From 66b9696b2df0bf69ae1fc4210e84fa7863b3066b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alejandro=20Guti=C3=A9rrez?= <35082514+alezmad@users.noreply.github.com> Date: Mon, 6 Apr 2026 00:18:27 +0100 Subject: [PATCH] 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) --- .../src/__tests__/crypto-roundtrip.test.ts | 42 ++++++++++++ apps/cli/src/__tests__/invite-parse.test.ts | 67 +++++++++++++++++++ apps/cli/vitest.config.ts | 7 ++ 3 files changed, 116 insertions(+) create mode 100644 apps/cli/src/__tests__/crypto-roundtrip.test.ts create mode 100644 apps/cli/src/__tests__/invite-parse.test.ts create mode 100644 apps/cli/vitest.config.ts diff --git a/apps/cli/src/__tests__/crypto-roundtrip.test.ts b/apps/cli/src/__tests__/crypto-roundtrip.test.ts new file mode 100644 index 0000000..8e0d196 --- /dev/null +++ b/apps/cli/src/__tests__/crypto-roundtrip.test.ts @@ -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(); + }); +}); diff --git a/apps/cli/src/__tests__/invite-parse.test.ts b/apps/cli/src/__tests__/invite-parse.test.ts new file mode 100644 index 0000000..8bcf127 --- /dev/null +++ b/apps/cli/src/__tests__/invite-parse.test.ts @@ -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(); + }); +}); diff --git a/apps/cli/vitest.config.ts b/apps/cli/vitest.config.ts new file mode 100644 index 0000000..8696084 --- /dev/null +++ b/apps/cli/vitest.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + include: ["src/__tests__/**/*.test.ts"], + }, +});