Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
114 changes: 114 additions & 0 deletions tests/unit/stellar/progress-tracker.test.ts
Original file line number Diff line number Diff line change
@@ -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<typeof import("@stellar/stellar-sdk")>();
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<typeof vi.fn>).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<typeof vi.fn>).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"),
);
});
});
136 changes: 136 additions & 0 deletions tests/unit/stellar/sequence-cache.test.ts
Original file line number Diff line number Diff line change
@@ -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<typeof vi.fn>).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,
);
});
});
});
91 changes: 91 additions & 0 deletions tests/unit/utils/account-lock.test.ts
Original file line number Diff line number Diff line change
@@ -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<void>((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();
}
});
});
23 changes: 23 additions & 0 deletions tests/unit/utils/auth-attempt-tracker.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand Down Expand Up @@ -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);
});
});
});