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
43 changes: 16 additions & 27 deletions apps/backend/src/lib/stellar/contract-validation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,41 +5,25 @@ import {
validateContractAddresses,
type ContractValidationResult,
} from './contract-validation';

// Helper to generate a malformed contract address with a different version byte
// but matching checksum. This tests the validation gap where strkey validation
// passes but the version byte is incorrect.
function generateContractWithVersionByte(versionByte: number): string {
// Valid contract address starting with C (version byte 0x10)
const validAddr = 'CBQWI64FZ2NKSJC7D45HJZVVMQZ3T7KHXOJSLZPZ5LHKQM7FFWVGNQST';

// For this test, we'll use a hardcoded malformed address with version byte 0x11
// The CRC-16 is computed over the payload, so a different version byte but same
// overall structure would still pass the checksum if we recomputed it.
// This address has version byte 0x11 instead of 0x10:
return 'CCQWI64FZ2NKSJC7D45HJZVVMQZ3T7KHXOJSLZPZ5LHKQM7FFWVGNQSP';
}
import {
INVALID_CONTRACT_ADDRESSES,
VALID_CONTRACT_ADDRESSES,
} from '@craft/stellar';

// ── Valid Contract Addresses ─────────────────────────────────────────────────

const VALID_TESTNET_CONTRACTS = {
usdcContract: 'CBQWI64FZ2NKSJC7D45HJZVVMQZ3T7KHXOJSLZPZ5LHKQM7FFWVGNQST', // 56 chars
nativeTokenContract: 'CATPNZ2SJRSVZJBWXGFSMZQHQ47JM5PXNQRVJLGHGHVKPZ2OVH3FHPAA', // 56 chars
usdcContract: VALID_CONTRACT_ADDRESSES.testnetUsdc,
nativeTokenContract: VALID_CONTRACT_ADDRESSES.testnetNativeToken,
};

const VALID_MAINNET_CONTRACTS = {
someContract: 'CATHQD7JDJFQ4WVQXVJDAJX4CSJM3XDYPRMHMV35FVPVLCZDWJYC5WDA', // 56 chars
someContract: VALID_CONTRACT_ADDRESSES.mainnetExample,
};

// ── Invalid Contract Addresses ───────────────────────────────────────────────

const INVALID_CONTRACTS = {
tooShort: 'CBQWI64FZ2NKSJC7D45HJZVVMQZ3T7KHXOJSLZPZ5LHK',
tooLong: 'CBQWI64FZ2NKSJC7D45HJZVVMQZ3T7KHXOJSLZPZ5LHKQM7FFWVGNQSTX',
wrongPrefix: 'GBQWI64FZ2NKSJC7D45HJZVVMQZ3T7KHXOJSLZPZ5LHKQM7FFWVGNQST',
invalidCharacters: 'CBQWI64FZ2NKSJC7D45HJZVVMQZ3T7KHXOJSLZPZ5LHKQM7-FWVGNQST',
invalidChars2: 'CBQWI64FZ2NKSJC7D45HJZVVMQZ3T7KHXOJSLZPZ5LHKQM7FFWVGNQSI', // I is invalid (not base32)
};
const INVALID_CONTRACTS = INVALID_CONTRACT_ADDRESSES;

// ── Arbitraries for Property-Based Tests ─────────────────────────────────────

Expand Down Expand Up @@ -138,13 +122,13 @@ describe('validateContractAddress', () => {
});

it('rejects address with I (invalid base32)', () => {
const result = validateContractAddress(INVALID_CONTRACTS.invalidChars2);
const result = validateContractAddress(INVALID_CONTRACTS.invalidCharacterI);
expect(result.valid).toBe(false);
expect(result.code).toBe('CONTRACT_ADDRESS_INVALID_CHARSET');
});

