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
160 changes: 160 additions & 0 deletions core/export/__tests__/sensitive-field-redaction.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
/**
* Permission-aware export with sensitive-field redaction (#314).
*
* Verifies that `applyFieldLevelAuthorization` and `evaluateFieldAuthorization`
* correctly omit or mask each class of sensitive field based on actor kind and
* explicit permission grants, covering the normal path, failure path, and the
* boundary between "omit" and "mask" behaviours.
*/

import { describe, expect, it } from "vitest";
import {
applyFieldLevelAuthorization,
evaluateFieldAuthorization,
DEFAULT_SENSITIVE_FIELD_RULES,
type ExportActor,
} from "../exporter";

const userActor: ExportActor = { kind: "user" };
const maintainerActor: ExportActor = { kind: "maintainer" };

const record = {
id: "op-001",
amount: "100.00",
status: "settled",
// maintainer-only fields
ipAddress: "203.0.113.10",
userAgent: "Mozilla/5.0",
internalLatencyMs: 42,
adminNotes: "flagged for review",
riskScore: 0.85,
debugTrace: '{"step":"auth","ok":true}',
traceId: "trace-abc123",
rawPayload: '{"raw":true}',
systemSignature: "sig-xyz",
nodeId: "node-7",
sessionToken: "tok-secret",
};

describe("sensitive-field redaction (#314)", () => {
describe("user actor — omit/mask behaviour", () => {
it("strips all maintainer-only fields for a plain user actor", () => {
const { record: out, filteredCount } = applyFieldLevelAuthorization(
record,
userActor,
{ rules: DEFAULT_SENSITIVE_FIELD_RULES }
);

// Non-sensitive fields must survive
expect(out.id).toBe("op-001");
expect(out.amount).toBe("100.00");
expect(out.status).toBe("settled");

// Every maintainer-only field must be absent or masked
expect(out.internalLatencyMs).toBeUndefined();
expect(out.userAgent).toBeUndefined();
expect(out.debugTrace).toBeUndefined();
expect(out.traceId).toBeUndefined();
expect(out.rawPayload).toBeUndefined();
expect(out.systemSignature).toBeUndefined();
expect(out.nodeId).toBeUndefined();
expect(out.sessionToken).toBeUndefined();
expect(out.adminNotes).toBe("[FILTERED: UNAUTHORIZED]"); // onDenied: "mask"
expect(filteredCount).toBeGreaterThanOrEqual(9);
});

it("masks ipAddress (onDenied: mask) instead of omitting it", () => {
const { record: out } = applyFieldLevelAuthorization(
{ ipAddress: "192.0.2.1" },
userActor,
{ rules: DEFAULT_SENSITIVE_FIELD_RULES }
);
expect(out.ipAddress).toBe("[FILTERED: UNAUTHORIZED]");
});

it("omits riskScore (onDenied: omit) from user output", () => {
const { record: out } = applyFieldLevelAuthorization(
{ riskScore: 0.9 },
userActor,
{ rules: DEFAULT_SENSITIVE_FIELD_RULES }
);
expect(out.riskScore).toBeUndefined();
});
});

describe("maintainer actor — full access", () => {
it("passes all fields through unchanged for a maintainer", () => {
const { record: out, filteredCount } = applyFieldLevelAuthorization(
record,
maintainerActor,
{ rules: DEFAULT_SENSITIVE_FIELD_RULES }
);

expect(out.ipAddress).toBe(record.ipAddress);
expect(out.adminNotes).toBe(record.adminNotes);
expect(out.riskScore).toBe(record.riskScore);
expect(out.debugTrace).toBe(record.debugTrace);
expect(filteredCount).toBe(0);
});
});

describe("evaluateFieldAuthorization — explicit field request", () => {
it("denies a user explicitly requesting a maintainer-only field", () => {
const result = evaluateFieldAuthorization(
userActor,
["id", "amount", "riskScore"],
DEFAULT_SENSITIVE_FIELD_RULES
);
expect(result.authorized).toBe(false);
expect(result.deniedFields).toContain("riskScore");
});

it("allows a user requesting only safe fields", () => {
const result = evaluateFieldAuthorization(
userActor,
["id", "amount", "status"],
DEFAULT_SENSITIVE_FIELD_RULES
);
expect(result.authorized).toBe(true);
expect(result.deniedFields).toHaveLength(0);
});

it("grants a maintainer access to any explicitly requested field", () => {
const result = evaluateFieldAuthorization(
maintainerActor,
["riskScore", "debugTrace", "sessionToken"],
DEFAULT_SENSITIVE_FIELD_RULES
);
expect(result.authorized).toBe(true);
expect(result.deniedFields).toHaveLength(0);
});
});

describe("field projection intersection", () => {
it("returns only the requested projection fields even when authorized", () => {
const { record: out } = applyFieldLevelAuthorization(
record,
maintainerActor,
{
requestedFields: ["id", "amount"],
rules: DEFAULT_SENSITIVE_FIELD_RULES,
}
);
expect(Object.keys(out)).toEqual(["id", "amount"]);
});

it("does not leak a sensitive field when projection and deny rule both apply", () => {
// User requests both a safe field and a sensitive one in the projection
const { record: out } = applyFieldLevelAuthorization(
record,
userActor,
{
requestedFields: ["id", "riskScore"],
rules: DEFAULT_SENSITIVE_FIELD_RULES,
}
);
expect(out.id).toBe("op-001");
expect(out.riskScore).toBeUndefined();
});
});
});
138 changes: 138 additions & 0 deletions core/reconciliation/__tests__/source-projection.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
/**
* Reconciliation checks for source state and cached projections (#315).
*
* Verifies that `reconcile` detects the specific class of drift that arises
* when a cached projection disagrees with its source of truth — a stored
* balance that does not match the current ledger entry, or a balance that
* exists in the cache but has no corresponding ledger record at all.
*
* These are the two scenarios an operator sees after a cache warm-up failure
* or a missed ledger close: the projection says one thing, the source says
* another.
*/

