Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
6bf069e
fix: dedupe pharmacy-admin auth, fail-fast pharmacy-api boot, re-chec…
JONAH-6 Jul 30, 2026
e47c604
ci: sync lockfiles and fix root typecheck config so npm ci and tsc su…
JONAH-6 Jul 30, 2026
e050301
fix(scripts): make check-env-vars aware of documentation-only wallet …
JONAH-6 Jul 30, 2026
5ec06ea
fix(ci): use pnpm exec to invoke playwright's install CLI in e2e.yml
JONAH-6 Jul 30, 2026
e5ce41a
fix(agent): derive SpendingPolicyInput from z.input, dedupe fs import
JONAH-6 Jul 30, 2026
8383147
test(agent): fix stale API references in tools.test.ts
JONAH-6 Jul 30, 2026
50d0b2d
fix(agent): implement documented lazy MPP client cache (issue #196)
JONAH-6 Jul 30, 2026
c90fa8c
fix(tests): correct invalid fallback AGENT_SECRET_KEY in tests/setup.ts
JONAH-6 Jul 30, 2026
d04239c
test(agent): set AGENT_API_KEY so auth-rejection assertions are meani…
JONAH-6 Jul 30, 2026
a90a6a9
test(agent): fix stale/malformed tx-hash fixtures in x402 tests
JONAH-6 Jul 30, 2026
2c340ef
fix(ci): allowlist the generated test AGENT_SECRET_KEY in gitleaks co…
JONAH-6 Jul 30, 2026
0737e9d
test(agent): fix DATA_DIR mismatch and stale fixtures in persistence/…
JONAH-6 Jul 30, 2026
de6a144
fix(agent): wire up paybillSeqRetryTotal counter and fix its test fix…
JONAH-6 Jul 30, 2026
5842739
fix(agent): restore lost signer-hint verification in the fee-bump path
JONAH-6 Jul 30, 2026
2c33081
test(agent): fix policy invariant violations and missing MOCK_NETWORK…
JONAH-6 Jul 30, 2026
623e2a6
fix(agent): wire STELLAR_TIMEBOUNDS_SECONDS into the fee-bump envelop…
JONAH-6 Jul 30, 2026
d403d13
test(agent): fix fs mock and dead assignment in timezone-policy.test.ts
JONAH-6 Jul 30, 2026
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
4 changes: 4 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,10 @@ jobs:
- name: Install dependencies
run: npm ci --legacy-peer-deps

- name: Install dashboard dependencies
run: npm ci
working-directory: dashboard

- name: Typecheck root
run: npx tsc --noEmit

Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/e2e.yml
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ jobs:
working-directory: dashboard

- name: Install Playwright chromium
run: pnpm --dir dashboard playwright install --with-deps chromium
run: pnpm --dir dashboard exec playwright install --with-deps chromium

- name: Build dashboard
run: npm run build
Expand Down
6 changes: 6 additions & 0 deletions .gitleaks.toml
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,12 @@ description = "Stellar Secret Seed Key"
regex = '''S[A-Z2-7]{55}'''
tags = ["key", "stellar"]

[rules.allowlist]
description = "Checksum-valid testnet keypairs used only as test fixtures, never real secrets"
regexes = [
'''SAQDKF5AKPWQZRXGHLH523VC7DU46U7IEQNOALOUO2A2UR55NWPVDRGF''', # tests/setup.ts fallback AGENT_SECRET_KEY
]

[[rules]]
id = "groq-api-key"
description = "Groq API Key"
Expand Down
1 change: 1 addition & 0 deletions agent/__tests__/agent-endpoints.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,7 @@ process.env.PHARMACY_1_PUBLIC_KEY = "GBQTESTPHARMACY1PUBKEY";
process.env.BILL_PROVIDER_PUBLIC_KEY = "GBQTESTBILLPROVIDERPUBKEY";
process.env.MPP_SECRET_KEY = "test-mpp-secret-key";
process.env.CAREGIVER_TOKEN = "test-caregiver-token";
process.env.AGENT_API_KEY = "test-agent-api-key";

const { app } = await import("../../server.ts");
const auth = (req: any) => req.set("Authorization", "Bearer test-agent-api-key");
Expand Down
1 change: 1 addition & 0 deletions agent/__tests__/cors-auth.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,7 @@ process.env.BILL_PROVIDER_PUBLIC_KEY = "GBQTESTBILLPROVIDERPUBKEY";
process.env.MPP_SECRET_KEY = "test-mpp-secret-key";
process.env.CAREGIVER_TOKEN = "test-caregiver-token";
process.env.ALLOWED_ORIGINS = "http://localhost:3000";
process.env.AGENT_API_KEY = "test-agent-api-key";

const { app } = await import("../../server.ts");
const auth = (req: any) => req.set("Authorization", "Bearer test-agent-api-key");
Expand Down
22 changes: 11 additions & 11 deletions agent/__tests__/extract-x402-tx-hash.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ vi.mock("@x402/stellar", () => ({ createEd25519Signer: vi.fn(), ExactStellarSche
vi.mock("@stellar/mpp/charge/client", () => ({ stellar: vi.fn() }));
vi.mock("mppx/client", () => ({ Mppx: { create: vi.fn() } }));

import { extractX402TxHash } from "../tools.ts";
import { extractX402TxHash, TX_HASH_EXTRACTION_FAILED } from "../tools.ts";

function makeResponse(headerValue: string | null): Response {
const headers = new Headers();
Expand Down Expand Up @@ -72,16 +72,16 @@ describe("extractX402TxHash", () => {
expect(result).toBeUndefined();
});

it("should return undefined when decoded has no transaction field", () => {
it("should return TX_HASH_EXTRACTION_FAILED when decoded has no transaction field", () => {
mockDecode.mockReturnValue({});
const result = extractX402TxHash(makeResponse("dGVzdA=="));
expect(result).toBeUndefined();
expect(result).toBe(TX_HASH_EXTRACTION_FAILED);
});

it("should return undefined when decode throws and header is not 64-char hex", () => {
it("should return TX_HASH_EXTRACTION_FAILED when decode throws and header is not 64-char hex", () => {
mockDecode.mockImplementation(() => { throw new Error("decode failed"); });
const result = extractX402TxHash(makeResponse("short"));
expect(result).toBeUndefined();
expect(result).toBe(TX_HASH_EXTRACTION_FAILED);
});

it("should fall back to raw header when decode throws and header is 64-char hex", () => {
Expand All @@ -91,21 +91,21 @@ describe("extractX402TxHash", () => {
expect(result).toBe(hash);
});

it("should return undefined for 63-char hex header when decode throws", () => {
it("should return TX_HASH_EXTRACTION_FAILED for 63-char hex header when decode throws", () => {
mockDecode.mockImplementation(() => { throw new Error("decode failed"); });
const result = extractX402TxHash(makeResponse("a".repeat(63)));
expect(result).toBeUndefined();
expect(result).toBe(TX_HASH_EXTRACTION_FAILED);
});

it("should return undefined for 65-char hex header when decode throws", () => {
it("should return TX_HASH_EXTRACTION_FAILED for 65-char hex header when decode throws", () => {
mockDecode.mockImplementation(() => { throw new Error("decode failed"); });
const result = extractX402TxHash(makeResponse("a".repeat(65)));
expect(result).toBeUndefined();
expect(result).toBe(TX_HASH_EXTRACTION_FAILED);
});

it("should return undefined on malformed base64 in decode", () => {
it("should return TX_HASH_EXTRACTION_FAILED on malformed base64 in decode", () => {
mockDecode.mockImplementation(() => { throw new Error("malformed base64"); });
const result = extractX402TxHash(makeResponse("!!!invalid-base64!!!"));
expect(result).toBeUndefined();
expect(result).toBe(TX_HASH_EXTRACTION_FAILED);
});
});
6 changes: 5 additions & 1 deletion agent/__tests__/fee-bump-doubling.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,11 @@ import {
const MAX_FEE_STROOPS = 100_000; // matches the MAX_FEE_STROOPS default in tools.ts
const mockAccount = { id: "GPUB123", sequence: "1" };
const mockServer = { loadAccount: mockLoadAccount, submitTransaction: mockSubmitTransaction } as any;
const signer = { publicKey: () => "GPUB123", sign: () => {} } as any;
const signer = {
publicKey: () => "GPUB123",
sign: () => {},
signatureHint: () => MOCK_HINT,
} as any;

function horizonError(code: string) {
const err: any = new Error("Request failed with status code 400");
Expand Down
10 changes: 5 additions & 5 deletions agent/__tests__/fee-bump-retry-budget.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -118,7 +118,7 @@ describe("submitTransactionWithFeeBump — 35s retry budget (Issue #797)", () =>
mockServer,
mockAccount,
[{}],
{ publicKey: () => "GPUB123", sign: () => {} } as any,
{ publicKey: () => "GPUB123", sign: () => {}, signatureHint: () => MOCK_HINT } as any,
"100",
clock,
),
Expand All @@ -145,7 +145,7 @@ describe("submitTransactionWithFeeBump — 35s retry budget (Issue #797)", () =>
mockServer,
mockAccount,
[{}],
{ publicKey: () => "GPUB123", sign: () => {} } as any,
{ publicKey: () => "GPUB123", sign: () => {}, signatureHint: () => MOCK_HINT } as any,
"100",
clock,
),
Expand Down Expand Up @@ -174,7 +174,7 @@ describe("submitTransactionWithFeeBump — 35s retry budget (Issue #797)", () =>
mockServer,
mockAccount,
[{}],
{ publicKey: () => "GPUB123", sign: () => {} } as any,
{ publicKey: () => "GPUB123", sign: () => {}, signatureHint: () => MOCK_HINT } as any,
"100",
clock,
),
Expand All @@ -196,7 +196,7 @@ describe("submitTransactionWithFeeBump — 35s retry budget (Issue #797)", () =>
mockServer,
mockAccount,
[{}],
{ publicKey: () => "GPUB123", sign: () => {} } as any,
{ publicKey: () => "GPUB123", sign: () => {}, signatureHint: () => MOCK_HINT } as any,
"100",
clock,
);
Expand All @@ -216,7 +216,7 @@ describe("submitTransactionWithFeeBump — 35s retry budget (Issue #797)", () =>
mockServer,
mockAccount,
[{}],
{ publicKey: () => "GPUB123", sign: () => {} } as any,
{ publicKey: () => "GPUB123", sign: () => {}, signatureHint: () => MOCK_HINT } as any,
"100",
clock,
).catch(() => {});
Expand Down
6 changes: 5 additions & 1 deletion agent/__tests__/mock-network.test.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { mkdirSync } from "fs";

vi.hoisted(() => {
process.env.MOCK_NETWORK = "1";
process.env.NODE_ENV = "test";
process.env.AGENT_SECRET_KEY = "test-agent-secret";
process.env.AGENT_SECRET_KEY = "S-test-agent-secret";
});

vi.mock("@stellar/stellar-sdk", () => ({
Expand Down Expand Up @@ -51,6 +52,9 @@ const POLICY = {

describe("MOCK_NETWORK tool paths", () => {
beforeEach(() => {
// tests/setup.ts wipes DATA_DIR after every test; recreate the recipient
// dir tools.ts otherwise only creates once at module load.
mkdirSync(`${process.env.DATA_DIR}/recipients/rosa`, { recursive: true });
resetSpendingTracker();
setSpendingPolicy(POLICY);
});
Expand Down
3 changes: 3 additions & 0 deletions agent/__tests__/mpp-client-lazy.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@

const { mppCreateSpy, MOCK_HINT } = vi.hoisted(() => {
process.env.AGENT_SECRET_KEY = "SBWWZYCAFDDJXNRRMKSFNRB6OTVZHTCMPUCVZ4FBZLSPHFKHYLPRTJCD";
// Disable mock network so getMppClient() goes through the real
// createMppClient() -> Mppx.create() path that mppCreateSpy observes.
process.env.MOCK_NETWORK = "0";
const MOCK_HINT = Buffer.from([0xca, 0xfe, 0xba, 0xbe]);
const mppCreateSpy = vi.fn().mockReturnValue({ fetch: vi.fn() });
return { mppCreateSpy, MOCK_HINT };
Expand Down
9 changes: 6 additions & 3 deletions agent/__tests__/pay-for-medication.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,9 @@
const { mockMppFetch, onProgressHolder, mockFiles, MOCK_HINT } = vi.hoisted(() => {
process.env.AGENT_SECRET_KEY = "SBWWZYCAFDDJXNRRMKSFNRB6OTVZHTCMPUCVZ4FBZLSPHFKHYLPRTJCD";
process.env.BILL_PROVIDER_PUBLIC_KEY = "GBILLPROVIDER";
// Disable mock network so getMppClient() goes through the real
// createMppClient() -> Mppx.create() path that mockMppFetch observes.
process.env.MOCK_NETWORK = "0";
const onProgressHolder: { fn?: (event: any) => void } = {};
return { mockMppFetch: vi.fn(), onProgressHolder, mockFiles: new Map<string, string>(), MOCK_HINT: Buffer.from([0xca, 0xfe, 0xba, 0xbe]) };
});
Expand Down Expand Up @@ -132,7 +135,7 @@ describe("payForMedication — policy-blocked (Issue #35)", () => {
});

it("returns success:false when daily limit would be exceeded", async () => {
setSpendingPolicy("rosa", { ...DEFAULT_POLICY, dailyLimit: 10 });
setSpendingPolicy("rosa", { ...DEFAULT_POLICY, dailyLimit: 10, approvalThreshold: 5 });
const r = await payForMedication("p1", "Pharma", "Drug", 50);
expect(r.success).toBe(false);
expect(r.error).toContain("BLOCKED BY SPENDING POLICY");
Expand Down Expand Up @@ -315,11 +318,11 @@ describe("checkSpendingPolicy — basic rules (Issue #35)", () => {
it("blocks medication + bill spending at the global monthly cap", async () => {
setSpendingPolicy("rosa", {
...DEFAULT_POLICY,
dailyLimit: 500,
dailyLimit: 120,
monthlyLimit: 120,
medicationMonthlyBudget: 80,
billMonthlyBudget: 40,
approvalThreshold: 500,
approvalThreshold: 120,
});
mockMppFetch.mockResolvedValueOnce({
json: async () => ({ success: true, order: { id: "order-global-cap" } }),
Expand Down
15 changes: 9 additions & 6 deletions agent/__tests__/paybill-seq-retry.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,10 @@ vi.mock("dotenv/config", () => ({}));
vi.mock("fs", () => ({
readFileSync: vi.fn().mockReturnValue("{}"),
writeFileSync: vi.fn(),
appendFileSync: vi.fn(),
existsSync: vi.fn().mockReturnValue(false),
mkdirSync: vi.fn(),
renameSync: vi.fn(),
}));
vi.mock("@stellar/stellar-sdk", () => ({
Keypair: {
Expand Down Expand Up @@ -70,8 +72,8 @@ import {
const DEFAULT_POLICY = {
dailyLimit: 100,
monthlyLimit: 500,
medicationMonthlyBudget: 300,
billMonthlyBudget: 500,
medicationMonthlyBudget: 200,
billMonthlyBudget: 300,
approvalThreshold: 75,
};

Expand Down Expand Up @@ -120,15 +122,16 @@ describe("payBill — tx_bad_seq retry (Issues #197 / #282)", () => {

it("surfaces error when retry also fails", async () => {
mockLoadAccount.mockResolvedValue(mockAccount);
mockSubmitTransaction
.mockRejectedValueOnce(makeSeqError())
.mockRejectedValueOnce(makeSeqError());
// Every attempt (initial + all inner reload/retry cycles) keeps failing
// with a sequence error, so the retry loop exhausts its bounded attempts
// and surfaces the final error instead of retrying forever.
mockSubmitTransaction.mockRejectedValue(makeSeqError());

const result = await payBill("provider-1", "General Hospital", "ER Visit", 50);

expect(result.success).toBe(false);
expect((result as any).error).toContain("Stellar USDC transfer failed");
expect(getPaybillSeqRetryTotal()).toBe(1);
expect(getPaybillSeqRetryTotal()).toBe(3);
});

it("does not retry on non-sequence errors", async () => {
Expand Down
25 changes: 20 additions & 5 deletions agent/__tests__/persistence.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -113,16 +113,19 @@ vi.mock("../../shared/audit-log.ts", () => ({
vi.mock("../../shared/notifications.ts", () => ({ notify: vi.fn().mockResolvedValue(undefined) }));
// Spying directly on the real pino instance is unreliable, so mock the
// logger module the same way shared/__tests__/request-logger.test.ts does.
const warnSpy = vi.hoisted(() => vi.fn());
const { warnSpy, errorSpy } = vi.hoisted(() => ({ warnSpy: vi.fn(), errorSpy: vi.fn() }));
vi.mock("../../shared/logger.ts", () => ({
logger: { info: vi.fn(), warn: warnSpy, error: vi.fn(), debug: vi.fn() },
logger: { info: vi.fn(), warn: warnSpy, error: errorSpy, debug: vi.fn() },
}));

import { describe, it, expect, beforeEach } from "vitest";

// Mirrors the DATA_DIR computation in agent/tools.ts so the fake fs paths
// we seed/inspect line up regardless of where the repo is checked out.
const DATA_DIR = new URL("../../data", import.meta.url).pathname;
// tools.ts's getDataDir() checks process.env.DATA_DIR first — tests/setup.ts
// always sets it to a worker-specific temp dir, so that must take priority
// here too, or every fsState lookup misses the key tools.ts actually wrote.
const DATA_DIR = process.env.DATA_DIR || new URL("../../data", import.meta.url).pathname;

function freshTracker(transactionCount: number) {
return {
Expand All @@ -146,6 +149,7 @@ beforeEach(() => {
fsState.files.clear();
fsState.readOnlyPaths.clear();
warnSpy.mockClear();
errorSpy.mockClear();
});

describe("Spending tracker persistence across restart (#44)", () => {
Expand Down Expand Up @@ -202,8 +206,19 @@ describe("Corrupted data falls back to defaults without throwing (#44)", () => {
result = tools.loadSpending("corrupt-recipient");
}).not.toThrow();

expect(result).toEqual({ medications: 0, bills: 0, serviceFees: 0, transactions: [] });
expect(warnSpy).toHaveBeenCalled();
expect(result).toEqual({
medications: 0,
bills: 0,
serviceFees: 0,
transactions: [],
monthTotals: {
yearMonth: expect.any(String),
medications: 0,
bills: 0,
serviceFees: 0,
},
});
expect(errorSpy).toHaveBeenCalled();
});

it("a corrupt policy.json on disk does not throw and yields the default policy", async () => {
Expand Down
Loading