it('rejects address with O (invalid base32)', () => {
const result = validateContractAddress('CBQWI64FZ2NKSJC7D45HJZVVMQZ3T7KHXOJSLZPZ5LHKQM7OFWVGNQST');
const result = validateContractAddress(INVALID_CONTRACTS.invalidCharacterO);
expect(result.valid).toBe(false);
expect(result.code).toBe('CONTRACT_ADDRESS_INVALID_CHARSET');
});
Expand All @@ -154,7 +138,7 @@ describe('validateContractAddress', () => {
it('rejects address with incorrect version byte (0x11 instead of 0x10)', () => {
// This address has a version byte of 0x11 instead of 0x10 (CONTRACT type)
// but passes all other strkey checks (length, prefix, charset, checksum)
const malformedAddr = generateContractWithVersionByte(0x11);
const malformedAddr = INVALID_CONTRACT_ADDRESSES.wrongVersionByte;
const result = validateContractAddress(malformedAddr);
expect(result.valid).toBe(false);
expect(result.code).toBe('CONTRACT_ADDRESS_INVALID_VERSION_BYTE');
Expand All @@ -164,6 +148,11 @@ describe('validateContractAddress', () => {
const result = validateContractAddress(VALID_TESTNET_CONTRACTS.usdcContract);
expect(result.valid).toBe(true);
});

it('rejects an address with a valid format but invalid checksum', () => {
const result = validateContractAddress(INVALID_CONTRACT_ADDRESSES.invalidChecksum);
expect(result).toMatchObject({ valid: false, code: 'CONTRACT_ADDRESS_INVALID_CHECKSUM' });
});
});
});

