diff --git a/deno.lock b/deno.lock index 9c3df80..7356890 100644 --- a/deno.lock +++ b/deno.lock @@ -215,10 +215,12 @@ "packages/consumer": { "dependencies": [ "jsr:@puregarlic/randimal@^1.0.1", + "jsr:@std/expect@^1.0.17", "npm:@atcute/atproto@^3.1.9", "npm:@atcute/client@^4.0.5", "npm:@atcute/jetstream@^1.1.2", - "npm:@atcute/lexicons@^1.2.2" + "npm:@atcute/lexicons@^1.2.2", + "npm:@atcute/tid@^1.0.3" ] }, "packages/crypto": { diff --git a/packages/consumer/deno.jsonc b/packages/consumer/deno.jsonc index a9b458f..a5fe047 100644 --- a/packages/consumer/deno.jsonc +++ b/packages/consumer/deno.jsonc @@ -8,6 +8,8 @@ "@atcute/client": "npm:@atcute/client@^4.0.5", "@atcute/jetstream": "npm:@atcute/jetstream@^1.1.2", "@atcute/lexicons": "npm:@atcute/lexicons@^1.2.2", - "@puregarlic/randimal": "jsr:@puregarlic/randimal@^1.0.1" + "@atcute/tid": "npm:@atcute/tid@^1.0.3", + "@puregarlic/randimal": "jsr:@puregarlic/randimal@^1.0.1", + "@std/expect": "jsr:@std/expect@^1.0.17" } } diff --git a/packages/consumer/mod.test.ts b/packages/consumer/mod.test.ts new file mode 100644 index 0000000..42e929a --- /dev/null +++ b/packages/consumer/mod.test.ts @@ -0,0 +1,483 @@ +import { expect } from "@std/expect"; +import { Consumer } from "./mod.ts"; +import { encryptText, generateKeys } from "@cistern/crypto"; +import type { ConsumerParams } from "./types.ts"; +import type { Client, CredentialManager } from "@atcute/client"; +import type { Did, Handle, ResourceUri } from "@atcute/lexicons"; +import { now } from "@atcute/tid"; + +// Helper to create a mock Consumer instance +function createMockConsumer( + overrides?: Partial, +): Consumer { + const mockParams: ConsumerParams = { + miniDoc: { + did: "did:plc:test123" as Did, + handle: "test.bsky.social" as Handle, + pds: "https://test.pds.example", + signing_key: "test-key", + }, + manager: {} as CredentialManager, + rpc: createMockRpcClient(), + options: { + handle: "test.bsky.social" as Handle, + appPassword: "test-password", + }, + ...overrides, + }; + + return new Consumer(mockParams); +} + +// Helper to create a mock RPC client +function createMockRpcClient(): Client { + return { + get: () => { + throw new Error("Mock RPC get not implemented"); + }, + post: () => { + throw new Error("Mock RPC post not implemented"); + }, + } as unknown as Client; +} + +Deno.test({ + name: "Consumer constructor initializes with provided params", + fn() { + const consumer = createMockConsumer(); + + expect(consumer.did).toEqual("did:plc:test123"); + expect(consumer.keypair).toBeUndefined(); + expect(consumer.rpc).toBeDefined(); + expect(consumer.manager).toBeDefined(); + }, +}); + +Deno.test({ + name: "Consumer constructor initializes with existing keypair", + fn() { + const mockKeypair = { + privateKey: new Uint8Array(32).toBase64(), + publicKey: + "at://did:plc:test/app.cistern.lexicon.pubkey/abc123" as ResourceUri, + }; + + const consumer = createMockConsumer({ + options: { + handle: "test.bsky.social" as Handle, + appPassword: "test-password", + keypair: mockKeypair, + }, + }); + + expect(consumer.keypair).toBeDefined(); + expect(consumer.keypair?.publicKey).toEqual(mockKeypair.publicKey); + expect(consumer.keypair?.privateKey).toBeInstanceOf(Uint8Array); + }, +}); + +Deno.test({ + name: "generateKeyPair creates and uploads a new keypair", + async fn() { + let capturedRecord: unknown; + let capturedCollection: string | undefined; + + const mockRpc = { + post: (endpoint: string, params: { input: unknown }) => { + if (endpoint === "com.atproto.repo.createRecord") { + const input = params.input as { + collection: string; + record: unknown; + }; + capturedCollection = input.collection; + capturedRecord = input.record; + + return Promise.resolve({ + ok: true, + data: { + uri: "at://did:plc:test/app.cistern.lexicon.pubkey/generated123", + }, + }); + } + return Promise.resolve({ ok: false, status: 500, data: {} }); + }, + } as unknown as Client; + + const consumer = createMockConsumer({ rpc: mockRpc }); + const keypair = await consumer.generateKeyPair(); + + expect(keypair).toBeDefined(); + expect(keypair.privateKey).toBeInstanceOf(Uint8Array); + expect(keypair.publicKey).toEqual( + "at://did:plc:test/app.cistern.lexicon.pubkey/generated123", + ); + expect(consumer.keypair).toEqual(keypair); + + expect(capturedCollection).toEqual("app.cistern.lexicon.pubkey"); + expect(capturedRecord).toMatchObject({ + $type: "app.cistern.lexicon.pubkey", + algorithm: "x_wing", + }); + }, +}); + +Deno.test({ + name: "generateKeyPair throws when consumer already has a keypair", + async fn() { + const consumer = createMockConsumer({ + options: { + handle: "test.bsky.social" as Handle, + appPassword: "test-password", + keypair: { + privateKey: new Uint8Array(32).toBase64(), + publicKey: + "at://did:plc:test/app.cistern.lexicon.pubkey/existing" as ResourceUri, + }, + }, + }); + + await expect(consumer.generateKeyPair()).rejects.toThrow( + "client already has a key pair", + ); + }, +}); + +Deno.test({ + name: "generateKeyPair throws when upload fails", + async fn() { + const mockRpc = { + post: () => + Promise.resolve({ + ok: false, + status: 500, + data: { error: "Internal Server Error" }, + }), + } as unknown as Client; + + const consumer = createMockConsumer({ rpc: mockRpc }); + + await expect(consumer.generateKeyPair()).rejects.toThrow( + "failed to save public key", + ); + }, +}); + +Deno.test({ + name: "listItems throws when no keypair is set", + async fn() { + const consumer = createMockConsumer(); + + const iterator = consumer.listItems(); + await expect(iterator.next()).rejects.toThrow( + "no key pair set; generate a key before listing items", + ); + }, +}); + +Deno.test({ + name: "listItems decrypts and yields items", + async fn() { + const keys = generateKeys(); + const testText = "Test item content"; + const encrypted = encryptText(keys.publicKey, testText); + const testTid = now(); + + const mockRpc = { + get: (endpoint: string) => { + if (endpoint === "com.atproto.repo.listRecords") { + return Promise.resolve({ + ok: true, + data: { + records: [ + { + uri: "at://did:plc:test/app.cistern.lexicon.item/item1", + value: { + $type: "app.cistern.lexicon.item", + tid: testTid, + ciphertext: encrypted.cipherText, + nonce: encrypted.nonce, + algorithm: "x_wing-xchacha20_poly1305-sha3_512", + pubkey: "at://did:plc:test/app.cistern.lexicon.pubkey/key1", + payload: encrypted.content, + contentLength: encrypted.length, + contentHash: encrypted.hash, + }, + }, + ], + cursor: undefined, + }, + }); + } + return Promise.resolve({ ok: false, status: 500, data: {} }); + }, + } as unknown as Client; + + const consumer = createMockConsumer({ + rpc: mockRpc, + options: { + handle: "test.bsky.social" as Handle, + appPassword: "test-password", + keypair: { + privateKey: keys.secretKey.toBase64(), + publicKey: + "at://did:plc:test/app.cistern.lexicon.pubkey/key1" as ResourceUri, + }, + }, + }); + + const items = []; + for await (const item of consumer.listItems()) { + items.push(item); + } + + expect(items).toHaveLength(1); + expect(items[0].text).toEqual(testText); + expect(items[0].tid).toEqual(testTid); + }, +}); + +Deno.test({ + name: "listItems skips items with mismatched public key", + async fn() { + const keys = generateKeys(); + const testText = "Test item content"; + const encrypted = encryptText(keys.publicKey, testText); + const testTid = now(); + + const mockRpc = { + get: (endpoint: string) => { + if (endpoint === "com.atproto.repo.listRecords") { + return Promise.resolve({ + ok: true, + data: { + records: [ + { + uri: "at://did:plc:test/app.cistern.lexicon.item/item1", + value: { + $type: "app.cistern.lexicon.item", + tid: testTid, + ciphertext: encrypted.cipherText, + nonce: encrypted.nonce, + algorithm: "x_wing-xchacha20_poly1305-sha3_512", + pubkey: + "at://did:plc:test/app.cistern.lexicon.pubkey/different-key", + payload: encrypted.content, + contentLength: encrypted.length, + contentHash: encrypted.hash, + }, + }, + ], + cursor: undefined, + }, + }); + } + return Promise.resolve({ ok: false, status: 500, data: {} }); + }, + } as unknown as Client; + + const consumer = createMockConsumer({ + rpc: mockRpc, + options: { + handle: "test.bsky.social" as Handle, + appPassword: "test-password", + keypair: { + privateKey: keys.secretKey.toBase64(), + publicKey: + "at://did:plc:test/app.cistern.lexicon.pubkey/my-key" as ResourceUri, + }, + }, + }); + + const items = []; + for await (const item of consumer.listItems()) { + items.push(item); + } + + expect(items).toHaveLength(0); + }, +}); + +Deno.test({ + name: "listItems handles pagination", + async fn() { + const keys = generateKeys(); + const text1 = "First item"; + const text2 = "Second item"; + const encrypted1 = encryptText(keys.publicKey, text1); + const encrypted2 = encryptText(keys.publicKey, text2); + const tid1 = now(); + const tid2 = now(); + + let callCount = 0; + const mockRpc = { + get: (endpoint: string, _params?: { params?: { cursor?: string } }) => { + if (endpoint === "com.atproto.repo.listRecords") { + callCount++; + + if (callCount === 1) { + return Promise.resolve({ + ok: true, + data: { + records: [ + { + uri: "at://did:plc:test/app.cistern.lexicon.item/item1", + value: { + $type: "app.cistern.lexicon.item", + tid: tid1, + ciphertext: encrypted1.cipherText, + nonce: encrypted1.nonce, + algorithm: "x_wing-xchacha20_poly1305-sha3_512", + pubkey: + "at://did:plc:test/app.cistern.lexicon.pubkey/key1", + payload: encrypted1.content, + contentLength: encrypted1.length, + contentHash: encrypted1.hash, + }, + }, + ], + cursor: "next-page", + }, + }); + } else { + return Promise.resolve({ + ok: true, + data: { + records: [ + { + uri: "at://did:plc:test/app.cistern.lexicon.item/item2", + value: { + $type: "app.cistern.lexicon.item", + tid: tid2, + ciphertext: encrypted2.cipherText, + nonce: encrypted2.nonce, + algorithm: "x_wing-xchacha20_poly1305-sha3_512", + pubkey: + "at://did:plc:test/app.cistern.lexicon.pubkey/key1", + payload: encrypted2.content, + contentLength: encrypted2.length, + contentHash: encrypted2.hash, + }, + }, + ], + cursor: undefined, + }, + }); + } + } + return Promise.resolve({ ok: false, status: 500, data: {} }); + }, + } as unknown as Client; + + const consumer = createMockConsumer({ + rpc: mockRpc, + options: { + handle: "test.bsky.social" as Handle, + appPassword: "test-password", + keypair: { + privateKey: keys.secretKey.toBase64(), + publicKey: + "at://did:plc:test/app.cistern.lexicon.pubkey/key1" as ResourceUri, + }, + }, + }); + + const items = []; + for await (const item of consumer.listItems()) { + items.push(item); + } + + expect(items).toHaveLength(2); + expect(items[0].text).toEqual(text1); + expect(items[1].text).toEqual(text2); + expect(callCount).toEqual(2); + }, +}); + +Deno.test({ + name: "listItems throws when list request fails", + async fn() { + const mockRpc = { + get: () => + Promise.resolve({ + ok: false, + status: 401, + data: { error: "Unauthorized" }, + }), + } as unknown as Client; + + const consumer = createMockConsumer({ + rpc: mockRpc, + options: { + handle: "test.bsky.social" as Handle, + appPassword: "test-password", + keypair: { + privateKey: new Uint8Array(32).toBase64(), + publicKey: "at://did:plc:test/app.cistern.lexicon.pubkey/key1", + }, + }, + }); + + const iterator = consumer.listItems(); + await expect(iterator.next()).rejects.toThrow("failed to list items"); + }, +}); + +Deno.test({ + name: "subscribeToItems throws when no keypair is set", + async fn() { + const consumer = createMockConsumer(); + + const iterator = consumer.subscribeToItems(); + await expect(iterator.next()).rejects.toThrow( + "no key pair set; generate a key before subscribing", + ); + }, +}); + +Deno.test({ + name: "deleteItem successfully deletes an item", + async fn() { + let deletedRkey: string | undefined; + + const mockRpc = { + post: (endpoint: string, params: { input: unknown }) => { + if (endpoint === "com.atproto.repo.deleteRecord") { + const input = params.input as { rkey: string }; + deletedRkey = input.rkey; + + return Promise.resolve({ + ok: true, + data: {}, + }); + } + return Promise.resolve({ ok: false, status: 500, data: {} }); + }, + } as unknown as Client; + + const consumer = createMockConsumer({ rpc: mockRpc }); + + await consumer.deleteItem("item123"); + + expect(deletedRkey).toEqual("item123"); + }, +}); + +Deno.test({ + name: "deleteItem throws when delete request fails", + async fn() { + const mockRpc = { + post: () => + Promise.resolve({ + ok: false, + status: 404, + data: { error: "Not Found" }, + }), + } as unknown as Client; + + const consumer = createMockConsumer({ rpc: mockRpc }); + + await expect(consumer.deleteItem("item123")).rejects.toThrow( + "failed to delete item item123", + ); + }, +});