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
26 changes: 26 additions & 0 deletions __tests__/unit/api/standardResponse.contract.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ jest.mock('@/utils/logger', () => ({
}));

import {
apiPaymentRequired,
apiSuccess,
apiError,
apiBadRequest,
Expand Down Expand Up @@ -222,3 +223,28 @@ describe('apiRateLimited — Retry-After header', () => {
expect(headers.get('Retry-After')).toBeNull();
});
});

/**
* A 402 carries a bearer credential, so it must never be cached.
*
* The body embeds `token` (`<intentId>.<statusToken>`) and WWW-Authenticate
* repeats it — that token is what a payer later exchanges for a receipt. So
* anything caching this response caches a credential for someone else's
* payment. Every other payment response sets no-store; this one did not.
* bitbaum/orangecat#563 suggestion 15.
*/
describe('apiPaymentRequired — never cached', () => {
const headersOf = (r: unknown) => (r as { headers: Headers }).headers;

it('sets no-store', () => {
expect(
headersOf(apiPaymentRequired('Pay up', { token: 'pi-1.tok' })).get('Cache-Control')
).toBe('no-store, must-revalidate');
});

it('sets it on the challenge form, where the header repeats the token', () => {
const res = apiPaymentRequired('Pay up', { token: 'pi-1.tok' }, 'L402 token="pi-1.tok"');
expect(headersOf(res).get('Cache-Control')).toBe('no-store, must-revalidate');
expect(headersOf(res).get('WWW-Authenticate')).toContain('L402');
});
});
94 changes: 94 additions & 0 deletions __tests__/unit/cat/forget-generic-stem-innocents.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
/**
* Innocent memories that merely SHARE A STEM with the target must survive.
*
* INCIDENT_CORPUS was built from one real over-deletion, so every row in it is
* either a target or obviously unrelated. That shape flatters the matcher: the
* dangerous case is a memory that shares a common stem with the forget phrase
* and is nonetheless a different fact — "cooking skills" beside "photography
* skills", "documentary work" beside "weekend work".
*
* bitbaum/orangecat#563 suggestion 17: the corpus lacked these, so nothing
* proved the word-boundary fix (#831 / finding 9) held against them.
*/

import { forgetMemoriesMatching } from '@/services/cat/memory';
import type { AnySupabaseClient } from '@/lib/supabase/types';

jest.mock('@/utils/logger', () => ({
logger: { error: jest.fn(), warn: jest.fn(), info: jest.fn(), debug: jest.fn() },
}));

jest.mock('@/services/ai/embeddings', () => ({
embeddingsEnabled: () => false,
embedText: jest.fn(),
embedTexts: jest.fn(),
}));

interface Row {
id: string;
content: string;
}

function clientFor(corpus: Row[]) {
return {
from: () => ({
select: () => ({ eq: async () => ({ data: corpus, error: null }) }),
delete: () => ({ eq: () => ({ in: async () => ({ error: null }) }) }),
insert: async () => ({ error: null }),
upsert: async () => ({ error: null }),
}),
rpc: async () => ({ data: [], error: null }),
} as unknown as AnySupabaseClient;
}

/** Targets plus innocents that share a stem with each target. */
const CORPUS: Row[] = [
{ id: 'a1', content: 'Has photography skills from years of freelance work' },
{ id: 'a2', content: 'Has strong cooking skills' },
{ id: 'a3', content: 'Can only work on weekends.' },
{ id: 'a4', content: 'Has a documentary photography background' },
{ id: 'a5', content: 'Prefers Lightning over on-chain payments' },
{ id: 'a6', content: 'Owns a drone.' },
];

const forget = (facts: string[]) => forgetMemoriesMatching(clientFor(CORPUS), 'u1', facts);

