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
38 changes: 36 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -148,7 +148,7 @@ jobs:
- name: Test
if: needs.changes.outputs.frontend == 'true'
# --json writes a machine-readable result file used by the flake reporter
run: pnpm test -- --json --outputFile=jest-results.json || true
run: pnpm test --json --outputFile=jest-results.json || true

- name: Detect flake candidates (frontend)
if: needs.changes.outputs.frontend == 'true'
Expand Down Expand Up @@ -305,6 +305,29 @@ jobs:
name: Backend Required Gate
runs-on: ubuntu-latest
needs: [changes, validate-quarantine]
services:
postgres:
image: postgres:16
env:
POSTGRES_USER: test
POSTGRES_PASSWORD: test
POSTGRES_DB: test
ports:
- 5432:5432
options: >-
--health-cmd "pg_isready -U test -d test"
--health-interval 10s
--health-timeout 5s
--health-retries 5
redis:
image: redis:7-alpine
ports:
- 6379:6379
options: >-
--health-cmd "redis-cli ping"
--health-interval 10s
--health-timeout 5s
--health-retries 5
defaults:
run:
working-directory: backend
Expand All @@ -325,17 +348,28 @@ jobs:
- name: Install deps
run: pnpm install --frozen-lockfile

- name: Apply database migrations
run: pnpm exec prisma migrate deploy

- name: Build
if: needs.changes.outputs.backend == 'true'
run: pnpm build

- name: Prepare backend test database
if: needs.changes.outputs.backend == 'true'
env:
DATABASE_URL: postgresql://test:test@localhost:5432/test
run: pnpm exec prisma db push

- name: Test
if: needs.changes.outputs.backend == 'true'
env:
NODE_ENV: test
JWT_SECRET: test-jwt-secret-value-with-minimum-length-32
DATABASE_URL: postgresql://test:test@localhost:5432/test
REDIS_URL: redis://localhost:6379
# --json writes a machine-readable result file used by the flake reporter
run: pnpm test -- --json --outputFile=jest-results.json || true
run: pnpm exec jest --config jest.config.js --forceExit --detectOpenHandles --json --outputFile=jest-results.json || true

- name: Detect flake candidates (backend)
if: needs.changes.outputs.backend == 'true'
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/money-math-parity.yml
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ jobs:

- name: TypeScript parity tests
working-directory: frontend
run: pnpm jest --config jest.config.ts --testPathPattern='shared-test-fixtures.*money_math_parity' --verbose
run: pnpm exec jest --config jest.money-math.config.ts --runInBand --verbose

- name: Parity check summary
run: |
Expand Down
9 changes: 5 additions & 4 deletions .github/workflows/nightly-e2e-lifecycle.yml
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ on:
- cron: "0 2 * * *"

env:
STELLAR_NETWORK_PASSPASSPHRASE: "Test SDF Network ; September 2015"
STELLAR_NETWORK_PASSPHRASE: "Test SDF Network ; September 2015"
STELLAR_RPC_URL: "https://soroban-testnet.stellar.org"
STELLAR_HORIZON_URL: "https://horizon-testnet.stellar.org"
NODE_ENV: test
Expand Down Expand Up @@ -64,7 +64,7 @@ jobs:
env:
NODE_ENV: test
JWT_SECRET: test-jwt-secret-value-with-minimum-length-32
run: pnpm test -- --forceExit --detectOpenHandles
run: pnpm test --forceExit --detectOpenHandles

# ── Contract Unit Tests ─────────────────────────────────────────────
- name: Run contract tests
Expand All @@ -83,6 +83,7 @@ jobs:
NEXT_PUBLIC_API_URL: http://localhost:4001
run: |
set +e
set -o pipefail
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo " Nightly E2E Trade Lifecycle — Stellar Testnet"
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
Expand All @@ -94,8 +95,8 @@ jobs:
echo ""

# Run the lifecycle integration tests
pnpm test -- --forceExit --detectOpenHandles \
--testPathPattern='e2e.*lifecycle|lifecycle.*e2e' \
pnpm exec playwright test tests/e2e --project=e2e-chromium \
--grep='lifecycle' \
--verbose 2>&1 | tee /tmp/e2e-output.log

EXIT_CODE=$?
Expand Down
24 changes: 24 additions & 0 deletions .github/workflows/staging.yml
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,30 @@ jobs:
./scripts/staging-up.sh
timeout-minutes: 10

