Skip to content
Open
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
58 changes: 49 additions & 9 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -23,13 +23,13 @@ jobs:
steps:
- uses: actions/checkout@v7
with:
fetch-depth: 0 # Full history needed for Gitleaks commit scanning
fetch-depth: 0 # Full history needed for Gitleaks commit scanning

- name: Run Gitleaks secret scan
uses: gitleaks/gitleaks-action@v2
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GITLEAKS_LICENSE: ${{ secrets.GITLEAKS_LICENSE }} # Optional: for org-level scanning
GITLEAKS_LICENSE: ${{ secrets.GITLEAKS_LICENSE }} # Optional: for org-level scanning
with:
args: >
--redact
Expand Down Expand Up @@ -270,6 +270,20 @@ jobs:
KEYCLOAK_CLIENT_ID: pos-shell-app
PLATFORM_BASE_URL: https://platform.staging.54link.io
PLATFORM_API_KEY: ci-smoke-platform-key
# Required by server env validation in NODE_ENV=production (CI test values).
CRON_SECRET: ci-smoke-cron-secret-32-characters-x
INTERNAL_API_KEY: ci-smoke-internal-api-key-32-chars-
TX_SIGNING_SECRET: ci-smoke-tx-signing-secret-32-chars
KEYCLOAK_CLIENT_SECRET: ci-smoke-keycloak-client-secret-32-
PLATFORM_SERVICE_TOKEN: ci-smoke-platform-service-token-32
MINIO_SECRET_KEY: ci-smoke-minio-secret-key-32-chars-
MINIO_ACCESS_KEY: ci-smoke-minio-access-key-32-chars-
APISIX_ADMIN_KEY: ci-smoke-apisix-admin-key-32-chars-
TERMII_API_KEY: ci-smoke-termii-api-key-32-chars-xx
FLUVIO_API_KEY: ci-smoke-fluvio-api-key-32-chars-xx
MQTT_PASSWORD: ci-smoke-mqtt-password-32-chars-xxx
# Security sidecars are not deployed in CI — fail open.
SECURITY_FAIL_OPEN: "true"
services:
postgres:
image: postgres:16-alpine
Expand Down Expand Up @@ -306,6 +320,9 @@ jobs:
run: pnpm db:push
continue-on-error: true

- name: Seed smoke test data (agents/admin/supervisor fixtures)
run: node scripts/seed-e2e.mjs

- name: Start server in background
run: |
node dist/index.js &
Expand Down Expand Up @@ -375,6 +392,26 @@ jobs:
BASE_URL: http://localhost:3000
TEST_ADMIN_EMAIL: admin@54link.ng
TEST_ADMIN_PASS: TestAdmin123!
# Required by server env validation in NODE_ENV=production (CI test values).
CRON_SECRET: ci-e2e-cron-secret-32-characters-xx
INTERNAL_API_KEY: ci-e2e-internal-api-key-32-chars-xx
TX_SIGNING_SECRET: ci-e2e-tx-signing-secret-32-chars-x
KEYCLOAK_CLIENT_SECRET: ci-e2e-keycloak-client-secret-32-ch
PLATFORM_API_KEY: ci-e2e-platform-api-key-32-chars-xx
PLATFORM_SERVICE_TOKEN: ci-e2e-platform-service-token-32-ch
MINIO_SECRET_KEY: ci-e2e-minio-secret-key-32-chars-xx
MINIO_ACCESS_KEY: ci-e2e-minio-access-key-32-chars-xx
APISIX_ADMIN_KEY: ci-e2e-apisix-admin-key-32-chars-xx
TERMII_API_KEY: ci-e2e-termii-api-key-32-chars-xxxx
FLUVIO_API_KEY: ci-e2e-fluvio-api-key-32-chars-xxxx
MQTT_PASSWORD: ci-e2e-mqtt-password-32-chars-xxxxx
# Security sidecars (Rust DDoS / Go PBAC / Python Fraud ML) are not
# deployed in CI — fail open so requests are not blocked by their absence.
SECURITY_FAIL_OPEN: "true"
# The app drives every request from a single IP under Playwright, which
# trips the in-process connection throttle and per-IP rate limiters
# (503/429). Disable them for the E2E run; production keeps them on.
DISABLE_RATE_LIMITS: "true"
services:
postgres:
image: postgres:16-alpine
Expand Down Expand Up @@ -414,6 +451,9 @@ jobs:
run: pnpm db:push
continue-on-error: true