describe('forget leaves stem-sharing innocents alone', () => {
it('"cooking skills" does not take the photography skills with it', async () => {
const result = await forget(['cooking skills']);
expect(result.deleted).toEqual(['Has strong cooking skills']);
});

it('"photography skills" does not take the cooking skills with it', async () => {
const result = await forget(['photography skills']);
expect(result.deleted).not.toContain('Has strong cooking skills');
// Both photography rows are legitimately about photography.
for (const kept of result.deleted) {
expect(kept.toLowerCase()).toContain('photograph');
}
});

/**
* Single-word containment is DELIBERATE — memory-forget.test.ts:138 pins it
* as how a user removes a whole topic ("forget photography" clears every
* photography memory). The consequence, pinned here so it is a decision and
* not a surprise: a generic word like "skills" is treated as a topic too, so
* it clears every memory phrased with it, across unrelated subjects.
*
* That is the accepted cost of topic removal, and #838 makes it visible —
* the reply names each memory it deleted, so an over-broad word is caught by
* the user reading the receipt rather than discovered later.
*/
it('treats a generic word as a topic, clearing every memory phrased with it', async () => {
const result = await forget(['skills']);
expect(result.deleted).toEqual([
'Has photography skills from years of freelance work',
'Has strong cooking skills',
]);
});

it('"weekend work" does not reach "freelance work"', async () => {
const result = await forget(['weekend work']);
expect(result.deleted).not.toContain('Has photography skills from years of freelance work');
});
});
87 changes: 87 additions & 0 deletions __tests__/unit/cat/forget-semantic-floor.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
/**
* The semantic fallback and its similarity floor, actually executed.
*
* Every other forget test disables embeddings, so the branch that runs when
* text containment finds NOTHING — and the 0.45 floor it passes to
* match_cat_memories — was never exercised by any test. The floor was moved
* from 0.75 to 0.45 on measured production data; a silent revert (or a typo)
* would have changed which memories a user's "forget that" reaches, and no
* test would have noticed.
*
* bitbaum/orangecat#563 suggestion 17.
*/

import { forgetMemoriesMatching } from '@/services/cat/memory';
import type { AnySupabaseClient } from '@/lib/supabase/types';

jest.mock('@/utils/logger', () => ({
logger: { error: jest.fn(), warn: jest.fn(), info: jest.fn(), debug: jest.fn() },
}));

const mockEmbedText = jest.fn();
jest.mock('@/services/ai/embeddings', () => ({
embeddingsEnabled: () => true,
embedText: (...args: unknown[]) => mockEmbedText(...args),
embedTexts: jest.fn().mockResolvedValue([]),
}));

/** Corpus deliberately shares NO word with the phrase, forcing the RPC path. */
const STORED = { id: 's1', content: 'Knows French' };

function makeClient(rpc: jest.Mock) {
return {
from: () => ({
select: () => ({ eq: async () => ({ data: [STORED], error: null }) }),
delete: () => ({ eq: () => ({ in: async () => ({ error: null }) }) }),
insert: async () => ({ error: null }),
upsert: async () => ({ error: null }),
}),
rpc,
} as unknown as AnySupabaseClient;
}