- name: Start backend service
working-directory: backend
run: |
set -a
source ../.env.staging
set +a
export DATABASE_URL="${STAGING_DATABASE_URL:-postgresql://postgres:staging-password@localhost:5434/amana_staging}"
export REDIS_URL="${STAGING_REDIS_URL:-redis://localhost:6380}"
export JWT_SECRET="${JWT_SECRET:-ci-staging-jwt-secret-placeholder-minimum-32}"
export AMANA_ESCROW_CONTRACT_ID="${AMANA_ESCROW_CONTRACT_ID:-CCY3G5O6M7K8N9P0Q1R2S3T4U5V6W7X8Y9Z0A1B2C3}"
export USDC_CONTRACT_ID="${USDC_CONTRACT_ID:-test-usdc-contract}"
export ADMIN_SECRET_KEY="${ADMIN_SECRET_KEY:-test-admin-secret-key-value}"
nohup npm run dev > /tmp/amana-backend.log 2>&1 &
echo $! > /tmp/amana-backend.pid
for attempt in $(seq 1 30); do
if curl -fsS http://localhost:4000/health/live >/dev/null; then
echo "Backend is ready"
exit 0
fi
sleep 2
done
cat /tmp/amana-backend.log
exit 1

- name: Validate staging deployment
run: ./scripts/staging-validate.sh
timeout-minutes: 5
Expand Down
13 changes: 7 additions & 6 deletions backend/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