import { describe, expect, it } from "vitest";
import {
reconcile,
amountsEqual,
type ReconciliationInput,
type StoredBalance,
type LedgerBalance,
} from "@/core/reconciliation/reconciliation";

const NOW = 1_770_000_000_000;

function snap(overrides: Partial<ReconciliationInput> = {}): ReconciliationInput {
return {
storedBalances: [],
ledgerBalances: [],
jobs: [],
notifications: [],
idempotencyRecords: [],
exportEnvelopes: [],
operations: [],
now: NOW,
...overrides,
};
}

describe("source state vs cached projection reconciliation (#315)", () => {
describe("missing ledger entry", () => {
it("flags a stored balance that has no matching ledger entry", () => {
const stored: StoredBalance = {
account: "GABC",
asset: "USDC:GAAA",
balance: "100.00",
cachedAt: new Date(NOW - 30_000).toISOString(),
};

const report = reconcile(snap({ storedBalances: [stored], ledgerBalances: [] }));

const missing = report.results.find((r) => r.invariant === "balance.ledger_entry_present");
expect(missing).toBeDefined();
expect(missing!.findings).toBeGreaterThanOrEqual(1);
expect(report.totalFindings).toBeGreaterThanOrEqual(1);
});

it("passes when every stored balance has a matching ledger entry", () => {
const account = "GABC";
const asset = "USDC:GAAA";
const stored: StoredBalance = { account, asset, balance: "50.00" };
const ledger: LedgerBalance = { account, asset, balance: "50.00", reference: "ledger-close-1" };

const report = reconcile(snap({ storedBalances: [stored], ledgerBalances: [ledger] }));

const missing = report.results.find((r) => r.invariant === "balance.ledger_entry_present");
expect(missing?.findings ?? 0).toBe(0);
expect(report.passed).toBe(true);
});
});

describe("amount disagreement between cached projection and source", () => {
it("flags a stored balance that differs from the ledger amount", () => {
const account = "GDEF";
const asset = "XLM:native";
const stored: StoredBalance = {
account,
asset,
balance: "200.00",
cachedAt: new Date(NOW - 5_000).toISOString(),
};
const ledger: LedgerBalance = {
account,
asset,
balance: "195.50", // ledger closed with a different amount
reference: "ledger-close-2",
};

const report = reconcile(snap({ storedBalances: [stored], ledgerBalances: [ledger] }));

const inconsistent = report.results.find((r) => r.invariant === "balance.amount_agrees");
expect(inconsistent).toBeDefined();
expect(inconsistent!.findings).toBeGreaterThanOrEqual(1);
});

it("passes when cached and ledger amounts are equal", () => {
const account = "GDEF";
const asset = "XLM:native";
const stored: StoredBalance = { account, asset, balance: "195.50" };
const ledger: LedgerBalance = { account, asset, balance: "195.50", reference: "ledger-close-3" };

const report = reconcile(snap({ storedBalances: [stored], ledgerBalances: [ledger] }));

const inconsistent = report.results.find((r) => r.invariant === "balance.amount_agrees");
expect(inconsistent?.findings ?? 0).toBe(0);
});
});

describe("duplicate cached projection lines", () => {
it("flags two stored lines for the same account and asset", () => {
const account = "GHIJ";
const asset = "USDC:GAAA";
const stored: StoredBalance[] = [
{ account, asset, balance: "10.00" },
{ account, asset, balance: "10.00" },
];
const ledger: LedgerBalance = { account, asset, balance: "10.00", reference: "lc-4" };

const report = reconcile(
snap({ storedBalances: stored, ledgerBalances: [ledger] })
);

const dup = report.results.find((r) => r.invariant === "balance.single_line_per_asset");
expect(dup).toBeDefined();
expect(dup!.findings).toBeGreaterThanOrEqual(1);
});
});

describe("amountsEqual helper", () => {
it("treats numerically equal decimal strings as equal", () => {
expect(amountsEqual("1.00", "1.0")).toBe(true);
expect(amountsEqual("100", "100.00")).toBe(true);
});

it("distinguishes genuinely different amounts", () => {
expect(amountsEqual("100.00", "100.01")).toBe(false);
});
});
});
Loading