nigig-org/.forgejo/workflows/pay-domain.yml
andodeki 775eec4424
Some checks failed
Payment domain, storage, platform and UI / isolated-payment-tests (push) Waiting to run
Payment domain, storage, platform and UI / payment-ui-tests (push) Waiting to run
repo hygiene / hygiene (push) Has been cancelled
fix(pay): remove PIN capture from default builds rather than hiding it (0.3)
Asked in review: "does the latest code have the PIN input field?" It did.

## Hiding was weaker than it looked

pin_input, form_pin and the reveal toggle were all present, and the default
build hid them at runtime in on_after_new. Three problems:

1. The DSL declared the control visible and Rust hid it afterwards, so
   anything re-applying the UI definition — a hot reload, a re-instantiated
   sheet — brought it back. Defect B11 already names this class.
2. Hidden is not absent. The TextInput stayed in the widget tree, and a
   hidden input can still be focused or filled programmatically.
3. form_pin was compiled into every build, so any path reaching it could
   populate it.

Item 0.3 asks for removal, not concealment.

## The field no longer exists without `demo`

form_pin, pin_visible, the eye-toggle handler, the text-input handler, both
PIN checks, the request construction and try_build_ussd_request are all
#[cfg(feature = "demo")]. A default build has no field to write to.

The DSL now declares visible: false on the PIN input and its reveal button,
so hidden is the default state rather than a runtime correction; demo
unhides them on init. That closes the reload path in (1).

One runtime call remains as belt-and-braces: if a hot-reloaded definition
surfaces the input, the non-demo arm wipes what was typed. It has no
form_pin to clear, because there isn't one.

## Proving absence rather than asserting it

A grep for form_pin proves nothing — it passes just as happily against a
field still present behind a runtime if. tools/check-no-pin-capture.sh is a
compile probe: it references the field outside any cfg block and requires
the default build to fail with "no field `form_pin`" while demo succeeds.
The script restores the file on every exit path.

Verified both directions: passes on current code, exits 1 when the field is
re-exposed un-gated.

## Validation

  cargo check -p {nigig-pay-ui,nigig-pay,nigig-mpesa,nigig-core}  pass
  cargo check -p {nigig-pay,nigig-mpesa} --features demo          pass
  nigig-pay-ui: cargo test --lib                                  pass (61)
  domain / storage / platform / mpesa harness                     pass
  no-PIN-capture guard: verified to fail on a re-exposed field    pass

## What 0.3 still leaves open

The Java-side KEY_PIN scrubbing was already done, so 0.3 is complete for the
default product. A demo build still captures a PIN by design — that path
exists for authorised device testing and is covered by the unresolved
Play-policy decision in ADR 0007. If that decision goes against the USSD
rail, the demo path and its PIN capture are deleted with it.
2026-08-02 07:31:44 +00:00

270 lines
12 KiB
YAML

