diff --git a/apps/backend/src/lib/stellar/contract-validation.test.ts b/apps/backend/src/lib/stellar/contract-validation.test.ts index 9923f319..891a6fd3 100644 --- a/apps/backend/src/lib/stellar/contract-validation.test.ts +++ b/apps/backend/src/lib/stellar/contract-validation.test.ts @@ -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 ───────────────────────────────────── @@ -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'); }); @@ -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'); @@ -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' }); + }); }); }); diff --git a/apps/backend/src/services/job-queue.service.test.ts b/apps/backend/src/services/job-queue.service.test.ts index c6198ee3..3dadbcce 100644 --- a/apps/backend/src/services/job-queue.service.test.ts +++ b/apps/backend/src/services/job-queue.service.test.ts @@ -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) => { + 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 = { high: 1, normal: 2, low: 3 }; const candidate = jobs @@ -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', diff --git a/apps/backend/src/services/job-queue.service.ts b/apps/backend/src/services/job-queue.service.ts index 3a7a99f6..743c55e0 100644 --- a/apps/backend/src/services/job-queue.service.ts +++ b/apps/backend/src/services/job-queue.service.ts @@ -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 { @@ -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); diff --git a/apps/backend/src/services/template-generator.service.test.ts b/apps/backend/src/services/template-generator.service.test.ts index e71d629c..d6af43f2 100644 --- a/apps/backend/src/services/template-generator.service.test.ts +++ b/apps/backend/src/services/template-generator.service.test.ts @@ -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', () => { diff --git a/apps/backend/vitest.config.ts b/apps/backend/vitest.config.ts index f2a5a59a..2689295f 100644 --- a/apps/backend/vitest.config.ts +++ b/apps/backend/vitest.config.ts @@ -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'), }, }, }); diff --git a/apps/frontend/src/lib/stellar/contract-validation.test.ts b/apps/frontend/src/lib/stellar/contract-validation.test.ts index e074a008..2c66794c 100644 --- a/apps/frontend/src/lib/stellar/contract-validation.test.ts +++ b/apps/frontend/src/lib/stellar/contract-validation.test.ts @@ -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 ─────────────────────────────────────────────── @@ -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); @@ -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' }); + }); }); }); diff --git a/apps/frontend/vitest.config.ts b/apps/frontend/vitest.config.ts index 18e20cf5..ddc65cce 100644 --- a/apps/frontend/vitest.config.ts +++ b/apps/frontend/vitest.config.ts @@ -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'), }, }, }); diff --git a/docs/rls-audit.md b/docs/rls-audit.md index 56657104..16a32420 100644 --- a/docs/rls-audit.md +++ b/docs/rls-audit.md @@ -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 diff --git a/packages/stellar/src/fixtures/strkey-addresses.ts b/packages/stellar/src/fixtures/strkey-addresses.ts new file mode 100644 index 00000000..c235012c --- /dev/null +++ b/packages/stellar/src/fixtures/strkey-addresses.ts @@ -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; \ No newline at end of file diff --git a/packages/stellar/src/index.ts b/packages/stellar/src/index.ts index 011693f0..000333a1 100644 --- a/packages/stellar/src/index.ts +++ b/packages/stellar/src/index.ts @@ -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'; diff --git a/supabase/migrations/022_atomic_dlq_reprocess_claim.sql b/supabase/migrations/022_atomic_dlq_reprocess_claim.sql new file mode 100644 index 00000000..f8539f07 --- /dev/null +++ b/supabase/migrations/022_atomic_dlq_reprocess_claim.sql @@ -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; \ No newline at end of file diff --git a/supabase/tests/rls/policy-verification.test.ts b/supabase/tests/rls/policy-verification.test.ts index 92d934ca..a65af105 100644 --- a/supabase/tests/rls/policy-verification.test.ts +++ b/supabase/tests/rls/policy-verification.test.ts @@ -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 ───────────────────────────────────────────────────────────────────── @@ -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', () => { diff --git a/supabase/tests/rls/rls-migration-hashes.json b/supabase/tests/rls/rls-migration-hashes.json new file mode 100644 index 00000000..7a5931ad --- /dev/null +++ b/supabase/tests/rls/rls-migration-hashes.json @@ -0,0 +1,26 @@ +[ + { + "migration": "002_row_level_security.sql", + "sha256": "89350bc72c17db192e9cc78d9d76739744089bf4ed85889d94fa96f461b9b6d6" + }, + { + "migration": "005_create_deployment_logs.sql", + "sha256": "5bff10539ceb78f8ce9be7a66edd3c8b6b149eb8455c060969423d13a57619da" + }, + { + "migration": "008_github_vercel_deployments.sql", + "sha256": "df2f4a6b97d4c13a2cd2db710e429c5894fb2fa8ce4568929d29bd4c44653e13" + }, + { + "migration": "009_deployment_updates_rollout.sql", + "sha256": "1063d70dec537851d43969101e02a1c392eb3acfe92c8c4132e6344aedc9f741" + }, + { + "migration": "010_auth_audit_logs_cross_region.sql", + "sha256": "1f83b9228ea94180982a34c016ac2328233e03d41152eb02f2942efac2de4c57" + }, + { + "migration": "019_fix_auth_audit_logs_rls.sql", + "sha256": "4c415169d23f7cb981e2c69765e3c9f489b72fcec56da674669a26e67163b9e4" + } +] \ No newline at end of file