27 changes: 16 additions & 11 deletions backend/prisma/seed.staging.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,8 @@ export async function main(): Promise<void> {
await prisma.deliveryManifest.create({
data: {
tradeId: createdTrades[2].tradeId,
driverName: 'John Doe',
driverIdNumber: 'DRV-12345',
driverNameHash: sha256('John Doe'),
driverIdHash: sha256('DRV-12345'),
vehicleRegistration: 'ABC-001',
Expand All @@ -73,8 +75,8 @@ export async function main(): Promise<void> {

// ── TradeEvidence (2) ───────────────────────────────────────────────────────
await Promise.all([
prisma.tradeEvidence.create({ data: { tradeId: createdTrades[3].tradeId, cid: 'bafybeiabc123stagingdelivery', mimeType: 'video/mp4', uploadedBy: buyer1.walletAddress } }),
prisma.tradeEvidence.create({ data: { tradeId: createdTrades[6].tradeId, cid: 'bafybeiabc456stagingdispute', mimeType: 'image/jpeg', uploadedBy: buyer2.walletAddress } }),
prisma.tradeEvidence.create({ data: { tradeId: createdTrades[3].tradeId, cid: 'bafybeiabc123stagingdelivery', filename: 'delivery.mp4', mimeType: 'video/mp4', uploadedBy: buyer1.walletAddress } }),
prisma.tradeEvidence.create({ data: { tradeId: createdTrades[6].tradeId, cid: 'bafybeiabc456stagingdispute', filename: 'dispute.jpg', mimeType: 'image/jpeg', uploadedBy: buyer2.walletAddress } }),
]);

// ── ProcessedEvents (10) ────────────────────────────────────────────────────
Expand All @@ -95,18 +97,21 @@ export async function main(): Promise<void> {
const vault2 = await prisma.vault.create({ data: { vaultId: 'vault-staging-002', ownerAddress: buyer2.walletAddress, balanceUsdc: '2000.00' } });

// ── Goals (4, covering all 3 statuses) ─────────────────────────────────────
const goalDeadline = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000);
await Promise.all([
prisma.goal.create({ data: { goalId: 'goal-staging-001', vaultId: vault1.vaultId, targetAmountUsdc: '500.00', currentAmountUsdc: '200.00', status: GoalStatus.ACTIVE } }),
prisma.goal.create({ data: { goalId: 'goal-staging-002', vaultId: vault1.vaultId, targetAmountUsdc: '300.00', currentAmountUsdc: '300.00', status: GoalStatus.COMPLETED } }),
prisma.goal.create({ data: { goalId: 'goal-staging-003', vaultId: vault2.vaultId, targetAmountUsdc: '1000.00', currentAmountUsdc: '750.00', status: GoalStatus.ACTIVE } }),
prisma.goal.create({ data: { goalId: 'goal-staging-004', vaultId: vault2.vaultId, targetAmountUsdc: '200.00', currentAmountUsdc: '0.00', status: GoalStatus.CANCELLED } }),
prisma.goal.create({ data: { goalId: 'goal-staging-001', vaultId: vault1.vaultId, userId: buyer1.id, targetAmountUsdc: '500.00', currentAmountUsdc: '200.00', deadline: goalDeadline, status: GoalStatus.ACTIVE } }),
prisma.goal.create({ data: { goalId: 'goal-staging-002', vaultId: vault1.vaultId, userId: buyer1.id, targetAmountUsdc: '300.00', currentAmountUsdc: '300.00', deadline: goalDeadline, status: GoalStatus.COMPLETED } }),
prisma.goal.create({ data: { goalId: 'goal-staging-003', vaultId: vault2.vaultId, userId: buyer2.id, targetAmountUsdc: '1000.00', currentAmountUsdc: '750.00', deadline: goalDeadline, status: GoalStatus.ACTIVE } }),
prisma.goal.create({ data: { goalId: 'goal-staging-004', vaultId: vault2.vaultId, userId: buyer2.id, targetAmountUsdc: '200.00', currentAmountUsdc: '0.00', deadline: goalDeadline, status: GoalStatus.CANCELLED } }),
]);

await prisma.$disconnect();
}

main().catch((e) => {
console.error(e);
prisma.$disconnect();
process.exit(1);
});
if (require.main === module) {
main().catch((e) => {
console.error(e);
prisma.$disconnect();
process.exit(1);
});
}
3 changes: 3 additions & 0 deletions backend/scripts/clawback.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,9 @@ export function parseArgs(argv: string[]): ClawbackArgs {
const key = arg.slice(2);
const next = argv[i + 1];
if (!next || next.startsWith("--")) {
if (key === "stream-id") {
throw new Error("--stream-id is required and must not be empty.");
}
throw new Error(`Flag --${key} requires a value.`);
}
args[key] = next;
Expand Down
31 changes: 19 additions & 12 deletions backend/scripts/validate-env-examples.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,9 +28,8 @@ process.env.AMANA_ESCROW_CONTRACT_ID = process.env.AMANA_ESCROW_CONTRACT_ID ?? '
process.env.USDC_CONTRACT_ID = process.env.USDC_CONTRACT_ID ?? 'test-usdc-contract';
process.env.ADMIN_SECRET_KEY = process.env.ADMIN_SECRET_KEY ?? 'test-admin-secret-key-value';

const { envSchema, SECRET_ENV_KEYS, getEnvSpecificIssues } = await import(
'../src/config/env'
);
type EnvModule = typeof import('../src/config/env');
let envModule: EnvModule;

const exampleFiles = [
'.env.example',
Expand All @@ -53,7 +52,12 @@ function parseEnvFile(filePath: string): Record<string, string> {
return map;
}

type ZodShape = {
safeParse: (value: unknown) => { success: boolean };
};

function validateExamples(): void {
const { envSchema, SECRET_ENV_KEYS, getEnvSpecificIssues } = envModule;
const schemaKeys = new Set(Object.keys(envSchema.shape));
let hasError = false;

Expand All @@ -78,11 +82,9 @@ function validateExamples(): void {
// 2. Every REQUIRED (no-default, non-optional) schema key must be
// documented in each example. Optional keys may be omitted.
for (const name of schemaKeys) {
const shape = (envSchema.shape as Record<string, unknown>)[name];
const isDefaulted =
typeof (shape as any)?._def?.defaultValue !== undefined;
const isOptional = (shape as any)?._def?.typeName === 'ZodOptional';
if (!isDefaulted && !isOptional && !fileKeys.has(name)) {
const shape = (envSchema.shape as Record<string, unknown>)[name] as ZodShape;
const isOptionalOrDefaulted = shape.safeParse(undefined).success;
if (!isOptionalOrDefaulted && !fileKeys.has(name)) {
console.error(`❌ [${file}] Missing required env var: ${name}`);
hasError = true;
}
Expand All @@ -93,9 +95,9 @@ function validateExamples(): void {
if (SECRET_ENV_KEYS.has(key)) continue;
const value = map[key];
if (value === '') continue; // placeholder / intentionally unset
const shape = (envSchema.shape as Record<string, unknown>)[key];
if (!shape || typeof (shape as any).safeParse !== 'function') continue;
const res = (shape as { safeParse(v: unknown): { success: boolean } }).safeParse(value);
const shape = (envSchema.shape as Record<string, unknown>)[key] as ZodShape;
if (!shape?.safeParse) continue;
const res = shape.safeParse(value);
if (!res.success) {
console.error(`❌ [${file}] Invalid value for ${key}="${value}"`);
hasError = true;
Expand Down Expand Up @@ -123,4 +125,9 @@ function validateExamples(): void {
process.exit(0);
}

void validateExamples();
async function main(): Promise<void> {
envModule = await import('../src/config/env');
validateExamples();
}

void main();
4 changes: 3 additions & 1 deletion backend/src/__tests__/admin.audit.routes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -126,7 +126,9 @@ describe("Admin Audit Routes", () => {
.set("Authorization", `Bearer ${nonAdminToken}`);

expect(res.status).toBe(403);
expect(res.body).toEqual({ error: "Forbidden: admin access required" });
expect(res.body).toEqual(
expect.objectContaining({ error: "Forbidden: admin access required" }),
);
expect(mockAdminAuditService.list).not.toHaveBeenCalled();
});
});
Expand Down
2 changes: 2 additions & 0 deletions backend/src/__tests__/admin.auth.routes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,8 @@ describe("GET /api/admin/auth/claims", () => {
expiresAt: new Date(expiresAtSeconds * 1000).toISOString(),
issuer: process.env.JWT_ISSUER ?? null,
audience: process.env.JWT_AUDIENCE ?? null,
tier: null,
deviceBound: false,
});
});

Expand Down
Loading
Loading