Expand Down
37 changes: 36 additions & 1 deletion apps/backend/src/services/job-queue.service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,17 @@ function makeSupabaseMock(
const jobs: JobRecord[] = [...jobRows];
const dlq: DLQRecord[] = [...dlqRows];

const mockRpc = vi.fn((_fn: string, { p_worker_id }: { p_worker_id: string }) => {
const mockRpc = vi.fn((fn: string, args: Record<string, string>) => {
if (fn === 'claim_dlq_reprocess_entry') {
const entry = dlq.find((row) => row.id === args.p_dlq_id);
if (!entry || entry.reprocess_status !== 'pending') {
return Promise.resolve({ data: [], error: null });
}
entry.reprocess_status = 'in_progress';
return Promise.resolve({ data: [entry], error: null });
}

const p_worker_id = args.p_worker_id;
// Mimic atomic claim: find the highest-priority pending scheduled job
const priorityOrder: Record<string, number> = { high: 1, normal: 2, low: 3 };
const candidate = jobs
Expand Down Expand Up @@ -441,6 +451,31 @@ describe('DLQ reprocessing', () => {
expect(supabase._jobs.some((j: JobRecord) => j.job_type === 'deployment')).toBe(true);
});

it('allows only one concurrent reprocess claim for a DLQ entry', async () => {
const dlqEntry: DLQRecord = {
id: 'dlq-concurrent',
original_job_id: 'job-dead-concurrent',
job_type: 'deployment',
priority: 'high',
payload: { userId: 'u1' },
failure_reason: 'network error',
attempts: 3,
reprocess_status: 'pending',
reprocessed_at: null,
created_at: new Date().toISOString(),
};
const supabase = makeSupabaseMock([], [dlqEntry]);
vi.mocked(createClient).mockReturnValue(supabase as any);

const outcomes = await Promise.allSettled([
new JobQueueService(1).reprocessDLQEntry(dlqEntry.id),
new JobQueueService(1).reprocessDLQEntry(dlqEntry.id),
]);

expect(outcomes.filter((outcome) => outcome.status === 'fulfilled')).toHaveLength(1);
expect(supabase._jobs.filter((job) => job.job_type === 'deployment')).toHaveLength(1);
});

it('throws when trying to reprocess an already-reprocessed entry', async () => {
const dlqEntry: DLQRecord = {
id: 'dlq-2',
Expand Down
25 changes: 14 additions & 11 deletions apps/backend/src/services/job-queue.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -239,7 +239,7 @@ export class JobQueueService {

/**
* Reprocess a dead-letter entry by re-enqueuing the original payload.
* Guards against double-reprocessing with an in-memory set.
* Claims the entry atomically in the database before re-enqueueing.
* Only marks the entry as succeeded after enqueue actually resolves.
*/
async reprocessDLQEntry(dlqId: string): Promise<EnqueueResult> {
Expand All @@ -249,19 +249,22 @@ export class JobQueueService {
throw new Error(`DLQ entry ${dlqId} is already being reprocessed`);
}

// Load the DLQ entry
const { data: entry, error: fetchError } = await supabase
.from('job_dlq')
.select('*')
.eq('id', dlqId)
.single();
const { data, error: claimError } = await supabase
.rpc('claim_dlq_reprocess_entry', { p_dlq_id: dlqId });

if (fetchError || !entry) {
throw new Error(`DLQ entry not found: ${dlqId}`);
if (claimError) {
throw new Error(`Failed to claim DLQ entry ${dlqId}: ${claimError.message}`);
}

if (entry.reprocess_status !== 'pending') {
throw new Error(`DLQ entry ${dlqId} has already been reprocessed (status: ${entry.reprocess_status})`);
const entry = (Array.isArray(data) ? data[0] : data) as DLQRecord | null;
if (!entry) {
const { data: existing, error: fetchError } = await supabase
.from('job_dlq')
.select('*')
.eq('id', dlqId)
.single();
if (fetchError || !existing) throw new Error(`DLQ entry not found: ${dlqId}`);
throw new Error(`DLQ entry ${dlqId} has already been reprocessed (status: ${existing.reprocess_status})`);
}

this._reprocessingDlqIds.add(dlqId);
Expand Down
38 changes: 38 additions & 0 deletions apps/backend/src/services/template-generator.service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -253,6 +253,44 @@ describe('TemplateGeneratorService.generate — error paths', () => {
});
});

describe('TemplateGeneratorService — Stellar network mismatch parity', () => {
const stellarTemplates = [
{ id: 'stellar-dex', category: 'dex' },
{ id: 'soroban-defi', category: 'lending' },
{ id: 'payment-gateway', category: 'payment' },
] as const;

it('rejects the same mismatched network configuration for every Stellar template', async () => {
const mismatchedCustomization = {
...validCustomization,
stellar: {
...validCustomization.stellar,
network: 'mainnet' as const,
horizonUrl: 'https://horizon-testnet.stellar.org',
},
};

const results = await Promise.all(stellarTemplates.map(async ({ id, category }) => {
const service = makeService({
getTemplate: vi.fn().mockResolvedValue({ ...mockTemplate, id, category }),
});
return {
templateId: id,
result: await service.generate({ ...validRequest, templateId: id, customization: mismatchedCustomization }),
};
}));

const expectedError = results[0].result.errors[0];
for (const { templateId, result } of results) {
expect(result.success, `${templateId} accepted a mismatched Stellar network`).toBe(false);
expect(result.errors[0], `${templateId} returned a different network-mismatch error`)
.toEqual(expectedError);
expect(result.errors[0].file).toBe('stellar.horizonUrl');
expect(result.errors[0].message).toContain('Horizon URL points to testnet');
}
});
});

// ── Happy path ────────────────────────────────────────────────────────────────

describe('TemplateGeneratorService.generate — happy path', () => {
Expand Down
1 change: 1 addition & 0 deletions apps/backend/vitest.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ export default defineConfig({
alias: {
'@': path.resolve(__dirname, './src'),
'@craft/types': path.resolve(__dirname, '../../packages/types/src'),
'@craft/stellar': path.resolve(__dirname, '../../packages/stellar/src'),
},
},
});
36 changes: 33 additions & 3 deletions apps/frontend/src/lib/stellar/contract-validation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,18 +5,22 @@ import {
validateContractAddresses,
type ContractValidationResult,
} from './contract-validation';
import {
INVALID_CONTRACT_ADDRESSES,
VALID_CONTRACT_ADDRESSES,
} from '@craft/stellar';
import { encodeContractAddress } from './strkey-test-utils';

// ── Valid Contract Addresses ─────────────────────────────────────────────────

// Real strkey-encoded contract IDs (valid CRC-16 checksum, version byte 0x10).
const VALID_TESTNET_CONTRACTS = {
usdcContract: 'CADQOBYHA4DQOBYHA4DQOBYHA4DQOBYHA4DQOBYHA4DQOBYHA4DQP5KR',
nativeTokenContract: 'CAAQQDYWDUSCWMRZIBDU4VK4MNVHC6D7Q2GZJG5CVGYLPPWFZTJ5U2RQ',
usdcContract: VALID_CONTRACT_ADDRESSES.testnetUsdc,
nativeTokenContract: VALID_CONTRACT_ADDRESSES.testnetNativeToken,
};

const VALID_MAINNET_CONTRACTS = {
someContract: 'CAAQQDYWDUSCWMRZIBDU4VK4MNVHC6D7Q2GZJG5CVGYLPPWFZTJ5U2RQ',
someContract: VALID_CONTRACT_ADDRESSES.mainnetExample,
};

// ── Invalid Contract Addresses ───────────────────────────────────────────────
Expand Down Expand Up @@ -153,6 +157,18 @@ describe('validateContractAddress', () => {
expect(result.code).toBe('CONTRACT_ADDRESS_INVALID_CHARSET');
});

it('rejects address with I (invalid base32)', () => {
const result = validateContractAddress(INVALID_CONTRACTS.invalidCharacterI);
expect(result.valid).toBe(false);
expect(result.code).toBe('CONTRACT_ADDRESS_INVALID_CHARSET');
});

it('rejects address with O (invalid base32)', () => {
const result = validateContractAddress(INVALID_CONTRACTS.invalidCharacterO);
expect(result.valid).toBe(false);
expect(result.code).toBe('CONTRACT_ADDRESS_INVALID_CHARSET');
});

it('rejects address with 1 (not in base32 alphabet)', () => {
const result = validateContractAddress(INVALID_CONTRACTS.invalidChars2);
expect(result.valid).toBe(false);
Expand All @@ -164,6 +180,20 @@ describe('validateContractAddress', () => {
expect(result.valid).toBe(false);
expect(result.code).toBe('CONTRACT_ADDRESS_INVALID_CHARSET');
});
expect(result.valid).toBe(false);
expect(result.code).toBe('CONTRACT_ADDRESS_INVALID_CHARSET');
});

it('rejects address with 1 (not in base32 alphabet)', () => {
const result = validateContractAddress(INVALID_CONTRACTS.invalidChars2);
expect(result.valid).toBe(false);
expect(result.code).toBe('CONTRACT_ADDRESS_INVALID_CHARSET');
});

it('rejects address with a valid format but invalid checksum', () => {
const result = validateContractAddress(INVALID_CONTRACTS.invalidChecksum);
expect(result).toMatchObject({ valid: false, code: 'CONTRACT_ADDRESS_INVALID_CHECKSUM' });
});
});
});

Expand Down
1 change: 1 addition & 0 deletions apps/frontend/vitest.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ export default defineConfig({
'@': path.resolve(__dirname, './src'),
'@craft/types': path.resolve(__dirname, '../../packages/types/src'),
'@craft/stellar': path.resolve(__dirname, '../../packages/stellar/src'),
'@craft/stellar': path.resolve(__dirname, '../../packages/stellar/src'),
},
},
});
2 changes: 2 additions & 0 deletions docs/rls-audit.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ Integration tests verify RLS enforcement across every protected table. Coverage

**Test file**: `supabase/tests/rls/policy-verification.test.ts`

**Migration drift guard**: The test reads `supabase/tests/rls/rls-migration-hashes.json` and checks the SHA-256 of each migration mirrored by the in-process predicates. When changing a tracked RLS migration, manually compare every affected `USING` and `WITH CHECK` expression with the corresponding predicate and update the mirror and its tests first. Then refresh that migration's hash in the manifest; the test will fail with the migration name and a stale-mirror warning until both sides are reviewed.

**Test categories**:
1. **Service-role bypass** — Verifies service_role skips all policies (all 8 tables)
2. **Cross-table isolation** — Ensures users cannot access other users' data via indirect joins
Expand Down
21 changes: 21 additions & 0 deletions packages/stellar/src/fixtures/strkey-addresses.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
/**
* Shared contract-address fixtures for backend and frontend validation tests.
* Add cases here, grouped by the validation property they exercise, rather
* than duplicating literals in individual test suites.
*/
export const VALID_CONTRACT_ADDRESSES = {
testnetUsdc: 'CBQWI64FZ2NKSJC7D45HJZVVMQZ3T7KHXOJSLZPZ5LHKQM7FFWVGNQST',
testnetNativeToken: 'CATPNZ2SJRSVZJBWXGFSMZQHQ47JM5PXNQRVJLGHGHVKPZ2OVH3FHPAA',
mainnetExample: 'CATHQD7JDJFQ4WVQXVJDAJX4CSJM3XDYPRMHMV35FVPVLCZDWJYC5WDA',
} as const;

export const INVALID_CONTRACT_ADDRESSES = {
tooShort: 'CBQWI64FZ2NKSJC7D45HJZVVMQZ3T7KHXOJSLZPZ5LHK',
tooLong: 'CBQWI64FZ2NKSJC7D45HJZVVMQZ3T7KHXOJSLZPZ5LHKQM7FFWVGNQSTX',
wrongPrefix: 'GBQWI64FZ2NKSJC7D45HJZVVMQZ3T7KHXOJSLZPZ5LHKQM7FFWVGNQST',
invalidCharacters: 'CBQWI64FZ2NKSJC7D45HJZVVMQZ3T7KHXOJSLZPZ5LHKQM7-FWVGNQST',
invalidCharacterI: 'CBQWI64FZ2NKSJC7D45HJZVVMQZ3T7KHXOJSLZPZ5LHKQM7FFWVGNQSI',
invalidCharacterO: 'CBQWI64FZ2NKSJC7D45HJZVVMQZ3T7KHXOJSLZPZ5LHKQM7OFWVGNQST',
invalidChecksum: 'CBQWI64FZ2NKSJC7D45HJZVVMQZ3T7KHXOJSLZPZ5LHKQM7FFWVGNQSU',
wrongVersionByte: 'CCQWI64FZ2NKSJC7D45HJZVVMQZ3T7KHXOJSLZPZ5LHKQM7FFWVGNQSP',
} as const;
1 change: 1 addition & 0 deletions packages/stellar/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ export * from './account-merge-protection';
export * from './asset-compliance';
export * from './circuit-breaker';
export * from './horizon-client';
export * from './fixtures/strkey-addresses';
export * from './abi-binding-generator';
export * from './asset-auth';
export * from './contract-state-snapshot';
Expand Down
19 changes: 19 additions & 0 deletions supabase/migrations/022_atomic_dlq_reprocess_claim.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
-- Atomically claim one pending dead-letter entry for reprocessing.
-- A database-side claim prevents workers on separate app instances from
-- enqueueing the same DLQ entry concurrently.

CREATE OR REPLACE FUNCTION claim_dlq_reprocess_entry(p_dlq_id UUID)
RETURNS SETOF job_dlq
LANGUAGE sql
SECURITY DEFINER
SET search_path = public
AS $$
UPDATE job_dlq
SET reprocess_status = 'in_progress'
WHERE id = p_dlq_id
AND reprocess_status = 'pending'
RETURNING *;
$$;

REVOKE ALL ON FUNCTION claim_dlq_reprocess_entry(UUID) FROM PUBLIC, anon, authenticated;
GRANT EXECUTE ON FUNCTION claim_dlq_reprocess_entry(UUID) TO service_role;
14 changes: 14 additions & 0 deletions supabase/tests/rls/policy-verification.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,11 @@
* mirroring the SQL USING / WITH CHECK expressions from migration 002.
*/

import { createHash } from 'node:crypto';
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { describe, it, expect } from 'vitest';
import trackedMigrations from './rls-migration-hashes.json';

// ── Types ─────────────────────────────────────────────────────────────────────

Expand Down Expand Up @@ -113,6 +117,16 @@ const policy = {
deployment_updates_all: (row: Row, uid: Uid) => uid !== null && uid === row.user_id,
};

describe('RLS predicate mirror migration drift', () => {
it.each(trackedMigrations)('keeps %s synchronized with its mirror', ({ migration, sha256 }) => {
const path = resolve(__dirname, '../../migrations', migration);
const actual = createHash('sha256').update(readFileSync(path)).digest('hex');

expect(actual, `${migration} changed; the RLS mirror may be stale. Review the SQL and update the mirror and this manifest.`)
.toBe(sha256);
});
});

// ── 1. Service-role bypass ────────────────────────────────────────────────────

describe('RLS: service_role bypass', () => {
Expand Down
Loading
Loading