From af8c5c01599e1deea6f9d35e719745b034e18725 Mon Sep 17 00:00:00 2001 From: Uche Okafor Date: Wed, 21 Oct 2026 18:50:32 +0100 Subject: [PATCH 1/4] test(stellar): add unit tests for sequence cache (#588) Covers getNextSequence cache hit/miss (both false and null miss representations), the NX-seed race with another process, concurrent calls for different accounts, invalidate(), and resetTo(). --- tests/unit/stellar/sequence-cache.test.ts | 136 ++++++++++++++++++++++ 1 file changed, 136 insertions(+) create mode 100644 tests/unit/stellar/sequence-cache.test.ts diff --git a/tests/unit/stellar/sequence-cache.test.ts b/tests/unit/stellar/sequence-cache.test.ts new file mode 100644 index 0000000..ed463fc --- /dev/null +++ b/tests/unit/stellar/sequence-cache.test.ts @@ -0,0 +1,136 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +const mockRedis = vi.hoisted(() => ({ + eval: vi.fn(), + del: vi.fn(), + set: vi.fn(), +})); + +vi.mock("../../../src/config/redis.js", () => ({ redis: mockRedis })); + +const mockStellarClient = vi.hoisted(() => ({ + getAccount: vi.fn(), +})); + +vi.mock("../../../src/stellar/client.js", () => ({ + stellarClient: mockStellarClient, +})); + +vi.mock("../../../src/audit/index.js", () => ({ + auditLog: vi.fn().mockResolvedValue(undefined), +})); + +import { sequenceCache } from "../../../src/stellar/sequence-cache.js"; +import { auditLog } from "../../../src/audit/index.js"; + +describe("SequenceCache (#588)", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe("getNextSequence", () => { + it("returns the incremented sequence on a cache hit", async () => { + mockRedis.eval.mockResolvedValueOnce(43); + + const result = await sequenceCache.getNextSequence("GABC123"); + + expect(result).toBe("43"); + expect(mockStellarClient.getAccount).not.toHaveBeenCalled(); + }); + + it("falls back to Horizon and seeds the cache on a cache miss (key absent)", async () => { + mockRedis.eval.mockResolvedValueOnce(false); + mockStellarClient.getAccount.mockResolvedValue({ sequence: "100" }); + mockRedis.eval.mockResolvedValueOnce("100"); + + const result = await sequenceCache.getNextSequence("GABC123"); + + expect(mockStellarClient.getAccount).toHaveBeenCalledWith("GABC123"); + expect(result).toBe("100"); + // Second eval call seeds with the account's *current* sequence, not +1. + expect(mockRedis.eval).toHaveBeenNthCalledWith( + 2, + expect.any(String), + 1, + expect.stringContaining("GABC123"), + 300, + "100", + ); + }); + + it("falls back to Horizon on a cache miss represented by null", async () => { + mockRedis.eval.mockResolvedValueOnce(null); + mockStellarClient.getAccount.mockResolvedValue({ sequence: "7" }); + mockRedis.eval.mockResolvedValueOnce("7"); + + const result = await sequenceCache.getNextSequence("GXYZ"); + + expect(mockStellarClient.getAccount).toHaveBeenCalledWith("GXYZ"); + expect(result).toBe("7"); + }); + + it("handles another process seeding the cache first (NX collision)", async () => { + mockRedis.eval.mockResolvedValueOnce(false); + mockStellarClient.getAccount.mockResolvedValue({ sequence: "50" }); + // SEED_AND_INCREMENT_SCRIPT returns the incremented value when the + // SET NX loses the race to another process. + mockRedis.eval.mockResolvedValueOnce(51); + + const result = await sequenceCache.getNextSequence("GRACE"); + + expect(result).toBe("51"); + }); + + it("supports concurrent calls for different accounts independently", async () => { + mockRedis.eval.mockImplementation(async (_script, _n, key) => { + return key.includes("GONE") ? 11 : 22; + }); + + const [one, two] = await Promise.all([ + sequenceCache.getNextSequence("GONE"), + sequenceCache.getNextSequence("GTWO"), + ]); + + expect(one).toBe("11"); + expect(two).toBe("22"); + }); + }); + + describe("invalidate", () => { + it("deletes the cache key for the account", async () => { + await sequenceCache.invalidate("GABC123"); + + expect(mockRedis.del).toHaveBeenCalledWith(expect.stringContaining("GABC123")); + }); + + it("writes an audit log entry on invalidation", async () => { + await sequenceCache.invalidate("GABC123"); + + expect(auditLog).toHaveBeenCalledWith( + "cache.invalidated", + expect.objectContaining({ url: expect.stringContaining("GABC123") }), + ); + }); + + it("does not throw when the audit log call rejects", async () => { + (auditLog as unknown as ReturnType).mockReturnValueOnce( + Promise.reject(new Error("audit down")), + ); + + await expect(sequenceCache.invalidate("GABC123")).resolves.toBeUndefined(); + }); + }); + + describe("resetTo", () => { + it("sets the cache key to the given sequence with the configured TTL", async () => { + await sequenceCache.resetTo("GABC123", 999n); + + expect(mockRedis.set).toHaveBeenCalledWith( + expect.stringContaining("GABC123"), + "999", + "EX", + 300, + ); + }); + }); +}); From 9b3baab7da917eb237d526e4187c1277f5a1fff3 Mon Sep 17 00:00:00 2001 From: Uche Okafor Date: Wed, 21 Oct 2026 18:50:33 +0100 Subject: [PATCH 2/4] test(utils): add unit tests for account lock utility (#589) Covers lock acquisition/release on success and on a thrown error, serialization of concurrent calls for the same account, independent execution for different accounts, and the configured timeout. --- tests/unit/utils/account-lock.test.ts | 91 +++++++++++++++++++++++++++ 1 file changed, 91 insertions(+) create mode 100644 tests/unit/utils/account-lock.test.ts diff --git a/tests/unit/utils/account-lock.test.ts b/tests/unit/utils/account-lock.test.ts new file mode 100644 index 0000000..134f941 --- /dev/null +++ b/tests/unit/utils/account-lock.test.ts @@ -0,0 +1,91 @@ +import { describe, it, expect, vi } from "vitest"; +import { withAccountLock } from "../../../src/utils/account-lock.js"; + +describe("withAccountLock (#589)", () => { + it("acquires the lock and resolves with the wrapped function's result", async () => { + const result = await withAccountLock("GABC123", async () => "done"); + expect(result).toBe("done"); + }); + + it("releases the lock after the wrapped function resolves, allowing reuse", async () => { + await withAccountLock("GABC123", async () => "first"); + const second = await withAccountLock("GABC123", async () => "second"); + expect(second).toBe("second"); + }); + + it("releases the lock after the wrapped function throws, allowing reuse", async () => { + await expect( + withAccountLock("GABC123", async () => { + throw new Error("boom"); + }), + ).rejects.toThrow("boom"); + + // The lock must have been released even though fn() rejected. + const result = await withAccountLock("GABC123", async () => "recovered"); + expect(result).toBe("recovered"); + }); + + it("serializes concurrent calls for the same account", async () => { + const order: string[] = []; + + const slow = withAccountLock("GSAME", async () => { + order.push("slow-start"); + await new Promise((resolve) => setTimeout(resolve, 20)); + order.push("slow-end"); + }); + const fast = withAccountLock("GSAME", async () => { + order.push("fast-start"); + order.push("fast-end"); + }); + + await Promise.all([slow, fast]); + + // "fast" must not start until "slow" has fully released the lock. + expect(order).toEqual(["slow-start", "slow-end", "fast-start", "fast-end"]); + }); + + it("runs concurrent calls for different accounts without serializing them", async () => { + const order: string[] = []; + + const a = withAccountLock("GACC_A", async () => { + order.push("a-start"); + await new Promise((resolve) => setTimeout(resolve, 20)); + order.push("a-end"); + }); + const b = withAccountLock("GACC_B", async () => { + order.push("b-start"); + order.push("b-end"); + }); + + await Promise.all([a, b]); + + // Different accounts don't share a lock, so "b" finishes while "a" is + // still pending rather than waiting for it to release. + expect(order.indexOf("b-end")).toBeLessThan(order.indexOf("a-end")); + }); + + it("times out when the lock cannot be acquired within the configured window", async () => { + vi.useFakeTimers(); + try { + let releaseHeld: () => void = () => {}; + const held = new Promise((resolve) => { + releaseHeld = resolve; + }); + + const holder = withAccountLock("GTIMEOUT", async () => held); + const waiter = withAccountLock("GTIMEOUT", async () => "should not run"); + // Prevent Node from reporting this as an unhandled rejection before + // the `expect(...).rejects` assertion below attaches its own handler. + waiter.catch(() => {}); + + await vi.advanceTimersByTimeAsync(10_001); + + await expect(waiter).rejects.toThrow(); + + releaseHeld(); + await holder; + } finally { + vi.useRealTimers(); + } + }); +}); From 154dd3fbda5e391ba95e124d88c27f785798b5d0 Mon Sep 17 00:00:00 2001 From: Uche Okafor Date: Wed, 21 Oct 2026 18:50:33 +0100 Subject: [PATCH 3/4] test(stellar): add unit tests for progress tracker (#590) Covers getOnChainContentHash hash retrieval, missing contract config, simulation errors/missing retval/non-string decode, and RPC failures. --- tests/unit/stellar/progress-tracker.test.ts | 114 ++++++++++++++++++++ 1 file changed, 114 insertions(+) create mode 100644 tests/unit/stellar/progress-tracker.test.ts diff --git a/tests/unit/stellar/progress-tracker.test.ts b/tests/unit/stellar/progress-tracker.test.ts new file mode 100644 index 0000000..83d72e1 --- /dev/null +++ b/tests/unit/stellar/progress-tracker.test.ts @@ -0,0 +1,114 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +const mockConfig = vi.hoisted(() => ({ + config: { STELLAR_PROGRESS_TRACKER_CONTRACT_ID: "" as string | undefined }, +})); + +vi.mock("../../../src/config/index.js", () => ({ config: mockConfig.config })); + +const mockSoroban = vi.hoisted(() => ({ + simulateTransaction: vi.fn(), +})); + +vi.mock("../../../src/config/stellar.js", () => ({ + getSorobanServer: vi.fn(() => mockSoroban), + getNetworkPassphrase: vi.fn(() => "Test SDF Network ; September 2015"), +})); + +vi.mock("../../../src/utils/logger.js", () => ({ + logger: { info: vi.fn(), error: vi.fn(), warn: vi.fn(), debug: vi.fn() }, +})); + +vi.mock("@stellar/stellar-sdk", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + scValToNative: vi.fn(), + }; +}); + +import * as StellarSdk from "@stellar/stellar-sdk"; +import { getOnChainContentHash } from "../../../src/stellar/progress-tracker.js"; +import { logger } from "../../../src/utils/logger.js"; + +describe("getOnChainContentHash (#590)", () => { + // A validly-formatted (though not necessarily deployed) Soroban contract + // ID, since `new StellarSdk.Contract(...)` validates the strkey checksum. + const VALID_CONTRACT_ID = "CAAQCAIBAEAQCAIBAEAQCAIBAEAQCAIBAEAQCAIBAEAQCAIBAEAQC526"; + + beforeEach(() => { + vi.clearAllMocks(); + mockConfig.config.STELLAR_PROGRESS_TRACKER_CONTRACT_ID = VALID_CONTRACT_ID; + }); + + it("returns null without calling Soroban when the contract isn't configured", async () => { + mockConfig.config.STELLAR_PROGRESS_TRACKER_CONTRACT_ID = undefined; + + const result = await getOnChainContentHash("course-1"); + + expect(result).toBeNull(); + expect(mockSoroban.simulateTransaction).not.toHaveBeenCalled(); + }); + + it("retrieves and decodes the content hash on a successful simulation", async () => { + const retval = "fake-scval"; + mockSoroban.simulateTransaction.mockResolvedValue({ + result: { retval }, + }); + vi.spyOn(StellarSdk.rpc.Api, "isSimulationError").mockReturnValue(false); + vi.spyOn(StellarSdk.rpc.Api, "isSimulationSuccess").mockReturnValue(true); + (StellarSdk.scValToNative as ReturnType).mockReturnValue("abc123hash"); + + const result = await getOnChainContentHash("course-1"); + + expect(result).toBe("abc123hash"); + }); + + it("returns null when the decoded value is not a string", async () => { + mockSoroban.simulateTransaction.mockResolvedValue({ + result: { retval: "fake-scval" }, + }); + vi.spyOn(StellarSdk.rpc.Api, "isSimulationError").mockReturnValue(false); + vi.spyOn(StellarSdk.rpc.Api, "isSimulationSuccess").mockReturnValue(true); + (StellarSdk.scValToNative as ReturnType).mockReturnValue(12345); + + const result = await getOnChainContentHash("course-1"); + + expect(result).toBeNull(); + }); + + it("returns null and warns when the simulation reports an error", async () => { + mockSoroban.simulateTransaction.mockResolvedValue({ error: "contract trapped" }); + vi.spyOn(StellarSdk.rpc.Api, "isSimulationError").mockReturnValue(true); + + const result = await getOnChainContentHash("course-1"); + + expect(result).toBeNull(); + expect(logger.warn).toHaveBeenCalledWith( + expect.objectContaining({ courseId: "course-1" }), + expect.stringContaining("skipping content hash verification"), + ); + }); + + it("returns null when the simulation result has no retval", async () => { + mockSoroban.simulateTransaction.mockResolvedValue({ result: {} }); + vi.spyOn(StellarSdk.rpc.Api, "isSimulationError").mockReturnValue(false); + vi.spyOn(StellarSdk.rpc.Api, "isSimulationSuccess").mockReturnValue(true); + + const result = await getOnChainContentHash("course-1"); + + expect(result).toBeNull(); + }); + + it("returns null and warns when the RPC call throws", async () => { + mockSoroban.simulateTransaction.mockRejectedValue(new Error("network unreachable")); + + const result = await getOnChainContentHash("course-1"); + + expect(result).toBeNull(); + expect(logger.warn).toHaveBeenCalledWith( + expect.objectContaining({ courseId: "course-1" }), + expect.stringContaining("Failed to read on-chain content hash"), + ); + }); +}); From e87b2854618b756955cfacbe17d42b03da8ec7f7 Mon Sep 17 00:00:00 2001 From: Uche Okafor Date: Wed, 21 Oct 2026 18:50:33 +0100 Subject: [PATCH 4/4] test(utils): expand unit tests for auth attempt tracker (#598) Add coverage for lockout expiration (ttl 0 and the missing-key -2 case) and for checkAuthLockout reporting not-locked-out again after clearAuthFailures runs. --- tests/unit/utils/auth-attempt-tracker.test.ts | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/tests/unit/utils/auth-attempt-tracker.test.ts b/tests/unit/utils/auth-attempt-tracker.test.ts index 4d365f3..d1ee2b5 100644 --- a/tests/unit/utils/auth-attempt-tracker.test.ts +++ b/tests/unit/utils/auth-attempt-tracker.test.ts @@ -41,6 +41,19 @@ describe("auth-attempt-tracker (#488)", () => { expect(result.lockedOut).toBe(true); expect(result.retryAfterSeconds).toBe(45); }); + + it("reports not locked out once the lockout key has expired (ttl 0)", async () => { + mockRedis.ttl.mockResolvedValue(0); + const result = await checkAuthLockout("GADDRESS"); + expect(result.lockedOut).toBe(false); + expect(result.retryAfterSeconds).toBeUndefined(); + }); + + it("treats a missing-key ttl (-2) the same as an expired lockout", async () => { + mockRedis.ttl.mockResolvedValue(-2); + const result = await checkAuthLockout("GADDRESS"); + expect(result.lockedOut).toBe(false); + }); }); describe("recordAuthFailure", () => { @@ -97,5 +110,15 @@ describe("auth-attempt-tracker (#488)", () => { expect.stringContaining("lockout"), ); }); + + it("allows a subsequent lockout check to report not-locked-out after clearing", async () => { + await clearAuthFailures("GADDRESS"); + + // Simulate redis reflecting the deletion: no lockout key left. + mockRedis.ttl.mockResolvedValue(-2); + const result = await checkAuthLockout("GADDRESS"); + + expect(result.lockedOut).toBe(false); + }); }); });