describe('forget semantic fallback', () => {
beforeEach(() => {
jest.clearAllMocks();
mockEmbedText.mockResolvedValue([0.1, 0.2, 0.3]);
});

it('passes the measured 0.45 floor to match_cat_memories', async () => {
const rpc = jest.fn().mockResolvedValue({ data: [], error: null });
await forgetMemoriesMatching(makeClient(rpc), 'u1', ['speaking a second language']);

expect(rpc).toHaveBeenCalledWith(
'match_cat_memories',
expect.objectContaining({ p_user_id: 'u1', min_similarity: 0.45 })
);
});

it('deletes what the semantic match returns when no word matched', async () => {
const rpc = jest.fn().mockResolvedValue({ data: [STORED], error: null });
const result = await forgetMemoriesMatching(makeClient(rpc), 'u1', [
'speaking a second language',
]);

expect(result.deleted).toEqual(['Knows French']);
expect(result.notFound).toEqual([]);
});

it('is a FALLBACK — a word match never reaches the RPC', async () => {
const rpc = jest.fn().mockResolvedValue({ data: [], error: null });
await forgetMemoriesMatching(makeClient(rpc), 'u1', ['French']);

expect(rpc).not.toHaveBeenCalled();
});

it('reports no-match rather than failing when the RPC errors', async () => {
// A dead embedding path must degrade to "nothing matched", never to a
// half-truth about what was removed.
const rpc = jest.fn().mockRejectedValue(new Error('vector index offline'));
const result = await forgetMemoriesMatching(makeClient(rpc), 'u1', [
'speaking a second language',
]);

expect(result.deleted).toEqual([]);
expect(result.notFound).toEqual(['speaking a second language']);
});
});
76 changes: 61 additions & 15 deletions __tests__/unit/domain/payments/publicPaymentStatus.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -138,36 +138,28 @@ describe('a claim is only accepted where no rail can answer', () => {
paid_at: null,
requires_recipient_confirmation: true,
});
expect(fake.updates.map(u => u.patch.status)).toEqual([
STATUS.PAYMENT_INTENTS.BUYER_CONFIRMED,
]);
expect(fake.updates.map(u => u.patch.status)).toEqual([STATUS.PAYMENT_INTENTS.BUYER_CONFIRMED]);
});

it('refuses a claim when the Lightning address can be verified automatically', async () => {
const fake = world(intent({ lnurl_verify_url: 'https://ln.example/verify/1' }));

await expect(acknowledgePublicPayment(PI_ID, TOKEN)).rejects.toThrow(
'confirmed automatically'
);
await expect(acknowledgePublicPayment(PI_ID, TOKEN)).rejects.toThrow('confirmed automatically');
expect(fake.updates).toEqual([]);
expect(dispatchMock).not.toHaveBeenCalled();
});

it('refuses a claim on an on-chain payment — the mempool answers, not the payer', async () => {
const fake = world(intent({ payment_method: 'onchain', onchain_address: 'bc1qexample' }));

await expect(acknowledgePublicPayment(PI_ID, TOKEN)).rejects.toThrow(
'confirmed automatically'
);
await expect(acknowledgePublicPayment(PI_ID, TOKEN)).rejects.toThrow('confirmed automatically');
expect(fake.updates).toEqual([]);
});

it('refuses a claim on an NWC payment', async () => {
const fake = world(intent({ payment_method: 'nwc', payment_hash: 'hash-1' }));

await expect(acknowledgePublicPayment(PI_ID, TOKEN)).rejects.toThrow(
'confirmed automatically'
);
await expect(acknowledgePublicPayment(PI_ID, TOKEN)).rejects.toThrow('confirmed automatically');
expect(fake.updates).toEqual([]);
});
});
Expand Down Expand Up @@ -226,9 +218,7 @@ describe('expiry bounds when a payment can be MADE, not when it can be REPORTED'

expect(result.status).toBe(STATUS.PAYMENT_INTENTS.BUYER_CONFIRMED);
expect(dispatchMock).toHaveBeenCalledTimes(1);
expect(fake.updates.map(u => u.patch.status)).toEqual([
STATUS.PAYMENT_INTENTS.BUYER_CONFIRMED,
]);
expect(fake.updates.map(u => u.patch.status)).toEqual([STATUS.PAYMENT_INTENTS.BUYER_CONFIRMED]);
});

it('refuses a claim once the claim window has also closed, and records the expiry', async () => {
Expand All @@ -255,3 +245,59 @@ describe('expiry bounds when a payment can be MADE, not when it can be REPORTED'
expect(fake.updates).toEqual([]);
});
});