name: Payment domain, storage, platform and UI
on:
push:
paths:
- 'crates/nigig-pay-domain/**'
- 'crates/nigig-pay-storage/**'
- 'crates/nigig-pay-platform/**'
- 'crates/robius-ussd/**'
- 'crates/apps/nigig-pay-ui/**'
- 'tools/makepad-native-libs.sh'
- 'tools/test-rust-clean.sh'
- 'tools/test-mpesa-store-clean.sh'
- 'crates/nigig-core/src/persistence/mpesa/**'
- 'rust-toolchain.toml'
- 'deny.toml'
- '.forgejo/workflows/pay-domain.yml'
pull_request:
paths:
- 'crates/nigig-pay-domain/**'
- 'crates/nigig-pay-storage/**'
- 'crates/nigig-pay-platform/**'
- 'crates/robius-ussd/**'
- 'crates/apps/nigig-pay-ui/**'
- 'tools/makepad-native-libs.sh'
- 'tools/test-rust-clean.sh'
- 'tools/test-mpesa-store-clean.sh'
- 'crates/nigig-core/src/persistence/mpesa/**'
- 'rust-toolchain.toml'
- 'deny.toml'
- '.forgejo/workflows/pay-domain.yml'
jobs:
isolated-payment-tests:
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- uses: actions/checkout@v4
# Each step installs its own throwaway toolchain at the version declared
# in rust-toolchain.toml, runs tests with --locked against the checked-in
# lockfile, then checks formatting, Clippy (-D warnings) and benchmarks.
- name: Domain tests, fmt, clippy and benches
run: TEST_TARGET=domain ./tools/test-rust-clean.sh
# The storage target additionally exercises the opt-in SQLCipher
# encryption-at-rest path.
- name: Storage tests, fmt, clippy and SQLCipher
run: TEST_TARGET=storage ./tools/test-rust-clean.sh
# The platform crate is the seam between the domain and Android. This
# target additionally compiles the optional USSD adapter for the host
# and asserts that the mock gateway cannot be built into a release.
- name: Platform gateway tests, fmt, clippy and mock-in-release guard
run: TEST_TARGET=platform ./tools/test-rust-clean.sh
# nigig-core is not a workspace member (it needs git-hosted Makepad and
# Robius crates), but its parser/classifier/store logic is pure and can
# be verified standalone. This covers defects B1 and B4.
- name: M-Pesa parser, classifier and store tests
run: ./tools/test-mpesa-store-clean.sh
- name: Reject whitespace errors
run: git diff --check
# Build governance: lockfiles must be present and current. A lockfile
# that changes during CI means the committed one was stale.
- name: Lockfiles must be committed and unchanged
run: |
set -e
for crate in nigig-pay-domain nigig-pay-storage nigig-pay-platform; do
test -f "crates/$crate/Cargo.lock" \
|| { echo "missing crates/$crate/Cargo.lock"; exit 1; }
done
git diff --exit-code -- '**/Cargo.lock'
# Dependency audit, licence and source checks (review item 1.2).
# Scoped to the crates whose dependency graph fully resolves standalone.
- name: Dependency audit, licences, bans and sources
run: |
set -e
version=0.18.6
url="https://github.com/EmbarkStudios/cargo-deny/releases/download"
curl -sSLf -o /tmp/cargo-deny.tar.gz \
"$url/$version/cargo-deny-$version-x86_64-unknown-linux-musl.tar.gz"
tar xzf /tmp/cargo-deny.tar.gz -C /tmp
install -m 0755 \
"/tmp/cargo-deny-$version-x86_64-unknown-linux-musl/cargo-deny" \
/usr/local/bin/cargo-deny
for crate in nigig-pay-domain nigig-pay-storage nigig-pay-platform; do
echo "== cargo-deny: $crate =="
cargo-deny --manifest-path "crates/$crate/Cargo.toml" \
--config deny.toml check
done
# Boundary enforcement (review item 1.4): the payment domain and storage
# crates must never depend on the UI framework. This checks real
# dependencies and imports, not prose in doc comments.
- name: Payment crates must not depend on Makepad
run: |
set -e
for crate in nigig-pay-domain nigig-pay-storage nigig-pay-platform; do
if grep -n -i 'makepad' "crates/$crate/Cargo.toml"; then
echo "Makepad dependency declared in $crate"
exit 1
fi
if grep -rn --include='*.rs' -E '^[[:space:]]*(use|extern crate)[[:space:]]+makepad' \
"crates/$crate"; then
echo "Makepad imported in $crate"
exit 1
fi
done
echo 'no Makepad dependency in payment domain, storage or platform'
# Review item 5.1: JNI and platform SDK calls belong in the platform
# crate's adapters. The domain and storage crates must stay portable.
- name: Domain and storage must not reach the platform SDK
run: |
set -e
for crate in nigig-pay-domain nigig-pay-storage; do
if grep -rn --include='*.rs' -E '^[[:space:]]*use[[:space:]]+(jni|robius_ussd|robius_sms|robius_fingerprinting)' \
"crates/$crate"; then
echo "platform SDK imported in $crate"
exit 1
fi
done
echo 'no platform SDK imports in payment domain or storage'
# Review item 5.4 / 0.5. The guard is a compile_error!, so the check is
# that the build genuinely fails. A passing build here means a release
# artifact could fabricate payment confirmations.
- name: Mock gateway must not compile into a release build
run: |
set -e
if cargo build --manifest-path crates/nigig-pay-platform/Cargo.toml \
--locked --release --features mock >/dev/null 2>&1; then
echo 'the mock gateway compiled into a release build'
exit 1
fi
echo 'mock gateway is correctly refused in release builds'
# Review item 7.3: the payment UI crate's tests must run in CI rather than
# being skipped. They were previously unrunnable here because the Makepad
# link step needs native libraries; `tools/makepad-native-libs.sh` now
# installs exactly the set required (including ALSA/PulseAudio/OpenSSL,
# which are needed to *link* a test binary even though `cargo check`
# succeeds without them).
payment-ui-tests:
runs-on: ubuntu-latest
timeout-minutes: 45
steps:
- uses: actions/checkout@v4
- name: Install Makepad native libraries
run: ./tools/makepad-native-libs.sh --install
- name: Install the declared toolchain
run: |
set -e
version="$(sed -n 's/^[[:space:]]*channel[[:space:]]*=[[:space:]]*"\([^"]*\)".*/\1/p' \
rust-toolchain.toml | head -n 1)"
curl --fail --location --proto '=https' --tlsv1.2 https://sh.rustup.rs -o /tmp/rustup-init
chmod 700 /tmp/rustup-init
/tmp/rustup-init -y --profile minimal --default-toolchain "$version" --no-modify-path
echo "$HOME/.cargo/bin" >> "$GITHUB_PATH"
# The pure payment crates are gated far more strictly elsewhere in this
# workflow. Here the goal is that the widget-layer logic — fee
# breakdowns, CSV parsing, money formatting — is actually exercised.
- name: Payment UI unit tests
run: cargo test -p nigig-pay-ui --lib
# Review item 6.1 / Phase 6 exit criterion. A settlement tick may only
# follow a provider confirmation, so the payment status vocabulary is
# owned by `nigig_pay_domain::FlowStage` and must not be hand-written
# beside an untrusted signal. This catches the specific regression that
# shipped before: "✓ SMS received" and "✓ N/N sent".
- name: Payment status must not claim settlement from untrusted signals
run: |
set -e
sheet=crates/apps/nigig-pay-ui/src/shared_pay_sheet.rs
if grep -n '✓.*\(SMS\|sent\|Sent\|received\)' "$sheet"; then
echo 'a settlement tick is being claimed from an untrusted signal'
exit 1
fi
for marker in ObservedEvidence NeedsReconciliation; do
if grep -n "PayFlowEvent::$marker" -A 6 "$sheet" | grep -q '✓'; then
echo "PayFlowEvent::$marker renders a settlement tick"
exit 1
fi
done
echo 'no settlement claims from untrusted signals'
# Review items 6.6 / U7. Batch progress must be derived from
# `PaymentBatch`, which records one outcome per item, rather than from a
# bare counter incremented at each match arm. The unbounded version let
# a single item with two terminal USSD events report "3/2".
- name: Batch progress must not be an unbounded counter
run: |
set -e
handler=crates/apps/nigig-pay-ui/src/pay_flow/pay_flow_handler.rs
if grep -n '^[^/]*bulk_done[[:space:]]*+=' "$handler"; then
echo 'bulk_done is being incremented directly; use settle_bulk_item()'
exit 1
fi
echo 'batch progress is derived, not counted'
# Review defect S2, the fail-open biometric. On Android
# `robius_fingerprinting::authenticate` returns Ok(()) as soon as the
# prompt is *shown*, not when the user answers. Dispatch must therefore
# be gated on an AuthorizationAttempt grant, never on that early Ok.
- name: USSD dispatch must be gated on an authorization grant
run: |
set -e
handler=crates/apps/nigig-pay-ui/src/pay_flow/pay_flow_handler.rs
if ! grep -q 'attempt.consume()' "$handler"; then
echo 'dispatch_ussd no longer consumes an authorization grant'
exit 1
fi
if ! grep -q 'AuthorizationSignal::Succeeded' "$handler"; then
echo 'no explicit success signal feeds the authorization attempt'
exit 1
fi
echo 'dispatch is gated on an explicit authorization grant'
# The demo gate must be reachable by the obvious command. Without a
# forwarding feature on the app crates, `cargo run -p nigig-pay
# --features demo` fails with "does not contain this feature", and the
# only way in is the non-obvious `--features nigig-pay-ui/demo`. That
# made the intended tracker-only message look like a broken build.
- name: The demo feature must be reachable from the app crates
run: |
set -e
for app in nigig-pay nigig-mpesa; do
grep -q 'demo = \["nigig-pay-ui/demo"\]' "crates/apps/$app/Cargo.toml" \
|| { echo "$app does not forward the demo feature"; exit 1; }
done
cargo check -p nigig-pay --features demo
cargo check -p nigig-mpesa --features demo
echo 'demo feature is reachable from both app crates'
# A gated feature must explain the product boundary, not the binary.
# "unavailable in this build" was reported as a bug three times: it
# reads like something is broken and gives the user no next step.
# Checks displayed strings only, so the explanatory comments stay legal.
- name: Gated features must not report themselves as build errors
run: |
set -e
if grep -rn --include='*.rs' 'set_status(.*unavailable in this build' crates/; then
echo 'a user-facing string describes the build instead of the product'
exit 1
fi
echo 'gated features explain the product boundary'
# Review item 0.3: remove custom PIN capture from the default product.
# `form_pin` must not exist unless `demo` is enabled, so a stale UI
# definition, a hot reload, or a merely-hidden widget cannot populate
# it. Asserted by compiling a reference to the field and requiring the
# default build to reject it.
- name: The default build must not contain a PIN field
run: ./tools/check-no-pin-capture.sh
# Phase 4's draw_walk fixes live in these two crates. Compiling them is
# what makes that work verifiable rather than merely written.
- name: Payment app crates must compile
run: |
set -e
cargo check -p nigig-pay-ui
cargo check -p nigig-pay
cargo check -p nigig-mpesa