@nostr-dev-kit/ndk
Version:
NDK - Nostr Development Kit. Includes AI Guardrails to catch common mistakes during development.
140 lines (113 loc) • 5.66 kB
text/typescript
import { beforeEach, describe, expect, it } from "vitest";
import type { NDKCacheAdapter } from "../cache";
import { NDK } from "../ndk";
import { NDKPrivateKeySigner } from "../signers/private-key";
import { giftUnwrap, giftWrap } from "./gift-wrapping.js";
import { NDKEvent } from "./index.js";
import { NDKKind } from "./kinds";
// Mock cache adapter to track cache operations
class MockCacheAdapter implements Partial<NDKCacheAdapter> {
public locking = false;
private cache = new Map<NDKEvent["id"], NDKEvent>();
public getDecryptedEventCalls: string[] = [];
public addDecryptedEventCalls: Array<{ wrapperId: string; rumorId: string }> = [];
async getDecryptedEvent(wrapperId: NDKEvent["id"]): Promise<NDKEvent | null> {
this.getDecryptedEventCalls.push(wrapperId);
return this.cache.get(wrapperId) ?? null;
}
async addDecryptedEvent(wrapperId: NDKEvent["id"], decryptedEvent: NDKEvent): Promise<void> {
this.addDecryptedEventCalls.push({ wrapperId, rumorId: decryptedEvent.id });
this.cache.set(wrapperId, decryptedEvent);
}
reset() {
this.getDecryptedEventCalls = [];
this.addDecryptedEventCalls = [];
}
clearCache() {
this.cache.clear();
this.getDecryptedEventCalls = [];
this.addDecryptedEventCalls = [];
}
}
describe("NIP-17 Gift Wrapping Cache", () => {
let ndk: NDK;
let aliceSigner: NDKPrivateKeySigner;
let bobSigner: NDKPrivateKeySigner;
let mockCache: MockCacheAdapter;
beforeEach(async () => {
mockCache = new MockCacheAdapter();
ndk = new NDK({ cacheAdapter: mockCache as any });
aliceSigner = NDKPrivateKeySigner.generate();
bobSigner = NDKPrivateKeySigner.generate();
});
it("should cache decrypted gift-wrapped events using wrapper ID as key", async () => {
const bob = await bobSigner.user();
// Alice creates a rumor
const rumor = new NDKEvent(ndk);
rumor.kind = NDKKind.PrivateDirectMessage;
rumor.content = "Secret message";
rumor.created_at = Math.floor(Date.now() / 1000);
rumor.tags = [["p", bob.pubkey]];
rumor.pubkey = (await aliceSigner.user()).pubkey;
// Alice gift-wraps the rumor for Bob
const wrapped = await giftWrap(rumor, bob, aliceSigner);
wrapped.ndk = ndk;
// First unwrap - should decrypt and cache
mockCache.clearCache();
const unwrapped1 = await giftUnwrap(wrapped, undefined, bobSigner);
expect(unwrapped1.content).toBe("Secret message");
expect(mockCache.getDecryptedEventCalls).toHaveLength(1);
expect(mockCache.getDecryptedEventCalls[0]).toBe(wrapped.id);
expect(mockCache.addDecryptedEventCalls).toHaveLength(1);
expect(mockCache.addDecryptedEventCalls[0].wrapperId).toBe(wrapped.id);
expect(mockCache.addDecryptedEventCalls[0].rumorId).toBe(unwrapped1.id);
// Second unwrap - should hit cache, no decryption
mockCache.reset();
const unwrapped2 = await giftUnwrap(wrapped, undefined, bobSigner);
expect(unwrapped2.content).toBe("Secret message");
expect(unwrapped2.id).toBe(unwrapped1.id);
expect(mockCache.getDecryptedEventCalls).toHaveLength(1);
expect(mockCache.getDecryptedEventCalls[0]).toBe(wrapped.id);
expect(mockCache.addDecryptedEventCalls).toHaveLength(0); // No cache write on hit
// Third unwrap - should still hit cache
mockCache.reset();
const unwrapped3 = await giftUnwrap(wrapped, undefined, bobSigner);
expect(unwrapped3.content).toBe("Secret message");
expect(unwrapped3.id).toBe(unwrapped1.id);
expect(mockCache.getDecryptedEventCalls).toHaveLength(1);
expect(mockCache.addDecryptedEventCalls).toHaveLength(0);
});
it("should use wrapper ID not rumor ID for cache key", async () => {
const bob = await bobSigner.user();
const rumor = new NDKEvent(ndk);
rumor.kind = NDKKind.PrivateDirectMessage;
rumor.content = "Test message";
rumor.created_at = Math.floor(Date.now() / 1000);
rumor.tags = [["p", bob.pubkey]];
rumor.pubkey = (await aliceSigner.user()).pubkey;
const wrapped = await giftWrap(rumor, bob, aliceSigner);
wrapped.ndk = ndk;
const unwrapped = await giftUnwrap(wrapped, undefined, bobSigner);
// Verify that the cache key is the wrapper ID, not the rumor ID
expect(mockCache.addDecryptedEventCalls).toHaveLength(1);
expect(mockCache.addDecryptedEventCalls[0].wrapperId).toBe(wrapped.id);
expect(mockCache.addDecryptedEventCalls[0].rumorId).toBe(unwrapped.id);
expect(mockCache.addDecryptedEventCalls[0].wrapperId).not.toBe(mockCache.addDecryptedEventCalls[0].rumorId);
});
it("should work without cache adapter", async () => {
// Create NDK without cache adapter
const ndkNoCache = new NDK({});
const bob = await bobSigner.user();
const rumor = new NDKEvent(ndkNoCache);
rumor.kind = NDKKind.PrivateDirectMessage;
rumor.content = "Message without cache";
rumor.created_at = Math.floor(Date.now() / 1000);
rumor.tags = [["p", bob.pubkey]];
rumor.pubkey = (await aliceSigner.user()).pubkey;
const wrapped = await giftWrap(rumor, bob, aliceSigner);
wrapped.ndk = ndkNoCache;
// Should not throw, just skip caching
const unwrapped = await giftUnwrap(wrapped, undefined, bobSigner);
expect(unwrapped.content).toBe("Message without cache");
});
});