/**
* A claim costs the recipient attention, so it is budgeted per recipient.
*
* The route in front of this only knows the caller's IP, and the abuse shape
* is many addresses aimed at ONE seller's confirmation queue — free intents
* fanned into plausible "someone paid you" cards, whose payoff is
* ship-the-goods-for-no-money. bitbaum/orangecat#563 finding 3.
*/
describe('claims are budgeted per recipient', () => {
const allow = jest.fn(async () => true);
const refuse = jest.fn(async () => false);

beforeEach(() => {
allow.mockClear();
refuse.mockClear();
});

it('asks about the recipient entity, not the caller', async () => {
world(intent());
await acknowledgePublicPayment(PI_ID, TOKEN, allow);
expect(allow).toHaveBeenCalledWith('product', 'prod-1');
});

it('refuses the claim once that recipient has been flooded', async () => {
world(intent());
await expect(acknowledgePublicPayment(PI_ID, TOKEN, refuse)).rejects.toThrow(
/Too many payment claims/
);
});

it('sends no card and moves no intent when the claim is refused', async () => {
const fake = world(intent());

await expect(acknowledgePublicPayment(PI_ID, TOKEN, refuse)).rejects.toThrow();

// The whole point: nothing reaches the seller, and the intent stays put.
expect(dispatchMock).not.toHaveBeenCalled();
expect(fake.updates).toEqual([]);
});

it('does not spend the budget on an idempotent re-claim', async () => {
// Already buyer_confirmed: this returns early, costs the attacker nothing,
// and so must not consume a genuine payer's allowance either.
world(intent({ status: STATUS.PAYMENT_INTENTS.BUYER_CONFIRMED }));

await acknowledgePublicPayment(PI_ID, TOKEN, allow);
expect(allow).not.toHaveBeenCalled();
});

it('is unbudgeted when no guard is supplied — the caller owns the policy', async () => {
const fake = world(intent());
await acknowledgePublicPayment(PI_ID, TOKEN);
expect(fake.updates.map(u => u.patch.status)).toEqual([STATUS.PAYMENT_INTENTS.BUYER_CONFIRMED]);
});
});
8 changes: 4 additions & 4 deletions __tests__/unit/domain/payments/resolveSellerWallet.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -110,7 +110,7 @@ describe('resolveSellerWallet — entity-linked wallet precedence', () => {
};
getAdminClientMock.mockReturnValue(makeAdmin(fx));

const resolved = await resolveSellerWallet({} as never, ENTITY.entity_type, ENTITY.entity_id);
const resolved = await resolveSellerWallet(ENTITY.entity_type, ENTITY.entity_id);

expect(resolved).toEqual({
method: 'onchain',
Expand All @@ -132,7 +132,7 @@ describe('resolveSellerWallet — entity-linked wallet precedence', () => {
};
getAdminClientMock.mockReturnValue(makeAdmin(fx));

const resolved = await resolveSellerWallet({} as never, ENTITY.entity_type, ENTITY.entity_id);
const resolved = await resolveSellerWallet(ENTITY.entity_type, ENTITY.entity_id);

expect(resolved).toEqual({
method: 'lightning_address',
Expand All @@ -145,7 +145,7 @@ describe('resolveSellerWallet — entity-linked wallet precedence', () => {
const fx = baseFixtures(); // entityWallets empty
getAdminClientMock.mockReturnValue(makeAdmin(fx));

const resolved = await resolveSellerWallet({} as never, ENTITY.entity_type, ENTITY.entity_id);
const resolved = await resolveSellerWallet(ENTITY.entity_type, ENTITY.entity_id);

expect(resolved).toEqual({
method: 'onchain',
Expand All @@ -160,7 +160,7 @@ describe('resolveSellerWallet — entity-linked wallet precedence', () => {
fx.walletsById = {}; // linked wallet not active → single() returns null
getAdminClientMock.mockReturnValue(makeAdmin(fx));

const resolved = await resolveSellerWallet({} as never, ENTITY.entity_type, ENTITY.entity_id);
const resolved = await resolveSellerWallet(ENTITY.entity_type, ENTITY.entity_id);

expect(resolved).toEqual({
method: 'onchain',
Expand Down
Loading
Loading