- name: Seed E2E test data (agents/admin/supervisor fixtures)
run: node scripts/seed-e2e.mjs

- name: Start server in background
run: |
node dist/index.js &
Expand Down Expand Up @@ -833,26 +873,26 @@ jobs:
- name: Run OWASP ZAP Baseline Scan
uses: zaproxy/action-baseline@v0.15.0
with:
target: 'http://localhost:3000'
rules_file_name: '.zap/rules.tsv'
target: "http://localhost:3000"
rules_file_name: ".zap/rules.tsv"
cmd_options: >
-a
-j
-l WARN
-I
fail_action: true
allow_issue_writing: true
artifact_name: 'zap-baseline-report'
artifact_name: "zap-baseline-report"
continue-on-error: false

- name: Run OWASP ZAP API Scan
uses: zaproxy/action-api-scan@v0.10.0
with:
target: 'http://localhost:3000/api/trpc'
target: "http://localhost:3000/api/trpc"
format: openapi
rules_file_name: '.zap/rules.tsv'
cmd_options: '-a -j -l WARN'
artifact_name: 'zap-api-report'
rules_file_name: ".zap/rules.tsv"
cmd_options: "-a -j -l WARN"
artifact_name: "zap-api-report"
continue-on-error: true

- name: Upload ZAP reports
Expand Down
12 changes: 5 additions & 7 deletions .github/workflows/codeql.yml
Original file line number Diff line number Diff line change
Expand Up @@ -170,13 +170,11 @@ jobs:
with:
python-version: "3.12"

- name: Install Python dependencies
run: |
pip install --upgrade pip
for req in services/python/*/requirements.txt; do
pip install -r "$req" 2>/dev/null || true
done
continue-on-error: true
# NOTE: CodeQL uses buildless extraction for Python, so installing
# dependencies is not required for analysis. Installing every service's
# requirements.txt into one environment exhausted the runner disk
# (~80 MB left) and intermittently crashed the analyze step, so it is
# omitted intentionally.

- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v3
Expand Down
14 changes: 14 additions & 0 deletions .github/workflows/e2e-playwright.yml
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,10 @@ jobs:
VITE_FRONTEND_FORGE_API_KEY: e2e-frontend-forge-key
VITE_FRONTEND_FORGE_API_URL: http://localhost:3001/api
NODE_ENV: test
# The app drives every request from a single IP under Playwright, which
# trips the in-process connection throttle and per-IP rate limiters
# (503/429). Disable them for the E2E run; production keeps them on.
DISABLE_RATE_LIMITS: "true"

steps:
- name: Checkout repository
Expand Down Expand Up @@ -97,6 +101,16 @@ jobs:
env:
POSTGRES_URL: ${{ env.POSTGRES_URL }}

# The specs authenticate as fixed fixtures (AGT001/1234, ADMIN1/0000,
# SUP001/9999). seed-comprehensive only generates randomized AGT#### rows
# with no PIN hash, so add the deterministic login fixtures the E2E flow
# logs in with. Idempotent upsert by agentCode.
- name: Seed E2E login fixtures (AGT001/ADMIN1/SUP001)
run: node scripts/seed-e2e.mjs
env:
POSTGRES_URL: ${{ env.POSTGRES_URL }}
DATABASE_URL: ${{ env.DATABASE_URL }}

- name: Build application
run: pnpm build

Expand Down
24 changes: 4 additions & 20 deletions .github/workflows/security-scan.yml
Original file line number Diff line number Diff line change
Expand Up @@ -59,26 +59,10 @@ jobs:
with:
sarif_file: trivy-results.sarif

codeql:
name: CodeQL Analysis
runs-on: ubuntu-latest
timeout-minutes: 20
strategy:
matrix:
language: [javascript-typescript]
steps:
- uses: actions/checkout@v7

- name: Initialize CodeQL
uses: github/codeql-action/init@v3
with:
languages: ${{ matrix.language }}

- name: Autobuild
uses: github/codeql-action/autobuild@v3

- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v3
# CodeQL analysis is owned by the dedicated codeql.yml workflow (JS/TS, Go,
# Python). A duplicate job here uploaded a second SARIF for the same
# javascript-typescript category, causing a category collision that failed
# the "CodeQL Analysis" check. Removed to keep a single authoritative run.

secret-scan:
name: Secret Detection
Expand Down
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -146,3 +146,7 @@ services/python/ml-pipeline/models/weights/*.json
services/python/ml-pipeline/models/lakehouse/
services/python/ml-pipeline/models/registry/
/data/

# Playwright E2E artifacts
test-results/
playwright-report/
17 changes: 13 additions & 4 deletions client/src/pages/AgentLogin.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useState } from "react";
import { useState, useRef } from "react";
import { trpc } from "../lib/trpc";
import { usePosStore } from "../store/posStore";
import { toast } from "sonner";
Expand All @@ -17,6 +17,7 @@ type Screen = "code" | "pin" | "forgot_phone" | "forgot_newpin";

export default function AgentLogin() {
const [agentCode, setAgentCode] = useState("");
const agentCodeRef = useRef("");
const [pin, setPin] = useState("");
const [step, setStep] = useState<Screen>("code");
const [loading, setLoading] = useState(false);
Expand Down Expand Up @@ -45,7 +46,10 @@ export default function AgentLogin() {
});

const handleCodeSubmit = () => {
if (agentCode.trim().length < 3) {
// Read from the ref so the latest keyed value is used even if the React
// state update from the input's onChange has not committed yet.
const code = (agentCodeRef.current || agentCode).trim();
if (code.length < 3) {
toast.error("Enter a valid agent code");
return;
}
Expand All @@ -63,7 +67,7 @@ export default function AgentLogin() {
if (newPin.length === 4) {
setLoading(true);
loginMutation.mutate({
agentCode: agentCode.trim().toUpperCase(),
agentCode: (agentCodeRef.current || agentCode).trim().toUpperCase(),
pin: newPin,
});
}
Expand Down Expand Up @@ -200,7 +204,11 @@ export default function AgentLogin() {
<input
type="text"
value={agentCode}
onChange={e => setAgentCode(e.target.value.toUpperCase())}
onChange={e => {
const v = e.target.value.toUpperCase();
agentCodeRef.current = v;
setAgentCode(v);
}}
onKeyDown={e => e.key === "Enter" && handleCodeSubmit()}
placeholder="e.g. AGT001"
className="w-full rounded-2xl px-4 py-4 text-white text-lg font-bold outline-none mb-4"
Expand Down Expand Up @@ -316,6 +324,7 @@ export default function AgentLogin() {
{PAD_KEYS.map((key, idx) => (
<button
key={idx}
data-digit={key && key !== "DEL" ? key : undefined}
onClick={() => key && handlePinKey(key)}
disabled={loading || !key}
className="h-16 rounded-2xl font-bold text-xl transition-all active:scale-90 disabled:opacity-0"
Expand Down
6 changes: 4 additions & 2 deletions e2e/01-agent-login-cashin.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,11 +26,13 @@ test.describe("Agent Login and Cash-In Flow", () => {
}

// ── 4. Wait for POS Shell dashboard ─────────────────────────────────────
await expect(page.locator("text=Cash In")).toBeVisible({ timeout: 15_000 });
await expect(page.locator("text=Cash In").first()).toBeVisible({
timeout: 15_000,
});

// ── 5. Navigate to Cash In ───────────────────────────────────────────────
await page.locator("text=Cash In").first().click();
await expect(page.locator("text=Cash In")).toBeVisible();
await expect(page.locator("text=Cash In").first()).toBeVisible();

// ── 6. Enter amount ──────────────────────────────────────────────────────
const amountInput = page
Expand Down
4 changes: 3 additions & 1 deletion e2e/02-admin-fraud-alert.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,9 @@ test.describe("Admin Panel Fraud Alert Flow", () => {
for (const digit of ["1", "2", "3", "4"]) {
await page.locator(`button[data-digit="${digit}"]`).first().click();
}
await expect(page.locator("text=Cash In")).toBeVisible({ timeout: 15_000 });
await expect(page.locator("text=Cash In").first()).toBeVisible({
timeout: 15_000,
});
});

test("should navigate to admin panel and update fraud alert status", async ({
Expand Down
4 changes: 3 additions & 1 deletion e2e/03-float-topup-approval.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,9 @@ test.describe("Float Top-Up Approval Workflow", () => {
for (const digit of ["1", "2", "3", "4"]) {
await page.locator(`button[data-digit="${digit}"]`).first().click();
}
await expect(page.locator("text=Cash In")).toBeVisible({ timeout: 15_000 });
await expect(page.locator("text=Cash In").first()).toBeVisible({
timeout: 15_000,
});

// Navigate to admin panel
await page.goto("/admin");
Expand Down
4 changes: 3 additions & 1 deletion e2e/04-sim-orchestrator-coverage.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,9 @@ test.describe("SIM Orchestrator Coverage Map", () => {
for (const digit of ["1", "2", "3", "4"]) {
await page.locator(`button[data-digit="${digit}"]`).first().click();
}
await expect(page.locator("text=Cash In")).toBeVisible({ timeout: 15_000 });
await expect(page.locator("text=Cash In").first()).toBeVisible({
timeout: 15_000,
});

// Navigate to admin panel
await page.goto("/admin");
Expand Down
8 changes: 6 additions & 2 deletions e2e/05-offline-queue-sync.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,9 @@ test.describe("Offline Queue and Auto-Sync", () => {
for (const digit of ["1", "2", "3", "4"]) {
await page.locator(`button[data-digit="${digit}"]`).first().click();
}
await expect(page.locator("text=Cash In")).toBeVisible({ timeout: 15_000 });
await expect(page.locator("text=Cash In").first()).toBeVisible({
timeout: 15_000,
});

// ── Simulate offline ─────────────────────────────────────────────────────
await context.setOffline(true);
Expand All @@ -44,6 +46,8 @@ test.describe("Offline Queue and Auto-Sync", () => {
// This is a soft assertion — sync may happen in background
const isVisible = await onlineIndicator.isVisible().catch(() => false);
// Even if toast is not visible, the page should still be functional
await expect(page.locator("text=Cash In")).toBeVisible({ timeout: 5_000 });
await expect(page.locator("text=Cash In").first()).toBeVisible({
timeout: 5_000,
});
});
});
8 changes: 4 additions & 4 deletions e2e/08-agent-login-cashin-full.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,9 @@ test.describe("Agent Login & Cash-In — Full Flow", () => {
).toBeVisible();

// Verify SSO link exists
await expect(page.locator("text=/supervisor|admin.*sso/i")).toBeVisible();
await expect(
page.locator("text=/supervisor|admin.*sso/i").first()
).toBeVisible();
});

test("should reject empty agent code", async ({ page }) => {
Expand All @@ -50,9 +52,7 @@ test.describe("Agent Login & Cash-In — Full Flow", () => {

// Should show PIN pad or PIN entry screen
await expect(
page
.locator('[data-digit], input[type="password"], text=/enter.*pin/i')
.first()
page.locator('[data-digit], input[type="password"]').first()
).toBeVisible({ timeout: 10_000 });
});

Expand Down
Loading
Loading