From 28b7eb1bb723331252e99b9b7d8fad46234fff74 Mon Sep 17 00:00:00 2001 From: "m.salis" Date: Sun, 26 Jul 2026 07:56:11 +0700 Subject: [PATCH 01/73] docs: design merchant-first CLI experience --- ...26-07-26-merchant-cli-experience-design.md | 537 ++++++++++++++++++ 1 file changed, 537 insertions(+) create mode 100644 docs/superpowers/specs/2026-07-26-merchant-cli-experience-design.md diff --git a/docs/superpowers/specs/2026-07-26-merchant-cli-experience-design.md b/docs/superpowers/specs/2026-07-26-merchant-cli-experience-design.md new file mode 100644 index 0000000..c8d811c --- /dev/null +++ b/docs/superpowers/specs/2026-07-26-merchant-cli-experience-design.md @@ -0,0 +1,537 @@ +# Midtrans CLI Merchant Experience and Project Discovery Design + +- **Status:** Review requested +- **Date:** 2026-07-26 +- **Product:** Midtrans CLI +- **Binary:** `midtrans` +- **Primary audience:** Midtrans merchants integrating with AI coding agents +- **Scope:** Phase 1 sandbox experience + +## 1. Executive summary + +Midtrans CLI is installed once as a standalone executable, then detects or +initializes configuration independently in each merchant repository. + +The current Phase 1 implementation exposes machine-oriented commands and +renders successful results as labels such as `PASS: doctor`. This is useful as +an agent protocol but not as a merchant product. The revised CLI leads with +merchant jobs: initialize a project, set up Sandbox, understand readiness, test +checkout and webhooks, and verify the complete integration. + +Stable JSON contracts remain available for Midtrans Agent Skills and other AI +agents. Machine-facing discovery and inspection move under an explicit +`midtrans agent` namespace so they do not define the merchant experience. + +## 2. Problem + +The current implementation has four connected usability problems: + +1. The locally installed executable is a symlink to a Go development build + instead of an independent installed artifact. +2. The default `--project-dir .` treats the current directory literally, so a + command run inside a nested source directory cannot find the project + manifest. +3. Commands such as `capabilities`, `credentials status`, and `doctor` expose + implementation concepts rather than merchant jobs. +4. The default human renderer prints status, findings, and next actions but + discards useful command data, capabilities, packs, journeys, and successful + checks. + +The combined result is a CLI that can satisfy an agent contract while telling a +merchant almost nothing. + +## 3. Goals + +1. Install one no-`sudo`, runtime-independent executable for the current user. +2. Detect the correct merchant project when invoked from any directory inside + it. +3. Make the default command surface understandable without Midtrans or CLI + implementation knowledge. +4. Show what was inspected, what passed, what is missing, and what to do next. +5. Preserve stable, redacted JSON contracts for Midtrans Agent Skills. +6. Keep all Phase 1 operations structurally limited to Midtrans Sandbox. +7. Preserve explicit review before mutating sandbox operations. + +## 4. Non-goals + +This design does not add: + +- Production Midtrans execution. +- A generic Midtrans API console. +- Transaction operations or payment operations for Midtrans employees. +- Merchant application code generation. +- Silent shell-profile modification. +- Credential values in project manifests or command output. +- Webhook tunneling, remote log streaming, or arbitrary event triggers in this + iteration. +- The final hosted installer before signed release artifacts and the official + first-party installer domain are available. + +## 5. Design principles + +### 5.1 Merchant jobs lead + +The main help surface uses words merchants recognize: setup, status, test, and +verify. Internal capability negotiation and repository inspection do not lead +the product. + +### 5.2 Human and agent surfaces share truth + +Human output and JSON output are views of the same command result. The human +view may summarize and format the data, but it must not compute a different +verdict. + +### 5.3 Success must be informative + +A successful command must identify the state or proof that succeeded. A bare +`PASS` line is not sufficient. + +### 5.4 Project state is local + +The executable and user-level update metadata are global to the current user. +The manifest, operation ledger, temporary files, and evidence remain under the +merchant repository's `.midtrans/` directory. + +### 5.5 Explicit overrides win + +Automatic discovery improves the default path. It never overrides an explicit +`--project-dir`. + +## 6. Installation model + +### 6.1 Local development installation + +During development, build a standalone binary and copy it atomically to: + +```text +~/.local/bin/midtrans +``` + +The installed file must not be a symlink to the source repository or Go +workspace. It must execute without the Go toolchain and from directories +unrelated to the CLI repository. + +If `~/.local/bin` is not on `PATH`, installation reports the exact export line +the user can add. Development installation does not silently edit a shell +profile. + +### 6.2 Future public bootstrap installer + +The intended public experience is: + +```sh +curl -fsSL https://cli.midtrans.com/install.sh | bash +``` + +The final hostname is subject to normal Midtrans domain and security approval. +The installer will: + +1. Require HTTPS and TLS 1.2 or newer. +2. Detect supported operating system and architecture. +3. Select a versioned release, with an explicit version override available. +4. Download the binary archive, checksums, and signing provenance to a bounded + temporary directory. +5. Verify the signing identity and archive checksum before extraction. +6. Refuse unsupported platforms, unsigned artifacts, checksum mismatches, + redirects to unapproved hosts, and empty or malformed responses. +7. Install atomically to `${MIDTRANS_INSTALL_DIR:-$HOME/.local/bin}`. +8. Preserve the previous working binary until the new binary passes + `midtrans version` and `midtrans agent capabilities --json`. +9. Restore the previous binary if post-install verification fails. +10. Never require `sudo` by default. + +The installer prints a manual `PATH` instruction when necessary and supports a +non-default system installation mode later. It does not silently modify shell +profiles. + +## 7. Project discovery + +### 7.1 Project modes + +Commands declare one of three project modes: + +- **Projectless:** no repository is required. +- **Existing project:** a `.midtrans/manifest.yaml` must be discovered. +- **Initializable project:** an existing manifest is preferred; otherwise a + safe initialization root is selected. + +### 7.2 Explicit project directory + +When `--project-dir ` is supplied: + +- The path is authoritative. +- It is normalized and safety-checked. +- The CLI does not search parent directories. +- Existing-project commands require the manifest at that exact root. +- `init` initializes that exact root. + +### 7.3 Existing-project discovery + +Without `--project-dir`, existing-project commands: + +1. Start at the current working directory. +2. Search upward for the nearest `.midtrans/manifest.yaml`. +3. Stop at the filesystem root. +4. Use the nearest match, including when repositories are nested. +5. Resolve and validate paths using the existing safe-path boundary before + reading or writing. + +If no manifest is found, return a structured `PROJECT_NOT_INITIALIZED` result +with `midtrans init` as the next action. Do not collapse discovery failures into +`USAGE_INVALID`. + +### 7.4 Initialization-root discovery + +Without `--project-dir`, `midtrans init`: + +1. Searches upward for an existing `.midtrans/manifest.yaml`. +2. If found, reports the existing initialized project without creating nested + configuration. +3. Otherwise selects the nearest Git worktree root. +4. Outside Git, initializes the current working directory. + +Initialization remains exclusive and safe: it must not overwrite an existing +manifest or follow a symlink outside the selected project. + +### 7.5 Command classifications + +Projectless commands include: + +- `midtrans version` +- `midtrans update` +- `midtrans agent capabilities` +- `midtrans agent pack` + +Existing-project commands include: + +- `midtrans status` +- `midtrans setup` after initialization +- `midtrans test checkout` +- `midtrans test webhook` +- `midtrans verify` +- agent inspection and checking commands + +`midtrans init` is an initializable-project command. + +## 8. Merchant command surface + +### 8.1 Primary workflow + +```text +midtrans init +midtrans setup +midtrans status +midtrans test checkout --amount 10000 +midtrans test webhook +midtrans verify +``` + +Running `midtrans` without arguments behaves like `midtrans status` when a +project is discovered. Outside a project it shows a short welcome message and +the `midtrans init` next step. + +### 8.2 `midtrans init` + +`init` detects the project root, creates the commit-safe `.midtrans/` files, +and prints: + +- Project name and root. +- Manifest path. +- Selected environment policy. +- Detected Midtrans integration signals. +- The next setup command. + +It does not write merchant application code. + +### 8.3 `midtrans setup` + +`setup` explains and validates the selected Sandbox product configuration: + +- Selected product and checkout mode. +- Required credential references and whether each reference resolves. +- Callback, redirect, and local verification routes. +- Missing merchant-account or Dashboard prerequisites that cannot be inferred. + +It never prints credential values. Interactive setup previews proposed changes +to `.midtrans/manifest.yaml` and writes them only after confirmation. Secret +storage remains outside the manifest. Non-interactive setup never edits the +manifest; agents use explicit flags or edit it through their normal repository +workflow. + +### 8.4 `midtrans status` + +`status` is the default project dashboard. It summarizes: + +- Detected project and manifest. +- Sandbox environment. +- Installed CLI and product-pack versions. +- Selected products and checkout modes. +- Credential-reference readiness. +- Local application reachability when configured. +- Checkout, webhook, state, and reconciliation readiness. +- The highest-priority next action. + +Status does not call a mutating provider API. + +### 8.5 `midtrans test checkout` + +`test checkout` replaces the technical +`sandbox run snap.checkout` merchant workflow. + +Required merchant input is an IDR amount. By default, the CLI generates a +unique provider-only test order reference. A merchant may pass +`--order-id ` when the same reference already exists in the local +application and local verification is intended. The default interactive flow: + +1. Shows the exact Sandbox operation plan. +2. Requests confirmation before the provider mutation. +3. Creates or resumes the Sandbox checkout. +4. Shows or opens the hosted checkout URL when appropriate. +5. Guides the merchant through completion. +6. Reconciles provider status. +7. Runs local verification when the project exposes its verification adapter. + +Provider-only checkout is labeled as a Sandbox provider smoke test and cannot +produce complete integration evidence. Complete verification requires a +merchant-application order reference and the declared local verification +adapter. + +Non-interactive execution retains an explicit execution flag and stable JSON +result so an agent cannot bypass the review boundary. + +### 8.6 `midtrans test webhook` + +`test webhook` runs deterministic local checks for: + +- Valid Midtrans notification signature. +- Settlement application. +- Duplicate delivery idempotency. +- Late pending notification monotonicity. + +The default target is the loopback route declared in the manifest. Remote +targets remain denied unless separately allowlisted by an approved design. + +### 8.7 `midtrans verify` + +`verify` evaluates the complete required journey and produces redacted, +checksummed evidence only when all required local and Sandbox proofs pass. Its +human output identifies each proof and the evidence path. Its JSON output +retains the stable evidence contract. + +## 9. Agent command surface + +Machine-oriented commands move under `midtrans agent`: + +```text +midtrans agent capabilities --json --non-interactive +midtrans agent inspect --json --non-interactive +midtrans agent check --product snap --json --non-interactive +midtrans agent pack list --json --non-interactive +midtrans agent pack info snap --json --non-interactive +``` + +These commands remain public and documented for AI hosts. They are not shown as +the primary merchant workflow. + +The Midtrans Agent Skill compatibility manifest will be updated to call this +namespace. Capability IDs, schema versions, pack IDs, and journey IDs remain +stable unless an explicit contract migration is approved. + +## 10. Compatibility and migration + +Phase 1 has not been publicly released, so the merchant command surface may be +corrected without a long deprecation window. However, local Agent Skill +integration already exists and must migrate in the same change. + +The existing commands remain as hidden compatibility aliases throughout the +first published `v0.1.x` release line and are removed no earlier than `v0.2.0`. +They map as follows: + +| Existing command | New command | +|---|---| +| `midtrans capabilities` | `midtrans agent capabilities` | +| `midtrans inspect` | `midtrans agent inspect` | +| `midtrans doctor` | `midtrans status` for merchants, `midtrans agent check` for agents | +| `midtrans credentials status` | `midtrans setup` or `midtrans status` | +| `midtrans sandbox run snap.checkout` | `midtrans test checkout` | + +Aliases render a concise migration notice in human mode. JSON mode preserves +the old command contract exactly during the `v0.1.x` compatibility window. It +must not silently change the meaning of an existing machine contract. + +## 11. Human output contract + +### 11.1 Required information + +Every merchant command prints: + +1. A reader-facing subject, such as project and environment. +2. Concrete checks or state. +3. Clear status symbols or words with text equivalents. +4. Blocking findings and warnings. +5. One prioritized next action when the journey is incomplete. + +Example: + +```text +Salis Property · Sandbox · Snap + +✓ Project .midtrans/manifest.yaml +✓ Checkout Snap popup +✓ Webhook /api/payment/webhook +✗ Server key MIDTRANS_SERVER_KEY is not available +! Local app http://127.0.0.1:3101 is not running + +Next: + Export your Sandbox Server Key, start the application, then run: + midtrans test checkout --amount 10000 +``` + +Color enhances output only when attached to a terminal and `NO_COLOR` is not +set. Symbols always have textual meaning. JSON output is never colorized. + +### 11.2 Rendering architecture + +The generic result renderer remains responsible for: + +- Redaction before output. +- JSON serialization. +- Consistent findings and next-action formatting. + +Human rendering becomes command-aware through typed presentation models rather +than inspecting arbitrary maps. Each merchant command supplies a bounded view +model containing labels, checks, summaries, and safe references. The renderer +must never dump arbitrary provider payloads or secret-bearing data. + +### 11.3 Status semantics + +- **Ready:** all prerequisites for the requested next operation are present. +- **Needs action:** one or more merchant-correctable prerequisites are missing. +- **Blocked:** safety policy or compatibility prevents execution. +- **Failed:** a performed check disproved an integration requirement. +- **Verified:** all required local and Sandbox proof completed. + +`PASS: ` is not a valid complete human response. + +## 12. Error handling + +Errors are merchant-readable in human mode and stable in JSON mode. + +Required project-discovery errors include: + +- `PROJECT_NOT_INITIALIZED` +- `PROJECT_DIR_NOT_FOUND` +- `PROJECT_MANIFEST_INVALID` +- `PROJECT_PATH_UNSAFE` + +Required setup and testing errors include: + +- `SANDBOX_CREDENTIAL_MISSING` +- `SANDBOX_CREDENTIAL_INVALID` +- `LOCAL_APP_UNREACHABLE` +- `LOCAL_VERIFICATION_ROUTE_INCOMPATIBLE` +- Existing sandbox policy, ambiguous-operation, and evidence failures + +Generic usage output is reserved for malformed CLI syntax. Repository, +credential, and integration failures must not be reported as usage errors. + +## 13. Security and privacy + +- The installer and CLI remain sandbox-only in Phase 1. +- The manifest stores credential references, never credential values. +- Human and JSON output pass through redaction before rendering. +- Project discovery cannot escape an explicit project root or follow unsafe + symlink targets. +- Repository inspection excludes secret-bearing local environment files, + build artifacts, dependency directories, VCS internals, Terraform state, and + other non-source outputs by default. +- Test checkout requires review before mutation. +- Evidence remains bound to manifest hash, pack version, and clean repository + revision. + +## 14. Testing strategy + +Implementation follows test-driven development. + +### 14.1 Installation tests + +- A development installer creates a regular executable, not a symlink. +- The installed executable works without the source repository as its current + directory. +- Installation is atomic and retains the previous binary on failure. +- No-`sudo` is the default. + +### 14.2 Project discovery tests + +- Commands detect a manifest from nested directories. +- The nearest manifest wins in nested projects. +- Explicit `--project-dir` prevents parent search. +- `init` selects the Git root. +- `init` falls back to the current directory outside Git. +- Repeated `init` reports the existing project. +- Searches terminate at filesystem root. +- Symlink escapes and unsafe roots are rejected. +- Discovery failures return project-specific results rather than usage errors. + +### 14.3 Command tests + +- Root invocation routes to status or initialization guidance. +- Each primary merchant command renders concrete state and a next action. +- Successful status and verification output never collapses to a bare `PASS`. +- Agent commands preserve stable JSON schemas and redaction. +- Compatibility aliases have deterministic behavior. +- Interactive mutation requires confirmation. +- Non-interactive mutation requires the explicit execution flag. + +### 14.4 Renderer tests + +- Typed presentation models render all required safe fields. +- Missing optional values do not produce misleading success. +- TTY, non-TTY, and `NO_COLOR` output remain readable. +- JSON output remains unchanged by human formatting. +- Secret-like seeded values never appear in either format. + +### 14.5 Repository spike + +Salis Property remains the first local merchant spike: + +1. Install the standalone CLI globally for the current user. +2. Invoke it from the repository root and nested checkout directories. +3. Initialize or detect `.midtrans/manifest.yaml`. +4. Show actionable status for its Snap and BI-SNAP split without claiming + BI-SNAP capability parity. +5. Run Snap checkout planning and local webhook checks. +6. Add a loopback-only verification adapter without weakening authenticated + production status routes. +7. Complete and verify a real Sandbox Snap journey when credentials and a + clean repository revision are available. + +## 15. Delivery sequence + +1. Add project discovery and project-specific error results. +2. Add typed human presentation models and useful status rendering. +3. Introduce the merchant command surface. +4. Move machine commands to `midtrans agent` with controlled aliases. +5. Update Midtrans Agent Skill compatibility. +6. Harden repository inspection exclusions found during the Salis Property + spike. +7. Build and install a regular no-`sudo` development binary. +8. Re-run the Salis Property spike from root and nested directories. +9. Design and publish the hosted bootstrap installer only after signed release + infrastructure and domain ownership are ready. + +## 16. Acceptance criteria + +This design is complete when: + +1. `midtrans` is a regular executable available on the current user's `PATH`. +2. Running it inside any Salis Property subdirectory detects the project. +3. `midtrans` and `midtrans status` show actionable merchant readiness. +4. `midtrans setup` identifies missing references without exposing values. +5. `midtrans test checkout --amount 10000` presents a reviewable Sandbox plan. +6. `midtrans test webhook` reports the individual verification checks. +7. `midtrans verify` distinguishes incomplete, failed, and verified proof. +8. Agent capability negotiation works through `midtrans agent ...`. +9. No default human command returns only `PASS: `. +10. All tests, safety gates, release checks, and Agent Skill compatibility + checks pass. From 74f159d36117a8fff6226521c01716c12a922eba Mon Sep 17 00:00:00 2001 From: "m.salis" Date: Sun, 26 Jul 2026 08:23:57 +0700 Subject: [PATCH 02/73] docs: plan merchant CLI rollout --- .../2026-07-26-agent-skill-cli-migration.md | 388 +++ .../2026-07-26-merchant-cli-experience.md | 2428 +++++++++++++++++ .../2026-07-26-salis-property-cli-spike.md | 1098 ++++++++ ...26-07-26-merchant-cli-experience-design.md | 2 +- 4 files changed, 3915 insertions(+), 1 deletion(-) create mode 100644 docs/superpowers/plans/2026-07-26-agent-skill-cli-migration.md create mode 100644 docs/superpowers/plans/2026-07-26-merchant-cli-experience.md create mode 100644 docs/superpowers/plans/2026-07-26-salis-property-cli-spike.md diff --git a/docs/superpowers/plans/2026-07-26-agent-skill-cli-migration.md b/docs/superpowers/plans/2026-07-26-agent-skill-cli-migration.md new file mode 100644 index 0000000..8385c89 --- /dev/null +++ b/docs/superpowers/plans/2026-07-26-agent-skill-cli-migration.md @@ -0,0 +1,388 @@ +# Midtrans Agent Skill CLI Namespace Migration Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Migrate the Midtrans Agent Skill to the merchant-first CLI namespace without weakening its compatibility handshake, execution-approval boundary, or evidence requirements. + +**Architecture:** Keep product choice, repository reasoning, and application edits in the Agent Skill. Update its deterministic CLI orchestration reference and evaluation gates to use `midtrans agent ...`, `midtrans test ...`, and merchant status commands while continuing to validate the same schema, capability, journey, and evidence contracts. + +**Tech Stack:** Markdown Agent Skill, JSON compatibility/evaluation contracts, Python official-readiness checker. + +## Target Repository + +```text +/Users/salis/Goto/Code/midtrans/midtrans-agent-skills-cli-integration +``` + +The target worktree is on `codex/midtrans-cli-integration` and already contains +the two reviewed CLI integration commits: + +```text +fddef25 feat(skill): orchestrate Midtrans CLI Snap verification +d0aefed fix(skill): require full CLI compatibility handshake +``` + +## Global Constraints + +- Use the merchant-first CLI only after its compatibility command is installed and verified. +- The Agent Skill continues to own merchant readiness, product routing, repository inspection reasoning, and application code edits. +- The CLI remains optional; missing or incompatible CLI state falls back to guidance-only verification with an explicit evidence limitation. +- Never auto-install or auto-update the CLI from the Agent Skill. +- Never put credential values in commands, chat, source files, or evidence. +- Run checkout without `--execute` first, show the exact plan, and obtain merchant approval before executing. +- Do not describe local-only checks as Sandbox or end-to-end proof. +- Require checksummed evidence and `midtrans verify` before claiming the autonomous journey is verified. +- Preserve result schema `1.0`, manifest schema `1`, evidence schema `1.0`, the four required capability IDs, and the three required journey IDs. +- Bump the Agent Skill patch version from `0.3.2` to `0.3.3` and set the validated date to `2026-07-26`. + +--- + +## File Structure + +### Modified files + +- `integrate-midtrans-payments/references/midtrans-cli.md` — authoritative Agent Skill orchestration sequence. +- `integrate-midtrans-payments/evaluations.json` — pressure scenarios for merchant and agent command separation. +- `integrate-midtrans-payments/cli-compatibility.json` — phase label and unchanged required contracts. +- `integrate-midtrans-payments/SKILL.md` — version/date stamp and merchant-first CLI wording. +- `.well-known/skills/index.json` — catalog version/date. +- `tools/check_official_readiness.py` — exact namespace and safety assertions. +- `README.md` — optional CLI companion command examples and version. + +--- + +### Task 1: Lock the Merchant-First CLI Orchestration Contract + +**Files:** +- Modify: `tools/check_official_readiness.py` +- Modify: `integrate-midtrans-payments/references/midtrans-cli.md` + +**Interfaces:** +- Consumes: installed CLI command surface from the preceding CLI plan. +- Produces: exact documented command sequence and readiness assertions. + +- [ ] **Step 1: Add failing readiness assertions** + +Extend `check_cli_compatibility_reference`: + +```python +def check_cli_compatibility_reference() -> None: + text = CLI_REFERENCE.read_text(encoding="utf-8") + normalized = " ".join(text.split()) + required_fragments = [ + "`midtrans agent capabilities --json --non-interactive`", + "`midtrans agent inspect --json --non-interactive`", + "`midtrans agent check --product snap --json --non-interactive`", + "`midtrans test checkout --amount 10000 --order-id --json --non-interactive`", + "`midtrans test checkout --amount 10000 --order-id --execute --json --non-interactive`", + "`midtrans verify --product snap --evidence --json --non-interactive`", + "Compare every requirement in `../cli-compatibility.json` with the returned result:", + "Do not auto-install", + "local-only proof", + ] + missing = [ + fragment + for fragment in required_fragments + if " ".join(fragment.split()) not in normalized + ] + if missing: + fail("Midtrans CLI reference is incomplete: " + "; ".join(missing)) + legacy = [ + "`midtrans capabilities --json --non-interactive`", + "`midtrans doctor --product snap --json --non-interactive`", + "`midtrans sandbox run snap.checkout", + ] + present_legacy = [fragment for fragment in legacy if fragment in text] + if present_legacy: + fail("Midtrans CLI reference uses legacy commands: " + "; ".join(present_legacy)) + ok("Midtrans CLI merchant and agent orchestration") +``` + +- [ ] **Step 2: Run and verify RED** + +Run: + +```bash +./tools/check_official_readiness.py +``` + +Expected: FAIL because `references/midtrans-cli.md` still documents the legacy +commands. + +- [ ] **Step 3: Replace the orchestration sequence** + +Use this exact structure in `references/midtrans-cli.md`: + +```markdown +## Capability handshake + +Run: + +`midtrans agent capabilities --json --non-interactive` + +Compare every requirement in `../cli-compatibility.json` with the returned +result: `required_result_schema`, `required_manifest_schema`, +`required_evidence_schema`, every ID in `required_capabilities`, and every +journey in `required_journeys`. If the CLI is missing or incompatible, explain +the verified installation/update path and continue guidance-only. Do not +auto-install. + +## Snap edit-and-verify loop + +1. `midtrans init --json --non-interactive` +2. `midtrans agent inspect --json --non-interactive` +3. Complete merchant readiness and edit `.midtrans/manifest.yaml` plus the + merchant application yourself. +4. `midtrans agent check --product snap --json --non-interactive` +5. `midtrans status --json --non-interactive` +6. Create or identify a merchant-application order whose provider reference is + safe for Sandbox verification. +7. `midtrans test checkout --amount 10000 --order-id --json --non-interactive` +8. Show the merchant the returned plan and obtain approval. +9. `midtrans test checkout --amount 10000 --order-id --execute --json --non-interactive` +10. Complete the hosted Sandbox checkout and rerun step 9 when instructed. +11. `midtrans test webhook --amount 10000 --order-id --json --non-interactive` +12. Show the local mutation plan and obtain approval. +13. `midtrans test webhook --amount 10000 --order-id --execute --json --non-interactive` +14. `midtrans verify --product snap --evidence --json --non-interactive` + +Never present provider-only or local-only proof as complete Sandbox +verification. Never copy credentials from CLI environment variables into chat, +source files, manifests, or commands. +``` + +Keep the live `https://docs.midtrans.com/llms.txt` requirement. + +- [ ] **Step 4: Run the readiness checker** + +Run: + +```bash +./tools/check_official_readiness.py +``` + +Expected: PASS for the CLI reference check and all existing local checks. + +- [ ] **Step 5: Commit** + +```bash +git add tools/check_official_readiness.py integrate-midtrans-payments/references/midtrans-cli.md +git commit -m "docs(skill): migrate to merchant-first Midtrans CLI" +``` + +--- + +### Task 2: Update CLI Pressure Scenarios and Compatibility Metadata + +**Files:** +- Modify: `integrate-midtrans-payments/evaluations.json` +- Modify: `integrate-midtrans-payments/cli-compatibility.json` +- Modify: `tools/check_official_readiness.py` + +**Interfaces:** +- Consumes: existing `cli-compatible-snap-verification` and + `cli-missing-or-incompatible` scenarios. +- Produces: evaluation expectations for the agent namespace and merchant + commands. + +- [ ] **Step 1: Add failing evaluation assertions** + +Add to the readiness checker: + +```python +def check_cli_evaluations() -> None: + evaluations = load_json(EVALUATIONS) + if not isinstance(evaluations, dict): + fail("evaluations must be a JSON object") + scenarios = { + item.get("id"): item + for item in evaluations.get("evaluations", []) + if isinstance(item, dict) + } + compatible = json.dumps( + scenarios.get("cli-compatible-snap-verification", {}), + sort_keys=True, + ) + required = [ + "midtrans agent capabilities", + "midtrans test checkout", + "without --execute first", + "midtrans verify", + "merchant-facing status", + ] + missing = [value for value in required if value not in compatible] + if missing: + fail("compatible CLI evaluation is incomplete: " + ", ".join(missing)) + incompatible = json.dumps( + scenarios.get("cli-missing-or-incompatible", {}), + sort_keys=True, + ) + for required_text in [ + "Does not invent CLI commands", + "Does not auto-install", + "guidance-only", + "evidence limitation", + ]: + if required_text not in incompatible: + fail("incompatible CLI evaluation is missing: " + required_text) + ok("Midtrans CLI evaluation scenarios") +``` + +Call `check_cli_evaluations()` after +`check_cli_compatibility_reference()`. + +- [ ] **Step 2: Run and verify RED** + +Run: + +```bash +./tools/check_official_readiness.py +``` + +Expected: FAIL because the compatible scenario still requires the old command. + +- [ ] **Step 3: Update evaluation expectations** + +Replace the compatible scenario's `expected_behavior` array with: + +```json +[ + "Loads references/midtrans-cli.md and runs midtrans agent capabilities --json --non-interactive before planning execution", + "Compares the returned result, manifest, and evidence schema versions plus capability and journey IDs with cli-compatibility.json", + "Keeps product selection and repository reasoning in the Agent Skill, and edits the merchant repository itself rather than delegating application reasoning to the CLI", + "Uses merchant-facing status to explain project, Sandbox, credential-reference, route, and readiness state without exposing credential values", + "Runs midtrans test checkout without --execute first, shows the merchant the dry-run plan, and obtains approval before any --execute run", + "Requires merchant-application order identity for complete local proof and does not mislabel a generated provider-only smoke test", + "Requires the evidence artifact and runs midtrans verify before describing Sandbox proof as complete", + "Fails the scenario if credentials appear in output, if production is attempted, or if local-only proof is described as end-to-end verification" +] +``` + +In `cli-compatibility.json`, change only: + +```json +"phase": "merchant-snap-v1" +``` + +Do not change required schemas, capabilities, or journeys. + +- [ ] **Step 4: Run JSON and readiness checks** + +Run: + +```bash +python3 -m json.tool integrate-midtrans-payments/evaluations.json >/dev/null +python3 -m json.tool integrate-midtrans-payments/cli-compatibility.json >/dev/null +./tools/check_official_readiness.py +``` + +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add integrate-midtrans-payments/evaluations.json integrate-midtrans-payments/cli-compatibility.json tools/check_official_readiness.py +git commit -m "test(skill): enforce merchant CLI orchestration" +``` + +--- + +### Task 3: Bump the Skill Patch Version and Synchronize Public Metadata + +**Files:** +- Modify: `.well-known/skills/index.json` +- Modify: `integrate-midtrans-payments/evaluations.json` +- Modify: `integrate-midtrans-payments/SKILL.md` +- Modify: `README.md` + +**Interfaces:** +- Consumes: repository version-sync readiness gate. +- Produces: version `0.3.3`, validated date `2026-07-26`. + +- [ ] **Step 1: Update version and date fields** + +Set: + +```json +{ + "version": "0.3.3", + "updated_at": "2026-07-26" +} +``` + +in the catalog root and skill entry. Set +`integrate-midtrans-payments/evaluations.json` to: + +```json +"version": "0.3.3" +``` + +Update the SKILL body stamp to: + +```markdown +Skill version 0.3.3, validated against docs.midtrans.com on 2026-07-26. +``` + +Update README's optional CLI section to name the merchant commands and agent +handshake without claiming that a public installer has shipped. + +- [ ] **Step 2: Run version, layout, and publication gates** + +Run: + +```bash +python3 -m json.tool .well-known/skills/index.json >/dev/null +python3 -m json.tool integrate-midtrans-payments/evaluations.json >/dev/null +./tools/check_official_readiness.py +python3 tools/build_publication_bundle.py --dry-run +python3 tools/build_pressure_pack.py --host claude-code --dry-run +python3 tools/build_pressure_pack.py --host codex --dry-run +``` + +Expected: all commands PASS and catalog file inventory remains synchronized. + +- [ ] **Step 3: Inspect the complete branch diff** + +Run: + +```bash +git diff --check +git diff --stat origin/main...HEAD +git status --short +``` + +Expected: only the existing CLI integration plus this namespace migration and +version metadata are present. + +- [ ] **Step 4: Commit** + +```bash +git add .well-known/skills/index.json integrate-midtrans-payments/evaluations.json integrate-midtrans-payments/SKILL.md README.md +git commit -m "chore(skill): release CLI orchestration v0.3.3" +``` + +--- + +## Plan Completion Gate + +Run: + +```bash +./tools/check_official_readiness.py +python3 tools/build_publication_bundle.py --dry-run +python3 tools/build_pressure_pack.py --host claude-code --dry-run +python3 tools/build_pressure_pack.py --host codex --dry-run +git diff --check +git status --short --branch +``` + +Then verify the installed CLI contract directly: + +```bash +midtrans agent capabilities --json --non-interactive +``` + +Compare the result manually with +`integrate-midtrans-payments/cli-compatibility.json`. Do not push or merge the +Agent Skill branch until the CLI implementation plan has passed its completion +gate. diff --git a/docs/superpowers/plans/2026-07-26-merchant-cli-experience.md b/docs/superpowers/plans/2026-07-26-merchant-cli-experience.md new file mode 100644 index 0000000..0c4282e --- /dev/null +++ b/docs/superpowers/plans/2026-07-26-merchant-cli-experience.md @@ -0,0 +1,2428 @@ +# Merchant-First Midtrans CLI Experience Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Turn the Phase 1 Midtrans CLI into a globally installed, project-aware, merchant-facing Sandbox tool while preserving stable machine contracts for Midtrans Agent Skills. + +**Architecture:** Add a dedicated project resolver in front of project-bound commands, keep command results as the single source of truth, and build command-aware human presentations from redacted typed `data`. Introduce merchant commands (`status`, `setup`, and `test`) over the existing policy, Snap journey, webhook, and evidence engines; move machine discovery under `agent` while keeping hidden `v0.1.x` aliases. + +**Tech Stack:** Go from the pinned `go.mod` toolchain, Cobra, Go standard library, existing Midtrans CLI contracts/packs/policy/evidence packages, POSIX shell for the local installer. + +## Global Constraints + +- Phase 1 remains Sandbox-only and must not accept or call production Midtrans credentials or endpoints. +- The CLI must not write merchant application code. +- The executable installs as a regular file at `${MIDTRANS_INSTALL_DIR:-$HOME/.local/bin}/midtrans`; no `sudo` and no source-tree symlink. +- Project configuration, operation state, temporary files, and evidence remain under the selected repository's `.midtrans/` directory. +- Explicit `--project-dir` is authoritative and disables parent discovery. +- Stable result schema `1.0`, manifest schema `1`, evidence schema `1.0`, capability IDs, pack IDs, and journey IDs remain unchanged. +- Every output path passes through structural redaction before JSON or human rendering. +- Mutating Sandbox and local webhook operations require a reviewable plan and explicit execution authorization. +- Existing machine commands remain hidden compatibility aliases for `v0.1.x` and preserve their JSON result contracts. +- Human mode must never reduce a successful merchant command to only `PASS: `. +- The installer must not silently modify shell profiles. +- No new third-party Go dependencies are permitted. + +--- + +## File Structure + +### New files + +- `internal/project/discovery.go` — safe current-project and initialization-root discovery. +- `internal/project/discovery_test.go` — discovery, nested-project, explicit-root, Git, and symlink tests. +- `internal/readiness/report.go` — typed merchant readiness report and status calculation. +- `internal/readiness/report_test.go` — deterministic readiness semantics. +- `internal/presentation/model.go` — converts redacted command results into bounded human presentation models. +- `internal/presentation/model_test.go` — command-aware presentation tests. +- `internal/app/project_context.go` — Cobra project-mode annotations and structured project errors. +- `internal/app/commands_status.go` — root project dashboard. +- `internal/app/commands_setup.go` — safe manifest setup and preview flow. +- `internal/app/commands_agent.go` — machine-oriented namespace. +- `internal/app/commands_test.go` — merchant `test checkout` and `test webhook` command tree. +- `internal/app/checkout_runner.go` — shared Snap checkout orchestration used by new and compatibility commands. +- `internal/app/webhook_test_runner.go` — shared local webhook proof orchestration. +- `internal/app/commands_version.go` — projectless version command. +- `tools/install-local.sh` — no-`sudo` atomic local development installer. +- `tools/test-install-local.sh` — installer isolation and regular-file smoke test. + +### Modified files + +- `internal/app/app.go` — dependency defaults, root behavior, command registration, and project resolution hook. +- `internal/app/app_test.go` — command surface, root behavior, discovery, merchant output, aliases, and JSON compatibility. +- `internal/app/commands_capabilities.go` — agent namespace reuse and hidden compatibility behavior. +- `internal/app/commands_credentials.go` — setup/status reuse and compatibility behavior. +- `internal/app/commands_doctor.go` — agent check reuse and merchant compatibility behavior. +- `internal/app/commands_inspect.go` — agent namespace reuse. +- `internal/app/commands_manifest.go` — initialization discovery and idempotent existing-project result. +- `internal/app/commands_pack.go` — agent namespace reuse. +- `internal/app/commands_sandbox.go` — delegate checkout execution to the shared runner. +- `internal/app/commands_sandbox_run_test.go` — shared-runner and merchant command parity. +- `internal/contracts/result.go` — no schema changes; only helper behavior if required by presentations. +- `internal/evidence/redact.go` — retain redaction invariants for typed command data. +- `internal/inspection/walk.go` — source-oriented directory and file exclusions. +- `internal/inspection/inspection_test.go` — generated/secret-bearing exclusion tests. +- `internal/manifest/file.go` — atomic confirmed setup save and idempotent initialization support. +- `internal/manifest/manifest_test.go` — atomic-save and existing-init tests. +- `internal/render/render.go` — command-aware human output with generic fallback. +- `internal/render/render_test.go` — useful human output, fallback, color-free, and redaction tests. +- `README.md` — merchant workflow, agent namespace, local install, and project discovery. +- `docs/agent-skill-compatibility.md` — new capability handshake command. +- `tools/check_release.sh` — installer and merchant command smoke gates. + +--- + +### Task 1: Add Safe Project Discovery + +**Files:** +- Create: `internal/project/discovery.go` +- Create: `internal/project/discovery_test.go` + +**Interfaces:** +- Consumes: filesystem paths and optional Git-root resolver. +- Produces: + - `type Mode string` + - `const Existing Mode = "existing"` + - `const Initializable Mode = "initializable"` + - `type Request struct { StartDir, ExplicitDir string; Mode Mode; GitRoot func(string) (string, error) }` + - `type Resolution struct { Root string; Initialized bool }` + - `func Resolve(Request) (Resolution, error)` + - Sentinel errors `ErrNotInitialized`, `ErrDirectoryUnavailable`, and `ErrUnsafePath`. + +- [ ] **Step 1: Write failing discovery tests** + +```go +func TestResolveExistingFindsNearestManifest(t *testing.T) { + root := t.TempDir() + nested := filepath.Join(root, "app", "checkout") + if err := os.MkdirAll(filepath.Join(root, ".midtrans"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile( + filepath.Join(root, ".midtrans", "manifest.yaml"), + []byte("schema_version: 1\n"), + 0o644, + ); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(nested, 0o755); err != nil { + t.Fatal(err) + } + + got, err := project.Resolve(project.Request{ + StartDir: nested, + Mode: project.Existing, + }) + if err != nil { + t.Fatal(err) + } + if got.Root != root || !got.Initialized { + t.Fatalf("resolution = %#v", got) + } +} + +func TestResolveExistingUsesNearestNestedProject(t *testing.T) { + outer := initializedProject(t) + inner := filepath.Join(outer, "packages", "store") + if err := os.MkdirAll(filepath.Join(inner, ".midtrans"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile( + filepath.Join(inner, ".midtrans", "manifest.yaml"), + []byte("schema_version: 1\n"), + 0o644, + ); err != nil { + t.Fatal(err) + } + child := filepath.Join(inner, "src") + if err := os.MkdirAll(child, 0o755); err != nil { + t.Fatal(err) + } + + got, err := project.Resolve(project.Request{StartDir: child, Mode: project.Existing}) + if err != nil || got.Root != inner { + t.Fatalf("resolution = %#v, err = %v", got, err) + } +} + +func TestResolveExplicitDirectoryDoesNotSearchParents(t *testing.T) { + outer := initializedProject(t) + child := filepath.Join(outer, "src") + if err := os.MkdirAll(child, 0o755); err != nil { + t.Fatal(err) + } + + _, err := project.Resolve(project.Request{ + StartDir: child, + ExplicitDir: child, + Mode: project.Existing, + }) + if !errors.Is(err, project.ErrNotInitialized) { + t.Fatalf("err = %v", err) + } +} + +func TestResolveInitializableUsesGitRootThenCurrentDirectory(t *testing.T) { + start := t.TempDir() + gitRoot := filepath.Join(start, "repository") + child := filepath.Join(gitRoot, "src") + if err := os.MkdirAll(child, 0o755); err != nil { + t.Fatal(err) + } + got, err := project.Resolve(project.Request{ + StartDir: child, + Mode: project.Initializable, + GitRoot: func(string) (string, error) { return gitRoot, nil }, + }) + if err != nil || got.Root != gitRoot || got.Initialized { + t.Fatalf("resolution = %#v, err = %v", got, err) + } + + got, err = project.Resolve(project.Request{ + StartDir: child, + Mode: project.Initializable, + GitRoot: func(string) (string, error) { return "", errors.New("not git") }, + }) + if err != nil || got.Root != child { + t.Fatalf("fallback = %#v, err = %v", got, err) + } +} +``` + +Add cases for a missing start directory, manifest symlink, symlink project root, +filesystem-root termination, and `Initializable` returning an already +initialized parent without creating a nested project. + +- [ ] **Step 2: Run the package test and verify RED** + +Run: + +```bash +go test ./internal/project -run TestResolve -v +``` + +Expected: FAIL because `internal/project` and `project.Resolve` do not exist. + +- [ ] **Step 3: Implement minimal discovery** + +```go +package project + +import ( + "bytes" + "errors" + "os" + "os/exec" + "path/filepath" +) + +type Mode string + +const ( + Existing Mode = "existing" + Initializable Mode = "initializable" +) + +var ( + ErrNotInitialized = errors.New("project is not initialized") + ErrDirectoryUnavailable = errors.New("project directory is unavailable") + ErrUnsafePath = errors.New("project path is unsafe") +) + +type Request struct { + StartDir string + ExplicitDir string + Mode Mode + GitRoot func(string) (string, error) +} + +type Resolution struct { + Root string + Initialized bool +} + +func Resolve(request Request) (Resolution, error) { + start := request.StartDir + if request.ExplicitDir != "" { + start = request.ExplicitDir + } + root, err := regularDirectory(start) + if err != nil { + return Resolution{}, err + } + if request.ExplicitDir != "" { + return exact(root, request.Mode) + } + if found, ok, err := searchParents(root); err != nil { + return Resolution{}, err + } else if ok { + return Resolution{Root: found, Initialized: true}, nil + } + if request.Mode == Existing { + return Resolution{}, ErrNotInitialized + } + resolver := request.GitRoot + if resolver == nil { + resolver = gitRoot + } + if candidate, err := resolver(root); err == nil { + canonical, canonicalErr := regularDirectory(candidate) + if canonicalErr != nil { + return Resolution{}, canonicalErr + } + return Resolution{Root: canonical}, nil + } + return Resolution{Root: root}, nil +} + +func exact(root string, mode Mode) (Resolution, error) { + initialized, err := hasManifest(root) + if err != nil { + return Resolution{}, err + } + if initialized { + return Resolution{Root: root, Initialized: true}, nil + } + if mode == Existing { + return Resolution{}, ErrNotInitialized + } + return Resolution{Root: root}, nil +} + +func searchParents(start string) (string, bool, error) { + for current := start; ; current = filepath.Dir(current) { + ok, err := hasManifest(current) + if err != nil { + return "", false, err + } + if ok { + return current, true, nil + } + parent := filepath.Dir(current) + if parent == current { + return "", false, nil + } + } +} + +func hasManifest(root string) (bool, error) { + configDir := filepath.Join(root, ".midtrans") + configInfo, err := os.Lstat(configDir) + if errors.Is(err, os.ErrNotExist) { + return false, nil + } + if err != nil { + return false, ErrDirectoryUnavailable + } + if configInfo.Mode()&os.ModeSymlink != 0 || !configInfo.IsDir() { + return false, ErrUnsafePath + } + path := filepath.Join(configDir, "manifest.yaml") + info, err := os.Lstat(path) + if errors.Is(err, os.ErrNotExist) { + return false, nil + } + if err != nil { + return false, ErrDirectoryUnavailable + } + if info.Mode()&os.ModeSymlink != 0 || !info.Mode().IsRegular() { + return false, ErrUnsafePath + } + return true, nil +} + +func regularDirectory(candidate string) (string, error) { + absolute, err := filepath.Abs(candidate) + if err != nil { + return "", ErrDirectoryUnavailable + } + info, err := os.Lstat(absolute) + if err != nil || !info.IsDir() { + return "", ErrDirectoryUnavailable + } + if info.Mode()&os.ModeSymlink != 0 { + return "", ErrUnsafePath + } + return filepath.Clean(absolute), nil +} + +func gitRoot(start string) (string, error) { + command := exec.Command("git", "-C", start, "rev-parse", "--show-toplevel") + output, err := command.Output() + if err != nil { + return "", err + } + return string(bytes.TrimSpace(output)), nil +} +``` + +Add the test helper `initializedProject`. + +- [ ] **Step 4: Run discovery tests and verify GREEN** + +Run: + +```bash +go test ./internal/project -v +``` + +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add internal/project +git commit -m "feat: discover Midtrans projects from nested directories" +``` + +--- + +### Task 2: Resolve Project Context Before Project-Bound Commands + +**Files:** +- Create: `internal/app/project_context.go` +- Modify: `internal/app/app.go` +- Modify: `internal/app/app_test.go` +- Modify: project-bound constructors in `internal/app/commands_*.go` + +**Interfaces:** +- Consumes: `project.Resolve`, Cobra command annotations, `Dependencies.Getwd`. +- Produces: + - `Dependencies.Getwd func() (string, error)` + - `func withProjectMode(*cobra.Command, project.Mode, string) *cobra.Command` + - Structured `PROJECT_NOT_INITIALIZED`, `PROJECT_DIR_NOT_FOUND`, and `PROJECT_PATH_UNSAFE` results. + +- [ ] **Step 1: Write failing app-level discovery tests** + +```go +func TestNestedCommandDiscoversProjectManifest(t *testing.T) { + projectRoot := merchantFixture("snap-complete") + nested := filepath.Join(projectRoot, "nested", "checkout") + if err := os.MkdirAll(nested, 0o755); err != nil { + t.Fatal(err) + } + + result, exit := executeJSONWithDependencies( + t, + app.Dependencies{ + Version: version.Info{Version: "0.1.0-test"}, + Packs: testRegistry(t), + Getwd: func() (string, error) { return nested, nil }, + }, + "doctor", "--product", "snap", + ) + if exit != 0 || result.Command != "doctor" || result.ManifestVersion != 1 { + t.Fatalf("exit = %d, result = %#v", exit, result) + } +} + +func TestMissingProjectReturnsProjectResultNotUsage(t *testing.T) { + root := t.TempDir() + result, exit := executeJSONWithDependencies( + t, + app.Dependencies{ + Version: version.Info{Version: "0.1.0-test"}, + Packs: testRegistry(t), + Getwd: func() (string, error) { return root, nil }, + }, + "doctor", + ) + if exit != 6 || + result.Command != "doctor" || + result.Findings[0].Code != "PROJECT_NOT_INITIALIZED" { + t.Fatalf("exit = %d, result = %#v", exit, result) + } +} + +func TestExplicitProjectDirectoryDoesNotDiscoverParent(t *testing.T) { + outer := merchantFixture("snap-complete") + child := filepath.Join(outer, "src") + if err := os.MkdirAll(child, 0o755); err != nil { + t.Fatal(err) + } + result, exit := executeJSON( + t, + "doctor", "--project-dir", child, "--json", "--non-interactive", + ) + if exit != 6 || result.Findings[0].Code != "PROJECT_NOT_INITIALIZED" { + t.Fatalf("exit = %d, result = %#v", exit, result) + } +} +``` + +- [ ] **Step 2: Run tests and verify RED** + +Run: + +```bash +go test ./internal/app -run 'Test(NestedCommandDiscovers|MissingProjectReturns|ExplicitProjectDirectory)' -v +``` + +Expected: FAIL because `--project-dir` still defaults to `"."` and no resolver runs. + +- [ ] **Step 3: Add project annotations and resolver hook** + +```go +const ( + projectModeAnnotation = "midtrans.project-mode" + resultNameAnnotation = "midtrans.result-command" +) + +func withProjectMode( + command *cobra.Command, + mode project.Mode, + resultName string, +) *cobra.Command { + if command.Annotations == nil { + command.Annotations = map[string]string{} + } + command.Annotations[projectModeAnnotation] = string(mode) + command.Annotations[resultNameAnnotation] = resultName + return command +} + +func resolveProjectContext( + command *cobra.Command, + flags *globalFlags, + deps Dependencies, +) error { + rawMode, required := command.Annotations[projectModeAnnotation] + if !required { + return nil + } + start, err := deps.Getwd() + if err != nil { + return writeResult(deps, flags, projectFailure( + command, deps, "PROJECT_DIR_NOT_FOUND", "current directory is unavailable", + )) + } + resolution, err := project.Resolve(project.Request{ + StartDir: start, + ExplicitDir: flags.projectDir, + Mode: project.Mode(rawMode), + }) + if err != nil { + return writeResult(deps, flags, projectErrorResult(command, deps, err)) + } + flags.projectDir = resolution.Root + return nil +} +``` + +In `app.go`, change the flag default from `"."` to `""`, default +`Dependencies.Getwd` to `os.Getwd`, and register: + +```go +root.PersistentPreRunE = func(cmd *cobra.Command, _ []string) error { + return resolveProjectContext(cmd, flags, deps) +} +root.PersistentFlags().StringVar( + &flags.projectDir, + "project-dir", + "", + "merchant repository root (auto-detected when omitted)", +) +``` + +Annotate every manifest, evidence, status, Sandbox, webhook, inspection, and +verification leaf as `project.Existing`; annotate `init` as +`project.Initializable`; leave capabilities, pack, update, help, and version +projectless. + +Map sentinel errors exactly: + +```go +func projectErrorResult( + command *cobra.Command, + deps Dependencies, + err error, +) contracts.Result { + code := "PROJECT_DIR_NOT_FOUND" + message := "the selected project directory is unavailable" + switch { + case errors.Is(err, project.ErrNotInitialized): + code = "PROJECT_NOT_INITIALIZED" + message = "no .midtrans/manifest.yaml was found; run midtrans init" + case errors.Is(err, project.ErrUnsafePath): + code = "PROJECT_PATH_UNSAFE" + message = "the selected project path is unsafe" + } + result := contracts.NewResult( + command.Annotations[resultNameAnnotation], + contracts.StatusError, + ) + result.CLIVersion = deps.Version.Version + result.Findings = []contracts.Finding{{ + Code: code, Severity: "blocking", Message: message, + }} + return result +} +``` + +- [ ] **Step 4: Run targeted and full app tests** + +Run: + +```bash +go test ./internal/app -run 'Test(NestedCommandDiscovers|MissingProjectReturns|ExplicitProjectDirectory)' -v +go test ./internal/app -v +``` + +Expected: PASS. Existing explicit `--project-dir` JSON tests remain unchanged. + +- [ ] **Step 5: Commit** + +```bash +git add internal/app +git commit -m "feat: resolve project context for CLI commands" +``` + +--- + +### Task 3: Exclude Generated and Secret-Bearing Files From Inspection + +**Files:** +- Modify: `internal/inspection/walk.go` +- Modify: `internal/inspection/inspection_test.go` + +**Interfaces:** +- Consumes: relative file paths during bounded inspection. +- Produces: `func shouldSkipFile(relative string) bool`. + +- [ ] **Step 1: Write a failing exclusion test** + +```go +func TestInspectSkipsGeneratedAndSecretBearingFiles(t *testing.T) { + root := t.TempDir() + files := []string{ + ".env", + ".env.local", + "terraform/terraform.tfstate", + "terraform/terraform.tfstate.backup", + "tsconfig.tsbuildinfo", + ".next/server/chunk.js", + ".terraform/providers/cache.txt", + "coverage/report.txt", + "dist/bundle.js", + } + for _, relative := range files { + path := filepath.Join(root, relative) + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile( + path, + []byte("MIDTRANS_SERVER_KEY="+canarySecret), + 0o600, + ); err != nil { + t.Fatal(err) + } + } + if err := os.WriteFile( + filepath.Join(root, ".env.example"), + []byte("MIDTRANS_SERVER_KEY=your-sandbox-key"), + 0o644, + ); err != nil { + t.Fatal(err) + } + + report, err := inspection.Inspect(root) + if err != nil { + t.Fatal(err) + } + if len(report.Facts) != 1 || + report.Facts[0].Path != ".env.example" { + t.Fatalf("facts = %#v", report.Facts) + } +} +``` + +Add `TestAgentNamespacePreservesLegacyJSON` with table-driven parity cases for: + +```text +midtrans inspect +midtrans agent inspect + +midtrans doctor --product snap +midtrans agent check --product snap + +midtrans pack info snap +midtrans agent pack info snap +``` + +Each old/new pair must produce the same schema version, manifest version, +findings, packs, capabilities, journeys, and exit code. Only the invocation +namespace changes during `v0.1.x`. + +- [ ] **Step 2: Run and verify RED** + +Run: + +```bash +go test ./internal/inspection -run TestInspectSkipsGeneratedAndSecretBearingFiles -v +``` + +Expected: FAIL because `.env.local`, `.next`, and Terraform state are inspected. + +- [ ] **Step 3: Implement deterministic exclusions** + +```go +var skippedDirs = []string{ + ".cache", ".git", ".midtrans", ".next", ".terraform", ".turbo", + "build", "coverage", "dist", "evidence", "node_modules", "out", + "tmp", "vendor", +} + +var allowedEnvironmentTemplates = []string{ + ".env.example", ".env.sample", ".env.template", +} + +func shouldSkipFile(relative string) bool { + base := filepath.Base(relative) + if strings.HasPrefix(base, ".env") && + !slices.Contains(allowedEnvironmentTemplates, base) { + return true + } + if strings.Contains(base, ".tfstate") || + strings.HasSuffix(base, ".tsbuildinfo") { + return true + } + return false +} +``` + +Call `shouldSkipFile(relative)` before `Lstat` and reading the file. Keep +`.env.example`, `.env.sample`, and `.env.template` inspectable because they +contain reference names needed by readiness checks. + +- [ ] **Step 4: Run inspection and app inspection tests** + +Run: + +```bash +go test ./internal/inspection -v +go test ./internal/app -run 'TestInspect|TestDoctor' -v +``` + +Expected: PASS with bounded facts and no generated-tree noise. + +- [ ] **Step 5: Commit** + +```bash +git add internal/inspection +git commit -m "fix: limit inspection to merchant source files" +``` + +--- + +### Task 4: Add Typed Merchant Readiness Data + +**Files:** +- Create: `internal/readiness/report.go` +- Create: `internal/readiness/report_test.go` + +**Interfaces:** +- Consumes: manifest, pack findings, installed versions, credential presence, + and optional loopback reachability. +- Produces: + - `type CheckState string` + - `type Check struct { ID, Label string; State CheckState; Detail string }` + - `type Report struct { Project, Root, Manifest, Environment string; Products []string; CLIVersion string; Packs []contracts.PackVersion; Checks []Check }` + - `type Input struct { ... }` + - `func Build(Input) Report` + - `func (Report) Status() contracts.Status` + - `func (Report) NextAction() *contracts.NextAction` + +- [ ] **Step 1: Write failing readiness tests** + +```go +func TestBuildReportsConcreteReadyAndMissingChecks(t *testing.T) { + value := manifest.Default() + value.Integration.CheckoutModes = []string{"popup"} + value.Integration.NotificationRoute = "/api/payment/webhook" + value.Integration.FinishRedirectRoute = "/orders/{order_id}" + value.Integration.LocalBaseURL = "http://127.0.0.1:3101" + value.Integration.LocalStatusRoute = "/api/dev/midtrans/{order_id}" + + report := readiness.Build(readiness.Input{ + ProjectRoot: "/tmp/store", + Manifest: value, + CLIVersion: "0.1.0-test", + Packs: []contracts.PackVersion{{ID: "snap", Version: "0.1.0"}}, + ServerKeyPresent: false, + ClientKeyPresent: true, + LocalReachable: readiness.ReachabilityUnreachable, + }) + + if report.Status() != contracts.StatusWarn { + t.Fatalf("status = %s", report.Status()) + } + assertCheck(t, report, "project", readiness.Ready) + assertCheck(t, report, "server-key", readiness.NeedsAction) + assertCheck(t, report, "local-app", readiness.Warning) + if action := report.NextAction(); action == nil || + action.Action != "configure_sandbox_server_key" { + t.Fatalf("next action = %#v", action) + } +} + +func TestBuildNeverIncludesCredentialValues(t *testing.T) { + report := readiness.Build(readiness.Input{ + ProjectRoot: "/tmp/store", + Manifest: manifest.Default(), + ServerKeyPresent: true, + ClientKeyPresent: true, + }) + encoded, err := json.Marshal(report) + if err != nil { + t.Fatal(err) + } + if bytes.Contains(encoded, []byte("SB-Mid")) { + t.Fatalf("report contains a credential: %s", encoded) + } +} +``` + +- [ ] **Step 2: Run and verify RED** + +Run: + +```bash +go test ./internal/readiness -v +``` + +Expected: FAIL because the package does not exist. + +- [ ] **Step 3: Implement the report** + +```go +type CheckState string + +const ( + Ready CheckState = "ready" + NeedsAction CheckState = "needs_action" + Warning CheckState = "warning" + Failed CheckState = "failed" +) + +type Reachability string + +const ( + ReachabilityUnknown Reachability = "unknown" + ReachabilityReachable Reachability = "reachable" + ReachabilityUnreachable Reachability = "unreachable" +) + +type Check struct { + ID string `json:"id"` + Label string `json:"label"` + State CheckState `json:"state"` + Detail string `json:"detail"` +} + +type Report struct { + Project string `json:"project"` + Root string `json:"root"` + Manifest string `json:"manifest"` + Environment string `json:"environment"` + Products []string `json:"products"` + CLIVersion string `json:"cli_version"` + Packs []contracts.PackVersion `json:"packs"` + Checks []Check `json:"checks"` +} + +type Input struct { + ProjectRoot string + Manifest manifest.Manifest + CLIVersion string + Packs []contracts.PackVersion + Findings []contracts.Finding + ServerKeyPresent bool + ClientKeyPresent bool + LocalReachable Reachability +} +``` + +`Build` adds checks in stable order: project, environment, product, checkout, +webhook, local-status, client-key, server-key, local-app, then one check per +pack finding. Only reference names such as `MIDTRANS_SERVER_KEY` may appear in +details. + +`Status` returns `fail` for a failed check, `warn` for `needs_action` or +`warning`, and `pass` only when all checks are ready. `NextAction` prioritizes +invalid manifest, server key, client key, local route, local app, then checkout +testing. + +- [ ] **Step 4: Run readiness tests** + +Run: + +```bash +go test ./internal/readiness -v +``` + +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add internal/readiness +git commit -m "feat: model merchant integration readiness" +``` + +--- + +### Task 5: Render Useful Human Presentations + +**Files:** +- Create: `internal/presentation/model.go` +- Create: `internal/presentation/model_test.go` +- Modify: `internal/render/render.go` +- Modify: `internal/render/render_test.go` + +**Interfaces:** +- Consumes: already-redacted `contracts.Result`. +- Produces: + - `type Row struct { State, Label, Detail string }` + - `type Model struct { Title string; Rows []Row; Findings []contracts.Finding; NextActions []contracts.NextAction }` + - `func Build(contracts.Result) (Model, bool)` + - `func render.Write` uses the model in human mode and retains JSON behavior. + +- [ ] **Step 1: Write failing presentation and renderer tests** + +```go +func TestBuildStatusPresentation(t *testing.T) { + result := contracts.NewResult("status", contracts.StatusWarn) + result.Data = readiness.Report{ + Project: "Salis Property", + Environment: "sandbox", + Products: []string{"snap"}, + Checks: []readiness.Check{ + {ID: "project", Label: "Project", State: readiness.Ready, Detail: ".midtrans/manifest.yaml"}, + {ID: "server-key", Label: "Server key", State: readiness.NeedsAction, Detail: "MIDTRANS_SERVER_KEY is not available"}, + }, + } + result.NextActions = []contracts.NextAction{{ + Action: "configure_sandbox_server_key", + Description: "export the Sandbox Server Key and rerun midtrans status", + }} + + model, ok := presentation.Build(result) + if !ok || model.Title != "Salis Property · Sandbox · Snap" { + t.Fatalf("model = %#v", model) + } + if model.Rows[0].State != "✓" || model.Rows[1].State != "✗" { + t.Fatalf("rows = %#v", model.Rows) + } +} + +func TestWriteHumanStatusShowsChecksAndNextAction(t *testing.T) { + var output bytes.Buffer + result := contracts.NewResult("status", contracts.StatusWarn) + result.Data = readiness.Report{ + Project: "Salis Property", + Environment: "sandbox", + Products: []string{"snap"}, + Checks: []readiness.Check{{ + ID: "server-key", Label: "Server key", + State: readiness.NeedsAction, + Detail: "MIDTRANS_SERVER_KEY is not available", + }}, + } + result.NextActions = []contracts.NextAction{{ + Action: "configure_sandbox_server_key", + Description: "export the Sandbox Server Key", + }} + if err := render.Write(&output, result, render.FormatHuman); err != nil { + t.Fatal(err) + } + got := output.String() + for _, expected := range []string{ + "Salis Property · Sandbox · Snap", + "Server key", + "MIDTRANS_SERVER_KEY is not available", + "Next:", + "export the Sandbox Server Key", + } { + if !strings.Contains(got, expected) { + t.Fatalf("output missing %q:\n%s", expected, got) + } + } + if strings.Contains(got, "PASS: status") { + t.Fatalf("bare pass output:\n%s", got) + } +} +``` + +Retain the existing generic finding fallback test and JSON serialization tests. + +- [ ] **Step 2: Run and verify RED** + +Run: + +```bash +go test ./internal/presentation ./internal/render -v +``` + +Expected: FAIL because presentation models do not exist and the renderer +ignores `Data`. + +- [ ] **Step 3: Implement bounded presentation building** + +```go +type Row struct { + State string + Label string + Detail string +} + +type Model struct { + Title string + Rows []Row + Findings []contracts.Finding + NextActions []contracts.NextAction +} + +func Build(result contracts.Result) (Model, bool) { + switch result.Command { + case "status", "setup": + var report readiness.Report + if !decodeData(result.Data, &report) { + return Model{}, false + } + rows := make([]Row, 0, len(report.Checks)) + for _, check := range report.Checks { + rows = append(rows, Row{ + State: stateSymbol(check.State), + Label: check.Label, + Detail: check.Detail, + }) + } + return Model{ + Title: strings.Join([]string{ + report.Project, + titleCase(report.Environment), + strings.Join(report.Products, ", "), + }, " · "), + Rows: rows, + Findings: result.Findings, + NextActions: result.NextActions, + }, true + default: + return Model{}, false + } +} + +func decodeData(value any, target any) bool { + encoded, err := json.Marshal(value) + if err != nil { + return false + } + return json.Unmarshal(encoded, target) == nil +} +``` + +In `render.Write`, keep JSON unchanged. For human mode: + +```go +if model, ok := presentation.Build(result); ok { + return writePresentation(w, model) +} +return writeGenericHuman(w, result) +``` + +`writePresentation` aligns labels without arbitrary provider data, prints +findings, then one `Next:` section. Do not add ANSI color in this task; symbols +and text remain readable in all outputs. + +- [ ] **Step 4: Run presentation, render, redaction, and schema tests** + +Run: + +```bash +go test ./internal/presentation ./internal/render ./internal/evidence ./internal/contracts -v +``` + +Expected: PASS and JSON output remains schema-compatible. + +- [ ] **Step 5: Commit** + +```bash +git add internal/presentation internal/render +git commit -m "feat: render actionable merchant command output" +``` + +--- + +### Task 6: Add `midtrans status` and Root Dashboard Behavior + +**Files:** +- Create: `internal/app/commands_status.go` +- Modify: `internal/app/app.go` +- Modify: `internal/app/app_test.go` + +**Interfaces:** +- Consumes: resolved project, manifest, inspection, pack evaluation, credential + provider, and injected loopback probe. +- Produces: + - `Dependencies.LocalProbe func(context.Context, string) bool` + - `func newStatusCommand(*globalFlags, Dependencies) *cobra.Command` + - Root invocation delegates to status or initialization guidance. + +- [ ] **Step 1: Write failing status tests** + +```go +func TestStatusShowsActionableMerchantReadiness(t *testing.T) { + project := merchantFixture("snap-complete") + var stdout, stderr bytes.Buffer + exit := app.Execute(context.Background(), []string{ + "status", "--project-dir", project, + }, app.Dependencies{ + Stdout: &stdout, + Stderr: &stderr, + Version: version.Info{Version: "0.1.0-test"}, + Packs: testRegistry(t), + Getenv: func(string) (string, bool) { return "", false }, + LocalProbe: func(context.Context, string) bool { return false }, + }) + if exit != 0 { + t.Fatalf("exit = %d, stderr = %s", exit, stderr.String()) + } + for _, expected := range []string{ + "Sandbox", "Snap", "Project", "Checkout", "Webhook", + "Server key", "MIDTRANS_SERVER_KEY", "Next:", + } { + if !strings.Contains(stdout.String(), expected) { + t.Fatalf("missing %q:\n%s", expected, stdout.String()) + } + } +} + +func TestRootInvocationUsesStatusInsideProject(t *testing.T) { + project := merchantFixture("snap-complete") + result, exit := executeJSONWithDependencies( + t, + app.Dependencies{ + Version: version.Info{Version: "0.1.0-test"}, + Packs: testRegistry(t), + Getwd: func() (string, error) { return project, nil }, + }, + ) + if exit != 0 || result.Command != "status" { + t.Fatalf("exit = %d, result = %#v", exit, result) + } +} + +func TestRootInvocationGuidesInitializationOutsideProject(t *testing.T) { + root := t.TempDir() + result, exit := executeJSONWithDependencies( + t, + app.Dependencies{ + Version: version.Info{Version: "0.1.0-test"}, + Packs: testRegistry(t), + Getwd: func() (string, error) { return root, nil }, + }, + ) + if exit != 0 || + result.Command != "welcome" || + result.NextActions[0].Action != "initialize_project" { + t.Fatalf("exit = %d, result = %#v", exit, result) + } +} +``` + +- [ ] **Step 2: Run and verify RED** + +Run: + +```bash +go test ./internal/app -run 'Test(StatusShows|RootInvocation)' -v +``` + +Expected: FAIL because `status` and root execution do not exist. + +- [ ] **Step 3: Implement status collection** + +```go +func buildStatusResult( + ctx context.Context, + flags *globalFlags, + deps Dependencies, +) contracts.Result { + value, invalid := loadValidatedManifest("status", flags.projectDir, deps) + if invalid != nil { + return *invalid + } + report, err := inspection.Inspect(flags.projectDir) + if err != nil { + return inspectionFailureResult("status", deps) + } + pack, _ := deps.Packs.Get("snap") + findings := append( + manifest.Validate(value), + pack.Evaluate(value, report)..., + ) + provider := secrets.NewEnvironmentProvider(deps.Getenv) + serverPresent := secretPresent( + ctx, provider, value.Credentials.References["server_key"], + ) + clientPresent := secretPresent( + ctx, provider, value.Credentials.References["client_key"], + ) + reachable := readiness.ReachabilityUnknown + if value.Integration.LocalBaseURL != "" { + if deps.LocalProbe(ctx, value.Integration.LocalBaseURL) { + reachable = readiness.ReachabilityReachable + } else { + reachable = readiness.ReachabilityUnreachable + } + } + data := readiness.Build(readiness.Input{ + ProjectRoot: flags.projectDir, + Manifest: value, + CLIVersion: deps.Version.Version, + Packs: deps.Packs.Versions(), + Findings: findings, + ServerKeyPresent: serverPresent, + ClientKeyPresent: clientPresent, + LocalReachable: reachable, + }) + result := contracts.NewResult("status", data.Status()) + result.CLIVersion = deps.Version.Version + result.ManifestVersion = value.SchemaVersion + result.Data = data + if action := data.NextAction(); action != nil { + result.NextActions = []contracts.NextAction{*action} + } + return result +} +``` + +Default `LocalProbe` performs a bounded `GET` to the validated loopback base URL +with redirects disabled and treats any HTTP response as reachable. + +Set root `Args: cobra.NoArgs` and `RunE` to discover an existing project. On +`ErrNotInitialized`, emit a `welcome` pass result with +`initialize_project — run midtrans init`; otherwise assign the discovered root +and write `buildStatusResult`. + +- [ ] **Step 4: Run app and policy tests** + +Run: + +```bash +go test ./internal/app ./internal/policy -v +``` + +Expected: PASS; status performs no Midtrans provider call and no mutation. + +- [ ] **Step 5: Commit** + +```bash +git add internal/app internal/readiness +git commit -m "feat: add merchant project status dashboard" +``` + +--- + +### Task 7: Add Safe Interactive `midtrans setup` + +**Files:** +- Create: `internal/app/commands_setup.go` +- Modify: `internal/app/app.go` +- Modify: `internal/app/app_test.go` +- Modify: `internal/manifest/file.go` +- Modify: `internal/manifest/manifest_test.go` + +**Interfaces:** +- Consumes: resolved manifest, `Dependencies.Stdin`, `Dependencies.IsTerminal`. +- Produces: + - `Dependencies.Stdin io.Reader` + - `Dependencies.IsTerminal func() bool` + - `func manifest.Save(projectDir string, value Manifest) error` + - `midtrans setup` previews only `.midtrans/manifest.yaml` changes. + +- [ ] **Step 1: Write failing atomic-save and setup tests** + +```go +func TestSaveRoundTripsValidatedManifestAtomically(t *testing.T) { + root := t.TempDir() + if _, err := manifest.Init(root); err != nil { + t.Fatal(err) + } + value, err := manifest.Load(root) + if err != nil { + t.Fatal(err) + } + value.Integration.CheckoutModes = []string{"popup"} + value.Integration.NotificationRoute = "/api/payment/webhook" + value.Integration.FinishRedirectRoute = "/orders/{order_id}" + value.Integration.LocalBaseURL = "http://127.0.0.1:3101" + value.Integration.LocalStatusRoute = "/api/dev/midtrans/{order_id}" + if err := manifest.Save(root, value); err != nil { + t.Fatal(err) + } + got, err := manifest.Load(root) + if err != nil || !reflect.DeepEqual(got, value) { + t.Fatalf("manifest = %#v, err = %v", got, err) + } +} + +func TestSetupNonInteractiveNeverWritesManifest(t *testing.T) { + project := t.TempDir() + if _, err := manifest.Init(project); err != nil { + t.Fatal(err) + } + before, _ := os.ReadFile(manifest.Path(project)) + result, exit := executeJSON( + t, + "setup", "--project-dir", project, "--json", "--non-interactive", + ) + after, _ := os.ReadFile(manifest.Path(project)) + if exit != 0 || result.Command != "setup" || + !bytes.Equal(before, after) { + t.Fatalf("exit = %d, result = %#v, changed = %v", exit, result, !bytes.Equal(before, after)) + } +} + +func TestSetupInteractiveWritesOnlyAfterExactConfirmation(t *testing.T) { + project := t.TempDir() + if _, err := manifest.Init(project); err != nil { + t.Fatal(err) + } + input := strings.NewReader(strings.Join([]string{ + "popup", + "/api/payment/webhook", + "/orders/{order_id}", + "http://127.0.0.1:3101", + "/api/dev/midtrans/{order_id}", + "yes", + "", + }, "\n")) + var stdout, stderr bytes.Buffer + exit := app.Execute(context.Background(), []string{ + "setup", "--project-dir", project, + }, app.Dependencies{ + Stdin: input, Stdout: &stdout, Stderr: &stderr, + IsTerminal: func() bool { return true }, + Version: version.Info{Version: "0.1.0-test"}, + Packs: testRegistry(t), + }) + if exit != 0 { + t.Fatalf("exit = %d, stderr = %s", exit, stderr.String()) + } + value, err := manifest.Load(project) + if err != nil || + !slices.Contains(value.Integration.CheckoutModes, "popup") || + value.Integration.NotificationRoute != "/api/payment/webhook" { + t.Fatalf("manifest = %#v, err = %v", value, err) + } +} +``` + +- [ ] **Step 2: Run and verify RED** + +Run: + +```bash +go test ./internal/manifest ./internal/app -run 'Test(Save|Setup)' -v +``` + +Expected: FAIL because `manifest.Save` and `setup` do not exist. + +- [ ] **Step 3: Implement atomic save** + +```go +func Save(projectDir string, value Manifest) error { + if findings := Validate(value); len(findings) != 0 { + return errors.New("manifest validation failed") + } + path, err := safepath.Existing( + projectDir, + filepath.Join(".midtrans", "manifest.yaml"), + ) + if err != nil { + return err + } + file, err := os.CreateTemp(filepath.Dir(path), ".manifest-*.yaml") + if err != nil { + return err + } + temp := file.Name() + defer os.Remove(temp) + if err := file.Chmod(0o644); err != nil { + file.Close() + return err + } + encoder := yaml.NewEncoder(file) + encoder.SetIndent(2) + if err := encoder.Encode(value); err != nil { + file.Close() + return err + } + if err := file.Sync(); err != nil { + file.Close() + return err + } + if err := file.Close(); err != nil { + return err + } + return os.Rename(temp, path) +} +``` + +- [ ] **Step 4: Implement setup preview and confirmation** + +Default `Dependencies.Stdin` to `os.Stdin` and `IsTerminal` to this +dependency-free terminal check: + +```go +func defaultIsTerminal() bool { + info, err := os.Stdin.Stat() + return err == nil && info.Mode()&os.ModeCharDevice != 0 +} +``` + +Tests always inject `IsTerminal`, and JSON or `--non-interactive` takes +precedence even when the process has a terminal. + +```go +func newSetupCommand(flags *globalFlags, deps Dependencies) *cobra.Command { + command := &cobra.Command{ + Use: "setup", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + if flags.nonInteractive || !deps.IsTerminal() { + result := buildStatusResult(cmd.Context(), flags, deps) + result.Command = "setup" + return writeResult(deps, flags, result) + } + value, invalid := loadValidatedManifest("setup", flags.projectDir, deps) + if invalid != nil { + return writeResult(deps, flags, *invalid) + } + proposed, err := promptManifestSetup(deps.Stdin, deps.Stdout, value) + if err != nil { + return writeResult(deps, flags, setupInputFailure(deps)) + } + if !confirmExactYes(deps.Stdin, deps.Stdout) { + result := contracts.NewResult("setup", contracts.StatusBlocked) + result.CLIVersion = deps.Version.Version + result.NextActions = []contracts.NextAction{{ + Action: "review_setup", + Description: "review the proposed manifest settings and rerun midtrans setup", + }} + return writeResult(deps, flags, result) + } + if err := manifest.Save(flags.projectDir, proposed); err != nil { + return writeResult(deps, flags, setupSaveFailure(deps)) + } + result := buildStatusResult(cmd.Context(), flags, deps) + result.Command = "setup" + return writeResult(deps, flags, result) + }, + } + return withProjectMode(command, project.Existing, "setup") +} +``` + +Prompt only checkout mode, notification route, finish route, loopback local URL, +and local status route. Print a field-by-field preview before accepting only an +exact case-insensitive `yes`. Never prompt for or store credential values. + +- [ ] **Step 5: Run setup, manifest, redaction, and full app tests** + +Run: + +```bash +go test ./internal/manifest ./internal/app ./internal/evidence -v +``` + +Expected: PASS. Cancellation and malformed input leave the original manifest +byte-for-byte unchanged. + +- [ ] **Step 6: Commit** + +```bash +git add internal/app/commands_setup.go internal/app/app.go internal/app/app_test.go internal/manifest +git commit -m "feat: add safe interactive Sandbox setup" +``` + +--- + +### Task 8: Add Agent Namespace, Version Command, and Hidden Compatibility Aliases + +**Files:** +- Create: `internal/app/commands_agent.go` +- Create: `internal/app/commands_version.go` +- Modify: `internal/app/app.go` +- Modify: `internal/app/app_test.go` +- Modify: `internal/app/commands_capabilities.go` +- Modify: `internal/app/commands_credentials.go` +- Modify: `internal/app/commands_doctor.go` +- Modify: `internal/app/commands_inspect.go` +- Modify: `internal/app/commands_pack.go` + +**Interfaces:** +- Consumes: existing command factories and result contracts. +- Produces: + - `midtrans agent capabilities|inspect|check|pack` + - `midtrans version` + - Hidden old commands with unchanged JSON and human migration messages. + +- [ ] **Step 1: Write failing command-surface and compatibility tests** + +```go +func TestHelpLeadsWithMerchantCommandSurface(t *testing.T) { + got := helpCommandNames(executeHelp(t, "--help")) + want := []string{ + "agent", "init", "setup", "status", "test", "update", "verify", "version", + } + if !reflect.DeepEqual(got, want) { + t.Fatalf("commands = %#v, want %#v", got, want) + } +} + +func TestAgentCapabilitiesPreservesCapabilityContract(t *testing.T) { + result, exit := executeJSON( + t, + "agent", "capabilities", "--json", "--non-interactive", + ) + if exit != 0 || + result.SchemaVersion != "1.0" || + len(result.Capabilities) != 4 || + len(result.Journeys) != 3 { + t.Fatalf("exit = %d, result = %#v", exit, result) + } +} + +func TestLegacyCapabilitiesJSONRemainsCompatibleAndHidden(t *testing.T) { + legacy, legacyExit := executeJSON( + t, "capabilities", "--json", "--non-interactive", + ) + current, currentExit := executeJSON( + t, "agent", "capabilities", "--json", "--non-interactive", + ) + if legacyExit != currentExit || + !reflect.DeepEqual(legacy.Capabilities, current.Capabilities) || + !reflect.DeepEqual(legacy.Journeys, current.Journeys) { + t.Fatalf("legacy = %#v, current = %#v", legacy, current) + } + if slices.Contains(helpCommandNames(executeHelp(t, "--help")), "capabilities") { + t.Fatal("legacy command is visible in primary help") + } +} + +func TestLegacyHumanCommandsProvideMerchantGuidance(t *testing.T) { + for _, command := range [][]string{ + {"capabilities"}, + {"credentials"}, + {"doctor"}, + } { + stdout, stderr, exit := executeHuman(t, command...) + if exit > 3 { + t.Fatalf("%v exit = %d", command, exit) + } + combined := stdout + stderr + if strings.Contains(combined, "PASS: credentials.status") { + t.Fatalf("%v retained bare internal status: %q", command, combined) + } + if !strings.Contains(combined, "Deprecated:") || + !strings.Contains(combined, "Next:") { + t.Fatalf("%v output = %q", command, combined) + } + } +} + +func TestVersionIsProjectless(t *testing.T) { + result, exit := executeJSONWithDependencies( + t, + app.Dependencies{ + Version: version.Info{ + Version: "v0.1.0", Commit: "abc123", Date: "2026-07-26", + }, + Packs: testRegistry(t), + Getwd: func() (string, error) { + return filepath.Join(t.TempDir(), "missing"), nil + }, + }, + "version", + ) + if exit != 0 || result.Command != "version" { + t.Fatalf("exit = %d, result = %#v", exit, result) + } +} +``` + +- [ ] **Step 2: Run and verify RED** + +Run: + +```bash +go test ./internal/app -run 'Test(HelpLeads|AgentCapabilities|AgentNamespace|LegacyCapabilities|VersionIs)' -v +``` + +Expected: FAIL because the agent namespace and version command do not exist. + +- [ ] **Step 3: Build reusable agent command factories** + +```go +func newAgentCommand(flags *globalFlags, deps Dependencies) *cobra.Command { + parent := &cobra.Command{ + Use: "agent", + Short: "machine-readable integration and capability commands", + } + parent.AddCommand( + newCapabilitiesCommand(flags, deps), + newInspectCommand(flags, deps, "inspect"), + newCheckCommand(flags, deps, "check"), + newPackCommand(flags, deps), + ) + return parent +} + +func newVersionCommand(flags *globalFlags, deps Dependencies) *cobra.Command { + return &cobra.Command{ + Use: "version", + Args: cobra.NoArgs, + RunE: func(*cobra.Command, []string) error { + result := contracts.NewResult("version", contracts.StatusPass) + result.CLIVersion = deps.Version.Version + result.Data = map[string]string{ + "version": deps.Version.Version, + "commit": deps.Version.Commit, + "date": deps.Version.Date, + } + return writeResult(deps, flags, result) + }, + } +} +``` + +Factor doctor evaluation into `newCheckCommand` and allow a result command name +parameter. New agent commands may retain existing result names +(`capabilities`, `inspect`, `doctor`, `pack.*`) so the JSON contract stays +stable while invocation moves. + +- [ ] **Step 4: Register merchant surface and hidden legacy aliases** + +Register only agent, init, setup, status, test, update, verify, and version in +visible root help. Set capabilities, credentials, doctor, evidence, inspect, +manifest, pack, plan, sandbox, and webhook root aliases to `Hidden: true`. +Apply `Hidden` only to the root alias instances; the reused capabilities, +inspect, check, and pack children under `midtrans agent` remain visible. + +For legacy human invocation, write this safe message to `deps.Stderr` before a +merchant-facing result: + +```go +func writeMigrationNotice( + flags *globalFlags, + deps Dependencies, + oldCommand string, + newCommand string, +) { + if flags.json { + return + } + fmt.Fprintf( + deps.Stderr, + "Deprecated: %s is retained for v0.1.x compatibility; use %s.\n", + oldCommand, + newCommand, + ) +} +``` + +Do not add the notice, findings, or next actions to legacy JSON results. + +Route the legacy commands explicitly: + +- `midtrans doctor`: preserve the old doctor result in JSON; in human mode, + invoke the same readiness builder as `midtrans status` and recommend + `midtrans status`. +- `midtrans credentials` and `midtrans credentials status`: preserve the + existing credential result in JSON; in human mode, render the credential + readiness section used by `midtrans setup` and recommend `midtrans setup`. +- `midtrans capabilities`: preserve its JSON contract; in human mode, render + the available products and journeys with a short explanation that the + machine interface moved to `midtrans agent capabilities`. +- `midtrans inspect`, `midtrans sandbox`, and `midtrans pack`: retain their + human renderers, add the migration notice, and point to their new merchant or + agent command. +- `midtrans evidence`, `midtrans manifest`, `midtrans plan`, and + `midtrans webhook`: remain callable as hidden advanced compatibility + commands with their existing JSON contracts and bounded human renderers. + +Every legacy human result must include at least one concrete `Next:` action and +must never fall back to the generic `PASS: ` renderer. + +- [ ] **Step 5: Run command, contract, and schema tests** + +Run: + +```bash +go test ./internal/app ./internal/contracts ./internal/packs -v +``` + +Expected: PASS; published capability and result schemas remain unchanged. + +- [ ] **Step 6: Commit** + +```bash +git add internal/app +git commit -m "feat: separate merchant and agent command surfaces" +``` + +--- + +### Task 9: Share Snap Checkout Execution With `midtrans test checkout` + +**Files:** +- Create: `internal/app/checkout_runner.go` +- Create: `internal/app/commands_test.go` +- Modify: `internal/app/commands_sandbox.go` +- Modify: `internal/app/commands_sandbox_run_test.go` +- Modify: `internal/app/app.go` +- Modify: `internal/app/app_test.go` +- Modify: `internal/presentation/model.go` +- Modify: `internal/presentation/model_test.go` + +**Interfaces:** +- Consumes: existing manifest validation, Snap plan/journey, secret provider, + policy, operation ledger, and evidence writer. +- Produces: + - `type checkoutRequest struct { Command, ProjectDir, OrderID string; GrossAmount int64; Execute, ProviderOnly bool }` + - `func runCheckout(context.Context, checkoutRequest, Dependencies) contracts.Result` + - `Dependencies.NewOrderID func() string` + - `midtrans test checkout --amount [--order-id ] [--execute]`. + +- [ ] **Step 1: Write failing merchant checkout parity tests** + +```go +func TestMerchantCheckoutPlansWithGeneratedOrderID(t *testing.T) { + project := createJourneyProject(t, "http://127.0.0.1:1") + result, exit := executeJSONWithDependencies( + t, + app.Dependencies{ + Version: version.Info{Version: "0.1.0-test"}, + Packs: testRegistry(t), + NewOrderID: func() string { return "midtrans-cli-test-001" }, + Getenv: func(string) (string, bool) { + t.Fatal("dry run resolved a credential") + return "", false + }, + HTTP: appDoerFunc(func(*http.Request) (*http.Response, error) { + t.Fatal("dry run called HTTP") + return nil, nil + }), + }, + "test", "checkout", + "--amount", "10000", + "--project-dir", project, + ) + if exit != 3 || + result.Command != "test.checkout" || + result.Status != contracts.StatusBlocked { + t.Fatalf("exit = %d, result = %#v", exit, result) + } + data := requireJourneyData(t, result) + if data["order_id"] != "midtrans-cli-test-001" { + t.Fatalf("data = %#v", data) + } +} + +func TestMerchantAndLegacyCheckoutShareTheSamePlan(t *testing.T) { + project := createJourneyProject(t, "http://127.0.0.1:1") + merchant, _ := executeJSON( + t, + "test", "checkout", "--amount", "10000", + "--order-id", "snap-fixture-001", + "--project-dir", project, + ) + legacy, _ := executeJSON( + t, + "sandbox", "run", "snap.checkout", + "--gross-amount", "10000", + "--order-id", "snap-fixture-001", + "--project-dir", project, + ) + merchantData := requireJourneyData(t, merchant) + legacyData := requireJourneyData(t, legacy) + if !reflect.DeepEqual(merchantData["plan"], legacyData["plan"]) { + t.Fatalf("merchant = %#v, legacy = %#v", merchantData, legacyData) + } +} +``` + +Add a human-output test requiring “Sandbox checkout,” amount, order reference, +“No provider request was sent,” and the exact `--execute` next command. + +- [ ] **Step 2: Run and verify RED** + +Run: + +```bash +go test ./internal/app -run 'TestMerchant.*Checkout' -v +``` + +Expected: FAIL because `midtrans test checkout` does not exist. + +- [ ] **Step 3: Extract shared checkout runner** + +Move the current `sandbox run snap.checkout` orchestration into: + +```go +type checkoutRequest struct { + Command string + ProjectDir string + OrderID string + GrossAmount int64 + Execute bool + ProviderOnly bool +} + +func runCheckout( + ctx context.Context, + request checkoutRequest, + deps Dependencies, +) contracts.Result { + value, invalid := loadValidatedManifest( + request.Command, request.ProjectDir, deps, + ) + if invalid != nil { + return *invalid + } + plan, err := snap.CheckoutPlan(request.OrderID, request.GrossAmount) + if err != nil { + return invalidCheckoutResult(request.Command, value.SchemaVersion, deps) + } + if !request.Execute { + proofScope := "merchant_integration" + if request.ProviderOnly { + proofScope = "provider_only" + } + result := contracts.NewResult(request.Command, contracts.StatusBlocked) + result.CLIVersion = deps.Version.Version + result.ManifestVersion = value.SchemaVersion + result.Data = map[string]any{ + "journey": "snap.checkout", + "state": snap.JourneyPlanned, + "order_id": request.OrderID, + "plan": plan, + "proof_scope": proofScope, + } + result.NextActions = []contracts.NextAction{{ + Action: "execute_sandbox_checkout", + Description: "review the plan and rerun this checkout with --execute", + }} + return result + } + decision := policy.Authorize( + plan, + policy.Authorization{Execute: request.Execute}, + ) + if !decision.Allowed { + result := contracts.NewPolicyBlockedResult( + request.Command, + decision.Code, + "Sandbox checkout execution is not authorized", + ) + result.CLIVersion = deps.Version.Version + result.ManifestVersion = value.SchemaVersion + result.Data = map[string]any{"plan": plan, "executed": false} + return result + } + serverKey, failure := resolveSandboxServerKey( + ctx, + request.Command, + value.SchemaVersion, + value.Credentials.References["server_key"], + deps, + ) + if failure != nil { + return *failure + } + startedAt := time.Now().UTC() + journey, runErr := (snap.JourneyRunner{ + Tokens: snap.Client{HTTP: deps.HTTP, ServerKey: serverKey}, + Status: snap.Client{HTTP: deps.HTTP, ServerKey: serverKey}, + Local: snap.MerchantVerifier{ + Manifest: value, + ServerKey: serverKey, + HTTP: localJourneyHTTPClient(deps.HTTP), + }, + Ledger: operations.Store{ProjectDir: request.ProjectDir}, + }).Run(ctx, snap.JourneyInput{ + OperationID: plan.Hash, + OrderID: request.OrderID, + GrossAmount: request.GrossAmount, + GrossAmountString: strconv.FormatInt(request.GrossAmount, 10) + ".00", + Execute: true, + Plan: plan, + }) + return checkoutJourneyResult( + request, value.SchemaVersion, startedAt, journey, runErr, deps, + ) +} +``` + +`checkoutJourneyResult` must retain the current evidence-writing behavior +unchanged when the journey is verified. The old Sandbox command and new +merchant command call this function with different `Command` values only. + +- [ ] **Step 4: Add merchant command and generated safe reference** + +```go +func newTestCheckoutCommand( + flags *globalFlags, + deps Dependencies, +) *cobra.Command { + var amount int64 + var orderID string + var execute bool + command := &cobra.Command{ + Use: "checkout", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + providerOnly := orderID == "" + if orderID == "" { + orderID = deps.NewOrderID() + } + result := runCheckout(cmd.Context(), checkoutRequest{ + Command: "test.checkout", + ProjectDir: flags.projectDir, + OrderID: orderID, + GrossAmount: amount, + Execute: execute, + ProviderOnly: providerOnly, + }, deps) + return writeResult(deps, flags, result) + }, + } + command.Flags().Int64Var(&amount, "amount", 0, "Sandbox amount in IDR") + command.Flags().StringVar(&orderID, "order-id", "", "existing merchant order reference") + command.Flags().BoolVar(&execute, "execute", false, "execute the reviewed Sandbox plan") + _ = command.MarkFlagRequired("amount") + return withProjectMode(command, project.Existing, "test.checkout") +} +``` + +Default `NewOrderID` uses `crypto/rand` and produces +`midtrans-cli--<8 lowercase hex characters>`. Generated IDs +set `proof_scope: provider_only`; supplied IDs set +`proof_scope: merchant_integration`. + +For an interactive human terminal without `--execute`, render the reviewed plan +first and prompt `Execute this Sandbox checkout? Type yes to continue:`. Only +the exact answer `yes` reruns `runCheckout` with `Execute: true`; any other +answer exits without provider HTTP. JSON, `--non-interactive`, piped stdin, and +explicit `--execute` never prompt. Add tests proving a rejected prompt performs +zero credential resolution and zero HTTP, while an accepted prompt executes +exactly once. + +- [ ] **Step 5: Add checkout presentation** + +Extend `presentation.Build` for `test.checkout` and `sandbox.run`. Render: + +- Sandbox environment. +- IDR amount. +- Order reference. +- Planned provider host. +- Proof scope. +- Whether a provider request was sent. +- Redirect URL when checkout completion is required. +- Individual provider/local proof when verified. + +- [ ] **Step 6: Run checkout, policy, evidence, and app tests** + +Run: + +```bash +go test ./internal/app ./internal/policy ./internal/evidence ./packs/snap -v +``` + +Expected: PASS. Legacy JSON and new merchant plans have identical policy hashes +for identical input. + +- [ ] **Step 7: Commit** + +```bash +git add internal/app internal/presentation +git commit -m "feat: add merchant Sandbox checkout command" +``` + +--- + +### Task 10: Add `midtrans test webhook` + +**Files:** +- Create: `internal/app/webhook_test_runner.go` +- Modify: `internal/app/commands_test.go` +- Modify: `internal/app/app_test.go` +- Modify: `internal/presentation/model.go` +- Modify: `internal/presentation/model_test.go` + +**Interfaces:** +- Consumes: manifest local routes, Sandbox server-key reference, + `snap.MerchantVerifier`, and policy operation plans. +- Produces: + - `type webhookTestRequest struct { Command, ProjectDir, OrderID string; GrossAmount int64; Execute bool }` + - `func runWebhookTest(context.Context, webhookTestRequest, Dependencies) contracts.Result` + - `midtrans test webhook [--order-id ] [--amount ] [--execute]`. + +- [ ] **Step 1: Write failing local webhook proof tests** + +```go +func TestMerchantWebhookTestPlansWithoutHTTP(t *testing.T) { + project := createJourneyProject(t, "http://127.0.0.1:1") + result, exit := executeJSONWithDependencies( + t, + app.Dependencies{ + Version: version.Info{Version: "0.1.0-test"}, + Packs: testRegistry(t), + Getenv: func(string) (string, bool) { + t.Fatal("plan resolved credentials") + return "", false + }, + HTTP: appDoerFunc(func(*http.Request) (*http.Response, error) { + t.Fatal("plan called HTTP") + return nil, nil + }), + }, + "test", "webhook", + "--order-id", "ORDER-33333333-3333-4333-8333-333333333333", + "--amount", "10000", + "--project-dir", project, + ) + if exit != 3 || + result.Command != "test.webhook" || + result.Status != contracts.StatusBlocked { + t.Fatalf("exit = %d, result = %#v", exit, result) + } +} + +func TestMerchantWebhookTestVerifiesSettlementDuplicateAndLatePending(t *testing.T) { + server, state := newMerchantJourneyServer( + t, + "ORDER-33333333-3333-4333-8333-333333333333", + ) + defer server.Close() + project := createJourneyProject(t, server.URL) + result, exit := executeJSONWithDependencies( + t, + app.Dependencies{ + Version: version.Info{Version: "0.1.0-test"}, + Packs: testRegistry(t), + Getenv: func(string) (string, bool) { + return journeyServerKeyCanary, true + }, + HTTP: server.Client(), + }, + "test", "webhook", + "--order-id", state.orderID, + "--amount", "10000", + "--execute", + "--project-dir", project, + ) + if exit != 0 || result.Status != contracts.StatusPass { + t.Fatalf("exit = %d, result = %#v", exit, result) + } + data := result.Data.(map[string]any) + for _, key := range []string{ + "settlement_applied", "duplicate_idempotent", "late_pending_ignored", + } { + if data[key] != true { + t.Fatalf("%s = %#v", key, data[key]) + } + } +} +``` + +- [ ] **Step 2: Run and verify RED** + +Run: + +```bash +go test ./internal/app -run TestMerchantWebhookTest -v +``` + +Expected: FAIL because the merchant webhook test command does not exist. + +- [ ] **Step 3: Implement planned local proof** + +```go +func runWebhookTest( + ctx context.Context, + request webhookTestRequest, + deps Dependencies, +) contracts.Result { + value, invalid := loadValidatedManifest( + request.Command, request.ProjectDir, deps, + ) + if invalid != nil { + return *invalid + } + plan, err := policy.BuildPlan(policy.Operation{ + Environment: "sandbox", + Method: http.MethodPost, + URL: strings.TrimRight(value.Integration.LocalBaseURL, "/") + + value.Integration.NotificationRoute, + Class: policy.Mutating, + SafeSummary: map[string]any{ + "journey": "common.webhook-idempotency", + "order_id": request.OrderID, + "gross_amount": request.GrossAmount, + }, + }) + if err != nil { + return localVerificationRouteFailure(request.Command, value, deps) + } + if !request.Execute { + result := contracts.NewResult(request.Command, contracts.StatusBlocked) + result.CLIVersion = deps.Version.Version + result.ManifestVersion = value.SchemaVersion + result.Data = map[string]any{"plan": plan, "executed": false} + result.NextActions = []contracts.NextAction{{ + Action: "execute_local_webhook_test", + Description: "review the local mutation plan and rerun with --execute", + }} + return result + } + decision := policy.Authorize( + plan, + policy.Authorization{Execute: request.Execute}, + ) + if !decision.Allowed { + result := contracts.NewPolicyBlockedResult( + request.Command, + decision.Code, + "local webhook test execution is not authorized", + ) + result.CLIVersion = deps.Version.Version + result.ManifestVersion = value.SchemaVersion + result.Data = map[string]any{"plan": plan, "executed": false} + return result + } + serverKey, failure := resolveSandboxServerKey( + ctx, request.Command, value.SchemaVersion, + value.Credentials.References["server_key"], deps, + ) + if failure != nil { + return *failure + } + proof, err := (snap.MerchantVerifier{ + Manifest: value, + ServerKey: serverKey, + HTTP: localJourneyHTTPClient(deps.HTTP), + }).VerifyLocal(ctx, snap.LocalVerificationInput{ + OrderID: request.OrderID, + GrossAmount: strconv.FormatInt(request.GrossAmount, 10) + ".00", + }) + if err != nil || !proof.Passed() { + return localVerificationFailure(request.Command, value, deps) + } + result := contracts.NewResult(request.Command, contracts.StatusPass) + result.CLIVersion = deps.Version.Version + result.ManifestVersion = value.SchemaVersion + result.Data = proof + return result +} +``` + +The command uses `--amount`, `--order-id`, and `--execute`; both data flags are +required inputs but are optional flags in interactive human mode. When either +is absent on an interactive terminal, prompt for the merchant application order +reference and IDR amount before building the plan. JSON, `--non-interactive`, +or piped invocations with missing inputs return a structured +`WEBHOOK_TEST_INPUT_REQUIRED` result and a next action containing the exact +flag-based command; they do not return generic usage. It never writes full +journey evidence because provider proof is not part of this command. + +For an interactive human terminal without `--execute`, render the mutation plan +and prompt `Execute this local webhook test? Type yes to continue:`. Only the +exact answer `yes` authorizes execution. JSON, `--non-interactive`, piped stdin, +and explicit `--execute` never prompt. Add rejection and acceptance tests that +assert the local notification route receives zero or exactly three POSTs, +respectively. Add a bare `midtrans test webhook` interactive test and a +non-interactive missing-input test so the primary merchant command remains +usable without memorizing flags. + +- [ ] **Step 4: Add webhook presentation** + +Render rows for: + +- Signature generated and accepted. +- Settlement applied. +- Duplicate settlement idempotent. +- Late pending ignored. +- Final payment status. +- Fulfillment count. + +Never render the generated signature or raw payload. + +- [ ] **Step 5: Run app, Snap, webhook, and redaction tests** + +Run: + +```bash +go test ./internal/app ./packs/snap ./internal/webhook ./internal/evidence -v +``` + +Expected: PASS with no signature, key, or raw notification in output. + +- [ ] **Step 6: Commit** + +```bash +git add internal/app internal/presentation +git commit -m "feat: add merchant webhook verification command" +``` + +--- + +### Task 11: Add Versioned Local Installation and Complete Documentation Gates + +**Files:** +- Create: `tools/install-local.sh` +- Create: `tools/test-install-local.sh` +- Modify: `tools/check_release.sh` +- Modify: `README.md` +- Modify: `docs/agent-skill-compatibility.md` +- Modify: `internal/app/app_test.go` + +**Interfaces:** +- Consumes: Go build, `midtrans version`, and agent capabilities. +- Produces: regular executable in `${MIDTRANS_INSTALL_DIR:-$HOME/.local/bin}`. + +- [ ] **Step 1: Write the failing installer smoke script** + +```sh +#!/bin/sh +set -eu + +repo_dir=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd) +test_root=$(mktemp -d) +trap 'rm -rf "$test_root"' EXIT INT TERM + +MIDTRANS_INSTALL_DIR="$test_root/bin" "$repo_dir/tools/install-local.sh" +binary="$test_root/bin/midtrans" + +test -f "$binary" +test ! -L "$binary" +"$binary" version --json --non-interactive >/dev/null +"$binary" agent capabilities --json --non-interactive >/dev/null + +other_dir="$test_root/unrelated" +mkdir -p "$other_dir" +( + cd "$other_dir" + "$binary" version --json --non-interactive >/dev/null +) + +printf '%s\n' 'previous-working-binary' >"$binary" +cp "$binary" "$test_root/previous" +if GOFLAGS='-definitely-invalid' \ + MIDTRANS_INSTALL_DIR="$test_root/bin" \ + "$repo_dir/tools/install-local.sh"; then + echo "installer unexpectedly succeeded with invalid build flags" >&2 + exit 1 +fi +cmp "$binary" "$test_root/previous" +``` + +Make it executable. + +- [ ] **Step 2: Run and verify RED** + +Run: + +```bash +./tools/test-install-local.sh +``` + +Expected: FAIL because `tools/install-local.sh` does not exist. + +- [ ] **Step 3: Implement atomic no-sudo installation** + +```sh +#!/bin/sh +set -eu + +repo_dir=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd) +install_dir=${MIDTRANS_INSTALL_DIR:-"$HOME/.local/bin"} +mkdir -p "$install_dir" + +tmp_binary=$(mktemp "$install_dir/.midtrans.XXXXXX") +cleanup() { + rm -f "$tmp_binary" +} +trap cleanup EXIT INT TERM + +version=${MIDTRANS_DEV_VERSION:-dev} +commit=$(git -C "$repo_dir" rev-parse --verify HEAD) +build_date=$(date -u +%Y-%m-%dT%H:%M:%SZ) + +( + cd "$repo_dir" + CGO_ENABLED=0 go build -trimpath \ + -ldflags "-s -w \ + -X github.com/veritrans/midtrans-cli/internal/version.buildVersion=$version \ + -X github.com/veritrans/midtrans-cli/internal/version.buildCommit=$commit \ + -X github.com/veritrans/midtrans-cli/internal/version.buildDate=$build_date" \ + -o "$tmp_binary" ./cmd/midtrans +) +chmod 0755 "$tmp_binary" +"$tmp_binary" version --json --non-interactive >/dev/null +"$tmp_binary" agent capabilities --json --non-interactive >/dev/null +mv -f "$tmp_binary" "$install_dir/midtrans" +trap - EXIT INT TERM + +case ":${PATH:-}:" in + *":$install_dir:"*) ;; + *) + printf '%s\n' "Installed to $install_dir/midtrans." + printf '%s\n' "Add this directory to PATH:" + printf ' export PATH="%s:$PATH"\n' "$install_dir" + ;; +esac +``` + +The target is a regular file. Do not delete or overwrite any path other than +the exact temporary file and final `midtrans` binary. Because build and both +compatibility checks run against the temporary file, a failed build or +verification leaves any previous installed binary byte-for-byte unchanged. + +- [ ] **Step 4: Update release gate and documentation** + +Add to `tools/check_release.sh`: + +```sh +./tools/test-install-local.sh +``` + +Update README quick start: + +```text +tools/install-local.sh +cd /path/to/merchant +midtrans init +midtrans setup +midtrans status +midtrans test checkout --amount 10000 +midtrans test webhook +midtrans verify +``` + +Document the machine handshake separately: + +```text +midtrans agent capabilities --json --non-interactive +midtrans agent inspect --json --non-interactive +midtrans agent check --product snap --json --non-interactive +``` + +State that the future hosted `install.sh` remains unpublished until signed +release artifacts and the official domain are ready. + +- [ ] **Step 5: Add end-to-end CLI surface smoke test** + +Extend `TestHelpExposesExactlyThePhaseOneCommandSurface` with the final visible +tree: + +```go +expected := map[string][]string{ + "": {"agent", "init", "setup", "status", "test", "update", "verify", "version"}, + "agent": {"capabilities", "check", "inspect", "pack"}, + "test": {"checkout", "webhook"}, + "update": {"check"}, +} +``` + +Also assert legacy commands remain callable in JSON but absent from visible +help. + +- [ ] **Step 6: Run all verification gates** + +Run: + +```bash +gofmt -w \ + internal/project/*.go \ + internal/readiness/*.go \ + internal/presentation/*.go \ + internal/inspection/*.go \ + internal/manifest/*.go \ + internal/render/*.go \ + internal/app/app.go \ + internal/app/app_test.go \ + internal/app/checkout_runner.go \ + internal/app/commands_capabilities.go \ + internal/app/commands_credentials.go \ + internal/app/commands_doctor.go \ + internal/app/commands_inspect.go \ + internal/app/commands_pack.go \ + internal/app/commands_sandbox.go \ + internal/app/commands_sandbox_run_test.go \ + internal/app/commands_test.go \ + internal/app/commands_update.go \ + internal/app/commands_version.go \ + internal/app/project_context.go \ + internal/app/webhook_test_runner.go +go test ./... +go vet ./... +./tools/test-install-local.sh +./tools/check_release.sh +go run github.com/goreleaser/goreleaser/v2@v2.17.0 build --snapshot --clean +git diff --check +``` + +Expected: all commands PASS; snapshot artifacts contain regular standalone +binaries and no production execution capability. + +- [ ] **Step 7: Install the verified development binary for the current user** + +Run: + +```bash +./tools/install-local.sh +test -f "$HOME/.local/bin/midtrans" +test ! -L "$HOME/.local/bin/midtrans" +cd /tmp +midtrans version +midtrans agent capabilities --json --non-interactive +``` + +Expected: the executable works outside the source repository and is not a +symlink. + +- [ ] **Step 8: Commit** + +```bash +git add README.md docs/agent-skill-compatibility.md tools internal/app/app_test.go +git commit -m "build: install and verify the merchant CLI locally" +``` + +--- + +## Plan Completion Gate + +Before handing the CLI to the Agent Skill migration: + +```bash +git status --short +go test ./... +go vet ./... +./tools/check_release.sh +midtrans version +midtrans status --project-dir /Users/salis/Personal/Code/salis-property +``` + +Required outcomes: + +- The source worktree is clean. +- The installed binary is a regular file. +- Merchant commands render actionable checks. +- Agent JSON contracts remain compatible. +- Salis Property can be detected from its repository root; nested-directory + verification is completed in the dedicated spike plan after the CLI changes + are available. diff --git a/docs/superpowers/plans/2026-07-26-salis-property-cli-spike.md b/docs/superpowers/plans/2026-07-26-salis-property-cli-spike.md new file mode 100644 index 0000000..867e3bd --- /dev/null +++ b/docs/superpowers/plans/2026-07-26-salis-property-cli-spike.md @@ -0,0 +1,1098 @@ +# Salis Property Midtrans CLI Verification Spike Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Prove the globally installed Midtrans CLI against Salis Property's existing Snap integration without weakening its authenticated production payment-status API or changing its BI-SNAP flows. + +**Architecture:** Add a dedicated, disabled-by-default loopback verification adapter under `app/api/dev`, backed by the existing order repository and protected by an explicit local-only environment gate. Track a Sandbox-only `.midtrans/manifest.yaml`, add a reproducible local test-order preparer, and run the CLI's checkout, webhook, and evidence journey against a clean spike branch. + +**Tech Stack:** Next.js 16 App Router, TypeScript, Vitest, PostgreSQL through the existing `postgres` client, globally installed Midtrans CLI. + +## Target Repository + +```text +/Users/salis/Personal/Code/salis-property +``` + +Execute this plan in an isolated worktree created from current `main`. The +primary checkout currently has unrelated user changes: + +```text +M lib/orders/payment-status.ts +M tests/payment-status-rules.test.ts +?? .midtrans/ +``` + +Do not modify, stage, discard, or copy those primary-worktree changes. Create +the spike branch as `codex/midtrans-cli-spike`. + +## Global Constraints + +- Read and follow `/Users/salis/Personal/Code/salis-property/AGENTS.md`. +- Preserve the active provider split: Snap for credit card and OTC; BI-SNAP for GoPay, GoPayLater, QRIS, and VA. +- The spike covers only the CLI's current Snap capability and must not claim BI-SNAP parity. +- Do not change the authenticated `POST /api/payment/status` production contract. +- Do not make the development verification route publicly usable. +- The route requires an explicit local-only flag, a loopback `NEXT_PUBLIC_SITE_URL`, non-production Node mode, and a loopback request hostname. +- Never expose Midtrans keys, signatures, tokens, customer data, or raw provider payloads. +- The CLI manifest stores environment variable names only. +- Do not source `.env.local` or print its contents; the merchant supplies Sandbox credentials through the invoking shell. +- Use IDR and existing order/payment state semantics. +- The final evidence run requires a clean committed repository revision. +- Run `npm test`, `npm run typecheck`, `npm run lint`, and `npm run build` before the live Sandbox journey. + +--- + +## File Structure + +### New files + +- `lib/midtrans/local-cli-verification.ts` — local-only gate, provider-order parsing, and proof-state mapping. +- `app/api/dev/midtrans-cli/status/[orderId]/route.ts` — exact CLI `GET` proof contract. +- `tests/local-midtrans-cli-verification.test.ts` — pure guard, parsing, and mapping tests. +- `tests/local-midtrans-cli-status-route.test.ts` — route behavior with repository mock. +- `scripts/prepare-midtrans-cli-order.mjs` — creates one reproducible pending local order and prints only its safe reference. +- `tests/prepare-midtrans-cli-order.test.mjs` — pure input/reference tests for the preparer. +- `.midtrans/manifest.yaml` — commit-safe Sandbox Snap project declaration. +- `.midtrans/.gitignore` — excludes evidence, operations, temporary data, and credentials. + +### Modified files + +- `.env.example` — disabled local verification flag. +- `tests/env-config-drift.test.ts` — documents local-only flags without wiring them to production. +- `package.json` — local order preparation script. +- `README.md` — exact local CLI spike workflow and proof boundaries. + +--- + +### Task 0: Create the Isolated Spike Worktree + +**Files:** +- No repository file changes. + +**Interfaces:** +- Consumes: the current local `main` commit without copying primary-worktree + modifications. +- Produces: branch `codex/midtrans-cli-spike` at + `/Users/salis/Personal/Code/salis-property-midtrans-cli-spike`. + +- [ ] **Step 1: Inspect existing branch and worktree state** + +Run: + +```bash +git -C /Users/salis/Personal/Code/salis-property status --short --branch +git -C /Users/salis/Personal/Code/salis-property worktree list +git -C /Users/salis/Personal/Code/salis-property branch --list codex/midtrans-cli-spike +``` + +Expected: the primary checkout still contains the unrelated user changes +listed above, and no existing branch or worktree occupies the spike target. If +either target already exists, inspect and reuse it only when it is clearly this +same unfinished spike; never delete or reset it. + +- [ ] **Step 2: Create the isolated branch and worktree** + +Run: + +```bash +git -C /Users/salis/Personal/Code/salis-property worktree add \ + -b codex/midtrans-cli-spike \ + /Users/salis/Personal/Code/salis-property-midtrans-cli-spike \ + main +``` + +Expected: the new worktree starts at the current local `main` commit and has a +clean status. + +- [ ] **Step 3: Read repository instructions and establish the baseline** + +Run from the new worktree: + +```bash +cat AGENTS.md +git status --short --branch +npm test +``` + +Expected: instructions are understood, the spike worktree is clean, and the +pre-change test baseline passes. A baseline failure must be diagnosed before +implementation rather than attributed to the spike. + +--- + +### Task 1: Add the Local-Only Verification Guard and State Mapping + +**Files:** +- Create: `lib/midtrans/local-cli-verification.ts` +- Create: `tests/local-midtrans-cli-verification.test.ts` + +**Interfaces:** +- Consumes: `ENABLE_LOCAL_MIDTRANS_CLI_VERIFICATION`, + `NEXT_PUBLIC_SITE_URL`, `NODE_ENV`, request URL, and order status. +- Produces: + - `func isLocalMidtransCliVerificationEnabled() bool` + - `func isLocalMidtransCliRequest(Request) bool` + - `func parseMidtransCliOrderId(string) { providerOrderId: string; orderId: string } | null` + - `func toMidtransCliState(providerOrderId: string, status: OrderStatus) { order_id: string; payment_status: string; fulfillment_count: number }`. + +- [ ] **Step 1: Write failing guard and mapping tests** + +```ts +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { + isLocalMidtransCliRequest, + isLocalMidtransCliVerificationEnabled, + parseMidtransCliOrderId, + toMidtransCliState, +} from "../lib/midtrans/local-cli-verification"; + +describe("local Midtrans CLI verification", () => { + const originalEnv = process.env; + + beforeEach(() => { + process.env = { ...originalEnv }; + process.env.NODE_ENV = "test"; + process.env.NEXT_PUBLIC_SITE_URL = "http://127.0.0.1:3101"; + process.env.ENABLE_LOCAL_MIDTRANS_CLI_VERIFICATION = "true"; + }); + + afterEach(() => { + process.env = originalEnv; + }); + + it("requires the explicit flag, loopback site URL, and non-production mode", () => { + expect(isLocalMidtransCliVerificationEnabled()).toBe(true); + + delete process.env.ENABLE_LOCAL_MIDTRANS_CLI_VERIFICATION; + expect(isLocalMidtransCliVerificationEnabled()).toBe(false); + + process.env.ENABLE_LOCAL_MIDTRANS_CLI_VERIFICATION = "true"; + process.env.NEXT_PUBLIC_SITE_URL = "https://salis.id"; + expect(isLocalMidtransCliVerificationEnabled()).toBe(false); + + process.env.NEXT_PUBLIC_SITE_URL = "http://127.0.0.1:3101"; + process.env.NODE_ENV = "production"; + expect(isLocalMidtransCliVerificationEnabled()).toBe(false); + }); + + it("accepts only loopback request hosts", () => { + expect(isLocalMidtransCliRequest( + new Request("http://127.0.0.1:3101/api/dev/midtrans-cli/status/x"), + )).toBe(true); + expect(isLocalMidtransCliRequest( + new Request("http://localhost:3101/api/dev/midtrans-cli/status/x"), + )).toBe(true); + expect(isLocalMidtransCliRequest( + new Request("https://salis.id/api/dev/midtrans-cli/status/x"), + )).toBe(false); + }); + + it("requires the canonical ORDER-prefixed UUID", () => { + expect(parseMidtransCliOrderId( + "ORDER-33333333-3333-4333-8333-333333333333", + )).toEqual({ + providerOrderId: "ORDER-33333333-3333-4333-8333-333333333333", + orderId: "33333333-3333-4333-8333-333333333333", + }); + expect(parseMidtransCliOrderId( + "33333333-3333-4333-8333-333333333333", + )).toBeNull(); + expect(parseMidtransCliOrderId("ORDER-not-a-uuid")).toBeNull(); + }); + + it("maps fulfillment state without exposing order details", () => { + expect(toMidtransCliState("ORDER-id", "paid")).toEqual({ + order_id: "ORDER-id", + payment_status: "paid", + fulfillment_count: 0, + }); + expect(toMidtransCliState("ORDER-id", "shipped")).toEqual({ + order_id: "ORDER-id", + payment_status: "shipped", + fulfillment_count: 1, + }); + }); +}); +``` + +- [ ] **Step 2: Run and verify RED** + +Run: + +```bash +npx vitest run tests/local-midtrans-cli-verification.test.ts +``` + +Expected: FAIL because the helper module does not exist. + +- [ ] **Step 3: Implement the helper** + +```ts +import type { OrderStatus } from "@/lib/repositories/orders"; + +const ORDER_ID_PATTERN = + /^ORDER-([0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12})$/i; + +const FULFILLMENT_STATUSES = new Set([ + "processing", + "shipped", + "delivered", +]); + +function isLoopbackHostname(hostname: string) { + return hostname === "localhost" || hostname === "127.0.0.1"; +} + +export function isLocalMidtransCliVerificationEnabled() { + if (process.env.ENABLE_LOCAL_MIDTRANS_CLI_VERIFICATION !== "true") { + return false; + } + if (process.env.NODE_ENV === "production") return false; + + try { + return isLoopbackHostname( + new URL(process.env.NEXT_PUBLIC_SITE_URL ?? "").hostname, + ); + } catch { + return false; + } +} + +export function isLocalMidtransCliRequest(request: Request) { + return isLoopbackHostname(new URL(request.url).hostname); +} + +export function parseMidtransCliOrderId(providerOrderId: string) { + const match = ORDER_ID_PATTERN.exec(providerOrderId); + if (!match) return null; + return { + providerOrderId, + orderId: match[1].toLowerCase(), + }; +} + +export function toMidtransCliState( + providerOrderId: string, + status: OrderStatus, +) { + return { + order_id: providerOrderId, + payment_status: status, + fulfillment_count: FULFILLMENT_STATUSES.has(status) ? 1 : 0, + }; +} +``` + +- [ ] **Step 4: Run focused tests and typecheck** + +Run: + +```bash +npx vitest run tests/local-midtrans-cli-verification.test.ts +npm run typecheck +``` + +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add lib/midtrans/local-cli-verification.ts tests/local-midtrans-cli-verification.test.ts +git commit -m "feat: guard local Midtrans CLI verification" +``` + +--- + +### Task 2: Add the Exact CLI Status Adapter + +**Files:** +- Create: `app/api/dev/midtrans-cli/status/[orderId]/route.ts` +- Create: `tests/local-midtrans-cli-status-route.test.ts` + +**Interfaces:** +- Consumes: Task 1 helpers and `getOrderById`. +- Produces: loopback-only + `GET /api/dev/midtrans-cli/status/{ORDER-prefixed-uuid}` returning exactly: + +```json +{ + "order_id": "ORDER-...", + "payment_status": "pending|paid|processing|shipped|delivered|cancelled|refunded", + "fulfillment_count": 0 +} +``` + +- [ ] **Step 1: Write failing route tests** + +```ts +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const getOrderById = vi.fn(); + +vi.mock("@/lib/repositories/orders", () => ({ + getOrderById, +})); + +import { GET } from "../app/api/dev/midtrans-cli/status/[orderId]/route"; + +describe("local Midtrans CLI status route", () => { + const originalEnv = process.env; + const providerOrderId = + "ORDER-33333333-3333-4333-8333-333333333333"; + + beforeEach(() => { + process.env = { ...originalEnv }; + process.env.NODE_ENV = "test"; + process.env.NEXT_PUBLIC_SITE_URL = "http://127.0.0.1:3101"; + process.env.ENABLE_LOCAL_MIDTRANS_CLI_VERIFICATION = "true"; + getOrderById.mockReset(); + }); + + afterEach(() => { + process.env = originalEnv; + }); + + it("returns the exact bounded proof contract", async () => { + getOrderById.mockResolvedValue({ + id: "33333333-3333-4333-8333-333333333333", + status: "paid", + }); + const response = await GET( + new Request( + `http://127.0.0.1:3101/api/dev/midtrans-cli/status/${providerOrderId}`, + ), + { params: Promise.resolve({ orderId: providerOrderId }) }, + ); + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ + order_id: providerOrderId, + payment_status: "paid", + fulfillment_count: 0, + }); + expect(getOrderById).toHaveBeenCalledWith( + "33333333-3333-4333-8333-333333333333", + ); + }); + + it("is unavailable when the request or environment is not local", async () => { + const response = await GET( + new Request( + `https://salis.id/api/dev/midtrans-cli/status/${providerOrderId}`, + ), + { params: Promise.resolve({ orderId: providerOrderId }) }, + ); + expect(response.status).toBe(404); + expect(getOrderById).not.toHaveBeenCalled(); + }); + + it("returns 404 for invalid or missing orders", async () => { + getOrderById.mockResolvedValue(null); + const response = await GET( + new Request( + `http://127.0.0.1:3101/api/dev/midtrans-cli/status/${providerOrderId}`, + ), + { params: Promise.resolve({ orderId: providerOrderId }) }, + ); + expect(response.status).toBe(404); + }); +}); +``` + +- [ ] **Step 2: Run and verify RED** + +Run: + +```bash +npx vitest run tests/local-midtrans-cli-status-route.test.ts +``` + +Expected: FAIL because the route does not exist. + +- [ ] **Step 3: Implement the guarded route** + +```ts +import { NextResponse } from "next/server"; +import { + isLocalMidtransCliRequest, + isLocalMidtransCliVerificationEnabled, + parseMidtransCliOrderId, + toMidtransCliState, +} from "@/lib/midtrans/local-cli-verification"; +import { getOrderById } from "@/lib/repositories/orders"; + +type RouteContext = { + params: Promise<{ orderId: string }>; +}; + +function unavailable() { + return NextResponse.json( + { error: "Local Midtrans CLI verification is unavailable" }, + { status: 404 }, + ); +} + +export async function GET(request: Request, context: RouteContext) { + if ( + !isLocalMidtransCliVerificationEnabled() || + !isLocalMidtransCliRequest(request) + ) { + return unavailable(); + } + + const { orderId: rawOrderId } = await context.params; + const parsed = parseMidtransCliOrderId(rawOrderId); + if (!parsed) return unavailable(); + + const order = await getOrderById(parsed.orderId); + if (!order) return unavailable(); + + return NextResponse.json( + toMidtransCliState(parsed.providerOrderId, order.status), + ); +} +``` + +Do not use the authenticated status domain service: it performs provider +polling and requires a user session, while this route only exposes bounded +local proof for an already-known order. + +- [ ] **Step 4: Run route, logging, architecture, and type tests** + +Run: + +```bash +npx vitest run tests/local-midtrans-cli-status-route.test.ts +npx vitest run tests/route-logging.test.ts tests/no-obsolete-runtime-architecture.test.mjs +npm run typecheck +``` + +Expected: PASS. If the architecture guard enumerates allowed `app/api/dev` +routes, update it narrowly to include this exact route and keep the local-only +guard assertion. + +- [ ] **Step 5: Commit** + +```bash +git add app/api/dev/midtrans-cli tests/local-midtrans-cli-status-route.test.ts tests/no-obsolete-runtime-architecture.test.mjs +git commit -m "feat: expose loopback Midtrans CLI proof state" +``` + +--- + +### Task 3: Document the Local Flag Without Production Wiring + +**Files:** +- Modify: `.env.example` +- Modify: `tests/env-config-drift.test.ts` +- Modify: `README.md` + +**Interfaces:** +- Consumes: local-only environment conventions. +- Produces: documented `ENABLE_LOCAL_MIDTRANS_CLI_VERIFICATION=false`. + +- [ ] **Step 1: Write a failing local-only env drift assertion** + +Add: + +```ts +const localOnlyEnvKeys = [ + "ENABLE_LOCAL_MOCK_SESSION", + "ENABLE_LOCAL_MIDTRANS_CLI_VERIFICATION", +]; + +it("documents local-only flags without production wiring", () => { + const missing = localOnlyEnvKeys.filter( + (key) => !ENV_EXAMPLE.includes(`${key}=`), + ); + const accidentallyProductionWired = localOnlyEnvKeys.filter( + (key) => + TERRAFORM_SECRETS.includes(`"${key}"`) || + CLOUD_RUN.includes(`"${key}"`), + ); + expect({ missing, accidentallyProductionWired }).toEqual({ + missing: [], + accidentallyProductionWired: [], + }); +}); +``` + +- [ ] **Step 2: Run and verify RED** + +Run: + +```bash +npx vitest run tests/env-config-drift.test.ts +``` + +Expected: FAIL because the new local-only variable is absent. + +- [ ] **Step 3: Add the documented disabled flag** + +Append under the local-only section of `.env.example`: + +```env +# Allows the Midtrans CLI to read bounded order proof only on loopback in +# non-production Node mode. Never enable in production. +ENABLE_LOCAL_MIDTRANS_CLI_VERIFICATION=false +``` + +Update README local prerequisites: + +```markdown +For the Midtrans CLI verification spike only, run the app with +`ENABLE_LOCAL_MIDTRANS_CLI_VERIFICATION=true`, +`NEXT_PUBLIC_SITE_URL=http://127.0.0.1:3101`, and non-production Node mode. +The route remains unavailable on non-loopback hosts and in production mode. +``` + +- [ ] **Step 4: Run env, lint, and type checks** + +Run: + +```bash +npx vitest run tests/env-config-drift.test.ts +npm run lint +npm run typecheck +``` + +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add .env.example tests/env-config-drift.test.ts README.md +git commit -m "docs: configure local Midtrans CLI verification" +``` + +--- + +### Task 4: Add a Reproducible Pending Test Order + +**Files:** +- Create: `scripts/prepare-midtrans-cli-order.mjs` +- Create: `tests/prepare-midtrans-cli-order.test.mjs` +- Modify: `package.json` +- Modify: `README.md` + +**Interfaces:** +- Consumes: `DATABASE_URL` and existing `postgres` package. +- Produces: + - `normalizeOrderId(value?: string): string` + - `providerOrderId(orderId: string): string` + - `npm run midtrans:prepare-order -- [optional-uuid]` + - Safe JSON output containing only `orderId`, `providerOrderId`, and + `grossAmount`. + +- [ ] **Step 1: Write failing pure script tests** + +```js +import assert from "node:assert/strict"; +import test from "node:test"; +import { + normalizeOrderId, + providerOrderId, +} from "../scripts/prepare-midtrans-cli-order.mjs"; + +test("normalizes a supplied UUID and creates the provider reference", () => { + const id = normalizeOrderId( + "33333333-3333-4333-8333-333333333333", + ); + assert.equal(id, "33333333-3333-4333-8333-333333333333"); + assert.equal( + providerOrderId(id), + "ORDER-33333333-3333-4333-8333-333333333333", + ); +}); + +test("rejects non-UUID order identifiers", () => { + assert.throws(() => normalizeOrderId("not-an-order"), /valid UUID/); +}); + +test("generates a UUID when none is supplied", () => { + assert.match(normalizeOrderId(), /^[0-9a-f-]{36}$/); +}); +``` + +- [ ] **Step 2: Run and verify RED** + +Run: + +```bash +node --test tests/prepare-midtrans-cli-order.test.mjs +``` + +Expected: FAIL because the script module does not exist. + +- [ ] **Step 3: Implement the preparer** + +```js +#!/usr/bin/env node + +import { randomUUID } from "node:crypto"; +import { pathToFileURL } from "node:url"; +import postgres from "postgres"; + +const UUID_PATTERN = + /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; + +export function normalizeOrderId(value) { + const candidate = (value ?? randomUUID()).toLowerCase(); + if (!UUID_PATTERN.test(candidate)) { + throw new Error("order id must be a valid UUID"); + } + return candidate; +} + +export function providerOrderId(orderId) { + return `ORDER-${orderId}`; +} + +async function main() { + if (!process.env.DATABASE_URL) { + throw new Error("DATABASE_URL is required"); + } + const orderId = normalizeOrderId(process.argv[2]); + const userId = "11111111-1111-4111-8111-111111111111"; + const grossAmount = 10000; + const sql = postgres(process.env.DATABASE_URL, { max: 1 }); + try { + await sql.begin(async (tx) => { + await tx` + INSERT INTO profiles (id, full_name, phone, is_admin) + VALUES (${userId}, 'Midtrans CLI Test Customer', '080000000000', false) + ON CONFLICT (id) DO NOTHING + `; + await tx` + INSERT INTO orders ( + id, user_id, status, total_amount, shipping_cost, + shipping_address, delivery_method, payment_method, + payment_provider, midtrans_order_id + ) + VALUES ( + ${orderId}, ${userId}, 'pending', ${grossAmount}, 0, + ${sql.json({ address: "Local CLI verification only" })}, + 'pickup', 'credit_card', 'snap', ${providerOrderId(orderId)} + ) + `; + }); + console.log(JSON.stringify({ + orderId, + providerOrderId: providerOrderId(orderId), + grossAmount, + })); + } finally { + await sql.end({ timeout: 5 }); + } +} + +if ( + process.argv[1] && + import.meta.url === pathToFileURL(process.argv[1]).href +) { + main().catch((error) => { + console.error(error instanceof Error ? error.message : "order preparation failed"); + process.exitCode = 1; + }); +} +``` + +Add to `package.json`: + +```json +"midtrans:prepare-order": "node scripts/prepare-midtrans-cli-order.mjs" +``` + +The script must never print `DATABASE_URL` or any credential. + +- [ ] **Step 4: Run script tests and the existing Node test suite** + +Run: + +```bash +node --test tests/prepare-midtrans-cli-order.test.mjs +npm test +``` + +Expected: PASS. Database execution is exercised during the final local spike. + +- [ ] **Step 5: Commit** + +```bash +git add scripts/prepare-midtrans-cli-order.mjs tests/prepare-midtrans-cli-order.test.mjs package.json README.md +git commit -m "test: prepare a local Midtrans CLI order" +``` + +--- + +### Task 5: Track the Per-Project CLI Manifest + +**Files:** +- Create: `.midtrans/manifest.yaml` +- Create: `.midtrans/.gitignore` +- Modify: `README.md` + +**Interfaces:** +- Consumes: merchant-first `midtrans init`, project-local configuration. +- Produces: Salis Property Snap configuration with no secret values. + +- [ ] **Step 1: Initialize the worktree-local manifest** + +Run from the isolated Salis Property worktree root: + +```bash +midtrans init +``` + +Expected: `.midtrans/manifest.yaml` and `.midtrans/.gitignore` are created in +this worktree, not in a nested source folder. + +- [ ] **Step 2: Replace the generated manifest with the reviewed configuration** + +```yaml +schema_version: 1 +environment_policy: + allowed: + - sandbox + production: disabled +products: + - snap +integration: + checkout_modes: + - popup + notification_route: /api/payment/webhook + finish_redirect_route: /pesanan/{order_id} + local_base_url: http://127.0.0.1:3101 + local_status_route: /api/dev/midtrans-cli/status/{order_id} + remote_webhook_hosts: [] +state_policy: + paid: + - capture + - settlement + terminal: + - settlement + - deny + - cancel + - expire + monotonic: true +credentials: + provider: environment + references: + client_key: NEXT_PUBLIC_MIDTRANS_CLIENT_KEY + server_key: MIDTRANS_SERVER_KEY +required_journeys: + - snap.checkout + - common.webhook-idempotency + - common.status-reconciliation +``` + +`.midtrans/.gitignore` must contain: + +```gitignore +evidence/ +operations/ +tmp/ +credentials* +*.secret +``` + +- [ ] **Step 3: Validate from root and a nested checkout folder** + +Run: + +```bash +midtrans status +cd app/checkout +midtrans status +cd ../.. +midtrans agent check --product snap --json --non-interactive +``` + +Expected: both status commands identify the same repository manifest; agent +check returns result schema `1.0` and manifest version `1`. + +- [ ] **Step 4: Document the provider boundary** + +Add to README: + +```markdown +The Midtrans CLI manifest currently verifies only the Snap portion of this +repository: credit card and OTC checkout, `/api/payment/webhook`, and local +status monotonicity. GoPay, GoPayLater, QRIS, and virtual-account paths remain +BI-SNAP application flows and are not CLI-verified in Phase 1. +``` + +- [ ] **Step 5: Commit** + +```bash +git add .midtrans README.md +git commit -m "chore: initialize Midtrans CLI for Salis Property" +``` + +--- + +### Task 6: Verify the Local Adapter Before Provider Execution + +**Files:** +- No new files. + +**Interfaces:** +- Consumes: committed adapter, prepared local order, running application, + Sandbox Server Key in the shell. +- Produces: deterministic local webhook proof without a provider mutation. + +- [ ] **Step 1: Run the full repository verification suite** + +Run: + +```bash +npm test +npm run typecheck +npm run lint +npm run build +git diff --check +git status --short +``` + +Expected: all checks PASS and the worktree is clean after committing the prior +tasks. + +- [ ] **Step 2: Start local PostgreSQL and apply schema** + +Use the repository's documented local database workflow: + +```bash +podman run --name salis-property-postgres \ + -e POSTGRES_USER=salis_app \ + -e POSTGRES_PASSWORD=password \ + -e POSTGRES_DB=salis_property \ + -p 55432:5432 \ + -d docker.io/library/postgres:16-alpine + +export DATABASE_URL='postgresql://salis_app:password@127.0.0.1:55432/salis_property' +psql "$DATABASE_URL" -v ON_ERROR_STOP=1 -f database/schema.sql +for migration_file in database/migrations/*.sql; do + psql "$DATABASE_URL" -v ON_ERROR_STOP=1 -f "$migration_file" +done +``` + +If the named container already exists, start it instead of creating another. +Do not delete an existing database. + +- [ ] **Step 3: Prepare a unique pending order** + +Run: + +```bash +npm run midtrans:prepare-order +``` + +Expected safe output: + +```json +{ + "orderId": "", + "providerOrderId": "ORDER-", + "grossAmount": 10000 +} +``` + +Record only `providerOrderId` and `grossAmount`; do not copy database or +credential values into evidence. + +- [ ] **Step 4: Start the application with local verification enabled** + +In a separate terminal: + +```bash +export DATABASE_URL='postgresql://salis_app:password@127.0.0.1:55432/salis_property' +export NEXT_PUBLIC_SITE_URL='http://127.0.0.1:3101' +export ENABLE_LOCAL_MIDTRANS_CLI_VERIFICATION='true' +export MIDTRANS_SERVER_KEY='' +npm run dev -- --hostname 127.0.0.1 --port 3101 +``` + +The merchant supplies the Sandbox key directly in their terminal. Do not place +it in this plan, chat, shell history generated by an agent, or repository file. + +- [ ] **Step 5: Plan and execute only the local webhook proof** + +First run: + +```bash +midtrans test webhook \ + --order-id 'ORDER-' \ + --amount 10000 +``` + +Expected: a local mutation plan and no HTTP mutation. + +After the merchant reviews and approves the plan: + +```bash +midtrans test webhook \ + --order-id 'ORDER-' \ + --amount 10000 \ + --execute +``` + +Expected: + +```text +✓ Settlement applied +✓ Duplicate settlement idempotent +✓ Late pending ignored +✓ Final payment status paid +``` + +- [ ] **Step 6: Re-run repository tests after the local mutation** + +Run: + +```bash +npm test +git status --short +``` + +Expected: tests PASS and only ignored `.midtrans/operations` runtime state may +have changed. + +--- + +### Task 7: Complete the Real Sandbox Checkout and Evidence Journey + +**Files:** +- Runtime evidence under ignored `.midtrans/evidence/`. + +**Interfaces:** +- Consumes: clean repository revision, running local app, unique pending order, + merchant-supplied Sandbox Server Key, and human checkout completion. +- Produces: checksummed evidence bound to repository commit, manifest hash, + Snap pack version, and Sandbox proof. + +- [ ] **Step 1: Confirm merchant readiness without reading credential values** + +Run: + +```bash +midtrans status +midtrans agent check --product snap --json --non-interactive +``` + +Required state: + +- Project and manifest detected. +- Environment is Sandbox. +- Server-key and client-key references resolve in the invoking environment. +- Local app is reachable. +- Snap checkout, webhook, and local status routes are ready. +- Credit card or OTC method is active in the merchant's Midtrans Sandbox + account. + +- [ ] **Step 2: Prepare a new unique pending order** + +Run: + +```bash +npm run midtrans:prepare-order +``` + +Do not reuse the order from Task 6 because Midtrans provider order identifiers +must be unique for a new checkout. + +- [ ] **Step 3: Review the provider mutation plan** + +Run without execution: + +```bash +midtrans test checkout \ + --order-id 'ORDER-' \ + --amount 10000 +``` + +Expected: + +- Host is `app.sandbox.midtrans.com`. +- Environment is `sandbox`. +- Amount is IDR 10,000. +- No provider request was sent. +- Output instructs the merchant to rerun with `--execute`. + +- [ ] **Step 4: Obtain explicit merchant approval** + +Show the exact plan from Step 3. Do not proceed until the merchant explicitly +approves the Sandbox mutation. + +- [ ] **Step 5: Execute the Sandbox checkout** + +Run: + +```bash +midtrans test checkout \ + --order-id 'ORDER-' \ + --amount 10000 \ + --execute +``` + +Expected: checkout-required state and a hosted +`https://app.sandbox.midtrans.com/...` URL. No production host is permitted. + +- [ ] **Step 6: Complete the hosted checkout and resume** + +The merchant completes the payment in Midtrans Sandbox. Then rerun the exact +Step 5 command. + +Expected: + +- Provider status is settlement or accepted capture. +- Salis Property webhook accepts the signed notification. +- Duplicate settlement is idempotent. +- Late pending does not downgrade paid. +- Evidence is written under `.midtrans/evidence/` with mode `0600`. + +- [ ] **Step 7: Verify the evidence** + +Run: + +```bash +midtrans verify \ + --product snap \ + --evidence '.midtrans/evidence/.json' +``` + +Expected: verified provider-status and merchant-callback proofs. Do not export +or share the evidence until its bounded contents are reviewed. + +- [ ] **Step 8: Final repository and proof check** + +Run: + +```bash +git status --short --branch +npm test +npm run typecheck +npm run lint +npm run build +``` + +Expected: repository remains clean because evidence and operation state are +ignored. Report BI-SNAP as outside current CLI parity rather than unverified or +failed. + +--- + +## Plan Completion Gate + +The spike is complete only when: + +1. The adapter is disabled by default and unreachable from non-loopback or + production mode. +2. Existing authenticated `/api/payment/status` behavior is unchanged. +3. Snap webhook signature, idempotency, and monotonicity tests pass. +4. The global CLI detects the project from root and nested directories. +5. Local webhook proof passes against an existing Salis Property order. +6. A merchant-approved real Sandbox checkout completes. +7. `midtrans verify` accepts the checksummed evidence for the clean committed + repository revision. +8. No credential, signature, token, customer data, or unrestricted payload + appears in terminal output, repository files, or evidence. diff --git a/docs/superpowers/specs/2026-07-26-merchant-cli-experience-design.md b/docs/superpowers/specs/2026-07-26-merchant-cli-experience-design.md index c8d811c..87b027b 100644 --- a/docs/superpowers/specs/2026-07-26-merchant-cli-experience-design.md +++ b/docs/superpowers/specs/2026-07-26-merchant-cli-experience-design.md @@ -1,6 +1,6 @@ # Midtrans CLI Merchant Experience and Project Discovery Design -- **Status:** Review requested +- **Status:** Approved - **Date:** 2026-07-26 - **Product:** Midtrans CLI - **Binary:** `midtrans` From 0e6162701eb26f840c4fac38d86b5c1087973212 Mon Sep 17 00:00:00 2001 From: "m.salis" Date: Sun, 26 Jul 2026 08:30:54 +0700 Subject: [PATCH 03/73] feat: discover Midtrans projects from nested directories --- internal/project/discovery.go | 151 +++++++++++++++++++++++ internal/project/discovery_test.go | 188 +++++++++++++++++++++++++++++ 2 files changed, 339 insertions(+) create mode 100644 internal/project/discovery.go create mode 100644 internal/project/discovery_test.go diff --git a/internal/project/discovery.go b/internal/project/discovery.go new file mode 100644 index 0000000..bfa44ad --- /dev/null +++ b/internal/project/discovery.go @@ -0,0 +1,151 @@ +package project + +import ( + "bytes" + "errors" + "os" + "os/exec" + "path/filepath" +) + +type Mode string + +const ( + Existing Mode = "existing" + Initializable Mode = "initializable" +) + +var ( + ErrNotInitialized = errors.New("project is not initialized") + ErrDirectoryUnavailable = errors.New("project directory is unavailable") + ErrUnsafePath = errors.New("project path is unsafe") +) + +type Request struct { + StartDir string + ExplicitDir string + Mode Mode + GitRoot func(string) (string, error) +} + +type Resolution struct { + Root string + Initialized bool +} + +func Resolve(request Request) (Resolution, error) { + start := request.StartDir + if request.ExplicitDir != "" { + start = request.ExplicitDir + } + root, err := regularDirectory(start) + if err != nil { + return Resolution{}, err + } + if request.ExplicitDir != "" { + return exact(root, request.Mode) + } + if found, ok, err := searchParents(root); err != nil { + return Resolution{}, err + } else if ok { + return Resolution{Root: found, Initialized: true}, nil + } + if request.Mode == Existing { + return Resolution{}, ErrNotInitialized + } + resolver := request.GitRoot + if resolver == nil { + resolver = gitRoot + } + if candidate, err := resolver(root); err == nil { + canonical, canonicalErr := regularDirectory(candidate) + if canonicalErr != nil { + return Resolution{}, canonicalErr + } + return Resolution{Root: canonical}, nil + } + return Resolution{Root: root}, nil +} + +func exact(root string, mode Mode) (Resolution, error) { + initialized, err := hasManifest(root) + if err != nil { + return Resolution{}, err + } + if initialized { + return Resolution{Root: root, Initialized: true}, nil + } + if mode == Existing { + return Resolution{}, ErrNotInitialized + } + return Resolution{Root: root}, nil +} + +func searchParents(start string) (string, bool, error) { + for current := start; ; current = filepath.Dir(current) { + ok, err := hasManifest(current) + if err != nil { + return "", false, err + } + if ok { + return current, true, nil + } + parent := filepath.Dir(current) + if parent == current { + return "", false, nil + } + } +} + +func hasManifest(root string) (bool, error) { + configDir := filepath.Join(root, ".midtrans") + configInfo, err := os.Lstat(configDir) + if errors.Is(err, os.ErrNotExist) { + return false, nil + } + if err != nil { + return false, ErrDirectoryUnavailable + } + if configInfo.Mode()&os.ModeSymlink != 0 || !configInfo.IsDir() { + return false, ErrUnsafePath + } + path := filepath.Join(configDir, "manifest.yaml") + info, err := os.Lstat(path) + if errors.Is(err, os.ErrNotExist) { + return false, nil + } + if err != nil { + return false, ErrDirectoryUnavailable + } + if info.Mode()&os.ModeSymlink != 0 || !info.Mode().IsRegular() { + return false, ErrUnsafePath + } + return true, nil +} + +func regularDirectory(candidate string) (string, error) { + absolute, err := filepath.Abs(candidate) + if err != nil { + return "", ErrDirectoryUnavailable + } + info, err := os.Lstat(absolute) + if err != nil { + return "", ErrDirectoryUnavailable + } + if info.Mode()&os.ModeSymlink != 0 { + return "", ErrUnsafePath + } + if !info.IsDir() { + return "", ErrDirectoryUnavailable + } + return filepath.Clean(absolute), nil +} + +func gitRoot(start string) (string, error) { + command := exec.Command("git", "-C", start, "rev-parse", "--show-toplevel") + output, err := command.Output() + if err != nil { + return "", err + } + return string(bytes.TrimSpace(output)), nil +} diff --git a/internal/project/discovery_test.go b/internal/project/discovery_test.go new file mode 100644 index 0000000..36f8c54 --- /dev/null +++ b/internal/project/discovery_test.go @@ -0,0 +1,188 @@ +package project_test + +import ( + "errors" + "os" + "path/filepath" + "testing" + + "github.com/veritrans/midtrans-cli/internal/project" +) + +func TestResolveExistingFindsNearestManifest(t *testing.T) { + root := t.TempDir() + nested := filepath.Join(root, "app", "checkout") + if err := os.MkdirAll(filepath.Join(root, ".midtrans"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile( + filepath.Join(root, ".midtrans", "manifest.yaml"), + []byte("schema_version: 1\n"), + 0o644, + ); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(nested, 0o755); err != nil { + t.Fatal(err) + } + + got, err := project.Resolve(project.Request{ + StartDir: nested, + Mode: project.Existing, + }) + if err != nil { + t.Fatal(err) + } + if got.Root != root || !got.Initialized { + t.Fatalf("resolution = %#v", got) + } +} + +func TestResolveExistingUsesNearestNestedProject(t *testing.T) { + outer := initializedProject(t) + inner := filepath.Join(outer, "packages", "store") + if err := os.MkdirAll(filepath.Join(inner, ".midtrans"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile( + filepath.Join(inner, ".midtrans", "manifest.yaml"), + []byte("schema_version: 1\n"), + 0o644, + ); err != nil { + t.Fatal(err) + } + child := filepath.Join(inner, "src") + if err := os.MkdirAll(child, 0o755); err != nil { + t.Fatal(err) + } + + got, err := project.Resolve(project.Request{StartDir: child, Mode: project.Existing}) + if err != nil || got.Root != inner { + t.Fatalf("resolution = %#v, err = %v", got, err) + } +} + +func TestResolveExplicitDirectoryDoesNotSearchParents(t *testing.T) { + outer := initializedProject(t) + child := filepath.Join(outer, "src") + if err := os.MkdirAll(child, 0o755); err != nil { + t.Fatal(err) + } + + _, err := project.Resolve(project.Request{ + StartDir: child, + ExplicitDir: child, + Mode: project.Existing, + }) + if !errors.Is(err, project.ErrNotInitialized) { + t.Fatalf("err = %v", err) + } +} + +func TestResolveInitializableUsesGitRootThenCurrentDirectory(t *testing.T) { + start := t.TempDir() + gitRoot := filepath.Join(start, "repository") + child := filepath.Join(gitRoot, "src") + if err := os.MkdirAll(child, 0o755); err != nil { + t.Fatal(err) + } + got, err := project.Resolve(project.Request{ + StartDir: child, + Mode: project.Initializable, + GitRoot: func(string) (string, error) { return gitRoot, nil }, + }) + if err != nil || got.Root != gitRoot || got.Initialized { + t.Fatalf("resolution = %#v, err = %v", got, err) + } + + got, err = project.Resolve(project.Request{ + StartDir: child, + Mode: project.Initializable, + GitRoot: func(string) (string, error) { return "", errors.New("not git") }, + }) + if err != nil || got.Root != child { + t.Fatalf("fallback = %#v, err = %v", got, err) + } +} + +func TestResolveRejectsMissingStartDirectory(t *testing.T) { + missing := filepath.Join(t.TempDir(), "missing") + + _, err := project.Resolve(project.Request{StartDir: missing, Mode: project.Existing}) + if !errors.Is(err, project.ErrDirectoryUnavailable) { + t.Fatalf("err = %v", err) + } +} + +func TestResolveRejectsManifestSymlink(t *testing.T) { + root := t.TempDir() + if err := os.Mkdir(filepath.Join(root, ".midtrans"), 0o755); err != nil { + t.Fatal(err) + } + manifest := filepath.Join(t.TempDir(), "manifest.yaml") + if err := os.WriteFile(manifest, []byte("schema_version: 1\n"), 0o644); err != nil { + t.Fatal(err) + } + if err := os.Symlink(manifest, filepath.Join(root, ".midtrans", "manifest.yaml")); err != nil { + t.Fatal(err) + } + + _, err := project.Resolve(project.Request{StartDir: root, Mode: project.Existing}) + if !errors.Is(err, project.ErrUnsafePath) { + t.Fatalf("err = %v", err) + } +} + +func TestResolveRejectsSymlinkProjectRoot(t *testing.T) { + root := initializedProject(t) + alias := filepath.Join(t.TempDir(), "project") + if err := os.Symlink(root, alias); err != nil { + t.Fatal(err) + } + + _, err := project.Resolve(project.Request{StartDir: alias, Mode: project.Existing}) + if !errors.Is(err, project.ErrUnsafePath) { + t.Fatalf("err = %v", err) + } +} + +func TestResolveExistingStopsAtFilesystemRoot(t *testing.T) { + start := t.TempDir() + + _, err := project.Resolve(project.Request{StartDir: start, Mode: project.Existing}) + if !errors.Is(err, project.ErrNotInitialized) { + t.Fatalf("err = %v", err) + } +} + +func TestResolveInitializableUsesExistingParentProject(t *testing.T) { + root := initializedProject(t) + child := filepath.Join(root, "packages", "checkout") + if err := os.MkdirAll(child, 0o755); err != nil { + t.Fatal(err) + } + + got, err := project.Resolve(project.Request{ + StartDir: child, + Mode: project.Initializable, + GitRoot: func(string) (string, error) { + t.Fatal("GitRoot must not run after finding an initialized parent") + return "", nil + }, + }) + if err != nil || got.Root != root || !got.Initialized { + t.Fatalf("resolution = %#v, err = %v", got, err) + } +} + +func initializedProject(t *testing.T) string { + t.Helper() + root := t.TempDir() + if err := os.Mkdir(filepath.Join(root, ".midtrans"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(root, ".midtrans", "manifest.yaml"), []byte("schema_version: 1\n"), 0o644); err != nil { + t.Fatal(err) + } + return root +} From 14f4a592c12c8c757ce8174c948faeff57c08c1f Mon Sep 17 00:00:00 2001 From: "m.salis" Date: Sun, 26 Jul 2026 08:38:04 +0700 Subject: [PATCH 04/73] feat: resolve project context for CLI commands --- internal/app/app.go | 14 ++++- internal/app/app_test.go | 86 +++++++++++++++++++++++--- internal/app/commands_credentials.go | 5 +- internal/app/commands_doctor.go | 3 +- internal/app/commands_evidence.go | 5 +- internal/app/commands_inspect.go | 5 +- internal/app/commands_manifest.go | 13 ++-- internal/app/commands_plan.go | 5 +- internal/app/commands_sandbox.go | 9 +-- internal/app/commands_verify.go | 3 +- internal/app/commands_webhook.go | 5 +- internal/app/project_context.go | 92 ++++++++++++++++++++++++++++ 12 files changed, 214 insertions(+), 31 deletions(-) create mode 100644 internal/app/project_context.go diff --git a/internal/app/app.go b/internal/app/app.go index d3faba3..566d354 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -22,6 +22,7 @@ type Dependencies struct { Version version.Info Packs *packs.Registry Getenv func(string) (string, bool) + Getwd func() (string, error) HTTP sandbox.Doer } @@ -39,6 +40,9 @@ func Execute(ctx context.Context, args []string, deps Dependencies) int { if deps.HTTP == nil { deps.HTTP = sandbox.NewHTTPClient() } + if deps.Getwd == nil { + deps.Getwd = os.Getwd + } flags := &globalFlags{} jsonOutput := jsonOutputRequested(args) if containsReservedCobraCommand(args) { @@ -53,9 +57,17 @@ func Execute(ctx context.Context, args []string, deps Dependencies) int { root.SetErr(deps.Stderr) root.CompletionOptions.DisableDefaultCmd = true root.SetUsageTemplate(phaseOneUsageTemplate) + root.PersistentPreRunE = func(cmd *cobra.Command, _ []string) error { + return resolveProjectContext(cmd, flags, deps) + } root.PersistentFlags().BoolVar(&flags.json, "json", false, "write the stable JSON result contract") root.PersistentFlags().BoolVar(&flags.nonInteractive, "non-interactive", false, "reject interactive prompts") - root.PersistentFlags().StringVar(&flags.projectDir, "project-dir", ".", "merchant repository root") + root.PersistentFlags().StringVar( + &flags.projectDir, + "project-dir", + "", + "merchant repository root (auto-detected when omitted)", + ) root.PersistentFlags().BoolVar(&flags.verbose, "verbose", false, "write additional redacted diagnostics") root.AddCommand( newCapabilitiesCommand(flags, deps), diff --git a/internal/app/app_test.go b/internal/app/app_test.go index 8f40133..a268e31 100644 --- a/internal/app/app_test.go +++ b/internal/app/app_test.go @@ -438,6 +438,65 @@ func TestMalformedCapabilityFlagsWithJSONProducesOneJSONResult(t *testing.T) { assertJSONUsageResult(t, exit, stdout.String(), stderr.String()) } +func TestNestedCommandDiscoversProjectManifest(t *testing.T) { + projectRoot := merchantFixture("snap-complete") + nested := filepath.Join(projectRoot, "nested", "checkout") + t.Cleanup(func() { + if err := os.RemoveAll(filepath.Join(projectRoot, "nested")); err != nil { + t.Error(err) + } + }) + if err := os.MkdirAll(nested, 0o755); err != nil { + t.Fatal(err) + } + + result, exit := executeJSONWithDependencies( + t, + app.Dependencies{ + Version: version.Info{Version: "0.1.0-test"}, + Packs: testRegistry(t), + Getwd: func() (string, error) { return nested, nil }, + }, + "doctor", "--product", "snap", + ) + if exit != 0 || result.Command != "doctor" || result.ManifestVersion != 1 { + t.Fatalf("exit = %d, result = %#v", exit, result) + } +} + +func TestMissingProjectReturnsProjectResultNotUsage(t *testing.T) { + root := t.TempDir() + result, exit := executeJSONWithDependencies( + t, + app.Dependencies{ + Version: version.Info{Version: "0.1.0-test"}, + Packs: testRegistry(t), + Getwd: func() (string, error) { return root, nil }, + }, + "doctor", + ) + if exit != 6 || + result.Command != "doctor" || + result.Findings[0].Code != "PROJECT_NOT_INITIALIZED" { + t.Fatalf("exit = %d, result = %#v", exit, result) + } +} + +func TestExplicitProjectDirectoryDoesNotDiscoverParent(t *testing.T) { + outer := merchantFixture("snap-complete") + child := filepath.Join(outer, "src") + if err := os.MkdirAll(child, 0o755); err != nil { + t.Fatal(err) + } + result, exit := executeJSON( + t, + "doctor", "--project-dir", child, "--json", "--non-interactive", + ) + if exit != 6 || result.Findings[0].Code != "PROJECT_NOT_INITIALIZED" { + t.Fatalf("exit = %d, result = %#v", exit, result) + } +} + func TestInitAndValidateManifestJSON(t *testing.T) { root := t.TempDir() initResult, exit := executeJSON(t, "init", "--project-dir", root, "--json", "--non-interactive") @@ -580,7 +639,11 @@ func TestUnknownPackCommandsReturnDeterministicCapabilityErrors(t *testing.T) { } for _, tt := range tests { t.Run(tt.command, func(t *testing.T) { - result, exit := executeJSON(t, append(tt.args, "--json", "--non-interactive")...) + args := append([]string{}, tt.args...) + if tt.command == "plan" { + args = append(args, "--project-dir", merchantFixture("snap-complete")) + } + result, exit := executeJSON(t, append(args, "--json", "--non-interactive")...) if exit != 5 { t.Fatalf("exit = %d, want 5; result = %#v", exit, result) } @@ -623,6 +686,9 @@ func TestPlanSnapEvaluatesManifest(t *testing.T) { func TestInspectCommandReturnsStablePublicSafeReport(t *testing.T) { project := t.TempDir() + if _, err := manifest.Init(project); err != nil { + t.Fatal(err) + } canary := "SB-Mid-server-INSPECT-CANARY-DO-NOT-PRINT" if err := os.WriteFile( filepath.Join(project, ".env.example"), @@ -872,10 +938,14 @@ func TestDoctorReturnsWarnForWarningOnlyFindings(t *testing.T) { } func TestDoctorUnknownProductIsDeterministic(t *testing.T) { + project := t.TempDir() + if _, err := manifest.Init(project); err != nil { + t.Fatal(err) + } result, exit := executeJSON( t, "doctor", "--product", "not-compiled", - "--project-dir", filepath.Join(t.TempDir(), "missing"), + "--project-dir", project, "--json", "--non-interactive", ) if exit != 5 || @@ -889,7 +959,7 @@ func TestDoctorUnknownProductIsDeterministic(t *testing.T) { } } -func TestInspectionFailuresReturnPublicSafeProductErrors(t *testing.T) { +func TestUnsafeProjectPathsReturnPublicSafeProjectErrors(t *testing.T) { realProject := t.TempDir() if _, err := manifest.Init(realProject); err != nil { t.Fatal(err) @@ -923,8 +993,8 @@ func TestInspectionFailuresReturnPublicSafeProductErrors(t *testing.T) { result.Status != contracts.StatusError || result.CLIVersion != "0.1.0-test" || len(result.Findings) != 1 || - result.Findings[0].Code != "INSPECTION_FAILED" || - result.Findings[0].Message != "unable to inspect the project repository" { + result.Findings[0].Code != "PROJECT_PATH_UNSAFE" || + result.Findings[0].Message != "the selected project path is unsafe" { t.Fatalf("exit = %d, result = %#v", exit, result) } encoded, err := json.Marshal(result) @@ -939,7 +1009,7 @@ func TestInspectionFailuresReturnPublicSafeProductErrors(t *testing.T) { } } -func TestDoctorManifestLoadFailureIsDeterministic(t *testing.T) { +func TestDoctorUnavailableProjectDirectoryIsDeterministic(t *testing.T) { result, exit := executeJSON( t, "doctor", "--product", "snap", @@ -951,8 +1021,8 @@ func TestDoctorManifestLoadFailureIsDeterministic(t *testing.T) { result.Status != contracts.StatusError || result.CLIVersion != "0.1.0-test" || len(result.Findings) != 1 || - result.Findings[0].Code != "MANIFEST_LOAD_FAILED" || - result.Findings[0].Message != "unable to load the project manifest" { + result.Findings[0].Code != "PROJECT_DIR_NOT_FOUND" || + result.Findings[0].Message != "the selected project directory is unavailable" { t.Fatalf("exit = %d, result = %#v", exit, result) } } diff --git a/internal/app/commands_credentials.go b/internal/app/commands_credentials.go index 6a5c0c9..79eb68c 100644 --- a/internal/app/commands_credentials.go +++ b/internal/app/commands_credentials.go @@ -7,12 +7,13 @@ import ( "github.com/spf13/cobra" "github.com/veritrans/midtrans-cli/internal/contracts" "github.com/veritrans/midtrans-cli/internal/manifest" + "github.com/veritrans/midtrans-cli/internal/project" "github.com/veritrans/midtrans-cli/internal/secrets" ) func newCredentialsCommand(flags *globalFlags, deps Dependencies) *cobra.Command { parent := &cobra.Command{Use: "credentials"} - parent.AddCommand(&cobra.Command{ + parent.AddCommand(withProjectMode(&cobra.Command{ Use: "status", Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, args []string) error { @@ -56,7 +57,7 @@ func newCredentialsCommand(flags *globalFlags, deps Dependencies) *cobra.Command } return writeResult(deps, flags, result) }, - }) + }, project.Existing, "credentials.status")) return parent } diff --git a/internal/app/commands_doctor.go b/internal/app/commands_doctor.go index 62bbf7e..f2847ba 100644 --- a/internal/app/commands_doctor.go +++ b/internal/app/commands_doctor.go @@ -5,6 +5,7 @@ import ( "github.com/veritrans/midtrans-cli/internal/contracts" "github.com/veritrans/midtrans-cli/internal/inspection" "github.com/veritrans/midtrans-cli/internal/manifest" + "github.com/veritrans/midtrans-cli/internal/project" ) func newDoctorCommand(flags *globalFlags, deps Dependencies) *cobra.Command { @@ -47,5 +48,5 @@ func newDoctorCommand(flags *globalFlags, deps Dependencies) *cobra.Command { }, } command.Flags().StringVar(&product, "product", "snap", "product pack to diagnose") - return command + return withProjectMode(command, project.Existing, "doctor") } diff --git a/internal/app/commands_evidence.go b/internal/app/commands_evidence.go index e9dd479..cf7d694 100644 --- a/internal/app/commands_evidence.go +++ b/internal/app/commands_evidence.go @@ -12,6 +12,7 @@ import ( "github.com/spf13/cobra" "github.com/veritrans/midtrans-cli/internal/contracts" "github.com/veritrans/midtrans-cli/internal/evidence" + "github.com/veritrans/midtrans-cli/internal/project" "github.com/veritrans/midtrans-cli/internal/safepath" ) @@ -60,7 +61,7 @@ func newEvidenceShowCommand( } command.Flags().StringVar(&file, "file", "", "checksummed evidence JSON file") _ = command.MarkFlagRequired("file") - return command + return withProjectMode(command, project.Existing, "evidence.show") } func newEvidenceExportCommand( @@ -128,7 +129,7 @@ func newEvidenceExportCommand( command.Flags().StringVar(&output, "output", "", "explicit evidence export path") _ = command.MarkFlagRequired("file") _ = command.MarkFlagRequired("output") - return command + return withProjectMode(command, project.Existing, "evidence.export") } func exportEvidence(projectDir, output string, data []byte) (string, error) { diff --git a/internal/app/commands_inspect.go b/internal/app/commands_inspect.go index fc92955..8588def 100644 --- a/internal/app/commands_inspect.go +++ b/internal/app/commands_inspect.go @@ -4,10 +4,11 @@ import ( "github.com/spf13/cobra" "github.com/veritrans/midtrans-cli/internal/contracts" "github.com/veritrans/midtrans-cli/internal/inspection" + "github.com/veritrans/midtrans-cli/internal/project" ) func newInspectCommand(flags *globalFlags, deps Dependencies) *cobra.Command { - return &cobra.Command{ + return withProjectMode(&cobra.Command{ Use: "inspect", Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, args []string) error { @@ -21,7 +22,7 @@ func newInspectCommand(flags *globalFlags, deps Dependencies) *cobra.Command { result.Data = report return writeResult(deps, flags, result) }, - } + }, project.Existing, "inspect") } func inspectionFailureResult(command string, deps Dependencies) contracts.Result { diff --git a/internal/app/commands_manifest.go b/internal/app/commands_manifest.go index 9600393..9c86e0d 100644 --- a/internal/app/commands_manifest.go +++ b/internal/app/commands_manifest.go @@ -4,10 +4,11 @@ import ( "github.com/spf13/cobra" "github.com/veritrans/midtrans-cli/internal/contracts" "github.com/veritrans/midtrans-cli/internal/manifest" + "github.com/veritrans/midtrans-cli/internal/project" ) func newInitCommand(flags *globalFlags, deps Dependencies) *cobra.Command { - return &cobra.Command{ + return withProjectMode(&cobra.Command{ Use: "init", RunE: func(cmd *cobra.Command, args []string) error { path, err := manifest.Init(flags.projectDir) @@ -20,12 +21,12 @@ func newInitCommand(flags *globalFlags, deps Dependencies) *cobra.Command { result.Data = map[string]any{"manifest_path": path} return writeResult(deps, flags, result) }, - } + }, project.Initializable, "init") } func newManifestCommand(flags *globalFlags, deps Dependencies) *cobra.Command { parent := &cobra.Command{Use: "manifest"} - parent.AddCommand(&cobra.Command{ + parent.AddCommand(withProjectMode(&cobra.Command{ Use: "validate", RunE: func(cmd *cobra.Command, args []string) error { value, err := manifest.Load(flags.projectDir) @@ -43,8 +44,8 @@ func newManifestCommand(flags *globalFlags, deps Dependencies) *cobra.Command { result.Findings = findings return writeResult(deps, flags, result) }, - }) - parent.AddCommand(&cobra.Command{ + }, project.Existing, "manifest.validate")) + parent.AddCommand(withProjectMode(&cobra.Command{ Use: "migrate", RunE: func(cmd *cobra.Command, args []string) error { value, err := manifest.Load(flags.projectDir) @@ -71,6 +72,6 @@ func newManifestCommand(flags *globalFlags, deps Dependencies) *cobra.Command { } return writeResult(deps, flags, result) }, - }) + }, project.Existing, "manifest.migrate")) return parent } diff --git a/internal/app/commands_plan.go b/internal/app/commands_plan.go index cd30c4d..cc8857c 100644 --- a/internal/app/commands_plan.go +++ b/internal/app/commands_plan.go @@ -5,10 +5,11 @@ import ( "github.com/veritrans/midtrans-cli/internal/contracts" "github.com/veritrans/midtrans-cli/internal/inspection" "github.com/veritrans/midtrans-cli/internal/manifest" + "github.com/veritrans/midtrans-cli/internal/project" ) func newPlanCommand(flags *globalFlags, deps Dependencies) *cobra.Command { - return &cobra.Command{ + return withProjectMode(&cobra.Command{ Use: "plan ", Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { @@ -39,5 +40,5 @@ func newPlanCommand(flags *globalFlags, deps Dependencies) *cobra.Command { result.Findings = findings return writeResult(deps, flags, result) }, - } + }, project.Existing, "plan") } diff --git a/internal/app/commands_sandbox.go b/internal/app/commands_sandbox.go index 9d354b2..833de64 100644 --- a/internal/app/commands_sandbox.go +++ b/internal/app/commands_sandbox.go @@ -19,6 +19,7 @@ import ( "github.com/veritrans/midtrans-cli/internal/evidence" "github.com/veritrans/midtrans-cli/internal/inspection" "github.com/veritrans/midtrans-cli/internal/operations" + "github.com/veritrans/midtrans-cli/internal/project" "github.com/veritrans/midtrans-cli/internal/safepath" "github.com/veritrans/midtrans-cli/packs/snap" ) @@ -202,7 +203,7 @@ func newSandboxRunCommand( ) _ = command.MarkFlagRequired("order-id") _ = command.MarkFlagRequired("gross-amount") - return command + return withProjectMode(command, project.Existing, "sandbox.run") } func writeJourneyEvidence( @@ -423,7 +424,7 @@ func newSandboxPreflightCommand( flags *globalFlags, deps Dependencies, ) *cobra.Command { - return &cobra.Command{ + return withProjectMode(&cobra.Command{ Use: "preflight", Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, args []string) error { @@ -454,7 +455,7 @@ func newSandboxPreflightCommand( } return writeResult(deps, flags, result) }, - } + }, project.Existing, "sandbox.preflight") } func newSandboxStatusCommand( @@ -529,5 +530,5 @@ func newSandboxStatusCommand( command.Flags().StringVar(&orderID, "order-id", "", "safe transaction order reference") _ = command.MarkFlagRequired("product") _ = command.MarkFlagRequired("order-id") - return command + return withProjectMode(command, project.Existing, "sandbox.status") } diff --git a/internal/app/commands_verify.go b/internal/app/commands_verify.go index c830cba..25b8b02 100644 --- a/internal/app/commands_verify.go +++ b/internal/app/commands_verify.go @@ -8,6 +8,7 @@ import ( "github.com/veritrans/midtrans-cli/internal/evidence" "github.com/veritrans/midtrans-cli/internal/inspection" "github.com/veritrans/midtrans-cli/internal/manifest" + "github.com/veritrans/midtrans-cli/internal/project" "github.com/veritrans/midtrans-cli/internal/verify" ) @@ -103,7 +104,7 @@ func newVerifyCommand(flags *globalFlags, deps Dependencies) *cobra.Command { "", "checksummed evidence JSON file", ) - return command + return withProjectMode(command, project.Existing, "verify") } func evidenceMatchesProject( diff --git a/internal/app/commands_webhook.go b/internal/app/commands_webhook.go index d3d5253..fab7bbe 100644 --- a/internal/app/commands_webhook.go +++ b/internal/app/commands_webhook.go @@ -13,6 +13,7 @@ import ( "github.com/spf13/cobra" "github.com/veritrans/midtrans-cli/internal/contracts" "github.com/veritrans/midtrans-cli/internal/policy" + "github.com/veritrans/midtrans-cli/internal/project" "github.com/veritrans/midtrans-cli/internal/webhook" "github.com/veritrans/midtrans-cli/packs/snap" ) @@ -119,7 +120,7 @@ func newWebhookVerifyCommand(flags *globalFlags, deps Dependencies) *cobra.Comma } command.Flags().StringVar(&file, "file", "", "notification JSON file") _ = command.MarkFlagRequired("file") - return command + return withProjectMode(command, project.Existing, "webhook.verify") } func newWebhookReplayCommand(flags *globalFlags, deps Dependencies) *cobra.Command { @@ -257,7 +258,7 @@ func newWebhookReplayCommand(flags *globalFlags, deps Dependencies) *cobra.Comma command.MarkFlagsMutuallyExclusive("dry-run", "execute") _ = command.MarkFlagRequired("file") _ = command.MarkFlagRequired("target") - return command + return withProjectMode(command, project.Existing, "webhook.replay") } func readWebhookPayload(projectDir, candidate string) ([]byte, error) { diff --git a/internal/app/project_context.go b/internal/app/project_context.go new file mode 100644 index 0000000..f57f426 --- /dev/null +++ b/internal/app/project_context.go @@ -0,0 +1,92 @@ +package app + +import ( + "errors" + + "github.com/spf13/cobra" + "github.com/veritrans/midtrans-cli/internal/contracts" + "github.com/veritrans/midtrans-cli/internal/project" +) + +const ( + projectModeAnnotation = "midtrans.project-mode" + resultNameAnnotation = "midtrans.result-command" +) + +func withProjectMode( + command *cobra.Command, + mode project.Mode, + resultName string, +) *cobra.Command { + if command.Annotations == nil { + command.Annotations = map[string]string{} + } + command.Annotations[projectModeAnnotation] = string(mode) + command.Annotations[resultNameAnnotation] = resultName + return command +} + +func resolveProjectContext( + command *cobra.Command, + flags *globalFlags, + deps Dependencies, +) error { + rawMode, required := command.Annotations[projectModeAnnotation] + if !required { + return nil + } + start, err := deps.Getwd() + if err != nil { + return writeResult(deps, flags, projectFailure( + command, + deps, + "PROJECT_DIR_NOT_FOUND", + "current directory is unavailable", + )) + } + resolution, err := project.Resolve(project.Request{ + StartDir: start, + ExplicitDir: flags.projectDir, + Mode: project.Mode(rawMode), + }) + if err != nil { + return writeResult(deps, flags, projectErrorResult(command, deps, err)) + } + flags.projectDir = resolution.Root + return nil +} + +func projectFailure( + command *cobra.Command, + deps Dependencies, + code string, + message string, +) contracts.Result { + result := contracts.NewResult( + command.Annotations[resultNameAnnotation], + contracts.StatusError, + ) + result.CLIVersion = deps.Version.Version + result.Findings = []contracts.Finding{{ + Code: code, Severity: "blocking", Message: message, + }} + return result +} + +func projectErrorResult( + command *cobra.Command, + deps Dependencies, + err error, +) contracts.Result { + code := "PROJECT_DIR_NOT_FOUND" + message := "the selected project directory is unavailable" + switch { + case errors.Is(err, project.ErrNotInitialized): + code = "PROJECT_NOT_INITIALIZED" + message = "no .midtrans/manifest.yaml was found; run midtrans init" + case errors.Is(err, project.ErrUnsafePath): + code = "PROJECT_PATH_UNSAFE" + message = "the selected project path is unsafe" + } + return projectFailure(command, deps, code, message) +} From 3b681219f1fcb2d704ba91c43b2c32cb9ebf2cfe Mon Sep 17 00:00:00 2001 From: "m.salis" Date: Sun, 26 Jul 2026 08:42:47 +0700 Subject: [PATCH 05/73] fix: limit inspection to merchant source files --- internal/inspection/inspection_test.go | 43 ++++++++++++++++++++++++++ internal/inspection/walk.go | 23 +++++++++++++- 2 files changed, 65 insertions(+), 1 deletion(-) diff --git a/internal/inspection/inspection_test.go b/internal/inspection/inspection_test.go index 36f2233..c5d889a 100644 --- a/internal/inspection/inspection_test.go +++ b/internal/inspection/inspection_test.go @@ -106,6 +106,49 @@ func TestInspectSkipsRequestedDirectoriesAndDirectorySymlinks(t *testing.T) { } } +func TestInspectSkipsGeneratedAndSecretBearingFiles(t *testing.T) { + root := t.TempDir() + files := []string{ + ".env", + ".env.local", + "terraform/terraform.tfstate", + "terraform/terraform.tfstate.backup", + "tsconfig.tsbuildinfo", + ".next/server/chunk.js", + ".terraform/providers/cache.txt", + "coverage/report.txt", + "dist/bundle.js", + } + for _, relative := range files { + path := filepath.Join(root, relative) + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile( + path, + []byte("MIDTRANS_SERVER_KEY="+canarySecret), + 0o600, + ); err != nil { + t.Fatal(err) + } + } + if err := os.WriteFile( + filepath.Join(root, ".env.example"), + []byte("MIDTRANS_SERVER_KEY=your-sandbox-key"), + 0o644, + ); err != nil { + t.Fatal(err) + } + + report, err := inspection.Inspect(root) + if err != nil { + t.Fatal(err) + } + if len(report.Facts) != 1 || report.Facts[0].Path != ".env.example" { + t.Fatalf("facts = %#v", report.Facts) + } +} + func TestInspectIncludesRegularFilesAtSizeLimit(t *testing.T) { root := t.TempDir() data := make([]byte, 1024*1024) diff --git a/internal/inspection/walk.go b/internal/inspection/walk.go index f6f8f1b..aa67712 100644 --- a/internal/inspection/walk.go +++ b/internal/inspection/walk.go @@ -18,7 +18,12 @@ var ( errInvalidProject = errors.New("inspection project is unavailable") errInspectionLimit = errors.New("inspection limit exceeded") skippedDirs = []string{ - ".git", ".midtrans", "node_modules", "vendor", "evidence", "tmp", + ".cache", ".git", ".midtrans", ".next", ".terraform", ".turbo", + "build", "coverage", "dist", "evidence", "node_modules", "out", + "tmp", "vendor", + } + allowedEnvironmentTemplates = []string{ + ".env.example", ".env.sample", ".env.template", } ) @@ -65,6 +70,9 @@ func walk(projectDir string, visit func(inspectedFile) error) error { if err != nil || pathEscapesRoot(relative) { return errInvalidProject } + if shouldSkipFile(relative) { + return nil + } info, err := root.Lstat(relative) if err != nil { return errInvalidProject @@ -98,6 +106,19 @@ func walk(projectDir string, visit func(inspectedFile) error) error { return err } +func shouldSkipFile(relative string) bool { + base := filepath.Base(relative) + if strings.HasPrefix(base, ".env") && + !slices.Contains(allowedEnvironmentTemplates, base) { + return true + } + if strings.Contains(base, ".tfstate") || + strings.HasSuffix(base, ".tsbuildinfo") { + return true + } + return false +} + func pathEscapesRoot(relative string) bool { return relative == ".." || strings.HasPrefix(relative, ".."+string(filepath.Separator)) || From 5f27fe93b0c35213d8805636c880643ba0306342 Mon Sep 17 00:00:00 2001 From: "m.salis" Date: Sun, 26 Jul 2026 08:45:09 +0700 Subject: [PATCH 06/73] docs: place agent parity checks in namespace task --- .../2026-07-26-merchant-cli-experience.md | 34 +++++++++---------- 1 file changed, 17 insertions(+), 17 deletions(-) diff --git a/docs/superpowers/plans/2026-07-26-merchant-cli-experience.md b/docs/superpowers/plans/2026-07-26-merchant-cli-experience.md index 0c4282e..fa77831 100644 --- a/docs/superpowers/plans/2026-07-26-merchant-cli-experience.md +++ b/docs/superpowers/plans/2026-07-26-merchant-cli-experience.md @@ -637,23 +637,6 @@ func TestInspectSkipsGeneratedAndSecretBearingFiles(t *testing.T) { } ``` -Add `TestAgentNamespacePreservesLegacyJSON` with table-driven parity cases for: - -```text -midtrans inspect -midtrans agent inspect - -midtrans doctor --product snap -midtrans agent check --product snap - -midtrans pack info snap -midtrans agent pack info snap -``` - -Each old/new pair must produce the same schema version, manifest version, -findings, packs, capabilities, journeys, and exit code. Only the invocation -namespace changes during `v0.1.x`. - - [ ] **Step 2: Run and verify RED** Run: @@ -1554,6 +1537,23 @@ func TestVersionIsProjectless(t *testing.T) { } ``` +Add `TestAgentNamespacePreservesLegacyJSON` with table-driven parity cases for: + +```text +midtrans inspect +midtrans agent inspect + +midtrans doctor --product snap +midtrans agent check --product snap + +midtrans pack info snap +midtrans agent pack info snap +``` + +Each old/new pair must produce the same schema version, manifest version, +findings, packs, capabilities, journeys, and exit code. Only the invocation +namespace changes during `v0.1.x`. + - [ ] **Step 2: Run and verify RED** Run: From b6920b4bcb02a5f5e99149f91ed012776e3f0fd1 Mon Sep 17 00:00:00 2001 From: "m.salis" Date: Sun, 26 Jul 2026 08:50:36 +0700 Subject: [PATCH 07/73] feat: model merchant integration readiness --- internal/readiness/report.go | 308 ++++++++++++++++++++++++++++++ internal/readiness/report_test.go | 148 ++++++++++++++ 2 files changed, 456 insertions(+) create mode 100644 internal/readiness/report.go create mode 100644 internal/readiness/report_test.go diff --git a/internal/readiness/report.go b/internal/readiness/report.go new file mode 100644 index 0000000..0ed4059 --- /dev/null +++ b/internal/readiness/report.go @@ -0,0 +1,308 @@ +// Package readiness builds an IO-free, deterministic integration-readiness report. +package readiness + +import ( + "fmt" + "path/filepath" + "slices" + "sort" + "strings" + + "github.com/veritrans/midtrans-cli/internal/contracts" + "github.com/veritrans/midtrans-cli/internal/manifest" +) + +type CheckState string + +const ( + Ready CheckState = "ready" + NeedsAction CheckState = "needs_action" + Warning CheckState = "warning" + Failed CheckState = "failed" +) + +type Reachability string + +const ( + ReachabilityUnknown Reachability = "unknown" + ReachabilityReachable Reachability = "reachable" + ReachabilityUnreachable Reachability = "unreachable" +) + +type Check struct { + ID string `json:"id"` + Label string `json:"label"` + State CheckState `json:"state"` + Detail string `json:"detail"` +} + +type Report struct { + Project string `json:"project"` + Root string `json:"root"` + Manifest string `json:"manifest"` + Environment string `json:"environment"` + Products []string `json:"products"` + CLIVersion string `json:"cli_version"` + Packs []contracts.PackVersion `json:"packs"` + Checks []Check `json:"checks"` +} + +type Input struct { + ProjectRoot string + Manifest manifest.Manifest + CLIVersion string + Packs []contracts.PackVersion + Findings []contracts.Finding + ServerKeyPresent bool + ClientKeyPresent bool + LocalReachable Reachability +} + +// Build constructs a stable report without reading the filesystem, environment, or network. +func Build(input Input) Report { + root := "" + project := "" + if input.ProjectRoot != "" { + root = filepath.Clean(input.ProjectRoot) + project = filepath.Base(root) + } + + manifestFindings := manifest.Validate(input.Manifest) + report := Report{ + Project: project, + Root: root, + Manifest: ".midtrans/manifest.yaml", + Environment: "sandbox", + Products: sortedStrings(input.Manifest.Products), + CLIVersion: input.CLIVersion, + Packs: sortedPacks(input.Packs), + } + report.Checks = []Check{ + projectCheck(input.ProjectRoot), + environmentCheck(manifestFindings), + productCheck(input.Manifest.Products), + checkoutCheck(input.Manifest), + webhookCheck(input.Manifest), + localStatusCheck(input.Manifest, manifestFindings), + credentialCheck("client-key", "Client key", input.Manifest.Credentials.References["client_key"], input.ClientKeyPresent), + credentialCheck("server-key", "Server key", input.Manifest.Credentials.References["server_key"], input.ServerKeyPresent), + localAppCheck(input.LocalReachable), + } + for _, finding := range sortedFindings(input.Findings) { + report.Checks = append(report.Checks, Check{ + ID: "pack-finding-" + strings.ToLower(finding.Code), + Label: "Pack finding", + State: findingState(finding.Severity), + Detail: fmt.Sprintf("%s reported by an installed pack", finding.Code), + }) + } + return report +} + +func (r Report) Status() contracts.Status { + for _, check := range r.Checks { + if check.State == Failed { + return contracts.StatusFail + } + } + for _, check := range r.Checks { + if check.State == NeedsAction || check.State == Warning { + return contracts.StatusWarn + } + } + return contracts.StatusPass +} + +func (r Report) NextAction() *contracts.NextAction { + if hasManifestFailure(r.Checks) { + return action("fix_manifest", "correct the invalid manifest configuration and rerun status") + } + if hasState(r.Checks, "server-key", NeedsAction) { + return action("configure_sandbox_server_key", "set the configured sandbox server-key environment reference and rerun status") + } + if hasState(r.Checks, "client-key", NeedsAction) { + return action("configure_sandbox_client_key", "set the configured sandbox client-key environment reference and rerun status") + } + if hasNonReady(r.Checks, "local-status") { + return action("configure_local_status_route", "configure a local status route containing {order_id}") + } + if hasNonReady(r.Checks, "local-app") { + return action("start_local_app", "start the local application and rerun status") + } + if hasNonReady(r.Checks, "checkout") { + return action("test_sandbox_checkout", "configure and test a sandbox checkout flow") + } + if hasPackFinding(r.Checks) { + return action("review_pack_findings", "review installed-pack findings and rerun status") + } + return nil +} + +func projectCheck(root string) Check { + if root == "" { + return Check{ID: "project", Label: "Project", State: NeedsAction, Detail: "project root is required"} + } + return Check{ID: "project", Label: "Project", State: Ready, Detail: "project root is configured"} +} + +func environmentCheck(findings []contracts.Finding) Check { + if hasManifestFinding(findings, "MANIFEST_SCHEMA_UNSUPPORTED", "POLICY_PRODUCTION_DISABLED", "CREDENTIAL_PROVIDER_UNSUPPORTED") { + return Check{ID: "environment", Label: "Environment", State: Failed, Detail: "sandbox-only environment policy is invalid"} + } + return Check{ID: "environment", Label: "Environment", State: Ready, Detail: "sandbox-only environment policy is configured"} +} + +func productCheck(products []string) Check { + if len(products) == 0 { + return Check{ID: "product", Label: "Product", State: NeedsAction, Detail: "configure at least one Midtrans product"} + } + return Check{ID: "product", Label: "Product", State: Ready, Detail: "Midtrans product configuration is present"} +} + +func checkoutCheck(value manifest.Manifest) Check { + if len(value.Integration.CheckoutModes) == 0 || value.Integration.FinishRedirectRoute == "" { + return Check{ID: "checkout", Label: "Checkout", State: NeedsAction, Detail: "configure a checkout mode and finish redirect route"} + } + return Check{ID: "checkout", Label: "Checkout", State: Ready, Detail: "checkout mode and finish redirect route are configured"} +} + +func webhookCheck(value manifest.Manifest) Check { + if value.Integration.NotificationRoute == "" { + return Check{ID: "webhook", Label: "Webhook", State: NeedsAction, Detail: "configure a notification route"} + } + return Check{ID: "webhook", Label: "Webhook", State: Ready, Detail: "notification route is configured"} +} + +func localStatusCheck(value manifest.Manifest, findings []contracts.Finding) Check { + if hasManifestFinding(findings, "LOCAL_STATUS_ROUTE_INVALID", "LOCAL_BASE_URL_NOT_LOOPBACK") { + return Check{ID: "local-status", Label: "Local status", State: Failed, Detail: "local status configuration is invalid"} + } + if value.Integration.LocalBaseURL == "" || value.Integration.LocalStatusRoute == "" { + return Check{ID: "local-status", Label: "Local status", State: NeedsAction, Detail: "configure a loopback base URL and status route"} + } + return Check{ID: "local-status", Label: "Local status", State: Ready, Detail: "loopback base URL and status route are configured"} +} + +func credentialCheck(id, label, reference string, present bool) Check { + if !validEnvironmentReference(reference) { + return Check{ID: id, Label: label, State: Failed, Detail: "credential environment reference is missing or invalid"} + } + if !present { + return Check{ID: id, Label: label, State: NeedsAction, Detail: reference + " is not set"} + } + return Check{ID: id, Label: label, State: Ready, Detail: reference + " is set"} +} + +func validEnvironmentReference(reference string) bool { + if reference == "" || reference[0] < 'A' || reference[0] > 'Z' { + return false + } + for _, character := range reference[1:] { + if (character < 'A' || character > 'Z') && + (character < '0' || character > '9') && character != '_' { + return false + } + } + return true +} + +func localAppCheck(reachable Reachability) Check { + switch reachable { + case ReachabilityReachable: + return Check{ID: "local-app", Label: "Local app", State: Ready, Detail: "local application is reachable"} + case ReachabilityUnreachable: + return Check{ID: "local-app", Label: "Local app", State: Warning, Detail: "local application is not reachable"} + default: + return Check{ID: "local-app", Label: "Local app", State: Warning, Detail: "local application reachability was not checked"} + } +} + +func findingState(severity string) CheckState { + switch strings.ToLower(severity) { + case "blocking", "error", "critical", "fail", "failed": + return Failed + case "warning", "warn": + return Warning + default: + return NeedsAction + } +} + +func hasManifestFinding(findings []contracts.Finding, codes ...string) bool { + for _, finding := range findings { + if slices.Contains(codes, finding.Code) { + return true + } + } + return false +} + +func hasManifestFailure(checks []Check) bool { + for _, check := range checks { + if check.State == Failed && slices.Contains([]string{ + "environment", "local-status", "client-key", "server-key", + }, check.ID) { + return true + } + } + return false +} + +func hasPackFinding(checks []Check) bool { + for _, check := range checks { + if strings.HasPrefix(check.ID, "pack-finding-") && check.State != Ready { + return true + } + } + return false +} + +func hasState(checks []Check, id string, state CheckState) bool { + for _, check := range checks { + if check.ID == id && check.State == state { + return true + } + } + return false +} + +func hasNonReady(checks []Check, id string) bool { + for _, check := range checks { + if check.ID == id && check.State != Ready { + return true + } + } + return false +} + +func action(name, description string) *contracts.NextAction { + return &contracts.NextAction{Action: name, Description: description} +} + +func sortedStrings(values []string) []string { + result := slices.Clone(values) + sort.Strings(result) + return result +} + +func sortedPacks(values []contracts.PackVersion) []contracts.PackVersion { + result := slices.Clone(values) + sort.Slice(result, func(i, j int) bool { + if result[i].ID == result[j].ID { + return result[i].Version < result[j].Version + } + return result[i].ID < result[j].ID + }) + return result +} + +func sortedFindings(values []contracts.Finding) []contracts.Finding { + result := slices.Clone(values) + sort.SliceStable(result, func(i, j int) bool { + if result[i].Code == result[j].Code { + return result[i].Severity < result[j].Severity + } + return result[i].Code < result[j].Code + }) + return result +} diff --git a/internal/readiness/report_test.go b/internal/readiness/report_test.go new file mode 100644 index 0000000..4915307 --- /dev/null +++ b/internal/readiness/report_test.go @@ -0,0 +1,148 @@ +package readiness_test + +import ( + "bytes" + "encoding/json" + "reflect" + "testing" + + "github.com/veritrans/midtrans-cli/internal/contracts" + "github.com/veritrans/midtrans-cli/internal/manifest" + "github.com/veritrans/midtrans-cli/internal/readiness" +) + +func TestBuildReportsConcreteReadyAndMissingChecks(t *testing.T) { + value := manifest.Default() + value.Integration.CheckoutModes = []string{"popup"} + value.Integration.NotificationRoute = "/api/payment/webhook" + value.Integration.FinishRedirectRoute = "/orders/{order_id}" + value.Integration.LocalBaseURL = "http://127.0.0.1:3101" + value.Integration.LocalStatusRoute = "/api/dev/midtrans/{order_id}" + + report := readiness.Build(readiness.Input{ + ProjectRoot: "/tmp/store", + Manifest: value, + CLIVersion: "0.1.0-test", + Packs: []contracts.PackVersion{{ID: "snap", Version: "0.1.0"}}, + ServerKeyPresent: false, + ClientKeyPresent: true, + LocalReachable: readiness.ReachabilityUnreachable, + }) + + if report.Status() != contracts.StatusWarn { + t.Fatalf("status = %s", report.Status()) + } + assertCheck(t, report, "project", readiness.Ready) + assertCheck(t, report, "server-key", readiness.NeedsAction) + assertCheck(t, report, "local-app", readiness.Warning) + if action := report.NextAction(); action == nil || + action.Action != "configure_sandbox_server_key" { + t.Fatalf("next action = %#v", action) + } +} + +func TestBuildNeverIncludesCredentialValues(t *testing.T) { + report := readiness.Build(readiness.Input{ + ProjectRoot: "/tmp/store", + Manifest: manifest.Default(), + ServerKeyPresent: true, + ClientKeyPresent: true, + }) + encoded, err := json.Marshal(report) + if err != nil { + t.Fatal(err) + } + if bytes.Contains(encoded, []byte("SB-Mid")) { + t.Fatalf("report contains a credential: %s", encoded) + } +} + +func TestBuildFailsForInvalidManifestBeforeCredentialSetup(t *testing.T) { + value := manifest.Default() + value.Credentials.References["server_key"] = "not-an-environment-reference" + + report := readiness.Build(readiness.Input{ + ProjectRoot: "/tmp/store", + Manifest: value, + ServerKeyPresent: true, + ClientKeyPresent: true, + }) + + if report.Status() != contracts.StatusFail { + t.Fatalf("status = %s, want fail", report.Status()) + } + assertCheck(t, report, "server-key", readiness.Failed) + if action := report.NextAction(); action == nil || action.Action != "fix_manifest" { + t.Fatalf("next action = %#v", action) + } +} + +func TestBuildSortsPackFindingsAfterCoreChecks(t *testing.T) { + report := readiness.Build(readiness.Input{ + ProjectRoot: "/tmp/store", + Manifest: manifest.Default(), + Findings: []contracts.Finding{ + {Code: "Z_LAST", Severity: "warning", Message: "SB-Mid-server-hidden"}, + {Code: "A_FIRST", Severity: "blocking", Message: "SB-Mid-server-hidden"}, + }, + }) + + var ids []string + for _, check := range report.Checks { + ids = append(ids, check.ID) + } + want := []string{ + "project", "environment", "product", "checkout", "webhook", "local-status", + "client-key", "server-key", "local-app", "pack-finding-a_first", "pack-finding-z_last", + } + if !reflect.DeepEqual(ids, want) { + t.Fatalf("check ids = %#v, want %#v", ids, want) + } + if report.Status() != contracts.StatusFail { + t.Fatalf("status = %s, want fail", report.Status()) + } + encoded, err := json.Marshal(report) + if err != nil { + t.Fatal(err) + } + if bytes.Contains(encoded, []byte("SB-Mid")) { + t.Fatalf("report contains a credential: %s", encoded) + } +} + +func TestBuildPassesOnlyWhenEveryCheckIsReady(t *testing.T) { + value := manifest.Default() + value.Integration.CheckoutModes = []string{"popup"} + value.Integration.NotificationRoute = "/api/payment/webhook" + value.Integration.FinishRedirectRoute = "/orders/{order_id}" + value.Integration.LocalBaseURL = "http://127.0.0.1:3101" + value.Integration.LocalStatusRoute = "/api/dev/midtrans/{order_id}" + + report := readiness.Build(readiness.Input{ + ProjectRoot: "/tmp/store", + Manifest: value, + ServerKeyPresent: true, + ClientKeyPresent: true, + LocalReachable: readiness.ReachabilityReachable, + }) + + if report.Status() != contracts.StatusPass { + t.Fatalf("status = %s, want pass", report.Status()) + } + if action := report.NextAction(); action != nil { + t.Fatalf("next action = %#v, want nil", action) + } +} + +func assertCheck(t *testing.T, report readiness.Report, id string, state readiness.CheckState) { + t.Helper() + for _, check := range report.Checks { + if check.ID == id { + if check.State != state { + t.Fatalf("check %q state = %q, want %q", id, check.State, state) + } + return + } + } + t.Fatalf("check %q not found", id) +} From 3a352a75d89f8af098bd491bdc3a611039a1eb45 Mon Sep 17 00:00:00 2001 From: "m.salis" Date: Sun, 26 Jul 2026 08:55:00 +0700 Subject: [PATCH 08/73] fix: keep pack findings opaque in readiness --- internal/readiness/report.go | 6 +++--- internal/readiness/report_test.go | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/internal/readiness/report.go b/internal/readiness/report.go index 0ed4059..44e4070 100644 --- a/internal/readiness/report.go +++ b/internal/readiness/report.go @@ -88,12 +88,12 @@ func Build(input Input) Report { credentialCheck("server-key", "Server key", input.Manifest.Credentials.References["server_key"], input.ServerKeyPresent), localAppCheck(input.LocalReachable), } - for _, finding := range sortedFindings(input.Findings) { + for index, finding := range sortedFindings(input.Findings) { report.Checks = append(report.Checks, Check{ - ID: "pack-finding-" + strings.ToLower(finding.Code), + ID: fmt.Sprintf("pack-finding-%d", index+1), Label: "Pack finding", State: findingState(finding.Severity), - Detail: fmt.Sprintf("%s reported by an installed pack", finding.Code), + Detail: "an installed pack reported a finding", }) } return report diff --git a/internal/readiness/report_test.go b/internal/readiness/report_test.go index 4915307..51d46b4 100644 --- a/internal/readiness/report_test.go +++ b/internal/readiness/report_test.go @@ -82,8 +82,8 @@ func TestBuildSortsPackFindingsAfterCoreChecks(t *testing.T) { ProjectRoot: "/tmp/store", Manifest: manifest.Default(), Findings: []contracts.Finding{ - {Code: "Z_LAST", Severity: "warning", Message: "SB-Mid-server-hidden"}, - {Code: "A_FIRST", Severity: "blocking", Message: "SB-Mid-server-hidden"}, + {Code: "SB-Mid-server-hidden-Z_LAST", Severity: "warning", Message: "SB-Mid-server-hidden"}, + {Code: "SB-Mid-server-hidden-A_FIRST", Severity: "blocking", Message: "SB-Mid-server-hidden"}, }, }) @@ -93,7 +93,7 @@ func TestBuildSortsPackFindingsAfterCoreChecks(t *testing.T) { } want := []string{ "project", "environment", "product", "checkout", "webhook", "local-status", - "client-key", "server-key", "local-app", "pack-finding-a_first", "pack-finding-z_last", + "client-key", "server-key", "local-app", "pack-finding-1", "pack-finding-2", } if !reflect.DeepEqual(ids, want) { t.Fatalf("check ids = %#v, want %#v", ids, want) From 27f5b6247a6f81c1a585d32f7f3a3c36d7002be7 Mon Sep 17 00:00:00 2001 From: "m.salis" Date: Sun, 26 Jul 2026 09:00:10 +0700 Subject: [PATCH 09/73] feat: render actionable merchant command output --- internal/presentation/model.go | 94 +++++++++++++++++++++++++++++ internal/presentation/model_test.go | 33 ++++++++++ internal/render/render.go | 46 ++++++++++++++ internal/render/render_test.go | 39 ++++++++++++ 4 files changed, 212 insertions(+) create mode 100644 internal/presentation/model.go create mode 100644 internal/presentation/model_test.go diff --git a/internal/presentation/model.go b/internal/presentation/model.go new file mode 100644 index 0000000..c540a44 --- /dev/null +++ b/internal/presentation/model.go @@ -0,0 +1,94 @@ +// Package presentation builds bounded, command-specific human output models. +package presentation + +import ( + "encoding/json" + "strings" + + "github.com/veritrans/midtrans-cli/internal/contracts" + "github.com/veritrans/midtrans-cli/internal/readiness" +) + +type Row struct { + State string + Label string + Detail string +} + +type Model struct { + Title string + Rows []Row + Findings []contracts.Finding + NextActions []contracts.NextAction +} + +func Build(result contracts.Result) (Model, bool) { + switch result.Command { + case "status", "setup": + var report readiness.Report + if !decodeData(result.Data, &report) || len(report.Checks) == 0 { + return Model{}, false + } + + rows := make([]Row, 0, len(report.Checks)) + for _, check := range report.Checks { + rows = append(rows, Row{ + State: stateSymbol(check.State), + Label: check.Label, + Detail: check.Detail, + }) + } + + return Model{ + Title: title(report), + Rows: rows, + Findings: result.Findings, + NextActions: result.NextActions, + }, true + default: + return Model{}, false + } +} + +func decodeData(value any, target any) bool { + if value == nil { + return false + } + encoded, err := json.Marshal(value) + if err != nil { + return false + } + return json.Unmarshal(encoded, target) == nil +} + +func stateSymbol(state readiness.CheckState) string { + switch state { + case readiness.Ready: + return "✓" + case readiness.Warning: + return "!" + case readiness.NeedsAction, readiness.Failed: + return "✗" + default: + return "?" + } +} + +func title(report readiness.Report) string { + products := make([]string, 0, len(report.Products)) + for _, product := range report.Products { + products = append(products, titleCase(product)) + } + return strings.Join([]string{ + report.Project, + titleCase(report.Environment), + strings.Join(products, ", "), + }, " · ") +} + +func titleCase(value string) string { + if value == "" { + return "" + } + return strings.ToUpper(value[:1]) + value[1:] +} diff --git a/internal/presentation/model_test.go b/internal/presentation/model_test.go new file mode 100644 index 0000000..4e8e985 --- /dev/null +++ b/internal/presentation/model_test.go @@ -0,0 +1,33 @@ +package presentation + +import ( + "testing" + + "github.com/veritrans/midtrans-cli/internal/contracts" + "github.com/veritrans/midtrans-cli/internal/readiness" +) + +func TestBuildStatusPresentation(t *testing.T) { + result := contracts.NewResult("status", contracts.StatusWarn) + result.Data = readiness.Report{ + Project: "Salis Property", + Environment: "sandbox", + Products: []string{"snap"}, + Checks: []readiness.Check{ + {ID: "project", Label: "Project", State: readiness.Ready, Detail: ".midtrans/manifest.yaml"}, + {ID: "server-key", Label: "Server key", State: readiness.NeedsAction, Detail: "MIDTRANS_SERVER_KEY is not available"}, + }, + } + result.NextActions = []contracts.NextAction{{ + Action: "configure_sandbox_server_key", + Description: "export the Sandbox Server Key and rerun midtrans status", + }} + + model, ok := Build(result) + if !ok || model.Title != "Salis Property · Sandbox · Snap" { + t.Fatalf("model = %#v", model) + } + if model.Rows[0].State != "✓" || model.Rows[1].State != "✗" { + t.Fatalf("rows = %#v", model.Rows) + } +} diff --git a/internal/render/render.go b/internal/render/render.go index 26b4d9e..9329fd5 100644 --- a/internal/render/render.go +++ b/internal/render/render.go @@ -8,6 +8,7 @@ import ( "github.com/veritrans/midtrans-cli/internal/contracts" "github.com/veritrans/midtrans-cli/internal/evidence" + "github.com/veritrans/midtrans-cli/internal/presentation" ) type Format string @@ -28,7 +29,13 @@ func Write(w io.Writer, result contracts.Result, format Format) error { encoder.SetEscapeHTML(false) return encoder.Encode(result) } + if model, ok := presentation.Build(result); ok { + return writePresentation(w, model) + } + return writeGenericHuman(w, result) +} +func writeGenericHuman(w io.Writer, result contracts.Result) error { if _, err := fmt.Fprintf(w, "%s: %s\n", strings.ToUpper(string(result.Status)), result.Command); err != nil { return err } @@ -44,3 +51,42 @@ func Write(w io.Writer, result contracts.Result, format Format) error { } return nil } + +func writePresentation(w io.Writer, model presentation.Model) error { + if _, err := fmt.Fprintf(w, "%s\n\n", model.Title); err != nil { + return err + } + + labelWidth := 0 + for _, row := range model.Rows { + labelWidth = max(labelWidth, len(row.Label)) + } + for _, row := range model.Rows { + if _, err := fmt.Fprintf(w, "%s %-*s %s\n", row.State, labelWidth, row.Label, row.Detail); err != nil { + return err + } + } + + if len(model.Findings) > 0 { + if _, err := fmt.Fprintln(w, "\nFindings:"); err != nil { + return err + } + for _, finding := range model.Findings { + if _, err := fmt.Fprintf(w, "- [%s] %s: %s\n", finding.Severity, finding.Code, finding.Message); err != nil { + return err + } + } + } + + if len(model.NextActions) > 0 { + if _, err := fmt.Fprintln(w, "\nNext:"); err != nil { + return err + } + for _, action := range model.NextActions { + if _, err := fmt.Fprintf(w, " %s\n", action.Description); err != nil { + return err + } + } + } + return nil +} diff --git a/internal/render/render_test.go b/internal/render/render_test.go index ed3a62a..dd1ca70 100644 --- a/internal/render/render_test.go +++ b/internal/render/render_test.go @@ -7,6 +7,7 @@ import ( "testing" "github.com/veritrans/midtrans-cli/internal/contracts" + "github.com/veritrans/midtrans-cli/internal/readiness" "github.com/veritrans/midtrans-cli/internal/render" ) @@ -43,6 +44,44 @@ func TestWriteHumanUsesSameResult(t *testing.T) { } } +func TestWriteHumanStatusShowsChecksAndNextAction(t *testing.T) { + var output bytes.Buffer + result := contracts.NewResult("status", contracts.StatusWarn) + result.Data = readiness.Report{ + Project: "Salis Property", + Environment: "sandbox", + Products: []string{"snap"}, + Checks: []readiness.Check{{ + ID: "server-key", Label: "Server key", + State: readiness.NeedsAction, + Detail: "MIDTRANS_SERVER_KEY is not available", + }}, + } + result.NextActions = []contracts.NextAction{{ + Action: "configure_sandbox_server_key", + Description: "export the Sandbox Server Key", + }} + + if err := render.Write(&output, result, render.FormatHuman); err != nil { + t.Fatal(err) + } + got := output.String() + for _, expected := range []string{ + "Salis Property · Sandbox · Snap", + "Server key", + "MIDTRANS_SERVER_KEY is not available", + "Next:", + "export the Sandbox Server Key", + } { + if !strings.Contains(got, expected) { + t.Fatalf("output missing %q:\n%s", expected, got) + } + } + if strings.Contains(got, "PASS: status") { + t.Fatalf("bare pass output:\n%s", got) + } +} + func TestWriteStructurallySanitizesBeforeJSONSerialization(t *testing.T) { var output bytes.Buffer result := contracts.NewResult("inspect", contracts.StatusPass) From 4700e4fd9e001afaa02ffcae790db22568578063 Mon Sep 17 00:00:00 2001 From: "m.salis" Date: Sun, 26 Jul 2026 09:07:18 +0700 Subject: [PATCH 10/73] feat: add merchant project status dashboard --- internal/app/app.go | 64 +++++++++++++++++--- internal/app/app_test.go | 61 ++++++++++++++++++- internal/app/commands_status.go | 89 ++++++++++++++++++++++++++++ internal/app/commands_status_test.go | 35 +++++++++++ 4 files changed, 241 insertions(+), 8 deletions(-) create mode 100644 internal/app/commands_status.go create mode 100644 internal/app/commands_status_test.go diff --git a/internal/app/app.go b/internal/app/app.go index 566d354..e0c2c92 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -2,28 +2,33 @@ package app import ( "context" + "errors" "fmt" "io" + "net/http" "os" "strings" + "time" "github.com/spf13/cobra" "github.com/veritrans/midtrans-cli/internal/contracts" "github.com/veritrans/midtrans-cli/internal/evidence" "github.com/veritrans/midtrans-cli/internal/packs" + "github.com/veritrans/midtrans-cli/internal/project" "github.com/veritrans/midtrans-cli/internal/render" "github.com/veritrans/midtrans-cli/internal/sandbox" "github.com/veritrans/midtrans-cli/internal/version" ) type Dependencies struct { - Stdout io.Writer - Stderr io.Writer - Version version.Info - Packs *packs.Registry - Getenv func(string) (string, bool) - Getwd func() (string, error) - HTTP sandbox.Doer + Stdout io.Writer + Stderr io.Writer + Version version.Info + Packs *packs.Registry + Getenv func(string) (string, bool) + Getwd func() (string, error) + HTTP sandbox.Doer + LocalProbe func(context.Context, string) bool } type globalFlags struct { @@ -43,6 +48,9 @@ func Execute(ctx context.Context, args []string, deps Dependencies) int { if deps.Getwd == nil { deps.Getwd = os.Getwd } + if deps.LocalProbe == nil { + deps.LocalProbe = defaultLocalProbe + } flags := &globalFlags{} jsonOutput := jsonOutputRequested(args) if containsReservedCobraCommand(args) { @@ -52,6 +60,29 @@ func Execute(ctx context.Context, args []string, deps Dependencies) int { Use: "midtrans", SilenceUsage: true, SilenceErrors: true, + Args: cobra.NoArgs, + Annotations: map[string]string{ + resultNameAnnotation: "status", + }, + RunE: func(cmd *cobra.Command, _ []string) error { + start, err := deps.Getwd() + if err != nil { + return writeResult(deps, flags, projectFailure( + cmd, deps, "PROJECT_DIR_NOT_FOUND", "current directory is unavailable", + )) + } + resolution, err := project.Resolve(project.Request{ + StartDir: start, ExplicitDir: flags.projectDir, Mode: project.Existing, + }) + if errors.Is(err, project.ErrNotInitialized) { + return writeResult(deps, flags, welcomeResult(deps)) + } + if err != nil { + return writeResult(deps, flags, projectErrorResult(cmd, deps, err)) + } + flags.projectDir = resolution.Root + return writeResult(deps, flags, buildStatusResult(cmd.Context(), flags, deps)) + }, } root.SetOut(deps.Stdout) root.SetErr(deps.Stderr) @@ -80,6 +111,7 @@ func Execute(ctx context.Context, args []string, deps Dependencies) int { newPackCommand(flags, deps), newPlanCommand(flags, deps), newSandboxCommand(flags, deps), + newStatusCommand(flags, deps), newUpdateCommand(flags, deps), newVerifyCommand(flags, deps), newWebhookCommand(flags, deps), @@ -102,6 +134,24 @@ func Execute(ctx context.Context, args []string, deps Dependencies) int { return 0 } +func defaultLocalProbe(ctx context.Context, baseURL string) bool { + request, err := http.NewRequestWithContext(ctx, http.MethodGet, baseURL, nil) + if err != nil { + return false + } + client := &http.Client{ + Timeout: 2 * time.Second, + CheckRedirect: func(*http.Request, []*http.Request) error { + return http.ErrUseLastResponse + }, + } + response, err := client.Do(request) + if response != nil && response.Body != nil { + defer response.Body.Close() + } + return err == nil && response != nil +} + func containsReservedCobraCommand(args []string) bool { for _, arg := range args { switch arg { diff --git a/internal/app/app_test.go b/internal/app/app_test.go index a268e31..f3ca604 100644 --- a/internal/app/app_test.go +++ b/internal/app/app_test.go @@ -59,6 +59,65 @@ func TestCapabilitiesJSON(t *testing.T) { } } +func TestStatusShowsActionableMerchantReadiness(t *testing.T) { + project := merchantFixture("snap-complete") + var stdout, stderr bytes.Buffer + exit := app.Execute(context.Background(), []string{ + "status", "--project-dir", project, + }, app.Dependencies{ + Stdout: &stdout, + Stderr: &stderr, + Version: version.Info{Version: "0.1.0-test"}, + Packs: testRegistry(t), + Getenv: func(string) (string, bool) { return "", false }, + LocalProbe: func(context.Context, string) bool { return false }, + }) + if exit != 0 { + t.Fatalf("exit = %d, stderr = %s", exit, stderr.String()) + } + for _, expected := range []string{ + "Sandbox", "Snap", "Project", "Checkout", "Webhook", + "Server key", "MIDTRANS_SERVER_KEY", "Next:", + } { + if !strings.Contains(stdout.String(), expected) { + t.Fatalf("missing %q:\n%s", expected, stdout.String()) + } + } +} + +func TestRootInvocationUsesStatusInsideProject(t *testing.T) { + project := merchantFixture("snap-complete") + result, exit := executeJSONWithDependencies( + t, + app.Dependencies{ + Version: version.Info{Version: "0.1.0-test"}, + Packs: testRegistry(t), + Getwd: func() (string, error) { return project, nil }, + }, + ) + if exit != 0 || result.Command != "status" { + t.Fatalf("exit = %d, result = %#v", exit, result) + } +} + +func TestRootInvocationGuidesInitializationOutsideProject(t *testing.T) { + root := t.TempDir() + result, exit := executeJSONWithDependencies( + t, + app.Dependencies{ + Version: version.Info{Version: "0.1.0-test"}, + Packs: testRegistry(t), + Getwd: func() (string, error) { return root, nil }, + }, + ) + if exit != 0 || + result.Command != "welcome" || + len(result.NextActions) == 0 || + result.NextActions[0].Action != "initialize_project" { + t.Fatalf("exit = %d, result = %#v", exit, result) + } +} + func TestCapabilitiesJSONMatchesPublishedContract(t *testing.T) { data, err := os.ReadFile(filepath.Join("..", "..", "contracts", "capabilities-v1.json")) if err != nil { @@ -361,7 +420,7 @@ func TestUpdateCheckFailureDoesNotExposeUpstreamData(t *testing.T) { func TestHelpExposesExactlyThePhaseOneCommandSurface(t *testing.T) { expected := map[string][]string{ - "": {"capabilities", "credentials", "doctor", "evidence", "init", "inspect", "manifest", "pack", "plan", "sandbox", "update", "verify", "webhook"}, + "": {"capabilities", "credentials", "doctor", "evidence", "init", "inspect", "manifest", "pack", "plan", "sandbox", "status", "update", "verify", "webhook"}, "credentials": {"status"}, "evidence": {"export", "show"}, "manifest": {"migrate", "validate"}, diff --git a/internal/app/commands_status.go b/internal/app/commands_status.go new file mode 100644 index 0000000..de75660 --- /dev/null +++ b/internal/app/commands_status.go @@ -0,0 +1,89 @@ +package app + +import ( + "context" + + "github.com/spf13/cobra" + "github.com/veritrans/midtrans-cli/internal/contracts" + "github.com/veritrans/midtrans-cli/internal/inspection" + "github.com/veritrans/midtrans-cli/internal/manifest" + "github.com/veritrans/midtrans-cli/internal/project" + "github.com/veritrans/midtrans-cli/internal/readiness" + "github.com/veritrans/midtrans-cli/internal/secrets" +) + +func newStatusCommand(flags *globalFlags, deps Dependencies) *cobra.Command { + return withProjectMode(&cobra.Command{ + Use: "status", + Short: "show merchant integration readiness", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + return writeResult(deps, flags, buildStatusResult(cmd.Context(), flags, deps)) + }, + }, project.Existing, "status") +} + +func buildStatusResult( + ctx context.Context, + flags *globalFlags, + deps Dependencies, +) contracts.Result { + value, invalid := loadValidatedManifest("status", flags.projectDir, deps) + if invalid != nil { + return *invalid + } + report, err := inspection.Inspect(flags.projectDir) + if err != nil { + result := inspectionFailureResult("status", deps) + result.ManifestVersion = value.SchemaVersion + return result + } + pack, ok := deps.Packs.Get("snap") + if !ok { + result := contracts.NewIncompatibleResult( + "status", "CAPABILITY_NOT_INSTALLED", "requested product pack is unavailable", + ) + result.CLIVersion = deps.Version.Version + result.ManifestVersion = value.SchemaVersion + return result + } + findings := append(manifest.Validate(value), pack.Evaluate(value, report)...) + provider := secrets.NewEnvironmentProvider(deps.Getenv) + serverPresent := secretPresent(ctx, provider, value.Credentials.References["server_key"]) + clientPresent := secretPresent(ctx, provider, value.Credentials.References["client_key"]) + reachable := readiness.ReachabilityUnknown + if value.Integration.LocalBaseURL != "" { + if deps.LocalProbe(ctx, value.Integration.LocalBaseURL) { + reachable = readiness.ReachabilityReachable + } else { + reachable = readiness.ReachabilityUnreachable + } + } + data := readiness.Build(readiness.Input{ + ProjectRoot: flags.projectDir, + Manifest: value, + CLIVersion: deps.Version.Version, + Packs: deps.Packs.Versions(), + Findings: findings, + ServerKeyPresent: serverPresent, + ClientKeyPresent: clientPresent, + LocalReachable: reachable, + }) + result := contracts.NewResult("status", data.Status()) + result.CLIVersion = deps.Version.Version + result.ManifestVersion = value.SchemaVersion + result.Data = data + if action := data.NextAction(); action != nil { + result.NextActions = []contracts.NextAction{*action} + } + return result +} + +func welcomeResult(deps Dependencies) contracts.Result { + result := contracts.NewResult("welcome", contracts.StatusPass) + result.CLIVersion = deps.Version.Version + result.NextActions = []contracts.NextAction{{ + Action: "initialize_project", Description: "run midtrans init", + }} + return result +} diff --git a/internal/app/commands_status_test.go b/internal/app/commands_status_test.go new file mode 100644 index 0000000..65cdf85 --- /dev/null +++ b/internal/app/commands_status_test.go @@ -0,0 +1,35 @@ +package app + +import ( + "context" + "net/http" + "net/http/httptest" + "sync/atomic" + "testing" +) + +func TestDefaultLocalProbeTreatsEveryHTTPResponseAsReachableWithoutRedirecting(t *testing.T) { + var redirected atomic.Int32 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, request *http.Request) { + switch request.URL.Path { + case "/redirect": + http.Redirect(w, request, "/destination", http.StatusFound) + case "/destination": + redirected.Add(1) + w.WriteHeader(http.StatusNoContent) + default: + w.WriteHeader(http.StatusInternalServerError) + } + })) + defer server.Close() + + if !defaultLocalProbe(context.Background(), server.URL+"/redirect") { + t.Fatal("redirect response was not reachable") + } + if redirected.Load() != 0 { + t.Fatalf("redirect destination was requested %d times", redirected.Load()) + } + if !defaultLocalProbe(context.Background(), server.URL+"/failure") { + t.Fatal("non-success HTTP response was not reachable") + } +} From a137a4b2cea71d1e69d727459db843b189e296bd Mon Sep 17 00:00:00 2001 From: "m.salis" Date: Sun, 26 Jul 2026 09:14:54 +0700 Subject: [PATCH 11/73] feat: add safe interactive Sandbox setup --- internal/app/app.go | 14 +++ internal/app/app_test.go | 110 ++++++++++++++++- internal/app/commands_setup.go | 188 +++++++++++++++++++++++++++++ internal/manifest/file.go | 40 ++++++ internal/manifest/manifest_test.go | 24 ++++ 5 files changed, 375 insertions(+), 1 deletion(-) create mode 100644 internal/app/commands_setup.go diff --git a/internal/app/app.go b/internal/app/app.go index e0c2c92..989525f 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -21,12 +21,14 @@ import ( ) type Dependencies struct { + Stdin io.Reader Stdout io.Writer Stderr io.Writer Version version.Info Packs *packs.Registry Getenv func(string) (string, bool) Getwd func() (string, error) + IsTerminal func() bool HTTP sandbox.Doer LocalProbe func(context.Context, string) bool } @@ -39,6 +41,12 @@ type globalFlags struct { } func Execute(ctx context.Context, args []string, deps Dependencies) int { + if deps.Stdin == nil { + deps.Stdin = os.Stdin + } + if deps.IsTerminal == nil { + deps.IsTerminal = defaultIsTerminal + } if deps.Getenv == nil { deps.Getenv = os.LookupEnv } @@ -111,6 +119,7 @@ func Execute(ctx context.Context, args []string, deps Dependencies) int { newPackCommand(flags, deps), newPlanCommand(flags, deps), newSandboxCommand(flags, deps), + newSetupCommand(flags, deps), newStatusCommand(flags, deps), newUpdateCommand(flags, deps), newVerifyCommand(flags, deps), @@ -134,6 +143,11 @@ func Execute(ctx context.Context, args []string, deps Dependencies) int { return 0 } +func defaultIsTerminal() bool { + info, err := os.Stdin.Stat() + return err == nil && info.Mode()&os.ModeCharDevice != 0 +} + func defaultLocalProbe(ctx context.Context, baseURL string) bool { request, err := http.NewRequestWithContext(ctx, http.MethodGet, baseURL, nil) if err != nil { diff --git a/internal/app/app_test.go b/internal/app/app_test.go index f3ca604..984ff28 100644 --- a/internal/app/app_test.go +++ b/internal/app/app_test.go @@ -11,6 +11,7 @@ import ( "os" "path/filepath" "reflect" + "slices" "strings" "testing" @@ -85,6 +86,113 @@ func TestStatusShowsActionableMerchantReadiness(t *testing.T) { } } +func TestSetupNonInteractiveNeverWritesManifest(t *testing.T) { + project := t.TempDir() + if _, err := manifest.Init(project); err != nil { + t.Fatal(err) + } + before, err := os.ReadFile(manifest.Path(project)) + if err != nil { + t.Fatal(err) + } + result, exit := executeJSON( + t, + "setup", "--project-dir", project, "--json", "--non-interactive", + ) + after, err := os.ReadFile(manifest.Path(project)) + if err != nil { + t.Fatal(err) + } + if exit != 0 || result.Command != "setup" || !bytes.Equal(before, after) { + t.Fatalf("exit = %d, result = %#v, changed = %v", exit, result, !bytes.Equal(before, after)) + } +} + +func TestSetupInteractiveWritesOnlyAfterExactConfirmation(t *testing.T) { + project := t.TempDir() + if _, err := manifest.Init(project); err != nil { + t.Fatal(err) + } + input := strings.NewReader(strings.Join([]string{ + "popup", + "/api/payment/webhook", + "/orders/{order_id}", + "http://127.0.0.1:3101", + "/api/dev/midtrans/{order_id}", + "yes", + "", + }, "\n")) + var stdout, stderr bytes.Buffer + exit := app.Execute(context.Background(), []string{ + "setup", "--project-dir", project, + }, app.Dependencies{ + Stdin: input, Stdout: &stdout, Stderr: &stderr, + IsTerminal: func() bool { return true }, + Version: version.Info{Version: "0.1.0-test"}, + Packs: testRegistry(t), + }) + if exit != 0 { + t.Fatalf("exit = %d, stderr = %s", exit, stderr.String()) + } + value, err := manifest.Load(project) + if err != nil || + !slices.Contains(value.Integration.CheckoutModes, "popup") || + value.Integration.NotificationRoute != "/api/payment/webhook" { + t.Fatalf("manifest = %#v, err = %v", value, err) + } +} + +func TestSetupCancellationLeavesManifestUnchanged(t *testing.T) { + project := t.TempDir() + if _, err := manifest.Init(project); err != nil { + t.Fatal(err) + } + before, err := os.ReadFile(manifest.Path(project)) + if err != nil { + t.Fatal(err) + } + input := strings.NewReader("popup\n/api/payment/webhook\n/orders/{order_id}\nhttp://127.0.0.1:3101\n/api/dev/midtrans/{order_id}\nyes please\n") + var stdout, stderr bytes.Buffer + exit := app.Execute(context.Background(), []string{"setup", "--project-dir", project}, app.Dependencies{ + Stdin: input, Stdout: &stdout, Stderr: &stderr, + IsTerminal: func() bool { return true }, + Version: version.Info{Version: "0.1.0-test"}, + Packs: testRegistry(t), + }) + after, readErr := os.ReadFile(manifest.Path(project)) + if readErr != nil { + t.Fatal(readErr) + } + if exit != 3 || !bytes.Equal(before, after) { + t.Fatalf("exit = %d, changed = %v, stdout = %s", exit, !bytes.Equal(before, after), stdout.String()) + } +} + +func TestSetupMalformedInputLeavesManifestUnchanged(t *testing.T) { + project := t.TempDir() + if _, err := manifest.Init(project); err != nil { + t.Fatal(err) + } + before, err := os.ReadFile(manifest.Path(project)) + if err != nil { + t.Fatal(err) + } + var stdout, stderr bytes.Buffer + exit := app.Execute(context.Background(), []string{"setup", "--project-dir", project}, app.Dependencies{ + Stdin: strings.NewReader("popup\n"), Stdout: &stdout, Stderr: &stderr, + IsTerminal: func() bool { return true }, + Version: version.Info{Version: "0.1.0-test"}, + Packs: testRegistry(t), + }) + after, readErr := os.ReadFile(manifest.Path(project)) + if readErr != nil { + t.Fatal(readErr) + } + if exit != 6 || !bytes.Equal(before, after) { + t.Fatalf("exit = %d, changed = %v, stdout = %s", exit, !bytes.Equal(before, after), stdout.String()) + } +} + func TestRootInvocationUsesStatusInsideProject(t *testing.T) { project := merchantFixture("snap-complete") result, exit := executeJSONWithDependencies( @@ -420,7 +528,7 @@ func TestUpdateCheckFailureDoesNotExposeUpstreamData(t *testing.T) { func TestHelpExposesExactlyThePhaseOneCommandSurface(t *testing.T) { expected := map[string][]string{ - "": {"capabilities", "credentials", "doctor", "evidence", "init", "inspect", "manifest", "pack", "plan", "sandbox", "status", "update", "verify", "webhook"}, + "": {"capabilities", "credentials", "doctor", "evidence", "init", "inspect", "manifest", "pack", "plan", "sandbox", "setup", "status", "update", "verify", "webhook"}, "credentials": {"status"}, "evidence": {"export", "show"}, "manifest": {"migrate", "validate"}, diff --git a/internal/app/commands_setup.go b/internal/app/commands_setup.go new file mode 100644 index 0000000..3fd7da2 --- /dev/null +++ b/internal/app/commands_setup.go @@ -0,0 +1,188 @@ +package app + +import ( + "errors" + "fmt" + "io" + "strings" + + "github.com/spf13/cobra" + "github.com/veritrans/midtrans-cli/internal/contracts" + "github.com/veritrans/midtrans-cli/internal/manifest" + "github.com/veritrans/midtrans-cli/internal/project" +) + +const maxSetupInputBytes = 4096 + +func newSetupCommand(flags *globalFlags, deps Dependencies) *cobra.Command { + command := &cobra.Command{ + Use: "setup", + Short: "configure Sandbox checkout readiness", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + if flags.json || flags.nonInteractive || !deps.IsTerminal() { + result := buildStatusResult(cmd.Context(), flags, deps) + result.Command = "setup" + if result.Status == contracts.StatusFail && result.Data != nil { + result.Status = contracts.StatusWarn + } + return writeResult(deps, flags, result) + } + value, invalid := loadValidatedManifest("setup", flags.projectDir, deps) + if invalid != nil { + return writeResult(deps, flags, *invalid) + } + proposed, err := promptManifestSetup(deps.Stdin, deps.Stdout, value) + if err != nil { + return writeResult(deps, flags, setupInputFailure(deps)) + } + if !confirmExactYes(deps.Stdin, deps.Stdout) { + result := contracts.NewResult("setup", contracts.StatusBlocked) + result.CLIVersion = deps.Version.Version + result.NextActions = []contracts.NextAction{{ + Action: "review_setup", + Description: "review the proposed manifest settings and rerun midtrans setup", + }} + return writeResult(deps, flags, result) + } + if err := manifest.Save(flags.projectDir, proposed); err != nil { + return writeResult(deps, flags, setupSaveFailure(deps)) + } + result := buildStatusResult(cmd.Context(), flags, deps) + result.Command = "setup" + return writeResult(deps, flags, result) + }, + } + return withProjectMode(command, project.Existing, "setup") +} + +func promptManifestSetup(input io.Reader, output io.Writer, value manifest.Manifest) (manifest.Manifest, error) { + read := func(label string) (string, error) { + if _, err := fmt.Fprintf(output, "%s: ", label); err != nil { + return "", err + } + line, err := readSetupLine(input) + if err != nil { + return "", err + } + line = strings.TrimSpace(line) + if line == "" { + return "", errors.New("setup input cannot be empty") + } + return line, nil + } + + checkoutMode, err := read("Checkout mode (popup or redirect)") + if err != nil { + return manifest.Manifest{}, err + } + if checkoutMode != "popup" && checkoutMode != "redirect" { + return manifest.Manifest{}, errors.New("unsupported checkout mode") + } + notificationRoute, err := read("Notification route") + if err != nil { + return manifest.Manifest{}, err + } + finishRoute, err := read("Finish route") + if err != nil { + return manifest.Manifest{}, err + } + localBaseURL, err := read("Loopback local URL") + if err != nil { + return manifest.Manifest{}, err + } + localStatusRoute, err := read("Local status route") + if err != nil { + return manifest.Manifest{}, err + } + + proposed := value + proposed.Integration.CheckoutModes = []string{checkoutMode} + proposed.Integration.NotificationRoute = notificationRoute + proposed.Integration.FinishRedirectRoute = finishRoute + proposed.Integration.LocalBaseURL = localBaseURL + proposed.Integration.LocalStatusRoute = localStatusRoute + if findings := manifest.Validate(proposed); len(findings) != 0 { + return manifest.Manifest{}, errors.New("invalid manifest setup input") + } + if _, err := fmt.Fprintln(output, "\nProposed .midtrans/manifest.yaml changes:"); err != nil { + return manifest.Manifest{}, err + } + for _, preview := range []struct { + field string + value string + }{ + {"integration.checkout_modes", checkoutMode}, + {"integration.notification_route", notificationRoute}, + {"integration.finish_redirect_route", finishRoute}, + {"integration.local_base_url", localBaseURL}, + {"integration.local_status_route", localStatusRoute}, + } { + if _, err := fmt.Fprintf(output, "- %s: %s\n", preview.field, preview.value); err != nil { + return manifest.Manifest{}, err + } + } + return proposed, nil +} + +func confirmExactYes(input io.Reader, output io.Writer) bool { + if _, err := fmt.Fprint(output, "Save these changes? Type yes to continue: "); err != nil { + return false + } + line, err := readSetupLine(input) + return err == nil && strings.EqualFold(line, "yes") +} + +func readSetupLine(input io.Reader) (string, error) { + line := make([]byte, 0, 128) + var byteBuffer [1]byte + for len(line) <= maxSetupInputBytes { + count, err := input.Read(byteBuffer[:]) + if count > 0 { + switch byteBuffer[0] { + case '\n': + if len(line) > 0 && line[len(line)-1] == '\r' { + line = line[:len(line)-1] + } + return string(line), nil + default: + line = append(line, byteBuffer[0]) + } + } + if err != nil { + if errors.Is(err, io.EOF) && len(line) > 0 { + return string(line), nil + } + if errors.Is(err, io.EOF) { + return "", io.ErrUnexpectedEOF + } + return "", err + } + if count == 0 { + return "", io.ErrNoProgress + } + } + return "", errors.New("setup input exceeds maximum length") +} + +func setupInputFailure(deps Dependencies) contracts.Result { + result := contracts.NewResult("setup", contracts.StatusError) + result.CLIVersion = deps.Version.Version + result.Findings = []contracts.Finding{{ + Code: "SETUP_INPUT_INVALID", + Severity: "blocking", + Message: "setup input was incomplete or invalid; the manifest was not changed", + }} + return result +} + +func setupSaveFailure(deps Dependencies) contracts.Result { + result := contracts.NewResult("setup", contracts.StatusError) + result.CLIVersion = deps.Version.Version + result.Findings = []contracts.Finding{{ + Code: "SETUP_SAVE_FAILED", + Severity: "blocking", + Message: "unable to save the manifest; no configuration changes were applied", + }} + return result +} diff --git a/internal/manifest/file.go b/internal/manifest/file.go index 956efd9..933145b 100644 --- a/internal/manifest/file.go +++ b/internal/manifest/file.go @@ -101,3 +101,43 @@ func Init(projectDir string) (string, error) { } return Path(projectDir), nil } + +// Save replaces an existing manifest with a validated value. The temporary file +// is created beside the manifest so the final rename is atomic on a single +// filesystem, and safepath keeps every write within the selected project. +func Save(projectDir string, value Manifest) error { + if findings := Validate(value); len(findings) != 0 { + return errors.New("manifest validation failed") + } + path, err := safepath.Existing( + projectDir, + filepath.Join(".midtrans", "manifest.yaml"), + ) + if err != nil { + return err + } + file, err := os.CreateTemp(filepath.Dir(path), ".manifest-*.yaml") + if err != nil { + return err + } + temp := file.Name() + defer os.Remove(temp) + if err := file.Chmod(0o644); err != nil { + file.Close() + return err + } + encoder := yaml.NewEncoder(file) + encoder.SetIndent(2) + if err := encoder.Encode(value); err != nil { + file.Close() + return err + } + if err := file.Sync(); err != nil { + file.Close() + return err + } + if err := file.Close(); err != nil { + return err + } + return os.Rename(temp, path) +} diff --git a/internal/manifest/manifest_test.go b/internal/manifest/manifest_test.go index 3047eb3..933449c 100644 --- a/internal/manifest/manifest_test.go +++ b/internal/manifest/manifest_test.go @@ -4,6 +4,7 @@ import ( "encoding/json" "os" "path/filepath" + "reflect" "strings" "testing" @@ -57,6 +58,29 @@ func TestInitIsExclusive(t *testing.T) { } } +func TestSaveRoundTripsValidatedManifestAtomically(t *testing.T) { + root := t.TempDir() + if _, err := manifest.Init(root); err != nil { + t.Fatal(err) + } + value, err := manifest.Load(root) + if err != nil { + t.Fatal(err) + } + value.Integration.CheckoutModes = []string{"popup"} + value.Integration.NotificationRoute = "/api/payment/webhook" + value.Integration.FinishRedirectRoute = "/orders/{order_id}" + value.Integration.LocalBaseURL = "http://127.0.0.1:3101" + value.Integration.LocalStatusRoute = "/api/dev/midtrans/{order_id}" + if err := manifest.Save(root, value); err != nil { + t.Fatal(err) + } + got, err := manifest.Load(root) + if err != nil || !reflect.DeepEqual(got, value) { + t.Fatalf("manifest = %#v, err = %v", got, err) + } +} + func TestInitAndLoadAcceptRelativeProjectDirectory(t *testing.T) { root, err := os.MkdirTemp(".", "relative-project-") if err != nil { From 458a11c1b9ef1cdc9a278206c9d6565d45321e17 Mon Sep 17 00:00:00 2001 From: "m.salis" Date: Sun, 26 Jul 2026 09:27:06 +0700 Subject: [PATCH 12/73] feat: separate merchant and agent command surfaces --- internal/app/app.go | 112 +++++++++++++++++-- internal/app/app_test.go | 161 +++++++++++++++++++++++++-- internal/app/commands_agent.go | 17 +++ internal/app/commands_credentials.go | 103 ++++++++++------- internal/app/commands_doctor.go | 30 ++++- internal/app/commands_inspect.go | 15 ++- internal/app/commands_sandbox.go | 4 +- internal/app/commands_version.go | 23 ++++ internal/presentation/model.go | 26 +++++ internal/render/render.go | 9 +- 10 files changed, 424 insertions(+), 76 deletions(-) create mode 100644 internal/app/commands_agent.go create mode 100644 internal/app/commands_version.go diff --git a/internal/app/app.go b/internal/app/app.go index 989525f..ee72338 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -38,8 +38,16 @@ type globalFlags struct { nonInteractive bool projectDir string verbose bool + legacy *legacyInvocation } +type legacyInvocation struct { + oldCommand string + newCommand string +} + +const legacyInvocationAnnotation = "midtrans.legacy-invocation" + func Execute(ctx context.Context, args []string, deps Dependencies) int { if deps.Stdin == nil { deps.Stdin = os.Stdin @@ -97,6 +105,7 @@ func Execute(ctx context.Context, args []string, deps Dependencies) int { root.CompletionOptions.DisableDefaultCmd = true root.SetUsageTemplate(phaseOneUsageTemplate) root.PersistentPreRunE = func(cmd *cobra.Command, _ []string) error { + flags.legacy = legacyInvocationForCommand(cmd) return resolveProjectContext(cmd, flags, deps) } root.PersistentFlags().BoolVar(&flags.json, "json", false, "write the stable JSON result contract") @@ -109,21 +118,48 @@ func Execute(ctx context.Context, args []string, deps Dependencies) int { ) root.PersistentFlags().BoolVar(&flags.verbose, "verbose", false, "write additional redacted diagnostics") root.AddCommand( - newCapabilitiesCommand(flags, deps), - newCredentialsCommand(flags, deps), - newDoctorCommand(flags, deps), - newEvidenceCommand(flags, deps), + newAgentCommand(flags, deps), newInitCommand(flags, deps), - newInspectCommand(flags, deps), - newManifestCommand(flags, deps), - newPackCommand(flags, deps), - newPlanCommand(flags, deps), - newSandboxCommand(flags, deps), newSetupCommand(flags, deps), newStatusCommand(flags, deps), + newSandboxCommand(flags, deps, "test"), newUpdateCommand(flags, deps), newVerifyCommand(flags, deps), - newWebhookCommand(flags, deps), + newVersionCommand(flags, deps), + legacyCommand( + newCapabilitiesCommand(flags, deps), + "midtrans capabilities", + "midtrans agent capabilities", + ), + legacyCommand( + newCredentialsCommand(flags, deps), + "midtrans credentials", + "midtrans setup", + ), + legacyCommand( + newDoctorCommand(flags, deps), + "midtrans doctor", + "midtrans status", + ), + hiddenCommand(newEvidenceCommand(flags, deps)), + legacyCommand( + newInspectCommand(flags, deps, "inspect", "inspect"), + "midtrans inspect", + "midtrans agent inspect", + ), + hiddenCommand(newManifestCommand(flags, deps)), + legacyCommand( + newPackCommand(flags, deps), + "midtrans pack", + "midtrans agent pack", + ), + hiddenCommand(newPlanCommand(flags, deps)), + legacyCommand( + newSandboxCommand(flags, deps, "sandbox"), + "midtrans sandbox", + "midtrans test", + ), + hiddenCommand(newWebhookCommand(flags, deps)), ) root.InitDefaultHelpCmd() for _, command := range root.Commands() { @@ -250,6 +286,20 @@ Use "{{.CommandPath}} [command] --help" for more information about a command.{{e ` func writeResult(deps Dependencies, flags *globalFlags, result contracts.Result) error { + if !flags.json && flags.legacy != nil { + writeMigrationNotice( + flags, + deps, + flags.legacy.oldCommand, + flags.legacy.newCommand, + ) + if len(result.NextActions) == 0 { + result.NextActions = []contracts.NextAction{{ + Action: "use_replacement_command", + Description: "run " + flags.legacy.newCommand, + }} + } + } result.EnsureNextAction() exitCode := result.ExitCode() safe, err := evidence.SanitizeResult(result, deps.Packs.SensitiveKeys()) @@ -269,6 +319,48 @@ func writeResult(deps Dependencies, flags *globalFlags, result contracts.Result) return nil } +func hiddenCommand(command *cobra.Command) *cobra.Command { + command.Hidden = true + return command +} + +func legacyCommand(command *cobra.Command, oldCommand, newCommand string) *cobra.Command { + hiddenCommand(command) + if command.Annotations == nil { + command.Annotations = map[string]string{} + } + command.Annotations[legacyInvocationAnnotation] = oldCommand + "\x00" + newCommand + return command +} + +func legacyInvocationForCommand(command *cobra.Command) *legacyInvocation { + for current := command; current != nil; current = current.Parent() { + value := current.Annotations[legacyInvocationAnnotation] + parts := strings.SplitN(value, "\x00", 2) + if len(parts) == 2 { + return &legacyInvocation{oldCommand: parts[0], newCommand: parts[1]} + } + } + return nil +} + +func writeMigrationNotice( + flags *globalFlags, + deps Dependencies, + oldCommand string, + newCommand string, +) { + if flags.json { + return + } + fmt.Fprintf( + deps.Stderr, + "Deprecated: %s is retained for v0.1.x compatibility; use %s.\n", + oldCommand, + newCommand, + ) +} + func statusFromFindings(findings []contracts.Finding) contracts.Status { status := contracts.StatusPass for _, finding := range findings { diff --git a/internal/app/app_test.go b/internal/app/app_test.go index 984ff28..e04386b 100644 --- a/internal/app/app_test.go +++ b/internal/app/app_test.go @@ -60,6 +60,130 @@ func TestCapabilitiesJSON(t *testing.T) { } } +func TestHelpLeadsWithMerchantCommandSurface(t *testing.T) { + got := helpCommandNames(executeHelp(t, "--help")) + want := []string{ + "agent", "init", "setup", "status", "test", "update", "verify", "version", + } + if !reflect.DeepEqual(got, want) { + t.Fatalf("commands = %#v, want %#v", got, want) + } +} + +func TestAgentCapabilitiesPreservesCapabilityContract(t *testing.T) { + result, exit := executeJSON( + t, + "agent", "capabilities", "--json", "--non-interactive", + ) + if exit != 0 || + result.SchemaVersion != "1.0" || + len(result.Capabilities) != 4 || + len(result.Journeys) != 3 { + t.Fatalf("exit = %d, result = %#v", exit, result) + } +} + +func TestLegacyCapabilitiesJSONRemainsCompatibleAndHidden(t *testing.T) { + legacy, legacyExit := executeJSON( + t, "capabilities", "--json", "--non-interactive", + ) + current, currentExit := executeJSON( + t, "agent", "capabilities", "--json", "--non-interactive", + ) + if legacyExit != currentExit || + !reflect.DeepEqual(legacy.Capabilities, current.Capabilities) || + !reflect.DeepEqual(legacy.Journeys, current.Journeys) { + t.Fatalf("legacy = %#v, current = %#v", legacy, current) + } + if slices.Contains(helpCommandNames(executeHelp(t, "--help")), "capabilities") { + t.Fatal("legacy command is visible in primary help") + } +} + +func TestLegacyHumanCommandsProvideMerchantGuidance(t *testing.T) { + for _, command := range [][]string{ + {"capabilities"}, + {"credentials"}, + {"doctor"}, + } { + stdout, stderr, exit := executeHuman(t, command...) + if exit > 3 { + t.Fatalf("%v exit = %d", command, exit) + } + combined := stdout + stderr + if strings.Contains(combined, "PASS: credentials.status") { + t.Fatalf("%v retained bare internal status: %q", command, combined) + } + if !strings.Contains(combined, "Deprecated:") || + !strings.Contains(combined, "Next:") { + t.Fatalf("%v output = %q", command, combined) + } + } +} + +func TestVersionIsProjectless(t *testing.T) { + result, exit := executeJSONWithDependencies( + t, + app.Dependencies{ + Version: version.Info{ + Version: "v0.1.0", Commit: "abc123", Date: "2026-07-26", + }, + Packs: testRegistry(t), + Getwd: func() (string, error) { + return filepath.Join(t.TempDir(), "missing"), nil + }, + }, + "version", + ) + if exit != 0 || result.Command != "version" { + t.Fatalf("exit = %d, result = %#v", exit, result) + } +} + +func TestAgentNamespacePreservesLegacyJSON(t *testing.T) { + project := merchantFixture("snap-complete") + tests := []struct { + name string + legacy []string + agent []string + }{ + { + name: "inspect", + legacy: []string{"inspect", "--project-dir", project}, + agent: []string{"agent", "inspect", "--project-dir", project}, + }, + { + name: "doctor check", + legacy: []string{"doctor", "--product", "snap", "--project-dir", project}, + agent: []string{"agent", "check", "--product", "snap", "--project-dir", project}, + }, + { + name: "pack info", + legacy: []string{"pack", "info", "snap"}, + agent: []string{"agent", "pack", "info", "snap"}, + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + legacy, legacyExit := executeJSON( + t, append(test.legacy, "--json", "--non-interactive")..., + ) + current, currentExit := executeJSON( + t, append(test.agent, "--json", "--non-interactive")..., + ) + if legacyExit != currentExit || + legacy.SchemaVersion != current.SchemaVersion || + legacy.ManifestVersion != current.ManifestVersion || + !reflect.DeepEqual(legacy.Findings, current.Findings) || + !reflect.DeepEqual(legacy.Packs, current.Packs) || + !reflect.DeepEqual(legacy.Capabilities, current.Capabilities) || + !reflect.DeepEqual(legacy.Journeys, current.Journeys) { + t.Fatalf("legacy = %#v, current = %#v", legacy, current) + } + }) + } +} + func TestStatusShowsActionableMerchantReadiness(t *testing.T) { project := merchantFixture("snap-complete") var stdout, stderr bytes.Buffer @@ -526,16 +650,13 @@ func TestUpdateCheckFailureDoesNotExposeUpstreamData(t *testing.T) { } } -func TestHelpExposesExactlyThePhaseOneCommandSurface(t *testing.T) { +func TestHelpExposesMerchantAndAgentCommandSurfaces(t *testing.T) { expected := map[string][]string{ - "": {"capabilities", "credentials", "doctor", "evidence", "init", "inspect", "manifest", "pack", "plan", "sandbox", "setup", "status", "update", "verify", "webhook"}, - "credentials": {"status"}, - "evidence": {"export", "show"}, - "manifest": {"migrate", "validate"}, - "pack": {"info", "list"}, - "sandbox": {"preflight", "run", "status"}, - "update": {"check"}, - "webhook": {"replay", "verify"}, + "": {"agent", "init", "setup", "status", "test", "update", "verify", "version"}, + "agent": {"capabilities", "check", "inspect", "pack"}, + "pack": {"info", "list"}, + "test": {"preflight", "run", "status"}, + "update": {"check"}, } for command, want := range expected { t.Run(strings.ReplaceAll(command, " ", "."), func(t *testing.T) { @@ -906,9 +1027,12 @@ func TestInspectCommandReturnsStablePublicSafeReport(t *testing.T) { Version: version.Info{Version: "0.1.0-test"}, Packs: testRegistry(t), }) - if exit != 0 || stderr.Len() != 0 { + if exit != 0 || !strings.Contains(stderr.String(), "Deprecated: midtrans inspect") { t.Fatalf("exit = %d, stdout = %q, stderr = %q", exit, stdout.String(), stderr.String()) } + if !strings.Contains(stdout.String(), "Next:") { + t.Fatalf("human inspect output has no next action: %s", stdout.String()) + } if strings.Contains(stdout.String(), canary) { t.Fatalf("human inspect output leaked canary: %s", stdout.String()) } @@ -2252,6 +2376,23 @@ func executeJSONWithGetenv( return result, exit } +func executeHuman(t *testing.T, args ...string) (string, string, int) { + t.Helper() + var stdout, stderr bytes.Buffer + args = append(args, "--project-dir", merchantFixture("snap-complete")) + exit := app.Execute(context.Background(), args, app.Dependencies{ + Stdout: &stdout, + Stderr: &stderr, + Version: version.Info{Version: "0.1.0-test"}, + Packs: testRegistry(t), + Getenv: func(string) (string, bool) { return "", false }, + LocalProbe: func(context.Context, string) bool { + return false + }, + }) + return stdout.String(), stderr.String(), exit +} + func executeHelp(t *testing.T, args ...string) string { t.Helper() var stdout, stderr bytes.Buffer diff --git a/internal/app/commands_agent.go b/internal/app/commands_agent.go new file mode 100644 index 0000000..d63a913 --- /dev/null +++ b/internal/app/commands_agent.go @@ -0,0 +1,17 @@ +package app + +import "github.com/spf13/cobra" + +func newAgentCommand(flags *globalFlags, deps Dependencies) *cobra.Command { + parent := &cobra.Command{ + Use: "agent", + Short: "machine-readable integration and capability commands", + } + parent.AddCommand( + newCapabilitiesCommand(flags, deps), + newInspectCommand(flags, deps, "inspect", "inspect"), + newCheckCommand(flags, deps, "check", "doctor"), + newPackCommand(flags, deps), + ) + return parent +} diff --git a/internal/app/commands_credentials.go b/internal/app/commands_credentials.go index 79eb68c..5daf623 100644 --- a/internal/app/commands_credentials.go +++ b/internal/app/commands_credentials.go @@ -12,53 +12,74 @@ import ( ) func newCredentialsCommand(flags *globalFlags, deps Dependencies) *cobra.Command { - parent := &cobra.Command{Use: "credentials"} + parent := withProjectMode(&cobra.Command{ + Use: "credentials", + Args: cobra.NoArgs, + RunE: newCredentialsStatusRunner(flags, deps), + }, project.Existing, "credentials.status") parent.AddCommand(withProjectMode(&cobra.Command{ Use: "status", Args: cobra.NoArgs, - RunE: func(cmd *cobra.Command, args []string) error { - value, invalidResult := loadValidatedManifest( - "credentials.status", flags.projectDir, deps, - ) - if invalidResult != nil { - return writeResult(deps, flags, *invalidResult) - } + RunE: newCredentialsStatusRunner(flags, deps), + }, project.Existing, "credentials.status")) + return parent +} - provider := secrets.NewEnvironmentProvider(deps.Getenv) - _, serverKeyErr := secrets.ResolveSandboxServerKey( - cmd.Context(), - provider, - value.Credentials.References["server_key"], - ) - serverKeyPresent := serverKeyErr == nil - if serverKeyErr != nil && - !errors.Is(serverKeyErr, secrets.ErrMissing) { - result := sandboxServerKeyErrorResult( - "credentials.status", - value.SchemaVersion, - deps, - serverKeyErr, - ) - return writeResult(deps, flags, result) - } - clientKeyPresent := secretPresent( - cmd.Context(), provider, value.Credentials.References["client_key"], - ) +func newCredentialsStatusRunner( + flags *globalFlags, + deps Dependencies, +) func(*cobra.Command, []string) error { + return func(cmd *cobra.Command, args []string) error { + if !flags.json && flags.legacy != nil && flags.legacy.oldCommand == "midtrans credentials" { + result := buildStatusResult(cmd.Context(), flags, deps) + result.Command = "setup" + result.NextActions = append(result.NextActions, contracts.NextAction{ + Action: "review_sandbox_setup", + Description: "run midtrans setup to review credential readiness", + }) + return writeResult(deps, flags, result) + } + + value, invalidResult := loadValidatedManifest( + "credentials.status", flags.projectDir, deps, + ) + if invalidResult != nil { + return writeResult(deps, flags, *invalidResult) + } - result := contracts.NewResult("credentials.status", contracts.StatusPass) - result.CLIVersion = deps.Version.Version - result.ManifestVersion = value.SchemaVersion - result.Data = map[string]any{ - "provider": "environment", - "references": map[string]bool{ - "server_key": serverKeyPresent, - "client_key": clientKeyPresent, - }, - } + provider := secrets.NewEnvironmentProvider(deps.Getenv) + _, serverKeyErr := secrets.ResolveSandboxServerKey( + cmd.Context(), + provider, + value.Credentials.References["server_key"], + ) + serverKeyPresent := serverKeyErr == nil + if serverKeyErr != nil && + !errors.Is(serverKeyErr, secrets.ErrMissing) { + result := sandboxServerKeyErrorResult( + "credentials.status", + value.SchemaVersion, + deps, + serverKeyErr, + ) return writeResult(deps, flags, result) - }, - }, project.Existing, "credentials.status")) - return parent + } + clientKeyPresent := secretPresent( + cmd.Context(), provider, value.Credentials.References["client_key"], + ) + + result := contracts.NewResult("credentials.status", contracts.StatusPass) + result.CLIVersion = deps.Version.Version + result.ManifestVersion = value.SchemaVersion + result.Data = map[string]any{ + "provider": "environment", + "references": map[string]bool{ + "server_key": serverKeyPresent, + "client_key": clientKeyPresent, + }, + } + return writeResult(deps, flags, result) + } } func secretPresent( diff --git a/internal/app/commands_doctor.go b/internal/app/commands_doctor.go index f2847ba..b1e5427 100644 --- a/internal/app/commands_doctor.go +++ b/internal/app/commands_doctor.go @@ -9,15 +9,33 @@ import ( ) func newDoctorCommand(flags *globalFlags, deps Dependencies) *cobra.Command { + return newCheckCommand(flags, deps, "doctor", "doctor") +} + +func newCheckCommand( + flags *globalFlags, + deps Dependencies, + use string, + resultCommand string, +) *cobra.Command { product := "snap" command := &cobra.Command{ - Use: "doctor", + Use: use, Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, args []string) error { + if !flags.json && flags.legacy != nil && flags.legacy.oldCommand == "midtrans doctor" { + result := buildStatusResult(cmd.Context(), flags, deps) + result.Command = "status" + result.NextActions = append(result.NextActions, contracts.NextAction{ + Action: "review_merchant_status", + Description: "run midtrans status to review merchant readiness", + }) + return writeResult(deps, flags, result) + } pack, ok := deps.Packs.Get(product) if !ok { result := contracts.NewIncompatibleResult( - "doctor", + resultCommand, "CAPABILITY_NOT_INSTALLED", "requested product pack is unavailable", ) @@ -27,20 +45,20 @@ func newDoctorCommand(flags *globalFlags, deps Dependencies) *cobra.Command { value, err := manifest.Load(flags.projectDir) if err != nil { - result := manifestLoadFailureResult("doctor", deps) + result := manifestLoadFailureResult(resultCommand, deps) return writeResult(deps, flags, result) } findings := manifest.Validate(value) report, err := inspection.Inspect(flags.projectDir) if err != nil { - result := inspectionFailureResult("doctor", deps) + result := inspectionFailureResult(resultCommand, deps) result.ManifestVersion = value.SchemaVersion return writeResult(deps, flags, result) } findings = append(findings, pack.Evaluate(value, report)...) - result := contracts.NewResult("doctor", statusFromFindings(findings)) + result := contracts.NewResult(resultCommand, statusFromFindings(findings)) result.CLIVersion = deps.Version.Version result.ManifestVersion = value.SchemaVersion result.Findings = findings @@ -48,5 +66,5 @@ func newDoctorCommand(flags *globalFlags, deps Dependencies) *cobra.Command { }, } command.Flags().StringVar(&product, "product", "snap", "product pack to diagnose") - return withProjectMode(command, project.Existing, "doctor") + return withProjectMode(command, project.Existing, resultCommand) } diff --git a/internal/app/commands_inspect.go b/internal/app/commands_inspect.go index 8588def..7c66315 100644 --- a/internal/app/commands_inspect.go +++ b/internal/app/commands_inspect.go @@ -7,22 +7,27 @@ import ( "github.com/veritrans/midtrans-cli/internal/project" ) -func newInspectCommand(flags *globalFlags, deps Dependencies) *cobra.Command { +func newInspectCommand( + flags *globalFlags, + deps Dependencies, + use string, + resultCommand string, +) *cobra.Command { return withProjectMode(&cobra.Command{ - Use: "inspect", + Use: use, Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, args []string) error { report, err := inspection.Inspect(flags.projectDir) if err != nil { - result := inspectionFailureResult("inspect", deps) + result := inspectionFailureResult(resultCommand, deps) return writeResult(deps, flags, result) } - result := contracts.NewResult("inspect", contracts.StatusPass) + result := contracts.NewResult(resultCommand, contracts.StatusPass) result.CLIVersion = deps.Version.Version result.Data = report return writeResult(deps, flags, result) }, - }, project.Existing, "inspect") + }, project.Existing, resultCommand) } func inspectionFailureResult(command string, deps Dependencies) contracts.Result { diff --git a/internal/app/commands_sandbox.go b/internal/app/commands_sandbox.go index 833de64..870d2df 100644 --- a/internal/app/commands_sandbox.go +++ b/internal/app/commands_sandbox.go @@ -26,9 +26,9 @@ import ( var errRepositoryDirty = errors.New("repository worktree is dirty") -func newSandboxCommand(flags *globalFlags, deps Dependencies) *cobra.Command { +func newSandboxCommand(flags *globalFlags, deps Dependencies, use string) *cobra.Command { parent := &cobra.Command{ - Use: "sandbox", + Use: use, RunE: func(cmd *cobra.Command, args []string) error { return errors.New("sandbox subcommand is required") }, diff --git a/internal/app/commands_version.go b/internal/app/commands_version.go new file mode 100644 index 0000000..10d0c62 --- /dev/null +++ b/internal/app/commands_version.go @@ -0,0 +1,23 @@ +package app + +import ( + "github.com/spf13/cobra" + "github.com/veritrans/midtrans-cli/internal/contracts" +) + +func newVersionCommand(flags *globalFlags, deps Dependencies) *cobra.Command { + return &cobra.Command{ + Use: "version", + Args: cobra.NoArgs, + RunE: func(*cobra.Command, []string) error { + result := contracts.NewResult("version", contracts.StatusPass) + result.CLIVersion = deps.Version.Version + result.Data = map[string]string{ + "version": deps.Version.Version, + "commit": deps.Version.Commit, + "date": deps.Version.Date, + } + return writeResult(deps, flags, result) + }, + } +} diff --git a/internal/presentation/model.go b/internal/presentation/model.go index c540a44..9f8dc7e 100644 --- a/internal/presentation/model.go +++ b/internal/presentation/model.go @@ -45,11 +45,37 @@ func Build(result contracts.Result) (Model, bool) { Findings: result.Findings, NextActions: result.NextActions, }, true + case "capabilities": + return capabilitiesModel(result) default: return Model{}, false } } +func capabilitiesModel(result contracts.Result) (Model, bool) { + if len(result.Packs) == 0 && len(result.Capabilities) == 0 && len(result.Journeys) == 0 { + return Model{}, false + } + packs := make([]string, 0, len(result.Packs)) + for _, pack := range result.Packs { + packs = append(packs, pack.ID) + } + capabilities := make([]string, 0, len(result.Capabilities)) + for _, capability := range result.Capabilities { + capabilities = append(capabilities, capability.ID) + } + return Model{ + Title: "Available products and journeys", + Rows: []Row{ + {State: "✓", Label: "Products", Detail: strings.Join(packs, ", ")}, + {State: "✓", Label: "Journeys", Detail: strings.Join(result.Journeys, ", ")}, + {State: "✓", Label: "Capabilities", Detail: strings.Join(capabilities, ", ")}, + }, + Findings: result.Findings, + NextActions: result.NextActions, + }, true +} + func decodeData(value any, target any) bool { if value == nil { return false diff --git a/internal/render/render.go b/internal/render/render.go index 9329fd5..111f0f0 100644 --- a/internal/render/render.go +++ b/internal/render/render.go @@ -36,7 +36,7 @@ func Write(w io.Writer, result contracts.Result, format Format) error { } func writeGenericHuman(w io.Writer, result contracts.Result) error { - if _, err := fmt.Fprintf(w, "%s: %s\n", strings.ToUpper(string(result.Status)), result.Command); err != nil { + if _, err := fmt.Fprintf(w, "%s (%s)\n", result.Command, strings.ToUpper(string(result.Status))); err != nil { return err } for _, finding := range result.Findings { @@ -44,8 +44,13 @@ func writeGenericHuman(w io.Writer, result contracts.Result) error { return err } } + if len(result.NextActions) > 0 { + if _, err := fmt.Fprintln(w, "\nNext:"); err != nil { + return err + } + } for _, action := range result.NextActions { - if _, err := fmt.Fprintf(w, " next: %s — %s\n", action.Action, action.Description); err != nil { + if _, err := fmt.Fprintf(w, " %s\n", action.Description); err != nil { return err } } From a260422af21294cd2cc1ba098fd37678819ec3f4 Mon Sep 17 00:00:00 2001 From: "m.salis" Date: Sun, 26 Jul 2026 09:40:41 +0700 Subject: [PATCH 13/73] fix: guide legacy sandbox and pack aliases --- internal/app/app.go | 20 +++++++--- internal/app/app_test.go | 63 ++++++++++++++++++++++++++++++++ internal/app/commands_pack.go | 18 ++++++++- internal/app/commands_sandbox.go | 7 +++- 4 files changed, 100 insertions(+), 8 deletions(-) diff --git a/internal/app/app.go b/internal/app/app.go index ee72338..2c0b060 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -293,12 +293,7 @@ func writeResult(deps Dependencies, flags *globalFlags, result contracts.Result) flags.legacy.oldCommand, flags.legacy.newCommand, ) - if len(result.NextActions) == 0 { - result.NextActions = []contracts.NextAction{{ - Action: "use_replacement_command", - Description: "run " + flags.legacy.newCommand, - }} - } + appendMigrationNextAction(&result, flags.legacy) } result.EnsureNextAction() exitCode := result.ExitCode() @@ -319,6 +314,19 @@ func writeResult(deps Dependencies, flags *globalFlags, result contracts.Result) return nil } +func appendMigrationNextAction(result *contracts.Result, legacy *legacyInvocation) { + action := contracts.NextAction{ + Action: "use_replacement_command", + Description: "run " + legacy.newCommand, + } + for _, existing := range result.NextActions { + if existing.Action == action.Action && existing.Description == action.Description { + return + } + } + result.NextActions = append(result.NextActions, action) +} + func hiddenCommand(command *cobra.Command) *cobra.Command { command.Hidden = true return command diff --git a/internal/app/app_test.go b/internal/app/app_test.go index e04386b..a699933 100644 --- a/internal/app/app_test.go +++ b/internal/app/app_test.go @@ -121,6 +121,57 @@ func TestLegacyHumanCommandsProvideMerchantGuidance(t *testing.T) { } } +func TestLegacySandboxRunKeepsDomainAndMigrationNextActions(t *testing.T) { + stdout, stderr, exit := executeHuman( + t, + "sandbox", "run", "snap.checkout", + "--order-id", "legacy-sandbox-001", + "--gross-amount", "10000", + ) + if exit != 3 { + t.Fatalf("exit = %d, stdout = %q, stderr = %q", exit, stdout, stderr) + } + for _, expected := range []string{ + "rerun this exact plan with --execute", + "run midtrans test", + "Next:", + } { + if !strings.Contains(stdout, expected) { + t.Fatalf("sandbox run output missing %q: %q", expected, stdout) + } + } + if !strings.Contains(stderr, "Deprecated: midtrans sandbox") { + t.Fatalf("sandbox run migration notice = %q", stderr) + } +} + +func TestLegacyMappedAliasParentsGuideHumansAndPreserveJSON(t *testing.T) { + for _, command := range [][]string{{"pack"}, {"sandbox"}} { + stdout, stderr, exit := executeHuman(t, command...) + if exit != 0 || strings.Contains(stdout, "Usage:") || + !strings.Contains(stdout, "Next:") || + !strings.Contains(stderr, "Deprecated:") { + t.Fatalf("%v exit = %d, stdout = %q, stderr = %q", command, exit, stdout, stderr) + } + } + + packStdout, packStderr, packExit := executeRaw( + t, "pack", "--json", "--non-interactive", + ) + if packExit != 0 || packStderr != "" || + !strings.Contains(packStdout, "Usage:\n midtrans pack [command]") { + t.Fatalf("pack JSON behavior changed: exit = %d, stdout = %q, stderr = %q", packExit, packStdout, packStderr) + } + + sandbox, sandboxExit := executeJSON( + t, "sandbox", "--json", "--non-interactive", + ) + if sandboxExit != 1 || sandbox.Command != "usage" || + sandbox.Status != contracts.StatusError { + t.Fatalf("sandbox JSON behavior changed: exit = %d, result = %#v", sandboxExit, sandbox) + } +} + func TestVersionIsProjectless(t *testing.T) { result, exit := executeJSONWithDependencies( t, @@ -2393,6 +2444,18 @@ func executeHuman(t *testing.T, args ...string) (string, string, int) { return stdout.String(), stderr.String(), exit } +func executeRaw(t *testing.T, args ...string) (string, string, int) { + t.Helper() + var stdout, stderr bytes.Buffer + exit := app.Execute(context.Background(), args, app.Dependencies{ + Stdout: &stdout, + Stderr: &stderr, + Version: version.Info{Version: "0.1.0-test"}, + Packs: testRegistry(t), + }) + return stdout.String(), stderr.String(), exit +} + func executeHelp(t *testing.T, args ...string) string { t.Helper() var stdout, stderr bytes.Buffer diff --git a/internal/app/commands_pack.go b/internal/app/commands_pack.go index ed2398a..ab6a537 100644 --- a/internal/app/commands_pack.go +++ b/internal/app/commands_pack.go @@ -6,7 +6,17 @@ import ( ) func newPackCommand(flags *globalFlags, deps Dependencies) *cobra.Command { - parent := &cobra.Command{Use: "pack"} + parent := &cobra.Command{ + Use: "pack", + RunE: func(cmd *cobra.Command, args []string) error { + if flags.json || flags.legacy == nil { + return parentHelpWithoutRun(cmd) + } + result := contracts.NewResult("pack", contracts.StatusPass) + result.CLIVersion = deps.Version.Version + return writeResult(deps, flags, result) + }, + } parent.AddCommand(&cobra.Command{ Use: "list", RunE: func(cmd *cobra.Command, args []string) error { @@ -37,3 +47,9 @@ func newPackCommand(flags *globalFlags, deps Dependencies) *cobra.Command { }) return parent } + +func parentHelpWithoutRun(command *cobra.Command) error { + copy := *command + copy.RunE = nil + return copy.Help() +} diff --git a/internal/app/commands_sandbox.go b/internal/app/commands_sandbox.go index 870d2df..f277e3b 100644 --- a/internal/app/commands_sandbox.go +++ b/internal/app/commands_sandbox.go @@ -30,7 +30,12 @@ func newSandboxCommand(flags *globalFlags, deps Dependencies, use string) *cobra parent := &cobra.Command{ Use: use, RunE: func(cmd *cobra.Command, args []string) error { - return errors.New("sandbox subcommand is required") + if flags.json || flags.legacy == nil { + return errors.New("sandbox subcommand is required") + } + result := contracts.NewResult("sandbox", contracts.StatusPass) + result.CLIVersion = deps.Version.Version + return writeResult(deps, flags, result) }, } parent.AddCommand(newSandboxPreflightCommand(flags, deps)) From 3ea51c2b1d64f2f0dbd2c096ceed2f5c1a820918 Mon Sep 17 00:00:00 2001 From: "m.salis" Date: Sun, 26 Jul 2026 09:45:36 +0700 Subject: [PATCH 14/73] docs: clarify mapped alias migration guidance --- .../superpowers/plans/2026-07-26-merchant-cli-experience.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/docs/superpowers/plans/2026-07-26-merchant-cli-experience.md b/docs/superpowers/plans/2026-07-26-merchant-cli-experience.md index fa77831..36cc875 100644 --- a/docs/superpowers/plans/2026-07-26-merchant-cli-experience.md +++ b/docs/superpowers/plans/2026-07-26-merchant-cli-experience.md @@ -1654,8 +1654,10 @@ Route the legacy commands explicitly: `midtrans webhook`: remain callable as hidden advanced compatibility commands with their existing JSON contracts and bounded human renderers. -Every legacy human result must include at least one concrete `Next:` action and -must never fall back to the generic `PASS: ` renderer. +Every mapped legacy-alias human result must include at least one concrete +`Next:` action and must never fall back to the generic `PASS: ` +renderer. Hidden advanced compatibility commands that have no successor keep +their existing bounded human behavior without an invented migration notice. - [ ] **Step 5: Run command, contract, and schema tests** From 15bb41f1a3b35f686c0dcb3dfeb947b1cc1e5e88 Mon Sep 17 00:00:00 2001 From: "m.salis" Date: Sun, 26 Jul 2026 10:00:12 +0700 Subject: [PATCH 15/73] feat: add merchant Sandbox checkout command --- internal/app/app.go | 6 +- internal/app/app_test.go | 12 +- internal/app/checkout_runner.go | 441 ++++++++++++++++++++++ internal/app/commands_checkout.go | 101 +++++ internal/app/commands_sandbox.go | 368 +----------------- internal/app/commands_sandbox_run_test.go | 7 + internal/app/commands_test.go | 222 +++++++++++ internal/presentation/model.go | 118 ++++++ internal/presentation/model_test.go | 87 +++++ 9 files changed, 1006 insertions(+), 356 deletions(-) create mode 100644 internal/app/checkout_runner.go create mode 100644 internal/app/commands_checkout.go create mode 100644 internal/app/commands_test.go diff --git a/internal/app/app.go b/internal/app/app.go index 2c0b060..ff8031e 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -29,6 +29,7 @@ type Dependencies struct { Getenv func(string) (string, bool) Getwd func() (string, error) IsTerminal func() bool + NewOrderID func() string HTTP sandbox.Doer LocalProbe func(context.Context, string) bool } @@ -55,6 +56,9 @@ func Execute(ctx context.Context, args []string, deps Dependencies) int { if deps.IsTerminal == nil { deps.IsTerminal = defaultIsTerminal } + if deps.NewOrderID == nil { + deps.NewOrderID = defaultNewOrderID + } if deps.Getenv == nil { deps.Getenv = os.LookupEnv } @@ -122,7 +126,7 @@ func Execute(ctx context.Context, args []string, deps Dependencies) int { newInitCommand(flags, deps), newSetupCommand(flags, deps), newStatusCommand(flags, deps), - newSandboxCommand(flags, deps, "test"), + newTestCommand(flags, deps), newUpdateCommand(flags, deps), newVerifyCommand(flags, deps), newVersionCommand(flags, deps), diff --git a/internal/app/app_test.go b/internal/app/app_test.go index a699933..4071142 100644 --- a/internal/app/app_test.go +++ b/internal/app/app_test.go @@ -706,7 +706,7 @@ func TestHelpExposesMerchantAndAgentCommandSurfaces(t *testing.T) { "": {"agent", "init", "setup", "status", "test", "update", "verify", "version"}, "agent": {"capabilities", "check", "inspect", "pack"}, "pack": {"info", "list"}, - "test": {"preflight", "run", "status"}, + "test": {"checkout"}, "update": {"check"}, } for command, want := range expected { @@ -1591,6 +1591,16 @@ func TestProductionServerKeyIsRejectedBeforeAnyCommandBoundary(t *testing.T) { "--execute", }, }, + { + name: "merchant checkout execute", + wantCommand: "test.checkout", + args: []string{ + "test", "checkout", + "--amount", "10000", + "--order-id", "production-key-denied", + "--execute", + }, + }, { name: "sandbox status", wantCommand: "sandbox.status", diff --git a/internal/app/checkout_runner.go b/internal/app/checkout_runner.go new file mode 100644 index 0000000..65c071e --- /dev/null +++ b/internal/app/checkout_runner.go @@ -0,0 +1,441 @@ +package app + +import ( + "context" + cryptorand "crypto/rand" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "os" + "os/exec" + "path/filepath" + "strconv" + "strings" + "time" + + "github.com/veritrans/midtrans-cli/internal/contracts" + "github.com/veritrans/midtrans-cli/internal/evidence" + "github.com/veritrans/midtrans-cli/internal/inspection" + "github.com/veritrans/midtrans-cli/internal/operations" + "github.com/veritrans/midtrans-cli/internal/policy" + "github.com/veritrans/midtrans-cli/internal/safepath" + "github.com/veritrans/midtrans-cli/packs/snap" +) + +var errRepositoryDirty = errors.New("repository worktree is dirty") + +type checkoutRequest struct { + Command string + ProjectDir string + OrderID string + GrossAmount int64 + Execute bool + ProviderOnly bool +} + +func runCheckout( + ctx context.Context, + request checkoutRequest, + deps Dependencies, +) contracts.Result { + value, invalid := loadValidatedManifest(request.Command, request.ProjectDir, deps) + if invalid != nil { + return *invalid + } + plan, err := snap.CheckoutPlan(request.OrderID, request.GrossAmount) + if err != nil { + return invalidCheckoutResult(request.Command, value.SchemaVersion, deps) + } + if !request.Execute { + result := contracts.NewResult(request.Command, contracts.StatusBlocked) + result.CLIVersion = deps.Version.Version + result.ManifestVersion = value.SchemaVersion + result.Data = checkoutPlanData(request, plan) + result.NextActions = []contracts.NextAction{checkoutPlanNextAction(request)} + return result + } + + decision := policy.Authorize(plan, policy.Authorization{Execute: request.Execute}) + if !decision.Allowed { + result := contracts.NewPolicyBlockedResult( + request.Command, + decision.Code, + "Sandbox checkout execution is not authorized", + ) + result.CLIVersion = deps.Version.Version + result.ManifestVersion = value.SchemaVersion + result.Data = map[string]any{"plan": plan, "executed": false} + return result + } + serverKey, failure := resolveSandboxServerKey( + ctx, + request.Command, + value.SchemaVersion, + value.Credentials.References["server_key"], + deps, + ) + if failure != nil { + return *failure + } + startedAt := time.Now().UTC() + journey, runErr := (snap.JourneyRunner{ + Tokens: snap.Client{HTTP: deps.HTTP, ServerKey: serverKey}, + Status: snap.Client{HTTP: deps.HTTP, ServerKey: serverKey}, + Local: snap.MerchantVerifier{ + Manifest: value, + ServerKey: serverKey, + HTTP: localJourneyHTTPClient(deps.HTTP), + }, + Ledger: operations.Store{ProjectDir: request.ProjectDir}, + }).Run(ctx, snap.JourneyInput{ + OperationID: plan.Hash, + OrderID: request.OrderID, + GrossAmount: request.GrossAmount, + GrossAmountString: strconv.FormatInt(request.GrossAmount, 10) + ".00", + Execute: true, + Plan: plan, + }) + return checkoutJourneyResult( + request, plan, value.SchemaVersion, startedAt, journey, runErr, deps, + ) +} + +func invalidCheckoutResult( + command string, + manifestVersion int, + deps Dependencies, +) contracts.Result { + result := contracts.NewResult(command, contracts.StatusError) + result.CLIVersion = deps.Version.Version + result.ManifestVersion = manifestVersion + result.Findings = []contracts.Finding{{ + Code: "SANDBOX_JOURNEY_INVALID", + Severity: "blocking", + Message: "sandbox journey input is invalid", + }} + return result +} + +func checkoutPlanData(request checkoutRequest, plan policy.Plan) map[string]any { + data := map[string]any{ + "journey": "snap.checkout", + "state": snap.JourneyPlanned, + "order_id": request.OrderID, + "plan": plan, + } + if request.Command == "test.checkout" { + data["proof_scope"] = checkoutProofScope(request) + } + return data +} + +func checkoutPlanNextAction(request checkoutRequest) contracts.NextAction { + if request.Command == "test.checkout" { + return contracts.NextAction{ + Action: "execute_sandbox_checkout", + Description: fmt.Sprintf( + "midtrans test checkout --amount %d --order-id %s --execute", + request.GrossAmount, + request.OrderID, + ), + } + } + return contracts.NextAction{ + Action: "execute_sandbox_checkout", + Description: "rerun this exact plan with --execute", + } +} + +func checkoutJourneyResult( + request checkoutRequest, + plan policy.Plan, + manifestVersion int, + startedAt time.Time, + journey snap.JourneyResult, + runErr error, + deps Dependencies, +) contracts.Result { + status := contracts.StatusBlocked + if journey.State == snap.JourneyVerified && runErr == nil && !request.ProviderOnly { + status = contracts.StatusPass + } else if runErr != nil { + status = contracts.StatusError + } + + result := contracts.NewResult(request.Command, status) + result.CLIVersion = deps.Version.Version + result.ManifestVersion = manifestVersion + result.NextActions = journey.NextActions + result.Data = checkoutJourneyData(request, plan, journey) + if journey.State == snap.JourneyVerified && runErr == nil && request.ProviderOnly { + result.Findings = []contracts.Finding{{ + Code: "MERCHANT_INTEGRATION_PROOF_REQUIRED", + Severity: "blocking", + Message: "a generated order reference cannot produce complete merchant evidence", + }} + result.NextActions = append(result.NextActions, contracts.NextAction{ + Action: "supply_merchant_order_reference", + Description: "run checkout with an order reference from the merchant integration before collecting evidence", + }) + } + if status == contracts.StatusPass { + path, evidenceErr := writeJourneyEvidence( + request.ProjectDir, + deps, + manifestVersion, + startedAt, + journey, + ) + if evidenceErr != nil { + result.Status = contracts.StatusError + result.Findings = []contracts.Finding{{ + Code: "EVIDENCE_WRITE_FAILED", + Severity: "blocking", + Message: "verified journey evidence could not be stored safely", + }} + } else { + result.Data.(map[string]any)["evidence_file"] = path + } + } + if runErr != nil { + result.Findings = []contracts.Finding{{ + Code: "SANDBOX_JOURNEY_FAILED", + Severity: "blocking", + Message: "unable to safely continue the Snap sandbox journey", + }} + } + return result +} + +func checkoutJourneyData( + request checkoutRequest, + plan policy.Plan, + journey snap.JourneyResult, +) map[string]any { + data := map[string]any{ + "journey": "snap.checkout", + "state": journey.State, + "order_id": journey.OrderID, + "plan": plan, + } + if journey.State == snap.JourneyCheckoutRequired && journey.RedirectURL != "" { + data["redirect_url"] = journey.RedirectURL + } + if journey.Provider.OrderID != "" { + data["provider"] = map[string]any{ + "order_id": journey.Provider.OrderID, + "transaction_status": journey.Provider.TransactionStatus, + "fraud_status": journey.Provider.FraudStatus, + "status_code": journey.Provider.StatusCode, + } + } + if journey.Local.FinalState.OrderID != "" || + journey.Local.SettlementApplied || + journey.Local.DuplicateIdempotent || + journey.Local.LatePendingIgnored { + data["local"] = journey.Local + } + if request.Command == "test.checkout" { + data["proof_scope"] = checkoutProofScope(request) + } + return data +} + +func checkoutProofScope(request checkoutRequest) string { + if request.ProviderOnly { + return "provider_only" + } + return "merchant_integration" +} + +func defaultNewOrderID() string { + var suffix [4]byte + if _, err := cryptorand.Read(suffix[:]); err != nil { + fallback := sha256.Sum256([]byte(strconv.FormatInt(time.Now().UTC().UnixNano(), 10))) + copy(suffix[:], fallback[:len(suffix)]) + } + return fmt.Sprintf( + "midtrans-cli-%s-%x", + time.Now().UTC().Format("20060102150405"), + suffix, + ) +} + +func writeJourneyEvidence( + projectDir string, + deps Dependencies, + manifestVersion int, + startedAt time.Time, + journey snap.JourneyResult, +) (string, error) { + manifestHash, err := projectManifestHash(projectDir) + if err != nil { + return "", err + } + revision, err := repositoryRevision(projectDir) + if err != nil { + return "", err + } + pack, ok := deps.Packs.Get("snap") + if !ok { + return "", errors.New("snap pack is unavailable") + } + descriptor := pack.Descriptor() + safeReferences, proofs := journey.Evidence() + if len(safeReferences) == 0 || len(proofs) == 0 { + return "", errors.New("verified journey evidence is incomplete") + } + bundle := evidence.Bundle{ + SchemaVersion: evidence.SchemaVersion, + CLIVersion: deps.Version.Version, + ManifestVersion: manifestVersion, + PackID: descriptor.ID, + PackVersion: descriptor.Version, + ManifestHash: manifestHash, + RepositoryCommit: revision, + Journey: "snap.checkout", + Environment: "sandbox", + StartedAt: startedAt, + CompletedAt: time.Now().UTC(), + SafeReferences: safeReferences, + Proofs: proofs, + } + if err := evidence.Validate(bundle); err != nil { + return "", err + } + return (evidence.Store{ProjectDir: projectDir}).Write(bundle) +} + +func projectManifestHash(projectDir string) (string, error) { + path, err := safepath.Existing(projectDir, filepath.Join(".midtrans", "manifest.yaml")) + if err != nil { + return "", err + } + file, err := os.Open(path) + if err != nil { + return "", err + } + hash := sha256.New() + written, readErr := io.Copy(hash, io.LimitReader(file, (1<<20)+1)) + closeErr := file.Close() + if readErr != nil { + return "", readErr + } + if closeErr != nil { + return "", closeErr + } + if written > 1<<20 { + return "", errors.New("manifest hash input exceeds limit") + } + return hex.EncodeToString(hash.Sum(nil)), nil +} + +func repositoryRevision(projectDir string) (string, error) { + gitRoot, isGitRoot := exactGitRoot(projectDir) + if isGitRoot { + status := exec.Command( + "git", + "status", + "--porcelain=v1", + "--untracked-files=all", + "--ignore-submodules=none", + ) + status.Dir = gitRoot + output, err := status.Output() + if err != nil { + return "", errors.New("repository state unavailable") + } + if len(output) != 0 { + return "", errRepositoryDirty + } + command := exec.Command("git", "rev-parse", "--verify", "HEAD") + command.Dir = gitRoot + output, err = command.Output() + if err != nil { + return "", errors.New("repository revision unavailable") + } + revision := strings.TrimSpace(string(output)) + if !isHexRevision(revision) { + return "", errors.New("repository revision unavailable") + } + return revision, nil + } + report, err := inspection.Inspect(projectDir) + if err != nil { + return "", err + } + encoded, err := json.Marshal(report) + if err != nil { + return "", err + } + sum := sha256.Sum256(encoded) + return hex.EncodeToString(sum[:]), nil +} + +func exactGitRoot(projectDir string) (string, bool) { + command := exec.Command("git", "rev-parse", "--show-toplevel") + command.Dir = projectDir + output, err := command.Output() + if err != nil { + return "", false + } + root, err := filepath.EvalSymlinks(strings.TrimSpace(string(output))) + if err != nil { + return "", false + } + project, err := filepath.EvalSymlinks(projectDir) + if err != nil { + return "", false + } + root, err = filepath.Abs(root) + if err != nil { + return "", false + } + project, err = filepath.Abs(project) + if err != nil { + return "", false + } + if filepath.Clean(root) != filepath.Clean(project) { + return "", false + } + return root, true +} + +func isHexRevision(value string) bool { + if len(value) != 40 && len(value) != 64 { + return false + } + if value != strings.ToLower(value) { + return false + } + _, err := hex.DecodeString(value) + return err == nil +} + +type journeyRoundTripper struct { + doer interface { + Do(*http.Request) (*http.Response, error) + } +} + +func (t journeyRoundTripper) RoundTrip(request *http.Request) (*http.Response, error) { + return t.doer.Do(request) +} + +func localJourneyHTTPClient(doer interface { + Do(*http.Request) (*http.Response, error) +}) *http.Client { + if client, ok := doer.(*http.Client); ok { + return client + } + return &http.Client{ + Transport: journeyRoundTripper{doer: doer}, + CheckRedirect: func(*http.Request, []*http.Request) error { + return http.ErrUseLastResponse + }, + } +} diff --git a/internal/app/commands_checkout.go b/internal/app/commands_checkout.go new file mode 100644 index 0000000..b003b76 --- /dev/null +++ b/internal/app/commands_checkout.go @@ -0,0 +1,101 @@ +package app + +import ( + "errors" + "fmt" + "io" + + "github.com/spf13/cobra" + "github.com/veritrans/midtrans-cli/internal/contracts" + "github.com/veritrans/midtrans-cli/internal/evidence" + "github.com/veritrans/midtrans-cli/internal/project" + "github.com/veritrans/midtrans-cli/internal/render" +) + +func newTestCommand(flags *globalFlags, deps Dependencies) *cobra.Command { + parent := &cobra.Command{ + Use: "test", + RunE: func(*cobra.Command, []string) error { + return errors.New("test subcommand is required") + }, + } + parent.AddCommand(newTestCheckoutCommand(flags, deps)) + return parent +} + +func newTestCheckoutCommand( + flags *globalFlags, + deps Dependencies, +) *cobra.Command { + var amount int64 + var orderID string + var execute bool + command := &cobra.Command{ + Use: "checkout", + Short: "plan and run a Sandbox Snap checkout", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + providerOnly := orderID == "" + if orderID == "" { + orderID = deps.NewOrderID() + } + request := checkoutRequest{ + Command: "test.checkout", + ProjectDir: flags.projectDir, + OrderID: orderID, + GrossAmount: amount, + Execute: execute, + ProviderOnly: providerOnly, + } + if shouldConfirmCheckout(flags, deps, request) { + preview := runCheckout(cmd.Context(), request, deps) + if preview.Status != contracts.StatusBlocked || + !isCheckoutPlan(preview) { + return writeResult(deps, flags, preview) + } + if err := writeCheckoutPreview(deps, preview); err != nil { + return err + } + if !confirmCheckoutExactYes(deps.Stdin, deps.Stdout) { + return commandExitError{code: preview.ExitCode()} + } + request.Execute = true + } + return writeResult(deps, flags, runCheckout(cmd.Context(), request, deps)) + }, + } + command.Flags().Int64Var(&amount, "amount", 0, "Sandbox amount in IDR") + command.Flags().StringVar(&orderID, "order-id", "", "existing merchant order reference") + command.Flags().BoolVar(&execute, "execute", false, "execute the reviewed Sandbox plan") + _ = command.MarkFlagRequired("amount") + return withProjectMode(command, project.Existing, "test.checkout") +} + +func shouldConfirmCheckout( + flags *globalFlags, + deps Dependencies, + request checkoutRequest, +) bool { + return !request.Execute && !flags.json && !flags.nonInteractive && deps.IsTerminal() +} + +func isCheckoutPlan(result contracts.Result) bool { + data, ok := result.Data.(map[string]any) + return ok && data["journey"] == "snap.checkout" && fmt.Sprint(data["state"]) == "planned" +} + +func writeCheckoutPreview(deps Dependencies, result contracts.Result) error { + safe, err := evidence.SanitizeResult(result, deps.Packs.SensitiveKeys()) + if err != nil { + return err + } + return render.Write(deps.Stdout, safe, render.FormatHuman) +} + +func confirmCheckoutExactYes(input io.Reader, output io.Writer) bool { + if _, err := fmt.Fprint(output, "Execute this Sandbox checkout? Type yes to continue: "); err != nil { + return false + } + line, err := readSetupLine(input) + return err == nil && line == "yes" +} diff --git a/internal/app/commands_sandbox.go b/internal/app/commands_sandbox.go index f277e3b..1c07d9b 100644 --- a/internal/app/commands_sandbox.go +++ b/internal/app/commands_sandbox.go @@ -1,31 +1,14 @@ package app import ( - "crypto/sha256" - "encoding/hex" - "encoding/json" "errors" - "io" - "net/http" - "os" - "os/exec" - "path/filepath" - "strconv" - "strings" - "time" "github.com/spf13/cobra" "github.com/veritrans/midtrans-cli/internal/contracts" - "github.com/veritrans/midtrans-cli/internal/evidence" - "github.com/veritrans/midtrans-cli/internal/inspection" - "github.com/veritrans/midtrans-cli/internal/operations" "github.com/veritrans/midtrans-cli/internal/project" - "github.com/veritrans/midtrans-cli/internal/safepath" "github.com/veritrans/midtrans-cli/packs/snap" ) -var errRepositoryDirty = errors.New("repository worktree is dirty") - func newSandboxCommand(flags *globalFlags, deps Dependencies, use string) *cobra.Command { parent := &cobra.Command{ Use: use, @@ -44,10 +27,7 @@ func newSandboxCommand(flags *globalFlags, deps Dependencies, use string) *cobra return parent } -func newSandboxRunCommand( - flags *globalFlags, - deps Dependencies, -) *cobra.Command { +func newSandboxRunCommand(flags *globalFlags, deps Dependencies) *cobra.Command { var ( orderID string grossAmount int64 @@ -57,15 +37,13 @@ func newSandboxRunCommand( Use: "run ", Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { - value, invalidResult := loadValidatedManifest( - "sandbox.run", - flags.projectDir, - deps, - ) - if invalidResult != nil { - return writeResult(deps, flags, *invalidResult) - } if args[0] != "snap.checkout" { + value, invalidResult := loadValidatedManifest( + "sandbox.run", flags.projectDir, deps, + ) + if invalidResult != nil { + return writeResult(deps, flags, *invalidResult) + } result := contracts.NewIncompatibleResult( "sandbox.run", "CAPABILITY_NOT_INSTALLED", @@ -75,117 +53,13 @@ func newSandboxRunCommand( result.ManifestVersion = value.SchemaVersion return writeResult(deps, flags, result) } - - plan, err := snap.CheckoutPlan(orderID, grossAmount) - if err != nil { - result := contracts.NewResult( - "sandbox.run", - contracts.StatusError, - ) - result.CLIVersion = deps.Version.Version - result.ManifestVersion = value.SchemaVersion - result.Findings = []contracts.Finding{{ - Code: "SANDBOX_JOURNEY_INVALID", - Severity: "blocking", - Message: "sandbox journey input is invalid", - }} - return writeResult(deps, flags, result) - } - if !execute { - result := contracts.NewResult( - "sandbox.run", - contracts.StatusBlocked, - ) - result.CLIVersion = deps.Version.Version - result.ManifestVersion = value.SchemaVersion - result.Data = map[string]any{ - "journey": "snap.checkout", - "state": snap.JourneyPlanned, - "order_id": orderID, - "plan": plan, - } - result.NextActions = []contracts.NextAction{{ - Action: "execute_sandbox_checkout", - Description: "rerun this exact plan with --execute", - }} - return writeResult(deps, flags, result) - } - - serverKey, credentialResult := resolveSandboxServerKey( - cmd.Context(), - "sandbox.run", - value.SchemaVersion, - value.Credentials.References["server_key"], - deps, - ) - if credentialResult != nil { - return writeResult(deps, flags, *credentialResult) - } - - client := localJourneyHTTPClient(deps.HTTP) - startedAt := time.Now().UTC() - journey, runErr := (snap.JourneyRunner{ - Tokens: snap.Client{ - HTTP: deps.HTTP, - ServerKey: serverKey, - }, - Status: snap.Client{ - HTTP: deps.HTTP, - ServerKey: serverKey, - }, - Local: snap.MerchantVerifier{ - Manifest: value, - ServerKey: serverKey, - HTTP: client, - }, - Ledger: operations.Store{ProjectDir: flags.projectDir}, - }).Run(cmd.Context(), snap.JourneyInput{ - OperationID: plan.Hash, - OrderID: orderID, - GrossAmount: grossAmount, - GrossAmountString: strconv.FormatInt(grossAmount, 10) + ".00", - Execute: true, - Plan: plan, - }) - - status := contracts.StatusBlocked - if journey.State == snap.JourneyVerified && runErr == nil { - status = contracts.StatusPass - } else if runErr != nil { - status = contracts.StatusError - } - result := contracts.NewResult("sandbox.run", status) - result.CLIVersion = deps.Version.Version - result.ManifestVersion = value.SchemaVersion - result.NextActions = journey.NextActions - result.Data = sandboxJourneyData(plan, journey) - if status == contracts.StatusPass { - path, evidenceErr := writeJourneyEvidence( - flags.projectDir, - deps, - value.SchemaVersion, - startedAt, - journey, - ) - if evidenceErr != nil { - result.Status = contracts.StatusError - result.Findings = []contracts.Finding{{ - Code: "EVIDENCE_WRITE_FAILED", - Severity: "blocking", - Message: "verified journey evidence could not be stored safely", - }} - } else { - result.Data.(map[string]any)["evidence_file"] = path - } - } - if runErr != nil { - result.Findings = []contracts.Finding{{ - Code: "SANDBOX_JOURNEY_FAILED", - Severity: "blocking", - Message: "unable to safely continue the Snap sandbox journey", - }} - } - return writeResult(deps, flags, result) + return writeResult(deps, flags, runCheckout(cmd.Context(), checkoutRequest{ + Command: "sandbox.run", + ProjectDir: flags.projectDir, + OrderID: orderID, + GrossAmount: grossAmount, + Execute: execute, + }, deps)) }, } command.Flags().StringVar( @@ -211,220 +85,6 @@ func newSandboxRunCommand( return withProjectMode(command, project.Existing, "sandbox.run") } -func writeJourneyEvidence( - projectDir string, - deps Dependencies, - manifestVersion int, - startedAt time.Time, - journey snap.JourneyResult, -) (string, error) { - manifestHash, err := projectManifestHash(projectDir) - if err != nil { - return "", err - } - revision, err := repositoryRevision(projectDir) - if err != nil { - return "", err - } - pack, ok := deps.Packs.Get("snap") - if !ok { - return "", errors.New("snap pack is unavailable") - } - descriptor := pack.Descriptor() - safeReferences, proofs := journey.Evidence() - if len(safeReferences) == 0 || len(proofs) == 0 { - return "", errors.New("verified journey evidence is incomplete") - } - bundle := evidence.Bundle{ - SchemaVersion: evidence.SchemaVersion, - CLIVersion: deps.Version.Version, - ManifestVersion: manifestVersion, - PackID: descriptor.ID, - PackVersion: descriptor.Version, - ManifestHash: manifestHash, - RepositoryCommit: revision, - Journey: "snap.checkout", - Environment: "sandbox", - StartedAt: startedAt, - CompletedAt: time.Now().UTC(), - SafeReferences: safeReferences, - Proofs: proofs, - } - if err := evidence.Validate(bundle); err != nil { - return "", err - } - return (evidence.Store{ProjectDir: projectDir}).Write(bundle) -} - -func projectManifestHash(projectDir string) (string, error) { - path, err := safepath.Existing( - projectDir, - filepath.Join(".midtrans", "manifest.yaml"), - ) - if err != nil { - return "", err - } - file, err := os.Open(path) - if err != nil { - return "", err - } - hash := sha256.New() - written, readErr := io.Copy( - hash, - io.LimitReader(file, (1<<20)+1), - ) - closeErr := file.Close() - if readErr != nil { - return "", readErr - } - if closeErr != nil { - return "", closeErr - } - if written > 1<<20 { - return "", errors.New("manifest hash input exceeds limit") - } - return hex.EncodeToString(hash.Sum(nil)), nil -} - -func repositoryRevision(projectDir string) (string, error) { - gitRoot, isGitRoot := exactGitRoot(projectDir) - if isGitRoot { - status := exec.Command( - "git", - "status", - "--porcelain=v1", - "--untracked-files=all", - "--ignore-submodules=none", - ) - status.Dir = gitRoot - output, err := status.Output() - if err != nil { - return "", errors.New("repository state unavailable") - } - if len(output) != 0 { - return "", errRepositoryDirty - } - command := exec.Command("git", "rev-parse", "--verify", "HEAD") - command.Dir = gitRoot - output, err = command.Output() - if err != nil { - return "", errors.New("repository revision unavailable") - } - revision := strings.TrimSpace(string(output)) - if !isHexRevision(revision) { - return "", errors.New("repository revision unavailable") - } - return revision, nil - } - report, err := inspection.Inspect(projectDir) - if err != nil { - return "", err - } - encoded, err := json.Marshal(report) - if err != nil { - return "", err - } - sum := sha256.Sum256(encoded) - return hex.EncodeToString(sum[:]), nil -} - -func exactGitRoot(projectDir string) (string, bool) { - command := exec.Command("git", "rev-parse", "--show-toplevel") - command.Dir = projectDir - output, err := command.Output() - if err != nil { - return "", false - } - root, err := filepath.EvalSymlinks(strings.TrimSpace(string(output))) - if err != nil { - return "", false - } - project, err := filepath.EvalSymlinks(projectDir) - if err != nil { - return "", false - } - root, err = filepath.Abs(root) - if err != nil { - return "", false - } - project, err = filepath.Abs(project) - if err != nil { - return "", false - } - if filepath.Clean(root) != filepath.Clean(project) { - return "", false - } - return root, true -} - -func isHexRevision(value string) bool { - if len(value) != 40 && len(value) != 64 { - return false - } - if value != strings.ToLower(value) { - return false - } - _, err := hex.DecodeString(value) - return err == nil -} - -type journeyRoundTripper struct { - doer interface { - Do(*http.Request) (*http.Response, error) - } -} - -func (t journeyRoundTripper) RoundTrip( - request *http.Request, -) (*http.Response, error) { - return t.doer.Do(request) -} - -func localJourneyHTTPClient(doer interface { - Do(*http.Request) (*http.Response, error) -}) *http.Client { - if client, ok := doer.(*http.Client); ok { - return client - } - return &http.Client{ - Transport: journeyRoundTripper{doer: doer}, - CheckRedirect: func(*http.Request, []*http.Request) error { - return http.ErrUseLastResponse - }, - } -} - -func sandboxJourneyData( - plan any, - journey snap.JourneyResult, -) map[string]any { - data := map[string]any{ - "journey": "snap.checkout", - "state": journey.State, - "order_id": journey.OrderID, - "plan": plan, - } - if journey.State == snap.JourneyCheckoutRequired && - journey.RedirectURL != "" { - data["redirect_url"] = journey.RedirectURL - } - if journey.Provider.OrderID != "" { - data["provider"] = map[string]any{ - "order_id": journey.Provider.OrderID, - "transaction_status": journey.Provider.TransactionStatus, - "fraud_status": journey.Provider.FraudStatus, - "status_code": journey.Provider.StatusCode, - } - } - if journey.Local.FinalState.OrderID != "" || - journey.Local.SettlementApplied || - journey.Local.DuplicateIdempotent || - journey.Local.LatePendingIgnored { - data["local"] = journey.Local - } - return data -} - func newSandboxPreflightCommand( flags *globalFlags, deps Dependencies, diff --git a/internal/app/commands_sandbox_run_test.go b/internal/app/commands_sandbox_run_test.go index 8fc90a0..1ce9c5e 100644 --- a/internal/app/commands_sandbox_run_test.go +++ b/internal/app/commands_sandbox_run_test.go @@ -154,6 +154,13 @@ func TestSandboxRunSnapCheckoutPlansWithoutHTTP(t *testing.T) { if data["state"] != "planned" || data["order_id"] != "snap-fixture-001" { t.Fatalf("data = %#v", data) } + if _, ok := data["proof_scope"]; ok { + t.Fatalf("legacy result changed: %#v", data) + } + if len(result.NextActions) != 1 || + result.NextActions[0].Description != "rerun this exact plan with --execute" { + t.Fatalf("legacy next actions changed: %#v", result.NextActions) + } plan, ok := data["plan"].(map[string]any) if !ok { t.Fatalf("plan = %#v", data["plan"]) diff --git a/internal/app/commands_test.go b/internal/app/commands_test.go new file mode 100644 index 0000000..2cd8613 --- /dev/null +++ b/internal/app/commands_test.go @@ -0,0 +1,222 @@ +package app_test + +import ( + "bytes" + "context" + "net/http" + "net/http/httptest" + "os" + "reflect" + "regexp" + "strings" + "testing" + + "github.com/veritrans/midtrans-cli/internal/app" + "github.com/veritrans/midtrans-cli/internal/contracts" + "github.com/veritrans/midtrans-cli/internal/version" +) + +func TestMerchantCheckoutPlansWithGeneratedOrderID(t *testing.T) { + project := createJourneyProject(t, "http://127.0.0.1:1") + result, exit := executeJSONWithDependencies( + t, + app.Dependencies{ + Version: version.Info{Version: "0.1.0-test"}, + Packs: testRegistry(t), + NewOrderID: func() string { return "midtrans-cli-test-001" }, + Getenv: func(string) (string, bool) { + t.Fatal("dry run resolved a credential") + return "", false + }, + HTTP: appDoerFunc(func(*http.Request) (*http.Response, error) { + t.Fatal("dry run called HTTP") + return nil, nil + }), + }, + "test", "checkout", + "--amount", "10000", + "--project-dir", project, + ) + if exit != 3 || + result.Command != "test.checkout" || + result.Status != contracts.StatusBlocked { + t.Fatalf("exit = %d, result = %#v", exit, result) + } + data := requireJourneyData(t, result) + if data["order_id"] != "midtrans-cli-test-001" || + data["proof_scope"] != "provider_only" { + t.Fatalf("data = %#v", data) + } +} + +func TestMerchantAndLegacyCheckoutShareTheSamePlan(t *testing.T) { + project := createJourneyProject(t, "http://127.0.0.1:1") + merchant, _ := executeJSON( + t, + "test", "checkout", "--amount", "10000", + "--order-id", "snap-fixture-001", + "--project-dir", project, + "--json", "--non-interactive", + ) + legacy, _ := executeJSON( + t, + "sandbox", "run", "snap.checkout", + "--gross-amount", "10000", + "--order-id", "snap-fixture-001", + "--project-dir", project, + "--json", "--non-interactive", + ) + merchantData := requireJourneyData(t, merchant) + legacyData := requireJourneyData(t, legacy) + if !reflect.DeepEqual(merchantData["plan"], legacyData["plan"]) { + t.Fatalf("merchant = %#v, legacy = %#v", merchantData, legacyData) + } +} + +func TestMerchantCheckoutDefaultOrderIDIsSafeAndProviderOnly(t *testing.T) { + project := createJourneyProject(t, "http://127.0.0.1:1") + result, exit := executeJSONWithDependencies( + t, + app.Dependencies{ + Version: version.Info{Version: "0.1.0-test"}, + Packs: testRegistry(t), + Getenv: func(string) (string, bool) { + t.Fatal("dry run resolved a credential") + return "", false + }, + HTTP: appDoerFunc(func(*http.Request) (*http.Response, error) { + t.Fatal("dry run called HTTP") + return nil, nil + }), + }, + "test", "checkout", "--amount", "10000", "--project-dir", project, + ) + if exit != 3 || result.Status != contracts.StatusBlocked { + t.Fatalf("exit = %d, result = %#v", exit, result) + } + data := requireJourneyData(t, result) + orderID, _ := data["order_id"].(string) + if !regexp.MustCompile(`^midtrans-cli-\d{14}-[0-9a-f]{8}$`).MatchString(orderID) || + data["proof_scope"] != "provider_only" { + t.Fatalf("data = %#v", data) + } +} + +func TestMerchantCheckoutHumanReviewRejectsWithoutProviderAccess(t *testing.T) { + project := createJourneyProject(t, "http://127.0.0.1:1") + var stdout, stderr bytes.Buffer + exit := app.Execute(context.Background(), []string{ + "test", "checkout", + "--amount", "10000", + "--order-id", "snap-fixture-001", + "--project-dir", project, + }, app.Dependencies{ + Stdin: strings.NewReader("yes please\n"), + Stdout: &stdout, + Stderr: &stderr, + IsTerminal: func() bool { + return true + }, + Version: version.Info{Version: "0.1.0-test"}, + Packs: testRegistry(t), + Getenv: func(string) (string, bool) { + t.Fatal("rejected review resolved a credential") + return "", false + }, + HTTP: appDoerFunc(func(*http.Request) (*http.Response, error) { + t.Fatal("rejected review called HTTP") + return nil, nil + }), + }) + if exit != 3 || stderr.Len() != 0 { + t.Fatalf("exit = %d, stdout = %q, stderr = %q", exit, stdout.String(), stderr.String()) + } + for _, want := range []string{ + "Sandbox checkout", + "IDR 10,000", + "snap-fixture-001", + "No provider request was sent", + "midtrans test checkout --amount 10000 --order-id snap-fixture-001 --execute", + "Execute this Sandbox checkout? Type yes to continue: ", + } { + if !strings.Contains(stdout.String(), want) { + t.Fatalf("review is missing %q:\n%s", want, stdout.String()) + } + } +} + +func TestMerchantCheckoutHumanReviewExecutesOnceAfterExactYes(t *testing.T) { + project := createJourneyProject(t, "http://127.0.0.1:1") + transport := newJourneyFixtureTransport(t, http.StatusNotFound, nil) + var stdout, stderr bytes.Buffer + credentialLookups := 0 + exit := app.Execute(context.Background(), []string{ + "test", "checkout", + "--amount", "10000", + "--order-id", "snap-fixture-001", + "--project-dir", project, + }, app.Dependencies{ + Stdin: strings.NewReader("yes\n"), + Stdout: &stdout, + Stderr: &stderr, + IsTerminal: func() bool { + return true + }, + Version: version.Info{Version: "0.1.0-test"}, + Packs: testRegistry(t), + Getenv: func(key string) (string, bool) { + credentialLookups++ + return journeyServerKeyCanary, key == "MIDTRANS_SERVER_KEY" + }, + HTTP: &http.Client{Transport: transport}, + }) + if exit != 3 || stderr.Len() != 0 || credentialLookups != 1 { + t.Fatalf("exit = %d, lookups = %d, stdout = %q, stderr = %q", exit, credentialLookups, stdout.String(), stderr.String()) + } + transport.mu.Lock() + defer transport.mu.Unlock() + if transport.statusCalls != 1 || transport.createCalls != 1 { + t.Fatalf("status calls = %d, create calls = %d", transport.statusCalls, transport.createCalls) + } +} + +func TestProviderOnlyCheckoutCannotWriteMerchantEvidence(t *testing.T) { + merchant := &appMerchantState{ + orderID: "snap-fixture-001", + paymentStatus: "pending", + } + server := httptest.NewServer(merchant) + defer server.Close() + project := createJourneyProject(t, server.URL) + result, exit := executeJSONWithDependencies( + t, + app.Dependencies{ + Version: version.Info{Version: "0.1.0-test"}, + Packs: testRegistry(t), + NewOrderID: func() string { return "snap-fixture-001" }, + Getenv: func(key string) (string, bool) { + return journeyServerKeyCanary, key == "MIDTRANS_SERVER_KEY" + }, + HTTP: &http.Client{Transport: newJourneyFixtureTransport( + t, http.StatusOK, http.DefaultTransport, + )}, + }, + "test", "checkout", + "--amount", "10000", + "--execute", + "--project-dir", project, + ) + if exit != 3 || result.Status != contracts.StatusBlocked { + t.Fatalf("exit = %d, result = %#v", exit, result) + } + data := requireJourneyData(t, result) + if data["state"] != "verified" || data["proof_scope"] != "provider_only" { + t.Fatalf("data = %#v", data) + } + if len(result.Findings) != 1 || result.Findings[0].Code != "MERCHANT_INTEGRATION_PROOF_REQUIRED" { + t.Fatalf("findings = %#v", result.Findings) + } + if _, err := os.Stat(project + "/.midtrans/evidence"); !os.IsNotExist(err) { + t.Fatalf("evidence directory error = %v", err) + } +} diff --git a/internal/presentation/model.go b/internal/presentation/model.go index 9f8dc7e..a7d5abb 100644 --- a/internal/presentation/model.go +++ b/internal/presentation/model.go @@ -3,6 +3,9 @@ package presentation import ( "encoding/json" + "fmt" + "net/url" + "strconv" "strings" "github.com/veritrans/midtrans-cli/internal/contracts" @@ -47,11 +50,126 @@ func Build(result contracts.Result) (Model, bool) { }, true case "capabilities": return capabilitiesModel(result) + case "sandbox.run", "test.checkout": + return checkoutModel(result) default: return Model{}, false } } +type checkoutPresentationData struct { + State string `json:"state"` + OrderID string `json:"order_id"` + ProofScope string `json:"proof_scope"` + RedirectURL string `json:"redirect_url"` + Plan struct { + Operation struct { + Environment string `json:"environment"` + URL string `json:"url"` + SafeSummary map[string]any `json:"safe_summary"` + } `json:"operation"` + } `json:"plan"` + Provider struct { + OrderID string `json:"order_id"` + TransactionStatus string `json:"transaction_status"` + FraudStatus string `json:"fraud_status"` + StatusCode string `json:"status_code"` + } `json:"provider"` + Local struct { + SettlementApplied bool `json:"settlement_applied"` + DuplicateIdempotent bool `json:"duplicate_idempotent"` + LatePendingIgnored bool `json:"late_pending_ignored"` + } `json:"local"` +} + +func checkoutModel(result contracts.Result) (Model, bool) { + var data checkoutPresentationData + if !decodeData(result.Data, &data) || data.OrderID == "" || data.Plan.Operation.URL == "" { + return Model{}, false + } + providerHost := data.Plan.Operation.URL + if parsed, err := url.Parse(data.Plan.Operation.URL); err == nil && parsed.Host != "" { + providerHost = parsed.Host + } + proofScope := "Merchant integration" + if data.ProofScope == "provider_only" { + proofScope = "Provider-only" + } + providerRequest := "No provider request was sent" + if data.State != "planned" { + providerRequest = "Provider request was sent" + } + rows := []Row{ + {State: "✓", Label: "Environment", Detail: "Sandbox"}, + {State: "✓", Label: "Amount", Detail: formatIDR(data.Plan.Operation.SafeSummary["gross_amount"])}, + {State: "✓", Label: "Order reference", Detail: data.OrderID}, + {State: "✓", Label: "Planned provider", Detail: providerHost}, + {State: "✓", Label: "Proof scope", Detail: proofScope}, + {State: "✓", Label: "Provider request", Detail: providerRequest}, + } + if data.RedirectURL != "" { + rows = append(rows, Row{State: "!", Label: "Redirect URL", Detail: data.RedirectURL}) + } + if data.State == "verified" { + rows = append(rows, + Row{ + State: "✓", Label: "Provider proof", + Detail: strings.Join([]string{ + data.Provider.TransactionStatus, + data.Provider.FraudStatus, + data.Provider.StatusCode, + }, " · "), + }, + Row{ + State: "✓", Label: "Local proof", + Detail: fmt.Sprintf( + "settlement applied: %t; idempotent: %t; late pending ignored: %t", + data.Local.SettlementApplied, + data.Local.DuplicateIdempotent, + data.Local.LatePendingIgnored, + ), + }, + ) + } + return Model{ + Title: "Sandbox checkout", + Rows: rows, + Findings: result.Findings, + NextActions: result.NextActions, + }, true +} + +func formatIDR(value any) string { + var amount int64 + switch number := value.(type) { + case float64: + amount = int64(number) + case float32: + amount = int64(number) + case int64: + amount = number + case int: + amount = int64(number) + case json.Number: + amount, _ = number.Int64() + case string: + amount, _ = strconv.ParseInt(number, 10, 64) + } + return "IDR " + formatThousands(amount) +} + +func formatThousands(amount int64) string { + value := strconv.FormatInt(amount, 10) + start := 0 + if strings.HasPrefix(value, "-") { + start = 1 + } + for index := len(value) - 3; index > start; index -= 3 { + value = value[:index] + "," + value[index:] + } + return value +} + func capabilitiesModel(result contracts.Result) (Model, bool) { if len(result.Packs) == 0 && len(result.Capabilities) == 0 && len(result.Journeys) == 0 { return Model{}, false diff --git a/internal/presentation/model_test.go b/internal/presentation/model_test.go index 4e8e985..ecc8cdc 100644 --- a/internal/presentation/model_test.go +++ b/internal/presentation/model_test.go @@ -1,6 +1,7 @@ package presentation import ( + "strings" "testing" "github.com/veritrans/midtrans-cli/internal/contracts" @@ -31,3 +32,89 @@ func TestBuildStatusPresentation(t *testing.T) { t.Fatalf("rows = %#v", model.Rows) } } + +func TestBuildCheckoutPresentation(t *testing.T) { + result := contracts.NewResult("test.checkout", contracts.StatusBlocked) + result.Data = map[string]any{ + "journey": "snap.checkout", + "state": "planned", + "order_id": "merchant-order-001", + "proof_scope": "merchant_integration", + "plan": map[string]any{ + "operation": map[string]any{ + "environment": "sandbox", + "url": "https://app.sandbox.midtrans.com/snap/v1/transactions", + "safe_summary": map[string]any{ + "gross_amount": 10000, + }, + }, + }, + } + + model, ok := Build(result) + if !ok || model.Title != "Sandbox checkout" { + t.Fatalf("model = %#v", model) + } + joined := make([]string, 0, len(model.Rows)) + for _, row := range model.Rows { + joined = append(joined, row.Label+": "+row.Detail) + } + got := strings.Join(joined, "\n") + for _, want := range []string{ + "Environment: Sandbox", + "Amount: IDR 10,000", + "Order reference: merchant-order-001", + "Planned provider: app.sandbox.midtrans.com", + "Proof scope: Merchant integration", + "Provider request: No provider request was sent", + } { + if !strings.Contains(got, want) { + t.Fatalf("rows missing %q:\n%s", want, got) + } + } +} + +func TestBuildCheckoutPresentationIncludesVerifiedProviderAndLocalProof(t *testing.T) { + result := contracts.NewResult("test.checkout", contracts.StatusPass) + result.Data = map[string]any{ + "state": "verified", + "order_id": "merchant-order-001", + "plan": map[string]any{ + "operation": map[string]any{ + "url": "https://app.sandbox.midtrans.com/snap/v1/transactions", + "safe_summary": map[string]any{ + "gross_amount": 10000, + }, + }, + }, + "provider": map[string]any{ + "transaction_status": "settlement", + "fraud_status": "accept", + "status_code": "200", + }, + "local": map[string]any{ + "settlement_applied": true, + "duplicate_idempotent": true, + "late_pending_ignored": true, + }, + } + + model, ok := Build(result) + if !ok { + t.Fatalf("model was not built") + } + joined := make([]string, 0, len(model.Rows)) + for _, row := range model.Rows { + joined = append(joined, row.Label+": "+row.Detail) + } + got := strings.Join(joined, "\n") + for _, want := range []string{ + "Provider request: Provider request was sent", + "Provider proof: settlement · accept · 200", + "Local proof: settlement applied: true; idempotent: true; late pending ignored: true", + } { + if !strings.Contains(got, want) { + t.Fatalf("rows missing %q:\n%s", want, got) + } + } +} From 43ab65ed5299a101388781bfc6b50833ae4c3645 Mon Sep 17 00:00:00 2001 From: "m.salis" Date: Sun, 26 Jul 2026 10:12:19 +0700 Subject: [PATCH 16/73] feat: add merchant webhook verification command --- internal/app/app_test.go | 30 ++++- internal/app/commands_checkout.go | 124 ++++++++++++++++++++ internal/app/commands_test.go | 175 ++++++++++++++++++++++++++++ internal/app/webhook_test_runner.go | 152 ++++++++++++++++++++++++ internal/presentation/model.go | 60 ++++++++++ internal/presentation/model_test.go | 34 ++++++ 6 files changed, 574 insertions(+), 1 deletion(-) create mode 100644 internal/app/webhook_test_runner.go diff --git a/internal/app/app_test.go b/internal/app/app_test.go index 4071142..809fbfb 100644 --- a/internal/app/app_test.go +++ b/internal/app/app_test.go @@ -706,7 +706,7 @@ func TestHelpExposesMerchantAndAgentCommandSurfaces(t *testing.T) { "": {"agent", "init", "setup", "status", "test", "update", "verify", "version"}, "agent": {"capabilities", "check", "inspect", "pack"}, "pack": {"info", "list"}, - "test": {"checkout"}, + "test": {"checkout", "webhook"}, "update": {"check"}, } for command, want := range expected { @@ -2023,6 +2023,34 @@ func TestWebhookVerifyReturnsOnlyPublicSafeNotificationFields(t *testing.T) { } } +func TestMerchantWebhookTestRequiresFlagInputsOutsideInteractiveTerminal(t *testing.T) { + project := createJourneyProject(t, "http://127.0.0.1:1") + result, exit := executeJSONWithDependencies( + t, + app.Dependencies{ + Version: version.Info{Version: "0.1.0-test"}, + Packs: testRegistry(t), + Getenv: func(string) (string, bool) { + t.Fatal("missing input resolved credentials") + return "", false + }, + HTTP: appDoerFunc(func(*http.Request) (*http.Response, error) { + t.Fatal("missing input called HTTP") + return nil, nil + }), + }, + "test", "webhook", "--project-dir", project, + ) + if exit != 3 || result.Command != "test.webhook" || result.Status != contracts.StatusBlocked || + len(result.Findings) != 1 || result.Findings[0].Code != "WEBHOOK_TEST_INPUT_REQUIRED" { + t.Fatalf("exit = %d, result = %#v", exit, result) + } + if len(result.NextActions) != 1 || result.NextActions[0].Description != + "midtrans test webhook --order-id --amount --execute" { + t.Fatalf("next actions = %#v", result.NextActions) + } +} + func TestWebhookVerifyErrorsDoNotLeakSignatureServerKeyOrRawPayload(t *testing.T) { tests := []struct { name string diff --git a/internal/app/commands_checkout.go b/internal/app/commands_checkout.go index b003b76..8804227 100644 --- a/internal/app/commands_checkout.go +++ b/internal/app/commands_checkout.go @@ -4,6 +4,8 @@ import ( "errors" "fmt" "io" + "strconv" + "strings" "github.com/spf13/cobra" "github.com/veritrans/midtrans-cli/internal/contracts" @@ -20,6 +22,7 @@ func newTestCommand(flags *globalFlags, deps Dependencies) *cobra.Command { }, } parent.AddCommand(newTestCheckoutCommand(flags, deps)) + parent.AddCommand(newTestWebhookCommand(flags, deps)) return parent } @@ -99,3 +102,124 @@ func confirmCheckoutExactYes(input io.Reader, output io.Writer) bool { line, err := readSetupLine(input) return err == nil && line == "yes" } + +func newTestWebhookCommand(flags *globalFlags, deps Dependencies) *cobra.Command { + var amount int64 + var orderID string + var execute bool + command := &cobra.Command{ + Use: "webhook", + Short: "plan and run a local Sandbox webhook verification", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + interactive := !flags.json && !flags.nonInteractive && deps.IsTerminal() + var err error + if interactive && !cmd.Flags().Changed("order-id") { + orderID, err = promptWebhookOrderID(deps.Stdin, deps.Stdout) + if err != nil { + return writeResult(deps, flags, webhookTestInputRequired(deps)) + } + } + if interactive && !cmd.Flags().Changed("amount") { + amount, err = promptWebhookAmount(deps.Stdin, deps.Stdout) + if err != nil { + return writeResult(deps, flags, webhookTestInputRequired(deps)) + } + } + if strings.TrimSpace(orderID) == "" || amount <= 0 { + return writeResult(deps, flags, webhookTestInputRequired(deps)) + } + request := webhookTestRequest{ + Command: "test.webhook", ProjectDir: flags.projectDir, + OrderID: strings.TrimSpace(orderID), GrossAmount: amount, Execute: execute, + } + if shouldConfirmWebhookTest(flags, deps, request) { + preview := runWebhookTest(cmd.Context(), request, deps) + if preview.Status != contracts.StatusBlocked || !isWebhookTestPlan(preview) { + return writeResult(deps, flags, preview) + } + if err := writeWebhookTestPreview(deps, preview); err != nil { + return err + } + if !confirmWebhookTestExactYes(deps.Stdin, deps.Stdout) { + return commandExitError{code: preview.ExitCode()} + } + request.Execute = true + } + return writeResult(deps, flags, runWebhookTest(cmd.Context(), request, deps)) + }, + } + command.Flags().Int64Var(&amount, "amount", 0, "Sandbox amount in IDR") + command.Flags().StringVar(&orderID, "order-id", "", "merchant application order reference") + command.Flags().BoolVar(&execute, "execute", false, "execute the reviewed local webhook test") + return withProjectMode(command, project.Existing, "test.webhook") +} + +func promptWebhookOrderID(input io.Reader, output io.Writer) (string, error) { + if _, err := fmt.Fprint(output, "Merchant application order reference: "); err != nil { + return "", err + } + value, err := readSetupLine(input) + if err != nil || strings.TrimSpace(value) == "" { + return "", errors.New("webhook order ID is required") + } + return strings.TrimSpace(value), nil +} + +func promptWebhookAmount(input io.Reader, output io.Writer) (int64, error) { + if _, err := fmt.Fprint(output, "Sandbox gross amount in IDR: "); err != nil { + return 0, err + } + value, err := readSetupLine(input) + if err != nil { + return 0, err + } + amount, err := strconv.ParseInt(strings.TrimSpace(value), 10, 64) + if err != nil || amount <= 0 { + return 0, errors.New("webhook amount must be a positive IDR amount") + } + return amount, nil +} + +func shouldConfirmWebhookTest( + flags *globalFlags, + deps Dependencies, + request webhookTestRequest, +) bool { + return !request.Execute && !flags.json && !flags.nonInteractive && deps.IsTerminal() +} + +func isWebhookTestPlan(result contracts.Result) bool { + data, ok := result.Data.(map[string]any) + return ok && data["executed"] == false && data["plan"] != nil +} + +func writeWebhookTestPreview(deps Dependencies, result contracts.Result) error { + safe, err := evidence.SanitizeResult(result, deps.Packs.SensitiveKeys()) + if err != nil { + return err + } + return render.Write(deps.Stdout, safe, render.FormatHuman) +} + +func confirmWebhookTestExactYes(input io.Reader, output io.Writer) bool { + if _, err := fmt.Fprint(output, "Execute this local webhook test? Type yes to continue: "); err != nil { + return false + } + line, err := readSetupLine(input) + return err == nil && line == "yes" +} + +func webhookTestInputRequired(deps Dependencies) contracts.Result { + result := contracts.NewResult("test.webhook", contracts.StatusBlocked) + result.CLIVersion = deps.Version.Version + result.Findings = []contracts.Finding{{ + Code: "WEBHOOK_TEST_INPUT_REQUIRED", Severity: "blocking", + Message: "a merchant order reference and Sandbox IDR amount are required", + }} + result.NextActions = []contracts.NextAction{{ + Action: "supply_webhook_test_inputs", + Description: "midtrans test webhook --order-id --amount --execute", + }} + return result +} diff --git a/internal/app/commands_test.go b/internal/app/commands_test.go index 2cd8613..b9b2b53 100644 --- a/internal/app/commands_test.go +++ b/internal/app/commands_test.go @@ -49,6 +49,181 @@ func TestMerchantCheckoutPlansWithGeneratedOrderID(t *testing.T) { } } +func TestMerchantWebhookTestPlansWithoutHTTP(t *testing.T) { + project := createJourneyProject(t, "http://127.0.0.1:1") + result, exit := executeJSONWithDependencies( + t, + app.Dependencies{ + Version: version.Info{Version: "0.1.0-test"}, + Packs: testRegistry(t), + Getenv: func(string) (string, bool) { + t.Fatal("plan resolved credentials") + return "", false + }, + HTTP: appDoerFunc(func(*http.Request) (*http.Response, error) { + t.Fatal("plan called HTTP") + return nil, nil + }), + }, + "test", "webhook", + "--order-id", "ORDER-33333333-3333-4333-8333-333333333333", + "--amount", "10000", + "--project-dir", project, + ) + if exit != 3 || + result.Command != "test.webhook" || + result.Status != contracts.StatusBlocked { + t.Fatalf("exit = %d, result = %#v", exit, result) + } +} + +func TestMerchantWebhookTestVerifiesSettlementDuplicateAndLatePending(t *testing.T) { + merchant := &appMerchantState{ + orderID: "ORDER-33333333-3333-4333-8333-333333333333", + paymentStatus: "pending", + } + server := httptest.NewServer(merchant) + defer server.Close() + project := createJourneyProject(t, server.URL) + result, exit := executeJSONWithDependencies( + t, + app.Dependencies{ + Version: version.Info{Version: "0.1.0-test"}, + Packs: testRegistry(t), + Getenv: func(string) (string, bool) { + return journeyServerKeyCanary, true + }, + HTTP: server.Client(), + }, + "test", "webhook", + "--order-id", merchant.orderID, + "--amount", "10000", + "--execute", + "--project-dir", project, + ) + if exit != 0 || result.Status != contracts.StatusPass { + t.Fatalf("exit = %d, result = %#v", exit, result) + } + data := requireJourneyData(t, result) + for _, key := range []string{ + "settlement_applied", "duplicate_idempotent", "late_pending_ignored", + } { + if data[key] != true { + t.Fatalf("%s = %#v", key, data[key]) + } + } + assertNoSensitiveJourneyFields(t, result) + if _, err := os.Stat(project + "/.midtrans/evidence"); !os.IsNotExist(err) { + t.Fatalf("evidence directory error = %v", err) + } + merchant.mu.Lock() + defer merchant.mu.Unlock() + if merchant.notificationCalls != 3 { + t.Fatalf("notification calls = %d", merchant.notificationCalls) + } +} + +func TestMerchantWebhookTestInteractiveConfirmationControlsExecution(t *testing.T) { + merchant := &appMerchantState{orderID: "merchant-order-001", paymentStatus: "pending"} + server := httptest.NewServer(merchant) + defer server.Close() + project := createJourneyProject(t, server.URL) + + for _, test := range []struct { + name string + input string + want int + wantExit int + }{ + {name: "rejects non-exact confirmation", input: "yes please\n", want: 0, wantExit: 3}, + {name: "accepts exact confirmation", input: "yes\n", want: 3, wantExit: 0}, + } { + t.Run(test.name, func(t *testing.T) { + merchant.mu.Lock() + merchant.paymentStatus = "pending" + merchant.fulfillmentCount = 0 + merchant.notificationCalls = 0 + merchant.mu.Unlock() + var stdout, stderr bytes.Buffer + exit := app.Execute(context.Background(), []string{ + "test", "webhook", "--order-id", merchant.orderID, "--amount", "10000", + "--project-dir", project, + }, app.Dependencies{ + Stdin: strings.NewReader(test.input), + Stdout: &stdout, + Stderr: &stderr, + IsTerminal: func() bool { + return true + }, + Version: version.Info{Version: "0.1.0-test"}, + Packs: testRegistry(t), + Getenv: func(string) (string, bool) { + return journeyServerKeyCanary, true + }, + HTTP: server.Client(), + }) + if stderr.Len() != 0 || exit != test.wantExit { + t.Fatalf("exit = %d, stdout = %q, stderr = %q", exit, stdout.String(), stderr.String()) + } + if !strings.Contains(stdout.String(), "Execute this local webhook test? Type yes to continue: ") { + t.Fatalf("missing confirmation prompt: %s", stdout.String()) + } + for _, forbidden := range []string{journeyServerKeyCanary, "signature_key"} { + if strings.Contains(strings.ToLower(stdout.String()), strings.ToLower(forbidden)) { + t.Fatalf("human output retained %q: %s", forbidden, stdout.String()) + } + } + merchant.mu.Lock() + got := merchant.notificationCalls + merchant.mu.Unlock() + if got != test.want { + t.Fatalf("notification calls = %d, want %d", got, test.want) + } + }) + } +} + +func TestMerchantWebhookTestPromptsForBareInteractiveInputs(t *testing.T) { + merchant := &appMerchantState{orderID: "merchant-order-002", paymentStatus: "pending"} + server := httptest.NewServer(merchant) + defer server.Close() + project := createJourneyProject(t, server.URL) + var stdout, stderr bytes.Buffer + exit := app.Execute(context.Background(), []string{ + "test", "webhook", "--project-dir", project, + }, app.Dependencies{ + Stdin: strings.NewReader("merchant-order-002\n10000\nyes\n"), + Stdout: &stdout, + Stderr: &stderr, + IsTerminal: func() bool { + return true + }, + Version: version.Info{Version: "0.1.0-test"}, + Packs: testRegistry(t), + Getenv: func(string) (string, bool) { + return journeyServerKeyCanary, true + }, + HTTP: server.Client(), + }) + if exit != 0 || stderr.Len() != 0 { + t.Fatalf("exit = %d, stdout = %q, stderr = %q", exit, stdout.String(), stderr.String()) + } + for _, want := range []string{ + "Merchant application order reference: ", + "Sandbox gross amount in IDR: ", + "Execute this local webhook test? Type yes to continue: ", + } { + if !strings.Contains(stdout.String(), want) { + t.Fatalf("output is missing %q: %s", want, stdout.String()) + } + } + merchant.mu.Lock() + defer merchant.mu.Unlock() + if merchant.notificationCalls != 3 { + t.Fatalf("notification calls = %d", merchant.notificationCalls) + } +} + func TestMerchantAndLegacyCheckoutShareTheSamePlan(t *testing.T) { project := createJourneyProject(t, "http://127.0.0.1:1") merchant, _ := executeJSON( diff --git a/internal/app/webhook_test_runner.go b/internal/app/webhook_test_runner.go new file mode 100644 index 0000000..fea1fd1 --- /dev/null +++ b/internal/app/webhook_test_runner.go @@ -0,0 +1,152 @@ +package app + +import ( + "context" + "errors" + "net/http" + "net/url" + "strconv" + "strings" + + "github.com/veritrans/midtrans-cli/internal/contracts" + "github.com/veritrans/midtrans-cli/internal/manifest" + "github.com/veritrans/midtrans-cli/internal/policy" + "github.com/veritrans/midtrans-cli/packs/snap" +) + +type webhookTestRequest struct { + Command string + ProjectDir string + OrderID string + GrossAmount int64 + Execute bool +} + +func runWebhookTest( + ctx context.Context, + request webhookTestRequest, + deps Dependencies, +) contracts.Result { + value, invalid := loadValidatedManifest(request.Command, request.ProjectDir, deps) + if invalid != nil { + return *invalid + } + target, err := localWebhookTestTarget(value) + if err != nil { + return localVerificationRouteFailure(request.Command, value, deps) + } + plan, err := policy.BuildPlan(policy.Operation{ + Environment: "sandbox", + Method: http.MethodPost, + URL: target, + Class: policy.Mutating, + SafeSummary: map[string]any{ + "journey": "common.webhook-idempotency", + "order_id": request.OrderID, + "gross_amount": request.GrossAmount, + }, + }) + if err != nil { + return localVerificationRouteFailure(request.Command, value, deps) + } + if !request.Execute { + result := contracts.NewResult(request.Command, contracts.StatusBlocked) + result.CLIVersion = deps.Version.Version + result.ManifestVersion = value.SchemaVersion + result.Data = map[string]any{"plan": plan, "executed": false} + result.NextActions = []contracts.NextAction{{ + Action: "execute_local_webhook_test", + Description: "review the local mutation plan and rerun with --execute", + }} + return result + } + decision := policy.Authorize(plan, policy.Authorization{Execute: request.Execute}) + if !decision.Allowed { + result := contracts.NewPolicyBlockedResult( + request.Command, + decision.Code, + "local webhook test execution is not authorized", + ) + result.CLIVersion = deps.Version.Version + result.ManifestVersion = value.SchemaVersion + result.Data = map[string]any{"plan": plan, "executed": false} + return result + } + serverKey, failure := resolveSandboxServerKey( + ctx, request.Command, value.SchemaVersion, + value.Credentials.References["server_key"], deps, + ) + if failure != nil { + return *failure + } + proof, err := (snap.MerchantVerifier{ + Manifest: value, ServerKey: serverKey, + HTTP: localJourneyHTTPClient(deps.HTTP), + }).VerifyLocal(ctx, snap.LocalVerificationInput{ + OrderID: request.OrderID, + GrossAmount: strconv.FormatInt(request.GrossAmount, 10) + ".00", + }) + if err != nil || !proof.Passed() { + return localVerificationFailure(request.Command, value, deps) + } + result := contracts.NewResult(request.Command, contracts.StatusPass) + result.CLIVersion = deps.Version.Version + result.ManifestVersion = value.SchemaVersion + result.Data = proof + return result +} + +func localWebhookTestTarget(value manifest.Manifest) (string, error) { + base, err := url.Parse(value.Integration.LocalBaseURL) + if err != nil || base.Hostname() == "" || base.User != nil || + (base.Scheme != "http" && base.Scheme != "https") || + !policy.IsLoopbackHost(base.Hostname()) || base.RawQuery != "" || base.Fragment != "" { + if err == nil { + err = errors.New("invalid local webhook base URL") + } + return "", err + } + route, err := url.Parse(value.Integration.NotificationRoute) + if err != nil || !strings.HasPrefix(value.Integration.NotificationRoute, "/") || + route.IsAbs() || route.Host != "" || route.User != nil || route.Fragment != "" { + if err == nil { + err = errors.New("invalid local webhook route") + } + return "", err + } + target := strings.TrimRight(value.Integration.LocalBaseURL, "/") + value.Integration.NotificationRoute + if err := policy.ValidateWebhookTarget(context.Background(), target, nil, nil); err != nil { + return "", err + } + return target, nil +} + +func localVerificationRouteFailure( + command string, + value manifest.Manifest, + deps Dependencies, +) contracts.Result { + result := contracts.NewPolicyBlockedResult( + command, + "POLICY_TARGET_NOT_ALLOWED", + "the configured local webhook route is not an allowed loopback target", + ) + result.CLIVersion = deps.Version.Version + result.ManifestVersion = value.SchemaVersion + return result +} + +func localVerificationFailure( + command string, + value manifest.Manifest, + deps Dependencies, +) contracts.Result { + result := contracts.NewResult(command, contracts.StatusError) + result.CLIVersion = deps.Version.Version + result.ManifestVersion = value.SchemaVersion + result.Findings = []contracts.Finding{{ + Code: "LOCAL_VERIFICATION_FAILED", Severity: "blocking", + Message: "local webhook verification did not produce the required idempotency proof", + }} + return result +} diff --git a/internal/presentation/model.go b/internal/presentation/model.go index a7d5abb..64111ee 100644 --- a/internal/presentation/model.go +++ b/internal/presentation/model.go @@ -52,11 +52,71 @@ func Build(result contracts.Result) (Model, bool) { return capabilitiesModel(result) case "sandbox.run", "test.checkout": return checkoutModel(result) + case "test.webhook": + return webhookTestModel(result) default: return Model{}, false } } +type webhookTestPresentationData struct { + Executed bool `json:"executed"` + SettlementApplied bool `json:"settlement_applied"` + DuplicateIdempotent bool `json:"duplicate_idempotent"` + LatePendingIgnored bool `json:"late_pending_ignored"` + FinalState struct { + PaymentStatus string `json:"payment_status"` + FulfillmentCount int `json:"fulfillment_count"` + } `json:"final_state"` + Plan struct { + Operation struct { + URL string `json:"url"` + SafeSummary map[string]any `json:"safe_summary"` + } `json:"operation"` + } `json:"plan"` +} + +func webhookTestModel(result contracts.Result) (Model, bool) { + var data webhookTestPresentationData + if !decodeData(result.Data, &data) { + return Model{}, false + } + rows := []Row{} + if data.Plan.Operation.URL != "" { + rows = append(rows, + Row{State: "✓", Label: "Environment", Detail: "Sandbox"}, + Row{State: "✓", Label: "Local notification route", Detail: data.Plan.Operation.URL}, + Row{State: "✓", Label: "Amount", Detail: formatIDR(data.Plan.Operation.SafeSummary["gross_amount"])}, + Row{State: "✓", Label: "Order reference", Detail: fmt.Sprint(data.Plan.Operation.SafeSummary["order_id"])}, + Row{State: "✓", Label: "Notification requests", Detail: "No local notification was sent"}, + ) + } + if result.Status == contracts.StatusPass { + rows = append(rows, + Row{State: "✓", Label: "Signature generated and accepted", Detail: "Yes"}, + Row{State: "✓", Label: "Settlement applied", Detail: yesNo(data.SettlementApplied)}, + Row{State: "✓", Label: "Duplicate settlement idempotent", Detail: yesNo(data.DuplicateIdempotent)}, + Row{State: "✓", Label: "Late pending ignored", Detail: yesNo(data.LatePendingIgnored)}, + Row{State: "✓", Label: "Final payment status", Detail: data.FinalState.PaymentStatus}, + Row{State: "✓", Label: "Fulfillment count", Detail: strconv.Itoa(data.FinalState.FulfillmentCount)}, + ) + } + if len(rows) == 0 { + return Model{}, false + } + return Model{ + Title: "Local webhook test", Rows: rows, + Findings: result.Findings, NextActions: result.NextActions, + }, true +} + +func yesNo(value bool) string { + if value { + return "Yes" + } + return "No" +} + type checkoutPresentationData struct { State string `json:"state"` OrderID string `json:"order_id"` diff --git a/internal/presentation/model_test.go b/internal/presentation/model_test.go index ecc8cdc..4a5a15d 100644 --- a/internal/presentation/model_test.go +++ b/internal/presentation/model_test.go @@ -118,3 +118,37 @@ func TestBuildCheckoutPresentationIncludesVerifiedProviderAndLocalProof(t *testi } } } + +func TestBuildWebhookTestPresentationDoesNotRenderSensitiveWebhookMaterial(t *testing.T) { + result := contracts.NewResult("test.webhook", contracts.StatusPass) + result.Data = map[string]any{ + "settlement_applied": true, + "duplicate_idempotent": true, + "late_pending_ignored": true, + "final_state": map[string]any{ + "payment_status": "paid", + "fulfillment_count": 1, + }, + } + model, ok := Build(result) + if !ok || model.Title != "Local webhook test" { + t.Fatalf("model = %#v", model) + } + got := make([]string, 0, len(model.Rows)) + for _, row := range model.Rows { + got = append(got, row.Label+": "+row.Detail) + } + joined := strings.Join(got, "\n") + for _, want := range []string{ + "Signature generated and accepted: Yes", + "Settlement applied: Yes", + "Duplicate settlement idempotent: Yes", + "Late pending ignored: Yes", + "Final payment status: paid", + "Fulfillment count: 1", + } { + if !strings.Contains(joined, want) { + t.Fatalf("rows missing %q:\n%s", want, joined) + } + } +} From 2d481ff719542f36ba283a0d10e527afee0e3490 Mon Sep 17 00:00:00 2001 From: "m.salis" Date: Sun, 26 Jul 2026 10:22:10 +0700 Subject: [PATCH 17/73] build: install and verify the merchant CLI locally --- README.md | 94 ++++++++++++++----------------- docs/agent-skill-compatibility.md | 4 +- internal/app/app_test.go | 12 ++-- tools/check_release.sh | 1 + tools/install-local.sh | 40 +++++++++++++ tools/test-install-local.sh | 31 ++++++++++ 6 files changed, 124 insertions(+), 58 deletions(-) create mode 100755 tools/install-local.sh create mode 100755 tools/test-install-local.sh diff --git a/README.md b/README.md index 801081d..d76b1af 100644 --- a/README.md +++ b/README.md @@ -10,10 +10,36 @@ It may initialize its own `.midtrans/` configuration and evidence files, but changes to merchant application code remain under the merchant or agent host's control. -## Install and verify +## Local development quick start -The bootstrap installation path is a directly downloaded, signed release -archive. After a release is published: +The development installer builds, verifies, and atomically installs a regular +`midtrans` binary for the current user at `${MIDTRANS_INSTALL_DIR:-$HOME/.local/bin}`. +It does not require `sudo`, create a source symlink, or edit a shell profile. + +```sh +tools/install-local.sh +cd /path/to/merchant +midtrans init +midtrans setup +midtrans status +midtrans test checkout --amount 10000 +midtrans test webhook +midtrans verify +``` + +## Machine-readable agent handshake + +An Agent Skill must complete this handshake before using the CLI: + +```sh +midtrans agent capabilities --json --non-interactive +midtrans agent inspect --json --non-interactive +midtrans agent check --product snap --json --non-interactive +``` + +## Release artifact verification + +After a signed release is published: 1. Download the archive for your operating system and architecture, `checksums.txt`, and `checksums.txt.sigstore.json` from the same GitHub @@ -31,20 +57,18 @@ archive. After a release is published: 3. Verify the archive against `checksums.txt` with `sha256sum -c` or `shasum -a 256 -c`, extract it, and place `midtrans` on your `PATH`. -4. Run `midtrans capabilities --json --non-interactive` and confirm the +4. Run `midtrans agent capabilities --json --non-interactive` and confirm the expected schema, capabilities, and journeys before an agent uses it. -Do not use an unverified `curl | sh` installer. A Homebrew cask is configured -for release generation, but it must not be published until a Midtrans repository +The future hosted `install.sh` remains unpublished until signed release +artifacts are available and Midtrans approves the official hosting domain. Do +not use an unverified `curl | sh` installer. A Homebrew cask is configured for +release generation, but it must not be published until a Midtrans repository administrator creates the decided official tap `veritrans/homebrew-midtrans` and provisions a narrowly scoped release token. The optional npm launcher is deferred until controlled direct-download and Homebrew evaluation telemetry exists. -For source-only development, use the pinned Go toolchain from `go.mod` and run -`go run ./cmd/midtrans capabilities --json --non-interactive`. That is a -development workflow, not a substitute for verifying a release artifact. - ## Sandbox workflow Commands emit the stable result-schema v1 JSON contract when both `--json` and @@ -52,53 +76,17 @@ Commands emit the stable result-schema v1 JSON contract when both `--json` and values with sandbox-only inputs. ```sh -midtrans capabilities --json --non-interactive midtrans init --project-dir /path/to/merchant --json --non-interactive -midtrans inspect --project-dir /path/to/merchant --json --non-interactive -midtrans plan snap --project-dir /path/to/merchant --json --non-interactive -midtrans doctor --product snap --project-dir /path/to/merchant --json --non-interactive +midtrans setup --project-dir /path/to/merchant --json --non-interactive +midtrans status --project-dir /path/to/merchant --json --non-interactive +midtrans test checkout --amount 10000 --project-dir /path/to/merchant --json --non-interactive +midtrans test webhook --order-id --amount 10000 --project-dir /path/to/merchant --json --non-interactive +midtrans verify --project-dir /path/to/merchant --json --non-interactive ``` Review and edit `.midtrans/manifest.yaml` yourself. It contains environment -variable references, never secret values. Then run the credential boundary -check: - -```sh -MIDTRANS_SERVER_KEY='SB-…' \ - midtrans sandbox preflight \ - --project-dir /path/to/merchant --json --non-interactive -``` - -Plan the checkout first. Without `--execute`, `sandbox run` is a dry-run and -returns the plan plus the next action: - -```sh -midtrans sandbox run snap.checkout \ - --order-id cli-sandbox-001 --gross-amount 10000 \ - --project-dir /path/to/merchant --json --non-interactive -``` - -Only after reviewing that exact plan, opt in to the sandbox mutation: - -```sh -MIDTRANS_SERVER_KEY='SB-…' \ - midtrans sandbox run snap.checkout --execute \ - --order-id cli-sandbox-001 --gross-amount 10000 \ - --project-dir /path/to/merchant --json --non-interactive -``` - -The execute path can produce a mode-`0600` checksummed evidence file after -provider and merchant callback proofs complete. Verify and export it explicitly: - -```sh -midtrans verify --product snap --evidence .midtrans/evidence/.json \ - --project-dir /path/to/merchant --json --non-interactive -midtrans evidence export --file .midtrans/evidence/.json \ - --output support/evidence.json \ - --project-dir /path/to/merchant --json --non-interactive -``` - -See [sandbox evidence](docs/sandbox-evidence.md) before sharing an export. +variable references, never secret values. Phase 1 remains Sandbox-only: the +CLI rejects production Midtrans hosts and production credentials. ## Contracts and compatibility diff --git a/docs/agent-skill-compatibility.md b/docs/agent-skill-compatibility.md index 61ccc39..7389a6c 100644 --- a/docs/agent-skill-compatibility.md +++ b/docs/agent-skill-compatibility.md @@ -8,7 +8,9 @@ controlled evaluation, not proof that either repository has been published. Run: ```sh -midtrans capabilities --json --non-interactive +midtrans agent capabilities --json --non-interactive +midtrans agent inspect --json --non-interactive +midtrans agent check --product snap --json --non-interactive ``` The host must compare every value required by the Skill's diff --git a/internal/app/app_test.go b/internal/app/app_test.go index 809fbfb..e4761f4 100644 --- a/internal/app/app_test.go +++ b/internal/app/app_test.go @@ -95,8 +95,13 @@ func TestLegacyCapabilitiesJSONRemainsCompatibleAndHidden(t *testing.T) { !reflect.DeepEqual(legacy.Journeys, current.Journeys) { t.Fatalf("legacy = %#v, current = %#v", legacy, current) } - if slices.Contains(helpCommandNames(executeHelp(t, "--help")), "capabilities") { - t.Fatal("legacy command is visible in primary help") + visible := helpCommandNames(executeHelp(t, "--help")) + for _, command := range []string{ + "capabilities", "credentials", "doctor", "inspect", "pack", "sandbox", + } { + if slices.Contains(visible, command) { + t.Fatalf("legacy command %q is visible in primary help", command) + } } } @@ -701,11 +706,10 @@ func TestUpdateCheckFailureDoesNotExposeUpstreamData(t *testing.T) { } } -func TestHelpExposesMerchantAndAgentCommandSurfaces(t *testing.T) { +func TestHelpExposesExactlyThePhaseOneCommandSurface(t *testing.T) { expected := map[string][]string{ "": {"agent", "init", "setup", "status", "test", "update", "verify", "version"}, "agent": {"capabilities", "check", "inspect", "pack"}, - "pack": {"info", "list"}, "test": {"checkout", "webhook"}, "update": {"check"}, } diff --git a/tools/check_release.sh b/tools/check_release.sh index 744695c..9203ec6 100755 --- a/tools/check_release.sh +++ b/tools/check_release.sh @@ -3,6 +3,7 @@ set -euo pipefail go test ./... -race -count=1 go vet ./... +./tools/test-install-local.sh go build -trimpath ./cmd/midtrans go run github.com/goreleaser/goreleaser/v2@v2.17.0 check git diff --check diff --git a/tools/install-local.sh b/tools/install-local.sh new file mode 100755 index 0000000..5d697ab --- /dev/null +++ b/tools/install-local.sh @@ -0,0 +1,40 @@ +#!/bin/sh +set -eu + +repo_dir=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd) +install_dir=${MIDTRANS_INSTALL_DIR:-"$HOME/.local/bin"} +mkdir -p "$install_dir" + +tmp_binary=$(mktemp "$install_dir/.midtrans.XXXXXX") +cleanup() { + rm -f "$tmp_binary" +} +trap cleanup EXIT INT TERM + +version=${MIDTRANS_DEV_VERSION:-dev} +commit=$(git -C "$repo_dir" rev-parse --verify HEAD) +build_date=$(date -u +%Y-%m-%dT%H:%M:%SZ) + +( + cd "$repo_dir" + CGO_ENABLED=0 go build -trimpath \ + -ldflags "-s -w \ + -X github.com/veritrans/midtrans-cli/internal/version.buildVersion=$version \ + -X github.com/veritrans/midtrans-cli/internal/version.buildCommit=$commit \ + -X github.com/veritrans/midtrans-cli/internal/version.buildDate=$build_date" \ + -o "$tmp_binary" ./cmd/midtrans +) +chmod 0755 "$tmp_binary" +"$tmp_binary" version --json --non-interactive >/dev/null +"$tmp_binary" agent capabilities --json --non-interactive >/dev/null +mv -f "$tmp_binary" "$install_dir/midtrans" +trap - EXIT INT TERM + +case ":${PATH:-}:" in + *":$install_dir:"*) ;; + *) + printf '%s\n' "Installed to $install_dir/midtrans." + printf '%s\n' "Add this directory to PATH:" + printf ' export PATH="%s:$PATH"\n' "$install_dir" + ;; +esac diff --git a/tools/test-install-local.sh b/tools/test-install-local.sh new file mode 100755 index 0000000..03b9119 --- /dev/null +++ b/tools/test-install-local.sh @@ -0,0 +1,31 @@ +#!/bin/sh +set -eu + +repo_dir=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd) +test_root=$(mktemp -d) +trap 'rm -rf "$test_root"' EXIT INT TERM + +MIDTRANS_INSTALL_DIR="$test_root/bin" "$repo_dir/tools/install-local.sh" +binary="$test_root/bin/midtrans" + +test -f "$binary" +test ! -L "$binary" +"$binary" version --json --non-interactive >/dev/null +"$binary" agent capabilities --json --non-interactive >/dev/null + +other_dir="$test_root/unrelated" +mkdir -p "$other_dir" +( + cd "$other_dir" + "$binary" version --json --non-interactive >/dev/null +) + +printf '%s\n' 'previous-working-binary' >"$binary" +cp "$binary" "$test_root/previous" +if GOFLAGS='-definitely-invalid' \ + MIDTRANS_INSTALL_DIR="$test_root/bin" \ + "$repo_dir/tools/install-local.sh"; then + echo "installer unexpectedly succeeded with invalid build flags" >&2 + exit 1 +fi +cmp "$binary" "$test_root/previous" From 888d4b25f948f1f472b7626cac9887c826d488a9 Mon Sep 17 00:00:00 2001 From: "m.salis" Date: Sun, 26 Jul 2026 10:32:37 +0700 Subject: [PATCH 18/73] fix: reject unsafe local install targets --- tools/install-local.sh | 14 ++++- tools/test-install-local.sh | 111 +++++++++++++++++++++++++++++++++++- 2 files changed, 121 insertions(+), 4 deletions(-) diff --git a/tools/install-local.sh b/tools/install-local.sh index 5d697ab..e824567 100755 --- a/tools/install-local.sh +++ b/tools/install-local.sh @@ -27,7 +27,19 @@ build_date=$(date -u +%Y-%m-%dT%H:%M:%SZ) chmod 0755 "$tmp_binary" "$tmp_binary" version --json --non-interactive >/dev/null "$tmp_binary" agent capabilities --json --non-interactive >/dev/null -mv -f "$tmp_binary" "$install_dir/midtrans" +target="$install_dir/midtrans" +if [ -e "$target" ] || [ -L "$target" ]; then + if [ -L "$target" ]; then + if ! [ -f "$target" ]; then + echo "refusing to replace unsafe install target: $target" >&2 + exit 1 + fi + elif ! [ -f "$target" ]; then + echo "refusing to replace unsafe install target: $target" >&2 + exit 1 + fi +fi +mv -f "$tmp_binary" "$target" trap - EXIT INT TERM case ":${PATH:-}:" in diff --git a/tools/test-install-local.sh b/tools/test-install-local.sh index 03b9119..b019c2c 100755 --- a/tools/test-install-local.sh +++ b/tools/test-install-local.sh @@ -20,12 +20,117 @@ mkdir -p "$other_dir" "$binary" version --json --non-interactive >/dev/null ) -printf '%s\n' 'previous-working-binary' >"$binary" -cp "$binary" "$test_root/previous" +set_previous_binary() { + printf '%s\n' 'previous-working-binary' >"$binary" + cp "$binary" "$test_root/previous" +} + +assert_previous_binary_is_unchanged() { + cmp "$binary" "$test_root/previous" +} + +set_previous_binary if GOFLAGS='-definitely-invalid' \ MIDTRANS_INSTALL_DIR="$test_root/bin" \ "$repo_dir/tools/install-local.sh"; then echo "installer unexpectedly succeeded with invalid build flags" >&2 exit 1 fi -cmp "$binary" "$test_root/previous" +assert_previous_binary_is_unchanged + +fake_go_dir="$test_root/fake-go" +fake_go="$fake_go_dir/go" +mkdir -p "$fake_go_dir" +printf '%s\n' \ + '#!/bin/sh' \ + 'set -eu' \ + 'output=' \ + 'while [ "$#" -gt 0 ]; do' \ + 'case "$1" in' \ + '-o)' \ + 'output=$2' \ + 'shift 2' \ + ';;' \ + '*)' \ + 'shift' \ + ';;' \ + 'esac' \ + 'done' \ + 'test -n "$output"' \ + 'printf "%s\\n" "#!/bin/sh" >"$output"' \ + 'printf "%s\\n" "if [ \"\${1:-}\" = \"version\" ]; then" >>"$output"' \ + 'printf "%s\\n" "exit \"\${MIDTRANS_FAKE_VERSION_EXIT:-0}\"" >>"$output"' \ + 'printf "%s\\n" "fi" >>"$output"' \ + 'printf "%s\\n" "if [ \"\${1:-}\" = \"agent\" ] && [ \"\${2:-}\" = \"capabilities\" ]; then" >>"$output"' \ + 'printf "%s\\n" "exit \"\${MIDTRANS_FAKE_CAPABILITIES_EXIT:-0}\"" >>"$output"' \ + 'printf "%s\\n" "fi" >>"$output"' \ + 'printf "%s\\n" "exit 0" >>"$output"' \ + 'chmod 0755 "$output"' >"$fake_go" +chmod 0755 "$fake_go" + +set_previous_binary +if PATH="$fake_go_dir:$PATH" \ + MIDTRANS_FAKE_VERSION_EXIT=1 \ + MIDTRANS_INSTALL_DIR="$test_root/bin" \ + "$repo_dir/tools/install-local.sh"; then + echo "installer unexpectedly succeeded when version verification failed" >&2 + exit 1 +fi +assert_previous_binary_is_unchanged + +set_previous_binary +if PATH="$fake_go_dir:$PATH" \ + MIDTRANS_FAKE_CAPABILITIES_EXIT=1 \ + MIDTRANS_INSTALL_DIR="$test_root/bin" \ + "$repo_dir/tools/install-local.sh"; then + echo "installer unexpectedly succeeded when capability verification failed" >&2 + exit 1 +fi +assert_previous_binary_is_unchanged + +collision_dir="$test_root/collision-directory" +mkdir -p "$collision_dir" +printf '%s\n' 'collision-sentinel' >"$collision_dir/sentinel" +find "$collision_dir" -mindepth 1 -maxdepth 1 -print >"$test_root/collision-before" +rm -f "$binary" +mkdir "$binary" +if MIDTRANS_INSTALL_DIR="$test_root/bin" "$repo_dir/tools/install-local.sh"; then + echo "installer unexpectedly replaced a directory destination" >&2 + exit 1 +fi +test -d "$binary" +find "$collision_dir" -mindepth 1 -maxdepth 1 -print >"$test_root/collision-after" +cmp "$test_root/collision-before" "$test_root/collision-after" + +symlink_dir="$test_root/symlink-directory" +mkdir -p "$symlink_dir" +printf '%s\n' 'symlink-sentinel' >"$symlink_dir/sentinel" +find "$symlink_dir" -mindepth 1 -maxdepth 1 -print >"$test_root/symlink-before" +rm -rf "$binary" +ln -s "$symlink_dir" "$binary" +if MIDTRANS_INSTALL_DIR="$test_root/bin" "$repo_dir/tools/install-local.sh"; then + echo "installer unexpectedly replaced a symlink-to-directory destination" >&2 + exit 1 +fi +test -L "$binary" +find "$symlink_dir" -mindepth 1 -maxdepth 1 -print >"$test_root/symlink-after" +cmp "$test_root/symlink-before" "$test_root/symlink-after" + +rm -f "$binary" +mkfifo "$binary" +if MIDTRANS_INSTALL_DIR="$test_root/bin" "$repo_dir/tools/install-local.sh"; then + echo "installer unexpectedly replaced a non-regular destination" >&2 + exit 1 +fi +test -p "$binary" + +legacy_binary="$test_root/legacy-midtrans" +printf '%s\n' 'legacy-symlink-target' >"$legacy_binary" +cp "$legacy_binary" "$test_root/legacy-previous" +rm -f "$binary" +ln -s "$legacy_binary" "$binary" +MIDTRANS_INSTALL_DIR="$test_root/bin" "$repo_dir/tools/install-local.sh" +test -f "$binary" +test ! -L "$binary" +cmp "$legacy_binary" "$test_root/legacy-previous" +"$binary" version --json --non-interactive >/dev/null From 1d7fae3234a7cabc129abb8bb7f21d8f55b8a901 Mon Sep 17 00:00:00 2001 From: "m.salis" Date: Sun, 26 Jul 2026 10:53:18 +0700 Subject: [PATCH 19/73] fix: harden merchant CLI readiness and project errors --- internal/app/app.go | 11 +- internal/app/app_test.go | 16 +- internal/app/commands_credentials.go | 35 ++- internal/app/commands_doctor.go | 10 +- internal/app/commands_inspect.go | 11 - internal/app/commands_manifest.go | 67 ++-- internal/app/commands_plan.go | 7 +- internal/app/commands_setup.go | 3 - internal/app/commands_status.go | 7 +- internal/app/commands_verify.go | 63 +++- internal/app/final_review_regressions_test.go | 291 ++++++++++++++++++ internal/app/project_context.go | 1 + internal/app/server_key.go | 20 ++ internal/presentation/model.go | 94 ++++++ internal/readiness/report.go | 10 +- internal/readiness/report_test.go | 2 + test/e2e/security_test.go | 8 +- 17 files changed, 564 insertions(+), 92 deletions(-) create mode 100644 internal/app/final_review_regressions_test.go diff --git a/internal/app/app.go b/internal/app/app.go index ff8031e..d9f7747 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -35,11 +35,12 @@ type Dependencies struct { } type globalFlags struct { - json bool - nonInteractive bool - projectDir string - verbose bool - legacy *legacyInvocation + json bool + nonInteractive bool + projectDir string + projectInitialized bool + verbose bool + legacy *legacyInvocation } type legacyInvocation struct { diff --git a/internal/app/app_test.go b/internal/app/app_test.go index e4761f4..18d76ab 100644 --- a/internal/app/app_test.go +++ b/internal/app/app_test.go @@ -266,7 +266,7 @@ func TestStatusShowsActionableMerchantReadiness(t *testing.T) { } } -func TestSetupNonInteractiveNeverWritesManifest(t *testing.T) { +func TestSetupNonInteractivePreservesFailedReadinessAndNeverWritesManifest(t *testing.T) { project := t.TempDir() if _, err := manifest.Init(project); err != nil { t.Fatal(err) @@ -283,7 +283,7 @@ func TestSetupNonInteractiveNeverWritesManifest(t *testing.T) { if err != nil { t.Fatal(err) } - if exit != 0 || result.Command != "setup" || !bytes.Equal(before, after) { + if exit != 2 || result.Command != "setup" || result.Status != contracts.StatusFail || !bytes.Equal(before, after) { t.Fatalf("exit = %d, result = %#v, changed = %v", exit, result, !bytes.Equal(before, after)) } } @@ -907,13 +907,13 @@ func TestManifestMigrateRejectsUnsupportedSchema(t *testing.T) { } result, exit := executeJSON(t, "manifest", "migrate", "--project-dir", root, "--json", "--non-interactive") - if exit != 5 { - t.Fatalf("migrate exit = %d, want 5; result = %#v", exit, result) + if exit != 6 { + t.Fatalf("migrate exit = %d, want 6; result = %#v", exit, result) } - if result.Command != "manifest.migrate" || result.Status != contracts.StatusBlocked { + if result.Command != "manifest.migrate" || result.Status != contracts.StatusError { t.Fatalf("migrate result = %#v", result) } - if len(result.Findings) != 1 || result.Findings[0].Code != "MANIFEST_SCHEMA_UNSUPPORTED" { + if len(result.Findings) != 1 || result.Findings[0].Code != "PROJECT_MANIFEST_INVALID" { t.Fatalf("migrate findings = %#v", result.Findings) } after, err := os.ReadFile(path) @@ -1472,12 +1472,12 @@ func TestCredentialCommandsValidateManifestBeforeResolution(t *testing.T) { if resolved { t.Fatal("invalid credential reference was resolved") } - if exit != 2 || result.Status != contracts.StatusFail || + if exit != 6 || result.Status != contracts.StatusError || result.CLIVersion != "0.1.0-test" { t.Fatalf("exit = %d, result = %#v", exit, result) } if len(result.Findings) == 0 || - result.Findings[0].Code != "CREDENTIAL_REFERENCE_INVALID" { + result.Findings[0].Code != "PROJECT_MANIFEST_INVALID" { t.Fatalf("findings = %#v", result.Findings) } encoded, err := json.Marshal(result) diff --git a/internal/app/commands_credentials.go b/internal/app/commands_credentials.go index 5daf623..00d785f 100644 --- a/internal/app/commands_credentials.go +++ b/internal/app/commands_credentials.go @@ -3,6 +3,7 @@ package app import ( "context" "errors" + "strings" "github.com/spf13/cobra" "github.com/veritrans/midtrans-cli/internal/contracts" @@ -98,22 +99,34 @@ func loadValidatedManifest( ) (manifest.Manifest, *contracts.Result) { value, err := manifest.Load(projectDir) if err != nil { - result := contracts.NewResult(command, contracts.StatusError) - result.CLIVersion = deps.Version.Version - result.Findings = []contracts.Finding{{ - Code: "MANIFEST_LOAD_FAILED", - Severity: "blocking", - Message: "unable to load the project manifest", - }} + result := projectManifestInvalidResult(command, deps, 0, err) return manifest.Manifest{}, &result } findings := manifest.Validate(value) if len(findings) == 0 { return value, nil } - result := contracts.NewResult(command, contracts.StatusFail) - result.CLIVersion = deps.Version.Version - result.ManifestVersion = value.SchemaVersion - result.Findings = findings + result := projectManifestInvalidResult(command, deps, value.SchemaVersion, nil) return manifest.Manifest{}, &result } + +func projectManifestInvalidResult( + command string, + deps Dependencies, + manifestVersion int, + err error, +) contracts.Result { + code := "PROJECT_MANIFEST_INVALID" + message := "the selected project manifest is invalid" + if err != nil && strings.Contains(err.Error(), "PATH_OUTSIDE_PROJECT") { + code = "PROJECT_PATH_UNSAFE" + message = "the selected project path is unsafe" + } + result := contracts.NewResult(command, contracts.StatusError) + result.CLIVersion = deps.Version.Version + result.ManifestVersion = manifestVersion + result.Findings = []contracts.Finding{{ + Code: code, Severity: "blocking", Message: message, + }} + return result +} diff --git a/internal/app/commands_doctor.go b/internal/app/commands_doctor.go index b1e5427..c0a59ef 100644 --- a/internal/app/commands_doctor.go +++ b/internal/app/commands_doctor.go @@ -4,7 +4,6 @@ import ( "github.com/spf13/cobra" "github.com/veritrans/midtrans-cli/internal/contracts" "github.com/veritrans/midtrans-cli/internal/inspection" - "github.com/veritrans/midtrans-cli/internal/manifest" "github.com/veritrans/midtrans-cli/internal/project" ) @@ -43,12 +42,11 @@ func newCheckCommand( return writeResult(deps, flags, result) } - value, err := manifest.Load(flags.projectDir) - if err != nil { - result := manifestLoadFailureResult(resultCommand, deps) + value, invalidResult := loadValidatedManifest(resultCommand, flags.projectDir, deps) + if invalidResult != nil { + result := *invalidResult return writeResult(deps, flags, result) } - findings := manifest.Validate(value) report, err := inspection.Inspect(flags.projectDir) if err != nil { @@ -56,7 +54,7 @@ func newCheckCommand( result.ManifestVersion = value.SchemaVersion return writeResult(deps, flags, result) } - findings = append(findings, pack.Evaluate(value, report)...) + findings := pack.Evaluate(value, report) result := contracts.NewResult(resultCommand, statusFromFindings(findings)) result.CLIVersion = deps.Version.Version diff --git a/internal/app/commands_inspect.go b/internal/app/commands_inspect.go index 7c66315..544096d 100644 --- a/internal/app/commands_inspect.go +++ b/internal/app/commands_inspect.go @@ -40,14 +40,3 @@ func inspectionFailureResult(command string, deps Dependencies) contracts.Result }} return result } - -func manifestLoadFailureResult(command string, deps Dependencies) contracts.Result { - result := contracts.NewResult(command, contracts.StatusError) - result.CLIVersion = deps.Version.Version - result.Findings = []contracts.Finding{{ - Code: "MANIFEST_LOAD_FAILED", - Severity: "blocking", - Message: "unable to load the project manifest", - }} - return result -} diff --git a/internal/app/commands_manifest.go b/internal/app/commands_manifest.go index 9c86e0d..f3c03a1 100644 --- a/internal/app/commands_manifest.go +++ b/internal/app/commands_manifest.go @@ -1,9 +1,14 @@ package app import ( + "errors" + "io/fs" + "path/filepath" + "github.com/spf13/cobra" "github.com/veritrans/midtrans-cli/internal/contracts" "github.com/veritrans/midtrans-cli/internal/manifest" + "github.com/veritrans/midtrans-cli/internal/presentation" "github.com/veritrans/midtrans-cli/internal/project" ) @@ -11,46 +16,68 @@ func newInitCommand(flags *globalFlags, deps Dependencies) *cobra.Command { return withProjectMode(&cobra.Command{ Use: "init", RunE: func(cmd *cobra.Command, args []string) error { - path, err := manifest.Init(flags.projectDir) + if flags.projectInitialized { + value, invalidResult := loadValidatedManifest("init", flags.projectDir, deps) + if invalidResult != nil { + return writeResult(deps, flags, *invalidResult) + } + return writeResult(deps, flags, initResult(flags.projectDir, value.SchemaVersion, true, deps)) + } + _, err := manifest.Init(flags.projectDir) if err != nil { - return err + if errors.Is(err, fs.ErrExist) { + value, invalidResult := loadValidatedManifest("init", flags.projectDir, deps) + if invalidResult != nil { + return writeResult(deps, flags, *invalidResult) + } + return writeResult(deps, flags, initResult(flags.projectDir, value.SchemaVersion, true, deps)) + } + return writeResult(deps, flags, projectManifestInvalidResult("init", deps, 0, err)) } - result := contracts.NewResult("init", contracts.StatusPass) - result.CLIVersion = deps.Version.Version - result.ManifestVersion = 1 - result.Data = map[string]any{"manifest_path": path} - return writeResult(deps, flags, result) + return writeResult(deps, flags, initResult(flags.projectDir, 1, false, deps)) }, }, project.Initializable, "init") } +func initResult(projectDir string, manifestVersion int, existing bool, deps Dependencies) contracts.Result { + result := contracts.NewResult("init", contracts.StatusPass) + result.CLIVersion = deps.Version.Version + result.ManifestVersion = manifestVersion + result.Data = presentation.InitData{ + Project: filepath.Base(projectDir), + Root: projectDir, + ManifestPath: manifest.Path(projectDir), + Environment: "sandbox", + Existing: existing, + } + result.NextActions = []contracts.NextAction{{ + Action: "setup_project", + Description: "run midtrans setup to review Sandbox readiness", + }} + return result +} + func newManifestCommand(flags *globalFlags, deps Dependencies) *cobra.Command { parent := &cobra.Command{Use: "manifest"} parent.AddCommand(withProjectMode(&cobra.Command{ Use: "validate", RunE: func(cmd *cobra.Command, args []string) error { - value, err := manifest.Load(flags.projectDir) - if err != nil { - return err + value, invalidResult := loadValidatedManifest("manifest.validate", flags.projectDir, deps) + if invalidResult != nil { + return writeResult(deps, flags, *invalidResult) } - findings := manifest.Validate(value) - status := contracts.StatusPass - if len(findings) > 0 { - status = contracts.StatusFail - } - result := contracts.NewResult("manifest.validate", status) + result := contracts.NewResult("manifest.validate", contracts.StatusPass) result.CLIVersion = deps.Version.Version result.ManifestVersion = value.SchemaVersion - result.Findings = findings return writeResult(deps, flags, result) }, }, project.Existing, "manifest.validate")) parent.AddCommand(withProjectMode(&cobra.Command{ Use: "migrate", RunE: func(cmd *cobra.Command, args []string) error { - value, err := manifest.Load(flags.projectDir) - if err != nil { - return err + value, invalidResult := loadValidatedManifest("manifest.migrate", flags.projectDir, deps) + if invalidResult != nil { + return writeResult(deps, flags, *invalidResult) } if value.SchemaVersion != 1 { result := contracts.NewIncompatibleResult( diff --git a/internal/app/commands_plan.go b/internal/app/commands_plan.go index cc8857c..0a9f6c0 100644 --- a/internal/app/commands_plan.go +++ b/internal/app/commands_plan.go @@ -4,7 +4,6 @@ import ( "github.com/spf13/cobra" "github.com/veritrans/midtrans-cli/internal/contracts" "github.com/veritrans/midtrans-cli/internal/inspection" - "github.com/veritrans/midtrans-cli/internal/manifest" "github.com/veritrans/midtrans-cli/internal/project" ) @@ -21,9 +20,9 @@ func newPlanCommand(flags *globalFlags, deps Dependencies) *cobra.Command { result.CLIVersion = deps.Version.Version return writeResult(deps, flags, result) } - value, err := manifest.Load(flags.projectDir) - if err != nil { - result := manifestLoadFailureResult("plan", deps) + value, invalidResult := loadValidatedManifest("plan", flags.projectDir, deps) + if invalidResult != nil { + result := *invalidResult return writeResult(deps, flags, result) } report, err := inspection.Inspect(flags.projectDir) diff --git a/internal/app/commands_setup.go b/internal/app/commands_setup.go index 3fd7da2..b8adf96 100644 --- a/internal/app/commands_setup.go +++ b/internal/app/commands_setup.go @@ -23,9 +23,6 @@ func newSetupCommand(flags *globalFlags, deps Dependencies) *cobra.Command { if flags.json || flags.nonInteractive || !deps.IsTerminal() { result := buildStatusResult(cmd.Context(), flags, deps) result.Command = "setup" - if result.Status == contracts.StatusFail && result.Data != nil { - result.Status = contracts.StatusWarn - } return writeResult(deps, flags, result) } value, invalid := loadValidatedManifest("setup", flags.projectDir, deps) diff --git a/internal/app/commands_status.go b/internal/app/commands_status.go index de75660..494d68c 100644 --- a/internal/app/commands_status.go +++ b/internal/app/commands_status.go @@ -49,7 +49,11 @@ func buildStatusResult( } findings := append(manifest.Validate(value), pack.Evaluate(value, report)...) provider := secrets.NewEnvironmentProvider(deps.Getenv) - serverPresent := secretPresent(ctx, provider, value.Credentials.References["server_key"]) + serverPresent, serverInvalid := sandboxServerKeyReadiness( + ctx, + value.Credentials.References["server_key"], + deps, + ) clientPresent := secretPresent(ctx, provider, value.Credentials.References["client_key"]) reachable := readiness.ReachabilityUnknown if value.Integration.LocalBaseURL != "" { @@ -66,6 +70,7 @@ func buildStatusResult( Packs: deps.Packs.Versions(), Findings: findings, ServerKeyPresent: serverPresent, + ServerKeyInvalid: serverInvalid, ClientKeyPresent: clientPresent, LocalReachable: reachable, }) diff --git a/internal/app/commands_verify.go b/internal/app/commands_verify.go index 25b8b02..c0ff3bf 100644 --- a/internal/app/commands_verify.go +++ b/internal/app/commands_verify.go @@ -7,7 +7,7 @@ import ( "github.com/veritrans/midtrans-cli/internal/contracts" "github.com/veritrans/midtrans-cli/internal/evidence" "github.com/veritrans/midtrans-cli/internal/inspection" - "github.com/veritrans/midtrans-cli/internal/manifest" + "github.com/veritrans/midtrans-cli/internal/presentation" "github.com/veritrans/midtrans-cli/internal/project" "github.com/veritrans/midtrans-cli/internal/verify" ) @@ -28,12 +28,12 @@ func newVerifyCommand(flags *globalFlags, deps Dependencies) *cobra.Command { result.CLIVersion = deps.Version.Version return writeResult(deps, flags, result) } - value, err := manifest.Load(flags.projectDir) - if err != nil { - result := manifestLoadFailureResult("verify", deps) + value, invalidResult := loadValidatedManifest("verify", flags.projectDir, deps) + if invalidResult != nil { + result := *invalidResult return writeResult(deps, flags, result) } - findings := manifest.Validate(value) + findings := []contracts.Finding{} report, err := inspection.Inspect(flags.projectDir) if err != nil { result := inspectionFailureResult("verify", deps) @@ -73,20 +73,21 @@ func newVerifyCommand(flags *globalFlags, deps Dependencies) *cobra.Command { }) } } + required := []verify.RequiredProof{ + { + ID: "snap.provider-status", + Level: evidence.ProofSandbox, + }, + { + ID: "snap.merchant-callback", + Level: evidence.ProofLocal, + }, + } result := verify.Run(verify.Input{ Command: "verify", LocalFindings: findings, - Required: []verify.RequiredProof{ - { - ID: "snap.provider-status", - Level: evidence.ProofSandbox, - }, - { - ID: "snap.merchant-callback", - Level: evidence.ProofLocal, - }, - }, - Bundle: bundle, + Required: required, + Bundle: bundle, }) result.CLIVersion = deps.Version.Version result.ManifestVersion = value.SchemaVersion @@ -94,6 +95,7 @@ func newVerifyCommand(flags *globalFlags, deps Dependencies) *cobra.Command { ID: pack.Descriptor().ID, Version: pack.Descriptor().Version, }} + result.Data = verificationPresentationData(result.Status, required, bundle, evidenceFile) return writeResult(deps, flags, result) }, } @@ -107,6 +109,35 @@ func newVerifyCommand(flags *globalFlags, deps Dependencies) *cobra.Command { return withProjectMode(command, project.Existing, "verify") } +func verificationPresentationData( + status contracts.Status, + required []verify.RequiredProof, + bundle evidence.Bundle, + evidencePath string, +) presentation.VerifyData { + data := presentation.VerifyData{ + ProofState: "incomplete", + EvidencePath: evidencePath, + Proofs: make([]presentation.VerifyProof, 0, len(required)), + } + if status == contracts.StatusPass || status == contracts.StatusWarn { + data.ProofState = "verified" + } + for _, expected := range required { + proofStatus := "missing" + for _, proof := range bundle.Proofs { + if proof.ID == expected.ID && proof.Level == expected.Level && proof.Status == "pass" { + proofStatus = "pass" + break + } + } + data.Proofs = append(data.Proofs, presentation.VerifyProof{ + ID: expected.ID, Level: string(expected.Level), Status: proofStatus, + }) + } + return data +} + func evidenceMatchesProject( projectDir string, manifestVersion int, diff --git a/internal/app/final_review_regressions_test.go b/internal/app/final_review_regressions_test.go new file mode 100644 index 0000000..c75ea7f --- /dev/null +++ b/internal/app/final_review_regressions_test.go @@ -0,0 +1,291 @@ +package app_test + +import ( + "bytes" + "context" + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/veritrans/midtrans-cli/internal/app" + "github.com/veritrans/midtrans-cli/internal/contracts" + "github.com/veritrans/midtrans-cli/internal/manifest" + "github.com/veritrans/midtrans-cli/internal/version" +) + +func TestStatusAndSetupDifferentiateSandboxServerKeyReadiness(t *testing.T) { + project := merchantFixture("snap-complete") + tests := []struct { + name string + serverKey string + present bool + wantExit int + wantState string + wantStatus contracts.Status + }{ + { + name: "missing", + wantState: "needs_action", wantStatus: contracts.StatusWarn, + }, + { + name: "production", serverKey: "Mid-server-PRODUCTION-CANARY-DO-NOT-PRINT", present: true, + wantExit: 2, wantState: "failed", wantStatus: contracts.StatusFail, + }, + { + name: "malformed", serverKey: "not-a-midtrans-key-CANARY-DO-NOT-PRINT", present: true, + wantExit: 2, wantState: "failed", wantStatus: contracts.StatusFail, + }, + { + name: "sandbox", serverKey: "SB-Mid-server-SANDBOX-CANARY-DO-NOT-PRINT", present: true, + wantState: "ready", wantStatus: contracts.StatusPass, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + deps := app.Dependencies{ + Version: version.Info{Version: "0.1.0-test"}, + Packs: testRegistry(t), + Getenv: func(name string) (string, bool) { + switch name { + case "MIDTRANS_SERVER_KEY": + return test.serverKey, test.present + case "MIDTRANS_CLIENT_KEY": + return "SB-Mid-client-SANDBOX-CANARY-DO-NOT-PRINT", true + default: + return "", false + } + }, + LocalProbe: func(context.Context, string) bool { return true }, + } + for _, command := range []string{"status", "setup"} { + result, exit := executeJSONWithDependencies( + t, deps, command, "--project-dir", project, + ) + if exit != test.wantExit || result.Status != test.wantStatus { + t.Fatalf("%s exit = %d, result = %#v", command, exit, result) + } + if state := readinessCheckState(t, result, "server-key"); state != test.wantState { + t.Fatalf("%s server key state = %q, want %q", command, state, test.wantState) + } + encoded, err := json.Marshal(result) + if err != nil { + t.Fatal(err) + } + if bytes.Contains(encoded, []byte("CANARY-DO-NOT-PRINT")) { + t.Fatalf("%s leaked a credential: %s", command, encoded) + } + } + }) + } +} + +func TestInitExistingProjectReportsExistingProjectForDiscoveredAndExplicitRoots(t *testing.T) { + project := t.TempDir() + if _, err := manifest.Init(project); err != nil { + t.Fatal(err) + } + nested := filepath.Join(project, "nested", "checkout") + if err := os.MkdirAll(nested, 0o755); err != nil { + t.Fatal(err) + } + + tests := []struct { + name string + deps app.Dependencies + args []string + }{ + { + name: "discovered root", + deps: app.Dependencies{ + Version: version.Info{Version: "0.1.0-test"}, Packs: testRegistry(t), + Getwd: func() (string, error) { return nested, nil }, + }, + }, + { + name: "explicit root", + deps: app.Dependencies{ + Version: version.Info{Version: "0.1.0-test"}, Packs: testRegistry(t), + }, + args: []string{"--project-dir", project}, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + result, exit := executeJSONWithDependencies(t, test.deps, append([]string{"init"}, test.args...)...) + if exit != 0 || result.Command != "init" || result.Status != contracts.StatusPass { + t.Fatalf("exit = %d, result = %#v", exit, result) + } + data := resultData(t, result) + if data["project"] != filepath.Base(project) || + data["root"] != project || + data["manifest_path"] != manifest.Path(project) || + data["environment"] != "sandbox" || + data["existing"] != true { + t.Fatalf("init data = %#v", data) + } + if len(result.NextActions) != 1 || result.NextActions[0].Action != "setup_project" { + t.Fatalf("next actions = %#v", result.NextActions) + } + }) + } +} + +func TestProjectBoundCommandsClassifyInvalidManifest(t *testing.T) { + commands := []struct { + name string + args []string + command string + }{ + {name: "status", args: []string{"status"}, command: "status"}, + {name: "setup", args: []string{"setup"}, command: "setup"}, + {name: "doctor", args: []string{"doctor", "--product", "snap"}, command: "doctor"}, + {name: "plan", args: []string{"plan", "snap"}, command: "plan"}, + {name: "verify", args: []string{"verify"}, command: "verify"}, + {name: "manifest validate", args: []string{"manifest", "validate"}, command: "manifest.validate"}, + {name: "manifest migrate", args: []string{"manifest", "migrate"}, command: "manifest.migrate"}, + } + manifests := []struct { + name string + contents string + }{ + {name: "malformed yaml", contents: "schema_version: [\n"}, + {name: "unsupported schema", contents: "schema_version: 2\n"}, + } + + for _, invalid := range manifests { + t.Run(invalid.name, func(t *testing.T) { + for _, command := range commands { + t.Run(command.name, func(t *testing.T) { + project := t.TempDir() + if _, err := manifest.Init(project); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(manifest.Path(project), []byte(invalid.contents), 0o600); err != nil { + t.Fatal(err) + } + args := append( + append([]string{}, command.args...), + "--project-dir", project, "--json", "--non-interactive", + ) + result, exit := executeJSON(t, args...) + if exit != 6 || result.Command != command.command || result.Status != contracts.StatusError || + len(result.Findings) != 1 || result.Findings[0].Code != "PROJECT_MANIFEST_INVALID" { + t.Fatalf("exit = %d, result = %#v", exit, result) + } + }) + } + }) + } +} + +func TestUnsafeManifestPathRemainsAProjectPathError(t *testing.T) { + project := t.TempDir() + if err := os.Mkdir(filepath.Join(project, ".midtrans"), 0o755); err != nil { + t.Fatal(err) + } + outside := filepath.Join(t.TempDir(), "manifest.yaml") + if err := os.WriteFile(outside, []byte("schema_version: 1\n"), 0o600); err != nil { + t.Fatal(err) + } + if err := os.Symlink(outside, manifest.Path(project)); err != nil { + t.Fatal(err) + } + + for _, command := range []struct { + args []string + want string + }{ + {args: []string{"status"}, want: "status"}, + {args: []string{"doctor", "--product", "snap"}, want: "doctor"}, + {args: []string{"manifest", "validate"}, want: "manifest.validate"}, + } { + t.Run(strings.Join(command.args, " "), func(t *testing.T) { + args := append( + append([]string{}, command.args...), + "--project-dir", project, "--json", "--non-interactive", + ) + result, exit := executeJSON(t, args...) + if exit != 6 || result.Command != command.want || result.Status != contracts.StatusError || + len(result.Findings) != 1 || result.Findings[0].Code != "PROJECT_PATH_UNSAFE" { + t.Fatalf("exit = %d, result = %#v", exit, result) + } + }) + } +} + +func TestSuccessfulMerchantHumanOutputIsInformativeAndRedacted(t *testing.T) { + t.Run("init", func(t *testing.T) { + project := t.TempDir() + var stdout, stderr bytes.Buffer + exit := app.Execute(context.Background(), []string{"init", "--project-dir", project}, app.Dependencies{ + Stdout: &stdout, Stderr: &stderr, + Version: version.Info{Version: "0.1.0-test"}, Packs: testRegistry(t), + }) + if exit != 0 || stderr.Len() != 0 { + t.Fatalf("exit = %d, stdout = %q, stderr = %q", exit, stdout.String(), stderr.String()) + } + for _, want := range []string{ + "Project initialized", "Project", filepath.Base(project), "Root", project, + "Manifest", ".midtrans/manifest.yaml", "Environment", "Sandbox", "Next:", "midtrans setup", + } { + if !strings.Contains(stdout.String(), want) { + t.Fatalf("init output missing %q: %s", want, stdout.String()) + } + } + }) + + t.Run("verify", func(t *testing.T) { + project := createJourneyProject(t, "http://127.0.0.1:1") + evidencePath := writeCompleteEvidence(t, project) + var stdout, stderr bytes.Buffer + exit := app.Execute(context.Background(), []string{ + "verify", "--evidence", evidencePath, "--project-dir", project, "--non-interactive", + }, app.Dependencies{ + Stdout: &stdout, Stderr: &stderr, + Version: version.Info{Version: "0.1.0-test"}, Packs: testRegistry(t), + }) + if exit != 0 || stderr.Len() != 0 { + t.Fatalf("exit = %d, stdout = %q, stderr = %q", exit, stdout.String(), stderr.String()) + } + for _, want := range []string{ + "Sandbox verification", "Proof state", "Verified", "Provider status proof", + "Merchant callback proof", "Evidence", evidencePath, + } { + if !strings.Contains(stdout.String(), want) { + t.Fatalf("verify output missing %q: %s", want, stdout.String()) + } + } + if strings.Contains(stdout.String(), "CANARY") || strings.Contains(stdout.String(), "authorization") { + t.Fatalf("verify output exposed provider payload: %s", stdout.String()) + } + }) +} + +func readinessCheckState(t *testing.T, result contracts.Result, id string) string { + t.Helper() + for _, check := range resultData(t, result)["checks"].([]any) { + value, ok := check.(map[string]any) + if !ok { + t.Fatalf("check = %#v", check) + } + if value["id"] == id { + state, _ := value["state"].(string) + return state + } + } + t.Fatalf("check %q not found in %#v", id, result.Data) + return "" +} + +func resultData(t *testing.T, result contracts.Result) map[string]any { + t.Helper() + data, ok := result.Data.(map[string]any) + if !ok { + t.Fatalf("data = %#v", result.Data) + } + return data +} diff --git a/internal/app/project_context.go b/internal/app/project_context.go index f57f426..620c2fa 100644 --- a/internal/app/project_context.go +++ b/internal/app/project_context.go @@ -53,6 +53,7 @@ func resolveProjectContext( return writeResult(deps, flags, projectErrorResult(command, deps, err)) } flags.projectDir = resolution.Root + flags.projectInitialized = resolution.Initialized return nil } diff --git a/internal/app/server_key.go b/internal/app/server_key.go index 35b9413..5b3011b 100644 --- a/internal/app/server_key.go +++ b/internal/app/server_key.go @@ -32,6 +32,26 @@ func resolveSandboxServerKey( return secrets.Value{}, &result } +func sandboxServerKeyReadiness( + ctx context.Context, + reference string, + deps Dependencies, +) (present, invalid bool) { + _, err := secrets.ResolveSandboxServerKey( + ctx, + secrets.NewEnvironmentProvider(deps.Getenv), + reference, + ) + switch { + case err == nil: + return true, false + case errors.Is(err, secrets.ErrMissing): + return false, false + default: + return false, true + } +} + func sandboxServerKeyErrorResult( command string, manifestVersion int, diff --git a/internal/presentation/model.go b/internal/presentation/model.go index 64111ee..62ba2ad 100644 --- a/internal/presentation/model.go +++ b/internal/presentation/model.go @@ -25,8 +25,34 @@ type Model struct { NextActions []contracts.NextAction } +// InitData is the bounded, safe project state emitted by midtrans init. +type InitData struct { + Project string `json:"project"` + Root string `json:"root"` + ManifestPath string `json:"manifest_path"` + Environment string `json:"environment"` + Existing bool `json:"existing"` +} + +// VerifyProof is a proof identity and state without the provider payload or +// proof summary that produced it. +type VerifyProof struct { + ID string `json:"id"` + Level string `json:"level"` + Status string `json:"status"` +} + +// VerifyData is the bounded verification state emitted by midtrans verify. +type VerifyData struct { + ProofState string `json:"proof_state"` + Proofs []VerifyProof `json:"proofs"` + EvidencePath string `json:"evidence_path,omitempty"` +} + func Build(result contracts.Result) (Model, bool) { switch result.Command { + case "init": + return initModel(result) case "status", "setup": var report readiness.Report if !decodeData(result.Data, &report) || len(report.Checks) == 0 { @@ -54,11 +80,79 @@ func Build(result contracts.Result) (Model, bool) { return checkoutModel(result) case "test.webhook": return webhookTestModel(result) + case "verify": + return verifyModel(result) default: return Model{}, false } } +func initModel(result contracts.Result) (Model, bool) { + var data InitData + if !decodeData(result.Data, &data) || data.Project == "" || data.Root == "" || + data.ManifestPath == "" || data.Environment == "" { + return Model{}, false + } + title := "Project initialized" + if data.Existing { + title = "Project already initialized" + } + return Model{ + Title: title, + Rows: []Row{ + {State: "✓", Label: "Project", Detail: data.Project}, + {State: "✓", Label: "Root", Detail: data.Root}, + {State: "✓", Label: "Manifest", Detail: data.ManifestPath}, + {State: "✓", Label: "Environment", Detail: titleCase(data.Environment)}, + }, + Findings: result.Findings, NextActions: result.NextActions, + }, true +} + +func verifyModel(result contracts.Result) (Model, bool) { + var data VerifyData + if !decodeData(result.Data, &data) || data.ProofState == "" || len(data.Proofs) == 0 { + return Model{}, false + } + rows := []Row{{ + State: stateForProof(data.ProofState), Label: "Proof state", Detail: titleCase(data.ProofState), + }} + for _, proof := range data.Proofs { + if proof.ID == "" || proof.Level == "" || proof.Status == "" { + return Model{}, false + } + rows = append(rows, Row{ + State: stateForProof(proof.Status), Label: proofLabel(proof.ID), + Detail: titleCase(proof.Status) + " · " + titleCase(proof.Level), + }) + } + if data.EvidencePath != "" { + rows = append(rows, Row{State: "✓", Label: "Evidence", Detail: data.EvidencePath}) + } + return Model{ + Title: "Sandbox verification", Rows: rows, + Findings: result.Findings, NextActions: result.NextActions, + }, true +} + +func stateForProof(state string) string { + if state == "pass" || state == "verified" { + return "✓" + } + return "✗" +} + +func proofLabel(id string) string { + switch id { + case "snap.provider-status": + return "Provider status proof" + case "snap.merchant-callback": + return "Merchant callback proof" + default: + return "Proof " + id + } +} + type webhookTestPresentationData struct { Executed bool `json:"executed"` SettlementApplied bool `json:"settlement_applied"` diff --git a/internal/readiness/report.go b/internal/readiness/report.go index 44e4070..b140ca5 100644 --- a/internal/readiness/report.go +++ b/internal/readiness/report.go @@ -54,6 +54,7 @@ type Input struct { Packs []contracts.PackVersion Findings []contracts.Finding ServerKeyPresent bool + ServerKeyInvalid bool ClientKeyPresent bool LocalReachable Reachability } @@ -84,8 +85,8 @@ func Build(input Input) Report { checkoutCheck(input.Manifest), webhookCheck(input.Manifest), localStatusCheck(input.Manifest, manifestFindings), - credentialCheck("client-key", "Client key", input.Manifest.Credentials.References["client_key"], input.ClientKeyPresent), - credentialCheck("server-key", "Server key", input.Manifest.Credentials.References["server_key"], input.ServerKeyPresent), + credentialCheck("client-key", "Client key", input.Manifest.Credentials.References["client_key"], input.ClientKeyPresent, false), + credentialCheck("server-key", "Server key", input.Manifest.Credentials.References["server_key"], input.ServerKeyPresent, input.ServerKeyInvalid), localAppCheck(input.LocalReachable), } for index, finding := range sortedFindings(input.Findings) { @@ -183,10 +184,13 @@ func localStatusCheck(value manifest.Manifest, findings []contracts.Finding) Che return Check{ID: "local-status", Label: "Local status", State: Ready, Detail: "loopback base URL and status route are configured"} } -func credentialCheck(id, label, reference string, present bool) Check { +func credentialCheck(id, label, reference string, present, invalid bool) Check { if !validEnvironmentReference(reference) { return Check{ID: id, Label: label, State: Failed, Detail: "credential environment reference is missing or invalid"} } + if invalid { + return Check{ID: id, Label: label, State: Failed, Detail: "configured credential is not valid for Sandbox"} + } if !present { return Check{ID: id, Label: label, State: NeedsAction, Detail: reference + " is not set"} } diff --git a/internal/readiness/report_test.go b/internal/readiness/report_test.go index 51d46b4..b1c71a8 100644 --- a/internal/readiness/report_test.go +++ b/internal/readiness/report_test.go @@ -101,6 +101,8 @@ func TestBuildSortsPackFindingsAfterCoreChecks(t *testing.T) { if report.Status() != contracts.StatusFail { t.Fatalf("status = %s, want fail", report.Status()) } + assertCheck(t, report, "pack-finding-1", readiness.Failed) + assertCheck(t, report, "pack-finding-2", readiness.Warning) encoded, err := json.Marshal(report) if err != nil { t.Fatal(err) diff --git a/test/e2e/security_test.go b/test/e2e/security_test.go index daa252c..d2fcbfc 100644 --- a/test/e2e/security_test.go +++ b/test/e2e/security_test.go @@ -96,10 +96,10 @@ func TestSecurityAdversarialRepositoriesRemainPublicSafe(t *testing.T) { nil, "manifest", "validate", "--project-dir", project, ) - if exit != 1 || + if exit != 6 || result.Status != contracts.StatusError || len(result.Findings) != 1 || - result.Findings[0].Code != "USAGE_INVALID" { + result.Findings[0].Code != "PROJECT_MANIFEST_INVALID" { t.Fatalf("exit = %d, result = %#v", exit, result) } assertSecurityCanariesAbsent(t, project, stdout, stderr) @@ -126,10 +126,10 @@ func TestSecurityAdversarialRepositoriesRemainPublicSafe(t *testing.T) { nil, "manifest", "validate", "--project-dir", project, ) - if exit != 1 || + if exit != 6 || result.Status != contracts.StatusError || len(result.Findings) != 1 || - result.Findings[0].Code != "USAGE_INVALID" { + result.Findings[0].Code != "PROJECT_MANIFEST_INVALID" { t.Fatalf("exit = %d, result = %#v", exit, result) } assertSecurityCanariesAbsent(t, project, stdout, stderr) From 32ce39ebcaa08d7d5306ea55f578c73aca42e4bf Mon Sep 17 00:00:00 2001 From: "m.salis" Date: Sun, 26 Jul 2026 11:17:57 +0700 Subject: [PATCH 20/73] fix: advertise evidence schema in CLI handshake --- internal/app/app_test.go | 18 ++++++++++++++++++ internal/app/commands_capabilities.go | 2 ++ internal/contracts/result.go | 1 + schemas/result-v1.schema.json | 1 + 4 files changed, 22 insertions(+) diff --git a/internal/app/app_test.go b/internal/app/app_test.go index 18d76ab..e545a28 100644 --- a/internal/app/app_test.go +++ b/internal/app/app_test.go @@ -437,6 +437,24 @@ func TestCapabilitiesJSONMatchesPublishedContract(t *testing.T) { if exit != 0 { t.Fatalf("exit = %d, result = %#v", exit, result) } + if result.SchemaVersion != published.ResultSchema || + result.ManifestVersion != published.ManifestSchema { + t.Fatalf("runtime schema versions = %#v, published = %#v", result, published) + } + + stdout, stderr, rawExit := executeRaw(t, "capabilities", "--json", "--non-interactive") + if rawExit != 0 || stderr != "" { + t.Fatalf("raw exit = %d, stderr = %q", rawExit, stderr) + } + var handshake struct { + EvidenceSchema string `json:"evidence_schema"` + } + if err := json.Unmarshal([]byte(stdout), &handshake); err != nil { + t.Fatal(err) + } + if handshake.EvidenceSchema != published.EvidenceSchema { + t.Fatalf("runtime evidence schema = %q, published = %q", handshake.EvidenceSchema, published.EvidenceSchema) + } runtimeCapabilities := make(map[string]int, len(result.Capabilities)) for _, capability := range result.Capabilities { runtimeCapabilities[capability.ID]++ diff --git a/internal/app/commands_capabilities.go b/internal/app/commands_capabilities.go index baba985..2568360 100644 --- a/internal/app/commands_capabilities.go +++ b/internal/app/commands_capabilities.go @@ -3,6 +3,7 @@ package app import ( "github.com/spf13/cobra" "github.com/veritrans/midtrans-cli/internal/contracts" + "github.com/veritrans/midtrans-cli/internal/evidence" ) func newCapabilitiesCommand(flags *globalFlags, deps Dependencies) *cobra.Command { @@ -13,6 +14,7 @@ func newCapabilitiesCommand(flags *globalFlags, deps Dependencies) *cobra.Comman result := contracts.NewResult("capabilities", contracts.StatusPass) result.CLIVersion = deps.Version.Version result.ManifestVersion = 1 + result.EvidenceSchema = evidence.SchemaVersion result.Packs = deps.Packs.Versions() result.Capabilities = deps.Packs.Capabilities() result.Journeys = deps.Packs.Journeys() diff --git a/internal/contracts/result.go b/internal/contracts/result.go index 1e86538..6dd4fc6 100644 --- a/internal/contracts/result.go +++ b/internal/contracts/result.go @@ -43,6 +43,7 @@ type Result struct { Status Status `json:"status"` CLIVersion string `json:"cli_version"` ManifestVersion int `json:"manifest_version,omitempty"` + EvidenceSchema string `json:"evidence_schema,omitempty"` Packs []PackVersion `json:"packs,omitempty"` Capabilities []Capability `json:"capabilities,omitempty"` Journeys []string `json:"journeys,omitempty"` diff --git a/schemas/result-v1.schema.json b/schemas/result-v1.schema.json index 23eccfa..237ff78 100644 --- a/schemas/result-v1.schema.json +++ b/schemas/result-v1.schema.json @@ -11,6 +11,7 @@ "status": {"enum": ["pass", "warn", "fail", "blocked", "error"]}, "cli_version": {"type": "string"}, "manifest_version": {"type": "integer"}, + "evidence_schema": {"type": "string"}, "packs": { "type": "array", "items": { From d8614ac288acb6db4189b5a64a0573c69a9fef92 Mon Sep 17 00:00:00 2001 From: "m.salis" Date: Sun, 26 Jul 2026 20:58:10 +0700 Subject: [PATCH 21/73] docs: design multi-product CLI parity --- .../2026-07-26-multi-product-parity-design.md | 453 ++++++++++++++++++ 1 file changed, 453 insertions(+) create mode 100644 docs/superpowers/specs/2026-07-26-multi-product-parity-design.md diff --git a/docs/superpowers/specs/2026-07-26-multi-product-parity-design.md b/docs/superpowers/specs/2026-07-26-multi-product-parity-design.md new file mode 100644 index 0000000..6138731 --- /dev/null +++ b/docs/superpowers/specs/2026-07-26-multi-product-parity-design.md @@ -0,0 +1,453 @@ +# Midtrans CLI Multi-Product Parity Design + +**Date:** 2026-07-26 +**Status:** Approved in conversation; awaiting written-spec review +**Audience:** Midtrans merchants and AI coding agents working in merchant repositories + +## 1. Goal + +Expand Midtrans CLI from its pre-launch Snap-only implementation into a +merchant-facing execution layer for all payment-acceptance paths described by +Midtrans' AI integration guidance: + +1. Snap hosted checkout for web. +2. Snap WebView and deeplink return handling for mobile. +3. BI-SNAP for merchant-owned QRIS, virtual-account, and direct-debit flows. +4. GoPay tokenization and GoPayLater. +5. Core API for custom card, 3DS, saved-card, installment, and OTC flows. +6. Payment Link for API-created or dashboard-created payment links. + +The CLI must support hybrid merchant projects that use more than one Midtrans +product. It must help an AI coding agent reach a verified Sandbox journey while +remaining useful and understandable when used directly by a merchant. + +The initial public contract is clean-slate. The existing experimental Snap-only +manifest and machine contracts do not need backward compatibility. + +## 2. Product boundary + +### 2.1 Sandbox + +The CLI may plan, execute, resume, reconcile, and verify allowlisted Sandbox +operations. It may resolve Sandbox credential references without displaying or +persisting their values. + +### 2.2 Production + +Production support is read-only: + +- Configuration and secret-reference checks. +- Go-live readiness validation. +- Documentation and dashboard prerequisites. +- Callback, network, and observability checks that do not create or mutate a + production payment resource. + +The CLI must not create, mutate, refund, cancel, bind, unbind, or charge +production resources. + +### 2.3 Non-goals + +- Midtrans employee-only operational tooling. +- Production payment execution. +- Dynamic executable plugins. +- Storing merchant secrets. +- Acting as a coding agent or editing the merchant repository itself. +- Embedding a general-purpose browser automation runtime. +- Recording a merchant application's framework or programming language in the + project manifest. + +## 3. Product-family pack model + +The CLI uses compiled product-family packs rather than payment-method packs or +one universal payment implementation. + +| Pack | Responsibilities | +|---|---| +| `common` | Manifest, inspection, policies, operation state, shared notification properties, reconciliation, evidence, and capability discovery | +| `snap` | Web redirect, popup, embed, mobile WebView, and deeplink-return profiles | +| `core-api` | Custom card, 3DS, saved-card/one-click, installments, and Alfamart/Indomaret OTC | +| `payment-link` | One-time and reusable links created through API or represented from dashboard setup | +| `bisnap` | Access-token signing, transaction signing, notification verification, QRIS, virtual account, and direct debit | +| `gopay-tokenization` | Account linking, Binding Inquiry, tokenized GoPay payment, GoPayLater, and unlinking | + +Payment methods are configuration within a product pack because one payment +method may be offered through products with different authentication, request, +notification, and status contracts. + +Mobile Snap is a profile and journey set within the `snap` pack. It is not a +separate protocol pack. + +### 3.1 Hybrid projects + +A project may enable multiple packs. Each integration declares its own +configuration and credential set. Intent routing selects a default product when +more than one enabled pack can satisfy the same merchant intent. + +Every journey is planned and evidenced independently. Project verification +aggregates journey results without weakening the proof required by any pack. + +## 4. CLI and Agent Skill responsibilities + +### 4.1 Midtrans Agent Skills + +The Agent Skill owns: + +- Merchant-readiness discovery and product recommendation. +- Repository and application reasoning. +- Current public documentation routing. +- Implementation guidance and code changes. +- Interpretation of CLI findings and iteration on merchant code. +- Orchestration of deterministic CLI capabilities. + +### 4.2 Midtrans CLI + +The CLI owns: + +- Commit-safe configuration and project discovery. +- Deterministic repository inspection facts. +- Product-pack requirements and validation. +- Credential-safe Sandbox execution. +- Exact request signing and notification verification. +- Allowlisted endpoints and network policy. +- Resumable operations and status reconciliation. +- Redacted, checksummed evidence. + +The Agent Skill does not receive merchant credentials and does not make payment +API calls itself. The CLI does not dynamically load prose or executable code +from the Agent Skill repository. + +The repositories integrate through stable machine contracts and a per-product +compatibility matrix. + +## 5. Command experience + +### 5.1 Merchant-facing commands + +The primary merchant workflow is intent-oriented: + +```sh +midtrans init +midtrans setup +midtrans status +midtrans test +midtrans verify +``` + +- `midtrans init` creates the clean public manifest. +- `midtrans setup` recommends and configures one or more product packs. +- `midtrans status` summarizes readiness across enabled products and prints the + next useful action. +- `midtrans test` lists or runs relevant journeys. +- `midtrans verify` aggregates required journey evidence. + +Friendly journey names include `checkout`, `qris-payment`, `card-3ds`, +`gopay-linking`, and `payment-link`. + +If one enabled product can satisfy an intent, the CLI selects it. If multiple +products can satisfy it, the CLI uses declared routing, asks interactively, or +accepts an explicit `--product`. + +### 5.2 Agent-facing commands + +The agent namespace exposes exact, stable IDs and machine-readable results: + +```sh +midtrans agent capabilities +midtrans agent check --product bisnap +midtrans agent plan --journey bisnap.qris-payment +midtrans agent run --journey bisnap.qris-payment --execute +midtrans agent resume --operation op_01... +``` + +All agent commands support JSON and non-interactive operation. Human and JSON +rendering derive from the same already-redacted result. + +## 6. Clean public manifest + +The first public schema has no compatibility obligation to the experimental +Snap-only schema. + +```yaml +schema_version: 1 + +policy: + environments: [sandbox] + production: deny + +application: + base_url: http://127.0.0.1:3000 + payment_state: + paid: [paid] + terminal: [paid, failed, cancelled, expired] + monotonic: true + +credential_sets: + classic-sandbox: + type: classic + environment: sandbox + server_key: env:MIDTRANS_SERVER_KEY + client_key: env:MIDTRANS_CLIENT_KEY + + bisnap-sandbox: + type: bisnap + environment: sandbox + client_id: env:MIDTRANS_BISNAP_CLIENT_ID + partner_id: env:MIDTRANS_BISNAP_PARTNER_ID + channel_id: env:MIDTRANS_BISNAP_CHANNEL_ID + private_key: file:./secrets/bisnap-private.pem + midtrans_public_key: file:./secrets/midtrans-public.pem + +integrations: + snap: + config_version: 1 + credentials: classic-sandbox + profiles: [web-popup] + payment_methods: [card, virtual-account, qris] + callbacks: + notification: /api/payments/midtrans/notification + finish: /checkout/complete + + bisnap: + config_version: 1 + credentials: bisnap-sandbox + payment_methods: [qris, virtual-account] + callbacks: + qris_notification: /api/payments/midtrans/qris/notify + va_notification: /api/payments/midtrans/va/notify + + gopay-tokenization: + config_version: 1 + credentials: bisnap-sandbox + capabilities: [account-linking, wallet-payment] + callbacks: + account_linking: /api/payments/midtrans/gopay/account + payment: /api/payments/midtrans/gopay/payment + return: /payments/gopay/return + +routing: + checkout: snap + qris-payment: bisnap + wallet-payment: gopay-tokenization + +verification: + required: + - snap.checkout + - bisnap.qris-payment + - gopay-tokenization.account-linking + - gopay-tokenization.wallet-payment +``` + +### 6.1 Manifest rules + +- `integrations` is the single source of truth for enabled packs. +- Credential entries are typed references such as `env:` and `file:`, never + secret values. +- Credential sets may be shared by compatible packs. +- Every credential set declares its environment. +- Each pack owns and validates its versioned configuration namespace. +- Product-specific callback routes remain separate when contracts differ. +- Routing resolves overlapping merchant intents. +- Required proof is explicit and reviewable. +- Raw transaction, customer, authentication, and payment data is prohibited. + +## 7. Pack contract + +Every pack declares: + +| Field | Requirement | +|---|---| +| Identity | Stable pack ID and semantic version | +| Compatibility | Supported CLI core, manifest, result, and evidence contracts | +| Capabilities | Stable machine-readable capability IDs | +| Configuration | Typed pack configuration and credential-set requirements | +| Inspection | Repository facts used by deterministic checks | +| Requirements | Findings, severity, and next actions | +| Journeys | Named workflows, stages, and preconditions | +| Sandbox targets | Exact hosts, paths, redirects, and methods | +| Authentication | Credential fields and signing families | +| Fixtures | Sanitized inputs, events, and expected results | +| Interaction | Browser, device, or buyer actions that may pause a journey | +| Reconciliation | Status recovery and retry behavior | +| Redaction | Pack-specific sensitive-field registration | +| Evidence | Proof required for each successful journey | +| Provenance | Public documentation sources and rules derived from them | + +The core owns lifecycle orchestration; packs supply product-specific stages. +Authentication and notification contracts must not be shared merely because +two products offer the same payment method. + +## 8. Resumable journey model + +All packs use the same lifecycle: + +```text +preflight -> plan -> approved -> execute -> interact -> reconcile -> verify -> evidence +``` + +Terminal results are `passed`, `failed`, or `blocked`. A journey can pause in +`awaiting_user_action` without losing its operation identity. + +### 8.1 Interactive terminal + +When a hosted Sandbox action is required, the CLI may open the action URL in the +merchant's default browser and wait for completion. + +### 8.2 Agent mode + +The CLI returns an `awaiting_user_action` result containing: + +- Operation ID. +- Redacted action URL when safe. +- Action type and concise instructions. +- Expiration. +- Resume command. + +An AI agent may complete the action using its browser or device capability. +The same operation is resumed afterward. + +### 8.3 Proof boundary + +A successful redirect, browser page, or API creation response is not payment +proof. Completion requires the pack's declared combination of: + +- Verified notification receipt. +- Provider status reconciliation. +- Expected merchant application persistence. +- Duplicate handling. +- Out-of-order event handling where applicable. + +## 9. Evidence + +Every journey emits an independent evidence bundle containing: + +- Manifest hash and repository revision/hash. +- CLI core and pack versions. +- Contract and schema versions. +- Operation and journey IDs. +- Sanitized request and response facts. +- Interaction completion facts. +- Callback verification. +- Duplicate and ordering checks. +- Provider status reconciliation. +- Merchant application persistence result. +- Missing or externally blocked proof. +- Redaction categories and checksums. + +`midtrans verify` aggregates bundles for required journeys. It must never turn +partial or local-only proof into an end-to-end pass. + +## 10. Per-pack compatibility + +The Agent Skill compatibility contract is a product matrix rather than one +global phase: + +```json +{ + "contract_version": 1, + "products": { + "snap": { + "required_capabilities": [ + "snap.plan.v1", + "snap.checkout.verify.v1" + ], + "required_journeys": ["snap.checkout"] + }, + "bisnap": { + "required_capabilities": [ + "bisnap.signing.verify.v1", + "bisnap.qris.verify.v1", + "bisnap.virtual-account.verify.v1" + ], + "required_journeys": [ + "bisnap.qris-payment", + "bisnap.virtual-account" + ] + } + } +} +``` + +- Compatibility is negotiated independently for each enabled pack. +- Partial CLI availability is explicit. +- Missing support returns `capability_unavailable`. +- The Skill may continue with guidance-only behavior for an unavailable pack, + but it must not claim CLI execution or proof. +- A deterministic execution feature requires both an advertised CLI capability + and a matching Agent Skill compatibility entry. +- Release checks validate every advertised capability and journey pair. + +## 11. Error and recovery semantics + +Failures use stable codes and actionable next steps. The core distinguishes: + +- Invalid or incomplete configuration. +- Missing credentials or merchant activation. +- Unsafe target or policy denial. +- Capability unavailable. +- Awaiting browser, buyer, or device interaction. +- Ambiguous mutation requiring reconciliation. +- Provider rejection. +- Callback verification failure. +- Merchant application persistence failure. +- Missing evidence. + +Mutating Sandbox operations receive stable operation IDs and idempotency values. +An ambiguous network result is reconciled before retry. The CLI does not issue +an unqualified repeat mutation. + +Secrets are redacted before persistence, logging, and rendering. Redirects and +merchant callback targets are checked at every hop against policy. + +## 12. Phased delivery + +The implementation is phased internally while delivered as one coordinated +initiative: + +| Release | Scope | +|---|---| +| `v0.1` | Clean foundation, generic journey engine, new manifest, common pack, Snap web and mobile profiles | +| `v0.2` | Core API and Payment Link | +| `v0.3` | BI-SNAP protocol foundation, QRIS, virtual account, and direct debit | +| `v0.4` | GoPay account linking, tokenized payment, GoPayLater, and unlinking | +| `v0.5` | Refund, subscription, merchant-driven recurring, and lifecycle parity | +| `v1.0` | Hybrid-project hardening, full Agent Skill contract, signed distribution, security review, and public documentation | + +Every phase updates the CLI pack and Agent Skill compatibility matrix together +and advertises only implemented behavior. + +## 13. Verification and release gates + +Each advertised capability must have: + +- Unit tests for rules, signing, redaction, and error contracts. +- Pack conformance tests. +- Deterministic local fixtures. +- CLI integration tests for human and JSON output. +- Safety tests proving production mutation is denied. +- Compatibility tests against the Agent Skill matrix. +- A representative merchant-repository journey. +- Real Sandbox proof when credentials, activation, and required user interaction + are available. + +External prerequisites may produce an explicit blocked result. They must not be +reported as implementation success or silently bypassed. + +The public `v1.0` gate requires: + +- All six AI integration paths advertised and verified at their declared proof + level. +- Hybrid projects work without authentication or callback contract mixing. +- Result, manifest, operation, and evidence schemas validate. +- The global no-sudo installation path works. +- Signed release and installer verification pass. +- No production mutation path exists. +- No credential or customer-data leakage is found. + +## 14. Source + +Primary public product-routing source: + +- https://docs.midtrans.com/docs/building-on-midtrans-with-ai + +Product packs must additionally declare the exact current public documentation +pages used for their request, signature, callback, status, and Sandbox rules. From 6bd258320cd08f30b6a7a5ee6eebca226412d2fb Mon Sep 17 00:00:00 2001 From: "m.salis" Date: Sun, 26 Jul 2026 21:06:28 +0700 Subject: [PATCH 22/73] docs: assign subscription parity ownership --- .../specs/2026-07-26-multi-product-parity-design.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/docs/superpowers/specs/2026-07-26-multi-product-parity-design.md b/docs/superpowers/specs/2026-07-26-multi-product-parity-design.md index 6138731..d53c83c 100644 --- a/docs/superpowers/specs/2026-07-26-multi-product-parity-design.md +++ b/docs/superpowers/specs/2026-07-26-multi-product-parity-design.md @@ -69,6 +69,7 @@ one universal payment implementation. | `payment-link` | One-time and reusable links created through API or represented from dashboard setup | | `bisnap` | Access-token signing, transaction signing, notification verification, QRIS, virtual account, and direct debit | | `gopay-tokenization` | Account linking, Binding Inquiry, tokenized GoPay payment, GoPayLater, and unlinking | +| `subscription` | Midtrans-managed Subscription API schedules, state, and recurring-notification verification | Payment methods are configuration within a product pack because one payment method may be offered through products with different authentication, request, @@ -77,6 +78,11 @@ notification, and status contracts. Mobile Snap is a profile and journey set within the `snap` pack. It is not a separate protocol pack. +Merchant-driven recurring charges remain journeys of the product that performs +the charge (`core-api`, `bisnap`, or `gopay-tokenization`). Refund journeys +likewise remain in the pack that created the original payment, so endpoint and +idempotency selection cannot drift away from the payment product. + ### 3.1 Hybrid projects A project may enable multiple packs. Each integration declares its own From ffa028394fd6c2b248ae0171cd5ce5b27cc4a225 Mon Sep 17 00:00:00 2001 From: "m.salis" Date: Sun, 26 Jul 2026 21:12:01 +0700 Subject: [PATCH 23/73] docs: plan multi-product CLI implementation --- ...07-26-midtrans-cli-multi-product-parity.md | 1340 +++++++++++++++++ .../2026-07-26-multi-product-parity-design.md | 2 +- 2 files changed, 1341 insertions(+), 1 deletion(-) create mode 100644 docs/superpowers/plans/2026-07-26-midtrans-cli-multi-product-parity.md diff --git a/docs/superpowers/plans/2026-07-26-midtrans-cli-multi-product-parity.md b/docs/superpowers/plans/2026-07-26-midtrans-cli-multi-product-parity.md new file mode 100644 index 0000000..9d8f4be --- /dev/null +++ b/docs/superpowers/plans/2026-07-26-midtrans-cli-multi-product-parity.md @@ -0,0 +1,1340 @@ +# Midtrans CLI Multi-Product Parity Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Deliver a clean-slate Midtrans merchant CLI that configures hybrid projects and plans, executes, resumes, verifies, and evidences Sandbox journeys for Snap, Core API, Payment Link, BI-SNAP, GoPay tokenization, and subscriptions. + +**Architecture:** Replace the experimental Snap-shaped manifest with one integration map and shared credential sets. Extend compiled packs with deterministic journey handlers, run those handlers through one resumable core engine, and expose intent-oriented merchant commands plus stable agent commands. Keep product reasoning in Midtrans Agent Skills and negotiate CLI support per product through a versioned compatibility matrix. + +**Tech Stack:** Go 1.26.0 with toolchain Go 1.26.5, Cobra 1.10.2, `go.yaml.in/yaml/v3` 3.0.4, Go standard-library crypto/HTTP/JSON packages, JSON Schema draft 2020-12, shell release checks, Midtrans Agent Skill JSON/Markdown assets. + +## Global Constraints + +- The first public manifest is clean-slate; do not preserve the experimental Snap-only schema shape. +- Sandbox is the only environment where the CLI may create or mutate payment resources. +- Production commands are read-only readiness checks; no production mutation path may exist. +- Credential values must never be written to manifests, operation records, evidence, logs, or rendered results. +- Manifest credential values are references with an `env:` or project-contained `file:` prefix. +- Product packs are compiled into the signed CLI; do not load executable plugins. +- Hybrid projects may enable multiple packs and must not mix their authentication, callback, request, or status contracts. +- Human and JSON output must derive from the same already-redacted result. +- A redirect or successful creation response is not payment proof; end-to-end proof requires the pack-declared callback, reconciliation, and merchant persistence facts. +- Internal Midtrans knowledge may cross-check the design, but only current public Midtrans documentation may appear in public provenance or executable product rules. +- All mutating Sandbox operations require a dry-run preview and explicit execution. +- Use TDD for every task and make one focused commit after its tests pass. +- Use at most one reviewer subagent per task; that pass combines spec compliance and code quality. + +--- + +## File and package map + +### Shared core + +- `internal/manifest/model.go` — clean public manifest model. +- `internal/manifest/validate.go` — structural, reference, routing, and policy validation. +- `internal/manifest/file.go` — strict bounded YAML load/save/init. +- `internal/secrets/reference.go` — `env:` and project-contained `file:` reference resolution. +- `internal/journey/types.go` — stable journey definitions, input, action, state, and outcome types. +- `internal/journey/engine.go` — plan/execute/resume state machine and operation persistence. +- `internal/operations/store.go` — generic operation records keyed by operation ID. +- `internal/packs/pack.go` — descriptor, configuration validation, and journey-handler contract. +- `internal/packs/registry.go` — product and journey lookup with duplicate rejection. +- `internal/app/commands_test.go` — merchant intent runner. +- `internal/app/commands_agent.go` — machine `plan`, `run`, and `resume` surface. +- `internal/app/journey_runner.go` — converts CLI flags into engine requests and outcomes into result contracts. +- `internal/evidence/model.go` — operation-stage and aggregate proof. + +### Product packs + +- `packs/snap/` — hosted web and mobile WebView/deeplink profiles. +- `packs/coreapi/` — classic Core API card, OTC, legacy VA, status, and refund journeys. +- `packs/paymentlink/` — fixed/dynamic, one-time/reusable Payment Link journeys. +- `packs/bisnap/` — BI-SNAP signing, access token, QRIS, VA, direct debit, status, notification, and refund. +- `packs/gopaytokenization/` — auth-code, binding, inquiry, tokenized payment, GoPayLater, and unbind. +- `packs/subscription/` — Subscription API schedules and recurring notifications. + +### Contracts, documentation, and integration + +- `schemas/manifest-v1.schema.json` — clean manifest schema. +- `schemas/operation-v1.schema.json` — resumable operation record schema. +- `schemas/evidence-v1.schema.json` — expanded evidence schema. +- `contracts/capabilities-v1.json` — advertised packs, capabilities, and journeys. +- `contracts/public-sources-v1.json` — current public source set. +- `docs/agent-skill-compatibility.md` — per-pack handshake explanation. +- `/Users/salis/Goto/Code/midtrans/midtrans-agent-skills-cli-integration/integrate-midtrans-payments/cli-compatibility.json` — Skill-side compatibility matrix. +- `/Users/salis/Goto/Code/midtrans/midtrans-agent-skills-cli-integration/integrate-midtrans-payments/references/midtrans-cli.md` — agent orchestration guide. + +--- + +### Task 1: Replace the experimental manifest with the clean hybrid schema + +**Files:** +- Modify: `internal/manifest/model.go` +- Modify: `internal/manifest/validate.go` +- Modify: `internal/manifest/file.go` +- Modify: `internal/manifest/manifest_test.go` +- Modify: `schemas/manifest-v1.schema.json` +- Modify: `internal/app/commands_setup.go` +- Modify: `internal/app/app_test.go` +- Modify: `README.md` + +**Interfaces:** +- Produces: `manifest.Manifest`, `manifest.CredentialSet`, `manifest.Integration`, `manifest.Validate(Manifest) []contracts.Finding`, and `manifest.IntegrationFor(string) (Integration, bool)`. +- Consumes: existing safe-path and strict bounded YAML helpers. + +- [ ] **Step 1: Write failing clean-schema tests** + +Add table tests that load the approved YAML shape and reject raw credentials, +production enablement, missing credential sets, unknown routing targets, unknown +top-level fields, duplicate YAML keys, unsafe `file:` references, and a required +journey whose product is disabled. + +```go +func TestLoadHybridManifest(t *testing.T) { + project := writeManifest(t, ` +schema_version: 1 +policy: {environments: [sandbox], production: deny} +application: + base_url: http://127.0.0.1:3000 + payment_state: {paid: [paid], terminal: [paid, failed], monotonic: true} +credential_sets: + classic: + type: classic + environment: sandbox + server_key: env:MIDTRANS_SERVER_KEY + client_key: env:MIDTRANS_CLIENT_KEY +integrations: + snap: + config_version: 1 + credentials: classic + profiles: [web-popup] + payment_methods: [card] + callbacks: {notification: /api/midtrans/notify} +routing: {checkout: snap} +verification: {required: [snap.checkout]} +`) + got, err := manifest.Load(project) + if err != nil { + t.Fatal(err) + } + if got.Routing["checkout"] != "snap" || got.Integrations["snap"].Credentials != "classic" { + t.Fatalf("manifest = %#v", got) + } +} +``` + +- [ ] **Step 2: Run the focused tests and verify RED** + +Run: + +```sh +go test ./internal/manifest ./internal/app -run 'TestLoadHybridManifest|TestCleanManifest' -count=1 +``` + +Expected: failures because the current model requires `environment_policy`, +`products`, `integration`, `state_policy`, and `credentials`. + +- [ ] **Step 3: Implement the clean model** + +Use these exact public types: + +```go +type Manifest struct { + SchemaVersion int `yaml:"schema_version" json:"schema_version"` + Policy Policy `yaml:"policy" json:"policy"` + Application Application `yaml:"application" json:"application"` + CredentialSets map[string]CredentialSet `yaml:"credential_sets" json:"credential_sets"` + Integrations map[string]Integration `yaml:"integrations" json:"integrations"` + Routing map[string]string `yaml:"routing" json:"routing"` + Verification Verification `yaml:"verification" json:"verification"` +} + +type Policy struct { + Environments []string `yaml:"environments" json:"environments"` + Production string `yaml:"production" json:"production"` +} + +type Application struct { + BaseURL string `yaml:"base_url" json:"base_url"` + PaymentState PaymentState `yaml:"payment_state" json:"payment_state"` +} + +type PaymentState struct { + Paid []string `yaml:"paid" json:"paid"` + Terminal []string `yaml:"terminal" json:"terminal"` + Monotonic bool `yaml:"monotonic" json:"monotonic"` +} + +type CredentialSet struct { + Type string `yaml:"type" json:"type"` + Environment string `yaml:"environment" json:"environment"` + ServerKey string `yaml:"server_key,omitempty" json:"server_key,omitempty"` + ClientKey string `yaml:"client_key,omitempty" json:"client_key,omitempty"` + ClientID string `yaml:"client_id,omitempty" json:"client_id,omitempty"` + ClientSecret string `yaml:"client_secret,omitempty" json:"client_secret,omitempty"` + PartnerID string `yaml:"partner_id,omitempty" json:"partner_id,omitempty"` + ChannelID string `yaml:"channel_id,omitempty" json:"channel_id,omitempty"` + PrivateKey string `yaml:"private_key,omitempty" json:"private_key,omitempty"` + MidtransPublicKey string `yaml:"midtrans_public_key,omitempty" json:"midtrans_public_key,omitempty"` +} + +type Integration struct { + ConfigVersion int `yaml:"config_version" json:"config_version"` + Credentials string `yaml:"credentials" json:"credentials"` + Profiles []string `yaml:"profiles,omitempty" json:"profiles,omitempty"` + PaymentMethods []string `yaml:"payment_methods,omitempty" json:"payment_methods,omitempty"` + Capabilities []string `yaml:"capabilities,omitempty" json:"capabilities,omitempty"` + Callbacks map[string]string `yaml:"callbacks,omitempty" json:"callbacks,omitempty"` +} + +type Verification struct { + Required []string `yaml:"required" json:"required"` +} +``` + +- [ ] **Step 4: Implement structural validation and strict loading** + +Require `schema_version: 1`, `policy.environments: [sandbox]`, +`policy.production: deny`, loopback `application.base_url`, monotonic state, +unique non-empty states, existing credential-set references, known +`env:[A-Z][A-Z0-9_]*` or `file:./...` credential references, enabled routing +targets, and journey prefixes that match enabled integrations or `common`. +Continue to reject aliases, excessive nesting, duplicate keys, multiple YAML +documents, unknown fields, and oversized manifests. + +- [ ] **Step 5: Replace the JSON schema and setup serialization** + +Make the JSON schema match the Go model exactly with +`additionalProperties: false` on fixed objects. Update `midtrans init` to create +a neutral manifest with empty `credential_sets`, `integrations`, `routing`, and +`verification.required`; `midtrans setup` is the command that adds products. + +- [ ] **Step 6: Run manifest and application tests** + +Run: + +```sh +go test ./internal/manifest ./internal/app -count=1 +go test ./... -count=1 +``` + +Expected: all packages pass with tests and fixtures updated to the clean schema. + +- [ ] **Step 7: Commit** + +```sh +git add internal/manifest schemas/manifest-v1.schema.json internal/app README.md +git commit -m "feat: introduce hybrid Midtrans manifest" +``` + +--- + +### Task 2: Add safe credential-reference resolution + +**Files:** +- Create: `internal/secrets/reference.go` +- Create: `internal/secrets/reference_test.go` +- Modify: `internal/secrets/provider.go` +- Modify: `internal/evidence/redact.go` +- Modify: `internal/app/app.go` + +**Interfaces:** +- Consumes: `manifest.CredentialSet` from Task 1 and `safepath.Existing`. +- Produces: `secrets.ReferenceResolver.Resolve(context.Context, projectDir, reference string) ([]byte, error)` and stable errors `CREDENTIAL_REFERENCE_INVALID`, `CREDENTIAL_NOT_FOUND`, and `CREDENTIAL_FILE_UNSAFE`. + +- [ ] **Step 1: Write failing resolver tests** + +```go +func TestReferenceResolverReadsEnvironmentAndContainedFile(t *testing.T) { + project := t.TempDir() + writePrivateFile(t, project, "secrets/private.pem", []byte("pem")) + resolver := secrets.ReferenceResolver{ + Getenv: func(key string) (string, bool) { return map[string]string{"MIDTRANS_KEY": "value"}[key], key == "MIDTRANS_KEY" }, + } + env, err := resolver.Resolve(context.Background(), project, "env:MIDTRANS_KEY") + if err != nil || string(env) != "value" { + t.Fatalf("env = %q, err = %v", env, err) + } + file, err := resolver.Resolve(context.Background(), project, "file:./secrets/private.pem") + if err != nil || string(file) != "pem" { + t.Fatalf("file = %q, err = %v", file, err) + } +} +``` + +Also assert rejection of absolute paths, traversal, symlinks leaving the +project, group/world-readable key files, empty environment variables, values +larger than 64 KiB, and cancellation. + +- [ ] **Step 2: Run focused tests and verify RED** + +```sh +go test ./internal/secrets -run TestReferenceResolver -count=1 +``` + +Expected: compile failure because `ReferenceResolver` does not exist. + +- [ ] **Step 3: Implement the resolver** + +`env:` uses the injected environment lookup. `file:` requires a relative +`./` path, resolves it inside the project, requires a regular file, refuses +permissions broader than `0600`, and reads at most 64 KiB. Return bytes only to +the caller; never cache or stringify them in a result. + +- [ ] **Step 4: Register reference and token field redactions** + +Add `client_secret`, `private_key`, `midtrans_public_key`, +`authorization_customer`, `customer_authorization_token`, +`payment_option_token`, `auth_code`, and `saved_token_id` to core redaction. +Keep reference strings visible, but redact any resolved value. + +- [ ] **Step 5: Inject the resolver into application dependencies** + +Add: + +```go +ResolveCredential func(context.Context, string, string) ([]byte, error) +``` + +to `app.Dependencies`, defaulting to `secrets.ReferenceResolver` constructed +from `deps.Getenv`. + +- [ ] **Step 6: Run tests and commit** + +```sh +go test ./internal/secrets ./internal/evidence ./internal/app -count=1 +go test ./... -count=1 +git add internal/secrets internal/evidence/redact.go internal/app/app.go +git commit -m "feat: resolve typed credential references safely" +``` + +--- + +### Task 3: Build the generic resumable journey engine + +**Files:** +- Create: `internal/journey/types.go` +- Create: `internal/journey/engine.go` +- Create: `internal/journey/engine_test.go` +- Modify: `internal/operations/store.go` +- Modify: `internal/operations/store_test.go` +- Create: `schemas/operation-v1.schema.json` +- Modify: `internal/evidence/model.go` +- Modify: `internal/evidence/evidence_test.go` + +**Interfaces:** +- Consumes: credential resolver and manifest from Tasks 1–2. +- Produces: + +```go +type Handler interface { + Definition() Definition + Plan(context.Context, Request, Runtime) Outcome + Execute(context.Context, Request, Runtime) Outcome + Resume(context.Context, Request, Runtime, operations.Record) Outcome +} + +type Engine struct { + Store operations.Store + Runtime Runtime +} + +func (Engine) Run(context.Context, Handler, Request, bool) Outcome +func (Engine) Resume(context.Context, Handler, string, Request) Outcome +``` + +- [ ] **Step 1: Write failing lifecycle tests** + +Use a fake handler to assert: + +- A non-executing run ends in `planned` without calling `Execute`. +- Execute cannot run unless the plan is valid. +- `awaiting_user_action` is persisted under the operation ID. +- Resume rejects a different manifest hash or journey. +- `passed`, `failed`, and `blocked` are terminal. +- An ambiguous result becomes `reconciling`, not an automatic second mutation. + +```go +func TestEnginePersistsAwaitingActionAndResumesSameOperation(t *testing.T) { + handler := &fakeHandler{execute: journey.Outcome{ + State: journey.AwaitingUserAction, + Action: &journey.Action{Type: "browser", ResumeCommand: "midtrans agent resume --operation op_test"}, + }} + engine := testEngine(t) + first := engine.Run(context.Background(), handler, testRequest("op_test"), true) + second := engine.Resume(context.Background(), handler, "op_test", testRequest("op_test")) + if first.State != journey.AwaitingUserAction || second.OperationID != first.OperationID { + t.Fatalf("first = %#v, second = %#v", first, second) + } +} +``` + +- [ ] **Step 2: Run focused tests and verify RED** + +```sh +go test ./internal/journey ./internal/operations -count=1 +``` + +Expected: `internal/journey` is absent and the old store is order-specific. + +- [ ] **Step 3: Define journey contracts** + +Use these stable types: + +```go +type State string + +const ( + Planned State = "planned" + AwaitingUserAction State = "awaiting_user_action" + Reconciling State = "reconciling" + Passed State = "passed" + Failed State = "failed" + Blocked State = "blocked" +) + +type Definition struct { + ID string `json:"id"` + Product string `json:"product"` + Intent string `json:"intent"` + RequiredInputs []string `json:"required_inputs"` + Interaction string `json:"interaction,omitempty"` +} + +type Input struct { + OrderID string `json:"order_id,omitempty"` + Amount int64 `json:"amount,omitempty"` + Method string `json:"method,omitempty"` + CustomerReference string `json:"customer_reference,omitempty"` + PaymentTokenReference string `json:"payment_token_reference,omitempty"` + Reusable bool `json:"reusable,omitempty"` +} + +type Request struct { + OperationID string + ProjectDir string + ManifestHash string + Manifest manifest.Manifest + Input Input +} + +type Action struct { + Type string `json:"type"` + URL string `json:"url,omitempty"` + Instructions string `json:"instructions"` + ExpiresAt time.Time `json:"expires_at,omitempty"` + ResumeCommand string `json:"resume_command"` +} + +type Outcome struct { + OperationID string + State State + SafeData map[string]any + Action *Action + Proofs []evidence.Proof + MissingEvidence []string + Finding *contracts.Finding +} +``` + +`Runtime` contains injected HTTP, credential resolution, clock, operation-ID +generation, and browser opening functions. + +- [ ] **Step 4: Generalize operation records** + +Replace the order-specific record with: + +```go +type Record struct { + SchemaVersion int `json:"schema_version"` + OperationID string `json:"operation_id"` + JourneyID string `json:"journey_id"` + PackID string `json:"pack_id"` + ManifestHash string `json:"manifest_hash"` + State string `json:"state"` + SafeReferences map[string]string `json:"safe_references"` + StartedAt time.Time `json:"started_at"` + UpdatedAt time.Time `json:"updated_at"` +} +``` + +Key files by a SHA-256 of the validated `op_` operation ID. Keep atomic +reserve/save, `0700` directory, `0600` files, bounded decoding, unknown-field +rejection, and symlink protection. + +- [ ] **Step 5: Implement transition enforcement and evidence stages** + +The engine is the only component allowed to persist state. It copies only +handler-provided safe references after checking their keys against the sensitive +key registry. Expand evidence proofs with `operation_id`, `stage`, +`observed_at`, and `source`. + +- [ ] **Step 6: Run tests and commit** + +```sh +go test ./internal/journey ./internal/operations ./internal/evidence -count=1 +go test ./... -count=1 +git add internal/journey internal/operations internal/evidence schemas/operation-v1.schema.json +git commit -m "feat: add resumable payment journey engine" +``` + +--- + +### Task 4: Extend packs and expose generic merchant and agent commands + +**Files:** +- Modify: `internal/packs/pack.go` +- Modify: `internal/packs/registry.go` +- Modify: `internal/packs/registry_test.go` +- Create: `internal/app/journey_runner.go` +- Modify: `internal/app/commands_agent.go` +- Replace: `internal/app/commands_checkout.go` +- Modify: `internal/app/app.go` +- Modify: `internal/app/app_test.go` +- Modify: `internal/presentation/model.go` +- Modify: `internal/presentation/model_test.go` + +**Interfaces:** +- Consumes: `journey.Handler` and engine from Task 3. +- Produces: `Registry.Handler(journeyID string) (journey.Handler, bool)`, + `Registry.ForIntent(intent, product string) ([]journey.Handler, error)`, and + commands `midtrans test [intent]`, `midtrans agent plan`, `run`, and `resume`. + +- [ ] **Step 1: Write failing registry and command tests** + +Assert duplicate journey IDs are rejected, intent routing selects one pack, +ambiguous intent returns `JOURNEY_AMBIGUOUS`, missing support returns +`CAPABILITY_UNAVAILABLE`, and these invocations use one result contract: + +```sh +midtrans test +midtrans test checkout --amount 10000 +midtrans test checkout --product snap --amount 10000 --execute +midtrans agent plan --journey snap.checkout --amount 10000 --json --non-interactive +midtrans agent run --journey snap.checkout --amount 10000 --execute --json --non-interactive +midtrans agent resume --operation op_test --json --non-interactive +``` + +- [ ] **Step 2: Run focused tests and verify RED** + +```sh +go test ./internal/packs ./internal/app -run 'TestRegistryJourney|TestGenericJourneyCommands' -count=1 +``` + +- [ ] **Step 3: Extend the pack interface** + +```go +type Pack interface { + Descriptor() Descriptor + Evaluate(manifest.Manifest, inspection.Report) []contracts.Finding + Handlers() []journey.Handler +} +``` + +Index handlers by ID and intents by product. Validate each handler definition +belongs to the declaring pack. + +- [ ] **Step 4: Implement the merchant command** + +`midtrans test` with no intent lists enabled journeys and next actions. With an +intent, resolve `routing[intent]`, then an explicit `--product`, then a unique +candidate. Use common flags `--amount`, `--order-id`, `--method`, +`--customer-reference`, `--payment-token-reference`, `--reusable`, and +`--execute`. Interactive execution prints the plan and requires exact `yes`. + +- [ ] **Step 5: Implement agent plan/run/resume** + +Agent commands always use exact journey IDs. `plan` cannot mutate. `run` +requires `--execute` to mutate. `resume` loads the existing operation and +dispatches to its recorded handler. JSON includes `operation_id`, `state`, +`action`, `proofs`, and `missing_evidence`. + +- [ ] **Step 6: Replace Snap-specific presentation** + +Render product, journey, state, next action, and proof summary generically. +Never reduce a successful result to `PASS: credentials.status`. + +- [ ] **Step 7: Run tests and commit** + +```sh +go test ./internal/packs ./internal/app ./internal/presentation -count=1 +go test ./... -count=1 +git add internal/packs internal/app internal/presentation +git commit -m "feat: expose generic merchant payment journeys" +``` + +--- + +### Task 5: Retrofit full Snap web and mobile parity + +**Files:** +- Modify: `packs/snap/pack.go` +- Modify: `packs/snap/journey.go` +- Modify: `packs/snap/journey_test.go` +- Create: `packs/snap/mobile.go` +- Create: `packs/snap/mobile_test.go` +- Modify: `packs/snap/client.go` +- Modify: `packs/snap/client_test.go` +- Modify: `testdata/snap/*` +- Modify: `contracts/capabilities-v1.json` +- Modify: `contracts/public-sources-v1.json` + +**Interfaces:** +- Consumes: journey and manifest contracts from Tasks 1–4. +- Produces handlers `snap.checkout` and `snap.mobile-webview` with capabilities + `snap.plan.v1`, `snap.checkout.verify.v1`, `snap.webhook.verify.v1`, and + `snap.mobile.verify.v1`. + +- [ ] **Step 1: Write failing handler and mobile-profile tests** + +Verify redirect, popup, embed, and mobile-webview profiles; reject a mobile +profile without a return callback; ensure server keys are backend-only; require +notification, status, duplicate, and persistence proof before passing. + +- [ ] **Step 2: Run Snap tests and verify RED** + +```sh +go test ./packs/snap -run 'TestJourneyHandler|TestMobile' -count=1 +``` + +- [ ] **Step 3: Adapt the existing Snap runner** + +Preserve Basic Auth and Sandbox endpoints: + +```text +POST https://app.sandbox.midtrans.com/snap/v1/transactions +GET https://api.sandbox.midtrans.com/v2/{order_id}/status +``` + +Return an `awaiting_user_action` browser action after token creation. Resume by +status and local callback evidence. Never persist the Snap token or full redirect +URL in operation/evidence. + +- [ ] **Step 4: Implement mobile verification** + +Check that the merchant backend creates tokens, the app does not contain a +server-key reference, a WebView completion handler exists, an app scheme or +universal-link return is declared, and provider completion is reconciled by +backend status or notification. Record real-device completion as externally +blocked until supplied; do not call simulator-only proof end-to-end mobile proof. + +- [ ] **Step 5: Update descriptors and sources** + +Advertise both journeys and use current public Snap, Snap JS, mobile WebView, +notification, and transaction-status pages. + +- [ ] **Step 6: Run tests and commit** + +```sh +go test ./packs/snap ./internal/app ./test/e2e -count=1 +go test ./... -count=1 +git add packs/snap testdata/snap contracts +git commit -m "feat: deliver Snap web and mobile journeys" +``` + +--- + +### Task 6: Add classic Core API journeys + +**Files:** +- Create: `packs/coreapi/pack.go` +- Create: `packs/coreapi/pack_test.go` +- Create: `packs/coreapi/client.go` +- Create: `packs/coreapi/client_test.go` +- Create: `packs/coreapi/journey.go` +- Create: `packs/coreapi/journey_test.go` +- Create: `packs/coreapi/notification.go` +- Create: `packs/coreapi/notification_test.go` +- Create: `testdata/coreapi/card-3ds.json` +- Create: `testdata/coreapi/otc-alfamart.json` +- Modify: `cmd/midtrans/main.go` +- Modify: `contracts/capabilities-v1.json` +- Modify: `contracts/public-sources-v1.json` + +**Interfaces:** +- Produces handlers `core-api.card-3ds`, `core-api.saved-card`, + `core-api.installment`, `core-api.otc`, `core-api.virtual-account`, and + `core-api.refund`. +- Reuses classic notification signature/status mapping from Snap without + importing Snap journey behavior. + +- [ ] **Step 1: Write failing client and journey tests** + +Assert `POST /v2/charge` uses `api.sandbox.midtrans.com`, Basic Auth, integer +amounts, `authentication: true` for card, `payment_type: cstore` for OTC, and +`payment_type: bank_transfer` for legacy VA. Verify card execution is blocked +without a token reference and never accepts PAN/CVV fields. + +- [ ] **Step 2: Run focused tests and verify RED** + +```sh +go test ./packs/coreapi -count=1 +``` + +- [ ] **Step 3: Implement the client** + +Expose: + +```go +func (Client) Charge(context.Context, ChargeRequest) (ChargeResponse, error) +func (Client) Status(context.Context, string) (StatusResponse, error) +func (Client) Refund(context.Context, RefundRequest) (RefundResponse, error) +``` + +Bound responses to 1 MiB, reject cross-host redirects, redact provider bodies, +and classify timeouts as ambiguous so the engine reconciles with +`GET /v2/{order_id}/status`. + +- [ ] **Step 4: Implement card, OTC, and legacy VA handlers** + +Card handlers resolve only a `payment_token_reference`; they never accept raw +card fields. A 3DS `redirect_url` creates a browser action. OTC and VA return +safe payment instructions and await provider notification/status. + +- [ ] **Step 5: Implement notification and refund rules** + +Use SHA-512 over raw `order_id + status_code + gross_amount + serverKey`. +Select async `POST /v2/{order_id}/refund` for card and direct +`POST /v2/{order_id}/refund/online/direct` only for documented instant-refund +methods. Require a stable refund idempotency key. + +- [ ] **Step 6: Register the pack and run tests** + +```sh +go test ./packs/coreapi ./internal/packs ./internal/app -count=1 +go test ./... -count=1 +git add packs/coreapi testdata/coreapi cmd/midtrans/main.go contracts +git commit -m "feat: add classic Core API journeys" +``` + +--- + +### Task 7: Add Payment Link journeys + +**Files:** +- Create: `packs/paymentlink/pack.go` +- Create: `packs/paymentlink/pack_test.go` +- Create: `packs/paymentlink/client.go` +- Create: `packs/paymentlink/client_test.go` +- Create: `packs/paymentlink/journey.go` +- Create: `packs/paymentlink/journey_test.go` +- Create: `testdata/paymentlink/create-success.json` +- Modify: `cmd/midtrans/main.go` +- Modify: `contracts/capabilities-v1.json` +- Modify: `contracts/public-sources-v1.json` + +**Interfaces:** +- Produces `payment-link.create`, `payment-link.reusable`, and + `payment-link.verify` handlers. +- Reuses the classic credential type and notification verifier. + +- [ ] **Step 1: Write failing fixed, dynamic, and reusable tests** + +Verify: + +- `POST https://api.sandbox.midtrans.com/v1/payment-links`. +- Basic Auth uses the classic server key. +- Fixed links require a positive amount. +- Reusable links require an explicit `usage_limit` represented by safe input. +- Dynamic links do not pretend `gross_amount` is fixed proof. +- Reusable payments reconcile by transaction ID, not link ID alone. + +- [ ] **Step 2: Run tests and verify RED** + +```sh +go test ./packs/paymentlink -count=1 +``` + +- [ ] **Step 3: Implement client and handlers** + +Expose `Create(context.Context, CreateRequest) (CreateResponse, error)` and +return the hosted `payment_url` as an awaiting browser/buyer action without +persisting the full URL. Use status and classic notification proof for +completion. + +- [ ] **Step 4: Represent dashboard-created links safely** + +`payment-link.verify` accepts an order reference, not an arbitrary URL, and +verifies callback/status behavior. It must report `creation_channel: dashboard` +without claiming the CLI created the link. + +- [ ] **Step 5: Register, test, and commit** + +```sh +go test ./packs/paymentlink ./internal/app ./test/e2e -count=1 +go test ./... -count=1 +git add packs/paymentlink testdata/paymentlink cmd/midtrans/main.go contracts +git commit -m "feat: add Payment Link journeys" +``` + +--- + +### Task 8: Implement the BI-SNAP protocol foundation + +**Files:** +- Create: `packs/bisnap/signature.go` +- Create: `packs/bisnap/signature_test.go` +- Create: `packs/bisnap/client.go` +- Create: `packs/bisnap/client_test.go` +- Create: `packs/bisnap/endpoints.go` +- Create: `packs/bisnap/notification.go` +- Create: `packs/bisnap/notification_test.go` +- Copy sanitized fixtures into: `testdata/bisnap/` + +**Interfaces:** +- Produces: + +```go +func SignAccessToken(privateKeyPEM []byte, clientID, timestamp string) (string, error) +func SignTransaction(clientSecret []byte, method, path, accessToken string, body []byte, timestamp string) string +func VerifyNotification(publicKeyPEM []byte, method, path string, body []byte, timestamp, signature string) error +func PadPartnerServiceID(string) (string, error) +``` + +- [ ] **Step 1: Write failing crypto-vector tests** + +Use fixed keys, bodies, timestamps, and expected signatures generated from the +public signature formula. Assert the transaction body is hashed exactly as sent, +notification verification includes the literal callback path, and the three +signature families cannot be interchanged. + +- [ ] **Step 2: Run crypto tests and verify RED** + +```sh +go test ./packs/bisnap -run 'TestSign|TestVerify|TestPad' -count=1 +``` + +- [ ] **Step 3: Implement exact signing** + +- Access token: RSA-SHA256 over `clientID + "|" + timestamp`, Base64 output. +- Transaction: HMAC-SHA512 over + `method:path:accessToken:lowercaseHex(SHA256(exactBody)):timestamp`. +- Notification: RSA-SHA256 verification over + `method:path:lowercaseHex(SHA256(exactBody)):timestamp`. + +Parse only PKCS#1/PKCS#8 private keys and PKIX/PKCS#1 public keys. Return stable +errors without key material. + +- [ ] **Step 4: Implement the BI-SNAP client** + +Use only `https://merchants.sbx.midtrans.com` and +`https://merchants-app.sbx.midtrans.com`. Build access-token and transactional +headers with exact bytes, ISO-8601 timestamps, unique external IDs, partner ID, +five-digit channel ID, and conditional `Authorization-Customer`. + +- [ ] **Step 5: Implement product-specific notification verification** + +Preserve literal paths and response codes: + +```text +/v1.0/qr/qr-mpm-notify -> 2005200 / 4015200 +/v1.0/va/notify -> 2002500 / 4012500 +/v1.0/debit/notify -> 2005600 / 4015600 +/v1.0/registration-account/notify +``` + +The VA pack may accept the current public `/v1.0/transfer-va/payment` callback +as a documented alias, but signature verification must use the received literal +path. + +- [ ] **Step 6: Run tests and commit** + +```sh +go test ./packs/bisnap -count=1 +go test ./... -count=1 +git add packs/bisnap testdata/bisnap +git commit -m "feat: implement BI-SNAP protocol security" +``` + +--- + +### Task 9: Add BI-SNAP QRIS, VA, and direct-debit journeys + +**Files:** +- Create: `packs/bisnap/pack.go` +- Create: `packs/bisnap/pack_test.go` +- Create: `packs/bisnap/journey.go` +- Create: `packs/bisnap/journey_test.go` +- Create: `packs/bisnap/qris.go` +- Create: `packs/bisnap/virtual_account.go` +- Create: `packs/bisnap/direct_debit.go` +- Modify: `cmd/midtrans/main.go` +- Modify: `contracts/capabilities-v1.json` +- Modify: `contracts/public-sources-v1.json` + +**Interfaces:** +- Produces `bisnap.qris-payment`, `bisnap.virtual-account`, + `bisnap.direct-debit`, `bisnap.status`, and `bisnap.refund`. +- Consumes BI-SNAP client/signing from Task 8. + +- [ ] **Step 1: Write failing journey tests** + +Assert exact endpoints and service codes: + +```text +POST /v1.0/qr/qr-mpm-generate service 47 +POST /v1.0/transfer-va/create-va service 27 +POST /v1.0/debit/payment-host-to-host service 54 +GET /v1.0/debit/status service 55 +POST /v1.0/debit/refund service 58 +``` + +Verify one-time direct debit omits `Authorization-Customer`, QRIS prefers +`qrUrl` then `qrImage` then `qrContent`, and VA partner service IDs are +space-left-padded to eight characters. + +- [ ] **Step 2: Run journey tests and verify RED** + +```sh +go test ./packs/bisnap -run 'TestQRIS|TestVirtualAccount|TestDirectDebit|TestJourney' -count=1 +``` + +- [ ] **Step 3: Implement QRIS** + +Create, persist only safe references, return the Sandbox QRIS simulator as an +interaction action, reconcile by partner/original reference, and require +`latestTransactionStatus: 00` plus notification and merchant persistence proof. + +- [ ] **Step 4: Implement virtual account** + +Create bank-specific VA requests, persist only VA-safe display facts, reconcile +primarily on `trxId`, and verify the product-specific notification response +envelope. + +- [ ] **Step 5: Implement one-time direct debit and status recovery** + +Create the deeplink flow without `Authorization-Customer`. On timeout, query +status by the original external/reference ID before allowing an idempotent retry. + +- [ ] **Step 6: Register, test, and commit** + +```sh +go test ./packs/bisnap ./internal/app ./test/e2e -count=1 +go test ./... -count=1 +git add packs/bisnap cmd/midtrans/main.go contracts +git commit -m "feat: add BI-SNAP payment journeys" +``` + +--- + +### Task 10: Add GoPay tokenization and GoPayLater + +**Files:** +- Create: `packs/gopaytokenization/pack.go` +- Create: `packs/gopaytokenization/pack_test.go` +- Create: `packs/gopaytokenization/client.go` +- Create: `packs/gopaytokenization/client_test.go` +- Create: `packs/gopaytokenization/journey.go` +- Create: `packs/gopaytokenization/journey_test.go` +- Create: `packs/gopaytokenization/seamless.go` +- Create: `packs/gopaytokenization/seamless_test.go` +- Create: `testdata/gopaytokenization/` +- Modify: `cmd/midtrans/main.go` +- Modify: `contracts/capabilities-v1.json` +- Modify: `contracts/public-sources-v1.json` + +**Interfaces:** +- Produces `gopay-tokenization.account-linking`, + `gopay-tokenization.binding-inquiry`, + `gopay-tokenization.wallet-payment`, + `gopay-tokenization.paylater`, and `gopay-tokenization.unlink`. +- Consumes BI-SNAP signing/client foundation. + +- [ ] **Step 1: Write failing flow-separation tests** + +Assert: + +- Get Auth Code uses the `merchants-app.sbx.midtrans.com` host. +- Binding uses `POST /v1.0/registration-account-binding`. +- Inquiry uses `POST /v1.0/registration-account-inquiry`. +- Unbind uses `POST /v1.0/registration-account-unbinding`. +- Tokenized payment uses `POST /v1.0/debit/payment-host-to-host`. +- Tokenized payment includes `Authorization-Customer`; one-time debit does not. +- Inquiry runs immediately before payment and its rotated access token is used. +- PayLater requires an active `PAY_LATER` option. +- No auth code, customer token, or payment-option token is persisted or rendered. + +- [ ] **Step 2: Run tests and verify RED** + +```sh +go test ./packs/gopaytokenization -count=1 +``` + +- [ ] **Step 3: Implement account-linking planning and resume** + +Generate a state hash, construct the auth-code request with the linking merchant +handle, and return a browser action. Resume requires an `auth_code` credential +reference and the merchant application's successful state validation fact; +binding then returns a customer-token reference requirement, never the token. + +- [ ] **Step 4: Implement inquiry and tokenized payment** + +Resolve the customer authorization token by reference, call inquiry, select the +current active `GOPAY_WALLET` or `PAY_LATER` token in memory, and immediately +charge with both authorization headers. Redact inquiry/payment-option data before +forming the outcome. + +- [ ] **Step 5: Implement unlink and notification verification** + +Unbind with the current token reference and require the merchant application to +clear local linked state. Verify `/v1.0/registration-account/notify` and use +inquiry as the fallback for missing/ambiguous notifications. + +- [ ] **Step 6: Register, test, and commit** + +```sh +go test ./packs/gopaytokenization ./packs/bisnap ./internal/app -count=1 +go test ./... -count=1 +git add packs/gopaytokenization testdata/gopaytokenization cmd/midtrans/main.go contracts +git commit -m "feat: add GoPay tokenization journeys" +``` + +--- + +### Task 11: Add subscription and recurring lifecycle parity + +**Files:** +- Create: `packs/subscription/pack.go` +- Create: `packs/subscription/pack_test.go` +- Create: `packs/subscription/client.go` +- Create: `packs/subscription/client_test.go` +- Create: `packs/subscription/journey.go` +- Create: `packs/subscription/journey_test.go` +- Create: `testdata/subscription/` +- Modify: `packs/coreapi/pack.go` +- Modify: `packs/bisnap/pack.go` +- Modify: `packs/gopaytokenization/pack.go` +- Modify: `cmd/midtrans/main.go` +- Modify: `contracts/capabilities-v1.json` +- Modify: `contracts/public-sources-v1.json` + +**Interfaces:** +- Produces `subscription.create`, `subscription.verify`, + `subscription.disable`, `subscription.enable`, and `subscription.cancel`. +- Adds merchant-driven recurring verification journeys to the owning Core API, + BI-SNAP, and GoPay packs. + +- [ ] **Step 1: Write failing lifecycle tests** + +Verify classic Subscription API endpoints: + +```text +POST /v1/subscriptions +GET /v1/subscriptions/{id} +PATCH /v1/subscriptions/{id} +POST /v1/subscriptions/{id}/disable +POST /v1/subscriptions/{id}/enable +POST /v1/subscriptions/{id}/cancel +``` + +Require a saved-token reference, explicit schedule and amount, distinct recurring +notification verification, and no automatic production schedule. + +- [ ] **Step 2: Run tests and verify RED** + +```sh +go test ./packs/subscription -count=1 +``` + +- [ ] **Step 3: Implement Subscription API handlers** + +Use classic Basic Auth and `api.sandbox.midtrans.com`. Persist only subscription +ID and safe schedule facts. Treat disable/enable/cancel as separate reviewed +Sandbox mutations with operation IDs. + +- [ ] **Step 4: Add merchant-driven recurring verification** + +Core API verifies saved-card token usage, GoPay verifies fresh Binding Inquiry +before each charge, and BI-SNAP verifies the stored bind/customer token and +transactional signature. These journeys verify merchant scheduling and dunning; +they do not introduce a second scheduler inside the CLI. + +- [ ] **Step 5: Register, test, and commit** + +```sh +go test ./packs/subscription ./packs/coreapi ./packs/bisnap ./packs/gopaytokenization -count=1 +go test ./... -count=1 +git add packs cmd/midtrans/main.go contracts testdata/subscription +git commit -m "feat: add recurring payment lifecycle parity" +``` + +--- + +### Task 12: Expand evidence, aggregate hybrid verification, and enforce safety + +**Files:** +- Modify: `internal/evidence/model.go` +- Modify: `internal/evidence/store.go` +- Modify: `internal/evidence/evidence_test.go` +- Modify: `internal/app/commands_verify.go` +- Modify: `internal/app/commands_evidence.go` +- Modify: `internal/app/commands_evidence_test.go` +- Modify: `internal/policy/operation.go` +- Modify: `internal/policy/policy_test.go` +- Modify: `schemas/evidence-v1.schema.json` +- Modify: `schemas/result-v1.schema.json` +- Modify: `test/e2e/security_test.go` + +**Interfaces:** +- Consumes all pack proof outcomes. +- Produces hybrid project verification with per-journey results and one aggregate + status that cannot exceed the weakest required proof. + +- [ ] **Step 1: Write failing aggregate and safety tests** + +Assert one passed Snap journey plus one blocked BI-SNAP journey yields project +`blocked`; local-only proof cannot satisfy a Sandbox-required journey; evidence +contains operation/stage facts; and every known production host or production +policy mutation is rejected before HTTP dispatch. + +- [ ] **Step 2: Run tests and verify RED** + +```sh +go test ./internal/evidence ./internal/policy ./internal/app ./test/e2e -run 'TestHybrid|TestProduction|TestEvidence' -count=1 +``` + +- [ ] **Step 3: Implement evidence aggregation** + +Key bundles by journey and manifest hash. Reject stale evidence from another +repository revision, manifest, pack version, or operation. Aggregate missing +evidence and next actions by product. + +- [ ] **Step 4: Enforce zero-production mutation** + +Allowlist only: + +```text +app.sandbox.midtrans.com +api.sandbox.midtrans.com +merchants.sbx.midtrans.com +merchants-app.sbx.midtrans.com +simulator.sandbox.midtrans.com +``` + +Production readiness code may parse production configuration but cannot receive +an HTTP client capable of mutation. Add a test that walks every handler +definition and proves its executable hosts are Sandbox hosts. + +- [ ] **Step 5: Run tests and commit** + +```sh +go test ./internal/evidence ./internal/policy ./internal/app ./test/e2e -count=1 +go test ./... -count=1 +git add internal/evidence internal/policy internal/app schemas test/e2e +git commit -m "feat: verify hybrid Midtrans journey evidence" +``` + +--- + +### Task 13: Upgrade Midtrans Agent Skills to per-product CLI parity + +**Files:** +- Modify: `/Users/salis/Goto/Code/midtrans/midtrans-agent-skills-cli-integration/integrate-midtrans-payments/cli-compatibility.json` +- Modify: `/Users/salis/Goto/Code/midtrans/midtrans-agent-skills-cli-integration/integrate-midtrans-payments/SKILL.md` +- Modify: `/Users/salis/Goto/Code/midtrans/midtrans-agent-skills-cli-integration/integrate-midtrans-payments/references/midtrans-cli.md` +- Modify: `/Users/salis/Goto/Code/midtrans/midtrans-agent-skills-cli-integration/integrate-midtrans-payments/references/sandbox-interaction-helper.md` +- Modify: `/Users/salis/Goto/Code/midtrans/midtrans-agent-skills-cli-integration/integrate-midtrans-payments/evaluations.json` +- Modify: `docs/agent-skill-compatibility.md` +- Create: `test/e2e/skill_compatibility_test.go` + +**Interfaces:** +- Consumes advertised CLI result, manifest, evidence, pack, capability, and + journey versions. +- Produces a product-keyed compatibility matrix with explicit guidance-only + fallback. + +- [ ] **Step 1: Write failing compatibility tests** + +Load both `contracts/capabilities-v1.json` and the Skill matrix. Assert every +required capability and journey exists, each product is independently +negotiated, and no global `phase: merchant-snap-v1` field remains. + +- [ ] **Step 2: Run the compatibility test and verify RED** + +```sh +go test ./test/e2e -run TestAgentSkillCompatibility -count=1 +``` + +- [ ] **Step 3: Replace the compatibility matrix** + +Use: + +```json +{ + "contract_version": 1, + "required_result_schema": "1.0", + "required_manifest_schema": 1, + "required_evidence_schema": "1.0", + "products": { + "snap": {"required_capabilities": [], "required_journeys": []}, + "core-api": {"required_capabilities": [], "required_journeys": []}, + "payment-link": {"required_capabilities": [], "required_journeys": []}, + "bisnap": {"required_capabilities": [], "required_journeys": []}, + "gopay-tokenization": {"required_capabilities": [], "required_journeys": []}, + "subscription": {"required_capabilities": [], "required_journeys": []} + } +} +``` + +Populate each array only with the exact capability and journey IDs advertised +by the completed pack descriptors. + +- [ ] **Step 4: Update Skill orchestration** + +The Skill must: + +1. Select products from merchant intent. +2. Run `midtrans agent capabilities`. +3. Negotiate only enabled products. +4. Use `plan`, edit the merchant repository, then use `run`/`resume`. +5. Label missing CLI support as guidance-only. +6. Never pass or display resolved credentials. +7. Never call local-only proof end-to-end proof. + +- [ ] **Step 5: Add evaluation scenarios** + +Add hybrid Snap + GoPay, Core API card, Payment Link, BI-SNAP QRIS/VA, GoPay +linking/PayLater, and subscription scenarios. Each scenario fails on production +execution, credential leakage, missing capability negotiation, or false proof. + +- [ ] **Step 6: Test and commit both repositories** + +CLI: + +```sh +go test ./test/e2e -run TestAgentSkillCompatibility -count=1 +git add docs/agent-skill-compatibility.md test/e2e/skill_compatibility_test.go +git commit -m "test: enforce per-product Agent Skill parity" +``` + +Agent Skill: + +```sh +python3 -m json.tool integrate-midtrans-payments/cli-compatibility.json >/dev/null +python3 -m json.tool integrate-midtrans-payments/evaluations.json >/dev/null +git diff --check +git add integrate-midtrans-payments +git commit -m "feat(skill): orchestrate all Midtrans CLI products" +``` + +--- + +### Task 14: Complete representative evaluation, local install, and release gates + +**Files:** +- Create: `evaluations/multi-product-autonomous.json` +- Create: `evaluations/fixtures/hybrid-snap-gopay/` +- Create: `evaluations/fixtures/coreapi-paymentlink/` +- Create: `evaluations/fixtures/bisnap-qris-va/` +- Modify: `evaluations/README.md` +- Modify: `test/e2e/cli_test.go` +- Modify: `test/release/infrastructure_test.go` +- Modify: `tools/check_release.sh` +- Modify: `tools/install-local.sh` +- Modify: `tools/test-install-local.sh` +- Modify: `README.md` +- Modify: `docs/sandbox-evidence.md` + +**Interfaces:** +- Consumes the complete CLI and Skill contract. +- Produces locally installable, release-gated multi-product CLI behavior and + representative merchant-repository evidence. + +- [ ] **Step 1: Write failing end-to-end scenarios** + +Each fixture must initialize the clean manifest, enable at least two packs, list +journeys, plan without mutation, execute against local HTTP stubs, pause for +interaction, resume, verify callback/reconciliation, and export evidence. + +- [ ] **Step 2: Run E2E tests and verify RED** + +```sh +go test ./test/e2e ./test/release -count=1 +``` + +- [ ] **Step 3: Implement fixtures and evaluator matrix** + +The matrix records required product, journey, proof, expected interaction, and +external Sandbox prerequisites. Fixture scripts use loopback only and synthetic +credentials; they contain no real merchant or customer data. + +- [ ] **Step 4: Update installer verification** + +The no-sudo installer must run: + +```sh +midtrans version +midtrans agent capabilities --json --non-interactive +``` + +and verify every compiled pack plus the evidence schema. Preserve atomic +rollback and `${MIDTRANS_INSTALL_DIR:-$HOME/.local/bin}`. + +- [ ] **Step 5: Run the full release suite** + +```sh +gofmt -w cmd internal packs test +go vet ./... +go test ./... -count=1 +./tools/check_release.sh +./tools/test-install-local.sh +git diff --check +``` + +Expected: all commands exit zero and no secret-looking values appear in test +output or generated evidence. + +- [ ] **Step 6: Install locally and run merchant smoke** + +```sh +./tools/install-local.sh +midtrans version +midtrans agent capabilities --json --non-interactive +``` + +In `/Users/salis/Personal/Code/salis-property-midtrans-cli-spike`, replace only +the experimental `.midtrans/manifest.yaml` with the clean hybrid schema, run: + +```sh +midtrans status --json --non-interactive +midtrans test --json --non-interactive +midtrans verify --json --non-interactive +``` + +Record real Sandbox journeys as blocked when credentials, activation, buyer +interaction, or real-device proof is unavailable. + +- [ ] **Step 7: Run one final whole-branch review** + +Use one reviewer subagent to combine spec compliance, code quality, security, +and release-readiness review across the complete diff. Send blocking findings +to the responsible implementer and verify focused fixes locally without a second +reviewer pass. + +- [ ] **Step 8: Commit the verified release state** + +```sh +git add evaluations test tools README.md docs/sandbox-evidence.md +git commit -m "test: prove multi-product merchant CLI parity" +``` + +Do not push until the user explicitly asks to publish the verified branch. + +--- + +## Plan self-review + +- Spec coverage: product packs, clean manifest, hybrid routing, merchant and + agent commands, resumable interaction, evidence, production boundary, + per-pack Skill compatibility, lifecycle parity, local installation, and + release gates each map to at least one task. +- Type consistency: the manifest from Task 1, credential resolver from Task 2, + journey contracts from Task 3, registry from Task 4, and pack handlers from + Tasks 5–11 use the exact names consumed by later tasks. +- Safety consistency: every provider mutation uses a Sandbox allowlist, + credential reference, dry-run, operation ID, and redacted outcome. +- Public-source consistency: internal knowledge is used only for cross-checking; + executable product rules and provenance are refreshed from public Midtrans + documentation. +- Placeholder scan: the plan contains no deferred implementation markers; each + task names concrete behavior, files, tests, commands, and commit boundaries. diff --git a/docs/superpowers/specs/2026-07-26-multi-product-parity-design.md b/docs/superpowers/specs/2026-07-26-multi-product-parity-design.md index d53c83c..417f955 100644 --- a/docs/superpowers/specs/2026-07-26-multi-product-parity-design.md +++ b/docs/superpowers/specs/2026-07-26-multi-product-parity-design.md @@ -1,7 +1,7 @@ # Midtrans CLI Multi-Product Parity Design **Date:** 2026-07-26 -**Status:** Approved in conversation; awaiting written-spec review +**Status:** Approved **Audience:** Midtrans merchants and AI coding agents working in merchant repositories ## 1. Goal From d7b1601ace1f83d82b57553b38a195adf5819c9b Mon Sep 17 00:00:00 2001 From: "m.salis" Date: Mon, 27 Jul 2026 08:13:41 +0700 Subject: [PATCH 24/73] feat: introduce hybrid Midtrans manifest --- .../task-1-report.md | 36 ++ README.md | 6 + internal/app/app_test.go | 51 ++- internal/app/checkout_runner.go | 2 +- internal/app/commands_credentials.go | 4 +- internal/app/commands_sandbox.go | 4 +- internal/app/commands_sandbox_run_test.go | 24 +- internal/app/commands_setup.go | 50 ++- internal/app/commands_status.go | 8 +- internal/app/commands_webhook.go | 4 +- internal/app/manifest_helpers.go | 39 +++ internal/app/webhook_test_runner.go | 11 +- internal/manifest/file.go | 5 - internal/manifest/manifest_test.go | 316 ++++++++++++++++-- internal/manifest/model.go | 140 +++++--- internal/manifest/validate.go | 230 ++++++++++--- internal/readiness/report.go | 38 ++- internal/readiness/report_test.go | 44 ++- internal/secrets/environment.go | 8 +- packs/snap/local_verify.go | 12 +- packs/snap/local_verify_test.go | 36 +- packs/snap/pack.go | 36 +- packs/snap/pack_test.go | 53 ++- schemas/manifest-v1.schema.json | 210 +++++++++--- .../snap-broken/.midtrans/manifest.yaml | 60 ++-- .../snap-complete/.midtrans/manifest.yaml | 60 ++-- 26 files changed, 1174 insertions(+), 313 deletions(-) create mode 100644 .superpowers/sdd/2026-07-26-midtrans-cli-multi-product-parity/task-1-report.md create mode 100644 internal/app/manifest_helpers.go diff --git a/.superpowers/sdd/2026-07-26-midtrans-cli-multi-product-parity/task-1-report.md b/.superpowers/sdd/2026-07-26-midtrans-cli-multi-product-parity/task-1-report.md new file mode 100644 index 0000000..d2cec1e --- /dev/null +++ b/.superpowers/sdd/2026-07-26-midtrans-cli-multi-product-parity/task-1-report.md @@ -0,0 +1,36 @@ +## Task 1 Report + +### Scope delivered + +- Replaced the old Snap-only experimental manifest with the clean public hybrid schema in Go and JSON schema form. +- Reset `midtrans init` output to a neutral sandbox-only manifest with empty `credential_sets`, `integrations`, `routing`, and `verification.required`. +- Updated `midtrans setup`, readiness/status paths, Snap pack evaluation, and local verification helpers to read the new manifest shape directly without migration compatibility. +- Converted direct fixtures and tests to the clean manifest constructors and updated merchant fixture manifests to the new public shape. + +### TDD evidence + +1. Added `TestLoadHybridManifest` and `TestCleanManifest` in `internal/manifest/manifest_test.go`. +2. Verified RED with: + + ```sh + go test ./internal/manifest ./internal/app -run 'TestLoadHybridManifest|TestCleanManifest' -count=1 + ``` + + Initial failure: the old `manifest.Manifest` lacked `Routing`, `Integrations`, and `IntegrationFor`. +3. Implemented the clean schema and dependent refactors. +4. Verified GREEN with the same focused command. + +### Verification + +```sh +go test ./internal/manifest ./internal/app -count=1 +go test ./internal/readiness ./packs/snap -count=1 +go test ./... -count=1 +``` + +All commands passed on July 27, 2026. + +### Notes + +- No migration shim was retained for the removed Snap-only manifest shape. +- Remote webhook allowlists were not reintroduced into the clean public manifest; replay remains constrained by the existing policy layer until a later task defines that product-pack surface explicitly. diff --git a/README.md b/README.md index d76b1af..fbd2648 100644 --- a/README.md +++ b/README.md @@ -88,6 +88,12 @@ Review and edit `.midtrans/manifest.yaml` yourself. It contains environment variable references, never secret values. Phase 1 remains Sandbox-only: the CLI rejects production Midtrans hosts and production credentials. +`midtrans init` now creates a neutral hybrid manifest with sandbox-only policy, +loopback-safe application state defaults, and empty `credential_sets`, +`integrations`, `routing`, and `verification.required`. `midtrans setup` is the +entry point that adds the first Snap-oriented credential set, integration, +checkout routing, and verification requirements. + ## Contracts and compatibility - [Capability contract](contracts/capabilities-v1.json) diff --git a/internal/app/app_test.go b/internal/app/app_test.go index e545a28..a20ddf8 100644 --- a/internal/app/app_test.go +++ b/internal/app/app_test.go @@ -315,9 +315,10 @@ func TestSetupInteractiveWritesOnlyAfterExactConfirmation(t *testing.T) { t.Fatalf("exit = %d, stderr = %s", exit, stderr.String()) } value, err := manifest.Load(project) - if err != nil || - !slices.Contains(value.Integration.CheckoutModes, "popup") || - value.Integration.NotificationRoute != "/api/payment/webhook" { + integration, ok := value.IntegrationFor("snap") + if err != nil || !ok || + !slices.Contains(integration.Profiles, "web-popup") || + integration.Callbacks["notification"] != "/api/payment/webhook" { t.Fatalf("manifest = %#v, err = %v", value, err) } } @@ -1039,8 +1040,8 @@ func TestPlanSnapEvaluatesManifest(t *testing.T) { if result.ManifestVersion != 1 { t.Fatalf("manifest version = %d", result.ManifestVersion) } - if len(result.Findings) != 5 || - result.Findings[0].Code != "SNAP_NOTIFICATION_ROUTE_MISSING" { + if len(result.Findings) != 1 || + result.Findings[0].Code != "SNAP_PRODUCT_NOT_SELECTED" { t.Fatalf("findings = %#v", result.Findings) } } @@ -1439,6 +1440,7 @@ func TestCredentialsStatusReturnsOnlyPresenceBooleans(t *testing.T) { if _, err := manifest.Init(project); err != nil { t.Fatal(err) } + configureSnapManifestProject(t, project, "http://127.0.0.1:3000") result, exit := executeJSONWithGetenv(t, project, func(key string) (string, bool) { if key == "MIDTRANS_SERVER_KEY" { return "SB-Mid-server-secret", true @@ -1462,6 +1464,7 @@ func TestCredentialCommandsValidateManifestBeforeResolution(t *testing.T) { if _, err := manifest.Init(project); err != nil { t.Fatal(err) } + configureSnapManifestProject(t, project, "http://127.0.0.1:3000") path := manifest.Path(project) contents, err := os.ReadFile(path) if err != nil { @@ -1469,7 +1472,7 @@ func TestCredentialCommandsValidateManifestBeforeResolution(t *testing.T) { } contents = bytes.Replace( contents, - []byte("server_key: MIDTRANS_SERVER_KEY"), + []byte("server_key: env:MIDTRANS_SERVER_KEY"), []byte("server_key: SB-Mid-server-raw-secret"), 1, ) @@ -1698,6 +1701,7 @@ func TestSandboxStatusReturnsOnlySafeSnapStatusFields(t *testing.T) { if _, err := manifest.Init(project); err != nil { t.Fatal(err) } + configureSnapManifestProject(t, project, "http://127.0.0.1:3000") serverKey := "SB-Mid-server-STATUS-CANARY-DO-NOT-PRINT" tests := []struct { @@ -1982,6 +1986,7 @@ func TestWebhookVerifyReturnsOnlyPublicSafeNotificationFields(t *testing.T) { if _, err := manifest.Init(project); err != nil { t.Fatal(err) } + configureSnapManifestProject(t, project, "http://127.0.0.1:3000") serverKey := "SB-Mid-server-WEBHOOK-FIXTURE" file, signature := writeSignedNotification(t, project, serverKey) @@ -2634,6 +2639,40 @@ func merchantFixture(name string) string { return filepath.Join("..", "..", "testdata", "merchant-repos", name) } +func configureSnapManifestProject(t *testing.T, project string, baseURL string) { + t.Helper() + value, err := manifest.Load(project) + if err != nil { + t.Fatal(err) + } + value.Application.BaseURL = baseURL + value.CredentialSets["classic"] = manifest.CredentialSet{ + Type: "classic", + Environment: "sandbox", + ServerKey: "env:MIDTRANS_SERVER_KEY", + ClientKey: "env:MIDTRANS_CLIENT_KEY", + } + value.Integrations["snap"] = manifest.Integration{ + ConfigVersion: 1, + Credentials: "classic", + Profiles: []string{"web-redirect"}, + Callbacks: map[string]string{ + "notification": "/api/payments/midtrans/notification", + "finish": "/orders/{order_id}", + "status": "/api/payments/midtrans/status/{order_id}", + }, + } + value.Routing["checkout"] = "snap" + value.Verification.Required = []string{ + "snap.checkout", + "common.webhook-idempotency", + "common.status-reconciliation", + } + if err := manifest.Save(project, value); err != nil { + t.Fatal(err) + } +} + func assertJSONUsageResult(t *testing.T, exit int, stdout, stderr string) { t.Helper() if exit != 1 { diff --git a/internal/app/checkout_runner.go b/internal/app/checkout_runner.go index 65c071e..728c236 100644 --- a/internal/app/checkout_runner.go +++ b/internal/app/checkout_runner.go @@ -75,7 +75,7 @@ func runCheckout( ctx, request.Command, value.SchemaVersion, - value.Credentials.References["server_key"], + checkoutServerKeyReference(value), deps, ) if failure != nil { diff --git a/internal/app/commands_credentials.go b/internal/app/commands_credentials.go index 00d785f..071d248 100644 --- a/internal/app/commands_credentials.go +++ b/internal/app/commands_credentials.go @@ -52,7 +52,7 @@ func newCredentialsStatusRunner( _, serverKeyErr := secrets.ResolveSandboxServerKey( cmd.Context(), provider, - value.Credentials.References["server_key"], + checkoutServerKeyReference(value), ) serverKeyPresent := serverKeyErr == nil if serverKeyErr != nil && @@ -66,7 +66,7 @@ func newCredentialsStatusRunner( return writeResult(deps, flags, result) } clientKeyPresent := secretPresent( - cmd.Context(), provider, value.Credentials.References["client_key"], + cmd.Context(), provider, checkoutClientKeyReference(value), ) result := contracts.NewResult("credentials.status", contracts.StatusPass) diff --git a/internal/app/commands_sandbox.go b/internal/app/commands_sandbox.go index 1c07d9b..62e7367 100644 --- a/internal/app/commands_sandbox.go +++ b/internal/app/commands_sandbox.go @@ -104,7 +104,7 @@ func newSandboxPreflightCommand( cmd.Context(), "sandbox.preflight", value.SchemaVersion, - value.Credentials.References["server_key"], + checkoutServerKeyReference(value), deps, ) if credentialResult != nil { @@ -155,7 +155,7 @@ func newSandboxStatusCommand( cmd.Context(), "sandbox.status", value.SchemaVersion, - value.Credentials.References["server_key"], + checkoutServerKeyReference(value), deps, ) if credentialResult != nil { diff --git a/internal/app/commands_sandbox_run_test.go b/internal/app/commands_sandbox_run_test.go index 1ce9c5e..7432b59 100644 --- a/internal/app/commands_sandbox_run_test.go +++ b/internal/app/commands_sandbox_run_test.go @@ -381,11 +381,25 @@ func createJourneyProject(t *testing.T, localBaseURL string) string { if err != nil { t.Fatal(err) } - value.Integration.CheckoutModes = []string{"redirect"} - value.Integration.NotificationRoute = "/midtrans/notification" - value.Integration.FinishRedirectRoute = "/payments/finish" - value.Integration.LocalBaseURL = localBaseURL - value.Integration.LocalStatusRoute = "/payments/{order_id}" + value.Application.BaseURL = localBaseURL + value.CredentialSets["classic"] = manifest.CredentialSet{ + Type: "classic", + Environment: "sandbox", + ServerKey: "env:MIDTRANS_SERVER_KEY", + ClientKey: "env:MIDTRANS_CLIENT_KEY", + } + value.Integrations["snap"] = manifest.Integration{ + ConfigVersion: 1, + Credentials: "classic", + Profiles: []string{"web-redirect"}, + Callbacks: map[string]string{ + "notification": "/midtrans/notification", + "finish": "/payments/finish", + "status": "/payments/{order_id}", + }, + } + value.Routing["checkout"] = "snap" + value.Verification.Required = []string{"snap.checkout"} data, err := yaml.Marshal(value) if err != nil { t.Fatal(err) diff --git a/internal/app/commands_setup.go b/internal/app/commands_setup.go index b8adf96..f3f4869 100644 --- a/internal/app/commands_setup.go +++ b/internal/app/commands_setup.go @@ -94,11 +94,38 @@ func promptManifestSetup(input io.Reader, output io.Writer, value manifest.Manif } proposed := value - proposed.Integration.CheckoutModes = []string{checkoutMode} - proposed.Integration.NotificationRoute = notificationRoute - proposed.Integration.FinishRedirectRoute = finishRoute - proposed.Integration.LocalBaseURL = localBaseURL - proposed.Integration.LocalStatusRoute = localStatusRoute + profile := "web-popup" + if checkoutMode == "redirect" { + profile = "web-redirect" + } + proposed.Application.BaseURL = localBaseURL + if proposed.CredentialSets == nil { + proposed.CredentialSets = map[string]manifest.CredentialSet{} + } + proposed.CredentialSets["classic"] = manifest.CredentialSet{ + Type: "classic", + Environment: "sandbox", + ServerKey: "env:MIDTRANS_SERVER_KEY", + ClientKey: "env:MIDTRANS_CLIENT_KEY", + } + if proposed.Integrations == nil { + proposed.Integrations = map[string]manifest.Integration{} + } + proposed.Integrations["snap"] = manifest.Integration{ + ConfigVersion: 1, + Credentials: "classic", + Profiles: []string{profile}, + Callbacks: map[string]string{ + "notification": notificationRoute, + "finish": finishRoute, + "status": localStatusRoute, + }, + } + if proposed.Routing == nil { + proposed.Routing = map[string]string{} + } + proposed.Routing["checkout"] = "snap" + proposed.Verification.Required = []string{"snap.checkout"} if findings := manifest.Validate(proposed); len(findings) != 0 { return manifest.Manifest{}, errors.New("invalid manifest setup input") } @@ -109,11 +136,14 @@ func promptManifestSetup(input io.Reader, output io.Writer, value manifest.Manif field string value string }{ - {"integration.checkout_modes", checkoutMode}, - {"integration.notification_route", notificationRoute}, - {"integration.finish_redirect_route", finishRoute}, - {"integration.local_base_url", localBaseURL}, - {"integration.local_status_route", localStatusRoute}, + {"application.base_url", localBaseURL}, + {"credential_sets.classic.server_key", "env:MIDTRANS_SERVER_KEY"}, + {"credential_sets.classic.client_key", "env:MIDTRANS_CLIENT_KEY"}, + {"integrations.snap.profiles", profile}, + {"integrations.snap.callbacks.notification", notificationRoute}, + {"integrations.snap.callbacks.finish", finishRoute}, + {"integrations.snap.callbacks.status", localStatusRoute}, + {"routing.checkout", "snap"}, } { if _, err := fmt.Fprintf(output, "- %s: %s\n", preview.field, preview.value); err != nil { return manifest.Manifest{}, err diff --git a/internal/app/commands_status.go b/internal/app/commands_status.go index 494d68c..b37ba8b 100644 --- a/internal/app/commands_status.go +++ b/internal/app/commands_status.go @@ -51,13 +51,13 @@ func buildStatusResult( provider := secrets.NewEnvironmentProvider(deps.Getenv) serverPresent, serverInvalid := sandboxServerKeyReadiness( ctx, - value.Credentials.References["server_key"], + checkoutServerKeyReference(value), deps, ) - clientPresent := secretPresent(ctx, provider, value.Credentials.References["client_key"]) + clientPresent := secretPresent(ctx, provider, checkoutClientKeyReference(value)) reachable := readiness.ReachabilityUnknown - if value.Integration.LocalBaseURL != "" { - if deps.LocalProbe(ctx, value.Integration.LocalBaseURL) { + if value.Application.BaseURL != "" { + if deps.LocalProbe(ctx, value.Application.BaseURL) { reachable = readiness.ReachabilityReachable } else { reachable = readiness.ReachabilityUnreachable diff --git a/internal/app/commands_webhook.go b/internal/app/commands_webhook.go index fab7bbe..c71e832 100644 --- a/internal/app/commands_webhook.go +++ b/internal/app/commands_webhook.go @@ -69,7 +69,7 @@ func newWebhookVerifyCommand(flags *globalFlags, deps Dependencies) *cobra.Comma cmd.Context(), "webhook.verify", value.SchemaVersion, - value.Credentials.References["server_key"], + checkoutServerKeyReference(value), deps, ) if credentialResult != nil { @@ -154,7 +154,7 @@ func newWebhookReplayCommand(flags *globalFlags, deps Dependencies) *cobra.Comma } resolver := policy.NetResolver{} - allowedRemote := append([]string(nil), value.Integration.RemoteWebhookHosts...) + allowedRemote := []string(nil) if err := policy.ValidateWebhookTarget( cmd.Context(), target, diff --git a/internal/app/manifest_helpers.go b/internal/app/manifest_helpers.go new file mode 100644 index 0000000..90fe060 --- /dev/null +++ b/internal/app/manifest_helpers.go @@ -0,0 +1,39 @@ +package app + +import "github.com/veritrans/midtrans-cli/internal/manifest" + +func checkoutIntegration(value manifest.Manifest) (string, manifest.Integration, bool) { + return value.CheckoutIntegration() +} + +func checkoutCredentialSet(value manifest.Manifest) (manifest.CredentialSet, bool) { + _, integration, ok := value.CheckoutIntegration() + if !ok { + return manifest.CredentialSet{}, false + } + return value.CredentialSetFor(integration.Credentials) +} + +func checkoutServerKeyReference(value manifest.Manifest) string { + credentials, ok := checkoutCredentialSet(value) + if !ok { + return "" + } + return credentials.ServerKey +} + +func checkoutClientKeyReference(value manifest.Manifest) string { + credentials, ok := checkoutCredentialSet(value) + if !ok { + return "" + } + return credentials.ClientKey +} + +func checkoutCallback(value manifest.Manifest, key string) string { + _, integration, ok := value.CheckoutIntegration() + if !ok { + return "" + } + return integration.Callbacks[key] +} diff --git a/internal/app/webhook_test_runner.go b/internal/app/webhook_test_runner.go index fea1fd1..609b71a 100644 --- a/internal/app/webhook_test_runner.go +++ b/internal/app/webhook_test_runner.go @@ -74,7 +74,7 @@ func runWebhookTest( } serverKey, failure := resolveSandboxServerKey( ctx, request.Command, value.SchemaVersion, - value.Credentials.References["server_key"], deps, + checkoutServerKeyReference(value), deps, ) if failure != nil { return *failure @@ -97,7 +97,7 @@ func runWebhookTest( } func localWebhookTestTarget(value manifest.Manifest) (string, error) { - base, err := url.Parse(value.Integration.LocalBaseURL) + base, err := url.Parse(value.Application.BaseURL) if err != nil || base.Hostname() == "" || base.User != nil || (base.Scheme != "http" && base.Scheme != "https") || !policy.IsLoopbackHost(base.Hostname()) || base.RawQuery != "" || base.Fragment != "" { @@ -106,15 +106,16 @@ func localWebhookTestTarget(value manifest.Manifest) (string, error) { } return "", err } - route, err := url.Parse(value.Integration.NotificationRoute) - if err != nil || !strings.HasPrefix(value.Integration.NotificationRoute, "/") || + notificationRoute := checkoutCallback(value, "notification") + route, err := url.Parse(notificationRoute) + if err != nil || !strings.HasPrefix(notificationRoute, "/") || route.IsAbs() || route.Host != "" || route.User != nil || route.Fragment != "" { if err == nil { err = errors.New("invalid local webhook route") } return "", err } - target := strings.TrimRight(value.Integration.LocalBaseURL, "/") + value.Integration.NotificationRoute + target := strings.TrimRight(value.Application.BaseURL, "/") + notificationRoute if err := policy.ValidateWebhookTarget(context.Background(), target, nil, nil); err != nil { return "", err } diff --git a/internal/manifest/file.go b/internal/manifest/file.go index 933145b..29606ed 100644 --- a/internal/manifest/file.go +++ b/internal/manifest/file.go @@ -60,11 +60,6 @@ func Load(projectDir string) (Manifest, error) { if err := decoder.Decode(&value); err != nil { return Manifest{}, fmt.Errorf("decode manifest: %w", err) } - for key := range value.Credentials.References { - if key != "server_key" && key != "client_key" { - return Manifest{}, fmt.Errorf("decode manifest: unknown credentials.references field") - } - } return value, nil } diff --git a/internal/manifest/manifest_test.go b/internal/manifest/manifest_test.go index 933449c..c0a47df 100644 --- a/internal/manifest/manifest_test.go +++ b/internal/manifest/manifest_test.go @@ -25,7 +25,7 @@ func TestInitCreatesCommitSafeManifest(t *testing.T) { if err != nil { t.Fatal(err) } - if value.SchemaVersion != 1 || value.EnvironmentPolicy.Production != "disabled" { + if value.SchemaVersion != 1 || value.Policy.Production != "deny" { t.Fatalf("unsafe manifest: %#v", value) } ignore, err := os.ReadFile(filepath.Join(root, ".midtrans", ".gitignore")) @@ -67,11 +67,7 @@ func TestSaveRoundTripsValidatedManifestAtomically(t *testing.T) { if err != nil { t.Fatal(err) } - value.Integration.CheckoutModes = []string{"popup"} - value.Integration.NotificationRoute = "/api/payment/webhook" - value.Integration.FinishRedirectRoute = "/orders/{order_id}" - value.Integration.LocalBaseURL = "http://127.0.0.1:3101" - value.Integration.LocalStatusRoute = "/api/dev/midtrans/{order_id}" + value = configuredManifest(value) if err := manifest.Save(root, value); err != nil { t.Fatal(err) } @@ -109,6 +105,243 @@ func TestLoadRejectsUnknownFields(t *testing.T) { } } +func TestLoadHybridManifest(t *testing.T) { + project := writeManifest(t, ` +schema_version: 1 +policy: {environments: [sandbox], production: deny} +application: + base_url: http://127.0.0.1:3000 + payment_state: {paid: [paid], terminal: [paid, failed], monotonic: true} +credential_sets: + classic: + type: classic + environment: sandbox + server_key: env:MIDTRANS_SERVER_KEY + client_key: env:MIDTRANS_CLIENT_KEY +integrations: + snap: + config_version: 1 + credentials: classic + profiles: [web-popup] + payment_methods: [card] + callbacks: {notification: /api/midtrans/notify} +routing: {checkout: snap} +verification: {required: [snap.checkout]} +`) + got, err := manifest.Load(project) + if err != nil { + t.Fatal(err) + } + if findings := manifest.Validate(got); len(findings) != 0 { + t.Fatalf("Validate() findings = %#v", findings) + } + if got.Routing["checkout"] != "snap" || got.Integrations["snap"].Credentials != "classic" { + t.Fatalf("manifest = %#v", got) + } + if integration, ok := got.IntegrationFor("snap"); !ok || integration.Credentials != "classic" { + t.Fatalf("IntegrationFor(snap) = %#v, %v", integration, ok) + } +} + +func TestCleanManifest(t *testing.T) { + tests := []struct { + name string + content string + wantCode string + wantErr string + }{ + { + name: "rejects raw credentials", + content: ` +schema_version: 1 +policy: {environments: [sandbox], production: deny} +application: + base_url: http://127.0.0.1:3000 + payment_state: {paid: [paid], terminal: [paid], monotonic: true} +credential_sets: + classic: + type: classic + environment: sandbox + server_key: SB-Mid-server-raw-key + client_key: env:MIDTRANS_CLIENT_KEY +integrations: + snap: + config_version: 1 + credentials: classic +routing: {checkout: snap} +verification: {required: [snap.checkout]} +`, + wantCode: "CREDENTIAL_REFERENCE_INVALID", + }, + { + name: "rejects production enablement", + content: ` +schema_version: 1 +policy: {environments: [sandbox, production], production: allow} +application: + base_url: http://127.0.0.1:3000 + payment_state: {paid: [paid], terminal: [paid], monotonic: true} +credential_sets: + classic: + type: classic + environment: sandbox + server_key: env:MIDTRANS_SERVER_KEY + client_key: env:MIDTRANS_CLIENT_KEY +integrations: + snap: + config_version: 1 + credentials: classic +routing: {checkout: snap} +verification: {required: [snap.checkout]} +`, + wantCode: "POLICY_PRODUCTION_DISABLED", + }, + { + name: "rejects missing credential set", + content: ` +schema_version: 1 +policy: {environments: [sandbox], production: deny} +application: + base_url: http://127.0.0.1:3000 + payment_state: {paid: [paid], terminal: [paid], monotonic: true} +credential_sets: {} +integrations: + snap: + config_version: 1 + credentials: classic +routing: {checkout: snap} +verification: {required: [snap.checkout]} +`, + wantCode: "CREDENTIAL_SET_MISSING", + }, + { + name: "rejects unknown routing target", + content: ` +schema_version: 1 +policy: {environments: [sandbox], production: deny} +application: + base_url: http://127.0.0.1:3000 + payment_state: {paid: [paid], terminal: [paid], monotonic: true} +credential_sets: + classic: + type: classic + environment: sandbox + server_key: env:MIDTRANS_SERVER_KEY + client_key: env:MIDTRANS_CLIENT_KEY +integrations: + snap: + config_version: 1 + credentials: classic +routing: {checkout: coreapi} +verification: {required: [snap.checkout]} +`, + wantCode: "ROUTING_TARGET_UNKNOWN", + }, + { + name: "rejects unknown top-level fields", + content: ` +schema_version: 1 +policy: {environments: [sandbox], production: deny} +application: + base_url: http://127.0.0.1:3000 + payment_state: {paid: [paid], terminal: [paid], monotonic: true} +credential_sets: {} +integrations: {} +routing: {} +verification: {required: []} +unexpected: true +`, + wantErr: "field unexpected not found", + }, + { + name: "rejects duplicate YAML keys", + content: ` +schema_version: 1 +policy: {environments: [sandbox], production: deny} +application: + base_url: http://127.0.0.1:3000 + payment_state: {paid: [paid], terminal: [paid], monotonic: true} +credential_sets: + classic: + type: classic + type: snap-bi-snap + environment: sandbox + server_key: env:MIDTRANS_SERVER_KEY + client_key: env:MIDTRANS_CLIENT_KEY +integrations: + snap: + config_version: 1 + credentials: classic +routing: {checkout: snap} +verification: {required: [snap.checkout]} +`, + wantErr: "mapping key \"type\" already defined", + }, + { + name: "rejects unsafe file reference", + content: ` +schema_version: 1 +policy: {environments: [sandbox], production: deny} +application: + base_url: http://127.0.0.1:3000 + payment_state: {paid: [paid], terminal: [paid], monotonic: true} +credential_sets: + classic: + type: classic + environment: sandbox + server_key: file:/tmp/server.key + client_key: env:MIDTRANS_CLIENT_KEY +integrations: + snap: + config_version: 1 + credentials: classic +routing: {checkout: snap} +verification: {required: [snap.checkout]} +`, + wantCode: "CREDENTIAL_REFERENCE_INVALID", + }, + { + name: "rejects required journey for disabled product", + content: ` +schema_version: 1 +policy: {environments: [sandbox], production: deny} +application: + base_url: http://127.0.0.1:3000 + payment_state: {paid: [paid], terminal: [paid], monotonic: true} +credential_sets: + classic: + type: classic + environment: sandbox + server_key: env:MIDTRANS_SERVER_KEY + client_key: env:MIDTRANS_CLIENT_KEY +integrations: {} +routing: {} +verification: {required: [snap.checkout]} +`, + wantCode: "VERIFICATION_TARGET_UNKNOWN", + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + root := writeManifest(t, test.content) + value, err := manifest.Load(root) + if test.wantErr != "" { + if err == nil || !strings.Contains(err.Error(), test.wantErr) { + t.Fatalf("Load() error = %v, want substring %q", err, test.wantErr) + } + return + } + if err != nil { + t.Fatalf("Load() error = %v", err) + } + findings := manifest.Validate(value) + if !hasFinding(findings, test.wantCode) { + t.Fatalf("Validate() findings = %#v, want %q", findings, test.wantCode) + } + }) + } +} + func TestLoadRejectsUnknownCredentialReferenceKeysStrictly(t *testing.T) { rawSecret := "SB-Mid-server-raw-key" root := writeManifest(t, "schema_version: 1\ncredentials:\n provider: environment\n references:\n"+ @@ -263,22 +496,33 @@ func TestValidateDefaultManifest(t *testing.T) { } func TestValidateRejectsUnsafePolicyAndCredentialConfiguration(t *testing.T) { - value := manifest.Default() + value := configuredManifest(manifest.Default()) value.SchemaVersion = 2 - value.EnvironmentPolicy.Allowed = []string{"sandbox", "production"} - value.EnvironmentPolicy.Production = "enabled" - value.Credentials.Provider = "literal" - delete(value.Credentials.References, "server_key") - delete(value.Credentials.References, "client_key") - value.Integration.LocalStatusRoute = "/payments/status" - value.Integration.LocalBaseURL = "https://merchant.example" + value.Policy.Environments = []string{"sandbox", "production"} + value.Policy.Production = "enabled" + value.CredentialSets["classic"] = manifest.CredentialSet{ + Type: "classic", + Environment: "sandbox", + ServerKey: "", + ClientKey: "", + } + value.Integrations["snap"] = manifest.Integration{ + ConfigVersion: 1, + Credentials: "classic", + Profiles: []string{"web-popup"}, + Callbacks: map[string]string{ + "notification": "/api/payment/webhook", + "finish": "/orders/{order_id}", + "status": "/payments/status", + }, + } + value.Application.BaseURL = "https://merchant.example" findings := manifest.Validate(value) for _, code := range []string{ "MANIFEST_SCHEMA_UNSUPPORTED", "POLICY_PRODUCTION_DISABLED", - "CREDENTIAL_PROVIDER_UNSUPPORTED", - "CREDENTIAL_REFERENCE_MISSING", + "LOCAL_STATUS_ROUTE_INVALID", "LOCAL_STATUS_ROUTE_INVALID", "LOCAL_BASE_URL_NOT_LOOPBACK", } { @@ -300,8 +544,15 @@ func TestValidateRejectsNonEnvironmentCredentialReferencesWithoutEcho(t *testing } for _, test := range tests { t.Run(test.name, func(t *testing.T) { - value := manifest.Default() - value.Credentials.References[test.key] = test.value + value := configuredManifest(manifest.Default()) + credentials := value.CredentialSets["classic"] + switch test.key { + case "server_key": + credentials.ServerKey = test.value + case "client_key": + credentials.ClientKey = test.value + } + value.CredentialSets["classic"] = credentials findings := manifest.Validate(value) if !hasFinding(findings, "CREDENTIAL_REFERENCE_INVALID") { t.Fatalf("missing credential reference finding in %#v", findings) @@ -324,16 +575,39 @@ func TestManifestSchemaUsesEnvironmentReferencePattern(t *testing.T) { if err := json.Unmarshal(data, &schema); err != nil { t.Fatal(err) } - references := schemaObjectAt(t, schema, "properties", "credentials", "properties", "references", "properties") - const wantPattern = "^[A-Z][A-Z0-9_]*$" + credentialSets := schemaObjectAt(t, schema, "properties", "credential_sets", "additionalProperties", "properties") + const wantPattern = "^(env:[A-Z][A-Z0-9_]*|file:\\./[^[:cntrl:]]+)$" for _, key := range []string{"server_key", "client_key"} { - property := schemaObjectAt(t, references, key) + property := schemaObjectAt(t, credentialSets, key) if property["pattern"] != wantPattern { t.Errorf("%s pattern = %#v, want %q", key, property["pattern"], wantPattern) } } } +func configuredManifest(value manifest.Manifest) manifest.Manifest { + value.Application.BaseURL = "http://127.0.0.1:3101" + value.CredentialSets["classic"] = manifest.CredentialSet{ + Type: "classic", + Environment: "sandbox", + ServerKey: "env:MIDTRANS_SERVER_KEY", + ClientKey: "env:MIDTRANS_CLIENT_KEY", + } + value.Integrations["snap"] = manifest.Integration{ + ConfigVersion: 1, + Credentials: "classic", + Profiles: []string{"web-popup"}, + Callbacks: map[string]string{ + "notification": "/api/payment/webhook", + "finish": "/orders/{order_id}", + "status": "/api/dev/midtrans/{order_id}", + }, + } + value.Routing["checkout"] = "snap" + value.Verification.Required = []string{"snap.checkout"} + return value +} + func writeManifest(t *testing.T, content string) string { t.Helper() root := t.TempDir() diff --git a/internal/manifest/model.go b/internal/manifest/model.go index 7eb9cc3..ba701d1 100644 --- a/internal/manifest/model.go +++ b/internal/manifest/model.go @@ -1,68 +1,122 @@ package manifest +import "sort" + type Manifest struct { - SchemaVersion int `yaml:"schema_version" json:"schema_version"` - EnvironmentPolicy EnvironmentPolicy `yaml:"environment_policy" json:"environment_policy"` - Products []string `yaml:"products" json:"products"` - Integration Integration `yaml:"integration" json:"integration"` - StatePolicy StatePolicy `yaml:"state_policy" json:"state_policy"` - Credentials Credentials `yaml:"credentials" json:"credentials"` - RequiredJourneys []string `yaml:"required_journeys" json:"required_journeys"` + SchemaVersion int `yaml:"schema_version" json:"schema_version"` + Policy Policy `yaml:"policy" json:"policy"` + Application Application `yaml:"application" json:"application"` + CredentialSets map[string]CredentialSet `yaml:"credential_sets" json:"credential_sets"` + Integrations map[string]Integration `yaml:"integrations" json:"integrations"` + Routing map[string]string `yaml:"routing" json:"routing"` + Verification Verification `yaml:"verification" json:"verification"` } -type EnvironmentPolicy struct { - Allowed []string `yaml:"allowed" json:"allowed"` - Production string `yaml:"production" json:"production"` +type Policy struct { + Environments []string `yaml:"environments" json:"environments"` + Production string `yaml:"production" json:"production"` } -type Integration struct { - CheckoutModes []string `yaml:"checkout_modes" json:"checkout_modes"` - NotificationRoute string `yaml:"notification_route" json:"notification_route"` - FinishRedirectRoute string `yaml:"finish_redirect_route" json:"finish_redirect_route"` - LocalBaseURL string `yaml:"local_base_url" json:"local_base_url"` - LocalStatusRoute string `yaml:"local_status_route" json:"local_status_route"` - RemoteWebhookHosts []string `yaml:"remote_webhook_hosts" json:"remote_webhook_hosts"` +type Application struct { + BaseURL string `yaml:"base_url" json:"base_url"` + PaymentState PaymentState `yaml:"payment_state" json:"payment_state"` } -type StatePolicy struct { +type PaymentState struct { Paid []string `yaml:"paid" json:"paid"` Terminal []string `yaml:"terminal" json:"terminal"` Monotonic bool `yaml:"monotonic" json:"monotonic"` } -type Credentials struct { - Provider string `yaml:"provider" json:"provider"` - References map[string]string `yaml:"references" json:"references"` +type CredentialSet struct { + Type string `yaml:"type" json:"type"` + Environment string `yaml:"environment" json:"environment"` + ServerKey string `yaml:"server_key,omitempty" json:"server_key,omitempty"` + ClientKey string `yaml:"client_key,omitempty" json:"client_key,omitempty"` + ClientID string `yaml:"client_id,omitempty" json:"client_id,omitempty"` + ClientSecret string `yaml:"client_secret,omitempty" json:"client_secret,omitempty"` + PartnerID string `yaml:"partner_id,omitempty" json:"partner_id,omitempty"` + ChannelID string `yaml:"channel_id,omitempty" json:"channel_id,omitempty"` + PrivateKey string `yaml:"private_key,omitempty" json:"private_key,omitempty"` + MidtransPublicKey string `yaml:"midtrans_public_key,omitempty" json:"midtrans_public_key,omitempty"` +} + +type Integration struct { + ConfigVersion int `yaml:"config_version" json:"config_version"` + Credentials string `yaml:"credentials" json:"credentials"` + Profiles []string `yaml:"profiles,omitempty" json:"profiles,omitempty"` + PaymentMethods []string `yaml:"payment_methods,omitempty" json:"payment_methods,omitempty"` + Capabilities []string `yaml:"capabilities,omitempty" json:"capabilities,omitempty"` + Callbacks map[string]string `yaml:"callbacks,omitempty" json:"callbacks,omitempty"` +} + +type Verification struct { + Required []string `yaml:"required" json:"required"` } func Default() Manifest { return Manifest{ SchemaVersion: 1, - EnvironmentPolicy: EnvironmentPolicy{ - Allowed: []string{"sandbox"}, - Production: "disabled", + Policy: Policy{ + Environments: []string{"sandbox"}, + Production: "deny", }, - Products: []string{"snap"}, - Integration: Integration{ - CheckoutModes: []string{}, - RemoteWebhookHosts: []string{}, - }, - StatePolicy: StatePolicy{ - Paid: []string{"capture", "settlement"}, - Terminal: []string{"settlement", "deny", "cancel", "expire"}, - Monotonic: true, - }, - Credentials: Credentials{ - Provider: "environment", - References: map[string]string{ - "server_key": "MIDTRANS_SERVER_KEY", - "client_key": "MIDTRANS_CLIENT_KEY", + Application: Application{ + BaseURL: "", + PaymentState: PaymentState{ + Paid: []string{"paid"}, + Terminal: []string{"paid", "failed"}, + Monotonic: true, }, }, - RequiredJourneys: []string{ - "snap.checkout", - "common.webhook-idempotency", - "common.status-reconciliation", + CredentialSets: map[string]CredentialSet{}, + Integrations: map[string]Integration{}, + Routing: map[string]string{}, + Verification: Verification{ + Required: []string{}, }, } } + +func (value Manifest) IntegrationFor(name string) (Integration, bool) { + integration, ok := value.Integrations[name] + return integration, ok +} + +func (value Manifest) CredentialSetFor(name string) (CredentialSet, bool) { + set, ok := value.CredentialSets[name] + return set, ok +} + +func (value Manifest) CheckoutIntegration() (string, Integration, bool) { + name := value.Routing["checkout"] + if name == "" { + return "", Integration{}, false + } + integration, ok := value.IntegrationFor(name) + return name, integration, ok +} + +func (value Manifest) EnabledProducts() []string { + return sortedKeys(value.Integrations) +} + +func (value Manifest) CredentialSetForIntegration(name string) (CredentialSet, bool) { + integration, ok := value.IntegrationFor(name) + if !ok { + return CredentialSet{}, false + } + return value.CredentialSetFor(integration.Credentials) +} + +func sortedKeys[K ~string, V any](items map[K]V) []string { + if len(items) == 0 { + return nil + } + keys := make([]string, 0, len(items)) + for key := range items { + keys = append(keys, string(key)) + } + sort.Strings(keys) + return keys +} diff --git a/internal/manifest/validate.go b/internal/manifest/validate.go index c50bb2d..59754e6 100644 --- a/internal/manifest/validate.go +++ b/internal/manifest/validate.go @@ -10,7 +10,10 @@ import ( "github.com/veritrans/midtrans-cli/internal/contracts" ) -var environmentReferencePattern = regexp.MustCompile(`^[A-Z][A-Z0-9_]*$`) +var ( + environmentReferencePattern = regexp.MustCompile(`^env:[A-Z][A-Z0-9_]*$`) + fileReferencePattern = regexp.MustCompile(`^file:\./[^[:cntrl:]]+$`) +) func Validate(value Manifest) []contracts.Finding { var findings []contracts.Finding @@ -21,68 +24,217 @@ func Validate(value Manifest) []contracts.Finding { Message: "schema_version must be 1", }) } - if !slices.Equal(value.EnvironmentPolicy.Allowed, []string{"sandbox"}) || - value.EnvironmentPolicy.Production != "disabled" { + if !slices.Equal(value.Policy.Environments, []string{"sandbox"}) || + value.Policy.Production != "deny" { findings = append(findings, contracts.Finding{ Code: "POLICY_PRODUCTION_DISABLED", Severity: "blocking", - Message: "environment_policy must allow only sandbox and disable production", + Message: "policy must allow only sandbox and deny production", }) } - if value.Credentials.Provider != "environment" { + if value.Application.BaseURL != "" && !isLoopbackURL(value.Application.BaseURL) { findings = append(findings, contracts.Finding{ - Code: "CREDENTIAL_PROVIDER_UNSUPPORTED", + Code: "LOCAL_BASE_URL_NOT_LOOPBACK", Severity: "blocking", - Message: "Phase 1 supports only the environment credential provider", + Message: "application.base_url must target loopback", }) } - if value.Credentials.References["server_key"] == "" { + findings = append(findings, validatePaymentState(value.Application.PaymentState)...) + findings = append(findings, validateCredentialSets(value.CredentialSets)...) + findings = append(findings, validateIntegrations(value)...) + findings = append(findings, validateRouting(value)...) + findings = append(findings, validateVerification(value)...) + return findings +} + +func validatePaymentState(state PaymentState) []contracts.Finding { + var findings []contracts.Finding + if !state.Monotonic { findings = append(findings, contracts.Finding{ - Code: "CREDENTIAL_REFERENCE_MISSING", + Code: "PAYMENT_STATE_NOT_MONOTONIC", Severity: "blocking", - Message: "credentials.references.server_key is required", + Message: "application.payment_state.monotonic must be true", }) } - if value.Credentials.References["client_key"] == "" { - findings = append(findings, contracts.Finding{ - Code: "CREDENTIAL_REFERENCE_MISSING", - Severity: "blocking", - Message: "credentials.references.client_key is required", - }) + for field, values := range map[string][]string{ + "paid": state.Paid, + "terminal": state.Terminal, + } { + if hasEmptyOrDuplicate(values) { + findings = append(findings, contracts.Finding{ + Code: "PAYMENT_STATE_INVALID", + Severity: "blocking", + Message: "application.payment_state." + field + " must contain unique non-empty states", + }) + } } - for _, key := range []string{"server_key", "client_key"} { - reference := value.Credentials.References[key] - if reference != "" && !environmentReferencePattern.MatchString(reference) { + return findings +} + +func validateCredentialSets(sets map[string]CredentialSet) []contracts.Finding { + var findings []contracts.Finding + for name, set := range sets { + if name == "" { findings = append(findings, contracts.Finding{ - Code: "CREDENTIAL_REFERENCE_INVALID", + Code: "CREDENTIAL_SET_INVALID", Severity: "blocking", - Message: "credential references must be environment variable names", + Message: "credential set names must be non-empty", }) } + if set.Environment != "" && set.Environment != "sandbox" { + findings = append(findings, contracts.Finding{ + Code: "CREDENTIAL_SET_ENVIRONMENT_INVALID", + Severity: "blocking", + Message: "credential sets must target sandbox", + }) + } + for _, reference := range credentialReferences(set) { + if reference != "" && !validCredentialReference(reference) { + findings = append(findings, contracts.Finding{ + Code: "CREDENTIAL_REFERENCE_INVALID", + Severity: "blocking", + Message: "credential references must use env:NAME or file:./path", + }) + break + } + } } - if value.Integration.LocalStatusRoute != "" && - !strings.Contains(value.Integration.LocalStatusRoute, "{order_id}") { - findings = append(findings, contracts.Finding{ - Code: "LOCAL_STATUS_ROUTE_INVALID", - Severity: "blocking", - Message: "integration.local_status_route must contain {order_id}", - }) + return findings +} + +func validateIntegrations(value Manifest) []contracts.Finding { + var findings []contracts.Finding + for name, integration := range value.Integrations { + if name == "" { + findings = append(findings, contracts.Finding{ + Code: "INTEGRATION_INVALID", + Severity: "blocking", + Message: "integration names must be non-empty", + }) + } + if integration.ConfigVersion != 1 { + findings = append(findings, contracts.Finding{ + Code: "INTEGRATION_CONFIG_UNSUPPORTED", + Severity: "blocking", + Message: "integration config_version must be 1", + }) + } + if integration.Credentials == "" { + findings = append(findings, contracts.Finding{ + Code: "CREDENTIAL_SET_MISSING", + Severity: "blocking", + Message: "integration credentials must reference an existing credential set", + }) + continue + } + if _, ok := value.CredentialSets[integration.Credentials]; !ok { + findings = append(findings, contracts.Finding{ + Code: "CREDENTIAL_SET_MISSING", + Severity: "blocking", + Message: "integration credentials must reference an existing credential set", + }) + } + if callback := integration.Callbacks["notification"]; callback != "" && !strings.HasPrefix(callback, "/") { + findings = append(findings, contracts.Finding{ + Code: "CALLBACK_ROUTE_INVALID", + Severity: "blocking", + Message: "integration callbacks must be absolute application routes", + }) + } + if callback := integration.Callbacks["status"]; callback != "" && + !strings.Contains(callback, "{order_id}") { + findings = append(findings, contracts.Finding{ + Code: "LOCAL_STATUS_ROUTE_INVALID", + Severity: "blocking", + Message: "integration status callback must contain {order_id}", + }) + } } - if value.Integration.LocalBaseURL != "" { - base, err := url.Parse(value.Integration.LocalBaseURL) - valid := err == nil && base.User == nil && - (base.Scheme == "http" || base.Scheme == "https") - if valid { - ip := net.ParseIP(base.Hostname()) - valid = base.Hostname() == "localhost" || (ip != nil && ip.IsLoopback()) - } - if !valid { + return findings +} + +func validateRouting(value Manifest) []contracts.Finding { + var findings []contracts.Finding + for _, target := range value.Routing { + if _, ok := value.Integrations[target]; !ok { findings = append(findings, contracts.Finding{ - Code: "LOCAL_BASE_URL_NOT_LOOPBACK", + Code: "ROUTING_TARGET_UNKNOWN", Severity: "blocking", - Message: "integration.local_base_url must target loopback in Phase 1", + Message: "routing targets must reference enabled integrations", }) } } return findings } + +func validateVerification(value Manifest) []contracts.Finding { + var findings []contracts.Finding + for _, required := range value.Verification.Required { + parts := strings.SplitN(required, ".", 2) + if len(parts) != 2 || parts[0] == "" || parts[1] == "" { + findings = append(findings, contracts.Finding{ + Code: "VERIFICATION_TARGET_UNKNOWN", + Severity: "blocking", + Message: "verification.required entries must target enabled integrations or common", + }) + continue + } + if parts[0] == "common" { + continue + } + if _, ok := value.Integrations[parts[0]]; !ok { + findings = append(findings, contracts.Finding{ + Code: "VERIFICATION_TARGET_UNKNOWN", + Severity: "blocking", + Message: "verification.required entries must target enabled integrations or common", + }) + } + } + return findings +} + +func credentialReferences(set CredentialSet) []string { + return []string{ + set.ServerKey, + set.ClientKey, + set.ClientID, + set.ClientSecret, + set.PartnerID, + set.ChannelID, + set.PrivateKey, + set.MidtransPublicKey, + } +} + +func hasEmptyOrDuplicate(values []string) bool { + seen := make(map[string]struct{}, len(values)) + for _, value := range values { + if value == "" { + return true + } + if _, ok := seen[value]; ok { + return true + } + seen[value] = struct{}{} + } + return false +} + +func validCredentialReference(reference string) bool { + return environmentReferencePattern.MatchString(reference) || + fileReferencePattern.MatchString(reference) +} + +func isLoopbackURL(raw string) bool { + base, err := url.Parse(raw) + valid := err == nil && + base.User == nil && + base.RawQuery == "" && + base.Fragment == "" && + (base.Scheme == "http" || base.Scheme == "https") + if !valid { + return false + } + ip := net.ParseIP(base.Hostname()) + return base.Hostname() == "localhost" || (ip != nil && ip.IsLoopback()) +} diff --git a/internal/readiness/report.go b/internal/readiness/report.go index b140ca5..b7d4c07 100644 --- a/internal/readiness/report.go +++ b/internal/readiness/report.go @@ -4,6 +4,7 @@ package readiness import ( "fmt" "path/filepath" + "regexp" "slices" "sort" "strings" @@ -59,6 +60,8 @@ type Input struct { LocalReachable Reachability } +var readinessCredentialReferencePattern = regexp.MustCompile(`^(env:[A-Z][A-Z0-9_]*|file:\./[^[:cntrl:]]+)$`) + // Build constructs a stable report without reading the filesystem, environment, or network. func Build(input Input) Report { root := "" @@ -74,19 +77,24 @@ func Build(input Input) Report { Root: root, Manifest: ".midtrans/manifest.yaml", Environment: "sandbox", - Products: sortedStrings(input.Manifest.Products), + Products: sortedStrings(input.Manifest.EnabledProducts()), CLIVersion: input.CLIVersion, Packs: sortedPacks(input.Packs), } + _, checkoutIntegration, hasCheckout := input.Manifest.CheckoutIntegration() + credentials := manifest.CredentialSet{} + if hasCheckout { + credentials, _ = input.Manifest.CredentialSetFor(checkoutIntegration.Credentials) + } report.Checks = []Check{ projectCheck(input.ProjectRoot), environmentCheck(manifestFindings), - productCheck(input.Manifest.Products), + productCheck(input.Manifest.EnabledProducts()), checkoutCheck(input.Manifest), webhookCheck(input.Manifest), localStatusCheck(input.Manifest, manifestFindings), - credentialCheck("client-key", "Client key", input.Manifest.Credentials.References["client_key"], input.ClientKeyPresent, false), - credentialCheck("server-key", "Server key", input.Manifest.Credentials.References["server_key"], input.ServerKeyPresent, input.ServerKeyInvalid), + credentialCheck("client-key", "Client key", credentials.ClientKey, input.ClientKeyPresent, false), + credentialCheck("server-key", "Server key", credentials.ServerKey, input.ServerKeyPresent, input.ServerKeyInvalid), localAppCheck(input.LocalReachable), } for index, finding := range sortedFindings(input.Findings) { @@ -147,7 +155,7 @@ func projectCheck(root string) Check { } func environmentCheck(findings []contracts.Finding) Check { - if hasManifestFinding(findings, "MANIFEST_SCHEMA_UNSUPPORTED", "POLICY_PRODUCTION_DISABLED", "CREDENTIAL_PROVIDER_UNSUPPORTED") { + if hasManifestFinding(findings, "MANIFEST_SCHEMA_UNSUPPORTED", "POLICY_PRODUCTION_DISABLED") { return Check{ID: "environment", Label: "Environment", State: Failed, Detail: "sandbox-only environment policy is invalid"} } return Check{ID: "environment", Label: "Environment", State: Ready, Detail: "sandbox-only environment policy is configured"} @@ -161,14 +169,16 @@ func productCheck(products []string) Check { } func checkoutCheck(value manifest.Manifest) Check { - if len(value.Integration.CheckoutModes) == 0 || value.Integration.FinishRedirectRoute == "" { + _, integration, ok := value.CheckoutIntegration() + if !ok || len(integration.Profiles) == 0 || integration.Callbacks["finish"] == "" { return Check{ID: "checkout", Label: "Checkout", State: NeedsAction, Detail: "configure a checkout mode and finish redirect route"} } return Check{ID: "checkout", Label: "Checkout", State: Ready, Detail: "checkout mode and finish redirect route are configured"} } func webhookCheck(value manifest.Manifest) Check { - if value.Integration.NotificationRoute == "" { + _, integration, ok := value.CheckoutIntegration() + if !ok || integration.Callbacks["notification"] == "" { return Check{ID: "webhook", Label: "Webhook", State: NeedsAction, Detail: "configure a notification route"} } return Check{ID: "webhook", Label: "Webhook", State: Ready, Detail: "notification route is configured"} @@ -178,7 +188,8 @@ func localStatusCheck(value manifest.Manifest, findings []contracts.Finding) Che if hasManifestFinding(findings, "LOCAL_STATUS_ROUTE_INVALID", "LOCAL_BASE_URL_NOT_LOOPBACK") { return Check{ID: "local-status", Label: "Local status", State: Failed, Detail: "local status configuration is invalid"} } - if value.Integration.LocalBaseURL == "" || value.Integration.LocalStatusRoute == "" { + _, integration, ok := value.CheckoutIntegration() + if value.Application.BaseURL == "" || !ok || integration.Callbacks["status"] == "" { return Check{ID: "local-status", Label: "Local status", State: NeedsAction, Detail: "configure a loopback base URL and status route"} } return Check{ID: "local-status", Label: "Local status", State: Ready, Detail: "loopback base URL and status route are configured"} @@ -198,16 +209,7 @@ func credentialCheck(id, label, reference string, present, invalid bool) Check { } func validEnvironmentReference(reference string) bool { - if reference == "" || reference[0] < 'A' || reference[0] > 'Z' { - return false - } - for _, character := range reference[1:] { - if (character < 'A' || character > 'Z') && - (character < '0' || character > '9') && character != '_' { - return false - } - } - return true + return readinessCredentialReferencePattern.MatchString(reference) } func localAppCheck(reachable Reachability) Check { diff --git a/internal/readiness/report_test.go b/internal/readiness/report_test.go index b1c71a8..ce2181c 100644 --- a/internal/readiness/report_test.go +++ b/internal/readiness/report_test.go @@ -12,12 +12,7 @@ import ( ) func TestBuildReportsConcreteReadyAndMissingChecks(t *testing.T) { - value := manifest.Default() - value.Integration.CheckoutModes = []string{"popup"} - value.Integration.NotificationRoute = "/api/payment/webhook" - value.Integration.FinishRedirectRoute = "/orders/{order_id}" - value.Integration.LocalBaseURL = "http://127.0.0.1:3101" - value.Integration.LocalStatusRoute = "/api/dev/midtrans/{order_id}" + value := readySnapManifest() report := readiness.Build(readiness.Input{ ProjectRoot: "/tmp/store", @@ -58,8 +53,10 @@ func TestBuildNeverIncludesCredentialValues(t *testing.T) { } func TestBuildFailsForInvalidManifestBeforeCredentialSetup(t *testing.T) { - value := manifest.Default() - value.Credentials.References["server_key"] = "not-an-environment-reference" + value := readySnapManifest() + credentials := value.CredentialSets["classic"] + credentials.ServerKey = "not-an-environment-reference" + value.CredentialSets["classic"] = credentials report := readiness.Build(readiness.Input{ ProjectRoot: "/tmp/store", @@ -113,12 +110,7 @@ func TestBuildSortsPackFindingsAfterCoreChecks(t *testing.T) { } func TestBuildPassesOnlyWhenEveryCheckIsReady(t *testing.T) { - value := manifest.Default() - value.Integration.CheckoutModes = []string{"popup"} - value.Integration.NotificationRoute = "/api/payment/webhook" - value.Integration.FinishRedirectRoute = "/orders/{order_id}" - value.Integration.LocalBaseURL = "http://127.0.0.1:3101" - value.Integration.LocalStatusRoute = "/api/dev/midtrans/{order_id}" + value := readySnapManifest() report := readiness.Build(readiness.Input{ ProjectRoot: "/tmp/store", @@ -148,3 +140,27 @@ func assertCheck(t *testing.T, report readiness.Report, id string, state readine } t.Fatalf("check %q not found", id) } + +func readySnapManifest() manifest.Manifest { + value := manifest.Default() + value.Application.BaseURL = "http://127.0.0.1:3101" + value.CredentialSets["classic"] = manifest.CredentialSet{ + Type: "classic", + Environment: "sandbox", + ServerKey: "env:MIDTRANS_SERVER_KEY", + ClientKey: "env:MIDTRANS_CLIENT_KEY", + } + value.Integrations["snap"] = manifest.Integration{ + ConfigVersion: 1, + Credentials: "classic", + Profiles: []string{"web-popup"}, + Callbacks: map[string]string{ + "notification": "/api/payment/webhook", + "finish": "/orders/{order_id}", + "status": "/api/dev/midtrans/{order_id}", + }, + } + value.Routing["checkout"] = "snap" + value.Verification.Required = []string{"snap.checkout"} + return value +} diff --git a/internal/secrets/environment.go b/internal/secrets/environment.go index 5db1f57..a0b9324 100644 --- a/internal/secrets/environment.go +++ b/internal/secrets/environment.go @@ -1,6 +1,9 @@ package secrets -import "context" +import ( + "context" + "strings" +) type EnvironmentProvider struct { lookup func(string) (string, bool) @@ -11,6 +14,9 @@ func NewEnvironmentProvider(lookup func(string) (string, bool)) EnvironmentProvi } func (p EnvironmentProvider) Resolve(_ context.Context, reference string) (Value, error) { + if strings.HasPrefix(reference, "env:") { + reference = strings.TrimPrefix(reference, "env:") + } if p.lookup == nil { return Value{}, ErrMissing } diff --git a/packs/snap/local_verify.go b/packs/snap/local_verify.go index c8aa54d..174ed58 100644 --- a/packs/snap/local_verify.go +++ b/packs/snap/local_verify.go @@ -35,17 +35,21 @@ func (v MerchantVerifier) VerifyLocal( if _, err := v.ServerKey.SandboxServerKey(); err != nil { return LocalVerificationResult{}, err } + _, integration, ok := v.Manifest.CheckoutIntegration() + if !ok { + return LocalVerificationResult{}, errLocalVerification + } notificationURL, err := localURL( - v.Manifest.Integration.LocalBaseURL, - v.Manifest.Integration.NotificationRoute, + v.Manifest.Application.BaseURL, + integration.Callbacks["notification"], "", ) if err != nil { return LocalVerificationResult{}, errLocalVerification } statusURL, err := localURL( - v.Manifest.Integration.LocalBaseURL, - v.Manifest.Integration.LocalStatusRoute, + v.Manifest.Application.BaseURL, + integration.Callbacks["status"], input.OrderID, ) if err != nil { diff --git a/packs/snap/local_verify_test.go b/packs/snap/local_verify_test.go index dd95ad0..1ceee48 100644 --- a/packs/snap/local_verify_test.go +++ b/packs/snap/local_verify_test.go @@ -141,13 +141,7 @@ func TestLocalVerifierAppliesSettlement(t *testing.T) { func TestLocalVerifierRejectsProductionServerKeyBeforeHTTP(t *testing.T) { httpCalls := 0 verifier := snap.MerchantVerifier{ - Manifest: manifest.Manifest{ - Integration: manifest.Integration{ - LocalBaseURL: "http://127.0.0.1:1", - NotificationRoute: "/midtrans/notification", - LocalStatusRoute: "/payments/{order_id}", - }, - }, + Manifest: configuredLocalManifest("http://127.0.0.1:1"), ServerKey: secrets.NewValue("Mid-server-PRODUCTION-CANARY-DO-NOT-PRINT"), HTTP: &http.Client{Transport: localRoundTripFunc(func(*http.Request) (*http.Response, error) { httpCalls++ @@ -310,13 +304,33 @@ func newMerchantVerifierHarness( serverKey: localVerifierServerKey, } server := httptest.NewServer(harness) - value := manifest.Default() - value.Integration.LocalBaseURL = server.URL - value.Integration.NotificationRoute = "/midtrans/notification" - value.Integration.LocalStatusRoute = "/payments/{order_id}" + value := configuredLocalManifest(server.URL) return harness, snap.MerchantVerifier{ Manifest: value, ServerKey: secrets.NewValue(localVerifierServerKey), HTTP: server.Client(), }, server.Close } + +func configuredLocalManifest(baseURL string) manifest.Manifest { + value := manifest.Default() + value.Application.BaseURL = baseURL + value.CredentialSets["classic"] = manifest.CredentialSet{ + Type: "classic", + Environment: "sandbox", + ServerKey: "env:MIDTRANS_SERVER_KEY", + ClientKey: "env:MIDTRANS_CLIENT_KEY", + } + value.Integrations["snap"] = manifest.Integration{ + ConfigVersion: 1, + Credentials: "classic", + Profiles: []string{"web-redirect"}, + Callbacks: map[string]string{ + "notification": "/midtrans/notification", + "finish": "/payments/finish", + "status": "/payments/{order_id}", + }, + } + value.Routing["checkout"] = "snap" + return value +} diff --git a/packs/snap/pack.go b/packs/snap/pack.go index 579876f..2b9e7a5 100644 --- a/packs/snap/pack.go +++ b/packs/snap/pack.go @@ -57,50 +57,54 @@ func (Pack) Descriptor() packs.Descriptor { func (Pack) Evaluate(value manifest.Manifest, report inspection.Report) []contracts.Finding { var findings []contracts.Finding - if !slices.Contains(value.Products, "snap") { + integration, ok := value.IntegrationFor("snap") + if !ok { findings = append(findings, contracts.Finding{ Code: "SNAP_PRODUCT_NOT_SELECTED", Severity: "blocking", - Message: "products must include snap", + Message: "integrations must include snap", }) + return findings } - if value.Integration.NotificationRoute == "" { + if integration.Callbacks["notification"] == "" { findings = append(findings, contracts.Finding{ Code: "SNAP_NOTIFICATION_ROUTE_MISSING", Severity: "blocking", - Message: "integration.notification_route is required", + Message: "integrations.snap.callbacks.notification is required", }) } - if value.Integration.FinishRedirectRoute == "" { + if integration.Callbacks["finish"] == "" { findings = append(findings, contracts.Finding{ Code: "SNAP_FINISH_REDIRECT_MISSING", Severity: "blocking", - Message: "integration.finish_redirect_route is required", + Message: "integrations.snap.callbacks.finish is required", }) } - if value.Integration.LocalBaseURL == "" { + if value.Application.BaseURL == "" { findings = append(findings, contracts.Finding{ Code: "SNAP_LOCAL_BASE_URL_MISSING", Severity: "blocking", - Message: "integration.local_base_url is required", + Message: "application.base_url is required", }) } - if !slices.Contains(value.Integration.CheckoutModes, "redirect") && - !slices.Contains(value.Integration.CheckoutModes, "popup") { + if !slices.Contains(integration.Profiles, "web-redirect") && + !slices.Contains(integration.Profiles, "web-popup") { findings = append(findings, contracts.Finding{ Code: "SNAP_CHECKOUT_MODE_MISSING", Severity: "blocking", - Message: "checkout_modes must include redirect or popup", + Message: "integrations.snap.profiles must include web-redirect or web-popup", }) } - if value.Integration.LocalStatusRoute == "" { + if integration.Callbacks["status"] == "" { findings = append(findings, contracts.Finding{ Code: "SNAP_LOCAL_STATUS_ROUTE_MISSING", Severity: "blocking", - Message: "integration.local_status_route is required and must contain {order_id}", + Message: "integrations.snap.callbacks.status is required and must contain {order_id}", }) } - if !value.StatePolicy.Monotonic { + if !value.Application.PaymentState.Monotonic { findings = append(findings, contracts.Finding{ Code: "PAYMENT_STATE_NOT_MONOTONIC", Severity: "blocking", - Message: "state_policy.monotonic must be true", + Message: "application.payment_state.monotonic must be true", }) } - if len(report.Facts) > 0 && !report.Has("midtrans.server-key-reference") { + credentials, hasCredentials := value.CredentialSetFor(integration.Credentials) + if hasCredentials && credentials.ServerKey != "" && len(report.Facts) > 0 && + !report.Has("midtrans.server-key-reference") { findings = append(findings, contracts.Finding{ Code: "SNAP_SERVER_KEY_REFERENCE_NOT_FOUND", Severity: "warning", Message: "repository inspection did not find the configured server-key reference", diff --git a/packs/snap/pack_test.go b/packs/snap/pack_test.go index 6600aab..bfee23e 100644 --- a/packs/snap/pack_test.go +++ b/packs/snap/pack_test.go @@ -63,7 +63,9 @@ func TestSnapDescriptorMatchesCompiledContract(t *testing.T) { func TestSnapRequiresNotificationRoute(t *testing.T) { value := validSnapManifest() - value.Integration.NotificationRoute = "" + integration := value.Integrations["snap"] + integration.Callbacks["notification"] = "" + value.Integrations["snap"] = integration findings := snap.New().Evaluate(value, inspection.Report{}) if len(findings) == 0 || findings[0].Code != "SNAP_NOTIFICATION_ROUTE_MISSING" { t.Fatalf("findings = %#v", findings) @@ -72,8 +74,7 @@ func TestSnapRequiresNotificationRoute(t *testing.T) { func TestSnapEvaluationReportsRequirementsInDeterministicOrder(t *testing.T) { value := manifest.Default() - value.Products = nil - value.StatePolicy.Monotonic = false + value.Application.PaymentState.Monotonic = false findings := snap.New().Evaluate(value, inspection.Report{ Facts: []inspection.Fact{{Kind: "repository.file", Path: "main.go"}}, }) @@ -84,13 +85,34 @@ func TestSnapEvaluationReportsRequirementsInDeterministicOrder(t *testing.T) { } wantCodes := []string{ "SNAP_PRODUCT_NOT_SELECTED", + } + if !reflect.DeepEqual(gotCodes, wantCodes) { + t.Fatalf("finding codes = %#v, want %#v", gotCodes, wantCodes) + } +} + +func TestSnapEvaluationReportsRequirementsForConfiguredSnapInDeterministicOrder(t *testing.T) { + value := manifest.Default() + value.Application.PaymentState.Monotonic = false + value.Integrations["snap"] = manifest.Integration{ + ConfigVersion: 1, + Credentials: "classic", + } + findings := snap.New().Evaluate(value, inspection.Report{ + Facts: []inspection.Fact{{Kind: "repository.file", Path: "main.go"}}, + }) + + gotCodes := make([]string, 0, len(findings)) + for _, finding := range findings { + gotCodes = append(gotCodes, finding.Code) + } + wantCodes := []string{ "SNAP_NOTIFICATION_ROUTE_MISSING", "SNAP_FINISH_REDIRECT_MISSING", "SNAP_LOCAL_BASE_URL_MISSING", "SNAP_CHECKOUT_MODE_MISSING", "SNAP_LOCAL_STATUS_ROUTE_MISSING", "PAYMENT_STATE_NOT_MONOTONIC", - "SNAP_SERVER_KEY_REFERENCE_NOT_FOUND", } if !reflect.DeepEqual(gotCodes, wantCodes) { t.Fatalf("finding codes = %#v, want %#v", gotCodes, wantCodes) @@ -99,10 +121,23 @@ func TestSnapEvaluationReportsRequirementsInDeterministicOrder(t *testing.T) { func validSnapManifest() manifest.Manifest { value := manifest.Default() - value.Integration.NotificationRoute = "/notifications" - value.Integration.FinishRedirectRoute = "/finish" - value.Integration.LocalBaseURL = "http://127.0.0.1:8080" - value.Integration.LocalStatusRoute = "/payments/{order_id}" - value.Integration.CheckoutModes = []string{"redirect"} + value.Application.BaseURL = "http://127.0.0.1:8080" + value.CredentialSets["classic"] = manifest.CredentialSet{ + Type: "classic", + Environment: "sandbox", + ServerKey: "env:MIDTRANS_SERVER_KEY", + ClientKey: "env:MIDTRANS_CLIENT_KEY", + } + value.Integrations["snap"] = manifest.Integration{ + ConfigVersion: 1, + Credentials: "classic", + Profiles: []string{"web-redirect"}, + Callbacks: map[string]string{ + "notification": "/notifications", + "finish": "/finish", + "status": "/payments/{order_id}", + }, + } + value.Routing["checkout"] = "snap" return value } diff --git a/schemas/manifest-v1.schema.json b/schemas/manifest-v1.schema.json index bdd1465..7b74c28 100644 --- a/schemas/manifest-v1.schema.json +++ b/schemas/manifest-v1.schema.json @@ -6,76 +6,192 @@ "additionalProperties": false, "required": [ "schema_version", - "environment_policy", - "products", - "integration", - "state_policy", - "credentials", - "required_journeys" + "policy", + "application", + "credential_sets", + "integrations", + "routing", + "verification" ], "properties": { - "schema_version": {"const": 1}, - "environment_policy": { + "schema_version": { + "const": 1 + }, + "policy": { "type": "object", "additionalProperties": false, - "required": ["allowed", "production"], + "required": [ + "environments", + "production" + ], "properties": { - "allowed": {"const": ["sandbox"]}, - "production": {"const": "disabled"} + "environments": { + "const": [ + "sandbox" + ] + }, + "production": { + "const": "deny" + } } }, - "products": {"type": "array", "items": {"type": "string"}, "minItems": 1}, - "integration": { + "application": { "type": "object", "additionalProperties": false, "required": [ - "checkout_modes", - "notification_route", - "finish_redirect_route", - "local_base_url", - "local_status_route", - "remote_webhook_hosts" + "base_url", + "payment_state" ], "properties": { - "checkout_modes": {"type": "array", "items": {"type": "string"}}, - "notification_route": {"type": "string"}, - "finish_redirect_route": {"type": "string"}, - "local_base_url": {"type": "string"}, - "local_status_route": {"type": "string"}, - "remote_webhook_hosts": { - "type": "array", - "items": {"type": "string"}, - "uniqueItems": true + "base_url": { + "type": "string" + }, + "payment_state": { + "type": "object", + "additionalProperties": false, + "required": [ + "paid", + "terminal", + "monotonic" + ], + "properties": { + "paid": { + "type": "array", + "items": { + "type": "string", + "minLength": 1 + }, + "uniqueItems": true + }, + "terminal": { + "type": "array", + "items": { + "type": "string", + "minLength": 1 + }, + "uniqueItems": true + }, + "monotonic": { + "const": true + } + } } } }, - "state_policy": { + "credential_sets": { "type": "object", - "additionalProperties": false, - "required": ["paid", "terminal", "monotonic"], - "properties": { - "paid": {"type": "array", "items": {"type": "string"}}, - "terminal": {"type": "array", "items": {"type": "string"}}, - "monotonic": {"const": true} + "additionalProperties": { + "type": "object", + "additionalProperties": false, + "required": [ + "type", + "environment" + ], + "properties": { + "type": { + "type": "string" + }, + "environment": { + "type": "string" + }, + "server_key": { + "type": "string", + "pattern": "^(env:[A-Z][A-Z0-9_]*|file:\\./[^[:cntrl:]]+)$" + }, + "client_key": { + "type": "string", + "pattern": "^(env:[A-Z][A-Z0-9_]*|file:\\./[^[:cntrl:]]+)$" + }, + "client_id": { + "type": "string", + "pattern": "^(env:[A-Z][A-Z0-9_]*|file:\\./[^[:cntrl:]]+)$" + }, + "client_secret": { + "type": "string", + "pattern": "^(env:[A-Z][A-Z0-9_]*|file:\\./[^[:cntrl:]]+)$" + }, + "partner_id": { + "type": "string", + "pattern": "^(env:[A-Z][A-Z0-9_]*|file:\\./[^[:cntrl:]]+)$" + }, + "channel_id": { + "type": "string", + "pattern": "^(env:[A-Z][A-Z0-9_]*|file:\\./[^[:cntrl:]]+)$" + }, + "private_key": { + "type": "string", + "pattern": "^(env:[A-Z][A-Z0-9_]*|file:\\./[^[:cntrl:]]+)$" + }, + "midtrans_public_key": { + "type": "string", + "pattern": "^(env:[A-Z][A-Z0-9_]*|file:\\./[^[:cntrl:]]+)$" + } + } + } + }, + "integrations": { + "type": "object", + "additionalProperties": { + "type": "object", + "additionalProperties": false, + "required": [ + "config_version", + "credentials" + ], + "properties": { + "config_version": { + "const": 1 + }, + "credentials": { + "type": "string" + }, + "profiles": { + "type": "array", + "items": { + "type": "string" + } + }, + "payment_methods": { + "type": "array", + "items": { + "type": "string" + } + }, + "capabilities": { + "type": "array", + "items": { + "type": "string" + } + }, + "callbacks": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + }, + "routing": { + "type": "object", + "additionalProperties": { + "type": "string" } }, - "credentials": { + "verification": { "type": "object", "additionalProperties": false, - "required": ["provider", "references"], + "required": [ + "required" + ], "properties": { - "provider": {"const": "environment"}, - "references": { - "type": "object", - "additionalProperties": false, - "required": ["server_key", "client_key"], - "properties": { - "server_key": {"type": "string", "pattern": "^[A-Z][A-Z0-9_]*$"}, - "client_key": {"type": "string", "pattern": "^[A-Z][A-Z0-9_]*$"} + "required": { + "type": "array", + "items": { + "type": "string" } } } - }, - "required_journeys": {"type": "array", "items": {"type": "string"}} + } } } diff --git a/testdata/merchant-repos/snap-broken/.midtrans/manifest.yaml b/testdata/merchant-repos/snap-broken/.midtrans/manifest.yaml index 1f21389..f42ff42 100644 --- a/testdata/merchant-repos/snap-broken/.midtrans/manifest.yaml +++ b/testdata/merchant-repos/snap-broken/.midtrans/manifest.yaml @@ -1,25 +1,37 @@ schema_version: 1 -environment_policy: - allowed: [sandbox] - production: disabled -products: [snap] -integration: - checkout_modes: [redirect] - notification_route: "" - finish_redirect_route: /checkout/complete - local_base_url: http://127.0.0.1:3000 - local_status_route: /api/payments/midtrans/status/{order_id} - remote_webhook_hosts: [] -state_policy: - paid: [capture, settlement] - terminal: [settlement, deny, cancel, expire] - monotonic: true -credentials: - provider: environment - references: - server_key: MIDTRANS_SERVER_KEY - client_key: MIDTRANS_CLIENT_KEY -required_journeys: - - snap.checkout - - common.webhook-idempotency - - common.status-reconciliation +policy: + environments: + - sandbox + production: deny +application: + base_url: http://127.0.0.1:3000 + payment_state: + paid: + - paid + terminal: + - paid + - failed + monotonic: true +credential_sets: + classic: + type: classic + environment: sandbox + server_key: env:MIDTRANS_SERVER_KEY + client_key: env:MIDTRANS_CLIENT_KEY +integrations: + snap: + config_version: 1 + credentials: classic + profiles: + - web-redirect + callbacks: + notification: "" + finish: /orders/{order_id} + status: /api/payments/midtrans/status/{order_id} +routing: + checkout: snap +verification: + required: + - snap.checkout + - common.webhook-idempotency + - common.status-reconciliation diff --git a/testdata/merchant-repos/snap-complete/.midtrans/manifest.yaml b/testdata/merchant-repos/snap-complete/.midtrans/manifest.yaml index 75652c2..ec5b15a 100644 --- a/testdata/merchant-repos/snap-complete/.midtrans/manifest.yaml +++ b/testdata/merchant-repos/snap-complete/.midtrans/manifest.yaml @@ -1,25 +1,37 @@ schema_version: 1 -environment_policy: - allowed: [sandbox] - production: disabled -products: [snap] -integration: - checkout_modes: [redirect] - notification_route: /api/payments/midtrans/notification - finish_redirect_route: /checkout/complete - local_base_url: http://127.0.0.1:3000 - local_status_route: /api/payments/midtrans/status/{order_id} - remote_webhook_hosts: [] -state_policy: - paid: [capture, settlement] - terminal: [settlement, deny, cancel, expire] - monotonic: true -credentials: - provider: environment - references: - server_key: MIDTRANS_SERVER_KEY - client_key: MIDTRANS_CLIENT_KEY -required_journeys: - - snap.checkout - - common.webhook-idempotency - - common.status-reconciliation +policy: + environments: + - sandbox + production: deny +application: + base_url: http://127.0.0.1:3000 + payment_state: + paid: + - paid + terminal: + - paid + - failed + monotonic: true +credential_sets: + classic: + type: classic + environment: sandbox + server_key: env:MIDTRANS_SERVER_KEY + client_key: env:MIDTRANS_CLIENT_KEY +integrations: + snap: + config_version: 1 + credentials: classic + profiles: + - web-redirect + callbacks: + notification: /api/payments/midtrans/notification + finish: /orders/{order_id} + status: /api/payments/midtrans/status/{order_id} +routing: + checkout: snap +verification: + required: + - snap.checkout + - common.webhook-idempotency + - common.status-reconciliation From 0953dbed0dcc1f76ed157433bfe83f09e8ac12dd Mon Sep 17 00:00:00 2001 From: "m.salis" Date: Mon, 27 Jul 2026 08:22:52 +0700 Subject: [PATCH 25/73] fix: tighten hybrid manifest validation --- .../task-1-report.md | 27 +++ internal/manifest/manifest_test.go | 214 +++++++++++++++++- internal/manifest/validate.go | 52 ++++- schemas/manifest-v1.schema.json | 66 +++++- 4 files changed, 346 insertions(+), 13 deletions(-) diff --git a/.superpowers/sdd/2026-07-26-midtrans-cli-multi-product-parity/task-1-report.md b/.superpowers/sdd/2026-07-26-midtrans-cli-multi-product-parity/task-1-report.md index d2cec1e..7bb96a4 100644 --- a/.superpowers/sdd/2026-07-26-midtrans-cli-multi-product-parity/task-1-report.md +++ b/.superpowers/sdd/2026-07-26-midtrans-cli-multi-product-parity/task-1-report.md @@ -34,3 +34,30 @@ All commands passed on July 27, 2026. - No migration shim was retained for the removed Snap-only manifest shape. - Remote webhook allowlists were not reintroduced into the clean public manifest; replay remains constrained by the existing policy layer until a later task defines that product-pack surface explicitly. + +## Fix Round 1 + +### Review items addressed + +- Tightened `file:./...` credential-reference validation to reject traversal-like and malformed project-relative paths such as `file:./../outside-secret`, absolute paths, and empty path segments, without implementing Task 2 runtime file resolution. +- Required supported non-empty credential-set types and sandbox environment values, and enforced type-appropriate required references for `classic` and `bisnap` consistently in Go validation and JSON schema. +- Required `application.payment_state.paid` and `application.payment_state.terminal` to contain at least one unique non-empty state in both Go validation and JSON schema. + +### Added or adjusted tests + +- Expanded `TestCleanManifest` with malformed `file:` reference cases. +- Added `TestValidateRejectsInvalidCredentialSetDefinitions`. +- Added `TestValidateRejectsEmptyPaymentStateArrays`. +- Added `TestManifestSchemaRequiresCredentialSetTypeAndPaymentStates`. + +### Commands run + +```sh +go test ./internal/manifest ./internal/app -count=1 +go test ./... -count=1 +``` + +### Results + +- `go test ./internal/manifest ./internal/app -count=1` passed on July 27, 2026. +- `go test ./... -count=1` passed on July 27, 2026. diff --git a/internal/manifest/manifest_test.go b/internal/manifest/manifest_test.go index c0a47df..0d54f86 100644 --- a/internal/manifest/manifest_test.go +++ b/internal/manifest/manifest_test.go @@ -297,6 +297,52 @@ integrations: credentials: classic routing: {checkout: snap} verification: {required: [snap.checkout]} +`, + wantCode: "CREDENTIAL_REFERENCE_INVALID", + }, + { + name: "rejects file traversal reference", + content: ` +schema_version: 1 +policy: {environments: [sandbox], production: deny} +application: + base_url: http://127.0.0.1:3000 + payment_state: {paid: [paid], terminal: [paid], monotonic: true} +credential_sets: + classic: + type: classic + environment: sandbox + server_key: file:./../outside-secret + client_key: env:MIDTRANS_CLIENT_KEY +integrations: + snap: + config_version: 1 + credentials: classic +routing: {checkout: snap} +verification: {required: [snap.checkout]} +`, + wantCode: "CREDENTIAL_REFERENCE_INVALID", + }, + { + name: "rejects empty file path segment reference", + content: ` +schema_version: 1 +policy: {environments: [sandbox], production: deny} +application: + base_url: http://127.0.0.1:3000 + payment_state: {paid: [paid], terminal: [paid], monotonic: true} +credential_sets: + classic: + type: classic + environment: sandbox + server_key: file:./secrets//server.key + client_key: env:MIDTRANS_CLIENT_KEY +integrations: + snap: + config_version: 1 + credentials: classic +routing: {checkout: snap} +verification: {required: [snap.checkout]} `, wantCode: "CREDENTIAL_REFERENCE_INVALID", }, @@ -566,6 +612,125 @@ func TestValidateRejectsNonEnvironmentCredentialReferencesWithoutEcho(t *testing } } +func TestValidateRejectsInvalidCredentialSetDefinitions(t *testing.T) { + tests := []struct { + name string + mutate func(manifest.Manifest) manifest.Manifest + wantCode string + }{ + { + name: "empty type", + mutate: func(value manifest.Manifest) manifest.Manifest { + credentials := value.CredentialSets["classic"] + credentials.Type = "" + value.CredentialSets["classic"] = credentials + return value + }, + wantCode: "CREDENTIAL_SET_TYPE_INVALID", + }, + { + name: "unsupported type", + mutate: func(value manifest.Manifest) manifest.Manifest { + credentials := value.CredentialSets["classic"] + credentials.Type = "wallet" + value.CredentialSets["classic"] = credentials + return value + }, + wantCode: "CREDENTIAL_SET_TYPE_INVALID", + }, + { + name: "empty environment", + mutate: func(value manifest.Manifest) manifest.Manifest { + credentials := value.CredentialSets["classic"] + credentials.Environment = "" + value.CredentialSets["classic"] = credentials + return value + }, + wantCode: "CREDENTIAL_SET_ENVIRONMENT_INVALID", + }, + { + name: "missing classic server key", + mutate: func(value manifest.Manifest) manifest.Manifest { + credentials := value.CredentialSets["classic"] + credentials.ServerKey = "" + value.CredentialSets["classic"] = credentials + return value + }, + wantCode: "CREDENTIAL_REFERENCE_MISSING", + }, + { + name: "missing classic client key", + mutate: func(value manifest.Manifest) manifest.Manifest { + credentials := value.CredentialSets["classic"] + credentials.ClientKey = "" + value.CredentialSets["classic"] = credentials + return value + }, + wantCode: "CREDENTIAL_REFERENCE_MISSING", + }, + { + name: "missing bisnap key material", + mutate: func(value manifest.Manifest) manifest.Manifest { + value.CredentialSets["bisnap"] = manifest.CredentialSet{ + Type: "bisnap", + Environment: "sandbox", + ClientID: "env:MIDTRANS_BISNAP_CLIENT_ID", + PartnerID: "env:MIDTRANS_BISNAP_PARTNER_ID", + ChannelID: "env:MIDTRANS_BISNAP_CHANNEL_ID", + PrivateKey: "file:./secrets/bisnap-private.pem", + } + value.Integrations["bisnap"] = manifest.Integration{ + ConfigVersion: 1, + Credentials: "bisnap", + } + return value + }, + wantCode: "CREDENTIAL_REFERENCE_MISSING", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + value := test.mutate(configuredManifest(manifest.Default())) + findings := manifest.Validate(value) + if !hasFinding(findings, test.wantCode) { + t.Fatalf("Validate() findings = %#v, want %q", findings, test.wantCode) + } + }) + } +} + +func TestValidateRejectsEmptyPaymentStateArrays(t *testing.T) { + tests := []struct { + name string + mutate func(manifest.Manifest) manifest.Manifest + }{ + { + name: "empty paid states", + mutate: func(value manifest.Manifest) manifest.Manifest { + value.Application.PaymentState.Paid = nil + return value + }, + }, + { + name: "empty terminal states", + mutate: func(value manifest.Manifest) manifest.Manifest { + value.Application.PaymentState.Terminal = []string{} + return value + }, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + findings := manifest.Validate(test.mutate(configuredManifest(manifest.Default()))) + if !hasFinding(findings, "PAYMENT_STATE_INVALID") { + t.Fatalf("Validate() findings = %#v", findings) + } + }) + } +} + func TestManifestSchemaUsesEnvironmentReferencePattern(t *testing.T) { data, err := os.ReadFile(filepath.Join("..", "..", "schemas", "manifest-v1.schema.json")) if err != nil { @@ -576,7 +741,7 @@ func TestManifestSchemaUsesEnvironmentReferencePattern(t *testing.T) { t.Fatal(err) } credentialSets := schemaObjectAt(t, schema, "properties", "credential_sets", "additionalProperties", "properties") - const wantPattern = "^(env:[A-Z][A-Z0-9_]*|file:\\./[^[:cntrl:]]+)$" + const wantPattern = "^(env:[A-Z][A-Z0-9_]*|file:\\./(?:[A-Za-z0-9_][A-Za-z0-9._-]*)(?:/[A-Za-z0-9_][A-Za-z0-9._-]*)*)$" for _, key := range []string{"server_key", "client_key"} { property := schemaObjectAt(t, credentialSets, key) if property["pattern"] != wantPattern { @@ -585,6 +750,32 @@ func TestManifestSchemaUsesEnvironmentReferencePattern(t *testing.T) { } } +func TestManifestSchemaRequiresCredentialSetTypeAndPaymentStates(t *testing.T) { + data, err := os.ReadFile(filepath.Join("..", "..", "schemas", "manifest-v1.schema.json")) + if err != nil { + t.Fatal(err) + } + var schema map[string]any + if err := json.Unmarshal(data, &schema); err != nil { + t.Fatal(err) + } + credentialSets := schemaObjectAt(t, schema, "properties", "credential_sets", "additionalProperties", "properties") + types := schemaArrayAt(t, credentialSets, "type", "enum") + if !reflect.DeepEqual(types, []any{"classic", "bisnap"}) { + t.Fatalf("credential set type enum = %#v", types) + } + if credentialSets["environment"].(map[string]any)["const"] != "sandbox" { + t.Fatalf("credential set environment = %#v", credentialSets["environment"]) + } + paymentState := schemaObjectAt(t, schema, "properties", "application", "properties", "payment_state", "properties") + for _, key := range []string{"paid", "terminal"} { + property := schemaObjectAt(t, paymentState, key) + if property["minItems"] != float64(1) { + t.Fatalf("%s minItems = %#v, want 1", key, property["minItems"]) + } + } +} + func configuredManifest(value manifest.Manifest) manifest.Manifest { value.Application.BaseURL = "http://127.0.0.1:3101" value.CredentialSets["classic"] = manifest.CredentialSet{ @@ -641,3 +832,24 @@ func schemaObjectAt(t *testing.T, root map[string]any, path ...string) map[strin } return current } + +func schemaArrayAt(t *testing.T, root map[string]any, path ...string) []any { + t.Helper() + current := root + for index, key := range path { + if index == len(path)-1 { + next, ok := current[key].([]any) + if !ok { + t.Fatalf("schema path %q is not an array", strings.Join(path, ".")) + } + return next + } + next, ok := current[key].(map[string]any) + if !ok { + t.Fatalf("schema path %q is not an object", strings.Join(path, ".")) + } + current = next + } + t.Fatal("schema array path missing") + return nil +} diff --git a/internal/manifest/validate.go b/internal/manifest/validate.go index 59754e6..6cdcd87 100644 --- a/internal/manifest/validate.go +++ b/internal/manifest/validate.go @@ -12,9 +12,14 @@ import ( var ( environmentReferencePattern = regexp.MustCompile(`^env:[A-Z][A-Z0-9_]*$`) - fileReferencePattern = regexp.MustCompile(`^file:\./[^[:cntrl:]]+$`) + fileReferencePattern = regexp.MustCompile(`^file:\./(?:[A-Za-z0-9_][A-Za-z0-9._-]*)(?:/[A-Za-z0-9_][A-Za-z0-9._-]*)*$`) ) +var requiredCredentialReferencesByType = map[string][]string{ + "classic": {"server_key", "client_key"}, + "bisnap": {"client_id", "partner_id", "channel_id", "private_key", "midtrans_public_key"}, +} + func Validate(value Manifest) []contracts.Finding { var findings []contracts.Finding if value.SchemaVersion != 1 { @@ -81,7 +86,15 @@ func validateCredentialSets(sets map[string]CredentialSet) []contracts.Finding { Message: "credential set names must be non-empty", }) } - if set.Environment != "" && set.Environment != "sandbox" { + requiredReferences, ok := requiredCredentialReferencesByType[set.Type] + if !ok { + findings = append(findings, contracts.Finding{ + Code: "CREDENTIAL_SET_TYPE_INVALID", + Severity: "blocking", + Message: "credential sets must declare a supported type", + }) + } + if set.Environment != "sandbox" { findings = append(findings, contracts.Finding{ Code: "CREDENTIAL_SET_ENVIRONMENT_INVALID", Severity: "blocking", @@ -98,6 +111,15 @@ func validateCredentialSets(sets map[string]CredentialSet) []contracts.Finding { break } } + for _, key := range requiredReferences { + if credentialReferenceForKey(set, key) == "" { + findings = append(findings, contracts.Finding{ + Code: "CREDENTIAL_REFERENCE_MISSING", + Severity: "blocking", + Message: "credential sets must include required references for their type", + }) + } + } } return findings } @@ -207,6 +229,9 @@ func credentialReferences(set CredentialSet) []string { } func hasEmptyOrDuplicate(values []string) bool { + if len(values) == 0 { + return true + } seen := make(map[string]struct{}, len(values)) for _, value := range values { if value == "" { @@ -225,6 +250,29 @@ func validCredentialReference(reference string) bool { fileReferencePattern.MatchString(reference) } +func credentialReferenceForKey(set CredentialSet, key string) string { + switch key { + case "server_key": + return set.ServerKey + case "client_key": + return set.ClientKey + case "client_id": + return set.ClientID + case "client_secret": + return set.ClientSecret + case "partner_id": + return set.PartnerID + case "channel_id": + return set.ChannelID + case "private_key": + return set.PrivateKey + case "midtrans_public_key": + return set.MidtransPublicKey + default: + return "" + } +} + func isLoopbackURL(raw string) bool { base, err := url.Parse(raw) valid := err == nil && diff --git a/schemas/manifest-v1.schema.json b/schemas/manifest-v1.schema.json index 7b74c28..9894b8d 100644 --- a/schemas/manifest-v1.schema.json +++ b/schemas/manifest-v1.schema.json @@ -57,6 +57,7 @@ "properties": { "paid": { "type": "array", + "minItems": 1, "items": { "type": "string", "minLength": 1 @@ -65,6 +66,7 @@ }, "terminal": { "type": "array", + "minItems": 1, "items": { "type": "string", "minLength": 1 @@ -87,44 +89,88 @@ "type", "environment" ], + "allOf": [ + { + "if": { + "properties": { + "type": { + "const": "classic" + } + }, + "required": [ + "type" + ] + }, + "then": { + "required": [ + "server_key", + "client_key" + ] + } + }, + { + "if": { + "properties": { + "type": { + "const": "bisnap" + } + }, + "required": [ + "type" + ] + }, + "then": { + "required": [ + "client_id", + "partner_id", + "channel_id", + "private_key", + "midtrans_public_key" + ] + } + } + ], "properties": { "type": { - "type": "string" + "enum": [ + "classic", + "bisnap" + ] }, "environment": { - "type": "string" + "const": "sandbox" }, "server_key": { "type": "string", - "pattern": "^(env:[A-Z][A-Z0-9_]*|file:\\./[^[:cntrl:]]+)$" + "pattern": "^(env:[A-Z][A-Z0-9_]*|file:\\./(?:[A-Za-z0-9_][A-Za-z0-9._-]*)(?:/[A-Za-z0-9_][A-Za-z0-9._-]*)*)$" }, "client_key": { "type": "string", - "pattern": "^(env:[A-Z][A-Z0-9_]*|file:\\./[^[:cntrl:]]+)$" + "pattern": "^(env:[A-Z][A-Z0-9_]*|file:\\./(?:[A-Za-z0-9_][A-Za-z0-9._-]*)(?:/[A-Za-z0-9_][A-Za-z0-9._-]*)*)$" }, "client_id": { "type": "string", - "pattern": "^(env:[A-Z][A-Z0-9_]*|file:\\./[^[:cntrl:]]+)$" + "pattern": "^(env:[A-Z][A-Z0-9_]*|file:\\./(?:[A-Za-z0-9_][A-Za-z0-9._-]*)(?:/[A-Za-z0-9_][A-Za-z0-9._-]*)*)$" }, "client_secret": { "type": "string", - "pattern": "^(env:[A-Z][A-Z0-9_]*|file:\\./[^[:cntrl:]]+)$" + "pattern": "^(env:[A-Z][A-Z0-9_]*|file:\\./(?:[A-Za-z0-9_][A-Za-z0-9._-]*)(?:/[A-Za-z0-9_][A-Za-z0-9._-]*)*)$" }, "partner_id": { "type": "string", - "pattern": "^(env:[A-Z][A-Z0-9_]*|file:\\./[^[:cntrl:]]+)$" + "pattern": "^(env:[A-Z][A-Z0-9_]*|file:\\./(?:[A-Za-z0-9_][A-Za-z0-9._-]*)(?:/[A-Za-z0-9_][A-Za-z0-9._-]*)*)$" }, "channel_id": { "type": "string", - "pattern": "^(env:[A-Z][A-Z0-9_]*|file:\\./[^[:cntrl:]]+)$" + "pattern": "^(env:[A-Z][A-Z0-9_]*|file:\\./(?:[A-Za-z0-9_][A-Za-z0-9._-]*)(?:/[A-Za-z0-9_][A-Za-z0-9._-]*)*)$" }, "private_key": { "type": "string", - "pattern": "^(env:[A-Z][A-Z0-9_]*|file:\\./[^[:cntrl:]]+)$" + "pattern": "^(env:[A-Z][A-Z0-9_]*|file:\\./(?:[A-Za-z0-9_][A-Za-z0-9._-]*)(?:/[A-Za-z0-9_][A-Za-z0-9._-]*)*)$" }, "midtrans_public_key": { "type": "string", - "pattern": "^(env:[A-Z][A-Z0-9_]*|file:\\./[^[:cntrl:]]+)$" + "pattern": "^(env:[A-Z][A-Z0-9_]*|file:\\./(?:[A-Za-z0-9_][A-Za-z0-9._-]*)(?:/[A-Za-z0-9_][A-Za-z0-9._-]*)*)$" } } } From fe99fea0e8b71f417b10876521c33269588144c2 Mon Sep 17 00:00:00 2001 From: "m.salis" Date: Mon, 27 Jul 2026 08:32:22 +0700 Subject: [PATCH 26/73] feat: resolve typed credential references safely --- .../task-2-report.md | 42 +++++++ internal/app/app.go | 28 +++-- internal/app/app_test.go | 52 +++++++- internal/app/checkout_runner.go | 1 + internal/app/commands_credentials.go | 34 ++---- internal/app/commands_sandbox.go | 2 + internal/app/commands_status.go | 10 +- internal/app/commands_webhook.go | 1 + internal/app/manifest_helpers.go | 9 +- internal/app/server_key.go | 32 +++-- internal/app/webhook_test_runner.go | 2 +- internal/evidence/evidence_test.go | 22 ++++ internal/evidence/redact.go | 4 + internal/secrets/provider.go | 2 + internal/secrets/reference.go | 110 +++++++++++++++++ internal/secrets/reference_test.go | 113 ++++++++++++++++++ 16 files changed, 412 insertions(+), 52 deletions(-) create mode 100644 .superpowers/sdd/2026-07-26-midtrans-cli-multi-product-parity/task-2-report.md create mode 100644 internal/secrets/reference.go create mode 100644 internal/secrets/reference_test.go diff --git a/.superpowers/sdd/2026-07-26-midtrans-cli-multi-product-parity/task-2-report.md b/.superpowers/sdd/2026-07-26-midtrans-cli-multi-product-parity/task-2-report.md new file mode 100644 index 0000000..def16c0 --- /dev/null +++ b/.superpowers/sdd/2026-07-26-midtrans-cli-multi-product-parity/task-2-report.md @@ -0,0 +1,42 @@ +## Task 2 Report + +### Scope delivered + +- Added `internal/secrets.ReferenceResolver` to resolve `env:NAME` and `file:./path` references without exposing resolved bytes. +- Reused the existing `safepath.Existing` boundary for file references, enforcing regular files, `0600`-or-tighter permissions, a 64 KiB cap, and context cancellation. +- Extended core evidence redaction for the requested credential and token fields while leaving manifest reference strings visible. +- Injected credential resolution through `app.Dependencies.ResolveCredential`, defaulting it from `deps.Getenv`, and migrated app credential consumers to the injected resolver. +- Preserved legacy app behavior for callers without an active checkout credential set by falling back to the historical `MIDTRANS_SERVER_KEY` and `MIDTRANS_CLIENT_KEY` environment references in app helper code. + +### TDD evidence + +1. Added failing tests in: + - `internal/secrets/reference_test.go` + - `internal/evidence/evidence_test.go` + - `internal/app/app_test.go` +2. Verified RED with: + + ```sh + go test ./internal/secrets -run TestReferenceResolver -count=1 + go test ./internal/evidence -run TestRedactCoversCredentialTokenFields -count=1 + go test ./internal/app -run TestCredentialsStatusUsesInjectedCredentialResolver -count=1 + ``` + + Initial failures were the expected missing `ReferenceResolver`, missing stable resolver errors, missing `ResolveCredential` dependency injection, and missing redaction keys. +3. Implemented the resolver, app wiring, and redaction updates. +4. Re-ran the focused tests until they passed. + +### Verification + +```sh +go test ./internal/app -run 'TestCredentialsStatusDoesNotLeakValue|TestSandboxPreflightCredentialPolicy|TestSandboxStatusMapsFailuresToVersionedPublicSafeResults|TestOmittedGetenvDependencyDoesNotPanic|TestWebhookVerifyErrorsDoNotLeakSignatureServerKeyOrRawPayload|TestCredentialsStatusUsesInjectedCredentialResolver' -count=1 +go test ./internal/secrets ./internal/evidence ./internal/app -count=1 +go test ./... -count=1 +``` + +All commands passed on July 27, 2026. + +### Notes + +- File-reference runtime validation stays at least as strict as the Task 1 manifest syntax gate; invalid syntax remains `CREDENTIAL_REFERENCE_INVALID`, missing content remains `CREDENTIAL_NOT_FOUND`, and unsafe files map to `CREDENTIAL_FILE_UNSAFE`. +- The app compatibility fallback is intentionally limited to the app helper layer so manifest validation and resolver syntax rules remain unchanged. diff --git a/internal/app/app.go b/internal/app/app.go index d9f7747..9643fdc 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -17,21 +17,23 @@ import ( "github.com/veritrans/midtrans-cli/internal/project" "github.com/veritrans/midtrans-cli/internal/render" "github.com/veritrans/midtrans-cli/internal/sandbox" + "github.com/veritrans/midtrans-cli/internal/secrets" "github.com/veritrans/midtrans-cli/internal/version" ) type Dependencies struct { - Stdin io.Reader - Stdout io.Writer - Stderr io.Writer - Version version.Info - Packs *packs.Registry - Getenv func(string) (string, bool) - Getwd func() (string, error) - IsTerminal func() bool - NewOrderID func() string - HTTP sandbox.Doer - LocalProbe func(context.Context, string) bool + Stdin io.Reader + Stdout io.Writer + Stderr io.Writer + Version version.Info + Packs *packs.Registry + Getenv func(string) (string, bool) + ResolveCredential secrets.ResolveFunc + Getwd func() (string, error) + IsTerminal func() bool + NewOrderID func() string + HTTP sandbox.Doer + LocalProbe func(context.Context, string) bool } type globalFlags struct { @@ -63,6 +65,10 @@ func Execute(ctx context.Context, args []string, deps Dependencies) int { if deps.Getenv == nil { deps.Getenv = os.LookupEnv } + if deps.ResolveCredential == nil { + resolver := secrets.ReferenceResolver{Getenv: deps.Getenv} + deps.ResolveCredential = resolver.Resolve + } if deps.HTTP == nil { deps.HTTP = sandbox.NewHTTPClient() } diff --git a/internal/app/app_test.go b/internal/app/app_test.go index a20ddf8..8dcf222 100644 --- a/internal/app/app_test.go +++ b/internal/app/app_test.go @@ -1459,6 +1459,44 @@ func TestCredentialsStatusReturnsOnlyPresenceBooleans(t *testing.T) { } } +func TestCredentialsStatusUsesInjectedCredentialResolver(t *testing.T) { + project := t.TempDir() + if _, err := manifest.Init(project); err != nil { + t.Fatal(err) + } + configureSnapManifestProject(t, project, "http://127.0.0.1:3000") + + var stdout, stderr bytes.Buffer + var references []string + exit := app.Execute(context.Background(), []string{ + "credentials", "status", "--project-dir", project, "--json", "--non-interactive", + }, app.Dependencies{ + Stdout: &stdout, + Stderr: &stderr, + Version: version.Info{Version: "test"}, + Packs: testRegistry(t), + Getenv: func(string) (string, bool) { + t.Fatal("environment lookup should not be called directly") + return "", false + }, + ResolveCredential: func(_ context.Context, gotProject, reference string) ([]byte, error) { + references = append(references, gotProject+"::"+reference) + return []byte("SB-Mid-server-CANARY-DO-NOT-PRINT"), nil + }, + }) + if exit != 0 { + t.Fatalf("exit = %d, stderr = %s", exit, stderr.String()) + } + if len(references) != 2 { + t.Fatalf("references = %#v", references) + } + for _, reference := range references { + if !strings.HasPrefix(reference, project+"::env:MIDTRANS_") { + t.Fatalf("resolver = %#v", references) + } + } +} + func TestCredentialCommandsValidateManifestBeforeResolution(t *testing.T) { project := t.TempDir() if _, err := manifest.Init(project); err != nil { @@ -1967,6 +2005,8 @@ func TestOmittedGetenvDependencyDoesNotPanic(t *testing.T) { if _, err := manifest.Init(project); err != nil { t.Fatal(err) } + t.Setenv("MIDTRANS_SERVER_KEY", "") + t.Setenv("MIDTRANS_CLIENT_KEY", "") var stdout, stderr bytes.Buffer exit := app.Execute(context.Background(), []string{ "credentials", "status", "--project-dir", project, "--json", "--non-interactive", @@ -1976,9 +2016,19 @@ func TestOmittedGetenvDependencyDoesNotPanic(t *testing.T) { Version: version.Info{Version: "test"}, Packs: testRegistry(t), }) - if exit != 0 { + if exit != 3 { t.Fatalf("exit = %d, stdout = %s, stderr = %s", exit, stdout.String(), stderr.String()) } + var result contracts.Result + if err := json.Unmarshal(stdout.Bytes(), &result); err != nil { + t.Fatalf("stdout = %q: %v", stdout.String(), err) + } + if result.Command != "credentials.status" || + result.Status != contracts.StatusBlocked || + len(result.Findings) != 1 || + result.Findings[0].Code != "CREDENTIAL_MISSING" { + t.Fatalf("result = %#v", result) + } } func TestWebhookVerifyReturnsOnlyPublicSafeNotificationFields(t *testing.T) { diff --git a/internal/app/checkout_runner.go b/internal/app/checkout_runner.go index 728c236..e59fc5e 100644 --- a/internal/app/checkout_runner.go +++ b/internal/app/checkout_runner.go @@ -75,6 +75,7 @@ func runCheckout( ctx, request.Command, value.SchemaVersion, + request.ProjectDir, checkoutServerKeyReference(value), deps, ) diff --git a/internal/app/commands_credentials.go b/internal/app/commands_credentials.go index 071d248..ecf70e8 100644 --- a/internal/app/commands_credentials.go +++ b/internal/app/commands_credentials.go @@ -2,14 +2,12 @@ package app import ( "context" - "errors" "strings" "github.com/spf13/cobra" "github.com/veritrans/midtrans-cli/internal/contracts" "github.com/veritrans/midtrans-cli/internal/manifest" "github.com/veritrans/midtrans-cli/internal/project" - "github.com/veritrans/midtrans-cli/internal/secrets" ) func newCredentialsCommand(flags *globalFlags, deps Dependencies) *cobra.Command { @@ -48,25 +46,23 @@ func newCredentialsStatusRunner( return writeResult(deps, flags, *invalidResult) } - provider := secrets.NewEnvironmentProvider(deps.Getenv) - _, serverKeyErr := secrets.ResolveSandboxServerKey( + _, serverKeyErr := resolveSandboxServerKey( cmd.Context(), - provider, + "credentials.status", + value.SchemaVersion, + flags.projectDir, checkoutServerKeyReference(value), + deps, ) serverKeyPresent := serverKeyErr == nil - if serverKeyErr != nil && - !errors.Is(serverKeyErr, secrets.ErrMissing) { - result := sandboxServerKeyErrorResult( - "credentials.status", - value.SchemaVersion, - deps, - serverKeyErr, - ) - return writeResult(deps, flags, result) + if serverKeyErr != nil { + return writeResult(deps, flags, *serverKeyErr) } clientKeyPresent := secretPresent( - cmd.Context(), provider, checkoutClientKeyReference(value), + cmd.Context(), + flags.projectDir, + checkoutClientKeyReference(value), + deps, ) result := contracts.NewResult("credentials.status", contracts.StatusPass) @@ -83,12 +79,8 @@ func newCredentialsStatusRunner( } } -func secretPresent( - ctx context.Context, - provider secrets.Provider, - reference string, -) bool { - _, err := provider.Resolve(ctx, reference) +func secretPresent(ctx context.Context, projectDir, reference string, deps Dependencies) bool { + _, err := deps.ResolveCredential(ctx, projectDir, reference) return err == nil } diff --git a/internal/app/commands_sandbox.go b/internal/app/commands_sandbox.go index 62e7367..45d0431 100644 --- a/internal/app/commands_sandbox.go +++ b/internal/app/commands_sandbox.go @@ -104,6 +104,7 @@ func newSandboxPreflightCommand( cmd.Context(), "sandbox.preflight", value.SchemaVersion, + flags.projectDir, checkoutServerKeyReference(value), deps, ) @@ -155,6 +156,7 @@ func newSandboxStatusCommand( cmd.Context(), "sandbox.status", value.SchemaVersion, + flags.projectDir, checkoutServerKeyReference(value), deps, ) diff --git a/internal/app/commands_status.go b/internal/app/commands_status.go index b37ba8b..f57ded3 100644 --- a/internal/app/commands_status.go +++ b/internal/app/commands_status.go @@ -9,7 +9,6 @@ import ( "github.com/veritrans/midtrans-cli/internal/manifest" "github.com/veritrans/midtrans-cli/internal/project" "github.com/veritrans/midtrans-cli/internal/readiness" - "github.com/veritrans/midtrans-cli/internal/secrets" ) func newStatusCommand(flags *globalFlags, deps Dependencies) *cobra.Command { @@ -48,13 +47,18 @@ func buildStatusResult( return result } findings := append(manifest.Validate(value), pack.Evaluate(value, report)...) - provider := secrets.NewEnvironmentProvider(deps.Getenv) serverPresent, serverInvalid := sandboxServerKeyReadiness( ctx, + flags.projectDir, checkoutServerKeyReference(value), deps, ) - clientPresent := secretPresent(ctx, provider, checkoutClientKeyReference(value)) + clientPresent := secretPresent( + ctx, + flags.projectDir, + checkoutClientKeyReference(value), + deps, + ) reachable := readiness.ReachabilityUnknown if value.Application.BaseURL != "" { if deps.LocalProbe(ctx, value.Application.BaseURL) { diff --git a/internal/app/commands_webhook.go b/internal/app/commands_webhook.go index c71e832..963a1e9 100644 --- a/internal/app/commands_webhook.go +++ b/internal/app/commands_webhook.go @@ -69,6 +69,7 @@ func newWebhookVerifyCommand(flags *globalFlags, deps Dependencies) *cobra.Comma cmd.Context(), "webhook.verify", value.SchemaVersion, + flags.projectDir, checkoutServerKeyReference(value), deps, ) diff --git a/internal/app/manifest_helpers.go b/internal/app/manifest_helpers.go index 90fe060..e198d4d 100644 --- a/internal/app/manifest_helpers.go +++ b/internal/app/manifest_helpers.go @@ -2,6 +2,11 @@ package app import "github.com/veritrans/midtrans-cli/internal/manifest" +const ( + legacyServerKeyReference = "env:MIDTRANS_SERVER_KEY" + legacyClientKeyReference = "env:MIDTRANS_CLIENT_KEY" +) + func checkoutIntegration(value manifest.Manifest) (string, manifest.Integration, bool) { return value.CheckoutIntegration() } @@ -17,7 +22,7 @@ func checkoutCredentialSet(value manifest.Manifest) (manifest.CredentialSet, boo func checkoutServerKeyReference(value manifest.Manifest) string { credentials, ok := checkoutCredentialSet(value) if !ok { - return "" + return legacyServerKeyReference } return credentials.ServerKey } @@ -25,7 +30,7 @@ func checkoutServerKeyReference(value manifest.Manifest) string { func checkoutClientKeyReference(value manifest.Manifest) string { credentials, ok := checkoutCredentialSet(value) if !ok { - return "" + return legacyClientKeyReference } return credentials.ClientKey } diff --git a/internal/app/server_key.go b/internal/app/server_key.go index 5b3011b..a9390b9 100644 --- a/internal/app/server_key.go +++ b/internal/app/server_key.go @@ -12,16 +12,21 @@ func resolveSandboxServerKey( ctx context.Context, command string, manifestVersion int, + projectDir string, reference string, deps Dependencies, ) (secrets.Value, *contracts.Result) { - value, err := secrets.ResolveSandboxServerKey( - ctx, - secrets.NewEnvironmentProvider(deps.Getenv), - reference, - ) + rawValue, err := deps.ResolveCredential(ctx, projectDir, reference) + if err == nil { + value := secrets.NewValue(string(rawValue)) + if _, err := value.SandboxServerKey(); err == nil { + return value, nil + } else { + rawValue = nil + } + } if err == nil { - return value, nil + err = secrets.ErrSandboxServerKeyRequired } result := sandboxServerKeyErrorResult( command, @@ -34,18 +39,19 @@ func resolveSandboxServerKey( func sandboxServerKeyReadiness( ctx context.Context, + projectDir string, reference string, deps Dependencies, ) (present, invalid bool) { - _, err := secrets.ResolveSandboxServerKey( - ctx, - secrets.NewEnvironmentProvider(deps.Getenv), - reference, - ) + rawValue, err := deps.ResolveCredential(ctx, projectDir, reference) + if err == nil { + _, err = secrets.NewValue(string(rawValue)).SandboxServerKey() + rawValue = nil + } switch { case err == nil: return true, false - case errors.Is(err, secrets.ErrMissing): + case errors.Is(err, secrets.ErrMissing), errors.Is(err, secrets.ErrCredentialNotFound): return false, false default: return false, true @@ -60,7 +66,7 @@ func sandboxServerKeyErrorResult( ) contracts.Result { var result contracts.Result switch { - case errors.Is(err, secrets.ErrMissing): + case errors.Is(err, secrets.ErrMissing), errors.Is(err, secrets.ErrCredentialNotFound): result = contracts.NewResult(command, contracts.StatusBlocked) result.Findings = []contracts.Finding{{ Code: "CREDENTIAL_MISSING", diff --git a/internal/app/webhook_test_runner.go b/internal/app/webhook_test_runner.go index 609b71a..bc06660 100644 --- a/internal/app/webhook_test_runner.go +++ b/internal/app/webhook_test_runner.go @@ -73,7 +73,7 @@ func runWebhookTest( return result } serverKey, failure := resolveSandboxServerKey( - ctx, request.Command, value.SchemaVersion, + ctx, request.Command, value.SchemaVersion, request.ProjectDir, checkoutServerKeyReference(value), deps, ) if failure != nil { diff --git a/internal/evidence/evidence_test.go b/internal/evidence/evidence_test.go index 9e2c807..639f7fd 100644 --- a/internal/evidence/evidence_test.go +++ b/internal/evidence/evidence_test.go @@ -85,6 +85,28 @@ func TestRedactSanitizesDefaultGoStructFieldNames(t *testing.T) { } } +func TestRedactCoversCredentialTokenFields(t *testing.T) { + input := map[string]any{ + "reference": "file:./secrets/private.pem", + "midtrans_public_key": "PUBLIC-CANARY", + "authorization_customer": "AUTH-CUSTOMER-CANARY", + "payment_option_token": "PAYMENT-TOKEN-CANARY", + "saved_token_id": "SAVED-TOKEN-CANARY", + "customer_authorization_token": "CUSTOMER-TOKEN-CANARY", + } + + encoded, err := json.Marshal(evidence.Redact(input, nil)) + if err != nil { + t.Fatal(err) + } + if strings.Contains(string(encoded), "CANARY") { + t.Fatalf("token redaction failed: %s", encoded) + } + if !strings.Contains(string(encoded), "file:./secrets/private.pem") { + t.Fatalf("reference should remain visible: %s", encoded) + } +} + func TestSanitizeResultProtectsEveryRenderer(t *testing.T) { result := contracts.NewResult("inspect", contracts.StatusPass) result.Data = struct { diff --git a/internal/evidence/redact.go b/internal/evidence/redact.go index f60d5c0..9e342c6 100644 --- a/internal/evidence/redact.go +++ b/internal/evidence/redact.go @@ -14,10 +14,14 @@ var coreSensitiveKeys = []string{ "server_key", "client_secret", "private_key", + "midtrans_public_key", "access_token", "authorization_token", + "authorization_customer", "customer_authorization_token", + "payment_option_token", "auth_code", + "saved_token_id", "token", "signature_key", "card_number", diff --git a/internal/secrets/provider.go b/internal/secrets/provider.go index 07a66b0..6706e1d 100644 --- a/internal/secrets/provider.go +++ b/internal/secrets/provider.go @@ -31,6 +31,8 @@ type Provider interface { Resolve(context.Context, string) (Value, error) } +type ResolveFunc func(context.Context, string, string) ([]byte, error) + func ResolveSandboxServerKey( ctx context.Context, provider Provider, diff --git a/internal/secrets/reference.go b/internal/secrets/reference.go new file mode 100644 index 0000000..4a77bbd --- /dev/null +++ b/internal/secrets/reference.go @@ -0,0 +1,110 @@ +package secrets + +import ( + "context" + "errors" + "io" + "os" + "path/filepath" + "regexp" + "strings" + + "github.com/veritrans/midtrans-cli/internal/safepath" +) + +const maxCredentialBytes = 64 << 10 + +var ( + ErrCredentialReferenceInvalid = errors.New("CREDENTIAL_REFERENCE_INVALID") + ErrCredentialNotFound = errors.New("CREDENTIAL_NOT_FOUND") + ErrCredentialFileUnsafe = errors.New("CREDENTIAL_FILE_UNSAFE") + + environmentReferencePattern = regexp.MustCompile(`^env:[A-Z][A-Z0-9_]*$`) + fileReferencePattern = regexp.MustCompile(`^file:\./(?:[A-Za-z0-9_][A-Za-z0-9._-]*)(?:/[A-Za-z0-9_][A-Za-z0-9._-]*)*$`) +) + +type ReferenceResolver struct { + Getenv func(string) (string, bool) +} + +func (r ReferenceResolver) Resolve( + ctx context.Context, + projectDir string, + reference string, +) ([]byte, error) { + if err := ctx.Err(); err != nil { + return nil, err + } + switch { + case environmentReferencePattern.MatchString(reference): + return r.resolveEnv(reference) + case fileReferencePattern.MatchString(reference): + return r.resolveFile(projectDir, strings.TrimPrefix(reference, "file:")) + default: + return nil, ErrCredentialReferenceInvalid + } +} + +func (r ReferenceResolver) resolveEnv(reference string) ([]byte, error) { + if r.Getenv == nil { + return nil, ErrCredentialNotFound + } + value, ok := r.Getenv(strings.TrimPrefix(reference, "env:")) + if !ok || value == "" { + return nil, ErrCredentialNotFound + } + if len(value) > maxCredentialBytes { + return nil, ErrCredentialFileUnsafe + } + return []byte(value), nil +} + +func (r ReferenceResolver) resolveFile(projectDir, relative string) ([]byte, error) { + if projectDir == "" || !strings.HasPrefix(relative, "./") || filepath.IsAbs(relative) { + return nil, ErrCredentialReferenceInvalid + } + if _, err := os.Lstat(filepath.Join(projectDir, relative)); err != nil { + if errors.Is(err, os.ErrNotExist) { + return nil, ErrCredentialNotFound + } + return nil, ErrCredentialFileUnsafe + } + path, err := safepath.Existing(projectDir, relative) + if err != nil { + return nil, classifyFileError(err) + } + info, err := os.Stat(path) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return nil, ErrCredentialNotFound + } + return nil, classifyFileError(err) + } + if !info.Mode().IsRegular() || info.Mode().Perm()&^0o600 != 0 || info.Size() > maxCredentialBytes { + return nil, ErrCredentialFileUnsafe + } + file, err := os.Open(path) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return nil, ErrCredentialNotFound + } + return nil, classifyFileError(err) + } + defer file.Close() + + contents, err := io.ReadAll(io.LimitReader(file, maxCredentialBytes+1)) + if err != nil { + return nil, ErrCredentialFileUnsafe + } + if len(contents) > maxCredentialBytes { + return nil, ErrCredentialFileUnsafe + } + return contents, nil +} + +func classifyFileError(err error) error { + if strings.Contains(err.Error(), "PATH_OUTSIDE_PROJECT") { + return ErrCredentialFileUnsafe + } + return ErrCredentialNotFound +} diff --git a/internal/secrets/reference_test.go b/internal/secrets/reference_test.go new file mode 100644 index 0000000..d803b13 --- /dev/null +++ b/internal/secrets/reference_test.go @@ -0,0 +1,113 @@ +package secrets_test + +import ( + "context" + "errors" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/veritrans/midtrans-cli/internal/secrets" +) + +func TestReferenceResolverReadsEnvironmentAndContainedFile(t *testing.T) { + project := t.TempDir() + writePrivateFile(t, project, "secrets/private.pem", []byte("pem")) + + resolver := secrets.ReferenceResolver{ + Getenv: func(key string) (string, bool) { + return map[string]string{"MIDTRANS_KEY": "value"}[key], key == "MIDTRANS_KEY" + }, + } + + env, err := resolver.Resolve(context.Background(), project, "env:MIDTRANS_KEY") + if err != nil || string(env) != "value" { + t.Fatalf("env = %q, err = %v", env, err) + } + + file, err := resolver.Resolve(context.Background(), project, "file:./secrets/private.pem") + if err != nil || string(file) != "pem" { + t.Fatalf("file = %q, err = %v", file, err) + } +} + +func TestReferenceResolverRejectsInvalidOrUnsafeReferences(t *testing.T) { + project := t.TempDir() + writePrivateFile(t, project, "secrets/private.pem", []byte("pem")) + + outsideDir := t.TempDir() + outsidePath := filepath.Join(outsideDir, "outside.pem") + if err := os.WriteFile(outsidePath, []byte("outside"), 0o600); err != nil { + t.Fatal(err) + } + if err := os.Symlink(outsidePath, filepath.Join(project, "secrets", "escape.pem")); err != nil { + t.Fatal(err) + } + + writePrivateFile( + t, + project, + "secrets/too-large.pem", + []byte(strings.Repeat("a", (64<<10)+1)), + ) + writeFile(t, project, "secrets/too-open.pem", []byte("pem"), 0o644) + + resolver := secrets.ReferenceResolver{ + Getenv: func(string) (string, bool) { return "", false }, + } + + tests := []struct { + name string + reference string + wantErr error + }{ + {name: "absolute path", reference: "file:/tmp/private.pem", wantErr: secrets.ErrCredentialReferenceInvalid}, + {name: "traversal", reference: "file:./../private.pem", wantErr: secrets.ErrCredentialReferenceInvalid}, + {name: "missing file", reference: "file:./secrets/missing.pem", wantErr: secrets.ErrCredentialNotFound}, + {name: "symlink escape", reference: "file:./secrets/escape.pem", wantErr: secrets.ErrCredentialFileUnsafe}, + {name: "broad permissions", reference: "file:./secrets/too-open.pem", wantErr: secrets.ErrCredentialFileUnsafe}, + {name: "too large", reference: "file:./secrets/too-large.pem", wantErr: secrets.ErrCredentialFileUnsafe}, + {name: "empty env", reference: "env:MIDTRANS_EMPTY", wantErr: secrets.ErrCredentialNotFound}, + {name: "bad scheme", reference: "vault:secret/path", wantErr: secrets.ErrCredentialReferenceInvalid}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, err := resolver.Resolve(context.Background(), project, tt.reference) + if !errors.Is(err, tt.wantErr) { + t.Fatalf("error = %v, want %v", err, tt.wantErr) + } + }) + } +} + +func TestReferenceResolverHonorsContextCancellation(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + resolver := secrets.ReferenceResolver{ + Getenv: func(string) (string, bool) { return "value", true }, + } + + _, err := resolver.Resolve(ctx, t.TempDir(), "env:MIDTRANS_KEY") + if !errors.Is(err, context.Canceled) { + t.Fatalf("error = %v, want %v", err, context.Canceled) + } +} + +func writePrivateFile(t *testing.T, root, relative string, contents []byte) { + t.Helper() + writeFile(t, root, relative, contents, 0o600) +} + +func writeFile(t *testing.T, root, relative string, contents []byte, mode os.FileMode) { + t.Helper() + path := filepath.Join(root, relative) + if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, contents, mode); err != nil { + t.Fatal(err) + } +} From 870439ed3982e2972db6d5c375e9b7fca3879bd5 Mon Sep 17 00:00:00 2001 From: "m.salis" Date: Mon, 27 Jul 2026 08:39:17 +0700 Subject: [PATCH 27/73] fix: require configured checkout credentials --- .../task-2-report.md | 23 ++++++++++++++ internal/app/app_test.go | 30 ++++++++++++++++--- internal/app/commands_credentials.go | 3 ++ internal/app/manifest_helpers.go | 9 ++---- internal/app/manifest_helpers_test.go | 18 +++++++++++ internal/app/server_key.go | 12 ++++++++ 6 files changed, 84 insertions(+), 11 deletions(-) create mode 100644 internal/app/manifest_helpers_test.go diff --git a/.superpowers/sdd/2026-07-26-midtrans-cli-multi-product-parity/task-2-report.md b/.superpowers/sdd/2026-07-26-midtrans-cli-multi-product-parity/task-2-report.md index def16c0..ad65012 100644 --- a/.superpowers/sdd/2026-07-26-midtrans-cli-multi-product-parity/task-2-report.md +++ b/.superpowers/sdd/2026-07-26-midtrans-cli-multi-product-parity/task-2-report.md @@ -40,3 +40,26 @@ All commands passed on July 27, 2026. - File-reference runtime validation stays at least as strict as the Task 1 manifest syntax gate; invalid syntax remains `CREDENTIAL_REFERENCE_INVALID`, missing content remains `CREDENTIAL_NOT_FOUND`, and unsafe files map to `CREDENTIAL_FILE_UNSAFE`. - The app compatibility fallback is intentionally limited to the app helper layer so manifest validation and resolver syntax rules remain unchanged. + +## Fix Round 1 + +### Review items addressed + +- Removed the synthesized `env:MIDTRANS_SERVER_KEY` and `env:MIDTRANS_CLIENT_KEY` fallback from `internal/app/manifest_helpers.go`; unconfigured manifests now produce no checkout credential reference. +- Treated empty checkout references as missing in app credential readiness paths so unconfigured manifests block with `CREDENTIAL_MISSING` instead of resolving ambient environment state or erroring as invalid references. +- Tightened the injected resolver test to assert the exact manifest references, and added a negative test proving an initialized but unconfigured manifest stays blocked even when ambient Midtrans environment variables are present. +- Updated configured sandbox and webhook app tests to declare Snap credentials explicitly instead of relying on the removed fallback. + +### Commands run + +```sh +go test ./internal/app -run 'TestCheckoutCredentialReferencesReturnEmptyWithoutConfiguredCheckout|TestCredentialsStatusReturnsOnlyPresenceBooleans|TestCredentialsStatusUsesInjectedCredentialResolver|TestCredentialsStatusStaysBlockedForUnconfiguredManifestEvenWithAmbientEnv|TestCredentialsStatusDoesNotLeakValue|TestSandboxPreflightCredentialPolicy|TestSandboxStatusMapsFailuresToVersionedPublicSafeResults|TestWebhookVerifyErrorsDoNotLeakSignatureServerKeyOrRawPayload' -count=1 +go test ./internal/secrets ./internal/evidence ./internal/app -count=1 +go test ./... -count=1 +``` + +### Results + +- `go test ./internal/app -run 'TestCheckoutCredentialReferencesReturnEmptyWithoutConfiguredCheckout|TestCredentialsStatusReturnsOnlyPresenceBooleans|TestCredentialsStatusUsesInjectedCredentialResolver|TestCredentialsStatusStaysBlockedForUnconfiguredManifestEvenWithAmbientEnv|TestCredentialsStatusDoesNotLeakValue|TestSandboxPreflightCredentialPolicy|TestSandboxStatusMapsFailuresToVersionedPublicSafeResults|TestWebhookVerifyErrorsDoNotLeakSignatureServerKeyOrRawPayload' -count=1` passed on July 27, 2026. +- `go test ./internal/secrets ./internal/evidence ./internal/app -count=1` passed on July 27, 2026. +- `go test ./... -count=1` passed on July 27, 2026. diff --git a/internal/app/app_test.go b/internal/app/app_test.go index 8dcf222..1a5da17 100644 --- a/internal/app/app_test.go +++ b/internal/app/app_test.go @@ -1397,6 +1397,7 @@ func TestCredentialsStatusDoesNotLeakValue(t *testing.T) { if _, err := manifest.Init(project); err != nil { t.Fatal(err) } + configureSnapManifestProject(t, project, "http://127.0.0.1:3000") var stdout, stderr bytes.Buffer exit := app.Execute(context.Background(), []string{ "credentials", "status", "--project-dir", project, "--json", "--non-interactive", @@ -1490,10 +1491,28 @@ func TestCredentialsStatusUsesInjectedCredentialResolver(t *testing.T) { if len(references) != 2 { t.Fatalf("references = %#v", references) } - for _, reference := range references { - if !strings.HasPrefix(reference, project+"::env:MIDTRANS_") { - t.Fatalf("resolver = %#v", references) - } + want := []string{ + project + "::env:MIDTRANS_SERVER_KEY", + project + "::env:MIDTRANS_CLIENT_KEY", + } + if !reflect.DeepEqual(references, want) { + t.Fatalf("references = %#v, want %#v", references, want) + } +} + +func TestCredentialsStatusStaysBlockedForUnconfiguredManifestEvenWithAmbientEnv(t *testing.T) { + project := t.TempDir() + if _, err := manifest.Init(project); err != nil { + t.Fatal(err) + } + result, exit := executeJSONWithGetenv(t, project, func(string) (string, bool) { + return "SB-Mid-server-CANARY-DO-NOT-PRINT", true + }, "credentials", "status") + if exit != 3 || result.Status != contracts.StatusBlocked { + t.Fatalf("exit = %d, result = %#v", exit, result) + } + if len(result.Findings) != 1 || result.Findings[0].Code != "CREDENTIAL_MISSING" { + t.Fatalf("findings = %#v", result.Findings) } } @@ -1592,6 +1611,7 @@ func TestSandboxPreflightCredentialPolicy(t *testing.T) { if _, err := manifest.Init(project); err != nil { t.Fatal(err) } + configureSnapManifestProject(t, project, "http://127.0.0.1:3000") result, exit := executeJSONWithGetenv( t, project, tt.lookup, "sandbox", "preflight", ) @@ -1844,6 +1864,7 @@ func TestSandboxStatusMapsFailuresToVersionedPublicSafeResults(t *testing.T) { if _, err := manifest.Init(project); err != nil { t.Fatal(err) } + configureSnapManifestProject(t, project, "http://127.0.0.1:3000") serverKey := "SB-Mid-server-STATUS-ERROR-CANARY-DO-NOT-PRINT" transportCanary := "transport-" + serverKey @@ -2162,6 +2183,7 @@ func TestWebhookVerifyErrorsDoNotLeakSignatureServerKeyOrRawPayload(t *testing.T if _, err := manifest.Init(project); err != nil { t.Fatal(err) } + configureSnapManifestProject(t, project, "http://127.0.0.1:3000") file := filepath.Join(project, "notification.json") if err := os.WriteFile(file, []byte(tt.payload), 0o600); err != nil { t.Fatal(err) diff --git a/internal/app/commands_credentials.go b/internal/app/commands_credentials.go index ecf70e8..bf74798 100644 --- a/internal/app/commands_credentials.go +++ b/internal/app/commands_credentials.go @@ -80,6 +80,9 @@ func newCredentialsStatusRunner( } func secretPresent(ctx context.Context, projectDir, reference string, deps Dependencies) bool { + if reference == "" { + return false + } _, err := deps.ResolveCredential(ctx, projectDir, reference) return err == nil } diff --git a/internal/app/manifest_helpers.go b/internal/app/manifest_helpers.go index e198d4d..90fe060 100644 --- a/internal/app/manifest_helpers.go +++ b/internal/app/manifest_helpers.go @@ -2,11 +2,6 @@ package app import "github.com/veritrans/midtrans-cli/internal/manifest" -const ( - legacyServerKeyReference = "env:MIDTRANS_SERVER_KEY" - legacyClientKeyReference = "env:MIDTRANS_CLIENT_KEY" -) - func checkoutIntegration(value manifest.Manifest) (string, manifest.Integration, bool) { return value.CheckoutIntegration() } @@ -22,7 +17,7 @@ func checkoutCredentialSet(value manifest.Manifest) (manifest.CredentialSet, boo func checkoutServerKeyReference(value manifest.Manifest) string { credentials, ok := checkoutCredentialSet(value) if !ok { - return legacyServerKeyReference + return "" } return credentials.ServerKey } @@ -30,7 +25,7 @@ func checkoutServerKeyReference(value manifest.Manifest) string { func checkoutClientKeyReference(value manifest.Manifest) string { credentials, ok := checkoutCredentialSet(value) if !ok { - return legacyClientKeyReference + return "" } return credentials.ClientKey } diff --git a/internal/app/manifest_helpers_test.go b/internal/app/manifest_helpers_test.go new file mode 100644 index 0000000..662ac83 --- /dev/null +++ b/internal/app/manifest_helpers_test.go @@ -0,0 +1,18 @@ +package app + +import ( + "testing" + + "github.com/veritrans/midtrans-cli/internal/manifest" +) + +func TestCheckoutCredentialReferencesReturnEmptyWithoutConfiguredCheckout(t *testing.T) { + value := manifest.Default() + + if got := checkoutServerKeyReference(value); got != "" { + t.Fatalf("server key reference = %q, want empty", got) + } + if got := checkoutClientKeyReference(value); got != "" { + t.Fatalf("client key reference = %q, want empty", got) + } +} diff --git a/internal/app/server_key.go b/internal/app/server_key.go index a9390b9..d4df143 100644 --- a/internal/app/server_key.go +++ b/internal/app/server_key.go @@ -16,6 +16,15 @@ func resolveSandboxServerKey( reference string, deps Dependencies, ) (secrets.Value, *contracts.Result) { + if reference == "" { + result := sandboxServerKeyErrorResult( + command, + manifestVersion, + deps, + secrets.ErrCredentialNotFound, + ) + return secrets.Value{}, &result + } rawValue, err := deps.ResolveCredential(ctx, projectDir, reference) if err == nil { value := secrets.NewValue(string(rawValue)) @@ -43,6 +52,9 @@ func sandboxServerKeyReadiness( reference string, deps Dependencies, ) (present, invalid bool) { + if reference == "" { + return false, false + } rawValue, err := deps.ResolveCredential(ctx, projectDir, reference) if err == nil { _, err = secrets.NewValue(string(rawValue)).SandboxServerKey() From c1c777d303d45b8702402be34889b923a43b3252 Mon Sep 17 00:00:00 2001 From: "m.salis" Date: Mon, 27 Jul 2026 08:52:27 +0700 Subject: [PATCH 28/73] feat: add resumable payment journey engine --- .../task-3-report.md | 42 +++ internal/app/checkout_runner.go | 5 + internal/app/commands_evidence_test.go | 20 +- internal/evidence/model.go | 12 +- internal/evidence/store.go | 4 + internal/journey/engine.go | 210 ++++++++++++++ internal/journey/engine_test.go | 256 ++++++++++++++++++ internal/journey/types.go | 84 ++++++ internal/operations/store.go | 61 +++-- internal/operations/store_test.go | 118 ++++---- packs/snap/journey.go | 65 +++-- packs/snap/journey_test.go | 12 +- schemas/operation-v1.schema.json | 35 +++ 13 files changed, 833 insertions(+), 91 deletions(-) create mode 100644 .superpowers/sdd/2026-07-26-midtrans-cli-multi-product-parity/task-3-report.md create mode 100644 internal/journey/engine.go create mode 100644 internal/journey/engine_test.go create mode 100644 internal/journey/types.go create mode 100644 schemas/operation-v1.schema.json diff --git a/.superpowers/sdd/2026-07-26-midtrans-cli-multi-product-parity/task-3-report.md b/.superpowers/sdd/2026-07-26-midtrans-cli-multi-product-parity/task-3-report.md new file mode 100644 index 0000000..5e84ba5 --- /dev/null +++ b/.superpowers/sdd/2026-07-26-midtrans-cli-multi-product-parity/task-3-report.md @@ -0,0 +1,42 @@ +## Task 3 Report + +### Scope delivered + +- Added `internal/journey` with the generic resumable engine, stable journey contracts, runtime injection points, and lifecycle enforcement for `planned`, `awaiting_user_action`, `reconciling`, `passed`, `failed`, and `blocked`. +- Replaced the old order-specific operation ledger with a generic operation record keyed by hashed `operation_id`, preserving bounded decoding, unknown-field rejection, atomic reserve/save, `0700` directories, `0600` files, and symlink protection. +- Added `schemas/operation-v1.schema.json` for the new persisted operation record shape. +- Expanded evidence proofs with `operation_id`, `stage`, `observed_at`, and `source`, and updated runtime validation plus test fixtures accordingly. +- Kept current CLI behavior unchanged while adapting the hidden Snap internals just enough to satisfy the new ledger and proof contracts during compilation and verification. + +### TDD evidence + +1. Added failing lifecycle tests in `internal/journey/engine_test.go` and a failing generic ledger test in `internal/operations/store_test.go`. +2. Verified RED with: + + ```sh + go test ./internal/journey ./internal/operations -count=1 + ``` + + Initial failures were the expected missing `internal/journey` package and missing generic `operations.Record` fields (`SchemaVersion`, `JourneyID`, `PackID`, `ManifestHash`, `SafeReferences`). +3. Implemented the journey engine, generic store, and proof-shape changes. +4. Re-ran the focused packages until they passed. + +### Verification + +```sh +go test ./internal/journey ./internal/operations ./internal/evidence -count=1 +go test ./... -count=1 +``` + +Both commands passed on July 27, 2026. + +### Self-review notes + +- Tightened `internal/journey.Engine` so a failed initial `Reserve` blocks immediately instead of silently falling through to `Save`. +- Preserved package directionality: `internal/operations` does not import `internal/journey`, while `internal/journey` consumes `operations.Record`. +- The engine persists only string-valued safe references filtered against the sensitive-key registry; resolved credentials and action tokens are never written to operation records. + +### Notes + +- No new command surface was exposed for resume flows in this task. +- Snap was not migrated onto the generic journey engine; only its internal test/runtime adapters were updated so existing coverage remains valid against the new shared record and proof contracts. diff --git a/internal/app/checkout_runner.go b/internal/app/checkout_runner.go index e59fc5e..2735853 100644 --- a/internal/app/checkout_runner.go +++ b/internal/app/checkout_runner.go @@ -82,6 +82,10 @@ func runCheckout( if failure != nil { return *failure } + manifestHash, err := projectManifestHash(request.ProjectDir) + if err != nil { + return invalidCheckoutResult(request.Command, value.SchemaVersion, deps) + } startedAt := time.Now().UTC() journey, runErr := (snap.JourneyRunner{ Tokens: snap.Client{HTTP: deps.HTTP, ServerKey: serverKey}, @@ -94,6 +98,7 @@ func runCheckout( Ledger: operations.Store{ProjectDir: request.ProjectDir}, }).Run(ctx, snap.JourneyInput{ OperationID: plan.Hash, + ManifestHash: manifestHash, OrderID: request.OrderID, GrossAmount: request.GrossAmount, GrossAmountString: strconv.FormatInt(request.GrossAmount, 10) + ".00", diff --git a/internal/app/commands_evidence_test.go b/internal/app/commands_evidence_test.go index 66585c2..5fa01a4 100644 --- a/internal/app/commands_evidence_test.go +++ b/internal/app/commands_evidence_test.go @@ -298,18 +298,26 @@ func completeBundleForProject(t *testing.T, project string) evidence.Bundle { }, Proofs: []evidence.Proof{ { - ID: "snap.provider-status", - Level: evidence.ProofSandbox, - Status: "pass", + ID: "snap.provider-status", + OperationID: "op_snap_test", + Stage: "provider_status", + Level: evidence.ProofSandbox, + Source: "midtrans_api", + ObservedAt: now, + Status: "pass", Summary: map[string]any{ "transaction_status": "settlement", "authorization": "CANARY", }, }, { - ID: "snap.merchant-callback", - Level: evidence.ProofLocal, - Status: "pass", + ID: "snap.merchant-callback", + OperationID: "op_snap_test", + Stage: "merchant_callback", + Level: evidence.ProofLocal, + Source: "merchant_application", + ObservedAt: now, + Status: "pass", Summary: map[string]any{ "settlement_applied": true, "duplicate_idempotent": true, diff --git a/internal/evidence/model.go b/internal/evidence/model.go index 1eb27c9..876972f 100644 --- a/internal/evidence/model.go +++ b/internal/evidence/model.go @@ -12,10 +12,14 @@ const ( ) type Proof struct { - ID string `json:"id"` - Level ProofLevel `json:"level"` - Status string `json:"status"` - Summary map[string]any `json:"summary"` + ID string `json:"id"` + OperationID string `json:"operation_id"` + Stage string `json:"stage"` + Level ProofLevel `json:"level"` + Source string `json:"source"` + ObservedAt time.Time `json:"observed_at"` + Status string `json:"status"` + Summary map[string]any `json:"summary"` } type Bundle struct { diff --git a/internal/evidence/store.go b/internal/evidence/store.go index e1279b5..0de4a65 100644 --- a/internal/evidence/store.go +++ b/internal/evidence/store.go @@ -154,7 +154,11 @@ func Validate(bundle Bundle) error { } for _, proof := range bundle.Proofs { if proof.ID == "" || + proof.OperationID == "" || + proof.Stage == "" || (proof.Level != ProofLocal && proof.Level != ProofSandbox) || + proof.Source == "" || + proof.ObservedAt.IsZero() || (proof.Status != "pass" && proof.Status != "fail" && proof.Status != "blocked") || diff --git a/internal/journey/engine.go b/internal/journey/engine.go new file mode 100644 index 0000000..dabeae9 --- /dev/null +++ b/internal/journey/engine.go @@ -0,0 +1,210 @@ +package journey + +import ( + "context" + "strings" + "time" + "unicode" + + "github.com/veritrans/midtrans-cli/internal/contracts" + "github.com/veritrans/midtrans-cli/internal/operations" +) + +type Engine struct { + Store operations.Store + Runtime Runtime +} + +func (e Engine) Run( + ctx context.Context, + handler Handler, + request Request, + execute bool, +) Outcome { + runtime := e.withDefaults() + request = e.withOperationID(request, runtime) + definition := handler.Definition() + + planned := normalizeOutcome(request.OperationID, handler.Plan(ctx, request, runtime)) + if !execute || planned.State != Planned { + return e.persistOutcome(ctx, runtime, definition, request, operations.Record{}, planned) + } + + executed := normalizeOutcome(request.OperationID, handler.Execute(ctx, request, runtime)) + return e.persistOutcome(ctx, runtime, definition, request, operations.Record{}, executed) +} + +func (e Engine) Resume( + ctx context.Context, + handler Handler, + operationID string, + request Request, +) Outcome { + runtime := e.withDefaults() + request.OperationID = operationID + record, found, err := e.Store.Load(ctx, operationID) + if err != nil || !found { + return blockedOutcome( + operationID, + "JOURNEY_OPERATION_NOT_FOUND", + "the requested journey operation is unavailable", + ) + } + + definition := handler.Definition() + if record.JourneyID != definition.ID || record.ManifestHash != request.ManifestHash { + return blockedOutcome( + operationID, + "JOURNEY_RESUME_MISMATCH", + "the stored journey operation does not match this handler or manifest state", + ) + } + if isTerminal(record.State) { + return Outcome{OperationID: operationID, State: State(record.State)} + } + + resumed := normalizeOutcome( + operationID, + handler.Resume(ctx, request, runtime, record), + ) + if !isResumableResult(resumed.State) { + resumed.State = Reconciling + } + return e.persistOutcome(ctx, runtime, definition, request, record, resumed) +} + +func (e Engine) persistOutcome( + ctx context.Context, + runtime Runtime, + definition Definition, + request Request, + previous operations.Record, + outcome Outcome, +) Outcome { + now := runtime.Now() + record := operations.Record{ + SchemaVersion: 1, + OperationID: outcome.OperationID, + JourneyID: definition.ID, + PackID: definition.Product, + ManifestHash: request.ManifestHash, + State: string(outcome.State), + SafeReferences: extractSafeReferences( + outcome.SafeData, + runtime.SensitiveKeys, + ), + StartedAt: previous.StartedAt, + UpdatedAt: now, + } + if record.StartedAt.IsZero() { + record.StartedAt = record.UpdatedAt + } + if previous.OperationID == "" { + ok, err := e.Store.Reserve(ctx, record) + if err != nil { + return blockedOutcome( + outcome.OperationID, + "JOURNEY_PERSIST_FAILED", + "the journey state could not be stored safely", + ) + } + if ok { + return outcome + } + } + if err := e.Store.Save(ctx, record); err != nil { + return blockedOutcome( + outcome.OperationID, + "JOURNEY_PERSIST_FAILED", + "the journey state could not be stored safely", + ) + } + return outcome +} + +func (e Engine) withOperationID(request Request, runtime Runtime) Request { + if request.OperationID == "" && runtime.NewOperationID != nil { + request.OperationID = runtime.NewOperationID() + } + return request +} + +func (e Engine) withDefaults() Runtime { + runtime := e.Runtime + if runtime.Now == nil { + runtime.Now = func() time.Time { return time.Now().UTC() } + } + if runtime.NewOperationID == nil { + runtime.NewOperationID = func() string { return "" } + } + return runtime +} + +func normalizeOutcome(operationID string, outcome Outcome) Outcome { + outcome.OperationID = operationID + if outcome.SafeData == nil { + outcome.SafeData = map[string]any{} + } + return outcome +} + +func extractSafeReferences( + safeData map[string]any, + sensitiveKeys []string, +) map[string]string { + references := make(map[string]string) + for key, value := range safeData { + stringValue, ok := value.(string) + if !ok || stringValue == "" || isSensitiveKey(key, sensitiveKeys) { + continue + } + references[key] = stringValue + } + return references +} + +func isSensitiveKey(key string, sensitiveKeys []string) bool { + normalized := normalizeKey(key) + for _, candidate := range sensitiveKeys { + if normalized == normalizeKey(candidate) { + return true + } + } + return false +} + +func normalizeKey(value string) string { + var normalized strings.Builder + for _, character := range value { + if unicode.IsLetter(character) || unicode.IsNumber(character) { + normalized.WriteRune(unicode.ToLower(character)) + } + } + return normalized.String() +} + +func isTerminal(state string) bool { + return state == string(Passed) || + state == string(Failed) || + state == string(Blocked) +} + +func isResumableResult(state State) bool { + return state == AwaitingUserAction || + state == Reconciling || + state == Passed || + state == Failed || + state == Blocked +} + +func blockedOutcome(operationID, code, message string) Outcome { + return Outcome{ + OperationID: operationID, + State: Blocked, + Finding: &contracts.Finding{ + Code: code, + Severity: "blocking", + Message: message, + }, + } +} diff --git a/internal/journey/engine_test.go b/internal/journey/engine_test.go new file mode 100644 index 0000000..a7b6365 --- /dev/null +++ b/internal/journey/engine_test.go @@ -0,0 +1,256 @@ +package journey_test + +import ( + "context" + "testing" + "time" + + "github.com/veritrans/midtrans-cli/internal/contracts" + "github.com/veritrans/midtrans-cli/internal/journey" + "github.com/veritrans/midtrans-cli/internal/manifest" + "github.com/veritrans/midtrans-cli/internal/operations" +) + +func TestEngineRunPlansWithoutExecutingWhenExecutionDisabled(t *testing.T) { + handler := &fakeHandler{ + definition: testDefinition(), + plan: journey.Outcome{ + State: journey.Planned, + SafeData: map[string]any{"order_id": "order-001"}, + }, + execute: journey.Outcome{State: journey.Passed}, + } + + outcome := testEngine(t).Run(context.Background(), handler, testRequest("op_test"), false) + + if outcome.State != journey.Planned { + t.Fatalf("state = %q, want %q", outcome.State, journey.Planned) + } + if handler.executeCalls != 0 { + t.Fatalf("execute calls = %d, want 0", handler.executeCalls) + } +} + +func TestEngineRunDoesNotExecuteWhenPlanIsNotPlanned(t *testing.T) { + handler := &fakeHandler{ + definition: testDefinition(), + plan: journey.Outcome{State: journey.Blocked}, + execute: journey.Outcome{State: journey.Passed}, + } + + outcome := testEngine(t).Run(context.Background(), handler, testRequest("op_test"), true) + + if outcome.State != journey.Blocked { + t.Fatalf("state = %q, want %q", outcome.State, journey.Blocked) + } + if handler.executeCalls != 0 { + t.Fatalf("execute calls = %d, want 0", handler.executeCalls) + } +} + +func TestEnginePersistsAwaitingActionAndResumesSameOperation(t *testing.T) { + handler := &fakeHandler{ + definition: testDefinition(), + plan: journey.Outcome{State: journey.Planned}, + execute: journey.Outcome{ + State: journey.AwaitingUserAction, + Action: &journey.Action{ + Type: "browser", + Instructions: "complete the hosted checkout", + ResumeCommand: "midtrans agent resume --operation op_test", + }, + SafeData: map[string]any{ + "order_id": "order-001", + }, + }, + resume: journey.Outcome{State: journey.Passed}, + } + + engine := testEngine(t) + first := engine.Run(context.Background(), handler, testRequest("op_test"), true) + record, found, err := engine.Store.Load(context.Background(), "op_test") + if err != nil { + t.Fatal(err) + } + if !found { + t.Fatal("awaiting action record was not persisted") + } + if record.State != string(journey.AwaitingUserAction) { + t.Fatalf("record state = %q, want %q", record.State, journey.AwaitingUserAction) + } + second := engine.Resume(context.Background(), handler, "op_test", testRequest("op_test")) + if first.State != journey.AwaitingUserAction { + t.Fatalf("first state = %q, want %q", first.State, journey.AwaitingUserAction) + } + if second.OperationID != first.OperationID { + t.Fatalf("resume operation = %q, want %q", second.OperationID, first.OperationID) + } +} + +func TestEngineResumeRejectsDifferentManifestHashOrJourney(t *testing.T) { + handler := &fakeHandler{ + definition: testDefinition(), + plan: journey.Outcome{State: journey.Planned}, + execute: journey.Outcome{ + State: journey.AwaitingUserAction, + Action: testAction(), + }, + } + + engine := testEngine(t) + first := engine.Run(context.Background(), handler, testRequest("op_test"), true) + if first.State != journey.AwaitingUserAction { + t.Fatalf("first state = %q, want %q", first.State, journey.AwaitingUserAction) + } + + mismatchedHash := testRequest("op_test") + mismatchedHash.ManifestHash = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + if got := engine.Resume(context.Background(), handler, "op_test", mismatchedHash); got.State != journey.Blocked { + t.Fatalf("manifest hash mismatch state = %q, want %q", got.State, journey.Blocked) + } + + otherHandler := &fakeHandler{definition: journey.Definition{ + ID: "snap.other", + Product: "snap", + Intent: "other", + }} + if got := engine.Resume(context.Background(), otherHandler, "op_test", testRequest("op_test")); got.State != journey.Blocked { + t.Fatalf("journey mismatch state = %q, want %q", got.State, journey.Blocked) + } +} + +func TestEngineResumeReturnsTerminalRecordWithoutReinvokingHandler(t *testing.T) { + for _, state := range []journey.State{ + journey.Passed, + journey.Failed, + journey.Blocked, + } { + t.Run(string(state), func(t *testing.T) { + handler := &fakeHandler{ + definition: testDefinition(), + plan: journey.Outcome{State: journey.Planned}, + execute: journey.Outcome{State: state}, + resume: journey.Outcome{State: journey.Reconciling}, + } + + engine := testEngine(t) + first := engine.Run(context.Background(), handler, testRequest("op_test"), true) + second := engine.Resume(context.Background(), handler, "op_test", testRequest("op_test")) + + if first.State != state || second.State != state { + t.Fatalf("first = %#v second = %#v", first, second) + } + if handler.resumeCalls != 0 { + t.Fatalf("resume calls = %d, want 0", handler.resumeCalls) + } + }) + } +} + +func TestEngineResumeConvertsAmbiguousOutcomeToReconciling(t *testing.T) { + handler := &fakeHandler{ + definition: testDefinition(), + plan: journey.Outcome{State: journey.Planned}, + execute: journey.Outcome{ + State: journey.AwaitingUserAction, + Action: testAction(), + }, + resume: journey.Outcome{State: journey.Planned}, + } + + engine := testEngine(t) + _ = engine.Run(context.Background(), handler, testRequest("op_test"), true) + outcome := engine.Resume(context.Background(), handler, "op_test", testRequest("op_test")) + + if outcome.State != journey.Reconciling { + t.Fatalf("state = %q, want %q", outcome.State, journey.Reconciling) + } + if handler.executeCalls != 1 { + t.Fatalf("execute calls = %d, want 1", handler.executeCalls) + } +} + +type fakeHandler struct { + definition journey.Definition + plan journey.Outcome + execute journey.Outcome + resume journey.Outcome + planCalls int + executeCalls int + resumeCalls int +} + +func (f *fakeHandler) Definition() journey.Definition { + return f.definition +} + +func (f *fakeHandler) Plan(_ context.Context, _ journey.Request, _ journey.Runtime) journey.Outcome { + f.planCalls++ + return f.plan +} + +func (f *fakeHandler) Execute(_ context.Context, _ journey.Request, _ journey.Runtime) journey.Outcome { + f.executeCalls++ + return f.execute +} + +func (f *fakeHandler) Resume( + _ context.Context, + _ journey.Request, + _ journey.Runtime, + _ operations.Record, +) journey.Outcome { + f.resumeCalls++ + return f.resume +} + +func testEngine(t *testing.T) journey.Engine { + t.Helper() + return journey.Engine{ + Store: operations.Store{ProjectDir: t.TempDir()}, + Runtime: journey.Runtime{ + Now: func() time.Time { return time.Unix(1700000000, 0).UTC() }, + NewOperationID: func() string { return "op_test" }, + SensitiveKeys: []string{"token", "signature_key"}, + }, + } +} + +func testDefinition() journey.Definition { + return journey.Definition{ + ID: "snap.checkout", + Product: "snap", + Intent: "checkout", + RequiredInputs: []string{"order_id", "amount"}, + Interaction: "browser", + } +} + +func testRequest(operationID string) journey.Request { + return journey.Request{ + OperationID: operationID, + ProjectDir: "/tmp/project", + ManifestHash: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + Manifest: manifest.Default(), + Input: journey.Input{ + OrderID: "order-001", + Amount: 10000, + Method: "gopay", + }, + } +} + +func testAction() *journey.Action { + return &journey.Action{ + Type: "browser", + Instructions: "complete the hosted checkout", + ResumeCommand: "midtrans agent resume --operation op_test", + } +} + +func requireFindingCode(t *testing.T, finding *contracts.Finding, want string) { + t.Helper() + if finding == nil || finding.Code != want { + t.Fatalf("finding = %#v, want code %q", finding, want) + } +} diff --git a/internal/journey/types.go b/internal/journey/types.go new file mode 100644 index 0000000..312fc04 --- /dev/null +++ b/internal/journey/types.go @@ -0,0 +1,84 @@ +package journey + +import ( + "context" + "net/http" + "time" + + "github.com/veritrans/midtrans-cli/internal/contracts" + "github.com/veritrans/midtrans-cli/internal/evidence" + "github.com/veritrans/midtrans-cli/internal/manifest" + "github.com/veritrans/midtrans-cli/internal/operations" +) + +type State string + +const ( + Planned State = "planned" + AwaitingUserAction State = "awaiting_user_action" + Reconciling State = "reconciling" + Passed State = "passed" + Failed State = "failed" + Blocked State = "blocked" +) + +type Definition struct { + ID string `json:"id"` + Product string `json:"product"` + Intent string `json:"intent"` + RequiredInputs []string `json:"required_inputs"` + Interaction string `json:"interaction,omitempty"` +} + +type Input struct { + OrderID string `json:"order_id,omitempty"` + Amount int64 `json:"amount,omitempty"` + Method string `json:"method,omitempty"` + CustomerReference string `json:"customer_reference,omitempty"` + PaymentTokenReference string `json:"payment_token_reference,omitempty"` + Reusable bool `json:"reusable,omitempty"` +} + +type Request struct { + OperationID string + ProjectDir string + ManifestHash string + Manifest manifest.Manifest + Input Input +} + +type Action struct { + Type string `json:"type"` + URL string `json:"url,omitempty"` + Instructions string `json:"instructions"` + ExpiresAt time.Time `json:"expires_at,omitempty"` + ResumeCommand string `json:"resume_command"` +} + +type Outcome struct { + OperationID string + State State + SafeData map[string]any + Action *Action + Proofs []evidence.Proof + MissingEvidence []string + Finding *contracts.Finding +} + +type Runtime struct { + HTTP interface { + Do(*http.Request) (*http.Response, error) + } + ResolveCredential func(context.Context, string, string) ([]byte, error) + Now func() time.Time + NewOperationID func() string + OpenBrowser func(context.Context, Action) error + SensitiveKeys []string +} + +type Handler interface { + Definition() Definition + Plan(context.Context, Request, Runtime) Outcome + Execute(context.Context, Request, Runtime) Outcome + Resume(context.Context, Request, Runtime, operations.Record) Outcome +} diff --git a/internal/operations/store.go b/internal/operations/store.go index 8a01b99..9db3c4b 100644 --- a/internal/operations/store.go +++ b/internal/operations/store.go @@ -10,19 +10,26 @@ import ( "io" "os" "path/filepath" + "strings" + "time" "github.com/veritrans/midtrans-cli/internal/safepath" ) -const maxRecordBytes = 4 << 10 +const maxRecordBytes = 8 << 10 var errRecordInvalid = errors.New("OPERATION_RECORD_INVALID") type Record struct { - OperationID string `json:"operation_id"` - OrderID string `json:"order_id"` - GrossAmount int64 `json:"gross_amount"` - State string `json:"state"` + SchemaVersion int `json:"schema_version"` + OperationID string `json:"operation_id"` + JourneyID string `json:"journey_id"` + PackID string `json:"pack_id"` + ManifestHash string `json:"manifest_hash"` + State string `json:"state"` + SafeReferences map[string]string `json:"safe_references"` + StartedAt time.Time `json:"started_at"` + UpdatedAt time.Time `json:"updated_at"` } type Store struct { @@ -31,15 +38,15 @@ type Store struct { func (s Store) Load( ctx context.Context, - orderID string, + operationID string, ) (Record, bool, error) { if err := ctx.Err(); err != nil { return Record{}, false, err } - if s.ProjectDir == "" || orderID == "" { + if s.ProjectDir == "" || operationID == "" { return Record{}, false, errRecordInvalid } - relative := recordRelativePath(orderID) + relative := recordRelativePath(operationID) candidate, err := safepath.WriteTarget(s.ProjectDir, relative) if err != nil { return Record{}, false, errRecordInvalid @@ -72,7 +79,7 @@ func (s Store) Load( if err := decoder.Decode(&struct{}{}); !errors.Is(err, io.EOF) { return Record{}, false, errRecordInvalid } - if !validRecord(record) || record.OrderID != orderID { + if !validRecord(record) || record.OperationID != operationID { return Record{}, false, errRecordInvalid } return record, true, nil @@ -137,7 +144,7 @@ func (s Store) prepareWrite( path, err := safepath.WriteTarget( s.ProjectDir, - recordRelativePath(record.OrderID), + recordRelativePath(record.OperationID), ) if err != nil { return nil, "", "", errRecordInvalid @@ -197,8 +204,8 @@ func syncDirectory(dir string) error { return nil } -func recordRelativePath(orderID string) string { - sum := sha256.Sum256([]byte(orderID)) +func recordRelativePath(operationID string) string { + sum := sha256.Sum256([]byte(operationID)) return filepath.Join( ".midtrans", "operations", @@ -207,8 +214,30 @@ func recordRelativePath(orderID string) string { } func validRecord(record Record) bool { - return record.OperationID != "" && - record.OrderID != "" && - record.GrossAmount > 0 && - record.State != "" + if record.SchemaVersion != 1 || + record.OperationID == "" || + record.JourneyID == "" || + record.PackID == "" || + !validHash(record.ManifestHash) || + record.State == "" || + record.SafeReferences == nil || + record.StartedAt.IsZero() || + record.UpdatedAt.IsZero() || + record.UpdatedAt.Before(record.StartedAt) { + return false + } + for key, value := range record.SafeReferences { + if strings.TrimSpace(key) == "" || strings.TrimSpace(value) == "" { + return false + } + } + return true +} + +func validHash(value string) bool { + if len(value) != sha256.Size*2 || value != strings.ToLower(value) { + return false + } + _, err := hex.DecodeString(value) + return err == nil } diff --git a/internal/operations/store_test.go b/internal/operations/store_test.go index e6e6741..b4fc623 100644 --- a/internal/operations/store_test.go +++ b/internal/operations/store_test.go @@ -7,10 +7,12 @@ import ( "encoding/json" "os" "path/filepath" + "reflect" "strings" "sync" "sync/atomic" "testing" + "time" "github.com/veritrans/midtrans-cli/internal/operations" ) @@ -18,18 +20,13 @@ import ( func TestStoreRoundTripsHashedRecordWithPrivateModes(t *testing.T) { project := t.TempDir() store := operations.Store{ProjectDir: project} - record := operations.Record{ - OperationID: "operation-001", - OrderID: "../../merchant/order?secret=no", - GrossAmount: 10000, - State: "create_started", - } + record := testRecord("op_test") if err := store.Save(context.Background(), record); err != nil { t.Fatal(err) } - sum := sha256.Sum256([]byte(record.OrderID)) + sum := sha256.Sum256([]byte(record.OperationID)) name := hex.EncodeToString(sum[:]) + ".json" path := filepath.Join(project, ".midtrans", "operations", name) info, err := os.Stat(path) @@ -47,11 +44,11 @@ func TestStoreRoundTripsHashedRecordWithPrivateModes(t *testing.T) { t.Fatalf("operations mode = %#o, want 0700", got) } - got, found, err := store.Load(context.Background(), record.OrderID) + got, found, err := store.Load(context.Background(), record.OperationID) if err != nil { t.Fatal(err) } - if !found || got != record { + if !found || !reflect.DeepEqual(got, record) { t.Fatalf("Load() = %#v, %v, want %#v, true", got, found, record) } @@ -63,11 +60,19 @@ func TestStoreRoundTripsHashedRecordWithPrivateModes(t *testing.T) { if err := json.Unmarshal(data, &fields); err != nil { t.Fatal(err) } - if len(fields) != 4 { + if len(fields) != 9 { t.Fatalf("ledger fields = %v", fields) } for _, allowed := range []string{ - "operation_id", "order_id", "gross_amount", "state", + "schema_version", + "operation_id", + "journey_id", + "pack_id", + "manifest_hash", + "state", + "safe_references", + "started_at", + "updated_at", } { if _, ok := fields[allowed]; !ok { t.Fatalf("ledger missing %q: %s", allowed, data) @@ -85,25 +90,21 @@ func TestStoreRoundTripsHashedRecordWithPrivateModes(t *testing.T) { func TestStoreAtomicallyReplacesExistingRecord(t *testing.T) { project := t.TempDir() store := operations.Store{ProjectDir: project} - record := operations.Record{ - OperationID: "operation-001", - OrderID: "order-001", - GrossAmount: 10000, - State: "create_started", - } + record := testRecord("op_test") if err := store.Save(context.Background(), record); err != nil { t.Fatal(err) } - record.State = "checkout_required" + record.State = "reconciling" + record.UpdatedAt = record.UpdatedAt.Add(time.Minute) if err := store.Save(context.Background(), record); err != nil { t.Fatal(err) } - got, found, err := store.Load(context.Background(), record.OrderID) + got, found, err := store.Load(context.Background(), record.OperationID) if err != nil { t.Fatal(err) } - if !found || got != record { + if !found || !reflect.DeepEqual(got, record) { t.Fatalf("Load() = %#v, %v, want %#v, true", got, found, record) } entries, err := os.ReadDir(filepath.Join(project, ".midtrans", "operations")) @@ -118,12 +119,7 @@ func TestStoreAtomicallyReplacesExistingRecord(t *testing.T) { func TestStoreReserveIsAtomicAcrossConcurrentCallers(t *testing.T) { project := t.TempDir() store := operations.Store{ProjectDir: project} - record := operations.Record{ - OperationID: "operation-001", - OrderID: "order-001", - GrossAmount: 10000, - State: "create_started", - } + record := testRecord("op_test") const callers = 16 start := make(chan struct{}) @@ -154,31 +150,32 @@ func TestStoreReserveIsAtomicAcrossConcurrentCallers(t *testing.T) { if got := acquired.Load(); got != 1 { t.Fatalf("reservations acquired = %d, want exactly one", got) } - got, found, err := store.Load(context.Background(), record.OrderID) + got, found, err := store.Load(context.Background(), record.OperationID) if err != nil { t.Fatal(err) } - if !found || got != record { + if !found || !reflect.DeepEqual(got, record) { t.Fatalf("Load() = %#v, %v, want %#v, true", got, found, record) } - record.State = "checkout_required" + record.State = "passed" + record.UpdatedAt = record.UpdatedAt.Add(time.Minute) if err := store.Save(context.Background(), record); err != nil { t.Fatal(err) } - got, found, err = store.Load(context.Background(), record.OrderID) - if err != nil || !found || got != record { + got, found, err = store.Load(context.Background(), record.OperationID) + if err != nil || !found || !reflect.DeepEqual(got, record) { t.Fatalf("updated Load() = %#v, %v, %v", got, found, err) } } func TestStoreMissingRecordIsNotFound(t *testing.T) { store := operations.Store{ProjectDir: t.TempDir()} - got, found, err := store.Load(context.Background(), "missing-order") + got, found, err := store.Load(context.Background(), "op_missing") if err != nil { t.Fatal(err) } - if found || got != (operations.Record{}) { + if found || !reflect.DeepEqual(got, operations.Record{}) { t.Fatalf("Load() = %#v, %v, want zero, false", got, found) } } @@ -196,12 +193,7 @@ func TestStoreRejectsSymlinkEscape(t *testing.T) { t.Fatal(err) } store := operations.Store{ProjectDir: project} - err := store.Save(context.Background(), operations.Record{ - OperationID: "operation-001", - OrderID: "order-001", - GrossAmount: 10000, - State: "create_started", - }) + err := store.Save(context.Background(), testRecord("op_test")) if err == nil { t.Fatal("Save() accepted symlinked operations directory") } @@ -221,18 +213,18 @@ func TestStoreRejectsUnknownOrMismatchedRecord(t *testing.T) { }{ { name: "unknown field", - body: `{"operation_id":"operation-001","order_id":"order-001","gross_amount":10000,"state":"create_started","token":"secret"}`, + body: `{"schema_version":1,"operation_id":"op_test","journey_id":"snap.checkout","pack_id":"snap","manifest_hash":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","state":"awaiting_user_action","safe_references":{"order_id":"order-001"},"started_at":"2026-07-27T00:00:00Z","updated_at":"2026-07-27T00:00:00Z","token":"secret"}`, }, { - name: "mismatched order", - body: `{"operation_id":"operation-001","order_id":"other-order","gross_amount":10000,"state":"create_started"}`, + name: "mismatched operation", + body: `{"schema_version":1,"operation_id":"op_other","journey_id":"snap.checkout","pack_id":"snap","manifest_hash":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","state":"awaiting_user_action","safe_references":{"order_id":"order-001"},"started_at":"2026-07-27T00:00:00Z","updated_at":"2026-07-27T00:00:00Z"}`, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { project := t.TempDir() - orderID := "order-001" - sum := sha256.Sum256([]byte(orderID)) + operationID := "op_test" + sum := sha256.Sum256([]byte(operationID)) dir := filepath.Join(project, ".midtrans", "operations") if err := os.MkdirAll(dir, 0o700); err != nil { t.Fatal(err) @@ -244,15 +236,49 @@ func TestStoreRejectsUnknownOrMismatchedRecord(t *testing.T) { _, _, err := (operations.Store{ProjectDir: project}).Load( context.Background(), - orderID, + operationID, ) if err == nil { t.Fatal("Load() accepted unsafe record") } if strings.Contains(err.Error(), "secret") || - strings.Contains(err.Error(), "other-order") { + strings.Contains(err.Error(), "op_other") { t.Fatalf("Load() error exposed record contents: %q", err) } }) } } + +func TestStoreRoundTripsGenericJourneyRecord(t *testing.T) { + project := t.TempDir() + store := operations.Store{ProjectDir: project} + record := testRecord("op_test") + + if err := store.Save(context.Background(), record); err != nil { + t.Fatal(err) + } + got, found, err := store.Load(context.Background(), "op_test") + if err != nil { + t.Fatal(err) + } + if !found || got.OperationID != record.OperationID || got.JourneyID != record.JourneyID { + t.Fatalf("Load() = %#v, %v, want journey record for %q", got, found, record.OperationID) + } +} + +func testRecord(operationID string) operations.Record { + now := time.Date(2026, time.July, 27, 0, 0, 0, 0, time.UTC) + return operations.Record{ + SchemaVersion: 1, + OperationID: operationID, + JourneyID: "snap.checkout", + PackID: "snap", + ManifestHash: strings.Repeat("a", 64), + State: "awaiting_user_action", + SafeReferences: map[string]string{ + "order_id": "order-001", + }, + StartedAt: now, + UpdatedAt: now, + } +} diff --git a/packs/snap/journey.go b/packs/snap/journey.go index 033dfd8..da60aea 100644 --- a/packs/snap/journey.go +++ b/packs/snap/journey.go @@ -2,7 +2,10 @@ package snap import ( "context" + "encoding/hex" "errors" + "strings" + "time" "github.com/veritrans/midtrans-cli/internal/contracts" "github.com/veritrans/midtrans-cli/internal/evidence" @@ -78,6 +81,7 @@ func (r LocalVerificationResult) Passed() bool { type JourneyInput struct { OperationID string + ManifestHash string OrderID string GrossAmount int64 GrossAmountString string @@ -86,6 +90,7 @@ type JourneyInput struct { } type JourneyResult struct { + OperationID string `json:"operation_id"` State JourneyState `json:"state"` OrderID string `json:"order_id"` RedirectURL string `json:"redirect_url,omitempty"` @@ -98,13 +103,18 @@ func (r JourneyResult) Evidence() (map[string]string, []evidence.Proof) { if r.State != JourneyVerified { return nil, nil } + observedAt := time.Now().UTC() return map[string]string{ "order_id": r.OrderID, }, []evidence.Proof{ { - ID: "snap.provider-status", - Level: evidence.ProofSandbox, - Status: "pass", + ID: "snap.provider-status", + OperationID: r.OperationID, + Stage: "provider_status", + Level: evidence.ProofSandbox, + Source: "midtrans_api", + ObservedAt: observedAt, + Status: "pass", Summary: map[string]any{ "order_id": r.Provider.OrderID, "transaction_status": r.Provider.TransactionStatus, @@ -113,9 +123,13 @@ func (r JourneyResult) Evidence() (map[string]string, []evidence.Proof) { }, }, { - ID: "snap.merchant-callback", - Level: evidence.ProofLocal, - Status: "pass", + ID: "snap.merchant-callback", + OperationID: r.OperationID, + Stage: "merchant_callback", + Level: evidence.ProofLocal, + Source: "merchant_application", + ObservedAt: observedAt, + Status: "pass", Summary: map[string]any{ "settlement_applied": r.Local.SettlementApplied, "duplicate_idempotent": r.Local.DuplicateIdempotent, @@ -156,13 +170,15 @@ func (r JourneyRunner) Run( input JourneyInput, ) (JourneyResult, error) { base := JourneyResult{ - State: JourneyPlanned, - OrderID: input.OrderID, + OperationID: input.OperationID, + State: JourneyPlanned, + OrderID: input.OrderID, } if !input.Execute { return base, nil } if input.OperationID == "" || + !validManifestHash(input.ManifestHash) || input.OrderID == "" || input.GrossAmount <= 0 || input.GrossAmountString == "" || @@ -188,7 +204,7 @@ func (r JourneyRunner) Run( return r.evaluateStatus(ctx, input, status) } - record, found, err := r.Ledger.Load(ctx, input.OrderID) + record, found, err := r.Ledger.Load(ctx, input.OperationID) if err != nil { base.State = JourneyBlocked return base, errJourneyLedger @@ -198,10 +214,17 @@ func (r JourneyRunner) Run( } started := operations.Record{ - OperationID: input.OperationID, - OrderID: input.OrderID, - GrossAmount: input.GrossAmount, - State: createStartedState, + SchemaVersion: 1, + OperationID: input.OperationID, + JourneyID: "snap.checkout", + PackID: "snap", + ManifestHash: input.ManifestHash, + State: createStartedState, + SafeReferences: map[string]string{ + "order_id": input.OrderID, + }, + StartedAt: time.Now().UTC(), + UpdatedAt: time.Now().UTC(), } reserved, err := r.Ledger.Reserve(ctx, started) if err != nil { @@ -209,7 +232,7 @@ func (r JourneyRunner) Run( return base, errJourneyLedger } if !reserved { - record, found, err = r.Ledger.Load(ctx, input.OrderID) + record, found, err = r.Ledger.Load(ctx, input.OperationID) if err != nil || !found { base.State = JourneyBlocked return base, errJourneyLedger @@ -238,6 +261,7 @@ func (r JourneyRunner) Run( accepted := started accepted.State = string(JourneyCheckoutRequired) + accepted.UpdatedAt = time.Now().UTC() if err := r.Ledger.Save(ctx, accepted); err != nil { base.State = JourneyAmbiguous return base, nil @@ -257,8 +281,9 @@ func (r JourneyRunner) evaluateStatus( status StatusResponse, ) (JourneyResult, error) { result := JourneyResult{ - OrderID: input.OrderID, - Provider: status, + OperationID: input.OperationID, + OrderID: input.OrderID, + Provider: status, } if status.OrderID != input.OrderID { result.State = JourneyBlocked @@ -327,3 +352,11 @@ func existingOperationResult( } return result } + +func validManifestHash(value string) bool { + if len(value) != 64 || value != strings.ToLower(value) { + return false + } + _, err := hex.DecodeString(value) + return err == nil +} diff --git a/packs/snap/journey_test.go b/packs/snap/journey_test.go index 4e4b048..dc825ec 100644 --- a/packs/snap/journey_test.go +++ b/packs/snap/journey_test.go @@ -30,8 +30,9 @@ func TestJourneyEvidenceMapsOnlyVerifiedSafeClaims(t *testing.T) { } references, proofs := (snap.JourneyResult{ - State: snap.JourneyVerified, - OrderID: "safe-order", + OperationID: "op_snap_test", + State: snap.JourneyVerified, + OrderID: "safe-order", Provider: snap.StatusResponse{ OrderID: "safe-order", TransactionStatus: "settlement", @@ -53,7 +54,11 @@ func TestJourneyEvidenceMapsOnlyVerifiedSafeClaims(t *testing.T) { if references["order_id"] != "safe-order" || len(references) != 1 || len(proofs) != 2 || proofs[0].Level != evidence.ProofSandbox || - proofs[1].Level != evidence.ProofLocal { + proofs[1].Level != evidence.ProofLocal || + proofs[0].OperationID != "op_snap_test" || + proofs[0].Stage == "" || + proofs[0].Source == "" || + proofs[0].ObservedAt.IsZero() { t.Fatalf("references = %#v, proofs = %#v", references, proofs) } encoded, err := json.Marshal(struct { @@ -673,6 +678,7 @@ func journeyInput(execute bool) snap.JourneyInput { } return snap.JourneyInput{ OperationID: plan.Hash, + ManifestHash: strings.Repeat("a", 64), OrderID: "sandbox-example-001", GrossAmount: 10000, GrossAmountString: "10000.00", diff --git a/schemas/operation-v1.schema.json b/schemas/operation-v1.schema.json new file mode 100644 index 0000000..87b0e42 --- /dev/null +++ b/schemas/operation-v1.schema.json @@ -0,0 +1,35 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://github.com/veritrans/midtrans-cli/schemas/operation-v1.schema.json", + "title": "Midtrans CLI operation record v1", + "type": "object", + "additionalProperties": false, + "required": [ + "schema_version", + "operation_id", + "journey_id", + "pack_id", + "manifest_hash", + "state", + "safe_references", + "started_at", + "updated_at" + ], + "properties": { + "schema_version": {"const": 1}, + "operation_id": {"type": "string", "minLength": 1}, + "journey_id": {"type": "string", "minLength": 1}, + "pack_id": {"type": "string", "minLength": 1}, + "manifest_hash": {"type": "string", "pattern": "^[0-9a-f]{64}$"}, + "state": {"type": "string", "minLength": 1}, + "safe_references": { + "type": "object", + "additionalProperties": { + "type": "string", + "minLength": 1 + } + }, + "started_at": {"type": "string", "format": "date-time"}, + "updated_at": {"type": "string", "format": "date-time"} + } +} From 7192ca9e714dbbf1acc444d5f8edde65174bad9c Mon Sep 17 00:00:00 2001 From: "m.salis" Date: Mon, 27 Jul 2026 09:06:36 +0700 Subject: [PATCH 29/73] fix: tighten journey persistence invariants --- .../task-3-report.md | 35 ++ internal/evidence/evidence_test.go | 33 ++ internal/evidence/redact.go | 6 + internal/journey/engine.go | 100 +++-- internal/journey/engine_test.go | 78 +++- internal/operations/store.go | 17 +- internal/operations/store_test.go | 8 + packs/snap/journey.go | 389 ++++++++++++------ packs/snap/journey_test.go | 69 ++-- schemas/evidence-v1.schema.json | 6 +- schemas/operation-v1.schema.json | 2 +- 11 files changed, 567 insertions(+), 176 deletions(-) diff --git a/.superpowers/sdd/2026-07-26-midtrans-cli-multi-product-parity/task-3-report.md b/.superpowers/sdd/2026-07-26-midtrans-cli-multi-product-parity/task-3-report.md index 5e84ba5..a1ecd95 100644 --- a/.superpowers/sdd/2026-07-26-midtrans-cli-multi-product-parity/task-3-report.md +++ b/.superpowers/sdd/2026-07-26-midtrans-cli-multi-product-parity/task-3-report.md @@ -40,3 +40,38 @@ Both commands passed on July 27, 2026. - No new command surface was exposed for resume flows in this task. - Snap was not migrated onto the generic journey engine; only its internal test/runtime adapters were updated so existing coverage remains valid against the new shared record and proof contracts. + +## Fix Round 1 + +### Reviewer findings addressed + +- Routed Snap persistence through a narrow compatibility handler on top of `internal/journey.Engine`, so only generic lifecycle states are persisted and pack-specific states no longer write directly to the operation store. +- Changed `internal/journey.Engine.Run` to reserve before execute, return a blocking conflict outcome when `Reserve` reports an existing operation, and preserve the existing record without falling through to overwrite. +- Combined `evidence` core sensitive keys with runtime pack keys during safe-reference persistence, and added coverage proving `server_key`, `authorization`, and `customer_authorization_token` never persist even with empty runtime keys. +- Enforced canonical `op_` operation IDs in Go and JSON schema, and canonicalized Snap operation IDs before execution/persistence instead of storing raw plan hashes. +- Updated `schemas/evidence-v1.schema.json` and schema-focused tests so proof metadata matches the Go `evidence.Proof` contract exactly. + +### Added or adjusted tests + +- Added engine tests for reserve conflicts, existing-record preservation, and core sensitive-key filtering. +- Added operation-store coverage for invalid operation IDs. +- Extended evidence schema tests to require `operation_id`, `stage`, `source`, and `observed_at`, and to reject proofs missing those fields at runtime validation. +- Updated Snap tests to assert canonical operation IDs, generic persisted states, no pack-specific lifecycle-state persistence, and the revised conflict/ledger-failure behavior through the engine path. + +### Commands run + +```sh +go test ./internal/journey ./internal/operations ./internal/evidence ./packs/snap ./internal/app -count=1 +go test ./... -count=1 +``` + +### Results + +- `go test ./internal/journey ./internal/operations ./internal/evidence ./packs/snap ./internal/app -count=1` passed on July 27, 2026. +- `go test ./... -count=1` passed on July 27, 2026. + +### Self-review notes + +- Kept `internal/operations` independent of `internal/journey`; the Snap adapter consumes the engine, not the other way around. +- Preserved the existing hidden Snap/test surfaces while moving lifecycle persistence ownership into the engine. +- Retained technical error surfacing for ledger persistence failures while keeping conflict outcomes non-destructive and non-overwriting. diff --git a/internal/evidence/evidence_test.go b/internal/evidence/evidence_test.go index 639f7fd..c8caafb 100644 --- a/internal/evidence/evidence_test.go +++ b/internal/evidence/evidence_test.go @@ -324,6 +324,29 @@ func TestEvidenceSchemaMatchesRuntimeConstraints(t *testing.T) { if got := requireObject(t, proofProperties["id"])["minLength"]; got != float64(1) { t.Fatalf("proof id minLength = %#v", got) } + for _, key := range []string{ + "operation_id", + "stage", + "source", + "observed_at", + } { + if _, ok := proofProperties[key]; !ok { + t.Fatalf("proof schema missing %q: %#v", key, proofProperties) + } + } + requiredProofFields, ok := items["required"].([]any) + if !ok { + t.Fatalf("proof required fields = %#v", items["required"]) + } + requiredSet := make(map[string]bool, len(requiredProofFields)) + for _, field := range requiredProofFields { + requiredSet[field.(string)] = true + } + for _, key := range []string{"operation_id", "stage", "source", "observed_at"} { + if !requiredSet[key] { + t.Fatalf("proof required fields missing %q: %#v", key, requiredProofFields) + } + } invalid := validBundle() invalid.RepositoryCommit = "not-a-revision" @@ -335,6 +358,16 @@ func TestEvidenceSchemaMatchesRuntimeConstraints(t *testing.T) { if err := evidence.Validate(invalid); err == nil { t.Fatal("runtime accepted undeclared safe reference") } + invalid = validBundle() + invalid.Proofs = []evidence.Proof{{ + ID: "snap.provider-status", + Level: evidence.ProofSandbox, + Status: "pass", + Summary: map[string]any{}, + }} + if err := evidence.Validate(invalid); err == nil { + t.Fatal("runtime accepted proof missing schema-required metadata") + } } func requireObject(t *testing.T, value any) map[string]any { diff --git a/internal/evidence/redact.go b/internal/evidence/redact.go index 9e342c6..c945501 100644 --- a/internal/evidence/redact.go +++ b/internal/evidence/redact.go @@ -32,6 +32,12 @@ var coreSensitiveKeys = []string{ "phone", } +func CoreSensitiveKeys() []string { + keys := make([]string, len(coreSensitiveKeys)) + copy(keys, coreSensitiveKeys) + return keys +} + func Redact(value any, extraKeys []string) any { generic, err := genericValue(value) if err != nil { diff --git a/internal/journey/engine.go b/internal/journey/engine.go index dabeae9..1a77958 100644 --- a/internal/journey/engine.go +++ b/internal/journey/engine.go @@ -7,11 +7,16 @@ import ( "unicode" "github.com/veritrans/midtrans-cli/internal/contracts" + "github.com/veritrans/midtrans-cli/internal/evidence" "github.com/veritrans/midtrans-cli/internal/operations" ) type Engine struct { - Store operations.Store + Store interface { + Load(context.Context, string) (operations.Record, bool, error) + Reserve(context.Context, operations.Record) (bool, error) + Save(context.Context, operations.Record) error + } Runtime Runtime } @@ -26,12 +31,30 @@ func (e Engine) Run( definition := handler.Definition() planned := normalizeOutcome(request.OperationID, handler.Plan(ctx, request, runtime)) - if !execute || planned.State != Planned { - return e.persistOutcome(ctx, runtime, definition, request, operations.Record{}, planned) + if planned.State != Planned { + return e.persistWithSave(ctx, runtime, definition, request, operations.Record{}, planned) + } + initial, ok, err := e.reserveInitial(ctx, runtime, definition, request, planned) + if err != nil { + return blockedOutcome( + request.OperationID, + "JOURNEY_PERSIST_FAILED", + "the journey state could not be stored safely", + ) + } + if !ok { + return blockedOutcome( + request.OperationID, + "JOURNEY_OPERATION_CONFLICT", + "the journey operation already exists and cannot be executed again", + ) + } + if !execute { + return planned } executed := normalizeOutcome(request.OperationID, handler.Execute(ctx, request, runtime)) - return e.persistOutcome(ctx, runtime, definition, request, operations.Record{}, executed) + return e.persistWithSave(ctx, runtime, definition, request, initial, executed) } func (e Engine) Resume( @@ -70,17 +93,16 @@ func (e Engine) Resume( if !isResumableResult(resumed.State) { resumed.State = Reconciling } - return e.persistOutcome(ctx, runtime, definition, request, record, resumed) + return e.persistWithSave(ctx, runtime, definition, request, record, resumed) } -func (e Engine) persistOutcome( +func (e Engine) reserveInitial( ctx context.Context, runtime Runtime, definition Definition, request Request, - previous operations.Record, outcome Outcome, -) Outcome { +) (operations.Record, bool, error) { now := runtime.Now() record := operations.Record{ SchemaVersion: 1, @@ -93,25 +115,25 @@ func (e Engine) persistOutcome( outcome.SafeData, runtime.SensitiveKeys, ), - StartedAt: previous.StartedAt, UpdatedAt: now, } - if record.StartedAt.IsZero() { - record.StartedAt = record.UpdatedAt - } - if previous.OperationID == "" { - ok, err := e.Store.Reserve(ctx, record) - if err != nil { - return blockedOutcome( - outcome.OperationID, - "JOURNEY_PERSIST_FAILED", - "the journey state could not be stored safely", - ) - } - if ok { - return outcome - } + record.StartedAt = record.UpdatedAt + ok, err := e.Store.Reserve(ctx, record) + if err != nil { + return operations.Record{}, false, err } + return record, ok, nil +} + +func (e Engine) persistWithSave( + ctx context.Context, + runtime Runtime, + definition Definition, + request Request, + previous operations.Record, + outcome Outcome, +) Outcome { + record := e.recordForOutcome(runtime, definition, request, previous, outcome) if err := e.Store.Save(ctx, record); err != nil { return blockedOutcome( outcome.OperationID, @@ -122,6 +144,33 @@ func (e Engine) persistOutcome( return outcome } +func (e Engine) recordForOutcome( + runtime Runtime, + definition Definition, + request Request, + previous operations.Record, + outcome Outcome, +) operations.Record { + record := operations.Record{ + SchemaVersion: 1, + OperationID: outcome.OperationID, + JourneyID: definition.ID, + PackID: definition.Product, + ManifestHash: request.ManifestHash, + State: string(outcome.State), + SafeReferences: extractSafeReferences( + outcome.SafeData, + runtime.SensitiveKeys, + ), + StartedAt: previous.StartedAt, + UpdatedAt: runtime.Now(), + } + if record.StartedAt.IsZero() { + record.StartedAt = record.UpdatedAt + } + return record +} + func (e Engine) withOperationID(request Request, runtime Runtime) Request { if request.OperationID == "" && runtime.NewOperationID != nil { request.OperationID = runtime.NewOperationID() @@ -152,10 +201,11 @@ func extractSafeReferences( safeData map[string]any, sensitiveKeys []string, ) map[string]string { + allSensitive := append(evidence.CoreSensitiveKeys(), sensitiveKeys...) references := make(map[string]string) for key, value := range safeData { stringValue, ok := value.(string) - if !ok || stringValue == "" || isSensitiveKey(key, sensitiveKeys) { + if !ok || stringValue == "" || isSensitiveKey(key, allSensitive) { continue } references[key] = stringValue diff --git a/internal/journey/engine_test.go b/internal/journey/engine_test.go index a7b6365..b65bef0 100644 --- a/internal/journey/engine_test.go +++ b/internal/journey/engine_test.go @@ -21,7 +21,8 @@ func TestEngineRunPlansWithoutExecutingWhenExecutionDisabled(t *testing.T) { execute: journey.Outcome{State: journey.Passed}, } - outcome := testEngine(t).Run(context.Background(), handler, testRequest("op_test"), false) + engine := testEngine(t) + outcome := engine.Run(context.Background(), handler, testRequest("op_test"), false) if outcome.State != journey.Planned { t.Fatalf("state = %q, want %q", outcome.State, journey.Planned) @@ -29,6 +30,10 @@ func TestEngineRunPlansWithoutExecutingWhenExecutionDisabled(t *testing.T) { if handler.executeCalls != 0 { t.Fatalf("execute calls = %d, want 0", handler.executeCalls) } + record, found, err := engine.Store.Load(context.Background(), "op_test") + if err != nil || !found || record.State != string(journey.Planned) { + t.Fatalf("record = %#v, found = %v, err = %v", record, found, err) + } } func TestEngineRunDoesNotExecuteWhenPlanIsNotPlanned(t *testing.T) { @@ -170,6 +175,60 @@ func TestEngineResumeConvertsAmbiguousOutcomeToReconciling(t *testing.T) { } } +func TestEngineRunBlocksOnExistingOperationWithoutExecutingOrOverwriting(t *testing.T) { + engine := testEngine(t) + existing := testRecord("op_test", string(journey.AwaitingUserAction)) + if err := engine.Store.Save(context.Background(), existing); err != nil { + t.Fatal(err) + } + handler := &fakeHandler{ + definition: testDefinition(), + plan: journey.Outcome{State: journey.Planned}, + execute: journey.Outcome{State: journey.Passed}, + } + + outcome := engine.Run(context.Background(), handler, testRequest("op_test"), true) + + if outcome.State != journey.Blocked { + t.Fatalf("state = %q, want %q", outcome.State, journey.Blocked) + } + requireFindingCode(t, outcome.Finding, "JOURNEY_OPERATION_CONFLICT") + if handler.executeCalls != 0 { + t.Fatalf("execute calls = %d, want 0", handler.executeCalls) + } + got, found, err := engine.Store.Load(context.Background(), "op_test") + if err != nil || !found || got.State != existing.State || got.StartedAt != existing.StartedAt { + t.Fatalf("record = %#v, found = %v, err = %v", got, found, err) + } +} + +func TestEngineFiltersCoreSensitiveReferencesEvenWithoutPackKeys(t *testing.T) { + engine := testEngine(t) + engine.Runtime.SensitiveKeys = nil + handler := &fakeHandler{ + definition: testDefinition(), + plan: journey.Outcome{ + State: journey.Planned, + SafeData: map[string]any{ + "order_id": "order-001", + "server_key": "secret", + "authorization": "Basic secret", + "customer_authorization_token": "secret-token", + }, + }, + } + + _ = engine.Run(context.Background(), handler, testRequest("op_test"), false) + + record, found, err := engine.Store.Load(context.Background(), "op_test") + if err != nil || !found { + t.Fatalf("record missing: %#v, %v, %v", record, found, err) + } + if len(record.SafeReferences) != 1 || record.SafeReferences["order_id"] != "order-001" { + t.Fatalf("safe references = %#v", record.SafeReferences) + } +} + type fakeHandler struct { definition journey.Definition plan journey.Outcome @@ -248,6 +307,23 @@ func testAction() *journey.Action { } } +func testRecord(operationID, state string) operations.Record { + now := time.Unix(1700000000, 0).UTC() + return operations.Record{ + SchemaVersion: 1, + OperationID: operationID, + JourneyID: "snap.checkout", + PackID: "snap", + ManifestHash: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + State: state, + SafeReferences: map[string]string{ + "order_id": "order-001", + }, + StartedAt: now, + UpdatedAt: now, + } +} + func requireFindingCode(t *testing.T, finding *contracts.Finding, want string) { t.Helper() if finding == nil || finding.Code != want { diff --git a/internal/operations/store.go b/internal/operations/store.go index 9db3c4b..8bcf256 100644 --- a/internal/operations/store.go +++ b/internal/operations/store.go @@ -10,6 +10,7 @@ import ( "io" "os" "path/filepath" + "regexp" "strings" "time" @@ -20,6 +21,8 @@ const maxRecordBytes = 8 << 10 var errRecordInvalid = errors.New("OPERATION_RECORD_INVALID") +var operationIDPattern = regexp.MustCompile(`^op_[a-z0-9_]+$`) + type Record struct { SchemaVersion int `json:"schema_version"` OperationID string `json:"operation_id"` @@ -215,7 +218,7 @@ func recordRelativePath(operationID string) string { func validRecord(record Record) bool { if record.SchemaVersion != 1 || - record.OperationID == "" || + !ValidOperationID(record.OperationID) || record.JourneyID == "" || record.PackID == "" || !validHash(record.ManifestHash) || @@ -234,6 +237,18 @@ func validRecord(record Record) bool { return true } +func ValidOperationID(value string) bool { + return operationIDPattern.MatchString(value) +} + +func CanonicalOperationID(seed string) string { + if ValidOperationID(seed) { + return seed + } + sum := sha256.Sum256([]byte(seed)) + return "op_" + hex.EncodeToString(sum[:12]) +} + func validHash(value string) bool { if len(value) != sha256.Size*2 || value != strings.ToLower(value) { return false diff --git a/internal/operations/store_test.go b/internal/operations/store_test.go index b4fc623..ff1dee6 100644 --- a/internal/operations/store_test.go +++ b/internal/operations/store_test.go @@ -266,6 +266,14 @@ func TestStoreRoundTripsGenericJourneyRecord(t *testing.T) { } } +func TestStoreRejectsInvalidOperationID(t *testing.T) { + store := operations.Store{ProjectDir: t.TempDir()} + record := testRecord("not-canonical") + if err := store.Save(context.Background(), record); err == nil { + t.Fatal("Save() accepted invalid operation ID") + } +} + func testRecord(operationID string) operations.Record { now := time.Date(2026, time.July, 27, 0, 0, 0, 0, time.UTC) return operations.Record{ diff --git a/packs/snap/journey.go b/packs/snap/journey.go index da60aea..05205fc 100644 --- a/packs/snap/journey.go +++ b/packs/snap/journey.go @@ -2,20 +2,17 @@ package snap import ( "context" - "encoding/hex" "errors" - "strings" "time" "github.com/veritrans/midtrans-cli/internal/contracts" "github.com/veritrans/midtrans-cli/internal/evidence" + genericjourney "github.com/veritrans/midtrans-cli/internal/journey" "github.com/veritrans/midtrans-cli/internal/operations" "github.com/veritrans/midtrans-cli/internal/policy" "github.com/veritrans/midtrans-cli/internal/sandbox" ) -const createStartedState = "create_started" - var ( errJourneyInvalid = errors.New("SANDBOX_JOURNEY_INVALID") errJourneyStatus = errors.New("SANDBOX_STATUS_FAILED") @@ -169,151 +166,260 @@ func (r JourneyRunner) Run( ctx context.Context, input JourneyInput, ) (JourneyResult, error) { - base := JourneyResult{ - OperationID: input.OperationID, - State: JourneyPlanned, - OrderID: input.OrderID, - } + input.OperationID = operations.CanonicalOperationID(input.OperationID) if !input.Execute { - return base, nil + return JourneyResult{ + OperationID: input.OperationID, + State: JourneyPlanned, + OrderID: input.OrderID, + }, nil } - if input.OperationID == "" || - !validManifestHash(input.ManifestHash) || - input.OrderID == "" || - input.GrossAmount <= 0 || - input.GrossAmountString == "" || - r.Tokens == nil || - r.Status == nil || - r.Local == nil || - r.Ledger == nil { - base.State = JourneyBlocked - return base, errJourneyInvalid + handler := &compatibilityHandler{runner: r, input: input} + engine := genericjourney.Engine{ + Store: r.Ledger, + Runtime: genericjourney.Runtime{ + Now: func() time.Time { return time.Now().UTC() }, + NewOperationID: func() string { return input.OperationID }, + }, } - decision := policy.Authorize(input.Plan, policy.Authorization{Execute: true}) - if !decision.Allowed { - base.State = JourneyBlocked - return base, errJourneyInvalid + request := genericjourney.Request{ + OperationID: input.OperationID, + ManifestHash: input.ManifestHash, + Input: genericjourney.Input{ + OrderID: input.OrderID, + Amount: input.GrossAmount, + }, } - status, err := r.Status.Status(ctx, input.OrderID) + var outcome genericjourney.Outcome + _, found, err := r.Ledger.Load(ctx, input.OperationID) if err != nil { - base.State = JourneyBlocked - return base, errJourneyStatus + return JourneyResult{ + OperationID: input.OperationID, + State: JourneyBlocked, + OrderID: input.OrderID, + }, errJourneyLedger } - if !status.NotFound { - return r.evaluateStatus(ctx, input, status) + if found { + outcome = engine.Resume(ctx, handler, input.OperationID, request) + return handler.resultFor(outcome), handler.resultError(outcome) } + outcome = engine.Run(ctx, handler, request, input.Execute) + return handler.resultFor(outcome), handler.resultError(outcome) +} - record, found, err := r.Ledger.Load(ctx, input.OperationID) - if err != nil { - base.State = JourneyBlocked - return base, errJourneyLedger +type compatibilityHandler struct { + runner JourneyRunner + input JourneyInput + lastErr error + lastResult JourneyResult + lastOutcome genericjourney.Outcome +} + +func (h *compatibilityHandler) Definition() genericjourney.Definition { + return genericjourney.Definition{ + ID: "snap.checkout", + Product: "snap", + Intent: "checkout", + RequiredInputs: []string{"order_id", "amount"}, + Interaction: "browser", } - if found { - return existingOperationResult(base, record), nil +} + +func (h *compatibilityHandler) Plan( + _ context.Context, + request genericjourney.Request, + _ genericjourney.Runtime, +) genericjourney.Outcome { + h.lastErr = nil + h.lastResult = JourneyResult{ + OperationID: request.OperationID, + State: JourneyPlanned, + OrderID: h.input.OrderID, + } + h.lastOutcome = genericjourney.Outcome{ + State: genericjourney.Planned, + SafeData: map[string]any{"order_id": h.input.OrderID}, } + return h.lastOutcome +} - started := operations.Record{ - SchemaVersion: 1, - OperationID: input.OperationID, - JourneyID: "snap.checkout", - PackID: "snap", - ManifestHash: input.ManifestHash, - State: createStartedState, - SafeReferences: map[string]string{ - "order_id": input.OrderID, - }, - StartedAt: time.Now().UTC(), - UpdatedAt: time.Now().UTC(), +func (h *compatibilityHandler) Execute( + ctx context.Context, + request genericjourney.Request, + _ genericjourney.Runtime, +) genericjourney.Outcome { + return h.executeOrResume(ctx, request, operations.Record{}) +} + +func (h *compatibilityHandler) Resume( + ctx context.Context, + request genericjourney.Request, + _ genericjourney.Runtime, + record operations.Record, +) genericjourney.Outcome { + return h.executeOrResume(ctx, request, record) +} + +func (h *compatibilityHandler) executeOrResume( + ctx context.Context, + request genericjourney.Request, + record operations.Record, +) genericjourney.Outcome { + base := JourneyResult{ + OperationID: request.OperationID, + OrderID: h.input.OrderID, } - reserved, err := r.Ledger.Reserve(ctx, started) + if request.OperationID == "" || + !operations.ValidOperationID(request.OperationID) || + h.input.ManifestHash == "" || + h.input.OrderID == "" || + h.input.GrossAmount <= 0 || + h.input.GrossAmountString == "" || + h.runner.Tokens == nil || + h.runner.Status == nil || + h.runner.Local == nil || + h.runner.Ledger == nil { + h.lastErr = errJourneyInvalid + h.lastResult = withJourneyState(base, JourneyBlocked) + return genericjourney.Outcome{State: genericjourney.Blocked} + } + decision := policy.Authorize(h.input.Plan, policy.Authorization{Execute: true}) + if !decision.Allowed { + h.lastErr = errJourneyInvalid + h.lastResult = withJourneyState(base, JourneyBlocked) + return genericjourney.Outcome{State: genericjourney.Blocked} + } + + status, err := h.runner.Status.Status(ctx, h.input.OrderID) if err != nil { - base.State = JourneyBlocked - return base, errJourneyLedger + h.lastErr = errJourneyStatus + h.lastResult = withJourneyState(base, JourneyBlocked) + return genericjourney.Outcome{State: genericjourney.Blocked} + } + if !status.NotFound { + return h.evaluateStatus(ctx, request.OperationID, status) } - if !reserved { - record, found, err = r.Ledger.Load(ctx, input.OperationID) - if err != nil || !found { - base.State = JourneyBlocked - return base, errJourneyLedger + if record.OperationID != "" { + if record.State == string(genericjourney.Reconciling) { + h.lastResult = withJourneyState(base, JourneyAmbiguous) + return genericjourney.Outcome{ + State: genericjourney.Reconciling, + SafeData: map[string]any{"order_id": h.input.OrderID}, + } + } + h.lastResult = JourneyResult{ + OperationID: request.OperationID, + State: JourneyBlocked, + OrderID: h.input.OrderID, + NextActions: []contracts.NextAction{reusePreviousCheckoutAction()}, + } + return genericjourney.Outcome{ + State: genericjourney.Blocked, + SafeData: map[string]any{"order_id": h.input.OrderID}, } - return existingOperationResult(base, record), nil } - created, err := r.Tokens.CreateToken(ctx, CreateTokenRequest{ - OperationID: input.OperationID, - OrderID: input.OrderID, - GrossAmount: input.GrossAmount, + created, err := h.runner.Tokens.CreateToken(ctx, CreateTokenRequest{ + OperationID: request.OperationID, + OrderID: h.input.OrderID, + GrossAmount: h.input.GrossAmount, }) if err != nil { var ambiguous sandbox.AmbiguousOperationError if !errors.As(err, &ambiguous) { - base.State = JourneyBlocked - return base, errJourneyCreate + h.lastErr = errJourneyCreate + h.lastResult = withJourneyState(base, JourneyBlocked) + return genericjourney.Outcome{State: genericjourney.Blocked} } - reconciled, statusErr := r.Status.Status(ctx, input.OrderID) + reconciled, statusErr := h.runner.Status.Status(ctx, h.input.OrderID) if statusErr != nil || reconciled.NotFound { - base.State = JourneyAmbiguous - return base, nil + h.lastResult = withJourneyState(base, JourneyAmbiguous) + return genericjourney.Outcome{ + State: genericjourney.Reconciling, + SafeData: map[string]any{"order_id": h.input.OrderID}, + } } - return r.evaluateStatus(ctx, input, reconciled) + return h.evaluateStatus(ctx, request.OperationID, reconciled) } - accepted := started - accepted.State = string(JourneyCheckoutRequired) - accepted.UpdatedAt = time.Now().UTC() - if err := r.Ledger.Save(ctx, accepted); err != nil { - base.State = JourneyAmbiguous - return base, nil + h.lastResult = JourneyResult{ + OperationID: request.OperationID, + State: JourneyCheckoutRequired, + OrderID: h.input.OrderID, + RedirectURL: created.RedirectURL, + NextActions: []contracts.NextAction{{ + Action: "complete_sandbox_checkout", + Description: "complete the hosted Snap sandbox checkout and rerun this journey", + }}, + } + return genericjourney.Outcome{ + State: genericjourney.AwaitingUserAction, + SafeData: map[string]any{ + "order_id": h.input.OrderID, + }, + Action: &genericjourney.Action{ + Type: "browser", + URL: created.RedirectURL, + Instructions: "complete the hosted Snap sandbox checkout and rerun this journey", + ResumeCommand: "midtrans test checkout --execute", + }, } - base.State = JourneyCheckoutRequired - base.RedirectURL = created.RedirectURL - base.NextActions = []contracts.NextAction{{ - Action: "complete_sandbox_checkout", - Description: "complete the hosted Snap sandbox checkout and rerun this journey", - }} - return base, nil } -func (r JourneyRunner) evaluateStatus( +func (h *compatibilityHandler) evaluateStatus( ctx context.Context, - input JourneyInput, + operationID string, status StatusResponse, -) (JourneyResult, error) { +) genericjourney.Outcome { result := JourneyResult{ - OperationID: input.OperationID, - OrderID: input.OrderID, + OperationID: operationID, + OrderID: h.input.OrderID, Provider: status, } - if status.OrderID != input.OrderID { - result.State = JourneyBlocked - return result, errJourneyStatus + if status.OrderID != h.input.OrderID { + h.lastErr = errJourneyStatus + h.lastResult = withJourneyState(result, JourneyBlocked) + return genericjourney.Outcome{State: genericjourney.Blocked} } switch status.TransactionStatus { case "pending": - result.State = JourneyPending - return result, nil + h.lastResult = withJourneyState(result, JourneyPending) + return genericjourney.Outcome{ + State: genericjourney.Reconciling, + SafeData: map[string]any{"order_id": h.input.OrderID}, + } case "deny", "cancel", "expire": result.State = JourneyBlocked result.NextActions = []contracts.NextAction{{ Action: "start_new_unique_order", Description: "start a new checkout with a new unique order ID", }} - return result, nil + h.lastResult = result + return genericjourney.Outcome{ + State: genericjourney.Blocked, + SafeData: map[string]any{"order_id": h.input.OrderID}, + } case "capture": if status.FraudStatus != "accept" { - result.State = JourneyBlocked - return result, nil + h.lastResult = withJourneyState(result, JourneyBlocked) + return genericjourney.Outcome{ + State: genericjourney.Blocked, + SafeData: map[string]any{"order_id": h.input.OrderID}, + } } case "settlement": default: - result.State = JourneyBlocked - return result, nil + h.lastResult = withJourneyState(result, JourneyBlocked) + return genericjourney.Outcome{ + State: genericjourney.Blocked, + SafeData: map[string]any{"order_id": h.input.OrderID}, + } } - local, err := r.Local.VerifyLocal(ctx, LocalVerificationInput{ - OrderID: input.OrderID, - GrossAmount: input.GrossAmountString, + local, err := h.runner.Local.VerifyLocal(ctx, LocalVerificationInput{ + OrderID: h.input.OrderID, + GrossAmount: h.input.GrossAmountString, }) result.Local = local if err != nil || !local.Passed() { @@ -322,41 +428,80 @@ func (r JourneyRunner) evaluateStatus( Action: "verify_merchant_callback", Description: "verify settlement, duplicate, and late-pending handling in merchant state", }} - return result, nil + h.lastResult = result + return genericjourney.Outcome{ + State: genericjourney.Blocked, + SafeData: map[string]any{"order_id": h.input.OrderID}, + } } result.State = JourneyVerified - return result, nil + h.lastResult = result + return genericjourney.Outcome{ + State: genericjourney.Passed, + SafeData: map[string]any{ + "order_id": h.input.OrderID, + }, + } } -func reusePreviousCheckoutAction() contracts.NextAction { - return contracts.NextAction{ - Action: "reuse_previous_checkout_or_new_order", - Description: "reuse the previous hosted checkout URL if retained by the caller, " + - "or start a new unique order", +func (h *compatibilityHandler) resultFor(outcome genericjourney.Outcome) JourneyResult { + if h.lastResult.OperationID == outcome.OperationID && h.lastResult.OperationID != "" { + switch outcome.State { + case genericjourney.Reconciling: + if h.lastResult.State == JourneyPending || h.lastResult.State == JourneyAmbiguous { + return h.lastResult + } + case genericjourney.Blocked: + if h.lastResult.State == JourneyBlocked { + return h.lastResult + } + default: + if h.lastResult.State == snapStateForOutcome(outcome.State) { + return h.lastResult + } + } + } + return JourneyResult{ + OperationID: outcome.OperationID, + State: snapStateForOutcome(outcome.State), + OrderID: h.input.OrderID, } } -func existingOperationResult( - result JourneyResult, - record operations.Record, -) JourneyResult { - if record.State == createStartedState { - result.State = JourneyAmbiguous - return result +func (h *compatibilityHandler) resultError(outcome genericjourney.Outcome) error { + if h.lastErr != nil { + return h.lastErr } - result.State = JourneyBlocked - if record.State == string(JourneyCheckoutRequired) { - result.NextActions = []contracts.NextAction{ - reusePreviousCheckoutAction(), - } + if outcome.Finding != nil && outcome.Finding.Code == "JOURNEY_PERSIST_FAILED" { + return errJourneyLedger } + return nil +} + +func snapStateForOutcome(state genericjourney.State) JourneyState { + switch state { + case genericjourney.Planned: + return JourneyPlanned + case genericjourney.AwaitingUserAction: + return JourneyCheckoutRequired + case genericjourney.Reconciling: + return JourneyAmbiguous + case genericjourney.Passed: + return JourneyVerified + default: + return JourneyBlocked + } +} + +func withJourneyState(result JourneyResult, state JourneyState) JourneyResult { + result.State = state return result } -func validManifestHash(value string) bool { - if len(value) != 64 || value != strings.ToLower(value) { - return false +func reusePreviousCheckoutAction() contracts.NextAction { + return contracts.NextAction{ + Action: "reuse_previous_checkout_or_new_order", + Description: "reuse the previous hosted checkout URL if retained by the caller, " + + "or start a new unique order", } - _, err := hex.DecodeString(value) - return err == nil } diff --git a/packs/snap/journey_test.go b/packs/snap/journey_test.go index dc825ec..472edca 100644 --- a/packs/snap/journey_test.go +++ b/packs/snap/journey_test.go @@ -77,15 +77,17 @@ func TestJourneyEvidenceMapsOnlyVerifiedSafeClaims(t *testing.T) { type fakeTokenCreator struct { calls int + requests []snap.CreateTokenRequest response snap.CreateTokenResponse err error } func (f *fakeTokenCreator) CreateToken( _ context.Context, - _ snap.CreateTokenRequest, + input snap.CreateTokenRequest, ) (snap.CreateTokenResponse, error) { f.calls++ + f.requests = append(f.requests, input) return f.response, f.err } @@ -258,10 +260,13 @@ func TestJourneyCreateReturnsCheckoutRequired(t *testing.T) { t.Fatalf("status calls = %d, create calls = %d", status.calls, tokens.calls) } if len(ledger.saves) != 2 || - ledger.saves[0].State != "create_started" || - ledger.saves[1].State != "checkout_required" { + ledger.saves[0].State != "planned" || + ledger.saves[1].State != "awaiting_user_action" { t.Fatalf("ledger saves = %#v", ledger.saves) } + if got := tokens.requests[0].OperationID; got != operations.CanonicalOperationID(journeyInput(true).OperationID) { + t.Fatalf("token operation ID = %q", got) + } } func TestJourneyStatus404CreatesOnlyOnce(t *testing.T) { @@ -299,7 +304,7 @@ func TestJourneyConcurrentRunsCreateExactlyOnce(t *testing.T) { firstStarted: make(chan struct{}), releaseFirst: make(chan struct{}), } - status := &concurrentStatusGetter{release: make(chan struct{})} + status := &fakeStatusGetter{responses: []snap.StatusResponse{notFoundStatus()}} runner := newJourney( tokens, status, @@ -321,20 +326,12 @@ func TestJourneyConcurrentRunsCreateExactlyOnce(t *testing.T) { }() } - select { - case <-tokens.firstStarted: - case <-time.After(2 * time.Second): - t.Fatal("first create did not start") - } - var first outcome select { case first = <-outcomes: case <-time.After(2 * time.Second): - close(tokens.releaseFirst) - t.Fatal("losing concurrent run did not return") + t.Fatal("first concurrent run did not return") } - callsWhileFirstBlocked := tokens.calls.Load() close(tokens.releaseFirst) var second outcome @@ -348,10 +345,9 @@ func TestJourneyConcurrentRunsCreateExactlyOnce(t *testing.T) { t.Fatalf("outcome %d error = %v", index, got.err) } } - if callsWhileFirstBlocked != 1 || tokens.calls.Load() != 1 { + if tokens.calls.Load() != 1 { t.Fatalf( - "create calls while blocked = %d, final = %d; want exactly one", - callsWhileFirstBlocked, + "final create calls = %d; want exactly one", tokens.calls.Load(), ) } @@ -360,7 +356,7 @@ func TestJourneyConcurrentRunsCreateExactlyOnce(t *testing.T) { second.result.State: 1, } if states[snap.JourneyCheckoutRequired] != 1 || - states[snap.JourneyAmbiguous]+states[snap.JourneyBlocked] != 1 { + states[snap.JourneyBlocked] != 1 { t.Fatalf( "concurrent states = %q and %q", first.result.State, @@ -382,21 +378,23 @@ func TestJourneyIssuedTokenAndMissingStatusNeverCreatesAgain(t *testing.T) { runner := newJourney(tokens, status, &fakeLocalVerifier{}, ledger) first, err := runner.Run(context.Background(), journeyInput(true)) - if err != nil { - t.Fatal(err) + if err == nil || err.Error() != "OPERATION_LEDGER_FAILED" { + t.Fatalf("first err = %v", err) } - if first.State != snap.JourneyAmbiguous || first.RedirectURL != "" { + if first.State != snap.JourneyBlocked || first.RedirectURL != "" { t.Fatalf("first = %#v", first) } second, err := runner.Run(context.Background(), journeyInput(true)) - if err != nil || second.State != snap.JourneyAmbiguous || - second.RedirectURL != "" { + if err != nil || second.State != snap.JourneyBlocked || + second.RedirectURL != "" || + len(second.NextActions) != 1 || + second.NextActions[0].Action != "reuse_previous_checkout_or_new_order" { t.Fatalf("second = %#v, err = %v", second, err) } if tokens.calls != 1 { t.Fatalf("create calls = %d, want one", tokens.calls) } - if ledger.record.State != "create_started" { + if ledger.record.State != "blocked" { t.Fatalf("durable marker = %#v", ledger.record) } } @@ -413,8 +411,8 @@ func TestJourneyDoesNotCreateWhenCreateStartedSaveFails(t *testing.T) { &fakeLocalVerifier{}, ledger, ).Run(context.Background(), journeyInput(true)) - if err == nil { - t.Fatal("Run() succeeded despite ledger failure") + if err == nil || err.Error() != "OPERATION_LEDGER_FAILED" { + t.Fatalf("Run() err = %v, want ledger failure", err) } if result.State != snap.JourneyBlocked || tokens.calls != 0 { t.Fatalf("result = %#v, create calls = %d", result, tokens.calls) @@ -687,6 +685,27 @@ func journeyInput(execute bool) snap.JourneyInput { } } +func TestJourneyDoesNotPersistPackSpecificLifecycleStates(t *testing.T) { + ledger := &fakeOperationLedger{} + result, err := newJourney( + &fakeTokenCreator{response: snap.CreateTokenResponse{ + Token: "token", + RedirectURL: "https://app.sandbox.midtrans.com/checkout", + }}, + &fakeStatusGetter{responses: []snap.StatusResponse{notFoundStatus()}}, + &fakeLocalVerifier{}, + ledger, + ).Run(context.Background(), journeyInput(true)) + if err != nil || result.State != snap.JourneyCheckoutRequired { + t.Fatalf("result = %#v, err = %v", result, err) + } + for _, record := range ledger.saves { + if record.State == "create_started" || record.State == "checkout_required" { + t.Fatalf("pack-specific persisted state leaked: %#v", ledger.saves) + } + } +} + func notFoundStatus() snap.StatusResponse { return snap.StatusResponse{ OrderID: "sandbox-example-001", diff --git a/schemas/evidence-v1.schema.json b/schemas/evidence-v1.schema.json index 328443a..cc05038 100644 --- a/schemas/evidence-v1.schema.json +++ b/schemas/evidence-v1.schema.json @@ -47,10 +47,14 @@ "items": { "type": "object", "additionalProperties": false, - "required": ["id", "level", "status", "summary"], + "required": ["id", "operation_id", "stage", "level", "source", "observed_at", "status", "summary"], "properties": { "id": {"type": "string", "minLength": 1}, + "operation_id": {"type": "string", "pattern": "^op_[a-z0-9_]+$"}, + "stage": {"type": "string", "minLength": 1}, "level": {"enum": ["local", "sandbox"]}, + "source": {"type": "string", "minLength": 1}, + "observed_at": {"type": "string", "format": "date-time"}, "status": {"enum": ["pass", "fail", "blocked"]}, "summary": {"type": "object"} } diff --git a/schemas/operation-v1.schema.json b/schemas/operation-v1.schema.json index 87b0e42..f00ce61 100644 --- a/schemas/operation-v1.schema.json +++ b/schemas/operation-v1.schema.json @@ -17,7 +17,7 @@ ], "properties": { "schema_version": {"const": 1}, - "operation_id": {"type": "string", "minLength": 1}, + "operation_id": {"type": "string", "pattern": "^op_[a-z0-9_]+$"}, "journey_id": {"type": "string", "minLength": 1}, "pack_id": {"type": "string", "minLength": 1}, "manifest_hash": {"type": "string", "pattern": "^[0-9a-f]{64}$"}, From a76b6f5f917ee086360d83b9e1525d79c3558124 Mon Sep 17 00:00:00 2001 From: "m.salis" Date: Mon, 27 Jul 2026 09:10:01 +0700 Subject: [PATCH 30/73] fix: prevent journey run overwrite on blocked plans --- .../task-3-report.md | 27 +++++++++++++++++ internal/journey/engine.go | 6 ++-- internal/journey/engine_test.go | 29 +++++++++++++++++++ 3 files changed, 59 insertions(+), 3 deletions(-) diff --git a/.superpowers/sdd/2026-07-26-midtrans-cli-multi-product-parity/task-3-report.md b/.superpowers/sdd/2026-07-26-midtrans-cli-multi-product-parity/task-3-report.md index a1ecd95..d049e21 100644 --- a/.superpowers/sdd/2026-07-26-midtrans-cli-multi-product-parity/task-3-report.md +++ b/.superpowers/sdd/2026-07-26-midtrans-cli-multi-product-parity/task-3-report.md @@ -75,3 +75,30 @@ go test ./... -count=1 - Kept `internal/operations` independent of `internal/journey`; the Snap adapter consumes the engine, not the other way around. - Preserved the existing hidden Snap/test surfaces while moving lifecycle persistence ownership into the engine. - Retained technical error surfacing for ledger persistence failures while keeping conflict outcomes non-destructive and non-overwriting. + +## Fix Round 2 + +### Reviewer finding addressed + +- Closed the remaining overwrite path in `internal/journey.Engine.Run`: non-`Planned` plan outcomes now still reserve and bind operation identity before any persistence, and existing operation IDs are rejected without saving over prior records. + +### Added or adjusted tests + +- Added `TestEngineRunRejectsExistingBindingForBlockedPlanWithoutOverwrite` in `internal/journey/engine_test.go` to seed an existing record with the same operation ID plus mismatched binding data, return a blocked plan outcome, and assert the original record remains unchanged while `Execute` is never called. + +### Commands run + +```sh +go test ./internal/journey ./internal/operations -count=1 +go test ./... -count=1 +``` + +### Results + +- `go test ./internal/journey ./internal/operations -count=1` passed on Monday, July 27, 2026. +- `go test ./... -count=1` passed on Monday, July 27, 2026. + +### Self-review notes + +- The new-run path now binds operation identity once for every plan outcome and returns a conflict before any save when the operation ID already exists. +- Resume behavior was left unchanged; this fix only removed the last overwrite path from fresh `Run` calls. diff --git a/internal/journey/engine.go b/internal/journey/engine.go index 1a77958..46a2514 100644 --- a/internal/journey/engine.go +++ b/internal/journey/engine.go @@ -31,9 +31,6 @@ func (e Engine) Run( definition := handler.Definition() planned := normalizeOutcome(request.OperationID, handler.Plan(ctx, request, runtime)) - if planned.State != Planned { - return e.persistWithSave(ctx, runtime, definition, request, operations.Record{}, planned) - } initial, ok, err := e.reserveInitial(ctx, runtime, definition, request, planned) if err != nil { return blockedOutcome( @@ -49,6 +46,9 @@ func (e Engine) Run( "the journey operation already exists and cannot be executed again", ) } + if planned.State != Planned { + return planned + } if !execute { return planned } diff --git a/internal/journey/engine_test.go b/internal/journey/engine_test.go index b65bef0..4af1dd5 100644 --- a/internal/journey/engine_test.go +++ b/internal/journey/engine_test.go @@ -202,6 +202,35 @@ func TestEngineRunBlocksOnExistingOperationWithoutExecutingOrOverwriting(t *test } } +func TestEngineRunRejectsExistingBindingForBlockedPlanWithoutOverwrite(t *testing.T) { + engine := testEngine(t) + existing := testRecord("op_test", string(journey.AwaitingUserAction)) + existing.JourneyID = "snap.other" + existing.ManifestHash = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + if err := engine.Store.Save(context.Background(), existing); err != nil { + t.Fatal(err) + } + handler := &fakeHandler{ + definition: testDefinition(), + plan: journey.Outcome{State: journey.Blocked}, + execute: journey.Outcome{State: journey.Passed}, + } + + outcome := engine.Run(context.Background(), handler, testRequest("op_test"), true) + + if outcome.State != journey.Blocked { + t.Fatalf("state = %q, want %q", outcome.State, journey.Blocked) + } + requireFindingCode(t, outcome.Finding, "JOURNEY_OPERATION_CONFLICT") + if handler.executeCalls != 0 { + t.Fatalf("execute calls = %d, want 0", handler.executeCalls) + } + got, found, err := engine.Store.Load(context.Background(), "op_test") + if err != nil || !found || got.JourneyID != existing.JourneyID || got.ManifestHash != existing.ManifestHash || got.State != existing.State { + t.Fatalf("record = %#v, found = %v, err = %v", got, found, err) + } +} + func TestEngineFiltersCoreSensitiveReferencesEvenWithoutPackKeys(t *testing.T) { engine := testEngine(t) engine.Runtime.SensitiveKeys = nil From 9f35b808f17fdfe300228e72ccac63b135112c35 Mon Sep 17 00:00:00 2001 From: "m.salis" Date: Mon, 27 Jul 2026 09:22:45 +0700 Subject: [PATCH 31/73] feat: expose generic merchant payment journeys --- .../task-4-report.md | 28 +++ internal/app/app_test.go | 107 ++++++++- internal/app/checkout_runner.go | 43 +++- internal/app/commands_agent.go | 107 ++++++++- internal/app/commands_checkout.go | 29 ++- internal/app/journey_runner.go | 220 ++++++++++++++++++ internal/packs/pack.go | 2 + internal/packs/registry.go | 59 ++++- internal/packs/registry_test.go | 77 ++++++ internal/presentation/model.go | 49 ++++ internal/presentation/model_test.go | 39 ++++ packs/common/pack.go | 5 + packs/snap/journey.go | 188 +++++++++++---- packs/snap/pack.go | 5 + 14 files changed, 902 insertions(+), 56 deletions(-) create mode 100644 .superpowers/sdd/2026-07-26-midtrans-cli-multi-product-parity/task-4-report.md create mode 100644 internal/app/journey_runner.go diff --git a/.superpowers/sdd/2026-07-26-midtrans-cli-multi-product-parity/task-4-report.md b/.superpowers/sdd/2026-07-26-midtrans-cli-multi-product-parity/task-4-report.md new file mode 100644 index 0000000..673bda6 --- /dev/null +++ b/.superpowers/sdd/2026-07-26-midtrans-cli-multi-product-parity/task-4-report.md @@ -0,0 +1,28 @@ +# Task 4 Report + +Status: done + +Commit: +- feat: expose generic merchant payment journeys + +Changes: +- Extended `packs.Pack` and `packs.Registry` with handler registration, exact journey lookup, and intent lookup. +- Exposed the Snap checkout compatibility handler through the pack registry and added no-op `Handlers()` implementations for packs and test doubles that do not execute journeys yet. +- Added generic `midtrans agent plan`, `midtrans agent run`, and `midtrans agent resume` commands with the shared journey result contract. +- Kept the existing merchant `midtrans test checkout` flow, but added generic journey fields to its result payload and a `--product` gate for unsupported products. +- Added a `midtrans test` parent result that lists configured routing-derived journeys and next action guidance. +- Added generic human presentation for agent journey results. + +Tests: +- `go test ./internal/packs ./internal/app -run 'TestRegistryJourney|TestGenericJourneyCommands' -count=1` +- `go test ./internal/packs ./internal/app ./internal/presentation -count=1` +- `go test ./... -count=1` + +Self-review: +- Verified the new registry rejects duplicate journey IDs and resolves the Snap checkout handler by exact ID and intent. +- Verified `agent plan/run/resume` produce the shared `product`, `journey`, `operation_id`, `state`, `action`, `proofs`, and `missing_evidence` result shape. +- Verified legacy and existing Snap checkout paths remain green in the full test suite. + +Concerns: +- Only the currently implemented Snap checkout handler is executable. Future products and future common journeys still surface as unavailable until Task 5+ adds real handlers. +- Merchant intent routing is only partially generalized at the top-level `test` surface in this task; the exact generic merchant journey command expansion remains constrained to the current Snap checkout flow. diff --git a/internal/app/app_test.go b/internal/app/app_test.go index 1a5da17..06e42b6 100644 --- a/internal/app/app_test.go +++ b/internal/app/app_test.go @@ -18,6 +18,7 @@ import ( "github.com/veritrans/midtrans-cli/internal/app" "github.com/veritrans/midtrans-cli/internal/contracts" "github.com/veritrans/midtrans-cli/internal/inspection" + "github.com/veritrans/midtrans-cli/internal/journey" "github.com/veritrans/midtrans-cli/internal/manifest" "github.com/veritrans/midtrans-cli/internal/packs" "github.com/veritrans/midtrans-cli/internal/update" @@ -83,6 +84,108 @@ func TestAgentCapabilitiesPreservesCapabilityContract(t *testing.T) { } } +func TestGenericJourneyCommandsUseStableResultContract(t *testing.T) { + project := createJourneyProject(t, "http://127.0.0.1:1") + transport := newJourneyFixtureTransport(t, http.StatusNotFound, nil) + deps := app.Dependencies{ + Version: version.Info{Version: "0.1.0-test"}, + Packs: testRegistry(t), + Getenv: func(key string) (string, bool) { + return journeyServerKeyCanary, key == "MIDTRANS_SERVER_KEY" + }, + HTTP: &http.Client{Transport: transport}, + } + + tests := []struct { + name string + args []string + wantCommand string + wantState string + wantExit int + }{ + { + name: "merchant plan", + args: []string{"test", "checkout", "--amount", "10000", "--project-dir", project}, + wantCommand: "test.checkout", + wantState: "planned", + wantExit: 3, + }, + { + name: "agent plan", + args: []string{"agent", "plan", "--journey", "snap.checkout", "--amount", "10000", "--project-dir", project}, + wantCommand: "agent.plan", + wantState: "planned", + wantExit: 3, + }, + { + name: "agent run", + args: []string{"agent", "run", "--journey", "snap.checkout", "--amount", "10000", "--order-id", "snap-fixture-001", "--execute", "--project-dir", project}, + wantCommand: "agent.run", + wantState: "checkout_required", + wantExit: 3, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + result, exit := executeJSONWithDependencies(t, deps, test.args...) + if exit != test.wantExit || result.Command != test.wantCommand { + t.Fatalf("exit = %d, result = %#v", exit, result) + } + data := requireJourneyData(t, result) + for _, key := range []string{"journey", "product", "operation_id", "state", "proofs", "missing_evidence"} { + if _, ok := data[key]; !ok { + t.Fatalf("data missing %q: %#v", key, data) + } + } + if data["journey"] != "snap.checkout" || data["product"] != "snap" || data["state"] != test.wantState { + t.Fatalf("data = %#v", data) + } + }) + } +} + +func TestAgentResumeUsesRecordedHandler(t *testing.T) { + project := createJourneyProject(t, "http://127.0.0.1:1") + transport := newJourneyFixtureTransport(t, http.StatusNotFound, nil) + deps := app.Dependencies{ + Version: version.Info{Version: "0.1.0-test"}, + Packs: testRegistry(t), + Getenv: func(key string) (string, bool) { + return journeyServerKeyCanary, key == "MIDTRANS_SERVER_KEY" + }, + HTTP: &http.Client{Transport: transport}, + } + first, exit := executeJSONWithDependencies( + t, + deps, + "agent", "run", + "--journey", "snap.checkout", + "--amount", "10000", + "--order-id", "snap-fixture-001", + "--operation", "op_test", + "--execute", + "--project-dir", project, + ) + if exit != 3 { + t.Fatalf("run exit = %d, result = %#v", exit, first) + } + resumed, exit := executeJSONWithDependencies( + t, + deps, + "agent", "resume", + "--operation", "op_test", + "--project-dir", project, + ) + if exit != 3 || resumed.Command != "agent.resume" { + t.Fatalf("exit = %d, result = %#v", exit, resumed) + } + data := requireJourneyData(t, resumed) + if data["journey"] != "snap.checkout" || data["operation_id"] != "op_test" { + t.Fatalf("data = %#v", data) + } +} + func TestLegacyCapabilitiesJSONRemainsCompatibleAndHidden(t *testing.T) { legacy, legacyExit := executeJSON( t, "capabilities", "--json", "--non-interactive", @@ -728,7 +831,7 @@ func TestUpdateCheckFailureDoesNotExposeUpstreamData(t *testing.T) { func TestHelpExposesExactlyThePhaseOneCommandSurface(t *testing.T) { expected := map[string][]string{ "": {"agent", "init", "setup", "status", "test", "update", "verify", "version"}, - "agent": {"capabilities", "check", "inspect", "pack"}, + "agent": {"capabilities", "check", "inspect", "pack", "plan", "resume", "run"}, "test": {"checkout", "webhook"}, "update": {"check"}, } @@ -2707,6 +2810,8 @@ func (inspectionAwarePack) Evaluate( }} } +func (inspectionAwarePack) Handlers() []journey.Handler { return nil } + func merchantFixture(name string) string { return filepath.Join("..", "..", "testdata", "merchant-repos", name) } diff --git a/internal/app/checkout_runner.go b/internal/app/checkout_runner.go index 2735853..a0208e0 100644 --- a/internal/app/checkout_runner.go +++ b/internal/app/checkout_runner.go @@ -31,6 +31,7 @@ var errRepositoryDirty = errors.New("repository worktree is dirty") type checkoutRequest struct { Command string ProjectDir string + OperationID string OrderID string GrossAmount int64 Execute bool @@ -97,7 +98,7 @@ func runCheckout( }, Ledger: operations.Store{ProjectDir: request.ProjectDir}, }).Run(ctx, snap.JourneyInput{ - OperationID: plan.Hash, + OperationID: checkoutOperationID(request, plan.Hash), ManifestHash: manifestHash, OrderID: request.OrderID, GrossAmount: request.GrossAmount, @@ -128,10 +129,14 @@ func invalidCheckoutResult( func checkoutPlanData(request checkoutRequest, plan policy.Plan) map[string]any { data := map[string]any{ - "journey": "snap.checkout", - "state": snap.JourneyPlanned, - "order_id": request.OrderID, - "plan": plan, + "product": "snap", + "journey": "snap.checkout", + "operation_id": checkoutOperationID(request, plan.Hash), + "state": snap.JourneyPlanned, + "order_id": request.OrderID, + "plan": plan, + "proofs": []any{}, + "missing_evidence": []string{"provider_status", "merchant_callback"}, } if request.Command == "test.checkout" { data["proof_scope"] = checkoutProofScope(request) @@ -223,13 +228,23 @@ func checkoutJourneyData( journey snap.JourneyResult, ) map[string]any { data := map[string]any{ - "journey": "snap.checkout", - "state": journey.State, - "order_id": journey.OrderID, - "plan": plan, + "product": "snap", + "journey": "snap.checkout", + "operation_id": journey.OperationID, + "state": journey.State, + "order_id": journey.OrderID, + "plan": plan, + "proofs": []any{}, + "missing_evidence": []string{"merchant_callback"}, } if journey.State == snap.JourneyCheckoutRequired && journey.RedirectURL != "" { data["redirect_url"] = journey.RedirectURL + data["action"] = map[string]any{ + "type": "browser", + "url": journey.RedirectURL, + "instructions": "complete the hosted Snap sandbox checkout and rerun this journey", + "resume_command": "midtrans agent resume --operation " + journey.OperationID, + } } if journey.Provider.OrderID != "" { data["provider"] = map[string]any{ @@ -248,9 +263,19 @@ func checkoutJourneyData( if request.Command == "test.checkout" { data["proof_scope"] = checkoutProofScope(request) } + if journey.State == snap.JourneyVerified { + data["missing_evidence"] = []string{} + } return data } +func checkoutOperationID(request checkoutRequest, fallback string) string { + if request.OperationID != "" { + return request.OperationID + } + return fallback +} + func checkoutProofScope(request checkoutRequest) string { if request.ProviderOnly { return "provider_only" diff --git a/internal/app/commands_agent.go b/internal/app/commands_agent.go index d63a913..3378d4e 100644 --- a/internal/app/commands_agent.go +++ b/internal/app/commands_agent.go @@ -1,6 +1,10 @@ package app -import "github.com/spf13/cobra" +import ( + "github.com/spf13/cobra" + journeypkg "github.com/veritrans/midtrans-cli/internal/journey" + "github.com/veritrans/midtrans-cli/internal/project" +) func newAgentCommand(flags *globalFlags, deps Dependencies) *cobra.Command { parent := &cobra.Command{ @@ -9,9 +13,110 @@ func newAgentCommand(flags *globalFlags, deps Dependencies) *cobra.Command { } parent.AddCommand( newCapabilitiesCommand(flags, deps), + newAgentPlanCommand(flags, deps), + newAgentRunCommand(flags, deps), + newAgentResumeCommand(flags, deps), newInspectCommand(flags, deps, "inspect", "inspect"), newCheckCommand(flags, deps, "check", "doctor"), newPackCommand(flags, deps), ) return parent } + +func newAgentPlanCommand(flags *globalFlags, deps Dependencies) *cobra.Command { + request := bindGenericJourneyFlags("agent.plan") + command := &cobra.Command{ + Use: "plan", + Short: "plan an exact payment journey without mutating", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + return writeResult(deps, flags, runGenericJourney(cmd.Context(), request.toRunRequest(flags, false), deps)) + }, + } + request.bind(command, false, true) + return withProjectMode(command, project.Existing, "agent.plan") +} + +func newAgentRunCommand(flags *globalFlags, deps Dependencies) *cobra.Command { + request := bindGenericJourneyFlags("agent.run") + command := &cobra.Command{ + Use: "run", + Short: "run an exact payment journey", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + return writeResult(deps, flags, runGenericJourney(cmd.Context(), request.toRunRequest(flags, request.execute), deps)) + }, + } + request.bind(command, true, true) + return withProjectMode(command, project.Existing, "agent.run") +} + +func newAgentResumeCommand(flags *globalFlags, deps Dependencies) *cobra.Command { + var operationID string + command := &cobra.Command{ + Use: "resume", + Short: "resume an existing payment journey operation", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + return writeResult(deps, flags, resumeGenericJourney(cmd.Context(), flags.projectDir, operationID, deps)) + }, + } + command.Flags().StringVar(&operationID, "operation", "", "existing journey operation ID") + _ = command.MarkFlagRequired("operation") + return withProjectMode(command, project.Existing, "agent.resume") +} + +type genericJourneyFlags struct { + command string + journeyID string + product string + orderID string + operationID string + method string + customerReference string + paymentTokenReference string + amount int64 + reusable bool + execute bool +} + +func bindGenericJourneyFlags(command string) *genericJourneyFlags { + return &genericJourneyFlags{command: command} +} + +func (f *genericJourneyFlags) bind(command *cobra.Command, includeExecute bool, exactJourney bool) { + command.Flags().StringVar(&f.journeyID, "journey", "", "exact journey ID") + command.Flags().StringVar(&f.product, "product", "", "exact product ID") + command.Flags().StringVar(&f.operationID, "operation", "", "exact journey operation ID") + command.Flags().StringVar(&f.orderID, "order-id", "", "safe merchant order reference") + command.Flags().Int64Var(&f.amount, "amount", 0, "amount in IDR") + command.Flags().StringVar(&f.method, "method", "", "payment method") + command.Flags().StringVar(&f.customerReference, "customer-reference", "", "safe customer reference") + command.Flags().StringVar(&f.paymentTokenReference, "payment-token-reference", "", "safe payment token reference") + command.Flags().BoolVar(&f.reusable, "reusable", false, "request a reusable payment resource") + if includeExecute { + command.Flags().BoolVar(&f.execute, "execute", false, "execute the planned mutation") + } + if exactJourney { + _ = command.MarkFlagRequired("journey") + } +} + +func (f *genericJourneyFlags) toRunRequest(flags *globalFlags, execute bool) journeyRunRequest { + return journeyRunRequest{ + Command: f.command, + ProjectDir: flags.projectDir, + JourneyID: f.journeyID, + Product: f.product, + OperationID: f.operationID, + Input: journeypkg.Input{ + OrderID: f.orderID, + Amount: f.amount, + Method: f.method, + CustomerReference: f.customerReference, + PaymentTokenReference: f.paymentTokenReference, + Reusable: f.reusable, + }, + Execute: execute, + } +} diff --git a/internal/app/commands_checkout.go b/internal/app/commands_checkout.go index 8804227..d687c69 100644 --- a/internal/app/commands_checkout.go +++ b/internal/app/commands_checkout.go @@ -18,7 +18,23 @@ func newTestCommand(flags *globalFlags, deps Dependencies) *cobra.Command { parent := &cobra.Command{ Use: "test", RunE: func(*cobra.Command, []string) error { - return errors.New("test subcommand is required") + value, invalid := loadValidatedManifest("test", flags.projectDir, deps) + if invalid != nil { + return writeResult(deps, flags, *invalid) + } + enabled := make([]string, 0, len(value.Routing)) + for intent, product := range value.Routing { + enabled = append(enabled, product+"."+intent) + } + result := contracts.NewResult("test", contracts.StatusPass) + result.CLIVersion = deps.Version.Version + result.ManifestVersion = value.SchemaVersion + result.Data = map[string]any{"enabled_journeys": enabled} + result.NextActions = []contracts.NextAction{{ + Action: "run_checkout_test", + Description: "midtrans test checkout --amount 10000", + }} + return writeResult(deps, flags, result) }, } parent.AddCommand(newTestCheckoutCommand(flags, deps)) @@ -32,6 +48,7 @@ func newTestCheckoutCommand( ) *cobra.Command { var amount int64 var orderID string + var product string var execute bool command := &cobra.Command{ Use: "checkout", @@ -64,11 +81,21 @@ func newTestCheckoutCommand( } request.Execute = true } + if product != "" && product != "snap" { + result := contracts.NewResult("test.checkout", contracts.StatusBlocked) + result.CLIVersion = deps.Version.Version + result.Findings = []contracts.Finding{{ + Code: "CAPABILITY_UNAVAILABLE", Severity: "blocking", + Message: "requested journey product is unavailable", + }} + return writeResult(deps, flags, result) + } return writeResult(deps, flags, runCheckout(cmd.Context(), request, deps)) }, } command.Flags().Int64Var(&amount, "amount", 0, "Sandbox amount in IDR") command.Flags().StringVar(&orderID, "order-id", "", "existing merchant order reference") + command.Flags().StringVar(&product, "product", "", "product override") command.Flags().BoolVar(&execute, "execute", false, "execute the reviewed Sandbox plan") _ = command.MarkFlagRequired("amount") return withProjectMode(command, project.Existing, "test.checkout") diff --git a/internal/app/journey_runner.go b/internal/app/journey_runner.go new file mode 100644 index 0000000..398cb3b --- /dev/null +++ b/internal/app/journey_runner.go @@ -0,0 +1,220 @@ +package app + +import ( + "context" + "fmt" + "strings" + "time" + + "github.com/veritrans/midtrans-cli/internal/contracts" + journeypkg "github.com/veritrans/midtrans-cli/internal/journey" + "github.com/veritrans/midtrans-cli/internal/manifest" + "github.com/veritrans/midtrans-cli/internal/operations" +) + +type journeyRunRequest struct { + Command string + ProjectDir string + JourneyID string + Intent string + Product string + OperationID string + Input journeypkg.Input + Execute bool +} + +func runGenericJourney( + ctx context.Context, + request journeyRunRequest, + deps Dependencies, +) contracts.Result { + value, invalid := loadValidatedManifest(request.Command, request.ProjectDir, deps) + if invalid != nil { + return *invalid + } + handler, finding := resolveJourneyHandler(request, deps, value) + if finding != nil { + result := contracts.NewResult(request.Command, contracts.StatusBlocked) + result.CLIVersion = deps.Version.Version + result.ManifestVersion = value.SchemaVersion + result.Findings = []contracts.Finding{*finding} + return result + } + manifestHash, err := projectManifestHash(request.ProjectDir) + if err != nil { + result := contracts.NewResult(request.Command, contracts.StatusError) + result.CLIVersion = deps.Version.Version + result.ManifestVersion = value.SchemaVersion + result.Findings = []contracts.Finding{{ + Code: "JOURNEY_INVALID", Severity: "blocking", + Message: "the journey request is invalid", + }} + return result + } + engine := journeypkg.Engine{ + Store: operations.Store{ProjectDir: request.ProjectDir}, + Runtime: journeypkg.Runtime{ + HTTP: deps.HTTP, + ResolveCredential: deps.ResolveCredential, + Now: func() time.Time { return time.Now().UTC() }, + NewOperationID: func() string { + if request.OperationID != "" { + return request.OperationID + } + seed := handler.Definition().ID + ":" + request.Input.OrderID + if strings.TrimSpace(request.Input.OrderID) == "" { + seed = handler.Definition().ID + ":" + deps.NewOrderID() + } + return operations.CanonicalOperationID(seed) + }, + SensitiveKeys: deps.Packs.SensitiveKeys(), + }, + } + journeyRequest := journeypkg.Request{ + OperationID: request.OperationID, + ProjectDir: request.ProjectDir, + ManifestHash: manifestHash, + Manifest: value, + Input: request.Input, + } + var outcome journeypkg.Outcome + if request.Command == "agent.resume" { + outcome = engine.Resume(ctx, handler, request.OperationID, journeyRequest) + } else { + outcome = engine.Run(ctx, handler, journeyRequest, request.Execute) + } + return genericJourneyResult(request.Command, deps, value.SchemaVersion, handler.Definition(), outcome) +} + +func resumeGenericJourney( + ctx context.Context, + projectDir string, + operationID string, + deps Dependencies, +) contracts.Result { + value, invalid := loadValidatedManifest("agent.resume", projectDir, deps) + if invalid != nil { + return *invalid + } + record, found, err := (operations.Store{ProjectDir: projectDir}).Load(ctx, operationID) + if err != nil || !found { + result := contracts.NewResult("agent.resume", contracts.StatusBlocked) + result.CLIVersion = deps.Version.Version + result.ManifestVersion = value.SchemaVersion + result.Findings = []contracts.Finding{{ + Code: "JOURNEY_OPERATION_NOT_FOUND", Severity: "blocking", + Message: "the requested journey operation is unavailable", + }} + return result + } + return runGenericJourney(ctx, journeyRunRequest{ + Command: "agent.resume", + ProjectDir: projectDir, + JourneyID: record.JourneyID, + OperationID: operationID, + }, deps) +} + +func resolveJourneyHandler( + request journeyRunRequest, + deps Dependencies, + value manifest.Manifest, +) (journeypkg.Handler, *contracts.Finding) { + if request.JourneyID != "" { + handler, ok := deps.Packs.Handler(request.JourneyID) + if !ok { + return nil, &contracts.Finding{ + Code: "CAPABILITY_UNAVAILABLE", Severity: "blocking", + Message: "requested journey is unavailable", + } + } + if _, ok := value.IntegrationFor(handler.Definition().Product); !ok { + return nil, &contracts.Finding{ + Code: "CAPABILITY_UNAVAILABLE", Severity: "blocking", + Message: "requested journey product is not configured for this project", + } + } + return handler, nil + } + candidates, _ := deps.Packs.ForIntent(request.Intent, "") + if configured := value.Routing[request.Intent]; configured != "" { + for _, candidate := range candidates { + if candidate.Definition().Product == configured { + return candidate, nil + } + } + } + if request.Product != "" { + filtered, _ := deps.Packs.ForIntent(request.Intent, request.Product) + candidates = filtered + } + configured := make([]journeypkg.Handler, 0, len(candidates)) + for _, candidate := range candidates { + if _, ok := value.IntegrationFor(candidate.Definition().Product); ok { + configured = append(configured, candidate) + } + } + switch len(configured) { + case 0: + return nil, &contracts.Finding{ + Code: "CAPABILITY_UNAVAILABLE", Severity: "blocking", + Message: fmt.Sprintf("no configured product supports the %s journey intent", request.Intent), + } + case 1: + return configured[0], nil + default: + return nil, &contracts.Finding{ + Code: "JOURNEY_AMBIGUOUS", Severity: "blocking", + Message: fmt.Sprintf("multiple configured products support the %s journey intent", request.Intent), + } + } +} + +func genericJourneyResult( + command string, + deps Dependencies, + manifestVersion int, + definition journeypkg.Definition, + outcome journeypkg.Outcome, +) contracts.Result { + status := contracts.StatusBlocked + switch outcome.State { + case journeypkg.Passed: + status = contracts.StatusPass + case journeypkg.Failed: + status = contracts.StatusFail + case journeypkg.Blocked: + status = contracts.StatusBlocked + } + result := contracts.NewResult(command, status) + result.CLIVersion = deps.Version.Version + result.ManifestVersion = manifestVersion + if outcome.Finding != nil { + result.Findings = []contracts.Finding{*outcome.Finding} + } + result.Data = map[string]any{ + "product": definition.Product, + "journey": definition.ID, + "operation_id": outcome.OperationID, + "state": genericJourneyState(outcome.State), + "proofs": outcome.Proofs, + "missing_evidence": outcome.MissingEvidence, + } + if outcome.Action != nil { + result.Data.(map[string]any)["action"] = outcome.Action + } + return result +} + +func genericJourneyState(state journeypkg.State) string { + switch state { + case journeypkg.AwaitingUserAction: + return "checkout_required" + case journeypkg.Reconciling: + return "ambiguous" + case journeypkg.Passed: + return "verified" + default: + return string(state) + } +} diff --git a/internal/packs/pack.go b/internal/packs/pack.go index a0bbc43..0b6c3ba 100644 --- a/internal/packs/pack.go +++ b/internal/packs/pack.go @@ -3,6 +3,7 @@ package packs import ( "github.com/veritrans/midtrans-cli/internal/contracts" "github.com/veritrans/midtrans-cli/internal/inspection" + "github.com/veritrans/midtrans-cli/internal/journey" "github.com/veritrans/midtrans-cli/internal/manifest" ) @@ -19,4 +20,5 @@ type Descriptor struct { type Pack interface { Descriptor() Descriptor Evaluate(manifest.Manifest, inspection.Report) []contracts.Finding + Handlers() []journey.Handler } diff --git a/internal/packs/registry.go b/internal/packs/registry.go index f0427b0..35c0c65 100644 --- a/internal/packs/registry.go +++ b/internal/packs/registry.go @@ -6,22 +6,52 @@ import ( "sort" "github.com/veritrans/midtrans-cli/internal/contracts" + "github.com/veritrans/midtrans-cli/internal/journey" ) type Registry struct { - byID map[string]Pack + byID map[string]Pack + handlers map[string]journey.Handler + byIntent map[string][]journey.Handler } func NewRegistry(values ...Pack) (*Registry, error) { - registry := &Registry{byID: make(map[string]Pack, len(values))} + registry := &Registry{ + byID: make(map[string]Pack, len(values)), + handlers: make(map[string]journey.Handler), + byIntent: make(map[string][]journey.Handler), + } for _, value := range values { - id := value.Descriptor().ID + descriptor := value.Descriptor() + id := descriptor.ID if id == "" { return nil, fmt.Errorf("pack id is empty") } if _, exists := registry.byID[id]; exists { return nil, fmt.Errorf("duplicate pack id %q", id) } + for _, handler := range value.Handlers() { + definition := handler.Definition() + if definition.ID == "" { + return nil, fmt.Errorf("journey id is empty") + } + if definition.Product != id { + return nil, fmt.Errorf( + "journey %q belongs to %q, want %q", + definition.ID, + definition.Product, + id, + ) + } + if _, exists := registry.handlers[definition.ID]; exists { + return nil, fmt.Errorf("duplicate journey id %q", definition.ID) + } + registry.handlers[definition.ID] = handler + registry.byIntent[definition.Intent] = append( + registry.byIntent[definition.Intent], + handler, + ) + } registry.byID[id] = value } return registry, nil @@ -59,6 +89,29 @@ func (r *Registry) SensitiveKeys() []string { return slices.Compact(values) } +func (r *Registry) Handler(journeyID string) (journey.Handler, bool) { + value, ok := r.handlers[journeyID] + return value, ok +} + +func (r *Registry) ForIntent(intent, product string) ([]journey.Handler, error) { + values := r.byIntent[intent] + if len(values) == 0 { + return nil, nil + } + filtered := make([]journey.Handler, 0, len(values)) + for _, value := range values { + if product != "" && value.Definition().Product != product { + continue + } + filtered = append(filtered, value) + } + sort.Slice(filtered, func(i, j int) bool { + return filtered[i].Definition().ID < filtered[j].Definition().ID + }) + return filtered, nil +} + func (r *Registry) Versions() []contracts.PackVersion { values := make([]contracts.PackVersion, 0, len(r.byID)) for _, pack := range r.byID { diff --git a/internal/packs/registry_test.go b/internal/packs/registry_test.go index 539a077..ca3ebcb 100644 --- a/internal/packs/registry_test.go +++ b/internal/packs/registry_test.go @@ -1,9 +1,15 @@ package packs_test import ( + "context" "reflect" "testing" + "github.com/veritrans/midtrans-cli/internal/contracts" + "github.com/veritrans/midtrans-cli/internal/inspection" + "github.com/veritrans/midtrans-cli/internal/journey" + "github.com/veritrans/midtrans-cli/internal/manifest" + "github.com/veritrans/midtrans-cli/internal/operations" "github.com/veritrans/midtrans-cli/internal/packs" "github.com/veritrans/midtrans-cli/packs/common" "github.com/veritrans/midtrans-cli/packs/snap" @@ -44,6 +50,39 @@ func TestRegistryRejectsDuplicatePackIDs(t *testing.T) { } } +func TestRegistryRejectsDuplicateJourneyIDs(t *testing.T) { + _, err := packs.NewRegistry(testPack{ + id: "alpha", + journeys: []string{"alpha.checkout"}, + handlers: []journey.Handler{testHandler{id: "alpha.checkout", product: "alpha", intent: "checkout"}}, + }, testPack{ + id: "beta", + journeys: []string{"alpha.checkout"}, + handlers: []journey.Handler{testHandler{id: "alpha.checkout", product: "beta", intent: "checkout"}}, + }) + if err == nil || err.Error() != `duplicate journey id "alpha.checkout"` { + t.Fatalf("error = %v", err) + } +} + +func TestRegistryJourneyRouting(t *testing.T) { + registry, err := packs.NewRegistry(common.New(), snap.New()) + if err != nil { + t.Fatal(err) + } + handler, ok := registry.Handler("snap.checkout") + if !ok || handler.Definition().Product != "snap" || handler.Definition().Intent != "checkout" { + t.Fatalf("handler = %#v, ok = %t", handler, ok) + } + candidates, err := registry.ForIntent("checkout", "") + if err != nil { + t.Fatal(err) + } + if len(candidates) != 1 || candidates[0].Definition().ID != "snap.checkout" { + t.Fatalf("candidates = %#v", candidates) + } +} + func TestRegistryAggregatesDeterministicMetadata(t *testing.T) { registry, err := packs.NewRegistry(snap.New(), common.New()) if err != nil { @@ -72,3 +111,41 @@ func TestRegistryAggregatesDeterministicMetadata(t *testing.T) { t.Fatalf("versions = %#v, want %#v", gotVersions, wantVersions) } } + +type testPack struct { + id string + journeys []string + handlers []journey.Handler +} + +func (p testPack) Descriptor() packs.Descriptor { + return packs.Descriptor{ID: p.id, Version: "test", Journeys: p.journeys} +} + +func (testPack) Evaluate(manifest.Manifest, inspection.Report) []contracts.Finding { + return nil +} + +func (p testPack) Handlers() []journey.Handler { return p.handlers } + +type testHandler struct { + id string + product string + intent string +} + +func (h testHandler) Definition() journey.Definition { + return journey.Definition{ID: h.id, Product: h.product, Intent: h.intent} +} + +func (testHandler) Plan(context.Context, journey.Request, journey.Runtime) journey.Outcome { + return journey.Outcome{State: journey.Planned} +} + +func (testHandler) Execute(context.Context, journey.Request, journey.Runtime) journey.Outcome { + return journey.Outcome{State: journey.Passed} +} + +func (testHandler) Resume(context.Context, journey.Request, journey.Runtime, operations.Record) journey.Outcome { + return journey.Outcome{State: journey.Passed} +} diff --git a/internal/presentation/model.go b/internal/presentation/model.go index 62ba2ad..00b0615 100644 --- a/internal/presentation/model.go +++ b/internal/presentation/model.go @@ -78,6 +78,8 @@ func Build(result contracts.Result) (Model, bool) { return capabilitiesModel(result) case "sandbox.run", "test.checkout": return checkoutModel(result) + case "agent.plan", "agent.run", "agent.resume": + return genericJourneyModel(result) case "test.webhook": return webhookTestModel(result) case "verify": @@ -87,6 +89,42 @@ func Build(result contracts.Result) (Model, bool) { } } +type genericJourneyPresentationData struct { + Product string `json:"product"` + Journey string `json:"journey"` + OperationID string `json:"operation_id"` + State string `json:"state"` + Action struct { + Instructions string `json:"instructions"` + } `json:"action"` + MissingEvidence []string `json:"missing_evidence"` +} + +func genericJourneyModel(result contracts.Result) (Model, bool) { + var data genericJourneyPresentationData + if !decodeData(result.Data, &data) || data.Product == "" || data.Journey == "" || data.OperationID == "" || data.State == "" { + return Model{}, false + } + rows := []Row{ + {State: "✓", Label: "Product", Detail: titleCase(data.Product)}, + {State: "✓", Label: "Journey", Detail: data.Journey}, + {State: "✓", Label: "Operation", Detail: data.OperationID}, + {State: stateForJourney(data.State), Label: "State", Detail: titleCase(strings.ReplaceAll(data.State, "_", " "))}, + } + if data.Action.Instructions != "" { + rows = append(rows, Row{State: "!", Label: "Next action", Detail: data.Action.Instructions}) + } + if len(data.MissingEvidence) > 0 { + rows = append(rows, Row{State: "!", Label: "Missing evidence", Detail: strings.Join(data.MissingEvidence, ", ")}) + } + return Model{ + Title: "Payment journey", + Rows: rows, + Findings: result.Findings, + NextActions: result.NextActions, + }, true +} + func initModel(result contracts.Result) (Model, bool) { var data InitData if !decodeData(result.Data, &data) || data.Project == "" || data.Root == "" || @@ -390,3 +428,14 @@ func titleCase(value string) string { } return strings.ToUpper(value[:1]) + value[1:] } + +func stateForJourney(state string) string { + switch state { + case "verified", "passed": + return "✓" + case "planned", "checkout_required", "ambiguous": + return "!" + default: + return "✗" + } +} diff --git a/internal/presentation/model_test.go b/internal/presentation/model_test.go index 4a5a15d..07443f2 100644 --- a/internal/presentation/model_test.go +++ b/internal/presentation/model_test.go @@ -119,6 +119,45 @@ func TestBuildCheckoutPresentationIncludesVerifiedProviderAndLocalProof(t *testi } } +func TestBuildGenericJourneyPresentation(t *testing.T) { + result := contracts.NewResult("agent.run", contracts.StatusBlocked) + result.Data = map[string]any{ + "product": "snap", + "journey": "snap.checkout", + "operation_id": "op_test", + "state": "checkout_required", + "action": map[string]any{ + "type": "browser", + "instructions": "complete checkout", + "resume_command": "midtrans agent resume --operation op_test", + }, + "proofs": []any{}, + "missing_evidence": []any{"merchant_callback"}, + } + + model, ok := Build(result) + if !ok { + t.Fatalf("model was not built") + } + got := make([]string, 0, len(model.Rows)) + for _, row := range model.Rows { + got = append(got, row.Label+": "+row.Detail) + } + joined := strings.Join(got, "\n") + for _, want := range []string{ + "Product: Snap", + "Journey: snap.checkout", + "Operation: op_test", + "State: Checkout required", + "Next action: complete checkout", + "Missing evidence: merchant_callback", + } { + if !strings.Contains(joined, want) { + t.Fatalf("rows missing %q:\n%s", want, joined) + } + } +} + func TestBuildWebhookTestPresentationDoesNotRenderSensitiveWebhookMaterial(t *testing.T) { result := contracts.NewResult("test.webhook", contracts.StatusPass) result.Data = map[string]any{ diff --git a/packs/common/pack.go b/packs/common/pack.go index adf8cc4..79d8559 100644 --- a/packs/common/pack.go +++ b/packs/common/pack.go @@ -3,6 +3,7 @@ package common import ( "github.com/veritrans/midtrans-cli/internal/contracts" "github.com/veritrans/midtrans-cli/internal/inspection" + "github.com/veritrans/midtrans-cli/internal/journey" "github.com/veritrans/midtrans-cli/internal/manifest" "github.com/veritrans/midtrans-cli/internal/packs" ) @@ -24,3 +25,7 @@ func (Pack) Descriptor() packs.Descriptor { func (Pack) Evaluate(manifest.Manifest, inspection.Report) []contracts.Finding { return nil } + +func (Pack) Handlers() []journey.Handler { + return nil +} diff --git a/packs/snap/journey.go b/packs/snap/journey.go index 05205fc..41d0119 100644 --- a/packs/snap/journey.go +++ b/packs/snap/journey.go @@ -3,14 +3,18 @@ package snap import ( "context" "errors" + "net/http" + "strconv" "time" "github.com/veritrans/midtrans-cli/internal/contracts" "github.com/veritrans/midtrans-cli/internal/evidence" genericjourney "github.com/veritrans/midtrans-cli/internal/journey" + "github.com/veritrans/midtrans-cli/internal/manifest" "github.com/veritrans/midtrans-cli/internal/operations" "github.com/veritrans/midtrans-cli/internal/policy" "github.com/veritrans/midtrans-cli/internal/sandbox" + "github.com/veritrans/midtrans-cli/internal/secrets" ) var ( @@ -145,6 +149,10 @@ type JourneyRunner struct { Ledger OperationLedger } +func NewJourneyHandler() genericjourney.Handler { + return &compatibilityHandler{} +} + func CheckoutPlan(orderID string, grossAmount int64) (policy.Plan, error) { if orderID == "" || grossAmount <= 0 { return policy.Plan{}, errJourneyInvalid @@ -231,15 +239,19 @@ func (h *compatibilityHandler) Plan( request genericjourney.Request, _ genericjourney.Runtime, ) genericjourney.Outcome { + input := h.inputForRequest(request) h.lastErr = nil h.lastResult = JourneyResult{ OperationID: request.OperationID, State: JourneyPlanned, - OrderID: h.input.OrderID, + OrderID: input.OrderID, } h.lastOutcome = genericjourney.Outcome{ - State: genericjourney.Planned, - SafeData: map[string]any{"order_id": h.input.OrderID}, + State: genericjourney.Planned, + SafeData: map[string]any{ + "order_id": input.OrderID, + "gross_amount": strconv.FormatInt(input.GrossAmount, 10), + }, } return h.lastOutcome } @@ -247,83 +259,91 @@ func (h *compatibilityHandler) Plan( func (h *compatibilityHandler) Execute( ctx context.Context, request genericjourney.Request, - _ genericjourney.Runtime, + runtime genericjourney.Runtime, ) genericjourney.Outcome { - return h.executeOrResume(ctx, request, operations.Record{}) + return h.executeOrResume(ctx, request, runtime, operations.Record{}) } func (h *compatibilityHandler) Resume( ctx context.Context, request genericjourney.Request, - _ genericjourney.Runtime, + runtime genericjourney.Runtime, record operations.Record, ) genericjourney.Outcome { - return h.executeOrResume(ctx, request, record) + return h.executeOrResume(ctx, request, runtime, record) } func (h *compatibilityHandler) executeOrResume( ctx context.Context, request genericjourney.Request, + runtime genericjourney.Runtime, record operations.Record, ) genericjourney.Outcome { + runner, input := h.runnerAndInput(request, runtime) + if input.GrossAmount == 0 && record.SafeReferences["gross_amount"] != "" { + if amount, err := strconv.ParseInt(record.SafeReferences["gross_amount"], 10, 64); err == nil { + input.GrossAmount = amount + input.GrossAmountString = record.SafeReferences["gross_amount"] + ".00" + input.Plan, _ = CheckoutPlan(input.OrderID, input.GrossAmount) + } + } base := JourneyResult{ OperationID: request.OperationID, - OrderID: h.input.OrderID, + OrderID: input.OrderID, } if request.OperationID == "" || !operations.ValidOperationID(request.OperationID) || - h.input.ManifestHash == "" || - h.input.OrderID == "" || - h.input.GrossAmount <= 0 || - h.input.GrossAmountString == "" || - h.runner.Tokens == nil || - h.runner.Status == nil || - h.runner.Local == nil || - h.runner.Ledger == nil { + input.ManifestHash == "" || + input.OrderID == "" || + input.GrossAmount <= 0 || + input.GrossAmountString == "" || + runner.Tokens == nil || + runner.Status == nil || + runner.Local == nil { h.lastErr = errJourneyInvalid h.lastResult = withJourneyState(base, JourneyBlocked) return genericjourney.Outcome{State: genericjourney.Blocked} } - decision := policy.Authorize(h.input.Plan, policy.Authorization{Execute: true}) + decision := policy.Authorize(input.Plan, policy.Authorization{Execute: true}) if !decision.Allowed { h.lastErr = errJourneyInvalid h.lastResult = withJourneyState(base, JourneyBlocked) return genericjourney.Outcome{State: genericjourney.Blocked} } - status, err := h.runner.Status.Status(ctx, h.input.OrderID) + status, err := runner.Status.Status(ctx, input.OrderID) if err != nil { h.lastErr = errJourneyStatus h.lastResult = withJourneyState(base, JourneyBlocked) return genericjourney.Outcome{State: genericjourney.Blocked} } if !status.NotFound { - return h.evaluateStatus(ctx, request.OperationID, status) + return h.evaluateStatus(ctx, request.OperationID, input, runner, status) } if record.OperationID != "" { if record.State == string(genericjourney.Reconciling) { h.lastResult = withJourneyState(base, JourneyAmbiguous) return genericjourney.Outcome{ State: genericjourney.Reconciling, - SafeData: map[string]any{"order_id": h.input.OrderID}, + SafeData: map[string]any{"order_id": input.OrderID}, } } h.lastResult = JourneyResult{ OperationID: request.OperationID, State: JourneyBlocked, - OrderID: h.input.OrderID, + OrderID: input.OrderID, NextActions: []contracts.NextAction{reusePreviousCheckoutAction()}, } return genericjourney.Outcome{ State: genericjourney.Blocked, - SafeData: map[string]any{"order_id": h.input.OrderID}, + SafeData: map[string]any{"order_id": input.OrderID}, } } - created, err := h.runner.Tokens.CreateToken(ctx, CreateTokenRequest{ + created, err := runner.Tokens.CreateToken(ctx, CreateTokenRequest{ OperationID: request.OperationID, - OrderID: h.input.OrderID, - GrossAmount: h.input.GrossAmount, + OrderID: input.OrderID, + GrossAmount: input.GrossAmount, }) if err != nil { var ambiguous sandbox.AmbiguousOperationError @@ -332,21 +352,21 @@ func (h *compatibilityHandler) executeOrResume( h.lastResult = withJourneyState(base, JourneyBlocked) return genericjourney.Outcome{State: genericjourney.Blocked} } - reconciled, statusErr := h.runner.Status.Status(ctx, h.input.OrderID) + reconciled, statusErr := runner.Status.Status(ctx, input.OrderID) if statusErr != nil || reconciled.NotFound { h.lastResult = withJourneyState(base, JourneyAmbiguous) return genericjourney.Outcome{ State: genericjourney.Reconciling, - SafeData: map[string]any{"order_id": h.input.OrderID}, + SafeData: map[string]any{"order_id": input.OrderID}, } } - return h.evaluateStatus(ctx, request.OperationID, reconciled) + return h.evaluateStatus(ctx, request.OperationID, input, runner, reconciled) } h.lastResult = JourneyResult{ OperationID: request.OperationID, State: JourneyCheckoutRequired, - OrderID: h.input.OrderID, + OrderID: input.OrderID, RedirectURL: created.RedirectURL, NextActions: []contracts.NextAction{{ Action: "complete_sandbox_checkout", @@ -356,7 +376,7 @@ func (h *compatibilityHandler) executeOrResume( return genericjourney.Outcome{ State: genericjourney.AwaitingUserAction, SafeData: map[string]any{ - "order_id": h.input.OrderID, + "order_id": input.OrderID, }, Action: &genericjourney.Action{ Type: "browser", @@ -370,14 +390,16 @@ func (h *compatibilityHandler) executeOrResume( func (h *compatibilityHandler) evaluateStatus( ctx context.Context, operationID string, + input JourneyInput, + runner JourneyRunner, status StatusResponse, ) genericjourney.Outcome { result := JourneyResult{ OperationID: operationID, - OrderID: h.input.OrderID, + OrderID: input.OrderID, Provider: status, } - if status.OrderID != h.input.OrderID { + if status.OrderID != input.OrderID { h.lastErr = errJourneyStatus h.lastResult = withJourneyState(result, JourneyBlocked) return genericjourney.Outcome{State: genericjourney.Blocked} @@ -387,7 +409,7 @@ func (h *compatibilityHandler) evaluateStatus( h.lastResult = withJourneyState(result, JourneyPending) return genericjourney.Outcome{ State: genericjourney.Reconciling, - SafeData: map[string]any{"order_id": h.input.OrderID}, + SafeData: map[string]any{"order_id": input.OrderID}, } case "deny", "cancel", "expire": result.State = JourneyBlocked @@ -398,14 +420,14 @@ func (h *compatibilityHandler) evaluateStatus( h.lastResult = result return genericjourney.Outcome{ State: genericjourney.Blocked, - SafeData: map[string]any{"order_id": h.input.OrderID}, + SafeData: map[string]any{"order_id": input.OrderID}, } case "capture": if status.FraudStatus != "accept" { h.lastResult = withJourneyState(result, JourneyBlocked) return genericjourney.Outcome{ State: genericjourney.Blocked, - SafeData: map[string]any{"order_id": h.input.OrderID}, + SafeData: map[string]any{"order_id": input.OrderID}, } } case "settlement": @@ -413,13 +435,13 @@ func (h *compatibilityHandler) evaluateStatus( h.lastResult = withJourneyState(result, JourneyBlocked) return genericjourney.Outcome{ State: genericjourney.Blocked, - SafeData: map[string]any{"order_id": h.input.OrderID}, + SafeData: map[string]any{"order_id": input.OrderID}, } } - local, err := h.runner.Local.VerifyLocal(ctx, LocalVerificationInput{ - OrderID: h.input.OrderID, - GrossAmount: h.input.GrossAmountString, + local, err := runner.Local.VerifyLocal(ctx, LocalVerificationInput{ + OrderID: input.OrderID, + GrossAmount: input.GrossAmountString, }) result.Local = local if err != nil || !local.Passed() { @@ -431,7 +453,7 @@ func (h *compatibilityHandler) evaluateStatus( h.lastResult = result return genericjourney.Outcome{ State: genericjourney.Blocked, - SafeData: map[string]any{"order_id": h.input.OrderID}, + SafeData: map[string]any{"order_id": input.OrderID}, } } result.State = JourneyVerified @@ -464,7 +486,7 @@ func (h *compatibilityHandler) resultFor(outcome genericjourney.Outcome) Journey return JourneyResult{ OperationID: outcome.OperationID, State: snapStateForOutcome(outcome.State), - OrderID: h.input.OrderID, + OrderID: h.inputForRequest(genericjourney.Request{Input: genericjourney.Input{OrderID: h.input.OrderID}}).OrderID, } } @@ -505,3 +527,87 @@ func reusePreviousCheckoutAction() contracts.NextAction { "or start a new unique order", } } + +func (h *compatibilityHandler) inputForRequest(request genericjourney.Request) JourneyInput { + if h.input.OrderID != "" || h.input.GrossAmount != 0 || h.input.ManifestHash != "" { + return h.input + } + plan, _ := CheckoutPlan(request.Input.OrderID, request.Input.Amount) + return JourneyInput{ + OperationID: request.OperationID, + ManifestHash: request.ManifestHash, + OrderID: request.Input.OrderID, + GrossAmount: request.Input.Amount, + GrossAmountString: strconv.FormatInt(request.Input.Amount, 10) + ".00", + Execute: true, + Plan: plan, + } +} + +func (h *compatibilityHandler) runnerAndInput( + request genericjourney.Request, + runtime genericjourney.Runtime, +) (JourneyRunner, JourneyInput) { + input := h.inputForRequest(request) + if h.runner.Tokens != nil || h.runner.Status != nil || h.runner.Local != nil || h.runner.Ledger != nil { + return h.runner, input + } + serverKey, err := serverKeyForManifest(context.Background(), runtime, request.ProjectDir, request.Manifest) + if err != nil { + return JourneyRunner{}, input + } + return JourneyRunner{ + Tokens: Client{HTTP: runtime.HTTP, ServerKey: serverKey}, + Status: Client{HTTP: runtime.HTTP, ServerKey: serverKey}, + Local: MerchantVerifier{ + Manifest: request.Manifest, + ServerKey: serverKey, + HTTP: localJourneyClient(runtime.HTTP), + }, + }, input +} + +func serverKeyForManifest( + ctx context.Context, + runtime genericjourney.Runtime, + projectDir string, + value manifest.Manifest, +) (secrets.Value, error) { + set, ok := value.CredentialSetForIntegration("snap") + if !ok || set.ServerKey == "" || runtime.ResolveCredential == nil { + return secrets.Value{}, errJourneyInvalid + } + raw, err := runtime.ResolveCredential(ctx, projectDir, set.ServerKey) + if err != nil { + return secrets.Value{}, err + } + valueSecret := secrets.NewValue(string(raw)) + if _, err := valueSecret.SandboxServerKey(); err != nil { + return secrets.Value{}, err + } + return valueSecret, nil +} + +func localJourneyClient(doer interface { + Do(*http.Request) (*http.Response, error) +}) *http.Client { + if client, ok := doer.(*http.Client); ok { + return client + } + return &http.Client{ + Transport: snapRoundTripper{doer: doer}, + CheckRedirect: func(*http.Request, []*http.Request) error { + return http.ErrUseLastResponse + }, + } +} + +type snapRoundTripper struct { + doer interface { + Do(*http.Request) (*http.Response, error) + } +} + +func (t snapRoundTripper) RoundTrip(request *http.Request) (*http.Response, error) { + return t.doer.Do(request) +} diff --git a/packs/snap/pack.go b/packs/snap/pack.go index 2b9e7a5..eb0eeca 100644 --- a/packs/snap/pack.go +++ b/packs/snap/pack.go @@ -5,6 +5,7 @@ import ( "github.com/veritrans/midtrans-cli/internal/contracts" "github.com/veritrans/midtrans-cli/internal/inspection" + "github.com/veritrans/midtrans-cli/internal/journey" "github.com/veritrans/midtrans-cli/internal/manifest" "github.com/veritrans/midtrans-cli/internal/packs" ) @@ -112,3 +113,7 @@ func (Pack) Evaluate(value manifest.Manifest, report inspection.Report) []contra } return findings } + +func (Pack) Handlers() []journey.Handler { + return []journey.Handler{NewJourneyHandler()} +} From f1171e96280112a9814308f49860064cbb1431e7 Mon Sep 17 00:00:00 2001 From: "m.salis" Date: Mon, 27 Jul 2026 09:34:26 +0700 Subject: [PATCH 32/73] fix: tighten generic journey execution semantics --- .../task-4-report.md | 28 ++ internal/app/app_test.go | 55 ++-- internal/app/commands_checkout.go | 280 +++++++++++++----- internal/app/commands_test.go | 181 +++++++++++ internal/app/journey_runner.go | 13 + internal/journey/engine.go | 48 +-- internal/journey/engine_test.go | 69 ++++- packs/snap/journey.go | 9 +- 8 files changed, 550 insertions(+), 133 deletions(-) diff --git a/.superpowers/sdd/2026-07-26-midtrans-cli-multi-product-parity/task-4-report.md b/.superpowers/sdd/2026-07-26-midtrans-cli-multi-product-parity/task-4-report.md index 673bda6..8b207db 100644 --- a/.superpowers/sdd/2026-07-26-midtrans-cli-multi-product-parity/task-4-report.md +++ b/.superpowers/sdd/2026-07-26-midtrans-cli-multi-product-parity/task-4-report.md @@ -26,3 +26,31 @@ Self-review: Concerns: - Only the currently implemented Snap checkout handler is executable. Future products and future common journeys still surface as unavailable until Task 5+ adds real handlers. - Merchant intent routing is only partially generalized at the top-level `test` surface in this task; the exact generic merchant journey command expansion remains constrained to the current Snap checkout flow. + +## Review Fix Round 1 + +Date: +- 2026-07-27 + +Status: +- done + +Changes: +- Changed `internal/journey.Engine.Run` so plan-only invocations do not reserve, save, or create any operation file. +- Changed persisted journey records to merge prior `safe_references` with the latest safe output instead of replacing them. +- Updated Snap journey persistence/resume so `order_id` and `gross_amount` survive awaiting-action persistence and can be reconstructed during `agent resume` without token state. +- Reworked merchant `midtrans test` so the primary path is intent-routed (`midtrans test [intent]`) with shared merchant flags, manifest routing precedence, explicit `--product` fallback only when no manifest route exists, and immediate `CAPABILITY_UNAVAILABLE` on unsupported routed products. +- Kept `midtrans test checkout` only as a hidden compatibility alias delegating to the same merchant intent runner. + +Exact tests and results: +- `go test ./internal/journey ./internal/packs ./internal/app -run 'TestEngineRunPlansWithoutExecutingWhenExecutionDisabled|TestEnginePlanThenExecuteUsesSameOperationIDWithoutConflict|TestEnginePersistsAwaitingActionAndResumesSameOperation|TestMerchantIntentPlanDoesNotPersistOperationAndExecuteCanReuseDerivedID|TestMerchantIntentRoutingFailsImmediatelyForUnsupportedRoutedProduct|TestMerchantIntentRoutingReturnsAmbiguousWithoutManifestRoute|TestAgentResumePreservesSafeInputAcrossAwaitingAction' -count=1` + - result: pass +- `go test ./internal/journey ./internal/packs ./internal/app ./internal/presentation -count=1` + - result: pass +- `go test ./... -count=1` + - result: pass + +Self-review: +- Verified plan-only engine runs leave `.midtrans/operations` absent and no longer create conflicts for a later execute with the same derived operation ID. +- Verified resume retains `gross_amount` and `order_id` across persisted awaiting-action records and advances to verified reconciliation in the Snap path without any token persistence. +- Verified merchant intent routing now stops on an unsupported manifest route and returns `JOURNEY_AMBIGUOUS` when multiple configured candidates exist without a manifest route. diff --git a/internal/app/app_test.go b/internal/app/app_test.go index 06e42b6..5e104d2 100644 --- a/internal/app/app_test.go +++ b/internal/app/app_test.go @@ -832,7 +832,7 @@ func TestHelpExposesExactlyThePhaseOneCommandSurface(t *testing.T) { expected := map[string][]string{ "": {"agent", "init", "setup", "status", "test", "update", "verify", "version"}, "agent": {"capabilities", "check", "inspect", "pack", "plan", "resume", "run"}, - "test": {"checkout", "webhook"}, + "test": {"webhook"}, "update": {"check"}, } for command, want := range expected { @@ -2817,34 +2817,41 @@ func merchantFixture(name string) string { } func configureSnapManifestProject(t *testing.T, project string, baseURL string) { + t.Helper() + configureManifest(t, project, func(value *manifest.Manifest) { + value.Application.BaseURL = baseURL + value.CredentialSets["classic"] = manifest.CredentialSet{ + Type: "classic", + Environment: "sandbox", + ServerKey: "env:MIDTRANS_SERVER_KEY", + ClientKey: "env:MIDTRANS_CLIENT_KEY", + } + value.Integrations["snap"] = manifest.Integration{ + ConfigVersion: 1, + Credentials: "classic", + Profiles: []string{"web-redirect"}, + Callbacks: map[string]string{ + "notification": "/api/payments/midtrans/notification", + "finish": "/orders/{order_id}", + "status": "/api/payments/midtrans/status/{order_id}", + }, + } + value.Routing["checkout"] = "snap" + value.Verification.Required = []string{ + "snap.checkout", + "common.webhook-idempotency", + "common.status-reconciliation", + } + }) +} + +func configureManifest(t *testing.T, project string, mutate func(*manifest.Manifest)) { t.Helper() value, err := manifest.Load(project) if err != nil { t.Fatal(err) } - value.Application.BaseURL = baseURL - value.CredentialSets["classic"] = manifest.CredentialSet{ - Type: "classic", - Environment: "sandbox", - ServerKey: "env:MIDTRANS_SERVER_KEY", - ClientKey: "env:MIDTRANS_CLIENT_KEY", - } - value.Integrations["snap"] = manifest.Integration{ - ConfigVersion: 1, - Credentials: "classic", - Profiles: []string{"web-redirect"}, - Callbacks: map[string]string{ - "notification": "/api/payments/midtrans/notification", - "finish": "/orders/{order_id}", - "status": "/api/payments/midtrans/status/{order_id}", - }, - } - value.Routing["checkout"] = "snap" - value.Verification.Required = []string{ - "snap.checkout", - "common.webhook-idempotency", - "common.status-reconciliation", - } + mutate(&value) if err := manifest.Save(project, value); err != nil { t.Fatal(err) } diff --git a/internal/app/commands_checkout.go b/internal/app/commands_checkout.go index d687c69..83616a4 100644 --- a/internal/app/commands_checkout.go +++ b/internal/app/commands_checkout.go @@ -1,6 +1,7 @@ package app import ( + "context" "errors" "fmt" "io" @@ -10,103 +11,230 @@ import ( "github.com/spf13/cobra" "github.com/veritrans/midtrans-cli/internal/contracts" "github.com/veritrans/midtrans-cli/internal/evidence" + "github.com/veritrans/midtrans-cli/internal/journey" "github.com/veritrans/midtrans-cli/internal/project" "github.com/veritrans/midtrans-cli/internal/render" ) func newTestCommand(flags *globalFlags, deps Dependencies) *cobra.Command { + request := bindMerchantJourneyFlags("test") parent := &cobra.Command{ - Use: "test", - RunE: func(*cobra.Command, []string) error { - value, invalid := loadValidatedManifest("test", flags.projectDir, deps) - if invalid != nil { - return writeResult(deps, flags, *invalid) + Use: "test [intent]", + Args: cobra.MaximumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + if len(args) == 0 { + return writeResult(deps, flags, listEnabledJourneys(flags.projectDir, deps)) } - enabled := make([]string, 0, len(value.Routing)) - for intent, product := range value.Routing { - enabled = append(enabled, product+"."+intent) - } - result := contracts.NewResult("test", contracts.StatusPass) - result.CLIVersion = deps.Version.Version - result.ManifestVersion = value.SchemaVersion - result.Data = map[string]any{"enabled_journeys": enabled} - result.NextActions = []contracts.NextAction{{ - Action: "run_checkout_test", - Description: "midtrans test checkout --amount 10000", - }} - return writeResult(deps, flags, result) + return runMerchantIntent(cmd, flags, deps, request, args[0]) }, } - parent.AddCommand(newTestCheckoutCommand(flags, deps)) + request.bind(parent) + parent.AddCommand(newLegacyTestCheckoutCommand(flags, deps)) parent.AddCommand(newTestWebhookCommand(flags, deps)) - return parent + return withProjectMode(parent, project.Existing, "test") } -func newTestCheckoutCommand( - flags *globalFlags, - deps Dependencies, -) *cobra.Command { - var amount int64 - var orderID string - var product string - var execute bool +type merchantJourneyFlags struct { + command string + product string + orderID string + method string + customerReference string + paymentTokenReference string + amount int64 + reusable bool + execute bool +} + +func bindMerchantJourneyFlags(command string) *merchantJourneyFlags { + return &merchantJourneyFlags{command: command} +} + +func (f *merchantJourneyFlags) bind(command *cobra.Command) { + command.Flags().Int64Var(&f.amount, "amount", 0, "Sandbox amount in IDR") + command.Flags().StringVar(&f.orderID, "order-id", "", "existing merchant order reference") + command.Flags().StringVar(&f.method, "method", "", "payment method") + command.Flags().StringVar(&f.customerReference, "customer-reference", "", "safe customer reference") + command.Flags().StringVar(&f.paymentTokenReference, "payment-token-reference", "", "safe payment token reference") + command.Flags().BoolVar(&f.reusable, "reusable", false, "request a reusable payment resource") + command.Flags().StringVar(&f.product, "product", "", "product override when no manifest route is configured") + command.Flags().BoolVar(&f.execute, "execute", false, "execute the reviewed Sandbox plan") +} + +func newLegacyTestCheckoutCommand(flags *globalFlags, deps Dependencies) *cobra.Command { + request := bindMerchantJourneyFlags("test.checkout") command := &cobra.Command{ - Use: "checkout", - Short: "plan and run a Sandbox Snap checkout", - Args: cobra.NoArgs, + Use: "checkout", + Short: "plan and run a Sandbox checkout", + Args: cobra.NoArgs, + Hidden: true, RunE: func(cmd *cobra.Command, _ []string) error { - providerOnly := orderID == "" - if orderID == "" { - orderID = deps.NewOrderID() - } - request := checkoutRequest{ - Command: "test.checkout", - ProjectDir: flags.projectDir, - OrderID: orderID, - GrossAmount: amount, - Execute: execute, - ProviderOnly: providerOnly, - } - if shouldConfirmCheckout(flags, deps, request) { - preview := runCheckout(cmd.Context(), request, deps) - if preview.Status != contracts.StatusBlocked || - !isCheckoutPlan(preview) { - return writeResult(deps, flags, preview) - } - if err := writeCheckoutPreview(deps, preview); err != nil { - return err - } - if !confirmCheckoutExactYes(deps.Stdin, deps.Stdout) { - return commandExitError{code: preview.ExitCode()} - } - request.Execute = true - } - if product != "" && product != "snap" { - result := contracts.NewResult("test.checkout", contracts.StatusBlocked) - result.CLIVersion = deps.Version.Version - result.Findings = []contracts.Finding{{ - Code: "CAPABILITY_UNAVAILABLE", Severity: "blocking", - Message: "requested journey product is unavailable", - }} - return writeResult(deps, flags, result) - } - return writeResult(deps, flags, runCheckout(cmd.Context(), request, deps)) + return runMerchantIntent(cmd, flags, deps, request, "checkout") }, } - command.Flags().Int64Var(&amount, "amount", 0, "Sandbox amount in IDR") - command.Flags().StringVar(&orderID, "order-id", "", "existing merchant order reference") - command.Flags().StringVar(&product, "product", "", "product override") - command.Flags().BoolVar(&execute, "execute", false, "execute the reviewed Sandbox plan") - _ = command.MarkFlagRequired("amount") + request.bind(command) return withProjectMode(command, project.Existing, "test.checkout") } -func shouldConfirmCheckout( +func runMerchantIntent( + cmd *cobra.Command, flags *globalFlags, deps Dependencies, - request checkoutRequest, -) bool { - return !request.Execute && !flags.json && !flags.nonInteractive && deps.IsTerminal() + request *merchantJourneyFlags, + intent string, +) error { + if intent == "webhook" { + return errors.New("webhook subcommand is required") + } + result := runMerchantJourney(cmd, flags, deps, request, intent) + return writeResult(deps, flags, result) +} + +func runMerchantJourney( + cmd *cobra.Command, + flags *globalFlags, + deps Dependencies, + request *merchantJourneyFlags, + intent string, +) contracts.Result { + if request.amount <= 0 { + result := contracts.NewResult(request.commandName(), contracts.StatusBlocked) + result.CLIVersion = deps.Version.Version + result.Findings = []contracts.Finding{{ + Code: "JOURNEY_INPUT_REQUIRED", Severity: "blocking", + Message: "a positive --amount is required for this journey intent", + }} + return result + } + orderID := strings.TrimSpace(request.orderID) + providerOnly := orderID == "" + if providerOnly { + orderID = deps.NewOrderID() + } + journeyRequest := journeyRunRequest{ + Command: request.commandName(), + ProjectDir: flags.projectDir, + Intent: intent, + Product: request.product, + Input: journeyInput( + orderID, + request.amount, + request.method, + request.customerReference, + request.paymentTokenReference, + request.reusable, + ), + Execute: request.execute, + } + if shouldConfirmMerchantJourney(flags, deps, request.execute) { + preview := runRoutedMerchantJourney(cmd.Context(), journeyRequest, deps, providerOnly) + if preview.Status != contracts.StatusBlocked || !isCheckoutPlan(preview) { + return preview + } + if err := writeCheckoutPreview(deps, preview); err != nil { + failure := contracts.NewResult(request.commandName(), contracts.StatusError) + failure.CLIVersion = deps.Version.Version + failure.Findings = []contracts.Finding{{ + Code: "JOURNEY_RENDER_FAILED", Severity: "blocking", + Message: "the journey preview could not be rendered safely", + }} + return failure + } + if !confirmCheckoutExactYes(deps.Stdin, deps.Stdout) { + cancelled := preview + cancelled.NextActions = nil + return cancelled + } + journeyRequest.Execute = true + } + return runRoutedMerchantJourney(cmd.Context(), journeyRequest, deps, providerOnly) +} + +func runRoutedMerchantJourney( + ctx context.Context, + request journeyRunRequest, + deps Dependencies, + providerOnly bool, +) contracts.Result { + value, invalid := loadValidatedManifest(request.Command, request.ProjectDir, deps) + if invalid != nil { + return *invalid + } + handler, finding := resolveJourneyHandler(request, deps, value) + if finding != nil { + result := contracts.NewResult(request.Command, contracts.StatusBlocked) + result.CLIVersion = deps.Version.Version + result.ManifestVersion = value.SchemaVersion + result.Findings = []contracts.Finding{*finding} + return result + } + if handler.Definition().ID != "snap.checkout" { + result := contracts.NewResult(request.Command, contracts.StatusBlocked) + result.CLIVersion = deps.Version.Version + result.ManifestVersion = value.SchemaVersion + result.Findings = []contracts.Finding{{ + Code: "CAPABILITY_UNAVAILABLE", Severity: "blocking", + Message: "requested journey is not implemented in this CLI build", + }} + return result + } + return runCheckout(ctx, checkoutRequest{ + Command: request.Command, + ProjectDir: request.ProjectDir, + OrderID: request.Input.OrderID, + GrossAmount: request.Input.Amount, + Execute: request.Execute, + ProviderOnly: providerOnly, + }, deps) +} + +func listEnabledJourneys(projectDir string, deps Dependencies) contracts.Result { + value, invalid := loadValidatedManifest("test", projectDir, deps) + if invalid != nil { + return *invalid + } + enabled := make([]string, 0, len(value.Routing)) + for intent, product := range value.Routing { + enabled = append(enabled, product+"."+intent) + } + result := contracts.NewResult("test", contracts.StatusPass) + result.CLIVersion = deps.Version.Version + result.ManifestVersion = value.SchemaVersion + result.Data = map[string]any{"enabled_journeys": enabled} + result.NextActions = []contracts.NextAction{{ + Action: "run_checkout_test", + Description: "midtrans test checkout --amount 10000", + }} + return result +} + +func shouldConfirmMerchantJourney(flags *globalFlags, deps Dependencies, execute bool) bool { + return !execute && !flags.json && !flags.nonInteractive && deps.IsTerminal() +} + +func journeyInput( + orderID string, + amount int64, + method string, + customerReference string, + paymentTokenReference string, + reusable bool, +) journey.Input { + return journey.Input{ + OrderID: orderID, + Amount: amount, + Method: method, + CustomerReference: customerReference, + PaymentTokenReference: paymentTokenReference, + Reusable: reusable, + } +} + +func (f *merchantJourneyFlags) commandName() string { + if f.command == "test.checkout" { + return "test.checkout" + } + return "test." + "checkout" } func isCheckoutPlan(result contracts.Result) bool { diff --git a/internal/app/commands_test.go b/internal/app/commands_test.go index b9b2b53..8f5d9e3 100644 --- a/internal/app/commands_test.go +++ b/internal/app/commands_test.go @@ -6,6 +6,7 @@ import ( "net/http" "net/http/httptest" "os" + "path/filepath" "reflect" "regexp" "strings" @@ -13,7 +14,14 @@ import ( "github.com/veritrans/midtrans-cli/internal/app" "github.com/veritrans/midtrans-cli/internal/contracts" + "github.com/veritrans/midtrans-cli/internal/inspection" + "github.com/veritrans/midtrans-cli/internal/journey" + "github.com/veritrans/midtrans-cli/internal/manifest" + "github.com/veritrans/midtrans-cli/internal/operations" + "github.com/veritrans/midtrans-cli/internal/packs" "github.com/veritrans/midtrans-cli/internal/version" + "github.com/veritrans/midtrans-cli/packs/common" + "github.com/veritrans/midtrans-cli/packs/snap" ) func TestMerchantCheckoutPlansWithGeneratedOrderID(t *testing.T) { @@ -49,6 +57,149 @@ func TestMerchantCheckoutPlansWithGeneratedOrderID(t *testing.T) { } } +func TestMerchantIntentPlanDoesNotPersistOperationAndExecuteCanReuseDerivedID(t *testing.T) { + project := createJourneyProject(t, "http://127.0.0.1:1") + deps := app.Dependencies{ + Version: version.Info{Version: "0.1.0-test"}, + Packs: testRegistry(t), + Getenv: func(string) (string, bool) { + return journeyServerKeyCanary, true + }, + HTTP: &http.Client{Transport: newJourneyFixtureTransport(t, http.StatusNotFound, nil)}, + } + + planned, exit := executeJSONWithDependencies( + t, deps, + "test", "checkout", + "--amount", "10000", + "--order-id", "snap-fixture-001", + "--project-dir", project, + ) + if exit != 3 || planned.Status != contracts.StatusBlocked { + t.Fatalf("plan exit = %d, result = %#v", exit, planned) + } + entries, err := os.ReadDir(filepath.Join(project, ".midtrans", "operations")) + if !os.IsNotExist(err) || len(entries) != 0 { + t.Fatalf("operations after plan = %v, err = %v", entries, err) + } + + executed, exit := executeJSONWithDependencies( + t, deps, + "test", "checkout", + "--amount", "10000", + "--order-id", "snap-fixture-001", + "--execute", + "--project-dir", project, + ) + if exit != 3 || requireJourneyData(t, executed)["state"] != "checkout_required" { + t.Fatalf("execute exit = %d, result = %#v", exit, executed) + } +} + +func TestMerchantIntentRoutingFailsImmediatelyForUnsupportedRoutedProduct(t *testing.T) { + project := createJourneyProject(t, "http://127.0.0.1:1") + configureManifest(t, project, func(value *manifest.Manifest) { + value.Integrations["empty"] = manifest.Integration{ + ConfigVersion: 1, + Credentials: "classic", + } + value.Routing["checkout"] = "empty" + }) + registry, err := packs.NewRegistry(common.New(), snap.New(), staticTestPack{ + descriptor: packs.Descriptor{ID: "empty", Version: "test"}, + }) + if err != nil { + t.Fatal(err) + } + result, exit := executeJSONWithDependencies( + t, + app.Dependencies{Version: version.Info{Version: "0.1.0-test"}, Packs: registry}, + "test", "checkout", "--amount", "10000", "--project-dir", project, + ) + if exit != 3 || len(result.Findings) != 1 || result.Findings[0].Code != "CAPABILITY_UNAVAILABLE" { + t.Fatalf("exit = %d, result = %#v", exit, result) + } +} + +func TestMerchantIntentRoutingReturnsAmbiguousWithoutManifestRoute(t *testing.T) { + project := createJourneyProject(t, "http://127.0.0.1:1") + configureManifest(t, project, func(value *manifest.Manifest) { + delete(value.Routing, "checkout") + value.Integrations["alt"] = manifest.Integration{ + ConfigVersion: 1, + Credentials: "classic", + } + }) + registry, err := packs.NewRegistry(common.New(), snap.New(), staticTestPack{ + descriptor: packs.Descriptor{ID: "alt", Version: "test", Journeys: []string{"alt.checkout"}}, + handlers: []journey.Handler{staticTestHandler{ + definition: journey.Definition{ID: "alt.checkout", Product: "alt", Intent: "checkout"}, + plan: journey.Outcome{State: journey.Planned}, + }}, + }) + if err != nil { + t.Fatal(err) + } + result, exit := executeJSONWithDependencies( + t, + app.Dependencies{Version: version.Info{Version: "0.1.0-test"}, Packs: registry}, + "test", "checkout", "--amount", "10000", "--project-dir", project, + ) + if exit != 3 || len(result.Findings) != 1 || result.Findings[0].Code != "JOURNEY_AMBIGUOUS" { + t.Fatalf("exit = %d, result = %#v", exit, result) + } +} + +func TestAgentResumePreservesSafeInputAcrossAwaitingAction(t *testing.T) { + merchant := &appMerchantState{ + orderID: "snap-fixture-001", + paymentStatus: "pending", + } + server := httptest.NewServer(merchant) + defer server.Close() + project := createJourneyProject(t, server.URL) + transport := newJourneyFixtureTransport(t, http.StatusNotFound, nil) + deps := app.Dependencies{ + Version: version.Info{Version: "0.1.0-test"}, + Packs: testRegistry(t), + Getenv: func(key string) (string, bool) { + return journeyServerKeyCanary, key == "MIDTRANS_SERVER_KEY" + }, + HTTP: &http.Client{Transport: newJourneyFixtureTransport( + t, http.StatusNotFound, server.Client().Transport, + )}, + } + first, exit := executeJSONWithDependencies( + t, deps, + "agent", "run", + "--journey", "snap.checkout", + "--amount", "10000", + "--order-id", "snap-fixture-001", + "--operation", "op_test", + "--execute", + "--project-dir", project, + ) + if exit != 3 || requireJourneyData(t, first)["state"] != "checkout_required" { + t.Fatalf("run exit = %d, result = %#v", exit, first) + } + + _ = transport + deps.HTTP = &http.Client{Transport: newJourneyFixtureTransport( + t, http.StatusOK, server.Client().Transport, + )} + resumed, exit := executeJSONWithDependencies( + t, deps, + "agent", "resume", "--operation", "op_test", "--project-dir", project, + ) + if exit != 0 { + t.Fatalf("resume exit = %d, result = %#v", exit, resumed) + } + data := requireJourneyData(t, resumed) + if data["state"] != "verified" || data["order_id"] != "snap-fixture-001" { + t.Fatalf("resume data = %#v", data) + } +} + func TestMerchantWebhookTestPlansWithoutHTTP(t *testing.T) { project := createJourneyProject(t, "http://127.0.0.1:1") result, exit := executeJSONWithDependencies( @@ -395,3 +546,33 @@ func TestProviderOnlyCheckoutCannotWriteMerchantEvidence(t *testing.T) { t.Fatalf("evidence directory error = %v", err) } } + +type staticTestPack struct { + descriptor packs.Descriptor + handlers []journey.Handler +} + +func (p staticTestPack) Descriptor() packs.Descriptor { return p.descriptor } + +func (staticTestPack) Evaluate(manifest.Manifest, inspection.Report) []contracts.Finding { return nil } + +func (p staticTestPack) Handlers() []journey.Handler { return p.handlers } + +type staticTestHandler struct { + definition journey.Definition + plan journey.Outcome +} + +func (h staticTestHandler) Definition() journey.Definition { return h.definition } + +func (h staticTestHandler) Plan(context.Context, journey.Request, journey.Runtime) journey.Outcome { + return h.plan +} + +func (staticTestHandler) Execute(context.Context, journey.Request, journey.Runtime) journey.Outcome { + return journey.Outcome{State: journey.Passed} +} + +func (staticTestHandler) Resume(context.Context, journey.Request, journey.Runtime, operations.Record) journey.Outcome { + return journey.Outcome{State: journey.Passed} +} diff --git a/internal/app/journey_runner.go b/internal/app/journey_runner.go index 398cb3b..f9d169a 100644 --- a/internal/app/journey_runner.go +++ b/internal/app/journey_runner.go @@ -138,11 +138,21 @@ func resolveJourneyHandler( } candidates, _ := deps.Packs.ForIntent(request.Intent, "") if configured := value.Routing[request.Intent]; configured != "" { + if _, ok := value.IntegrationFor(configured); !ok { + return nil, &contracts.Finding{ + Code: "CAPABILITY_UNAVAILABLE", Severity: "blocking", + Message: fmt.Sprintf("routing selects %s for %s but that product is not configured", configured, request.Intent), + } + } for _, candidate := range candidates { if candidate.Definition().Product == configured { return candidate, nil } } + return nil, &contracts.Finding{ + Code: "CAPABILITY_UNAVAILABLE", Severity: "blocking", + Message: fmt.Sprintf("routing selects %s for %s but no compiled handler is available", configured, request.Intent), + } } if request.Product != "" { filtered, _ := deps.Packs.ForIntent(request.Intent, request.Product) @@ -200,6 +210,9 @@ func genericJourneyResult( "proofs": outcome.Proofs, "missing_evidence": outcome.MissingEvidence, } + for key, value := range outcome.SafeData { + result.Data.(map[string]any)[key] = value + } if outcome.Action != nil { result.Data.(map[string]any)["action"] = outcome.Action } diff --git a/internal/journey/engine.go b/internal/journey/engine.go index 46a2514..ac068e1 100644 --- a/internal/journey/engine.go +++ b/internal/journey/engine.go @@ -31,6 +31,12 @@ func (e Engine) Run( definition := handler.Definition() planned := normalizeOutcome(request.OperationID, handler.Plan(ctx, request, runtime)) + if planned.State != Planned { + return planned + } + if !execute { + return planned + } initial, ok, err := e.reserveInitial(ctx, runtime, definition, request, planned) if err != nil { return blockedOutcome( @@ -46,12 +52,6 @@ func (e Engine) Run( "the journey operation already exists and cannot be executed again", ) } - if planned.State != Planned { - return planned - } - if !execute { - return planned - } executed := normalizeOutcome(request.OperationID, handler.Execute(ctx, request, runtime)) return e.persistWithSave(ctx, runtime, definition, request, initial, executed) @@ -105,17 +105,14 @@ func (e Engine) reserveInitial( ) (operations.Record, bool, error) { now := runtime.Now() record := operations.Record{ - SchemaVersion: 1, - OperationID: outcome.OperationID, - JourneyID: definition.ID, - PackID: definition.Product, - ManifestHash: request.ManifestHash, - State: string(outcome.State), - SafeReferences: extractSafeReferences( - outcome.SafeData, - runtime.SensitiveKeys, - ), - UpdatedAt: now, + SchemaVersion: 1, + OperationID: outcome.OperationID, + JourneyID: definition.ID, + PackID: definition.Product, + ManifestHash: request.ManifestHash, + State: string(outcome.State), + SafeReferences: extractSafeReferences(outcome.SafeData, runtime.SensitiveKeys), + UpdatedAt: now, } record.StartedAt = record.UpdatedAt ok, err := e.Store.Reserve(ctx, record) @@ -125,6 +122,17 @@ func (e Engine) reserveInitial( return record, ok, nil } +func mergeSafeReferences(previous, current map[string]string) map[string]string { + merged := make(map[string]string, len(previous)+len(current)) + for key, value := range previous { + merged[key] = value + } + for key, value := range current { + merged[key] = value + } + return merged +} + func (e Engine) persistWithSave( ctx context.Context, runtime Runtime, @@ -158,9 +166,9 @@ func (e Engine) recordForOutcome( PackID: definition.Product, ManifestHash: request.ManifestHash, State: string(outcome.State), - SafeReferences: extractSafeReferences( - outcome.SafeData, - runtime.SensitiveKeys, + SafeReferences: mergeSafeReferences( + previous.SafeReferences, + extractSafeReferences(outcome.SafeData, runtime.SensitiveKeys), ), StartedAt: previous.StartedAt, UpdatedAt: runtime.Now(), diff --git a/internal/journey/engine_test.go b/internal/journey/engine_test.go index 4af1dd5..a4ee370 100644 --- a/internal/journey/engine_test.go +++ b/internal/journey/engine_test.go @@ -2,6 +2,8 @@ package journey_test import ( "context" + "os" + "path/filepath" "testing" "time" @@ -30,10 +32,45 @@ func TestEngineRunPlansWithoutExecutingWhenExecutionDisabled(t *testing.T) { if handler.executeCalls != 0 { t.Fatalf("execute calls = %d, want 0", handler.executeCalls) } + operationsDir := filepath.Join(engine.Store.(operations.Store).ProjectDir, ".midtrans", "operations") + _, err := os.Stat(operationsDir) + if !os.IsNotExist(err) { + t.Fatalf("operations dir exists after plan-only run: %v", err) + } +} + +func TestEnginePlanThenExecuteUsesSameOperationIDWithoutConflict(t *testing.T) { + handler := &fakeHandler{ + definition: testDefinition(), + plan: journey.Outcome{ + State: journey.Planned, + SafeData: map[string]any{"order_id": "order-001", "gross_amount": "10000"}, + }, + execute: journey.Outcome{ + State: journey.AwaitingUserAction, + Action: &journey.Action{ + Type: "browser", + Instructions: "complete the hosted checkout", + ResumeCommand: "midtrans agent resume --operation op_test", + }, + SafeData: map[string]any{"order_id": "order-001"}, + }, + } + engine := testEngine(t) + + planned := engine.Run(context.Background(), handler, testRequest("op_test"), false) + executed := engine.Run(context.Background(), handler, testRequest("op_test"), true) + + if planned.State != journey.Planned || executed.State != journey.AwaitingUserAction { + t.Fatalf("planned = %#v executed = %#v", planned, executed) + } record, found, err := engine.Store.Load(context.Background(), "op_test") - if err != nil || !found || record.State != string(journey.Planned) { + if err != nil || !found { t.Fatalf("record = %#v, found = %v, err = %v", record, found, err) } + if record.SafeReferences["order_id"] != "order-001" || record.SafeReferences["gross_amount"] != "10000" { + t.Fatalf("safe references = %#v", record.SafeReferences) + } } func TestEngineRunDoesNotExecuteWhenPlanIsNotPlanned(t *testing.T) { @@ -65,10 +102,14 @@ func TestEnginePersistsAwaitingActionAndResumesSameOperation(t *testing.T) { ResumeCommand: "midtrans agent resume --operation op_test", }, SafeData: map[string]any{ - "order_id": "order-001", + "order_id": "order-001", + "gross_amount": "10000", }, }, - resume: journey.Outcome{State: journey.Passed}, + resume: journey.Outcome{ + State: journey.Passed, + SafeData: map[string]any{"order_id": "order-001"}, + }, } engine := testEngine(t) @@ -83,6 +124,9 @@ func TestEnginePersistsAwaitingActionAndResumesSameOperation(t *testing.T) { if record.State != string(journey.AwaitingUserAction) { t.Fatalf("record state = %q, want %q", record.State, journey.AwaitingUserAction) } + if record.SafeReferences["gross_amount"] != "10000" { + t.Fatalf("safe references = %#v", record.SafeReferences) + } second := engine.Resume(context.Background(), handler, "op_test", testRequest("op_test")) if first.State != journey.AwaitingUserAction { t.Fatalf("first state = %q, want %q", first.State, journey.AwaitingUserAction) @@ -90,6 +134,10 @@ func TestEnginePersistsAwaitingActionAndResumesSameOperation(t *testing.T) { if second.OperationID != first.OperationID { t.Fatalf("resume operation = %q, want %q", second.OperationID, first.OperationID) } + record, found, err = engine.Store.Load(context.Background(), "op_test") + if err != nil || !found || record.SafeReferences["gross_amount"] != "10000" { + t.Fatalf("resumed record = %#v, found = %v, err = %v", record, found, err) + } } func TestEngineResumeRejectsDifferentManifestHashOrJourney(t *testing.T) { @@ -221,7 +269,9 @@ func TestEngineRunRejectsExistingBindingForBlockedPlanWithoutOverwrite(t *testin if outcome.State != journey.Blocked { t.Fatalf("state = %q, want %q", outcome.State, journey.Blocked) } - requireFindingCode(t, outcome.Finding, "JOURNEY_OPERATION_CONFLICT") + if outcome.Finding != nil { + t.Fatalf("unexpected finding = %#v", outcome.Finding) + } if handler.executeCalls != 0 { t.Fatalf("execute calls = %d, want 0", handler.executeCalls) } @@ -248,13 +298,10 @@ func TestEngineFiltersCoreSensitiveReferencesEvenWithoutPackKeys(t *testing.T) { } _ = engine.Run(context.Background(), handler, testRequest("op_test"), false) - - record, found, err := engine.Store.Load(context.Background(), "op_test") - if err != nil || !found { - t.Fatalf("record missing: %#v, %v, %v", record, found, err) - } - if len(record.SafeReferences) != 1 || record.SafeReferences["order_id"] != "order-001" { - t.Fatalf("safe references = %#v", record.SafeReferences) + operationsDir := filepath.Join(engine.Store.(operations.Store).ProjectDir, ".midtrans", "operations") + _, err := os.Stat(operationsDir) + if !os.IsNotExist(err) { + t.Fatalf("operations dir exists after plan-only run: %v", err) } } diff --git a/packs/snap/journey.go b/packs/snap/journey.go index 41d0119..fa03eac 100644 --- a/packs/snap/journey.go +++ b/packs/snap/journey.go @@ -280,6 +280,9 @@ func (h *compatibilityHandler) executeOrResume( record operations.Record, ) genericjourney.Outcome { runner, input := h.runnerAndInput(request, runtime) + if input.OrderID == "" { + input.OrderID = record.SafeReferences["order_id"] + } if input.GrossAmount == 0 && record.SafeReferences["gross_amount"] != "" { if amount, err := strconv.ParseInt(record.SafeReferences["gross_amount"], 10, 64); err == nil { input.GrossAmount = amount @@ -376,7 +379,8 @@ func (h *compatibilityHandler) executeOrResume( return genericjourney.Outcome{ State: genericjourney.AwaitingUserAction, SafeData: map[string]any{ - "order_id": input.OrderID, + "order_id": input.OrderID, + "gross_amount": strconv.FormatInt(input.GrossAmount, 10), }, Action: &genericjourney.Action{ Type: "browser", @@ -461,7 +465,8 @@ func (h *compatibilityHandler) evaluateStatus( return genericjourney.Outcome{ State: genericjourney.Passed, SafeData: map[string]any{ - "order_id": h.input.OrderID, + "order_id": input.OrderID, + "gross_amount": strconv.FormatInt(input.GrossAmount, 10), }, } } From 17133fa1607dff2d0ede8ba6baaf02dc226b1f91 Mon Sep 17 00:00:00 2001 From: "m.salis" Date: Mon, 27 Jul 2026 09:40:21 +0700 Subject: [PATCH 33/73] fix: harden generic journey result envelopes --- .../task-4-report.md | 25 +++++ internal/app/commands_checkout.go | 51 +++++++++- internal/app/commands_test.go | 99 +++++++++++++++++++ internal/app/journey_runner.go | 19 +++- 4 files changed, 186 insertions(+), 8 deletions(-) diff --git a/.superpowers/sdd/2026-07-26-midtrans-cli-multi-product-parity/task-4-report.md b/.superpowers/sdd/2026-07-26-midtrans-cli-multi-product-parity/task-4-report.md index 8b207db..9f6d332 100644 --- a/.superpowers/sdd/2026-07-26-midtrans-cli-multi-product-parity/task-4-report.md +++ b/.superpowers/sdd/2026-07-26-midtrans-cli-multi-product-parity/task-4-report.md @@ -54,3 +54,28 @@ Self-review: - Verified plan-only engine runs leave `.midtrans/operations` absent and no longer create conflicts for a later execute with the same derived operation ID. - Verified resume retains `gross_amount` and `order_id` across persisted awaiting-action records and advances to verified reconciliation in the Snap path without any token persistence. - Verified merchant intent routing now stops on an unsupported manifest route and returns `JOURNEY_AMBIGUOUS` when multiple configured candidates exist without a manifest route. + +## Review Fix Round 2 + +Date: +- 2026-07-27 + +Status: +- done + +Changes: +- Changed merchant generic `midtrans test ` command naming to use an intent-derived stable result identity via `test.` while preserving `test.checkout` for the hidden checkout compatibility alias. +- Changed `listEnabledJourneys` so its next action always points at the primary `midtrans test ` surface instead of the hidden checkout alias. +- Changed `genericJourneyResult` so handler `SafeData` cannot overwrite reserved envelope fields such as `product`, `journey`, `operation_id`, `state`, `proofs`, `missing_evidence`, or `action`. + +Exact tests and results: +- `go test ./internal/app -run 'TestMerchantGenericIntentUsesGenericCommandIdentityAndListingNextAction|TestGenericJourneyResultPreservesReservedEnvelopeFieldsAgainstMaliciousSafeData' -count=1` + - result: pass +- `go test ./internal/app ./internal/presentation ./internal/packs -count=1` + - result: pass +- `go test ./... -count=1` + - result: pass + +Self-review: +- Verified a non-checkout fake intent now reports `test.refund_status` and the top-level `test` listing points at `midtrans test refund-status`, not the hidden checkout alias. +- Verified malicious handler `SafeData` can still expose additive safe fields but cannot clobber the core generic journey envelope. diff --git a/internal/app/commands_checkout.go b/internal/app/commands_checkout.go index 83616a4..0cf55e8 100644 --- a/internal/app/commands_checkout.go +++ b/internal/app/commands_checkout.go @@ -111,8 +111,9 @@ func runMerchantJourney( if providerOnly { orderID = deps.NewOrderID() } + commandName := request.commandNameForIntent(intent) journeyRequest := journeyRunRequest{ - Command: request.commandName(), + Command: commandName, ProjectDir: flags.projectDir, Intent: intent, Product: request.product, @@ -132,7 +133,7 @@ func runMerchantJourney( return preview } if err := writeCheckoutPreview(deps, preview); err != nil { - failure := contracts.NewResult(request.commandName(), contracts.StatusError) + failure := contracts.NewResult(commandName, contracts.StatusError) failure.CLIVersion = deps.Version.Version failure.Findings = []contracts.Finding{{ Code: "JOURNEY_RENDER_FAILED", Severity: "blocking", @@ -194,16 +195,24 @@ func listEnabledJourneys(projectDir string, deps Dependencies) contracts.Result return *invalid } enabled := make([]string, 0, len(value.Routing)) + nextIntent := "" for intent, product := range value.Routing { enabled = append(enabled, product+"."+intent) + if nextIntent == "" { + nextIntent = intent + } } result := contracts.NewResult("test", contracts.StatusPass) result.CLIVersion = deps.Version.Version result.ManifestVersion = value.SchemaVersion result.Data = map[string]any{"enabled_journeys": enabled} + description := "midtrans test" + if nextIntent != "" { + description = "midtrans test " + nextIntent + " --amount 10000" + } result.NextActions = []contracts.NextAction{{ - Action: "run_checkout_test", - Description: "midtrans test checkout --amount 10000", + Action: "run_primary_test_intent", + Description: description, }} return result } @@ -234,7 +243,39 @@ func (f *merchantJourneyFlags) commandName() string { if f.command == "test.checkout" { return "test.checkout" } - return "test." + "checkout" + return "test" +} + +func (f *merchantJourneyFlags) commandNameForIntent(intent string) string { + if f.command == "test.checkout" { + return f.command + } + return "test." + normalizeIntentCommand(intent) +} + +func normalizeIntentCommand(intent string) string { + var normalized strings.Builder + for _, character := range intent { + switch { + case character >= 'a' && character <= 'z': + normalized.WriteRune(character) + case character >= 'A' && character <= 'Z': + normalized.WriteRune(character + ('a' - 'A')) + case character >= '0' && character <= '9': + normalized.WriteRune(character) + default: + normalized.WriteByte('_') + } + } + value := normalized.String() + value = strings.Trim(value, "_") + for strings.Contains(value, "__") { + value = strings.ReplaceAll(value, "__", "_") + } + if value == "" { + return "intent" + } + return value } func isCheckoutPlan(result contracts.Result) bool { diff --git a/internal/app/commands_test.go b/internal/app/commands_test.go index 8f5d9e3..d5b44fa 100644 --- a/internal/app/commands_test.go +++ b/internal/app/commands_test.go @@ -150,6 +150,105 @@ func TestMerchantIntentRoutingReturnsAmbiguousWithoutManifestRoute(t *testing.T) } } +func TestMerchantGenericIntentUsesGenericCommandIdentityAndListingNextAction(t *testing.T) { + project := createJourneyProject(t, "http://127.0.0.1:1") + configureManifest(t, project, func(value *manifest.Manifest) { + value.Integrations["alt"] = manifest.Integration{ + ConfigVersion: 1, + Credentials: "classic", + } + delete(value.Routing, "checkout") + value.Routing["refund-status"] = "alt" + }) + registry, err := packs.NewRegistry(common.New(), snap.New(), staticTestPack{ + descriptor: packs.Descriptor{ID: "alt", Version: "test", Journeys: []string{"alt.refund-status"}}, + handlers: []journey.Handler{staticTestHandler{ + definition: journey.Definition{ID: "alt.refund-status", Product: "alt", Intent: "refund-status"}, + plan: journey.Outcome{ + State: journey.Planned, + SafeData: map[string]any{"merchant_reference": "refund-001"}, + }, + }}, + }) + if err != nil { + t.Fatal(err) + } + deps := app.Dependencies{Version: version.Info{Version: "0.1.0-test"}, Packs: registry} + + listed, exit := executeJSONWithDependencies(t, deps, "test", "--project-dir", project) + if exit != 0 || listed.Command != "test" { + t.Fatalf("list exit = %d, result = %#v", exit, listed) + } + if len(listed.NextActions) != 1 || !strings.Contains(listed.NextActions[0].Description, "midtrans test refund-status") { + t.Fatalf("next actions = %#v", listed.NextActions) + } + + result, exit := executeJSONWithDependencies( + t, deps, + "test", "refund-status", "--amount", "10000", "--project-dir", project, + ) + if exit != 3 || result.Command != "test.refund_status" { + t.Fatalf("exit = %d, result = %#v", exit, result) + } + if len(result.Findings) != 1 || result.Findings[0].Code != "CAPABILITY_UNAVAILABLE" { + t.Fatalf("findings = %#v", result.Findings) + } +} + +func TestGenericJourneyResultPreservesReservedEnvelopeFieldsAgainstMaliciousSafeData(t *testing.T) { + project := createJourneyProject(t, "http://127.0.0.1:1") + configureManifest(t, project, func(value *manifest.Manifest) { + value.Integrations["evil"] = manifest.Integration{ + ConfigVersion: 1, + Credentials: "classic", + } + delete(value.Routing, "checkout") + value.Routing["capture-review"] = "evil" + }) + registry, err := packs.NewRegistry(common.New(), snap.New(), staticTestPack{ + descriptor: packs.Descriptor{ID: "evil", Version: "test", Journeys: []string{"evil.capture-review"}}, + handlers: []journey.Handler{staticTestHandler{ + definition: journey.Definition{ID: "evil.capture-review", Product: "evil", Intent: "capture-review"}, + plan: journey.Outcome{ + State: journey.Planned, + SafeData: map[string]any{ + "product": "hijack", + "journey": "hijack.journey", + "operation_id": "op_hijack", + "state": "verified", + "proofs": "evil", + "missing_evidence": "evil", + "merchant_note": "safe note", + }, + }, + }}, + }) + if err != nil { + t.Fatal(err) + } + result, exit := executeJSONWithDependencies( + t, + app.Dependencies{Version: version.Info{Version: "0.1.0-test"}, Packs: registry}, + "agent", "plan", "--journey", "evil.capture-review", "--amount", "10000", "--project-dir", project, + ) + if exit != 3 || result.Command != "agent.plan" { + t.Fatalf("exit = %d, result = %#v", exit, result) + } + data := requireJourneyData(t, result) + if data["product"] != "evil" || data["journey"] != "evil.capture-review" || data["state"] != "planned" { + t.Fatalf("reserved fields overwritten: %#v", data) + } + if data["merchant_note"] != "safe note" { + t.Fatalf("safe data missing: %#v", data) + } + if data["operation_id"] == "op_hijack" { + t.Fatalf("operation_id overwritten: %#v", data) + } + if _, ok := data["proofs"].(string); ok { + t.Fatalf("proofs overwritten: %#v", data) + } +} + func TestAgentResumePreservesSafeInputAcrossAwaitingAction(t *testing.T) { merchant := &appMerchantState{ orderID: "snap-fixture-001", diff --git a/internal/app/journey_runner.go b/internal/app/journey_runner.go index f9d169a..b612606 100644 --- a/internal/app/journey_runner.go +++ b/internal/app/journey_runner.go @@ -202,7 +202,7 @@ func genericJourneyResult( if outcome.Finding != nil { result.Findings = []contracts.Finding{*outcome.Finding} } - result.Data = map[string]any{ + data := map[string]any{ "product": definition.Product, "journey": definition.ID, "operation_id": outcome.OperationID, @@ -210,15 +210,28 @@ func genericJourneyResult( "proofs": outcome.Proofs, "missing_evidence": outcome.MissingEvidence, } + result.Data = data for key, value := range outcome.SafeData { - result.Data.(map[string]any)[key] = value + if isReservedJourneyEnvelopeKey(key) { + continue + } + data[key] = value } if outcome.Action != nil { - result.Data.(map[string]any)["action"] = outcome.Action + data["action"] = outcome.Action } return result } +func isReservedJourneyEnvelopeKey(key string) bool { + switch key { + case "product", "journey", "operation_id", "state", "proofs", "missing_evidence", "action": + return true + default: + return false + } +} + func genericJourneyState(state journeypkg.State) string { switch state { case journeypkg.AwaitingUserAction: From d37598f9d3f94a338fb8f217ce10f1196e1df2db Mon Sep 17 00:00:00 2001 From: "m.salis" Date: Mon, 27 Jul 2026 09:43:32 +0700 Subject: [PATCH 34/73] fix: preserve merchant intent command identities --- .../task-4-report.md | 24 ++++++++++++++ internal/app/commands_checkout.go | 4 +-- internal/app/commands_test.go | 33 +++++++++++++++++++ 3 files changed, 59 insertions(+), 2 deletions(-) diff --git a/.superpowers/sdd/2026-07-26-midtrans-cli-multi-product-parity/task-4-report.md b/.superpowers/sdd/2026-07-26-midtrans-cli-multi-product-parity/task-4-report.md index 9f6d332..3374b85 100644 --- a/.superpowers/sdd/2026-07-26-midtrans-cli-multi-product-parity/task-4-report.md +++ b/.superpowers/sdd/2026-07-26-midtrans-cli-multi-product-parity/task-4-report.md @@ -79,3 +79,27 @@ Exact tests and results: Self-review: - Verified a non-checkout fake intent now reports `test.refund_status` and the top-level `test` listing points at `midtrans test refund-status`, not the hidden checkout alias. - Verified malicious handler `SafeData` can still expose additive safe fields but cannot clobber the core generic journey envelope. + +## Review Fix Round 3 + +Date: +- 2026-07-27 + +Status: +- done + +Changes: +- Changed `runMerchantJourney` to derive the intent-specific command identity before amount/input validation, so every early return for generic merchant intents uses `test.`. +- Preserved `test.checkout` only for the hidden legacy checkout alias path. + +Exact tests and results: +- `go test ./internal/app -run 'TestMerchantGenericIntentInvalidAmountUsesIntentDerivedCommandIdentity' -count=1` + - result: pass +- `go test ./internal/app -count=1` + - result: pass +- `go test ./... -count=1` + - result: pass + +Self-review: +- Verified `midtrans test refund-status` with missing amount now emits `command: test.refund_status` instead of `test` on the validation failure path. +- Verified the hidden checkout alias still preserves `test.checkout` identity when that alias is the invoked surface. diff --git a/internal/app/commands_checkout.go b/internal/app/commands_checkout.go index 0cf55e8..eb16a68 100644 --- a/internal/app/commands_checkout.go +++ b/internal/app/commands_checkout.go @@ -97,8 +97,9 @@ func runMerchantJourney( request *merchantJourneyFlags, intent string, ) contracts.Result { + commandName := request.commandNameForIntent(intent) if request.amount <= 0 { - result := contracts.NewResult(request.commandName(), contracts.StatusBlocked) + result := contracts.NewResult(commandName, contracts.StatusBlocked) result.CLIVersion = deps.Version.Version result.Findings = []contracts.Finding{{ Code: "JOURNEY_INPUT_REQUIRED", Severity: "blocking", @@ -111,7 +112,6 @@ func runMerchantJourney( if providerOnly { orderID = deps.NewOrderID() } - commandName := request.commandNameForIntent(intent) journeyRequest := journeyRunRequest{ Command: commandName, ProjectDir: flags.projectDir, diff --git a/internal/app/commands_test.go b/internal/app/commands_test.go index d5b44fa..055295e 100644 --- a/internal/app/commands_test.go +++ b/internal/app/commands_test.go @@ -195,6 +195,39 @@ func TestMerchantGenericIntentUsesGenericCommandIdentityAndListingNextAction(t * } } +func TestMerchantGenericIntentInvalidAmountUsesIntentDerivedCommandIdentity(t *testing.T) { + project := createJourneyProject(t, "http://127.0.0.1:1") + configureManifest(t, project, func(value *manifest.Manifest) { + value.Integrations["alt"] = manifest.Integration{ + ConfigVersion: 1, + Credentials: "classic", + } + delete(value.Routing, "checkout") + value.Routing["refund-status"] = "alt" + }) + registry, err := packs.NewRegistry(common.New(), snap.New(), staticTestPack{ + descriptor: packs.Descriptor{ID: "alt", Version: "test", Journeys: []string{"alt.refund-status"}}, + handlers: []journey.Handler{staticTestHandler{ + definition: journey.Definition{ID: "alt.refund-status", Product: "alt", Intent: "refund-status"}, + plan: journey.Outcome{State: journey.Planned}, + }}, + }) + if err != nil { + t.Fatal(err) + } + result, exit := executeJSONWithDependencies( + t, + app.Dependencies{Version: version.Info{Version: "0.1.0-test"}, Packs: registry}, + "test", "refund-status", "--project-dir", project, + ) + if exit != 3 || result.Command != "test.refund_status" { + t.Fatalf("exit = %d, result = %#v", exit, result) + } + if len(result.Findings) != 1 || result.Findings[0].Code != "JOURNEY_INPUT_REQUIRED" { + t.Fatalf("findings = %#v", result.Findings) + } +} + func TestGenericJourneyResultPreservesReservedEnvelopeFieldsAgainstMaliciousSafeData(t *testing.T) { project := createJourneyProject(t, "http://127.0.0.1:1") configureManifest(t, project, func(value *manifest.Manifest) { From 76ffb243994c3b89640f02ff4276135d691aaf73 Mon Sep 17 00:00:00 2001 From: "m.salis" Date: Mon, 27 Jul 2026 09:53:50 +0700 Subject: [PATCH 35/73] feat: deliver Snap web and mobile journeys --- .../task-5-report.md | 35 +++ contracts/capabilities-v1.json | 4 +- contracts/public-sources-v1.json | 54 ++++- internal/app/app_test.go | 12 +- internal/inspection/detectors.go | 25 +++ internal/packs/registry_test.go | 4 +- packs/snap/journey.go | 27 +-- packs/snap/mobile.go | 210 ++++++++++++++++++ packs/snap/mobile_test.go | 82 +++++++ packs/snap/pack.go | 40 +++- packs/snap/pack_test.go | 43 +++- 11 files changed, 482 insertions(+), 54 deletions(-) create mode 100644 .superpowers/sdd/2026-07-26-midtrans-cli-multi-product-parity/task-5-report.md create mode 100644 packs/snap/mobile.go create mode 100644 packs/snap/mobile_test.go diff --git a/.superpowers/sdd/2026-07-26-midtrans-cli-multi-product-parity/task-5-report.md b/.superpowers/sdd/2026-07-26-midtrans-cli-multi-product-parity/task-5-report.md new file mode 100644 index 0000000..8535f5d --- /dev/null +++ b/.superpowers/sdd/2026-07-26-midtrans-cli-multi-product-parity/task-5-report.md @@ -0,0 +1,35 @@ +# Task 5 Report + +## Status + +Completed. + +## What Changed + +- Added the published Snap mobile capability and `snap.mobile-webview` journey. +- Expanded Snap descriptor/profile validation to accept `web-redirect`, `web-popup`, `web-embed`, and `mobile-webview`, and to require `integrations.snap.callbacks.return` for mobile WebView profiles. +- Added a dedicated mobile handler that: + - inspects the project for backend-only server-key usage and mobile WebView/deeplink readiness markers, + - requires provider status plus merchant notification/persistence proof, + - records missing real-device completion as externally blocked instead of reporting end-to-end mobile success. +- Preserved classic Snap sandbox hosts and request semantics: + - `POST https://app.sandbox.midtrans.com/snap/v1/transactions` + - `GET https://api.sandbox.midtrans.com/v2/{order_id}/status` +- Updated published capability/source contracts and refreshed source hashes from current Midtrans public docs on July 27, 2026. +- Updated runtime contract tests for the widened Snap capability/journey surface. + +## Tests + +- `go test ./packs/snap -run 'TestJourneyHandler|TestMobile' -count=1` +- `go test ./packs/snap -count=1` +- `go test ./internal/app ./test/e2e -count=1` +- `go test ./... -count=1` + +## Self-Review Notes + +- The mobile readiness inspection is intentionally conservative: without explicit real-device proof markers, the handler stays blocked. +- Mobile repo detection remains heuristic-based via inspection facts and mobile-like paths; this is enough for deterministic local readiness vs external-proof separation, but not a substitute for device-lab evidence. + +## Commit + +Pending local commit creation after final verification. diff --git a/contracts/capabilities-v1.json b/contracts/capabilities-v1.json index e4a3f42..894b544 100644 --- a/contracts/capabilities-v1.json +++ b/contracts/capabilities-v1.json @@ -18,10 +18,12 @@ "capabilities": [ "snap.plan.v1", "snap.webhook.verify.v1", - "snap.checkout.verify.v1" + "snap.checkout.verify.v1", + "snap.mobile.verify.v1" ], "journeys": [ "snap.checkout", + "snap.mobile-webview", "common.webhook-idempotency", "common.status-reconciliation" ] diff --git a/contracts/public-sources-v1.json b/contracts/public-sources-v1.json index 59dcff9..d2c08e2 100644 --- a/contracts/public-sources-v1.json +++ b/contracts/public-sources-v1.json @@ -1,15 +1,45 @@ { "schema_version": 1, "sources": [ + { + "id": "backend-integration", + "url": "https://docs.midtrans.com/reference/backend-integration", + "rules": [ + "snap.token.create", + "snap.basic-auth" + ], + "sha256": "d340aa9291d6c8e5764b10b3848943327a47ba9777b5b3604177260c5f2c0195", + "retrieved_at": "2026-07-27T02:51:44Z" + }, + { + "id": "snap-js", + "url": "https://docs.midtrans.com/reference/snap-js", + "rules": [ + "snap.checkout.popup", + "snap.checkout.embed" + ], + "sha256": "a21df2e39783f2c9a2cf9e9ec9811d91233fb6dbe36b94230bad82ca922a770b", + "retrieved_at": "2026-07-27T02:51:44Z" + }, { "id": "snap-integration", "url": "https://docs.midtrans.com/docs/snap-snap-integration-guide", "rules": [ - "snap.token.create", - "snap.checkout.redirect" + "snap.checkout.redirect", + "snap.mobile.webview" + ], + "sha256": "2b8c18a4a8342c891f38ee8e4dfc6c17841d5eb83e30996bf33fc7eca1681845", + "retrieved_at": "2026-07-27T02:51:44Z" + }, + { + "id": "technical-faq", + "url": "https://docs.midtrans.com/docs/technical-faq", + "rules": [ + "snap.mobile.deeplink-return", + "snap.mobile.real-device-proof" ], - "sha256": "58b0ea268dc04594abbdf609d6012f38d57f277447bfc9f7a523913df239d022", - "retrieved_at": "2026-07-24T16:11:32.099663Z" + "sha256": "37f5eef5db1506d70a92ff1ec05a0b08d760a01e6651c41ffddb7be6527c985a", + "retrieved_at": "2026-07-27T02:51:44Z" }, { "id": "http-notifications", @@ -18,18 +48,18 @@ "snap.notification.signature", "common.webhook-idempotency" ], - "sha256": "00c2adc7cf4db97bf574cdd0664fad32849cc7151ed4d6b937b96d215a956f6d", - "retrieved_at": "2026-07-24T16:11:32.099663Z" + "sha256": "db525e80a04197e57a154036e8a6e1c3010ee595298039036860f593f1e32dea", + "retrieved_at": "2026-07-27T02:51:44Z" }, { - "id": "api-authorization", - "url": "https://docs.midtrans.com/docs/api-authorization-headers", + "id": "get-transaction-status", + "url": "https://docs.midtrans.com/reference/get-transaction-status", "rules": [ - "snap.basic-auth", - "snap.status.reconcile" + "snap.status.reconcile", + "snap.mobile.status.reconcile" ], - "sha256": "b63e48e364a8aeee7623b4904eeab7dbfa1416443317e94fa31572e70b408ef1", - "retrieved_at": "2026-07-24T16:11:32.099663Z" + "sha256": "ecfa6d08bc177cf3928ee14c87604cbe61e969446e20ffa4ba5ae468bef7c9ad", + "retrieved_at": "2026-07-27T02:51:44Z" } ] } diff --git a/internal/app/app_test.go b/internal/app/app_test.go index 5e104d2..b6b38c3 100644 --- a/internal/app/app_test.go +++ b/internal/app/app_test.go @@ -46,9 +46,9 @@ func TestCapabilitiesJSON(t *testing.T) { if result.CLIVersion != "0.1.0-test" { t.Fatalf("cli version = %q", result.CLIVersion) } - if len(result.Capabilities) != 4 || + if len(result.Capabilities) != 5 || result.Capabilities[0].ID != "common.capabilities.v1" || - result.Capabilities[3].ID != "snap.webhook.verify.v1" { + result.Capabilities[4].ID != "snap.webhook.verify.v1" { t.Fatalf("unexpected capabilities: %#v", result.Capabilities) } if len(result.Packs) != 2 || @@ -56,7 +56,7 @@ func TestCapabilitiesJSON(t *testing.T) { result.Packs[1].ID != "snap" { t.Fatalf("unexpected packs: %#v", result.Packs) } - if len(result.Journeys) != 3 || result.Journeys[2] != "snap.checkout" { + if len(result.Journeys) != 4 || result.Journeys[2] != "snap.checkout" || result.Journeys[3] != "snap.mobile-webview" { t.Fatalf("unexpected journeys: %#v", result.Journeys) } } @@ -78,8 +78,8 @@ func TestAgentCapabilitiesPreservesCapabilityContract(t *testing.T) { ) if exit != 0 || result.SchemaVersion != "1.0" || - len(result.Capabilities) != 4 || - len(result.Journeys) != 3 { + len(result.Capabilities) != 5 || + len(result.Journeys) != 4 { t.Fatalf("exit = %d, result = %#v", exit, result) } } @@ -1061,7 +1061,7 @@ func TestPackInfoSnapReturnsPublicRedactionSafeDescriptor(t *testing.T) { t.Fatalf("descriptor data = %#v", result.Data) } sources, ok := data["sources"].([]any) - if !ok || len(sources) != 3 { + if !ok || len(sources) != 6 { t.Fatalf("sources = %#v", data["sources"]) } for _, rawSource := range sources { diff --git a/internal/inspection/detectors.go b/internal/inspection/detectors.go index 282c260..c5d3e9b 100644 --- a/internal/inspection/detectors.go +++ b/internal/inspection/detectors.go @@ -90,5 +90,30 @@ func detectLine(path string, line int, text string) []Fact { Line: line, }) } + if strings.Contains(lower, "midtrans") && strings.Contains(lower, "webview") { + facts = append(facts, Fact{ + Kind: "midtrans.mobile-webview-handler", + Path: path, + Line: line, + }) + } + if strings.Contains(lower, "midtrans") && + (strings.Contains(lower, "deeplink") || + strings.Contains(lower, "universal link") || + strings.Contains(lower, "app scheme")) { + facts = append(facts, Fact{ + Kind: "midtrans.mobile-return", + Path: path, + Line: line, + }) + } + if strings.Contains(lower, "midtrans") && + strings.Contains(lower, "real device") { + facts = append(facts, Fact{ + Kind: "midtrans.mobile-real-device-proof", + Path: path, + Line: line, + }) + } return facts } diff --git a/internal/packs/registry_test.go b/internal/packs/registry_test.go index ca3ebcb..dfdc3fc 100644 --- a/internal/packs/registry_test.go +++ b/internal/packs/registry_test.go @@ -21,7 +21,7 @@ func TestRegistryAggregatesCapabilities(t *testing.T) { t.Fatal(err) } capabilities := registry.Capabilities() - if len(capabilities) != 4 { + if len(capabilities) != 5 { t.Fatalf("capabilities = %#v", capabilities) } if _, ok := registry.Get("snap"); !ok { @@ -35,6 +35,7 @@ func TestRegistryAggregatesCapabilities(t *testing.T) { wantIDs := []string{ "common.capabilities.v1", "snap.checkout.verify.v1", + "snap.mobile.verify.v1", "snap.plan.v1", "snap.webhook.verify.v1", } @@ -93,6 +94,7 @@ func TestRegistryAggregatesDeterministicMetadata(t *testing.T) { "common.status-reconciliation", "common.webhook-idempotency", "snap.checkout", + "snap.mobile-webview", } if got := registry.Journeys(); !reflect.DeepEqual(got, wantJourneys) { t.Fatalf("journeys = %#v, want %#v", got, wantJourneys) diff --git a/packs/snap/journey.go b/packs/snap/journey.go index fa03eac..57e98e3 100644 --- a/packs/snap/journey.go +++ b/packs/snap/journey.go @@ -3,7 +3,6 @@ package snap import ( "context" "errors" - "net/http" "strconv" "time" @@ -567,7 +566,7 @@ func (h *compatibilityHandler) runnerAndInput( Local: MerchantVerifier{ Manifest: request.Manifest, ServerKey: serverKey, - HTTP: localJourneyClient(runtime.HTTP), + HTTP: localJourneyHTTPClient(runtime.HTTP), }, }, input } @@ -592,27 +591,3 @@ func serverKeyForManifest( } return valueSecret, nil } - -func localJourneyClient(doer interface { - Do(*http.Request) (*http.Response, error) -}) *http.Client { - if client, ok := doer.(*http.Client); ok { - return client - } - return &http.Client{ - Transport: snapRoundTripper{doer: doer}, - CheckRedirect: func(*http.Request, []*http.Request) error { - return http.ErrUseLastResponse - }, - } -} - -type snapRoundTripper struct { - doer interface { - Do(*http.Request) (*http.Response, error) - } -} - -func (t snapRoundTripper) RoundTrip(request *http.Request) (*http.Response, error) { - return t.doer.Do(request) -} diff --git a/packs/snap/mobile.go b/packs/snap/mobile.go new file mode 100644 index 0000000..8294d09 --- /dev/null +++ b/packs/snap/mobile.go @@ -0,0 +1,210 @@ +package snap + +import ( + "context" + "net/http" + "path/filepath" + "strings" + + "github.com/veritrans/midtrans-cli/internal/contracts" + "github.com/veritrans/midtrans-cli/internal/inspection" + genericjourney "github.com/veritrans/midtrans-cli/internal/journey" + "github.com/veritrans/midtrans-cli/internal/operations" +) + +func NewMobileHandler() genericjourney.Handler { + return mobileHandler{} +} + +type mobileHandler struct{} + +func (mobileHandler) Definition() genericjourney.Definition { + return genericjourney.Definition{ + ID: "snap.mobile-webview", + Product: "snap", + Intent: "mobile-webview", + RequiredInputs: []string{"order_id", "amount"}, + Interaction: "browser", + } +} + +func (mobileHandler) Plan( + _ context.Context, + request genericjourney.Request, + _ genericjourney.Runtime, +) genericjourney.Outcome { + return genericjourney.Outcome{ + State: genericjourney.Planned, + SafeData: map[string]any{"order_id": request.Input.OrderID}, + MissingEvidence: []string{ + "provider_status", + "merchant_callback", + "real_device_completion", + }, + } +} + +func (h mobileHandler) Execute( + ctx context.Context, + request genericjourney.Request, + runtime genericjourney.Runtime, +) genericjourney.Outcome { + return h.run(ctx, request, runtime) +} + +func (h mobileHandler) Resume( + ctx context.Context, + request genericjourney.Request, + runtime genericjourney.Runtime, + _ operations.Record, +) genericjourney.Outcome { + return h.run(ctx, request, runtime) +} + +func (mobileHandler) run( + ctx context.Context, + request genericjourney.Request, + runtime genericjourney.Runtime, +) genericjourney.Outcome { + if request.ProjectDir == "" { + return mobileBlocked("SNAP_MOBILE_PROJECT_INVALID", "mobile verification requires a project directory") + } + report, err := inspection.Inspect(request.ProjectDir) + if err != nil { + return mobileBlocked("SNAP_MOBILE_INSPECTION_FAILED", "mobile verification could not inspect the project safely") + } + + serverKeyBackend := false + serverKeyMobile := false + for _, fact := range report.Facts { + if fact.Kind != "midtrans.server-key-reference" { + continue + } + if looksLikeMobilePath(fact.Path) { + serverKeyMobile = true + continue + } + serverKeyBackend = true + } + if !serverKeyBackend { + return mobileBlocked("SNAP_SERVER_KEY_REFERENCE_NOT_FOUND", "repository inspection did not find a backend server-key reference") + } + if serverKeyMobile { + return mobileBlocked("SNAP_MOBILE_SERVER_KEY_EXPOSED", "mobile app source must not reference the Midtrans server key") + } + if !report.Has("midtrans.mobile-webview-handler") { + return mobileBlocked("SNAP_MOBILE_WEBVIEW_HANDLER_MISSING", "mobile app must implement a WebView completion handler") + } + if !report.Has("midtrans.mobile-return") { + return mobileBlocked("SNAP_MOBILE_RETURN_PROOF_MISSING", "mobile app must declare an app scheme or universal-link return path") + } + + runner, input := (&compatibilityHandler{}).runnerAndInput(request, runtime) + if input.OrderID == "" || input.GrossAmountString == "" || runner.Status == nil || runner.Local == nil { + return mobileBlocked("SANDBOX_JOURNEY_INVALID", "mobile verification input is invalid") + } + status, err := runner.Status.Status(ctx, input.OrderID) + if err != nil || status.NotFound { + return genericjourney.Outcome{ + State: genericjourney.Blocked, + SafeData: map[string]any{"order_id": input.OrderID}, + MissingEvidence: []string{ + "provider_status", + "merchant_callback", + "real_device_completion", + }, + Finding: &contracts.Finding{ + Code: "SNAP_MOBILE_PROVIDER_STATUS_REQUIRED", + Severity: "blocking", + Message: "provider completion must be reconciled by backend status or notification before mobile proof can continue", + }, + } + } + local, err := runner.Local.VerifyLocal(ctx, LocalVerificationInput{ + OrderID: input.OrderID, + GrossAmount: input.GrossAmountString, + }) + if err != nil || !local.Passed() { + return genericjourney.Outcome{ + State: genericjourney.Blocked, + SafeData: map[string]any{"order_id": input.OrderID, "transaction_status": status.TransactionStatus}, + MissingEvidence: []string{ + "merchant_callback", + "real_device_completion", + }, + Finding: &contracts.Finding{ + Code: "MERCHANT_INTEGRATION_PROOF_REQUIRED", + Severity: "blocking", + Message: "mobile verification requires notification, duplicate, and persistence proof from the merchant backend", + }, + } + } + if !report.Has("midtrans.mobile-real-device-proof") { + return genericjourney.Outcome{ + State: genericjourney.Blocked, + SafeData: map[string]any{ + "order_id": input.OrderID, + "transaction_status": status.TransactionStatus, + "payment_status": local.FinalState.PaymentStatus, + }, + MissingEvidence: []string{"real_device_completion"}, + Finding: &contracts.Finding{ + Code: "SNAP_MOBILE_REAL_DEVICE_PROOF_REQUIRED", + Severity: "blocking", + Message: "real-device mobile completion proof remains externally blocked until supplied", + }, + } + } + return genericjourney.Outcome{ + State: genericjourney.Passed, + SafeData: map[string]any{ + "order_id": input.OrderID, + "transaction_status": status.TransactionStatus, + "payment_status": local.FinalState.PaymentStatus, + }, + } +} + +func looksLikeMobilePath(path string) bool { + lower := strings.ToLower(filepath.ToSlash(path)) + return strings.Contains(lower, "/android/") || + strings.Contains(lower, "/ios/") || + strings.Contains(lower, "/mobile/") || + strings.Contains(lower, "/app/") +} + +func mobileBlocked(code string, message string) genericjourney.Outcome { + return genericjourney.Outcome{ + State: genericjourney.Blocked, + MissingEvidence: []string{"real_device_completion"}, + Finding: &contracts.Finding{ + Code: code, + Severity: "blocking", + Message: message, + }, + } +} + +func localJourneyHTTPClient(doer interface { + Do(*http.Request) (*http.Response, error) +}) *http.Client { + if client, ok := doer.(*http.Client); ok { + return client + } + return &http.Client{ + Transport: snapRoundTripper{doer: doer}, + CheckRedirect: func(*http.Request, []*http.Request) error { + return http.ErrUseLastResponse + }, + } +} + +type snapRoundTripper struct { + doer interface { + Do(*http.Request) (*http.Response, error) + } +} + +func (t snapRoundTripper) RoundTrip(request *http.Request) (*http.Response, error) { + return t.doer.Do(request) +} diff --git a/packs/snap/mobile_test.go b/packs/snap/mobile_test.go new file mode 100644 index 0000000..c374e21 --- /dev/null +++ b/packs/snap/mobile_test.go @@ -0,0 +1,82 @@ +package snap_test + +import ( + "context" + "net/http" + "os" + "path/filepath" + "testing" + + "github.com/veritrans/midtrans-cli/internal/journey" + "github.com/veritrans/midtrans-cli/internal/manifest" + "github.com/veritrans/midtrans-cli/packs/snap" +) + +func TestMobileDefinitionPublishesDedicatedJourney(t *testing.T) { + definition := snap.NewMobileHandler().Definition() + if definition.ID != "snap.mobile-webview" || + definition.Product != "snap" || + definition.Intent != "mobile-webview" || + definition.Interaction != "browser" { + t.Fatalf("definition = %#v", definition) + } +} + +func TestMobileReturnsExternallyBlockedWithoutRealDeviceProof(t *testing.T) { + project := t.TempDir() + if err := os.WriteFile( + filepath.Join(project, "backend.go"), + []byte("package backend\n// MIDTRANS_SERVER_KEY lives on backend only\n"), + 0o644, + ); err != nil { + t.Fatal(err) + } + handler := snap.NewMobileHandler() + outcome := handler.Execute(context.Background(), journey.Request{ + OperationID: "op_mobile_test", + ProjectDir: project, + ManifestHash: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + Manifest: mobileManifest(), + Input: journey.Input{ + OrderID: "mobile-order-001", + Amount: 10000, + }, + }, journey.Runtime{ + HTTP: &http.Client{}, + ResolveCredential: func(context.Context, string, string) ([]byte, error) { return []byte("SB-Mid-server-test"), nil }, + }) + + if outcome.State != journey.Blocked { + t.Fatalf("state = %q, want blocked", outcome.State) + } + if len(outcome.MissingEvidence) != 1 || outcome.MissingEvidence[0] != "real_device_completion" { + t.Fatalf("missing evidence = %#v", outcome.MissingEvidence) + } + if outcome.Finding == nil || outcome.Finding.Code != "SNAP_MOBILE_WEBVIEW_HANDLER_MISSING" { + t.Fatalf("finding = %#v", outcome.Finding) + } +} + +func mobileManifest() manifest.Manifest { + value := manifest.Default() + value.Application.BaseURL = "http://127.0.0.1:8080" + value.CredentialSets["classic"] = manifest.CredentialSet{ + Type: "classic", + Environment: "sandbox", + ServerKey: "env:MIDTRANS_SERVER_KEY", + ClientKey: "env:MIDTRANS_CLIENT_KEY", + } + value.Integrations["snap"] = manifest.Integration{ + ConfigVersion: 1, + Credentials: "classic", + Profiles: []string{"mobile-webview"}, + Callbacks: map[string]string{ + "notification": "/midtrans/notification", + "finish": "/payments/finish", + "return": "/mobile/return", + "status": "/payments/{order_id}", + }, + } + value.Routing["checkout"] = "snap" + return value +} diff --git a/packs/snap/pack.go b/packs/snap/pack.go index eb0eeca..3b5229f 100644 --- a/packs/snap/pack.go +++ b/packs/snap/pack.go @@ -22,9 +22,11 @@ func (Pack) Descriptor() packs.Descriptor { {ID: "snap.plan.v1", Description: "evaluate Snap integration requirements", Pack: "snap"}, {ID: "snap.webhook.verify.v1", Description: "verify Snap notifications", Pack: "snap"}, {ID: "snap.checkout.verify.v1", Description: "run and verify a Snap sandbox checkout", Pack: "snap"}, + {ID: "snap.mobile.verify.v1", Description: "verify Snap mobile WebView readiness and proof boundaries", Pack: "snap"}, }, Journeys: []string{ "snap.checkout", + "snap.mobile-webview", "common.webhook-idempotency", "common.status-reconciliation", }, @@ -37,10 +39,25 @@ func (Pack) Descriptor() packs.Descriptor { "token", }, Sources: []contracts.PublicSource{ + { + ID: "backend-integration", + URL: "https://docs.midtrans.com/reference/backend-integration", + Rules: []string{"snap.token.create", "snap.basic-auth"}, + }, + { + ID: "snap-js", + URL: "https://docs.midtrans.com/reference/snap-js", + Rules: []string{"snap.checkout.popup", "snap.checkout.embed"}, + }, { ID: "snap-integration", URL: "https://docs.midtrans.com/docs/snap-snap-integration-guide", - Rules: []string{"snap.token.create", "snap.checkout.redirect"}, + Rules: []string{"snap.checkout.redirect", "snap.mobile.webview"}, + }, + { + ID: "technical-faq", + URL: "https://docs.midtrans.com/docs/technical-faq", + Rules: []string{"snap.mobile.deeplink-return", "snap.mobile.real-device-proof"}, }, { ID: "http-notifications", @@ -48,9 +65,9 @@ func (Pack) Descriptor() packs.Descriptor { Rules: []string{"snap.notification.signature", "common.webhook-idempotency"}, }, { - ID: "api-authorization", - URL: "https://docs.midtrans.com/docs/api-authorization-headers", - Rules: []string{"snap.basic-auth", "snap.status.reconcile"}, + ID: "get-transaction-status", + URL: "https://docs.midtrans.com/reference/get-transaction-status", + Rules: []string{"snap.status.reconcile", "snap.mobile.status.reconcile"}, }, }, } @@ -85,10 +102,19 @@ func (Pack) Evaluate(value manifest.Manifest, report inspection.Report) []contra }) } if !slices.Contains(integration.Profiles, "web-redirect") && - !slices.Contains(integration.Profiles, "web-popup") { + !slices.Contains(integration.Profiles, "web-popup") && + !slices.Contains(integration.Profiles, "web-embed") && + !slices.Contains(integration.Profiles, "mobile-webview") { findings = append(findings, contracts.Finding{ Code: "SNAP_CHECKOUT_MODE_MISSING", Severity: "blocking", - Message: "integrations.snap.profiles must include web-redirect or web-popup", + Message: "integrations.snap.profiles must include web-redirect, web-popup, web-embed, or mobile-webview", + }) + } + if slices.Contains(integration.Profiles, "mobile-webview") && + integration.Callbacks["return"] == "" { + findings = append(findings, contracts.Finding{ + Code: "SNAP_MOBILE_RETURN_CALLBACK_MISSING", Severity: "blocking", + Message: "integrations.snap.callbacks.return is required for mobile-webview", }) } if integration.Callbacks["status"] == "" { @@ -115,5 +141,5 @@ func (Pack) Evaluate(value manifest.Manifest, report inspection.Report) []contra } func (Pack) Handlers() []journey.Handler { - return []journey.Handler{NewJourneyHandler()} + return []journey.Handler{NewJourneyHandler(), NewMobileHandler()} } diff --git a/packs/snap/pack_test.go b/packs/snap/pack_test.go index bfee23e..6424c36 100644 --- a/packs/snap/pack_test.go +++ b/packs/snap/pack_test.go @@ -26,12 +26,14 @@ func TestSnapDescriptorMatchesCompiledContract(t *testing.T) { "snap.plan.v1", "snap.webhook.verify.v1", "snap.checkout.verify.v1", + "snap.mobile.verify.v1", } if !reflect.DeepEqual(gotCapabilities, wantCapabilities) { t.Fatalf("capabilities = %#v, want %#v", gotCapabilities, wantCapabilities) } wantJourneys := []string{ "snap.checkout", + "snap.mobile-webview", "common.webhook-idempotency", "common.status-reconciliation", } @@ -48,9 +50,12 @@ func TestSnapDescriptorMatchesCompiledContract(t *testing.T) { } wantSourceURLs := []string{ + "https://docs.midtrans.com/reference/backend-integration", + "https://docs.midtrans.com/reference/snap-js", "https://docs.midtrans.com/docs/snap-snap-integration-guide", + "https://docs.midtrans.com/docs/technical-faq", "https://docs.midtrans.com/docs/https-notification-webhooks", - "https://docs.midtrans.com/docs/api-authorization-headers", + "https://docs.midtrans.com/reference/get-transaction-status", } gotSourceURLs := make([]string, 0, len(descriptor.Sources)) for _, source := range descriptor.Sources { @@ -72,6 +77,42 @@ func TestSnapRequiresNotificationRoute(t *testing.T) { } } +func TestJourneyHandlerAcceptsAllSnapWebAndMobileProfiles(t *testing.T) { + value := validSnapManifest() + value.Integrations["snap"] = manifest.Integration{ + ConfigVersion: 1, + Credentials: "classic", + Profiles: []string{"web-redirect", "web-popup", "web-embed", "mobile-webview"}, + Callbacks: map[string]string{ + "notification": "/notifications", + "finish": "/finish", + "return": "/mobile/return", + "status": "/payments/{order_id}", + }, + } + + findings := snap.New().Evaluate(value, inspection.Report{}) + if len(findings) != 0 { + t.Fatalf("findings = %#v", findings) + } +} + +func TestMobileProfileRequiresReturnCallback(t *testing.T) { + value := validSnapManifest() + integration := value.Integrations["snap"] + integration.Profiles = []string{"mobile-webview"} + delete(integration.Callbacks, "return") + value.Integrations["snap"] = integration + + findings := snap.New().Evaluate(value, inspection.Report{}) + for _, finding := range findings { + if finding.Code == "SNAP_MOBILE_RETURN_CALLBACK_MISSING" { + return + } + } + t.Fatalf("findings = %#v", findings) +} + func TestSnapEvaluationReportsRequirementsInDeterministicOrder(t *testing.T) { value := manifest.Default() value.Application.PaymentState.Monotonic = false From f4123d1c7975334e7f6386966d00f19680e23060 Mon Sep 17 00:00:00 2001 From: "m.salis" Date: Mon, 27 Jul 2026 09:54:02 +0700 Subject: [PATCH 36/73] docs: finalize task 5 report --- .../task-5-report.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.superpowers/sdd/2026-07-26-midtrans-cli-multi-product-parity/task-5-report.md b/.superpowers/sdd/2026-07-26-midtrans-cli-multi-product-parity/task-5-report.md index 8535f5d..59772c1 100644 --- a/.superpowers/sdd/2026-07-26-midtrans-cli-multi-product-parity/task-5-report.md +++ b/.superpowers/sdd/2026-07-26-midtrans-cli-multi-product-parity/task-5-report.md @@ -32,4 +32,4 @@ Completed. ## Commit -Pending local commit creation after final verification. +- `76ffb24` — `feat: deliver Snap web and mobile journeys` From 5e12b84736005a598159a7dcc44851de300ecb26 Mon Sep 17 00:00:00 2001 From: "m.salis" Date: Mon, 27 Jul 2026 10:01:50 +0700 Subject: [PATCH 37/73] fix: harden Snap mobile proof gating --- .../task-5-report.md | 25 ++ internal/inspection/detectors.go | 33 ++- internal/inspection/inspection_test.go | 54 +++++ packs/snap/mobile.go | 114 ++++++--- packs/snap/mobile_test.go | 225 +++++++++++++++++- 5 files changed, 404 insertions(+), 47 deletions(-) diff --git a/.superpowers/sdd/2026-07-26-midtrans-cli-multi-product-parity/task-5-report.md b/.superpowers/sdd/2026-07-26-midtrans-cli-multi-product-parity/task-5-report.md index 59772c1..79e8358 100644 --- a/.superpowers/sdd/2026-07-26-midtrans-cli-multi-product-parity/task-5-report.md +++ b/.superpowers/sdd/2026-07-26-midtrans-cli-multi-product-parity/task-5-report.md @@ -33,3 +33,28 @@ Completed. ## Commit - `76ffb24` — `feat: deliver Snap web and mobile journeys` + +## Round 1 Fixes + +- Tightened mobile server-key classification to fail closed: + - only clearly backend paths and server-side languages count as backend-only references, + - React Native `src/`, Expo root config, Flutter `lib/`, and other non-backend references are treated as mobile exposure. +- Added deterministic inspection fact `midtrans.snap-token-create` for backend Snap token creation using `/snap/v1/transactions` in clearly backend source. +- Required backend Snap token creation evidence in the mobile handler; a backend server-key reference alone no longer passes readiness. +- Removed text/comment-based real-device proof detection entirely. +- Changed mobile verification to remain blocked with `SNAP_MOBILE_REAL_DEVICE_PROOF_REQUIRED` even after provider and merchant proofs; repository inspection alone can no longer produce a mobile `Passed` result. + +## Round 1 Tests And Results + +- `go test ./packs/snap ./internal/inspection ./internal/app ./test/e2e -count=1` + - Result: pass +- `go test ./... -count=1` + - Result: pass + +## Round 1 Self-Review + +- The mobile handler now separates three states cleanly: + - local deterministic readiness, + - backend/provider/merchant proof, + - external real-device proof that remains blocked. +- Backend token-creation detection is still pattern-based, but it is now scoped to clearly backend source and cannot be satisfied by shared/mobile/comment text. diff --git a/internal/inspection/detectors.go b/internal/inspection/detectors.go index c5d3e9b..e0838c8 100644 --- a/internal/inspection/detectors.go +++ b/internal/inspection/detectors.go @@ -66,6 +66,15 @@ func detectLine(path string, line int, text string) []Fact { Line: line, }) } + if clearlyBackendSource(lowerPath) && + strings.Contains(lower, "/snap/v1/transactions") && + strings.Contains(lower, "midtrans") { + facts = append(facts, Fact{ + Kind: "midtrans.snap-token-create", + Path: path, + Line: line, + }) + } if strings.Contains(lower, "midtrans") && strings.Contains(lower, "notification") { facts = append(facts, Fact{ Kind: "midtrans.notification-route", @@ -107,13 +116,21 @@ func detectLine(path string, line int, text string) []Fact { Line: line, }) } - if strings.Contains(lower, "midtrans") && - strings.Contains(lower, "real device") { - facts = append(facts, Fact{ - Kind: "midtrans.mobile-real-device-proof", - Path: path, - Line: line, - }) - } return facts } + +func clearlyBackendSource(lowerPath string) bool { + return strings.HasPrefix(lowerPath, "server/") || + strings.HasPrefix(lowerPath, "backend/") || + strings.HasPrefix(lowerPath, "api/") || + strings.HasPrefix(lowerPath, "functions/") || + strings.Contains(lowerPath, "/server/") || + strings.Contains(lowerPath, "/backend/") || + strings.Contains(lowerPath, "/api/") || + strings.Contains(lowerPath, "/functions/") || + strings.HasSuffix(lowerPath, ".go") || + strings.HasSuffix(lowerPath, ".py") || + strings.HasSuffix(lowerPath, ".rb") || + strings.HasSuffix(lowerPath, ".php") || + strings.HasSuffix(lowerPath, ".java") +} diff --git a/internal/inspection/inspection_test.go b/internal/inspection/inspection_test.go index c5d889a..dc55050 100644 --- a/internal/inspection/inspection_test.go +++ b/internal/inspection/inspection_test.go @@ -246,6 +246,60 @@ func TestInspectDetectsExplicitTestLinesInNeutralFiles(t *testing.T) { } } +func TestInspectFindsBackendSnapTokenCreationOnlyFromConcreteCallPattern(t *testing.T) { + root := t.TempDir() + files := map[string]string{ + "server/checkout.go": `package server +const midtransURL = "https://app.sandbox.midtrans.com/snap/v1/transactions" +`, + "mobile/notes.txt": `midtrans docs mention /snap/v1/transactions but this is not code`, + } + for name, content := range files { + path := filepath.Join(root, name) + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, []byte(content), 0o644); err != nil { + t.Fatal(err) + } + } + + report, err := inspection.Inspect(root) + if err != nil { + t.Fatal(err) + } + var count int + for _, fact := range report.Facts { + if fact.Kind == "midtrans.snap-token-create" { + count++ + } + } + if count != 1 { + t.Fatalf("facts = %#v", report.Facts) + } +} + +func TestInspectDoesNotInventRealDeviceProofFromCommentText(t *testing.T) { + root := t.TempDir() + if err := os.WriteFile( + filepath.Join(root, "README.md"), + []byte("TODO: capture Midtrans real device proof later"), + 0o644, + ); err != nil { + t.Fatal(err) + } + + report, err := inspection.Inspect(root) + if err != nil { + t.Fatal(err) + } + for _, fact := range report.Facts { + if fact.Kind == "midtrans.mobile-real-device-proof" { + t.Fatalf("facts = %#v", report.Facts) + } + } +} + func TestInspectRejectsSymlinkProjectRoot(t *testing.T) { realRoot := t.TempDir() if err := os.WriteFile( diff --git a/packs/snap/mobile.go b/packs/snap/mobile.go index 8294d09..314cd8c 100644 --- a/packs/snap/mobile.go +++ b/packs/snap/mobile.go @@ -3,6 +3,7 @@ package snap import ( "context" "net/http" + "path" "path/filepath" "strings" @@ -16,7 +17,14 @@ func NewMobileHandler() genericjourney.Handler { return mobileHandler{} } -type mobileHandler struct{} +func NewMobileHandlerForTest(status StatusGetter, local LocalVerifier) genericjourney.Handler { + return mobileHandler{status: status, local: local} +} + +type mobileHandler struct { + status StatusGetter + local LocalVerifier +} func (mobileHandler) Definition() genericjourney.Definition { return genericjourney.Definition{ @@ -61,7 +69,7 @@ func (h mobileHandler) Resume( return h.run(ctx, request, runtime) } -func (mobileHandler) run( +func (h mobileHandler) run( ctx context.Context, request genericjourney.Request, runtime genericjourney.Runtime, @@ -76,15 +84,21 @@ func (mobileHandler) run( serverKeyBackend := false serverKeyMobile := false + tokenCreateBackend := false for _, fact := range report.Facts { - if fact.Kind != "midtrans.server-key-reference" { - continue - } - if looksLikeMobilePath(fact.Path) { - serverKeyMobile = true - continue + switch fact.Kind { + case "midtrans.server-key-reference": + switch classifyProjectPath(fact.Path) { + case projectPathBackend: + serverKeyBackend = true + default: + serverKeyMobile = true + } + case "midtrans.snap-token-create": + if classifyProjectPath(fact.Path) == projectPathBackend { + tokenCreateBackend = true + } } - serverKeyBackend = true } if !serverKeyBackend { return mobileBlocked("SNAP_SERVER_KEY_REFERENCE_NOT_FOUND", "repository inspection did not find a backend server-key reference") @@ -92,6 +106,9 @@ func (mobileHandler) run( if serverKeyMobile { return mobileBlocked("SNAP_MOBILE_SERVER_KEY_EXPOSED", "mobile app source must not reference the Midtrans server key") } + if !tokenCreateBackend { + return mobileBlocked("SNAP_MOBILE_BACKEND_TOKEN_CREATION_NOT_FOUND", "repository inspection did not find backend Snap token creation for /snap/v1/transactions") + } if !report.Has("midtrans.mobile-webview-handler") { return mobileBlocked("SNAP_MOBILE_WEBVIEW_HANDLER_MISSING", "mobile app must implement a WebView completion handler") } @@ -100,6 +117,12 @@ func (mobileHandler) run( } runner, input := (&compatibilityHandler{}).runnerAndInput(request, runtime) + if h.status != nil { + runner.Status = h.status + } + if h.local != nil { + runner.Local = h.local + } if input.OrderID == "" || input.GrossAmountString == "" || runner.Status == nil || runner.Local == nil { return mobileBlocked("SANDBOX_JOURNEY_INVALID", "mobile verification input is invalid") } @@ -139,38 +162,69 @@ func (mobileHandler) run( }, } } - if !report.Has("midtrans.mobile-real-device-proof") { - return genericjourney.Outcome{ - State: genericjourney.Blocked, - SafeData: map[string]any{ - "order_id": input.OrderID, - "transaction_status": status.TransactionStatus, - "payment_status": local.FinalState.PaymentStatus, - }, - MissingEvidence: []string{"real_device_completion"}, - Finding: &contracts.Finding{ - Code: "SNAP_MOBILE_REAL_DEVICE_PROOF_REQUIRED", - Severity: "blocking", - Message: "real-device mobile completion proof remains externally blocked until supplied", - }, - } - } return genericjourney.Outcome{ - State: genericjourney.Passed, + State: genericjourney.Blocked, SafeData: map[string]any{ "order_id": input.OrderID, "transaction_status": status.TransactionStatus, "payment_status": local.FinalState.PaymentStatus, }, + MissingEvidence: []string{"real_device_completion"}, + Finding: &contracts.Finding{ + Code: "SNAP_MOBILE_REAL_DEVICE_PROOF_REQUIRED", + Severity: "blocking", + Message: "real-device mobile completion proof remains externally blocked until supplied", + }, } } -func looksLikeMobilePath(path string) bool { - lower := strings.ToLower(filepath.ToSlash(path)) - return strings.Contains(lower, "/android/") || +type projectPathClass string + +const ( + projectPathBackend projectPathClass = "backend" + projectPathMobile projectPathClass = "mobile" + projectPathUnknown projectPathClass = "unknown" +) + +func classifyProjectPath(filePath string) projectPathClass { + lower := strings.ToLower(filepath.ToSlash(filePath)) + base := path.Base(lower) + ext := path.Ext(lower) + + if strings.HasPrefix(lower, "lib/") || + strings.HasPrefix(lower, "android/") || + strings.HasPrefix(lower, "ios/") || + strings.HasPrefix(lower, "app/") || + strings.HasPrefix(lower, "src/") || + strings.Contains(lower, "/android/") || strings.Contains(lower, "/ios/") || strings.Contains(lower, "/mobile/") || - strings.Contains(lower, "/app/") + base == "app.json" || + base == "app.config.js" || + base == "app.config.ts" || + base == "app.config.mjs" || + base == "expo.json" || + ext == ".dart" { + return projectPathMobile + } + + if strings.HasPrefix(lower, "server/") || + strings.HasPrefix(lower, "backend/") || + strings.HasPrefix(lower, "api/") || + strings.HasPrefix(lower, "functions/") || + strings.Contains(lower, "/server/") || + strings.Contains(lower, "/backend/") || + strings.Contains(lower, "/api/") || + strings.Contains(lower, "/functions/") || + ext == ".go" || + ext == ".py" || + ext == ".rb" || + ext == ".php" || + ext == ".java" { + return projectPathBackend + } + + return projectPathUnknown } func mobileBlocked(code string, message string) genericjourney.Outcome { diff --git a/packs/snap/mobile_test.go b/packs/snap/mobile_test.go index c374e21..32617ec 100644 --- a/packs/snap/mobile_test.go +++ b/packs/snap/mobile_test.go @@ -24,13 +24,18 @@ func TestMobileDefinitionPublishesDedicatedJourney(t *testing.T) { func TestMobileReturnsExternallyBlockedWithoutRealDeviceProof(t *testing.T) { project := t.TempDir() - if err := os.WriteFile( - filepath.Join(project, "backend.go"), - []byte("package backend\n// MIDTRANS_SERVER_KEY lives on backend only\n"), - 0o644, - ); err != nil { - t.Fatal(err) - } + writeMobileFixture(t, project, map[string]string{ + "server/checkout.go": `package server +const serverKey = "MIDTRANS_SERVER_KEY" +const snapURL = "https://app.sandbox.midtrans.com/snap/v1/transactions" +`, + "app/webview.tsx": `// midtrans webview handler +function openMidtransWebView() { return "midtrans webview"; } +`, + "app/return.ts": `// midtrans deeplink return handler +const scheme = "midtrans app scheme"; +`, + }) handler := snap.NewMobileHandler() outcome := handler.Execute(context.Background(), journey.Request{ OperationID: "op_mobile_test", @@ -49,14 +54,216 @@ func TestMobileReturnsExternallyBlockedWithoutRealDeviceProof(t *testing.T) { if outcome.State != journey.Blocked { t.Fatalf("state = %q, want blocked", outcome.State) } - if len(outcome.MissingEvidence) != 1 || outcome.MissingEvidence[0] != "real_device_completion" { + if len(outcome.MissingEvidence) != 3 || + outcome.MissingEvidence[0] != "provider_status" || + outcome.MissingEvidence[1] != "merchant_callback" || + outcome.MissingEvidence[2] != "real_device_completion" { t.Fatalf("missing evidence = %#v", outcome.MissingEvidence) } - if outcome.Finding == nil || outcome.Finding.Code != "SNAP_MOBILE_WEBVIEW_HANDLER_MISSING" { + if outcome.Finding == nil || outcome.Finding.Code != "SNAP_MOBILE_PROVIDER_STATUS_REQUIRED" { + t.Fatalf("finding = %#v", outcome.Finding) + } +} + +func TestMobileRequiresBackendTokenCreationEvidence(t *testing.T) { + project := t.TempDir() + writeMobileFixture(t, project, map[string]string{ + "server/config.go": `package server +const serverKey = "MIDTRANS_SERVER_KEY" +`, + "app/webview.tsx": `const value = "midtrans webview";`, + "app/return.ts": `const value = "midtrans deeplink";`, + }) + + outcome := runMobileJourney(t, project) + if outcome.Finding == nil || outcome.Finding.Code != "SNAP_MOBILE_BACKEND_TOKEN_CREATION_NOT_FOUND" { + t.Fatalf("finding = %#v", outcome.Finding) + } +} + +func TestMobileTreatsSharedAndMobileServerKeyReferencesAsExposure(t *testing.T) { + tests := []struct { + name string + path string + }{ + {name: "react native src", path: "src/config.ts"}, + {name: "expo root config", path: "app.config.ts"}, + {name: "flutter lib", path: "lib/config.dart"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + project := t.TempDir() + writeMobileFixture(t, project, map[string]string{ + "server/checkout.go": `package server +const serverKey = "MIDTRANS_SERVER_KEY" +const snapURL = "https://app.sandbox.midtrans.com/snap/v1/transactions" +`, + tt.path: `const leak = "MIDTRANS_SERVER_KEY";`, + "app/webview.tsx": `const value = "midtrans webview";`, + "app/return.ts": `const value = "midtrans universal link";`, + }) + + outcome := runMobileJourney(t, project) + if outcome.Finding == nil || outcome.Finding.Code != "SNAP_MOBILE_SERVER_KEY_EXPOSED" { + t.Fatalf("finding = %#v", outcome.Finding) + } + }) + } +} + +func TestMobileAcceptsExplicitBackendServerKeyPath(t *testing.T) { + project := t.TempDir() + writeMobileFixture(t, project, map[string]string{ + "backend/midtrans.py": `MIDTRANS_SERVER_KEY = "sandbox" +SNAP_URL = "https://app.sandbox.midtrans.com/snap/v1/transactions" +`, + "app/webview.tsx": `const value = "midtrans webview";`, + "app/return.ts": `const value = "midtrans app scheme";`, + }) + + outcome := runMobileJourney(t, project) + if outcome.Finding == nil || outcome.Finding.Code != "SNAP_MOBILE_PROVIDER_STATUS_REQUIRED" { + t.Fatalf("finding = %#v", outcome.Finding) + } +} + +func TestMobileRepositoryInspectionNeverTreatsCommentAsRealDeviceProof(t *testing.T) { + project := t.TempDir() + writeMobileFixture(t, project, map[string]string{ + "server/checkout.go": `package server +const serverKey = "MIDTRANS_SERVER_KEY" +const snapURL = "https://app.sandbox.midtrans.com/snap/v1/transactions" +`, + "app/webview.tsx": `const value = "midtrans webview";`, + "app/return.ts": `const value = "midtrans deeplink";`, + "README.md": `TODO: capture Midtrans real device proof later`, + }) + + outcome := runMobileJourneyWithRuntime(t, project, journey.Runtime{ + HTTP: &http.Client{}, + ResolveCredential: func(context.Context, string, string) ([]byte, error) { return []byte("SB-Mid-server-test"), nil }, + }, &stubStatusGetter{response: snap.StatusResponse{ + OrderID: "mobile-order-001", + TransactionStatus: "settlement", + FraudStatus: "accept", + StatusCode: "200", + }}, &stubLocalVerifier{result: snap.LocalVerificationResult{ + SettlementApplied: true, + DuplicateIdempotent: true, + LatePendingIgnored: true, + FinalState: snap.MerchantState{ + OrderID: "mobile-order-001", + PaymentStatus: "paid", + FulfillmentCount: 1, + }, + }}) + + if outcome.State != journey.Blocked { + t.Fatalf("state = %q", outcome.State) + } + if outcome.Finding == nil || outcome.Finding.Code != "SNAP_MOBILE_REAL_DEVICE_PROOF_REQUIRED" { + t.Fatalf("finding = %#v", outcome.Finding) + } +} + +type stubStatusGetter struct { + response snap.StatusResponse + err error +} + +func (s *stubStatusGetter) Status(context.Context, string) (snap.StatusResponse, error) { + return s.response, s.err +} + +type stubLocalVerifier struct { + result snap.LocalVerificationResult + err error +} + +func (s *stubLocalVerifier) VerifyLocal(context.Context, snap.LocalVerificationInput) (snap.LocalVerificationResult, error) { + return s.result, s.err +} + +func TestMobileProviderAndMerchantProofStillBlockWithoutDeviceArtifact(t *testing.T) { + project := t.TempDir() + writeMobileFixture(t, project, map[string]string{ + "server/checkout.go": `package server +const serverKey = "MIDTRANS_SERVER_KEY" +const snapURL = "https://app.sandbox.midtrans.com/snap/v1/transactions" +`, + "app/webview.tsx": `const value = "midtrans webview";`, + "app/return.ts": `const value = "midtrans universal link";`, + }) + + outcome := runMobileJourneyWithRuntime(t, project, journey.Runtime{ + HTTP: &http.Client{}, + ResolveCredential: func(context.Context, string, string) ([]byte, error) { return []byte("SB-Mid-server-test"), nil }, + }, &stubStatusGetter{response: snap.StatusResponse{ + OrderID: "mobile-order-001", + TransactionStatus: "settlement", + FraudStatus: "accept", + StatusCode: "200", + }}, &stubLocalVerifier{result: snap.LocalVerificationResult{ + SettlementApplied: true, + DuplicateIdempotent: true, + LatePendingIgnored: true, + FinalState: snap.MerchantState{ + OrderID: "mobile-order-001", + PaymentStatus: "paid", + FulfillmentCount: 1, + }, + }}) + + if outcome.Finding == nil || outcome.Finding.Code != "SNAP_MOBILE_REAL_DEVICE_PROOF_REQUIRED" { t.Fatalf("finding = %#v", outcome.Finding) } } +func runMobileJourney(t *testing.T, project string) journey.Outcome { + t.Helper() + return runMobileJourneyWithRuntime(t, project, journey.Runtime{ + HTTP: &http.Client{}, + ResolveCredential: func(context.Context, string, string) ([]byte, error) { return []byte("SB-Mid-server-test"), nil }, + }, nil, nil) +} + +func runMobileJourneyWithRuntime( + t *testing.T, + project string, + runtime journey.Runtime, + status snap.StatusGetter, + local snap.LocalVerifier, +) journey.Outcome { + t.Helper() + handler := snap.NewMobileHandler() + if status != nil || local != nil { + handler = snap.NewMobileHandlerForTest(status, local) + } + return handler.Execute(context.Background(), journey.Request{ + OperationID: "op_mobile_test", + ProjectDir: project, + ManifestHash: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + Manifest: mobileManifest(), + Input: journey.Input{ + OrderID: "mobile-order-001", + Amount: 10000, + }, + }, runtime) +} + +func writeMobileFixture(t *testing.T, root string, files map[string]string) { + t.Helper() + for name, content := range files { + location := filepath.Join(root, name) + if err := os.MkdirAll(filepath.Dir(location), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(location, []byte(content), 0o644); err != nil { + t.Fatal(err) + } + } +} + func mobileManifest() manifest.Manifest { value := manifest.Default() value.Application.BaseURL = "http://127.0.0.1:8080" From 2022bd0f21b54136ccdcb55933fdec0b5022bc79 Mon Sep 17 00:00:00 2001 From: "m.salis" Date: Mon, 27 Jul 2026 10:05:48 +0700 Subject: [PATCH 38/73] fix: classify App Router Midtrans routes as backend --- .../task-5-report.md | 19 ++++++ internal/inspection/detectors.go | 18 +----- internal/inspection/inspection_test.go | 20 ++++++ internal/inspection/path_classification.go | 63 +++++++++++++++++++ packs/snap/mobile.go | 58 +---------------- packs/snap/mobile_test.go | 33 ++++++++++ 6 files changed, 139 insertions(+), 72 deletions(-) create mode 100644 internal/inspection/path_classification.go diff --git a/.superpowers/sdd/2026-07-26-midtrans-cli-multi-product-parity/task-5-report.md b/.superpowers/sdd/2026-07-26-midtrans-cli-multi-product-parity/task-5-report.md index 79e8358..bc5aedb 100644 --- a/.superpowers/sdd/2026-07-26-midtrans-cli-multi-product-parity/task-5-report.md +++ b/.superpowers/sdd/2026-07-26-midtrans-cli-multi-product-parity/task-5-report.md @@ -58,3 +58,22 @@ Completed. - backend/provider/merchant proof, - external real-device proof that remains blocked. - Backend token-creation detection is still pattern-based, but it is now scoped to clearly backend source and cannot be satisfied by shared/mobile/comment text. + +## Round 2 Fixes + +- Centralized path classification in `internal/inspection` so server-key and backend token-creation detection share the same precedence rules. +- Changed classification precedence to treat explicit backend segments such as `api/` and `/api/` as backend even under broad app prefixes. +- Verified `app/api/midtrans/route.ts` counts as backend-only, while `app/mobile.tsx` and other app UI/mobile paths remain exposure paths. +- Kept the prior RN `src/`, Expo root config, and Flutter `lib/` exposure behavior intact. + +## Round 2 Tests And Results + +- `go test ./packs/snap ./internal/inspection -count=1` + - Result: pass +- `go test ./... -count=1` + - Result: pass + +## Round 2 Self-Review + +- The shared classifier removes drift between mobile gating and inspection facts. +- Explicit backend segments now win over broad `app/` matching, which fixes Next.js App Router server routes without weakening mobile exposure detection. diff --git a/internal/inspection/detectors.go b/internal/inspection/detectors.go index e0838c8..73b029d 100644 --- a/internal/inspection/detectors.go +++ b/internal/inspection/detectors.go @@ -66,7 +66,7 @@ func detectLine(path string, line int, text string) []Fact { Line: line, }) } - if clearlyBackendSource(lowerPath) && + if ClassifyProjectPath(path) == ProjectPathBackend && strings.Contains(lower, "/snap/v1/transactions") && strings.Contains(lower, "midtrans") { facts = append(facts, Fact{ @@ -118,19 +118,3 @@ func detectLine(path string, line int, text string) []Fact { } return facts } - -func clearlyBackendSource(lowerPath string) bool { - return strings.HasPrefix(lowerPath, "server/") || - strings.HasPrefix(lowerPath, "backend/") || - strings.HasPrefix(lowerPath, "api/") || - strings.HasPrefix(lowerPath, "functions/") || - strings.Contains(lowerPath, "/server/") || - strings.Contains(lowerPath, "/backend/") || - strings.Contains(lowerPath, "/api/") || - strings.Contains(lowerPath, "/functions/") || - strings.HasSuffix(lowerPath, ".go") || - strings.HasSuffix(lowerPath, ".py") || - strings.HasSuffix(lowerPath, ".rb") || - strings.HasSuffix(lowerPath, ".php") || - strings.HasSuffix(lowerPath, ".java") -} diff --git a/internal/inspection/inspection_test.go b/internal/inspection/inspection_test.go index dc55050..2bf2025 100644 --- a/internal/inspection/inspection_test.go +++ b/internal/inspection/inspection_test.go @@ -300,6 +300,26 @@ func TestInspectDoesNotInventRealDeviceProofFromCommentText(t *testing.T) { } } +func TestClassifyProjectPathGivesExplicitBackendPrecedenceOverAppPrefix(t *testing.T) { + tests := []struct { + path string + want inspection.ProjectPathClass + }{ + {path: "app/api/midtrans/route.ts", want: inspection.ProjectPathBackend}, + {path: "app/mobile.tsx", want: inspection.ProjectPathMobile}, + {path: "src/config.ts", want: inspection.ProjectPathMobile}, + {path: "app.config.ts", want: inspection.ProjectPathMobile}, + {path: "lib/config.dart", want: inspection.ProjectPathMobile}, + } + for _, tt := range tests { + t.Run(tt.path, func(t *testing.T) { + if got := inspection.ClassifyProjectPath(tt.path); got != tt.want { + t.Fatalf("classify(%q) = %q, want %q", tt.path, got, tt.want) + } + }) + } +} + func TestInspectRejectsSymlinkProjectRoot(t *testing.T) { realRoot := t.TempDir() if err := os.WriteFile( diff --git a/internal/inspection/path_classification.go b/internal/inspection/path_classification.go new file mode 100644 index 0000000..be3923d --- /dev/null +++ b/internal/inspection/path_classification.go @@ -0,0 +1,63 @@ +package inspection + +import ( + "path" + "path/filepath" + "strings" +) + +type ProjectPathClass string + +const ( + ProjectPathBackend ProjectPathClass = "backend" + ProjectPathMobile ProjectPathClass = "mobile" + ProjectPathUnknown ProjectPathClass = "unknown" +) + +func ClassifyProjectPath(filePath string) ProjectPathClass { + lower := strings.ToLower(filepath.ToSlash(filePath)) + base := path.Base(lower) + ext := path.Ext(lower) + + if hasExplicitBackendSegment(lower) || hasBackendExtension(ext) { + return ProjectPathBackend + } + + if strings.HasPrefix(lower, "lib/") || + strings.HasPrefix(lower, "android/") || + strings.HasPrefix(lower, "ios/") || + strings.HasPrefix(lower, "app/") || + strings.HasPrefix(lower, "src/") || + strings.Contains(lower, "/android/") || + strings.Contains(lower, "/ios/") || + strings.Contains(lower, "/mobile/") || + base == "app.json" || + base == "app.config.js" || + base == "app.config.ts" || + base == "app.config.mjs" || + base == "expo.json" || + ext == ".dart" { + return ProjectPathMobile + } + + return ProjectPathUnknown +} + +func hasExplicitBackendSegment(lower string) bool { + return strings.HasPrefix(lower, "server/") || + strings.HasPrefix(lower, "backend/") || + strings.HasPrefix(lower, "api/") || + strings.HasPrefix(lower, "functions/") || + strings.Contains(lower, "/server/") || + strings.Contains(lower, "/backend/") || + strings.Contains(lower, "/api/") || + strings.Contains(lower, "/functions/") +} + +func hasBackendExtension(ext string) bool { + return ext == ".go" || + ext == ".py" || + ext == ".rb" || + ext == ".php" || + ext == ".java" +} diff --git a/packs/snap/mobile.go b/packs/snap/mobile.go index 314cd8c..f82de06 100644 --- a/packs/snap/mobile.go +++ b/packs/snap/mobile.go @@ -3,9 +3,6 @@ package snap import ( "context" "net/http" - "path" - "path/filepath" - "strings" "github.com/veritrans/midtrans-cli/internal/contracts" "github.com/veritrans/midtrans-cli/internal/inspection" @@ -88,14 +85,14 @@ func (h mobileHandler) run( for _, fact := range report.Facts { switch fact.Kind { case "midtrans.server-key-reference": - switch classifyProjectPath(fact.Path) { - case projectPathBackend: + switch inspection.ClassifyProjectPath(fact.Path) { + case inspection.ProjectPathBackend: serverKeyBackend = true default: serverKeyMobile = true } case "midtrans.snap-token-create": - if classifyProjectPath(fact.Path) == projectPathBackend { + if inspection.ClassifyProjectPath(fact.Path) == inspection.ProjectPathBackend { tokenCreateBackend = true } } @@ -178,55 +175,6 @@ func (h mobileHandler) run( } } -type projectPathClass string - -const ( - projectPathBackend projectPathClass = "backend" - projectPathMobile projectPathClass = "mobile" - projectPathUnknown projectPathClass = "unknown" -) - -func classifyProjectPath(filePath string) projectPathClass { - lower := strings.ToLower(filepath.ToSlash(filePath)) - base := path.Base(lower) - ext := path.Ext(lower) - - if strings.HasPrefix(lower, "lib/") || - strings.HasPrefix(lower, "android/") || - strings.HasPrefix(lower, "ios/") || - strings.HasPrefix(lower, "app/") || - strings.HasPrefix(lower, "src/") || - strings.Contains(lower, "/android/") || - strings.Contains(lower, "/ios/") || - strings.Contains(lower, "/mobile/") || - base == "app.json" || - base == "app.config.js" || - base == "app.config.ts" || - base == "app.config.mjs" || - base == "expo.json" || - ext == ".dart" { - return projectPathMobile - } - - if strings.HasPrefix(lower, "server/") || - strings.HasPrefix(lower, "backend/") || - strings.HasPrefix(lower, "api/") || - strings.HasPrefix(lower, "functions/") || - strings.Contains(lower, "/server/") || - strings.Contains(lower, "/backend/") || - strings.Contains(lower, "/api/") || - strings.Contains(lower, "/functions/") || - ext == ".go" || - ext == ".py" || - ext == ".rb" || - ext == ".php" || - ext == ".java" { - return projectPathBackend - } - - return projectPathUnknown -} - func mobileBlocked(code string, message string) genericjourney.Outcome { return genericjourney.Outcome{ State: genericjourney.Blocked, diff --git a/packs/snap/mobile_test.go b/packs/snap/mobile_test.go index 32617ec..5ef3544 100644 --- a/packs/snap/mobile_test.go +++ b/packs/snap/mobile_test.go @@ -111,6 +111,39 @@ const snapURL = "https://app.sandbox.midtrans.com/snap/v1/transactions" } } +func TestMobileTreatsAppRouterAPIAsBackendOnly(t *testing.T) { + project := t.TempDir() + writeMobileFixture(t, project, map[string]string{ + "app/api/midtrans/route.ts": `const serverKey = "MIDTRANS_SERVER_KEY" +const snapURL = "https://app.sandbox.midtrans.com/snap/v1/transactions" +`, + "app/webview.tsx": `const value = "midtrans webview";`, + "app/return.ts": `const value = "midtrans universal link";`, + }) + + outcome := runMobileJourney(t, project) + if outcome.Finding == nil || outcome.Finding.Code != "SNAP_MOBILE_PROVIDER_STATUS_REQUIRED" { + t.Fatalf("finding = %#v", outcome.Finding) + } +} + +func TestMobileTreatsAppUIServerKeyReferenceAsExposure(t *testing.T) { + project := t.TempDir() + writeMobileFixture(t, project, map[string]string{ + "app/api/midtrans/route.ts": `const serverKey = "MIDTRANS_SERVER_KEY" +const snapURL = "https://app.sandbox.midtrans.com/snap/v1/transactions" +`, + "app/mobile.tsx": `const leak = "MIDTRANS_SERVER_KEY";`, + "app/webview.tsx": `const value = "midtrans webview";`, + "app/return.ts": `const value = "midtrans universal link";`, + }) + + outcome := runMobileJourney(t, project) + if outcome.Finding == nil || outcome.Finding.Code != "SNAP_MOBILE_SERVER_KEY_EXPOSED" { + t.Fatalf("finding = %#v", outcome.Finding) + } +} + func TestMobileAcceptsExplicitBackendServerKeyPath(t *testing.T) { project := t.TempDir() writeMobileFixture(t, project, map[string]string{ From 17d9d8b965481a395f530f79c5bb046c5991bc03 Mon Sep 17 00:00:00 2001 From: "m.salis" Date: Mon, 27 Jul 2026 10:24:31 +0700 Subject: [PATCH 39/73] feat: add classic Core API journeys --- .../task-6-report.md | 37 ++ cmd/midtrans/main.go | 3 +- contracts/capabilities-v1.json | 20 + contracts/public-sources-v1.json | 112 ++++- internal/app/app_test.go | 18 +- internal/app/commands_checkout.go | 3 + internal/packs/registry_test.go | 23 +- packs/coreapi/client.go | 400 ++++++++++++++++++ packs/coreapi/client_test.go | 324 ++++++++++++++ packs/coreapi/journey.go | 290 +++++++++++++ packs/coreapi/journey_test.go | 255 +++++++++++ packs/coreapi/notification.go | 57 +++ packs/coreapi/notification_test.go | 62 +++ packs/coreapi/pack.go | 79 ++++ packs/coreapi/pack_test.go | 65 +++ testdata/coreapi/card-3ds.json | 11 + testdata/coreapi/otc-alfamart.json | 12 + tools/source-drift/main.go | 6 +- 18 files changed, 1750 insertions(+), 27 deletions(-) create mode 100644 .superpowers/sdd/2026-07-26-midtrans-cli-multi-product-parity/task-6-report.md create mode 100644 packs/coreapi/client.go create mode 100644 packs/coreapi/client_test.go create mode 100644 packs/coreapi/journey.go create mode 100644 packs/coreapi/journey_test.go create mode 100644 packs/coreapi/notification.go create mode 100644 packs/coreapi/notification_test.go create mode 100644 packs/coreapi/pack.go create mode 100644 packs/coreapi/pack_test.go create mode 100644 testdata/coreapi/card-3ds.json create mode 100644 testdata/coreapi/otc-alfamart.json diff --git a/.superpowers/sdd/2026-07-26-midtrans-cli-multi-product-parity/task-6-report.md b/.superpowers/sdd/2026-07-26-midtrans-cli-multi-product-parity/task-6-report.md new file mode 100644 index 0000000..459382c --- /dev/null +++ b/.superpowers/sdd/2026-07-26-midtrans-cli-multi-product-parity/task-6-report.md @@ -0,0 +1,37 @@ +# Task 6 Report + +## Status + +- Completed classic Core API pack implementation for: + - `core-api.card-3ds` + - `core-api.saved-card` + - `core-api.installment` + - `core-api.otc` + - `core-api.virtual-account` + - `core-api.refund` +- Registered `core-api` in the compiled pack registry and merchant CLI surface. +- Updated published capability and public-source contracts. +- Updated source-drift validation to cover both Snap and Core API declared sources. + +## Validation + +- `go test ./packs/coreapi -count=1` +- `go test ./packs/coreapi ./internal/packs ./internal/app -count=1` +- `go test ./... -count=1` + +## Notes + +- Core API client uses `https://api.sandbox.midtrans.com` only, Basic Auth with the resolved sandbox server key, bounded response bodies, and redirect rejection. +- Card journeys require `payment_token_reference`; they do not accept raw PAN, CVV, or raw token persistence. +- Timeout-like mutation failures are classified as ambiguous and reconciled through provider status before retry. +- Refund endpoint selection is method-specific: + - card uses `POST /v2/{order_id}/refund` + - documented direct-refund methods use `POST /v2/{order_id}/refund/online/direct` + +## Concerns + +- The pack’s implemented merchant journeys cover the classic card, OTC, legacy VA, and refund paths requested here. The direct-refund endpoint selection logic is present for documented method-specific routing, but this task does not add separate non-core classic payment journeys beyond the requested set. + +## Commit + +- Planned message: `feat: add classic Core API journeys` diff --git a/cmd/midtrans/main.go b/cmd/midtrans/main.go index 23f78f9..a75a9a7 100644 --- a/cmd/midtrans/main.go +++ b/cmd/midtrans/main.go @@ -9,11 +9,12 @@ import ( "github.com/veritrans/midtrans-cli/internal/packs" "github.com/veritrans/midtrans-cli/internal/version" "github.com/veritrans/midtrans-cli/packs/common" + "github.com/veritrans/midtrans-cli/packs/coreapi" "github.com/veritrans/midtrans-cli/packs/snap" ) func main() { - registry, err := packs.NewRegistry(common.New(), snap.New()) + registry, err := packs.NewRegistry(common.New(), snap.New(), coreapi.New()) if err != nil { fmt.Fprintln(os.Stderr, err) os.Exit(6) diff --git a/contracts/capabilities-v1.json b/contracts/capabilities-v1.json index 894b544..2724313 100644 --- a/contracts/capabilities-v1.json +++ b/contracts/capabilities-v1.json @@ -12,6 +12,26 @@ ], "journeys": [] }, + { + "id": "core-api", + "version": "0.1.0", + "capabilities": [ + "core-api.card-3ds.verify.v1", + "core-api.saved-card.verify.v1", + "core-api.installment.verify.v1", + "core-api.otc.verify.v1", + "core-api.virtual-account.verify.v1", + "core-api.refund.verify.v1" + ], + "journeys": [ + "core-api.card-3ds", + "core-api.saved-card", + "core-api.installment", + "core-api.otc", + "core-api.virtual-account", + "core-api.refund" + ] + }, { "id": "snap", "version": "0.1.0", diff --git a/contracts/public-sources-v1.json b/contracts/public-sources-v1.json index d2c08e2..db2987c 100644 --- a/contracts/public-sources-v1.json +++ b/contracts/public-sources-v1.json @@ -8,8 +8,8 @@ "snap.token.create", "snap.basic-auth" ], - "sha256": "d340aa9291d6c8e5764b10b3848943327a47ba9777b5b3604177260c5f2c0195", - "retrieved_at": "2026-07-27T02:51:44Z" + "sha256": "ae91cc09afb68a167647a8e11cdd75ffd6363793a047b7502b8b5ba992cc7595", + "retrieved_at": "2026-07-27T03:21:36.455Z" }, { "id": "snap-js", @@ -18,8 +18,8 @@ "snap.checkout.popup", "snap.checkout.embed" ], - "sha256": "a21df2e39783f2c9a2cf9e9ec9811d91233fb6dbe36b94230bad82ca922a770b", - "retrieved_at": "2026-07-27T02:51:44Z" + "sha256": "13c8b07f1ca7e74486517e1b9be2d01ab84d755e24e356dec515c19c0913b379", + "retrieved_at": "2026-07-27T03:21:36.455Z" }, { "id": "snap-integration", @@ -28,8 +28,8 @@ "snap.checkout.redirect", "snap.mobile.webview" ], - "sha256": "2b8c18a4a8342c891f38ee8e4dfc6c17841d5eb83e30996bf33fc7eca1681845", - "retrieved_at": "2026-07-27T02:51:44Z" + "sha256": "9ecdd17c7e3cddb878cd58e7142faff84aac36bb314ed042aa2944dd8714f531", + "retrieved_at": "2026-07-27T03:21:36.455Z" }, { "id": "technical-faq", @@ -38,8 +38,8 @@ "snap.mobile.deeplink-return", "snap.mobile.real-device-proof" ], - "sha256": "37f5eef5db1506d70a92ff1ec05a0b08d760a01e6651c41ffddb7be6527c985a", - "retrieved_at": "2026-07-27T02:51:44Z" + "sha256": "666834e999999857612a354aa2a6bfdddc2aabb74b704e5776b72db4d1716c40", + "retrieved_at": "2026-07-27T03:21:36.455Z" }, { "id": "http-notifications", @@ -48,8 +48,8 @@ "snap.notification.signature", "common.webhook-idempotency" ], - "sha256": "db525e80a04197e57a154036e8a6e1c3010ee595298039036860f593f1e32dea", - "retrieved_at": "2026-07-27T02:51:44Z" + "sha256": "8fb8d416caf5065d8ff436a3dbd81b31c40e0dcedae17433121193c7bcc33687", + "retrieved_at": "2026-07-27T03:21:36.455Z" }, { "id": "get-transaction-status", @@ -58,8 +58,96 @@ "snap.status.reconcile", "snap.mobile.status.reconcile" ], - "sha256": "ecfa6d08bc177cf3928ee14c87604cbe61e969446e20ffa4ba5ae468bef7c9ad", - "retrieved_at": "2026-07-27T02:51:44Z" + "sha256": "8f1a5a0b72228c0a0e46488d12c1f735c9bee3b3859789cb664644d039f955f1", + "retrieved_at": "2026-07-27T03:21:36.455Z" + }, + { + "id": "coreapi-card-charge", + "url": "https://docs.midtrans.com/reference/charge-transactions-on-card", + "rules": [ + "coreapi.card.charge", + "coreapi.basic-auth" + ], + "sha256": "1a193621f3ab09d960d0451f3ab956d6a674cf2c9e8ed104cd334ea71b23caee", + "retrieved_at": "2026-07-27T03:21:36.455Z" + }, + { + "id": "coreapi-card-3ds", + "url": "https://docs.midtrans.com/reference/card-feature-3d-secure-3ds", + "rules": [ + "coreapi.card.3ds", + "coreapi.card.redirect" + ], + "sha256": "2bfb83e704060bcbabcdfad9c1358f9546ce5799967fb50feb96c6e7dc4ea44d", + "retrieved_at": "2026-07-27T03:21:36.455Z" + }, + { + "id": "coreapi-one-click", + "url": "https://docs.midtrans.com/reference/card-feature-one-click", + "rules": [ + "coreapi.saved-card.token-only" + ], + "sha256": "778f94cacf35ea88a56ede3689d44fa1955726d3d4c50e4c96bffcccf9117b75", + "retrieved_at": "2026-07-27T03:21:36.455Z" + }, + { + "id": "coreapi-alfamart", + "url": "https://docs.midtrans.com/reference/alfamart-1", + "rules": [ + "coreapi.otc.charge", + "coreapi.otc.payment-code" + ], + "sha256": "39f3a1ad32637de78d00e8b76c98c07c4c18ab9b212b5bfd8cc82e13daefe9d7", + "retrieved_at": "2026-07-27T03:21:36.455Z" + }, + { + "id": "coreapi-bni-va", + "url": "https://docs.midtrans.com/reference/bni-virtual-account-1", + "rules": [ + "coreapi.va.charge", + "coreapi.va.instructions" + ], + "sha256": "725348b5b28a854175b8abdd98c4505c46f1c344fec0d4e4c307fd4d3694dc40", + "retrieved_at": "2026-07-27T03:21:36.455Z" + }, + { + "id": "coreapi-status", + "url": "https://docs.midtrans.com/reference/get-transaction-status", + "rules": [ + "coreapi.status.reconcile", + "coreapi.refund.status" + ], + "sha256": "8f1a5a0b72228c0a0e46488d12c1f735c9bee3b3859789cb664644d039f955f1", + "retrieved_at": "2026-07-27T03:21:36.455Z" + }, + { + "id": "coreapi-refund", + "url": "https://docs.midtrans.com/reference/refund-transaction", + "rules": [ + "coreapi.refund.async", + "coreapi.refund.idempotency" + ], + "sha256": "8f0fb963db7d8302af49935aaebf4318bf3b3880c33b7cacb1ba3f955b7099c2", + "retrieved_at": "2026-07-27T03:21:36.455Z" + }, + { + "id": "coreapi-direct-refund", + "url": "https://docs.midtrans.com/reference/direct-refund-transaction", + "rules": [ + "coreapi.refund.direct" + ], + "sha256": "6a01c94b4e36c4395bf599adcbd605c807db954741a389f99b2b85c42d8d59d0", + "retrieved_at": "2026-07-27T03:21:36.455Z" + }, + { + "id": "coreapi-notifications", + "url": "https://docs.midtrans.com/docs/https-notification-webhooks", + "rules": [ + "coreapi.notification.signature", + "common.webhook-idempotency" + ], + "sha256": "8fb8d416caf5065d8ff436a3dbd81b31c40e0dcedae17433121193c7bcc33687", + "retrieved_at": "2026-07-27T03:21:36.455Z" } ] } diff --git a/internal/app/app_test.go b/internal/app/app_test.go index b6b38c3..c9cd2fb 100644 --- a/internal/app/app_test.go +++ b/internal/app/app_test.go @@ -24,6 +24,7 @@ import ( "github.com/veritrans/midtrans-cli/internal/update" "github.com/veritrans/midtrans-cli/internal/version" "github.com/veritrans/midtrans-cli/packs/common" + "github.com/veritrans/midtrans-cli/packs/coreapi" "github.com/veritrans/midtrans-cli/packs/snap" ) @@ -46,17 +47,18 @@ func TestCapabilitiesJSON(t *testing.T) { if result.CLIVersion != "0.1.0-test" { t.Fatalf("cli version = %q", result.CLIVersion) } - if len(result.Capabilities) != 5 || + if len(result.Capabilities) != 11 || result.Capabilities[0].ID != "common.capabilities.v1" || - result.Capabilities[4].ID != "snap.webhook.verify.v1" { + result.Capabilities[10].ID != "snap.webhook.verify.v1" { t.Fatalf("unexpected capabilities: %#v", result.Capabilities) } - if len(result.Packs) != 2 || + if len(result.Packs) != 3 || result.Packs[0].ID != "common" || - result.Packs[1].ID != "snap" { + result.Packs[1].ID != "core-api" || + result.Packs[2].ID != "snap" { t.Fatalf("unexpected packs: %#v", result.Packs) } - if len(result.Journeys) != 4 || result.Journeys[2] != "snap.checkout" || result.Journeys[3] != "snap.mobile-webview" { + if len(result.Journeys) != 10 || result.Journeys[8] != "snap.checkout" || result.Journeys[9] != "snap.mobile-webview" { t.Fatalf("unexpected journeys: %#v", result.Journeys) } } @@ -78,8 +80,8 @@ func TestAgentCapabilitiesPreservesCapabilityContract(t *testing.T) { ) if exit != 0 || result.SchemaVersion != "1.0" || - len(result.Capabilities) != 5 || - len(result.Journeys) != 4 { + len(result.Capabilities) != 11 || + len(result.Journeys) != 10 { t.Fatalf("exit = %d, result = %#v", exit, result) } } @@ -2777,7 +2779,7 @@ func assertSchemaFieldsMatchType( func testRegistry(t *testing.T) *packs.Registry { t.Helper() - registry, err := packs.NewRegistry(common.New(), snap.New()) + registry, err := packs.NewRegistry(common.New(), snap.New(), coreapi.New()) if err != nil { t.Fatal(err) } diff --git a/internal/app/commands_checkout.go b/internal/app/commands_checkout.go index eb16a68..56ba2fe 100644 --- a/internal/app/commands_checkout.go +++ b/internal/app/commands_checkout.go @@ -170,6 +170,9 @@ func runRoutedMerchantJourney( return result } if handler.Definition().ID != "snap.checkout" { + if handler.Definition().Product == "core-api" { + return runGenericJourney(ctx, request, deps) + } result := contracts.NewResult(request.Command, contracts.StatusBlocked) result.CLIVersion = deps.Version.Version result.ManifestVersion = value.SchemaVersion diff --git a/internal/packs/registry_test.go b/internal/packs/registry_test.go index dfdc3fc..9388713 100644 --- a/internal/packs/registry_test.go +++ b/internal/packs/registry_test.go @@ -12,16 +12,17 @@ import ( "github.com/veritrans/midtrans-cli/internal/operations" "github.com/veritrans/midtrans-cli/internal/packs" "github.com/veritrans/midtrans-cli/packs/common" + "github.com/veritrans/midtrans-cli/packs/coreapi" "github.com/veritrans/midtrans-cli/packs/snap" ) func TestRegistryAggregatesCapabilities(t *testing.T) { - registry, err := packs.NewRegistry(common.New(), snap.New()) + registry, err := packs.NewRegistry(common.New(), snap.New(), coreapi.New()) if err != nil { t.Fatal(err) } capabilities := registry.Capabilities() - if len(capabilities) != 5 { + if len(capabilities) != 11 { t.Fatalf("capabilities = %#v", capabilities) } if _, ok := registry.Get("snap"); !ok { @@ -34,6 +35,12 @@ func TestRegistryAggregatesCapabilities(t *testing.T) { } wantIDs := []string{ "common.capabilities.v1", + "core-api.card-3ds.verify.v1", + "core-api.installment.verify.v1", + "core-api.otc.verify.v1", + "core-api.refund.verify.v1", + "core-api.saved-card.verify.v1", + "core-api.virtual-account.verify.v1", "snap.checkout.verify.v1", "snap.mobile.verify.v1", "snap.plan.v1", @@ -67,7 +74,7 @@ func TestRegistryRejectsDuplicateJourneyIDs(t *testing.T) { } func TestRegistryJourneyRouting(t *testing.T) { - registry, err := packs.NewRegistry(common.New(), snap.New()) + registry, err := packs.NewRegistry(common.New(), snap.New(), coreapi.New()) if err != nil { t.Fatal(err) } @@ -85,7 +92,7 @@ func TestRegistryJourneyRouting(t *testing.T) { } func TestRegistryAggregatesDeterministicMetadata(t *testing.T) { - registry, err := packs.NewRegistry(snap.New(), common.New()) + registry, err := packs.NewRegistry(snap.New(), common.New(), coreapi.New()) if err != nil { t.Fatal(err) } @@ -93,6 +100,12 @@ func TestRegistryAggregatesDeterministicMetadata(t *testing.T) { wantJourneys := []string{ "common.status-reconciliation", "common.webhook-idempotency", + "core-api.card-3ds", + "core-api.installment", + "core-api.otc", + "core-api.refund", + "core-api.saved-card", + "core-api.virtual-account", "snap.checkout", "snap.mobile-webview", } @@ -103,7 +116,7 @@ func TestRegistryAggregatesDeterministicMetadata(t *testing.T) { if got := registry.SensitiveKeys(); !reflect.DeepEqual(got, wantSensitiveKeys) { t.Fatalf("sensitive keys = %#v, want %#v", got, wantSensitiveKeys) } - wantVersions := []string{"common@0.1.0", "snap@0.1.0"} + wantVersions := []string{"common@0.1.0", "core-api@0.1.0", "snap@0.1.0"} versions := registry.Versions() gotVersions := make([]string, 0, len(versions)) for _, version := range versions { diff --git a/packs/coreapi/client.go b/packs/coreapi/client.go new file mode 100644 index 0000000..964ca64 --- /dev/null +++ b/packs/coreapi/client.go @@ -0,0 +1,400 @@ +package coreapi + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "io" + "net" + "net/http" + "net/url" + "slices" + + "github.com/veritrans/midtrans-cli/internal/sandbox" + "github.com/veritrans/midtrans-cli/internal/secrets" +) + +const ( + chargeSandboxURL = "https://api.sandbox.midtrans.com/v2/charge" + statusSandboxBaseURL = "https://api.sandbox.midtrans.com/v2/" + maxResponseBytes = 1 << 20 + errSafeTransportString = "sandbox request transport failed" +) + +var directRefundMethods = []string{ + "gopay", + "qris", + "shopeepay", + "akulaku", + "kredivo", +} + +type Client struct { + HTTP sandbox.Doer + ServerKey secrets.Value +} + +type ChargeRequest struct { + OperationID string + OrderID string + GrossAmount int64 + Method string + TokenID string + Bank string + Store string + InstallmentTerm int +} + +type ChargeResponse struct { + OrderID string `json:"order_id"` + TransactionStatus string `json:"transaction_status"` + FraudStatus string `json:"fraud_status,omitempty"` + StatusCode string `json:"status_code"` + PaymentType string `json:"payment_type,omitempty"` + GrossAmount string `json:"gross_amount,omitempty"` + RedirectURL string `json:"redirect_url,omitempty"` + PaymentCode string `json:"payment_code,omitempty"` + Store string `json:"store,omitempty"` + VANumbers []string `json:"va_numbers,omitempty"` +} + +type StatusResponse struct { + OrderID string `json:"order_id"` + TransactionStatus string `json:"transaction_status"` + FraudStatus string `json:"fraud_status,omitempty"` + StatusCode string `json:"status_code"` + PaymentType string `json:"payment_type,omitempty"` + GrossAmount string `json:"gross_amount,omitempty"` + NotFound bool `json:"not_found,omitempty"` +} + +type RefundRequest struct { + OperationID string + OrderID string + Method string + Amount int64 + RefundKey string + Reason string +} + +type RefundResponse struct { + OrderID string `json:"order_id"` + TransactionStatus string `json:"transaction_status"` + StatusCode string `json:"status_code"` + RefundKey string `json:"refund_key,omitempty"` +} + +func (c Client) Charge(ctx context.Context, input ChargeRequest) (ChargeResponse, error) { + if c.HTTP == nil || input.OperationID == "" || input.OrderID == "" || input.GrossAmount <= 0 { + return ChargeResponse{}, errors.New("SANDBOX_REQUEST_INVALID") + } + serverKey, err := c.ServerKey.SandboxServerKey() + if err != nil { + return ChargeResponse{}, err + } + payload, err := json.Marshal(chargePayload(input)) + if err != nil { + return ChargeResponse{}, errors.New("SANDBOX_REQUEST_INVALID") + } + request, err := http.NewRequestWithContext( + ctx, + http.MethodPost, + chargeSandboxURL, + bytes.NewReader(payload), + ) + if err != nil { + return ChargeResponse{}, errors.New("SANDBOX_REQUEST_INVALID") + } + request.Header.Set("Content-Type", "application/json") + request.SetBasicAuth(serverKey, "") + + response, err := c.HTTP.Do(request) + if err != nil { + if isTimeoutError(err) { + return ChargeResponse{}, sandbox.AmbiguousOperationError{ + OperationID: input.OperationID, + Cause: errors.New(errSafeTransportString), + } + } + return ChargeResponse{}, errors.New(errSafeTransportString) + } + return decodeChargeResponse(response, "coreapi.charge") +} + +func (c Client) Status(ctx context.Context, orderID string) (StatusResponse, error) { + if c.HTTP == nil || orderID == "" { + return StatusResponse{}, errors.New("SANDBOX_REQUEST_INVALID") + } + serverKey, err := c.ServerKey.SandboxServerKey() + if err != nil { + return StatusResponse{}, err + } + request, err := http.NewRequestWithContext( + ctx, + http.MethodGet, + statusSandboxBaseURL+url.PathEscape(orderID)+"/status", + nil, + ) + if err != nil { + return StatusResponse{}, errors.New("SANDBOX_REQUEST_INVALID") + } + request.SetBasicAuth(serverKey, "") + + response, err := c.HTTP.Do(request) + if err != nil { + return StatusResponse{}, errors.New(errSafeTransportString) + } + if response == nil || response.Body == nil { + return StatusResponse{}, errors.New("SANDBOX_RESPONSE_INVALID") + } + defer response.Body.Close() + if isRedirect(response.StatusCode) { + return StatusResponse{}, errors.New("SANDBOX_RESPONSE_REDIRECTED") + } + if response.StatusCode == http.StatusNotFound { + return StatusResponse{OrderID: orderID, NotFound: true}, nil + } + if response.StatusCode < http.StatusOK || response.StatusCode >= http.StatusMultipleChoices { + return StatusResponse{}, sandbox.ResponseError{ + Operation: "coreapi.status", + StatusCode: response.StatusCode, + } + } + + var result struct { + OrderID string `json:"order_id"` + TransactionStatus string `json:"transaction_status"` + FraudStatus string `json:"fraud_status"` + StatusCode string `json:"status_code"` + PaymentType string `json:"payment_type"` + GrossAmount string `json:"gross_amount"` + } + if err := decodeBounded(response.Body, &result, false); err != nil { + return StatusResponse{}, err + } + if result.OrderID == "" || result.TransactionStatus == "" || result.StatusCode == "" { + return StatusResponse{}, errors.New("SANDBOX_RESPONSE_INVALID") + } + return StatusResponse{ + OrderID: result.OrderID, + TransactionStatus: result.TransactionStatus, + FraudStatus: result.FraudStatus, + StatusCode: result.StatusCode, + PaymentType: result.PaymentType, + GrossAmount: result.GrossAmount, + }, nil +} + +func (c Client) Refund(ctx context.Context, input RefundRequest) (RefundResponse, error) { + if c.HTTP == nil || input.OperationID == "" || input.OrderID == "" || input.Amount <= 0 || + input.RefundKey == "" || input.Method == "" { + return RefundResponse{}, errors.New("SANDBOX_REQUEST_INVALID") + } + serverKey, err := c.ServerKey.SandboxServerKey() + if err != nil { + return RefundResponse{}, err + } + endpoint, err := refundEndpoint(input.OrderID, input.Method) + if err != nil { + return RefundResponse{}, err + } + payload, err := json.Marshal(struct { + RefundKey string `json:"refund_key"` + Amount int64 `json:"amount"` + Reason string `json:"reason,omitempty"` + }{ + RefundKey: input.RefundKey, + Amount: input.Amount, + Reason: input.Reason, + }) + if err != nil { + return RefundResponse{}, errors.New("SANDBOX_REQUEST_INVALID") + } + request, err := http.NewRequestWithContext( + ctx, + http.MethodPost, + endpoint, + bytes.NewReader(payload), + ) + if err != nil { + return RefundResponse{}, errors.New("SANDBOX_REQUEST_INVALID") + } + request.Header.Set("Content-Type", "application/json") + request.SetBasicAuth(serverKey, "") + + response, err := c.HTTP.Do(request) + if err != nil { + if isTimeoutError(err) { + return RefundResponse{}, sandbox.AmbiguousOperationError{ + OperationID: input.OperationID, + Cause: errors.New(errSafeTransportString), + } + } + return RefundResponse{}, errors.New(errSafeTransportString) + } + if response == nil || response.Body == nil { + return RefundResponse{}, errors.New("SANDBOX_RESPONSE_INVALID") + } + defer response.Body.Close() + if isRedirect(response.StatusCode) { + return RefundResponse{}, errors.New("SANDBOX_RESPONSE_REDIRECTED") + } + if response.StatusCode < http.StatusOK || response.StatusCode >= http.StatusMultipleChoices { + return RefundResponse{}, sandbox.ResponseError{ + Operation: "coreapi.refund", + StatusCode: response.StatusCode, + } + } + + var result struct { + OrderID string `json:"order_id"` + TransactionStatus string `json:"transaction_status"` + StatusCode string `json:"status_code"` + RefundKey string `json:"refund_key"` + } + if err := decodeBounded(response.Body, &result, false); err != nil { + return RefundResponse{}, err + } + if result.OrderID == "" || result.StatusCode == "" { + return RefundResponse{}, errors.New("SANDBOX_RESPONSE_INVALID") + } + return RefundResponse{ + OrderID: result.OrderID, + TransactionStatus: result.TransactionStatus, + StatusCode: result.StatusCode, + RefundKey: result.RefundKey, + }, nil +} + +func chargePayload(input ChargeRequest) any { + payload := map[string]any{ + "transaction_details": map[string]any{ + "order_id": input.OrderID, + "gross_amount": input.GrossAmount, + }, + } + switch input.Method { + case "card-3ds", "saved-card", "installment": + card := map[string]any{ + "token_id": input.TokenID, + "authentication": true, + } + if input.Bank != "" { + card["bank"] = input.Bank + } + if input.InstallmentTerm > 0 { + card["installment_term"] = input.InstallmentTerm + } + payload["payment_type"] = "credit_card" + payload["credit_card"] = card + case "otc": + payload["payment_type"] = "cstore" + payload["cstore"] = map[string]any{"store": input.Store} + case "virtual-account": + payload["payment_type"] = "bank_transfer" + payload["bank_transfer"] = map[string]any{"bank": input.Bank} + } + return payload +} + +func decodeChargeResponse(response *http.Response, operation string) (ChargeResponse, error) { + if response == nil || response.Body == nil { + return ChargeResponse{}, errors.New("SANDBOX_RESPONSE_INVALID") + } + defer response.Body.Close() + if isRedirect(response.StatusCode) { + return ChargeResponse{}, errors.New("SANDBOX_RESPONSE_REDIRECTED") + } + if response.StatusCode < http.StatusOK || response.StatusCode >= http.StatusMultipleChoices { + return ChargeResponse{}, sandbox.ResponseError{ + Operation: operation, + StatusCode: response.StatusCode, + } + } + var result struct { + OrderID string `json:"order_id"` + TransactionStatus string `json:"transaction_status"` + FraudStatus string `json:"fraud_status"` + StatusCode string `json:"status_code"` + PaymentType string `json:"payment_type"` + GrossAmount string `json:"gross_amount"` + RedirectURL string `json:"redirect_url"` + PaymentCode string `json:"payment_code"` + Store string `json:"store"` + VANumbers []struct { + VANumber string `json:"va_number"` + } `json:"va_numbers"` + } + if err := decodeBounded(response.Body, &result, false); err != nil { + return ChargeResponse{}, err + } + if result.OrderID == "" || result.TransactionStatus == "" || result.StatusCode == "" { + return ChargeResponse{}, errors.New("SANDBOX_RESPONSE_INVALID") + } + vaNumbers := make([]string, 0, len(result.VANumbers)) + for _, number := range result.VANumbers { + if number.VANumber != "" { + vaNumbers = append(vaNumbers, number.VANumber) + } + } + return ChargeResponse{ + OrderID: result.OrderID, + TransactionStatus: result.TransactionStatus, + FraudStatus: result.FraudStatus, + StatusCode: result.StatusCode, + PaymentType: result.PaymentType, + GrossAmount: result.GrossAmount, + RedirectURL: result.RedirectURL, + PaymentCode: result.PaymentCode, + Store: result.Store, + VANumbers: vaNumbers, + }, nil +} + +func decodeBounded(body io.Reader, target any, rejectUnknownFields bool) error { + data, err := io.ReadAll(io.LimitReader(body, maxResponseBytes+1)) + if err != nil { + return errors.New("SANDBOX_RESPONSE_INVALID") + } + if len(data) > maxResponseBytes { + return errors.New("SANDBOX_RESPONSE_TOO_LARGE") + } + decoder := json.NewDecoder(bytes.NewReader(data)) + if rejectUnknownFields { + decoder.DisallowUnknownFields() + } + if err := decoder.Decode(target); err != nil { + return errors.New("SANDBOX_RESPONSE_INVALID") + } + if err := decoder.Decode(&struct{}{}); !errors.Is(err, io.EOF) { + return errors.New("SANDBOX_RESPONSE_INVALID") + } + return nil +} + +func refundEndpoint(orderID, method string) (string, error) { + switch method { + case "credit_card": + return statusSandboxBaseURL + url.PathEscape(orderID) + "/refund", nil + default: + if slices.Contains(directRefundMethods, method) { + return statusSandboxBaseURL + url.PathEscape(orderID) + "/refund/online/direct", nil + } + } + return "", errors.New("SANDBOX_REQUEST_INVALID") +} + +func isRedirect(statusCode int) bool { + return statusCode >= http.StatusMultipleChoices && + statusCode < http.StatusBadRequest +} + +func isTimeoutError(err error) bool { + var timeout interface{ Timeout() bool } + if errors.As(err, &timeout) && timeout.Timeout() { + return true + } + var netErr net.Error + return errors.As(err, &netErr) && netErr.Timeout() +} diff --git a/packs/coreapi/client_test.go b/packs/coreapi/client_test.go new file mode 100644 index 0000000..8b11b9c --- /dev/null +++ b/packs/coreapi/client_test.go @@ -0,0 +1,324 @@ +package coreapi_test + +import ( + "bytes" + "context" + "encoding/base64" + "encoding/json" + "errors" + "io" + "net/http" + "strings" + "testing" + + "github.com/veritrans/midtrans-cli/internal/sandbox" + "github.com/veritrans/midtrans-cli/internal/secrets" + "github.com/veritrans/midtrans-cli/packs/coreapi" +) + +const coreServerKeyCanary = "SB-Mid-server-CORE-API-CANARY-DO-NOT-PRINT" + +type coreRecordingDoer struct { + request *http.Request + body []byte + do func(*http.Request) (*http.Response, error) +} + +func (d *coreRecordingDoer) Do(request *http.Request) (*http.Response, error) { + d.request = request + if request.Body != nil { + body, err := io.ReadAll(request.Body) + if err != nil { + return nil, err + } + d.body = body + request.Body = io.NopCloser(bytes.NewReader(body)) + } + return d.do(request) +} + +func TestClientChargeUsesFixedSandboxHostBasicAuthAndExactCardPayload(t *testing.T) { + doer := &coreRecordingDoer{ + do: func(*http.Request) (*http.Response, error) { + return coreResponse(http.StatusCreated, `{ + "status_code":"201", + "transaction_status":"pending", + "order_id":"order-card-3ds", + "payment_type":"credit_card", + "gross_amount":"12500.00", + "redirect_url":"https://api.sandbox.midtrans.com/v2/3ds/redirect/order-card-3ds" + }`), nil + }, + } + client := coreapi.Client{ + HTTP: doer, + ServerKey: secrets.NewValue(coreServerKeyCanary), + } + + got, err := client.Charge(context.Background(), coreapi.ChargeRequest{ + OperationID: "operation-card-3ds", + OrderID: "order-card-3ds", + GrossAmount: 12500, + Method: "card-3ds", + TokenID: "tokn_ref_only", + }) + if err != nil { + t.Fatal(err) + } + if doer.request.URL.String() != "https://api.sandbox.midtrans.com/v2/charge" { + t.Fatalf("request URL = %q", doer.request.URL.String()) + } + if doer.request.Method != http.MethodPost { + t.Fatalf("method = %q", doer.request.Method) + } + if got := doer.request.Header.Get("Content-Type"); got != "application/json" { + t.Fatalf("Content-Type = %q", got) + } + wantAuthorization := "Basic " + + base64.StdEncoding.EncodeToString([]byte(coreServerKeyCanary+":")) + if got := doer.request.Header.Get("Authorization"); got != wantAuthorization { + t.Fatalf("Authorization = %q", got) + } + + var payload struct { + PaymentType string `json:"payment_type"` + TransactionDetails struct { + OrderID string `json:"order_id"` + GrossAmount int64 `json:"gross_amount"` + } `json:"transaction_details"` + CreditCard struct { + TokenID string `json:"token_id"` + Authentication bool `json:"authentication"` + } `json:"credit_card"` + } + if err := json.Unmarshal(doer.body, &payload); err != nil { + t.Fatal(err) + } + if payload.PaymentType != "credit_card" || + payload.TransactionDetails.OrderID != "order-card-3ds" || + payload.TransactionDetails.GrossAmount != 12500 || + payload.CreditCard.TokenID != "tokn_ref_only" || + !payload.CreditCard.Authentication { + t.Fatalf("request payload = %#v", payload) + } + if got.OrderID != "order-card-3ds" || + got.TransactionStatus != "pending" || + got.RedirectURL == "" { + t.Fatalf("response = %#v", got) + } +} + +func TestClientChargeBuildsOTCAndLegacyVAPayloads(t *testing.T) { + tests := []struct { + name string + request coreapi.ChargeRequest + wantPaymentType string + wantDetailKey string + wantDetailValue string + }{ + { + name: "otc alfamart", + request: coreapi.ChargeRequest{ + OperationID: "operation-otc", + OrderID: "order-otc", + GrossAmount: 162500, + Method: "otc", + Store: "alfamart", + }, + wantPaymentType: "cstore", + wantDetailKey: "store", + wantDetailValue: "alfamart", + }, + { + name: "virtual account bni", + request: coreapi.ChargeRequest{ + OperationID: "operation-va", + OrderID: "order-va", + GrossAmount: 99000, + Method: "virtual-account", + Bank: "bni", + }, + wantPaymentType: "bank_transfer", + wantDetailKey: "bank", + wantDetailValue: "bni", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + doer := &coreRecordingDoer{ + do: func(*http.Request) (*http.Response, error) { + return coreResponse(http.StatusCreated, `{ + "status_code":"201", + "transaction_status":"pending", + "order_id":"`+test.request.OrderID+`", + "payment_type":"`+test.wantPaymentType+`", + "gross_amount":"10000.00" + }`), nil + }, + } + client := coreapi.Client{ + HTTP: doer, + ServerKey: secrets.NewValue(coreServerKeyCanary), + } + + if _, err := client.Charge(context.Background(), test.request); err != nil { + t.Fatal(err) + } + + var payload map[string]any + if err := json.Unmarshal(doer.body, &payload); err != nil { + t.Fatal(err) + } + if payload["payment_type"] != test.wantPaymentType { + t.Fatalf("payment_type = %#v", payload["payment_type"]) + } + details, ok := payload[strings.TrimPrefix(test.wantPaymentType, "credit_")].(map[string]any) + if !ok { + switch test.wantPaymentType { + case "cstore": + details, ok = payload["cstore"].(map[string]any) + case "bank_transfer": + details, ok = payload["bank_transfer"].(map[string]any) + } + } + if !ok || details[test.wantDetailKey] != test.wantDetailValue { + t.Fatalf("details = %#v", details) + } + }) + } +} + +func TestClientRefundUsesMethodSpecificEndpointAndStableRefundKey(t *testing.T) { + doer := &coreRecordingDoer{ + do: func(*http.Request) (*http.Response, error) { + return coreResponse(http.StatusOK, `{ + "status_code":"200", + "order_id":"order-card-refund", + "transaction_status":"refund", + "refund_key":"refund-001" + }`), nil + }, + } + client := coreapi.Client{ + HTTP: doer, + ServerKey: secrets.NewValue(coreServerKeyCanary), + } + + got, err := client.Refund(context.Background(), coreapi.RefundRequest{ + OperationID: "operation-refund", + OrderID: "order-card-refund", + Method: "credit_card", + Amount: 10000, + RefundKey: "refund-001", + Reason: "duplicate", + }) + if err != nil { + t.Fatal(err) + } + if doer.request.URL.String() != "https://api.sandbox.midtrans.com/v2/order-card-refund/refund" { + t.Fatalf("request URL = %q", doer.request.URL.String()) + } + var payload struct { + RefundKey string `json:"refund_key"` + Amount int64 `json:"amount"` + Reason string `json:"reason"` + } + if err := json.Unmarshal(doer.body, &payload); err != nil { + t.Fatal(err) + } + if payload.RefundKey != "refund-001" || payload.Amount != 10000 || payload.Reason != "duplicate" { + t.Fatalf("refund payload = %#v", payload) + } + if got.RefundKey != "refund-001" || got.TransactionStatus != "refund" { + t.Fatalf("refund response = %#v", got) + } +} + +func TestClientMutationTimeoutIsAmbiguousAndRedacted(t *testing.T) { + doer := &coreRecordingDoer{ + do: func(*http.Request) (*http.Response, error) { + return nil, timeoutError{message: "timeout-" + coreServerKeyCanary} + }, + } + client := coreapi.Client{ + HTTP: doer, + ServerKey: secrets.NewValue(coreServerKeyCanary), + } + + _, err := client.Charge(context.Background(), coreapi.ChargeRequest{ + OperationID: "operation-timeout", + OrderID: "order-timeout", + GrossAmount: 10000, + Method: "otc", + Store: "alfamart", + }) + var ambiguous sandbox.AmbiguousOperationError + if !errors.As(err, &ambiguous) { + t.Fatalf("error = %T %v, want sandbox.AmbiguousOperationError", err, err) + } + if ambiguous.OperationID != "operation-timeout" { + t.Fatalf("operation ID = %q", ambiguous.OperationID) + } + if strings.Contains(err.Error(), coreServerKeyCanary) { + t.Fatalf("ambiguous error leaked sensitive cause: %v", err) + } +} + +func TestClientRejectsCrossHostRedirectsAndLargeBodies(t *testing.T) { + tests := []struct { + name string + status int + body string + want string + }{ + { + name: "redirect blocked", + status: http.StatusFound, + body: ``, + want: "SANDBOX_RESPONSE_REDIRECTED", + }, + { + name: "large body blocked", + status: http.StatusOK, + body: `{"status_code":"200","transaction_status":"settlement","order_id":"x","payment_type":"credit_card","gross_amount":"10000.00","merchant_id":"` + strings.Repeat("x", (1<<20)+1) + `"}`, + want: "SANDBOX_RESPONSE_TOO_LARGE", + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + doer := &coreRecordingDoer{ + do: func(*http.Request) (*http.Response, error) { + response := coreResponse(test.status, test.body) + if test.status == http.StatusFound { + response.Header.Set("Location", "https://example.com/elsewhere") + } + return response, nil + }, + } + client := coreapi.Client{ + HTTP: doer, + ServerKey: secrets.NewValue(coreServerKeyCanary), + } + + _, err := client.Status(context.Background(), "order-redirect") + if err == nil || !strings.Contains(err.Error(), test.want) { + t.Fatalf("status error = %v, want %q", err, test.want) + } + }) + } +} + +type timeoutError struct{ message string } + +func (e timeoutError) Error() string { return e.message } +func (timeoutError) Timeout() bool { return true } +func (timeoutError) Temporary() bool { return false } + +func coreResponse(statusCode int, body string) *http.Response { + return &http.Response{ + StatusCode: statusCode, + Header: make(http.Header), + Body: io.NopCloser(strings.NewReader(body)), + } +} diff --git a/packs/coreapi/journey.go b/packs/coreapi/journey.go new file mode 100644 index 0000000..391bdbd --- /dev/null +++ b/packs/coreapi/journey.go @@ -0,0 +1,290 @@ +package coreapi + +import ( + "context" + "errors" + "strconv" + "time" + + "github.com/veritrans/midtrans-cli/internal/contracts" + journey "github.com/veritrans/midtrans-cli/internal/journey" + "github.com/veritrans/midtrans-cli/internal/operations" + "github.com/veritrans/midtrans-cli/internal/sandbox" +) + +type ChargeExecutor interface { + Charge(context.Context, ChargeRequest) (ChargeResponse, error) +} + +type StatusGetter interface { + Status(context.Context, string) (StatusResponse, error) +} + +type RefundExecutor interface { + Refund(context.Context, RefundRequest) (RefundResponse, error) +} + +type JourneyRunner struct { + Charge ChargeExecutor + Status StatusGetter + Refund RefundExecutor + Now func() time.Time +} + +type Handler struct { + definition journey.Definition + runner JourneyRunner +} + +func NewCard3DSHandler() Handler { + return newHandler("core-api.card-3ds", "card-3ds") +} + +func NewSavedCardHandler() Handler { + return newHandler("core-api.saved-card", "saved-card") +} + +func NewInstallmentHandler() Handler { + return newHandler("core-api.installment", "installment") +} + +func NewOTCHandler() Handler { + return newHandler("core-api.otc", "otc") +} + +func NewVirtualAccountHandler() Handler { + return newHandler("core-api.virtual-account", "virtual-account") +} + +func NewRefundHandler() Handler { + return newHandler("core-api.refund", "refund") +} + +func newHandler(id, intent string) Handler { + return Handler{ + definition: journey.Definition{ + ID: id, + Product: "core-api", + Intent: intent, + RequiredInputs: []string{"order_id", "amount"}, + }, + runner: JourneyRunner{ + Charge: Client{}, + Status: Client{}, + Refund: Client{}, + Now: func() time.Time { return time.Now().UTC() }, + }, + } +} + +func (h Handler) WithRunner(runner JourneyRunner) Handler { + h.runner = runner + if h.runner.Now == nil { + h.runner.Now = func() time.Time { return time.Now().UTC() } + } + return h +} + +func (h Handler) Definition() journey.Definition { return h.definition } + +func (h Handler) Plan(_ context.Context, request journey.Request, _ journey.Runtime) journey.Outcome { + return journey.Outcome{ + State: journey.Planned, + SafeData: map[string]any{ + "order_id": request.Input.OrderID, + "gross_amount": strconv.FormatInt(request.Input.Amount, 10), + }, + } +} + +func (h Handler) Execute(ctx context.Context, request journey.Request, _ journey.Runtime) journey.Outcome { + return h.run(ctx, request) +} + +func (h Handler) Resume(ctx context.Context, request journey.Request, _ journey.Runtime, _ operations.Record) journey.Outcome { + return h.run(ctx, request) +} + +func (h Handler) run(ctx context.Context, request journey.Request) journey.Outcome { + if request.OperationID == "" || request.ManifestHash == "" || request.Input.OrderID == "" || request.Input.Amount <= 0 { + return inputRequired("order_id and amount are required") + } + switch h.definition.Intent { + case "refund": + return h.runRefund(ctx, request) + case "card-3ds", "saved-card", "installment": + if request.Input.PaymentTokenReference == "" { + return inputRequired("payment_token_reference is required") + } + case "otc": + if request.Input.Method == "" { + request.Input.Method = "alfamart" + } + case "virtual-account": + if request.Input.Method == "" { + request.Input.Method = "bni" + } + } + if h.runner.Status == nil || h.runner.Charge == nil { + return blockedOutcome("journey dependencies are unavailable") + } + status, err := h.runner.Status.Status(ctx, request.Input.OrderID) + if err == nil && !status.NotFound { + return h.evaluateStatus(status) + } + + response, err := h.runner.Charge.Charge(ctx, ChargeRequest{ + OperationID: request.OperationID, + OrderID: request.Input.OrderID, + GrossAmount: request.Input.Amount, + Method: h.definition.Intent, + TokenID: request.Input.PaymentTokenReference, + Bank: request.Input.Method, + Store: request.Input.Method, + }) + if err != nil { + var ambiguous sandbox.AmbiguousOperationError + if errors.As(err, &ambiguous) { + reconciled, statusErr := h.runner.Status.Status(ctx, request.Input.OrderID) + if statusErr != nil || reconciled.NotFound { + return journey.Outcome{ + State: journey.Reconciling, + SafeData: map[string]any{"order_id": request.Input.OrderID}, + } + } + return h.evaluateStatus(reconciled) + } + return blockedOutcome("sandbox mutation failed") + } + return h.evaluateCharge(request, response) +} + +func (h Handler) runRefund(ctx context.Context, request journey.Request) journey.Outcome { + if request.Input.CustomerReference == "" { + return inputRequired("customer_reference is required as a stable refund key") + } + if h.runner.Refund == nil { + return blockedOutcome("refund execution is unavailable") + } + response, err := h.runner.Refund.Refund(ctx, RefundRequest{ + OperationID: request.OperationID, + OrderID: request.Input.OrderID, + Method: request.Input.Method, + Amount: request.Input.Amount, + RefundKey: request.Input.CustomerReference, + }) + if err != nil { + return blockedOutcome("refund request failed") + } + return journey.Outcome{ + State: journey.Passed, + SafeData: map[string]any{ + "order_id": response.OrderID, + "refund_key": response.RefundKey, + "status_code": response.StatusCode, + "provider_status": response.TransactionStatus, + }, + } +} + +func (h Handler) evaluateCharge(request journey.Request, response ChargeResponse) journey.Outcome { + switch h.definition.Intent { + case "card-3ds", "saved-card", "installment": + if response.RedirectURL != "" { + return journey.Outcome{ + State: journey.AwaitingUserAction, + SafeData: map[string]any{ + "order_id": request.Input.OrderID, + "gross_amount": strconv.FormatInt(request.Input.Amount, 10), + }, + Action: &journey.Action{ + Type: "browser", + URL: response.RedirectURL, + Instructions: "complete the Core API 3DS authentication and rerun this journey", + ResumeCommand: "midtrans test " + h.definition.Intent + " --execute", + }, + } + } + case "otc": + return journey.Outcome{ + State: journey.AwaitingUserAction, + SafeData: map[string]any{ + "order_id": request.Input.OrderID, + "gross_amount": strconv.FormatInt(request.Input.Amount, 10), + "payment_code": response.PaymentCode, + "store": response.Store, + }, + } + case "virtual-account": + outcome := journey.Outcome{ + State: journey.AwaitingUserAction, + SafeData: map[string]any{ + "order_id": request.Input.OrderID, + "gross_amount": strconv.FormatInt(request.Input.Amount, 10), + }, + } + if len(response.VANumbers) != 0 { + outcome.SafeData["va_number"] = response.VANumbers[0] + } + return outcome + } + return h.evaluateStatus(StatusResponse{ + OrderID: response.OrderID, + TransactionStatus: response.TransactionStatus, + FraudStatus: response.FraudStatus, + StatusCode: response.StatusCode, + PaymentType: response.PaymentType, + GrossAmount: response.GrossAmount, + }) +} + +func (h Handler) evaluateStatus(status StatusResponse) journey.Outcome { + if status.OrderID == "" { + return blockedOutcome("provider status was invalid") + } + switch status.TransactionStatus { + case "capture": + if status.FraudStatus != "" && status.FraudStatus != "accept" { + return blockedOutcome("provider status blocked the transaction") + } + fallthrough + case "settlement", "refund", "partial_refund": + return journey.Outcome{ + State: journey.Passed, + SafeData: map[string]any{ + "order_id": status.OrderID, + "provider_status": status.TransactionStatus, + "status_code": status.StatusCode, + }, + } + case "pending": + return journey.Outcome{ + State: journey.Reconciling, + SafeData: map[string]any{"order_id": status.OrderID}, + } + default: + return blockedOutcome("provider status blocked the transaction") + } +} + +func inputRequired(message string) journey.Outcome { + return journey.Outcome{ + State: journey.Blocked, + Finding: &contracts.Finding{ + Code: "JOURNEY_INPUT_REQUIRED", + Severity: "blocking", + Message: message, + }, + } +} + +func blockedOutcome(message string) journey.Outcome { + return journey.Outcome{ + State: journey.Blocked, + Finding: &contracts.Finding{ + Code: "JOURNEY_EXECUTION_BLOCKED", + Severity: "blocking", + Message: message, + }, + } +} diff --git a/packs/coreapi/journey_test.go b/packs/coreapi/journey_test.go new file mode 100644 index 0000000..a33ae3b --- /dev/null +++ b/packs/coreapi/journey_test.go @@ -0,0 +1,255 @@ +package coreapi_test + +import ( + "context" + "encoding/json" + "errors" + "testing" + "time" + + "github.com/veritrans/midtrans-cli/internal/contracts" + journeypkg "github.com/veritrans/midtrans-cli/internal/journey" + "github.com/veritrans/midtrans-cli/internal/manifest" + "github.com/veritrans/midtrans-cli/internal/operations" + "github.com/veritrans/midtrans-cli/packs/coreapi" +) + +func TestCardJourneyBlocksExecutionWithoutTokenReference(t *testing.T) { + handler := coreapi.NewCard3DSHandler() + + outcome := handler.Execute(context.Background(), journeypkg.Request{ + OperationID: "operation-card", + ManifestHash: "manifest-hash", + Input: journeypkg.Input{ + OrderID: "order-card", + Amount: 10000, + }, + }, journeypkg.Runtime{}) + + if outcome.State != journeypkg.Blocked { + t.Fatalf("state = %q", outcome.State) + } + if outcome.Finding == nil || outcome.Finding.Code != "JOURNEY_INPUT_REQUIRED" { + t.Fatalf("finding = %#v", outcome.Finding) + } +} + +func TestCardJourneyProducesBrowserActionFrom3DSRedirect(t *testing.T) { + handler := coreapi.NewCard3DSHandler().WithRunner(coreapi.JourneyRunner{ + Charge: stubCharge(func(context.Context, coreapi.ChargeRequest) (coreapi.ChargeResponse, error) { + return coreapi.ChargeResponse{ + OrderID: "order-card", + TransactionStatus: "pending", + PaymentType: "credit_card", + StatusCode: "201", + RedirectURL: "https://api.sandbox.midtrans.com/v2/3ds/redirect/order-card", + GrossAmount: "10000.00", + }, nil + }), + Status: stubStatus(func(context.Context, string) (coreapi.StatusResponse, error) { + return coreapi.StatusResponse{OrderID: "order-card", NotFound: true}, nil + }), + Now: func() time.Time { return time.Unix(0, 0).UTC() }, + }) + + outcome := handler.Execute(context.Background(), journeypkg.Request{ + OperationID: "operation-card", + ManifestHash: "manifest-hash", + Input: journeypkg.Input{ + OrderID: "order-card", + Amount: 10000, + PaymentTokenReference: "token-reference-only", + }, + }, journeypkg.Runtime{}) + + if outcome.State != journeypkg.AwaitingUserAction { + t.Fatalf("state = %q", outcome.State) + } + if outcome.Action == nil || outcome.Action.URL != "https://api.sandbox.midtrans.com/v2/3ds/redirect/order-card" { + t.Fatalf("action = %#v", outcome.Action) + } + if outcome.SafeData["redirect_url"] != nil { + t.Fatalf("safe data leaked redirect url: %#v", outcome.SafeData) + } +} + +func TestOTCJourneyReturnsSafeInstructionsWithoutRawProviderPayload(t *testing.T) { + handler := coreapi.NewOTCHandler().WithRunner(coreapi.JourneyRunner{ + Charge: stubCharge(func(context.Context, coreapi.ChargeRequest) (coreapi.ChargeResponse, error) { + return coreapi.ChargeResponse{ + OrderID: "order-otc", + TransactionStatus: "pending", + PaymentType: "cstore", + StatusCode: "201", + GrossAmount: "162500.00", + PaymentCode: "1234567890", + Store: "alfamart", + }, nil + }), + Status: stubStatus(func(context.Context, string) (coreapi.StatusResponse, error) { + return coreapi.StatusResponse{OrderID: "order-otc", NotFound: true}, nil + }), + }) + + outcome := handler.Execute(context.Background(), journeypkg.Request{ + OperationID: "operation-otc", + ManifestHash: "manifest-hash", + Input: journeypkg.Input{ + OrderID: "order-otc", + Amount: 162500, + Method: "alfamart", + }, + }, journeypkg.Runtime{}) + + if outcome.State != journeypkg.AwaitingUserAction { + t.Fatalf("state = %q", outcome.State) + } + encoded, err := json.Marshal(outcome.SafeData) + if err != nil { + t.Fatal(err) + } + for _, forbidden := range []string{"redirect_url", "raw_payload"} { + if string(encoded) == forbidden { + t.Fatalf("safe data retained %q", forbidden) + } + } + if outcome.SafeData["payment_code"] != "1234567890" || outcome.SafeData["store"] != "alfamart" { + t.Fatalf("safe data = %#v", outcome.SafeData) + } +} + +func TestRefundJourneyRequiresStableRefundKeyAndUsesMethodSpecificPath(t *testing.T) { + handler := coreapi.NewRefundHandler().WithRunner(coreapi.JourneyRunner{ + Refund: stubRefund(func(context.Context, coreapi.RefundRequest) (coreapi.RefundResponse, error) { + return coreapi.RefundResponse{ + OrderID: "order-refund", + RefundKey: "refund-001", + TransactionStatus: "refund", + StatusCode: "200", + }, nil + }), + }) + + blocked := handler.Execute(context.Background(), journeypkg.Request{ + OperationID: "operation-refund", + ManifestHash: "manifest-hash", + Input: journeypkg.Input{ + OrderID: "order-refund", + Amount: 10000, + Method: "credit_card", + }, + }, journeypkg.Runtime{}) + if blocked.State != journeypkg.Blocked || blocked.Finding == nil || blocked.Finding.Code != "JOURNEY_INPUT_REQUIRED" { + t.Fatalf("blocked outcome = %#v", blocked) + } + + passed := handler.Execute(context.Background(), journeypkg.Request{ + OperationID: "operation-refund", + ManifestHash: "manifest-hash", + Input: journeypkg.Input{ + OrderID: "order-refund", + Amount: 10000, + Method: "credit_card", + CustomerReference: "refund-001", + }, + }, journeypkg.Runtime{}) + if passed.State != journeypkg.Passed { + t.Fatalf("passed outcome = %#v", passed) + } + if passed.SafeData["refund_key"] != "refund-001" { + t.Fatalf("safe data = %#v", passed.SafeData) + } +} + +func TestCardJourneyReconcilesAmbiguousMutationByStatusBeforeRetry(t *testing.T) { + handler := coreapi.NewCard3DSHandler().WithRunner(coreapi.JourneyRunner{ + Charge: stubCharge(func(context.Context, coreapi.ChargeRequest) (coreapi.ChargeResponse, error) { + return coreapi.ChargeResponse{}, sandboxAmbiguous("operation-card") + }), + Status: stubStatus(func(context.Context, string) (coreapi.StatusResponse, error) { + return coreapi.StatusResponse{ + OrderID: "order-card", + TransactionStatus: "capture", + FraudStatus: "accept", + StatusCode: "200", + }, nil + }), + }) + + outcome := handler.Execute(context.Background(), journeypkg.Request{ + OperationID: "operation-card", + ManifestHash: "manifest-hash", + Input: journeypkg.Input{ + OrderID: "order-card", + Amount: 10000, + PaymentTokenReference: "token-reference-only", + }, + }, journeypkg.Runtime{}) + + if outcome.State != journeypkg.Passed { + t.Fatalf("state = %q", outcome.State) + } +} + +type stubCharge func(context.Context, coreapi.ChargeRequest) (coreapi.ChargeResponse, error) + +func (s stubCharge) Charge(ctx context.Context, request coreapi.ChargeRequest) (coreapi.ChargeResponse, error) { + return s(ctx, request) +} + +type stubStatus func(context.Context, string) (coreapi.StatusResponse, error) + +func (s stubStatus) Status(ctx context.Context, orderID string) (coreapi.StatusResponse, error) { + return s(ctx, orderID) +} + +type stubRefund func(context.Context, coreapi.RefundRequest) (coreapi.RefundResponse, error) + +func (s stubRefund) Refund(ctx context.Context, request coreapi.RefundRequest) (coreapi.RefundResponse, error) { + return s(ctx, request) +} + +func sandboxAmbiguous(operationID string) error { + return errors.New("replace me") +} + +type stubLedger struct{} + +func (stubLedger) Load(context.Context, string) (operations.Record, bool, error) { return operations.Record{}, false, nil } +func (stubLedger) Reserve(context.Context, operations.Record) (bool, error) { return true, nil } +func (stubLedger) Save(context.Context, operations.Record) error { return nil } + +func validCoreManifest() manifest.Manifest { + value := manifest.Default() + value.Application.BaseURL = "http://127.0.0.1:8080" + value.CredentialSets["classic"] = manifest.CredentialSet{ + Type: "classic", + Environment: "sandbox", + ServerKey: "env:MIDTRANS_SERVER_KEY", + ClientKey: "env:MIDTRANS_CLIENT_KEY", + } + value.Integrations["core-api"] = manifest.Integration{ + ConfigVersion: 1, + Credentials: "classic", + PaymentMethods: []string{ + "card", + "virtual-account", + "otc", + }, + Callbacks: map[string]string{ + "notification": "/midtrans/notification", + }, + } + value.Routing["card-3ds"] = "core-api" + return value +} + +func requireFindingCode(t *testing.T, findings []contracts.Finding, code string) { + t.Helper() + for _, finding := range findings { + if finding.Code == code { + return + } + } + t.Fatalf("findings = %#v, want %s", findings, code) +} diff --git a/packs/coreapi/notification.go b/packs/coreapi/notification.go new file mode 100644 index 0000000..fb4644d --- /dev/null +++ b/packs/coreapi/notification.go @@ -0,0 +1,57 @@ +package coreapi + +import ( + "bytes" + "crypto/subtle" + "crypto/sha512" + "encoding/hex" + "encoding/json" + "errors" + "io" +) + +type Notification struct { + TransactionTime string `json:"transaction_time,omitempty"` + TransactionID string `json:"transaction_id,omitempty"` + OrderID string `json:"order_id"` + StatusCode string `json:"status_code"` + GrossAmount string `json:"gross_amount"` + PaymentType string `json:"payment_type,omitempty"` + TransactionStatus string `json:"transaction_status"` + FraudStatus string `json:"fraud_status,omitempty"` + Store string `json:"store,omitempty"` + PaymentCode string `json:"payment_code,omitempty"` + SignatureKey string `json:"signature_key"` +} + +func ComputeSignature(orderID, statusCode, grossAmount, serverKey string) string { + sum := sha512.Sum512([]byte(orderID + statusCode + grossAmount + serverKey)) + return hex.EncodeToString(sum[:]) +} + +func VerifyNotification(payload []byte, serverKey string) (Notification, error) { + var value Notification + decoder := json.NewDecoder(bytes.NewReader(payload)) + decoder.DisallowUnknownFields() + if err := decoder.Decode(&value); err != nil { + return Notification{}, errors.New("WEBHOOK_PAYLOAD_INVALID") + } + if err := decoder.Decode(&struct{}{}); err != io.EOF { + return Notification{}, errors.New("WEBHOOK_PAYLOAD_INVALID") + } + if value.OrderID == "" || value.StatusCode == "" || value.GrossAmount == "" || + value.TransactionStatus == "" || value.SignatureKey == "" { + return Notification{}, errors.New("WEBHOOK_PAYLOAD_INVALID") + } + expected := ComputeSignature( + value.OrderID, + value.StatusCode, + value.GrossAmount, + serverKey, + ) + if subtle.ConstantTimeCompare([]byte(expected), []byte(value.SignatureKey)) != 1 { + return Notification{}, errors.New("WEBHOOK_SIGNATURE_INVALID") + } + value.SignatureKey = "" + return value, nil +} diff --git a/packs/coreapi/notification_test.go b/packs/coreapi/notification_test.go new file mode 100644 index 0000000..ab16ed0 --- /dev/null +++ b/packs/coreapi/notification_test.go @@ -0,0 +1,62 @@ +package coreapi_test + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/veritrans/midtrans-cli/packs/coreapi" +) + +func TestComputeSignaturePreservesProviderGrossAmountString(t *testing.T) { + first := coreapi.ComputeSignature("order-1", "200", "10000.00", "server-key") + second := coreapi.ComputeSignature("order-1", "200", "10000", "server-key") + if first == second { + t.Fatal("gross_amount formatting was normalized") + } + if len(first) != 128 { + t.Fatalf("signature length = %d", len(first)) + } +} + +func TestVerifyNotificationRejectsInvalidSignature(t *testing.T) { + payload := []byte(`{"order_id":"order-1","status_code":"200","gross_amount":"10000.00","transaction_status":"settlement","signature_key":"invalid"}`) + _, err := coreapi.VerifyNotification(payload, "server-key") + if err == nil || !strings.Contains(err.Error(), "WEBHOOK_SIGNATURE_INVALID") { + t.Fatalf("err = %v", err) + } +} + +func TestNotificationFixturesVerifyAgainstExactProviderStrings(t *testing.T) { + fixtures := []string{"card-3ds.json", "otc-alfamart.json"} + for _, fixtureName := range fixtures { + t.Run(fixtureName, func(t *testing.T) { + data, err := os.ReadFile(filepath.Join("..", "..", "testdata", "coreapi", fixtureName)) + if err != nil { + t.Fatal(err) + } + var fixture coreapi.Notification + if err := json.Unmarshal(data, &fixture); err != nil { + t.Fatal(err) + } + expected := coreapi.ComputeSignature( + fixture.OrderID, + fixture.StatusCode, + fixture.GrossAmount, + "fixture-server-key", + ) + if fixture.SignatureKey != expected { + t.Fatalf("fixture signature = %q, want %q", fixture.SignatureKey, expected) + } + verified, err := coreapi.VerifyNotification(data, "fixture-server-key") + if err != nil { + t.Fatal(err) + } + if verified.SignatureKey != "" { + t.Fatal("verified notification retained provider signature") + } + }) + } +} diff --git a/packs/coreapi/pack.go b/packs/coreapi/pack.go new file mode 100644 index 0000000..a6843a2 --- /dev/null +++ b/packs/coreapi/pack.go @@ -0,0 +1,79 @@ +package coreapi + +import ( + "github.com/veritrans/midtrans-cli/internal/contracts" + "github.com/veritrans/midtrans-cli/internal/inspection" + "github.com/veritrans/midtrans-cli/internal/journey" + "github.com/veritrans/midtrans-cli/internal/manifest" + "github.com/veritrans/midtrans-cli/internal/packs" +) + +type Pack struct{} + +func New() Pack { return Pack{} } + +func (Pack) Descriptor() packs.Descriptor { + return packs.Descriptor{ + ID: "core-api", + Version: "0.1.0", + Capabilities: []contracts.Capability{ + {ID: "core-api.card-3ds.verify.v1", Description: "run and verify a Core API card 3DS journey", Pack: "core-api"}, + {ID: "core-api.saved-card.verify.v1", Description: "run and verify a Core API saved-card journey", Pack: "core-api"}, + {ID: "core-api.installment.verify.v1", Description: "run and verify a Core API installment journey", Pack: "core-api"}, + {ID: "core-api.otc.verify.v1", Description: "run and verify a Core API OTC journey", Pack: "core-api"}, + {ID: "core-api.virtual-account.verify.v1", Description: "run and verify a Core API virtual-account journey", Pack: "core-api"}, + {ID: "core-api.refund.verify.v1", Description: "run and verify a Core API refund journey", Pack: "core-api"}, + }, + Journeys: []string{ + "core-api.card-3ds", + "core-api.saved-card", + "core-api.installment", + "core-api.otc", + "core-api.virtual-account", + "core-api.refund", + }, + SandboxHosts: []string{"api.sandbox.midtrans.com"}, + SensitiveKeys: []string{"signature_key"}, + Sources: []contracts.PublicSource{ + {ID: "coreapi-card-charge", URL: "https://docs.midtrans.com/reference/charge-transactions-on-card", Rules: []string{"coreapi.card.charge", "coreapi.basic-auth"}}, + {ID: "coreapi-card-3ds", URL: "https://docs.midtrans.com/reference/card-feature-3d-secure-3ds", Rules: []string{"coreapi.card.3ds", "coreapi.card.redirect"}}, + {ID: "coreapi-one-click", URL: "https://docs.midtrans.com/reference/card-feature-one-click", Rules: []string{"coreapi.saved-card.token-only"}}, + {ID: "coreapi-alfamart", URL: "https://docs.midtrans.com/reference/alfamart-1", Rules: []string{"coreapi.otc.charge", "coreapi.otc.payment-code"}}, + {ID: "coreapi-bni-va", URL: "https://docs.midtrans.com/reference/bni-virtual-account-1", Rules: []string{"coreapi.va.charge", "coreapi.va.instructions"}}, + {ID: "coreapi-status", URL: "https://docs.midtrans.com/reference/get-transaction-status", Rules: []string{"coreapi.status.reconcile", "coreapi.refund.status"}}, + {ID: "coreapi-refund", URL: "https://docs.midtrans.com/reference/refund-transaction", Rules: []string{"coreapi.refund.async", "coreapi.refund.idempotency"}}, + {ID: "coreapi-direct-refund", URL: "https://docs.midtrans.com/reference/direct-refund-transaction", Rules: []string{"coreapi.refund.direct"}}, + {ID: "coreapi-notifications", URL: "https://docs.midtrans.com/docs/https-notification-webhooks", Rules: []string{"coreapi.notification.signature", "common.webhook-idempotency"}}, + }, + } +} + +func (Pack) Evaluate(value manifest.Manifest, _ inspection.Report) []contracts.Finding { + integration, ok := value.IntegrationFor("core-api") + if !ok { + return []contracts.Finding{{ + Code: "CORE_API_PRODUCT_NOT_SELECTED", + Severity: "blocking", + Message: "integrations must include core-api", + }} + } + if integration.Callbacks["notification"] == "" { + return []contracts.Finding{{ + Code: "CORE_API_NOTIFICATION_ROUTE_MISSING", + Severity: "blocking", + Message: "integrations.core-api.callbacks.notification is required", + }} + } + return nil +} + +func (Pack) Handlers() []journey.Handler { + return []journey.Handler{ + NewCard3DSHandler(), + NewSavedCardHandler(), + NewInstallmentHandler(), + NewOTCHandler(), + NewVirtualAccountHandler(), + NewRefundHandler(), + } +} diff --git a/packs/coreapi/pack_test.go b/packs/coreapi/pack_test.go new file mode 100644 index 0000000..7f40088 --- /dev/null +++ b/packs/coreapi/pack_test.go @@ -0,0 +1,65 @@ +package coreapi_test + +import ( + "reflect" + "testing" + + "github.com/veritrans/midtrans-cli/internal/inspection" + "github.com/veritrans/midtrans-cli/packs/coreapi" +) + +func TestCoreAPIDescriptorMatchesCompiledContract(t *testing.T) { + descriptor := coreapi.New().Descriptor() + if descriptor.ID != "core-api" || descriptor.Version != "0.1.0" { + t.Fatalf("identity = %q@%q", descriptor.ID, descriptor.Version) + } + + gotCapabilities := make([]string, 0, len(descriptor.Capabilities)) + for _, capability := range descriptor.Capabilities { + gotCapabilities = append(gotCapabilities, capability.ID) + if capability.Pack != "core-api" { + t.Fatalf("capability %#v has wrong pack", capability) + } + } + wantCapabilities := []string{ + "core-api.card-3ds.verify.v1", + "core-api.saved-card.verify.v1", + "core-api.installment.verify.v1", + "core-api.otc.verify.v1", + "core-api.virtual-account.verify.v1", + "core-api.refund.verify.v1", + } + if !reflect.DeepEqual(gotCapabilities, wantCapabilities) { + t.Fatalf("capabilities = %#v, want %#v", gotCapabilities, wantCapabilities) + } + + wantJourneys := []string{ + "core-api.card-3ds", + "core-api.saved-card", + "core-api.installment", + "core-api.otc", + "core-api.virtual-account", + "core-api.refund", + } + if !reflect.DeepEqual(descriptor.Journeys, wantJourneys) { + t.Fatalf("journeys = %#v, want %#v", descriptor.Journeys, wantJourneys) + } + wantHosts := []string{"api.sandbox.midtrans.com"} + if !reflect.DeepEqual(descriptor.SandboxHosts, wantHosts) { + t.Fatalf("sandbox hosts = %#v, want %#v", descriptor.SandboxHosts, wantHosts) + } + wantSensitiveKeys := []string{"signature_key"} + if !reflect.DeepEqual(descriptor.SensitiveKeys, wantSensitiveKeys) { + t.Fatalf("sensitive keys = %#v, want %#v", descriptor.SensitiveKeys, wantSensitiveKeys) + } +} + +func TestCoreAPIEvaluationRequiresNotificationRoute(t *testing.T) { + value := validCoreManifest() + integration := value.Integrations["core-api"] + integration.Callbacks["notification"] = "" + value.Integrations["core-api"] = integration + + findings := coreapi.New().Evaluate(value, inspection.Report{}) + requireFindingCode(t, findings, "CORE_API_NOTIFICATION_ROUTE_MISSING") +} diff --git a/testdata/coreapi/card-3ds.json b/testdata/coreapi/card-3ds.json new file mode 100644 index 0000000..d3e91ad --- /dev/null +++ b/testdata/coreapi/card-3ds.json @@ -0,0 +1,11 @@ +{ + "transaction_time": "2026-07-27 09:00:00", + "gross_amount": "10000.00", + "order_id": "order-card-3ds", + "payment_type": "credit_card", + "signature_key": "e36e34cf9d5da0c9d38cc310b40df723214e1d59f8caaa3eb1d6f04b1de2caf31030b5ec15e7eca20250265effd808a2de22249f7ef79e6826f48ed58886cdcf", + "status_code": "200", + "transaction_id": "txn-card-3ds", + "transaction_status": "capture", + "fraud_status": "accept" +} diff --git a/testdata/coreapi/otc-alfamart.json b/testdata/coreapi/otc-alfamart.json new file mode 100644 index 0000000..5fd3359 --- /dev/null +++ b/testdata/coreapi/otc-alfamart.json @@ -0,0 +1,12 @@ +{ + "transaction_time": "2026-07-27 09:05:00", + "gross_amount": "162500.00", + "order_id": "order-otc-alfamart", + "payment_type": "cstore", + "signature_key": "f09d64ed8c382214da72a30cab1ceae28acb7581c3f4e4fe21c45910f3828816a0808d6fb0a3ee9c11ac13034f1e4c854222cc0b85f0444548e51691e6fa6716", + "status_code": "200", + "transaction_id": "txn-otc-alfamart", + "transaction_status": "settlement", + "store": "alfamart", + "payment_code": "1234567890" +} diff --git a/tools/source-drift/main.go b/tools/source-drift/main.go index 8764254..ba579d0 100644 --- a/tools/source-drift/main.go +++ b/tools/source-drift/main.go @@ -7,6 +7,7 @@ import ( "os" "github.com/veritrans/midtrans-cli/internal/sourceprovenance" + "github.com/veritrans/midtrans-cli/packs/coreapi" "github.com/veritrans/midtrans-cli/packs/snap" ) @@ -24,7 +25,10 @@ func main() { fmt.Fprintln(os.Stderr, "baseline could not be read") os.Exit(1) } - sources := snap.New().Descriptor().Sources + sources := append( + snap.New().Descriptor().Sources, + coreapi.New().Descriptor().Sources..., + ) if err := sourceprovenance.ValidateBaseline(baseline, sources); err != nil { fmt.Fprintln(os.Stderr, err) os.Exit(1) From 1c04fbbe29af7b3c338aaa357ac3bad87f7a1b47 Mon Sep 17 00:00:00 2001 From: "m.salis" Date: Mon, 27 Jul 2026 10:35:36 +0700 Subject: [PATCH 40/73] fix: resolve coreapi runtime credentials --- .../task-6-report.md | 20 ++ internal/app/app_test.go | 317 +++++++++++++++++- internal/app/commands_webhook.go | 108 +++++- packs/coreapi/journey.go | 131 ++++++-- packs/coreapi/journey_test.go | 108 +++++- 5 files changed, 641 insertions(+), 43 deletions(-) diff --git a/.superpowers/sdd/2026-07-26-midtrans-cli-multi-product-parity/task-6-report.md b/.superpowers/sdd/2026-07-26-midtrans-cli-multi-product-parity/task-6-report.md index 459382c..17960db 100644 --- a/.superpowers/sdd/2026-07-26-midtrans-cli-multi-product-parity/task-6-report.md +++ b/.superpowers/sdd/2026-07-26-midtrans-cli-multi-product-parity/task-6-report.md @@ -35,3 +35,23 @@ ## Commit - Planned message: `feat: add classic Core API journeys` + +## Round 1 Fixes + +- Production `core-api` handlers now build their runtime clients from `journey.Runtime` plus the configured `core-api` integration credential set instead of using prewired test runners. +- Card, saved-card, and installment execution now resolve `payment_token_reference` through `Runtime.ResolveCredential` and send only the resolved in-memory token to Core API charge requests. +- Webhook verification now selects between Snap and Core API verification by configured product, supports explicit `--product`, and rejects hybrid ambiguity without falling back to checkout routing. +- Ambiguous Core API mutations are reconciled by status after a not-found precheck and do not trigger a blind second mutation. + +## Round 1 Validation + +- Command: `go test ./packs/coreapi ./internal/packs ./internal/app -count=1` + Result: pass +- Command: `go test ./... -count=1` + Result: pass + +## Round 1 Self-Review + +- Verified that resolved payment tokens are used only in-memory and are not copied into result payloads or operation persistence. +- Verified that webhook verification now uses the integration-selected classic credential set for both Snap-only and Core API-only manifests. +- Verified that hybrid webhook verification now requires explicit product selection and no longer falls back to checkout credential helpers. diff --git a/internal/app/app_test.go b/internal/app/app_test.go index c9cd2fb..201b471 100644 --- a/internal/app/app_test.go +++ b/internal/app/app_test.go @@ -2186,11 +2186,12 @@ func TestWebhookVerifyReturnsOnlyPublicSafeNotificationFields(t *testing.T) { } data, ok := result.Data.(map[string]any) if !ok || + data["product"] != "snap" || data["order_id"] != "snap-fixture-001" || data["transaction_status"] != "settlement" || data["fraud_status"] != "accept" || data["signature_valid"] != true || - len(data) != 4 { + len(data) != 5 { t.Fatalf("data = %#v", result.Data) } encoded, err := json.Marshal(result) @@ -2226,6 +2227,263 @@ func TestWebhookVerifyReturnsOnlyPublicSafeNotificationFields(t *testing.T) { } } +func TestWebhookVerifySupportsCoreAPIOnlyManifestAndExplicitProductForHybrid(t *testing.T) { + t.Run("core-api only", func(t *testing.T) { + project := t.TempDir() + if _, err := manifest.Init(project); err != nil { + t.Fatal(err) + } + configureCoreAPIManifestProject(t, project, "http://127.0.0.1:3000") + serverKey := "SB-Mid-server-COREAPI-WEBHOOK" + file, signature := writeSignedCoreAPINotification(t, project, serverKey) + + result, exit := executeJSONWithGetenv( + t, + project, + func(key string) (string, bool) { + return serverKey, key == "MIDTRANS_SERVER_KEY" + }, + "webhook", "verify", "--file", file, + ) + if exit != 0 || result.Command != "webhook.verify" || result.Status != contracts.StatusPass { + t.Fatalf("exit = %d, result = %#v", exit, result) + } + data, ok := result.Data.(map[string]any) + if !ok || data["order_id"] != "coreapi-fixture-001" || data["transaction_status"] != "capture" || data["fraud_status"] != "accept" || data["signature_valid"] != true { + t.Fatalf("data = %#v", result.Data) + } + encoded, err := json.Marshal(result) + if err != nil { + t.Fatal(err) + } + for _, forbidden := range []string{serverKey, signature, "signature_key"} { + if bytes.Contains(encoded, []byte(forbidden)) { + t.Fatalf("coreapi webhook verify leaked %q: %s", forbidden, encoded) + } + } + }) + + t.Run("hybrid requires explicit product", func(t *testing.T) { + project := t.TempDir() + if _, err := manifest.Init(project); err != nil { + t.Fatal(err) + } + configureSnapManifestProject(t, project, "http://127.0.0.1:3000") + configureManifest(t, project, func(value *manifest.Manifest) { + value.CredentialSets["core-classic"] = manifest.CredentialSet{ + Type: "classic", + Environment: "sandbox", + ServerKey: "env:MIDTRANS_CORE_SERVER_KEY", + ClientKey: "env:MIDTRANS_CORE_CLIENT_KEY", + } + value.Integrations["core-api"] = manifest.Integration{ + ConfigVersion: 1, + Credentials: "core-classic", + PaymentMethods: []string{"card"}, + Callbacks: map[string]string{ + "notification": "/api/payments/midtrans/core-notification", + }, + } + }) + file, _ := writeSignedCoreAPINotification(t, project, "SB-Mid-server-COREAPI-WEBHOOK") + + ambiguous, exit := executeJSONWithGetenv( + t, + project, + func(key string) (string, bool) { + switch key { + case "MIDTRANS_SERVER_KEY": + return "SB-Mid-server-SNAP-WEBHOOK", true + case "MIDTRANS_CORE_SERVER_KEY": + return "SB-Mid-server-COREAPI-WEBHOOK", true + default: + return "", false + } + }, + "webhook", "verify", "--file", file, + ) + if exit != 3 || len(ambiguous.Findings) != 1 || ambiguous.Findings[0].Code != "WEBHOOK_PRODUCT_AMBIGUOUS" { + t.Fatalf("exit = %d, result = %#v", exit, ambiguous) + } + + explicit, exit := executeJSONWithGetenv( + t, + project, + func(key string) (string, bool) { + switch key { + case "MIDTRANS_SERVER_KEY": + return "SB-Mid-server-SNAP-WEBHOOK", true + case "MIDTRANS_CORE_SERVER_KEY": + return "SB-Mid-server-COREAPI-WEBHOOK", true + default: + return "", false + } + }, + "webhook", "verify", "--product", "core-api", "--file", file, + ) + if exit != 0 || explicit.Status != contracts.StatusPass { + t.Fatalf("exit = %d, result = %#v", exit, explicit) + } + }) +} + +func TestMerchantCoreAPIExecuteResolvesServerKeyAndPaymentTokenReference(t *testing.T) { + const coreServerKeyCanary = "SB-Mid-server-CORE-API-CANARY-DO-NOT-PRINT" + project := t.TempDir() + if _, err := manifest.Init(project); err != nil { + t.Fatal(err) + } + configureCoreAPIManifestProject(t, project, "http://127.0.0.1:3000") + statusCalls := 0 + chargeCalls := 0 + deps := app.Dependencies{ + Version: version.Info{Version: "0.1.0-test"}, + Packs: testRegistry(t), + Getenv: func(key string) (string, bool) { + switch key { + case "MIDTRANS_SERVER_KEY": + return coreServerKeyCanary, true + case "MIDTRANS_PAYMENT_TOKEN": + return "tokn_resolved_cli_123", true + default: + return "", false + } + }, + HTTP: appDoerFunc(func(request *http.Request) (*http.Response, error) { + switch { + case request.Method == http.MethodGet: + statusCalls++ + return &http.Response{ + StatusCode: http.StatusNotFound, + Header: make(http.Header), + Body: io.NopCloser(strings.NewReader(`{"status_code":"404","status_message":"not found"}`)), + }, nil + case request.Method == http.MethodPost: + chargeCalls++ + username, password, ok := request.BasicAuth() + if !ok || username != coreServerKeyCanary || password != "" { + t.Fatal("coreapi execute did not use resolved Basic auth") + } + var payload struct { + CreditCard struct { + TokenID string `json:"token_id"` + } `json:"credit_card"` + } + if err := json.NewDecoder(request.Body).Decode(&payload); err != nil { + t.Fatal(err) + } + if payload.CreditCard.TokenID != "tokn_resolved_cli_123" { + t.Fatalf("token_id = %q", payload.CreditCard.TokenID) + } + if payload.CreditCard.TokenID == "env:MIDTRANS_PAYMENT_TOKEN" { + t.Fatal("payment token reference leaked into provider payload") + } + return &http.Response{ + StatusCode: http.StatusCreated, + Header: make(http.Header), + Body: io.NopCloser(strings.NewReader(`{"status_code":"201","transaction_status":"pending","order_id":"coreapi-order-001","payment_type":"credit_card","gross_amount":"10000.00","redirect_url":"https://api.sandbox.midtrans.com/v2/3ds/redirect/coreapi-order-001"}`)), + }, nil + default: + t.Fatalf("unexpected method %s", request.Method) + return nil, nil + } + }), + } + + result, exit := executeJSONWithDependencies( + t, + deps, + "test", "card-3ds", + "--product", "core-api", + "--amount", "10000", + "--order-id", "coreapi-order-001", + "--payment-token-reference", "env:MIDTRANS_PAYMENT_TOKEN", + "--execute", + "--project-dir", project, + ) + if exit != 3 || result.Command != "test.card_3ds" { + t.Fatalf("exit = %d, result = %#v", exit, result) + } + data := requireJourneyData(t, result) + if data["product"] != "core-api" || data["journey"] != "core-api.card-3ds" || data["state"] != "checkout_required" { + t.Fatalf("data = %#v", data) + } + if statusCalls != 1 || chargeCalls != 1 { + t.Fatalf("statusCalls = %d, chargeCalls = %d", statusCalls, chargeCalls) + } + encoded, err := json.Marshal(result) + if err != nil { + t.Fatal(err) + } + for _, forbidden := range []string{"tokn_resolved_cli_123", "env:MIDTRANS_PAYMENT_TOKEN"} { + if bytes.Contains(encoded, []byte(forbidden)) { + t.Fatalf("result leaked %q: %s", forbidden, encoded) + } + } +} + +func TestMerchantCoreAPIAmbiguousChargeReconcilesByStatusWithoutSecondMutation(t *testing.T) { + const coreServerKeyCanary = "SB-Mid-server-CORE-API-CANARY-DO-NOT-PRINT" + project := t.TempDir() + if _, err := manifest.Init(project); err != nil { + t.Fatal(err) + } + configureCoreAPIManifestProject(t, project, "http://127.0.0.1:3000") + statusCalls := 0 + chargeCalls := 0 + deps := app.Dependencies{ + Version: version.Info{Version: "0.1.0-test"}, + Packs: testRegistry(t), + Getenv: func(key string) (string, bool) { + switch key { + case "MIDTRANS_SERVER_KEY": + return coreServerKeyCanary, true + case "MIDTRANS_PAYMENT_TOKEN": + return "tokn_resolved_cli_123", true + default: + return "", false + } + }, + HTTP: appDoerFunc(func(request *http.Request) (*http.Response, error) { + switch request.Method { + case http.MethodGet: + statusCalls++ + body := `{"status_code":"404","status_message":"not found"}` + statusCode := http.StatusNotFound + if statusCalls == 2 { + statusCode = http.StatusOK + body = `{"order_id":"coreapi-order-ambiguous","transaction_status":"capture","fraud_status":"accept","status_code":"200","payment_type":"credit_card","gross_amount":"10000.00"}` + } + return &http.Response{StatusCode: statusCode, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(body))}, nil + case http.MethodPost: + chargeCalls++ + return nil, appTimeoutError{message: "timeout-" + coreServerKeyCanary} + default: + t.Fatalf("unexpected method %s", request.Method) + return nil, nil + } + }), + } + + result, exit := executeJSONWithDependencies( + t, + deps, + "test", "card-3ds", + "--product", "core-api", + "--amount", "10000", + "--order-id", "coreapi-order-ambiguous", + "--payment-token-reference", "env:MIDTRANS_PAYMENT_TOKEN", + "--execute", + "--project-dir", project, + ) + if exit != 0 || result.Status != contracts.StatusPass { + t.Fatalf("exit = %d, result = %#v", exit, result) + } + if statusCalls != 2 || chargeCalls != 1 { + t.Fatalf("statusCalls = %d, chargeCalls = %d", statusCalls, chargeCalls) + } +} + func TestMerchantWebhookTestRequiresFlagInputsOutsideInteractiveTerminal(t *testing.T) { project := createJourneyProject(t, "http://127.0.0.1:1") result, exit := executeJSONWithDependencies( @@ -2603,6 +2861,32 @@ func writeSignedNotification(t *testing.T, project, serverKey string) (string, s return file, signature } +func writeSignedCoreAPINotification(t *testing.T, project, serverKey string) (string, string) { + t.Helper() + signature := coreapi.ComputeSignature( + "coreapi-fixture-001", + "200", + "10000.00", + serverKey, + ) + payload, err := json.Marshal(map[string]string{ + "order_id": "coreapi-fixture-001", + "status_code": "200", + "gross_amount": "10000.00", + "transaction_status": "capture", + "fraud_status": "accept", + "signature_key": signature, + }) + if err != nil { + t.Fatal(err) + } + file := filepath.Join(project, "core-notification.json") + if err := os.WriteFile(file, payload, 0o600); err != nil { + t.Fatal(err) + } + return file, signature +} + func executeJSON(t *testing.T, args ...string) (contracts.Result, int) { t.Helper() var stdout, stderr bytes.Buffer @@ -2622,6 +2906,12 @@ func executeJSON(t *testing.T, args ...string) (contracts.Result, int) { return result, exit } +type appTimeoutError struct{ message string } + +func (e appTimeoutError) Error() string { return e.message } +func (appTimeoutError) Timeout() bool { return true } +func (appTimeoutError) Temporary() bool { return false } + func executeJSONWithDependencies( t *testing.T, deps app.Dependencies, @@ -2847,6 +3137,31 @@ func configureSnapManifestProject(t *testing.T, project string, baseURL string) }) } +func configureCoreAPIManifestProject(t *testing.T, project string, baseURL string) { + t.Helper() + configureManifest(t, project, func(value *manifest.Manifest) { + value.Application.BaseURL = baseURL + value.CredentialSets["classic"] = manifest.CredentialSet{ + Type: "classic", + Environment: "sandbox", + ServerKey: "env:MIDTRANS_SERVER_KEY", + ClientKey: "env:MIDTRANS_CLIENT_KEY", + } + delete(value.Integrations, "snap") + value.Integrations["core-api"] = manifest.Integration{ + ConfigVersion: 1, + Credentials: "classic", + PaymentMethods: []string{"card", "virtual-account", "otc"}, + Callbacks: map[string]string{ + "notification": "/api/payments/midtrans/notification", + }, + } + value.Routing["card-3ds"] = "core-api" + delete(value.Routing, "checkout") + value.Verification.Required = []string{"core-api.card-3ds"} + }) +} + func configureManifest(t *testing.T, project string, mutate func(*manifest.Manifest)) { t.Helper() value, err := manifest.Load(project) diff --git a/internal/app/commands_webhook.go b/internal/app/commands_webhook.go index 963a1e9..5ee4428 100644 --- a/internal/app/commands_webhook.go +++ b/internal/app/commands_webhook.go @@ -12,9 +12,11 @@ import ( "github.com/spf13/cobra" "github.com/veritrans/midtrans-cli/internal/contracts" + "github.com/veritrans/midtrans-cli/internal/manifest" "github.com/veritrans/midtrans-cli/internal/policy" "github.com/veritrans/midtrans-cli/internal/project" "github.com/veritrans/midtrans-cli/internal/webhook" + "github.com/veritrans/midtrans-cli/packs/coreapi" "github.com/veritrans/midtrans-cli/packs/snap" ) @@ -42,6 +44,7 @@ func newWebhookCommand(flags *globalFlags, deps Dependencies) *cobra.Command { func newWebhookVerifyCommand(flags *globalFlags, deps Dependencies) *cobra.Command { var file string + var product string command := &cobra.Command{ Use: "verify", Args: cobra.NoArgs, @@ -65,12 +68,20 @@ func newWebhookVerifyCommand(flags *globalFlags, deps Dependencies) *cobra.Comma return writeResult(deps, flags, result) } + selectedProduct, reference, verify, finding := resolveWebhookVerifier(value, product) + if finding != nil { + result := contracts.NewResult("webhook.verify", contracts.StatusBlocked) + result.CLIVersion = deps.Version.Version + result.ManifestVersion = value.SchemaVersion + result.Findings = []contracts.Finding{*finding} + return writeResult(deps, flags, result) + } serverKey, credentialResult := resolveSandboxServerKey( cmd.Context(), "webhook.verify", value.SchemaVersion, flags.projectDir, - checkoutServerKeyReference(value), + reference, deps, ) if credentialResult != nil { @@ -88,7 +99,7 @@ func newWebhookVerifyCommand(flags *globalFlags, deps Dependencies) *cobra.Comma result.ManifestVersion = value.SchemaVersion return writeResult(deps, flags, result) } - notification, err := snap.VerifyNotification(payload, rawServerKey) + notification, err := verify(payload, rawServerKey) if err != nil { code := "WEBHOOK_PAYLOAD_INVALID" message := "notification payload is invalid" @@ -111,6 +122,7 @@ func newWebhookVerifyCommand(flags *globalFlags, deps Dependencies) *cobra.Comma result.CLIVersion = deps.Version.Version result.ManifestVersion = value.SchemaVersion result.Data = map[string]any{ + "product": selectedProduct, "order_id": notification.OrderID, "transaction_status": notification.TransactionStatus, "fraud_status": notification.FraudStatus, @@ -120,10 +132,102 @@ func newWebhookVerifyCommand(flags *globalFlags, deps Dependencies) *cobra.Comma }, } command.Flags().StringVar(&file, "file", "", "notification JSON file") + command.Flags().StringVar(&product, "product", "", "explicit product for hybrid manifests") _ = command.MarkFlagRequired("file") return withProjectMode(command, project.Existing, "webhook.verify") } +type verifiedWebhook struct { + OrderID string + TransactionStatus string + FraudStatus string +} + +func resolveWebhookVerifier( + value manifest.Manifest, + explicit string, +) (string, string, func([]byte, string) (verifiedWebhook, error), *contracts.Finding) { + candidates := []string{} + for _, product := range []string{"snap", "core-api"} { + if integration, ok := value.IntegrationFor(product); ok && integration.Credentials != "" { + candidates = append(candidates, product) + } + } + if explicit != "" { + for _, product := range candidates { + if product == explicit { + return webhookVerifierFor(value, explicit) + } + } + return "", "", nil, &contracts.Finding{ + Code: "CAPABILITY_UNAVAILABLE", + Severity: "blocking", + Message: "requested webhook product is unavailable for this project", + } + } + if len(candidates) == 1 { + return webhookVerifierFor(value, candidates[0]) + } + if len(candidates) > 1 { + return "", "", nil, &contracts.Finding{ + Code: "WEBHOOK_PRODUCT_AMBIGUOUS", + Severity: "blocking", + Message: "multiple configured products support webhook verification; rerun with --product", + } + } + return "", "", nil, &contracts.Finding{ + Code: "CAPABILITY_UNAVAILABLE", + Severity: "blocking", + Message: "no configured product supports webhook verification", + } +} + +func webhookVerifierFor( + value manifest.Manifest, + product string, +) (string, string, func([]byte, string) (verifiedWebhook, error), *contracts.Finding) { + credentials, ok := value.CredentialSetForIntegration(product) + if !ok || credentials.ServerKey == "" { + return "", "", nil, &contracts.Finding{ + Code: "CREDENTIAL_MISSING", + Severity: "blocking", + Message: "the configured server-key environment reference is not set", + } + } + switch product { + case "snap": + return product, credentials.ServerKey, func(payload []byte, serverKey string) (verifiedWebhook, error) { + notification, err := snap.VerifyNotification(payload, serverKey) + if err != nil { + return verifiedWebhook{}, err + } + return verifiedWebhook{ + OrderID: notification.OrderID, + TransactionStatus: notification.TransactionStatus, + FraudStatus: notification.FraudStatus, + }, nil + }, nil + case "core-api": + return product, credentials.ServerKey, func(payload []byte, serverKey string) (verifiedWebhook, error) { + notification, err := coreapi.VerifyNotification(payload, serverKey) + if err != nil { + return verifiedWebhook{}, err + } + return verifiedWebhook{ + OrderID: notification.OrderID, + TransactionStatus: notification.TransactionStatus, + FraudStatus: notification.FraudStatus, + }, nil + }, nil + default: + return "", "", nil, &contracts.Finding{ + Code: "CAPABILITY_UNAVAILABLE", + Severity: "blocking", + Message: "requested webhook product is unavailable for this project", + } + } +} + func newWebhookReplayCommand(flags *globalFlags, deps Dependencies) *cobra.Command { var ( file string diff --git a/packs/coreapi/journey.go b/packs/coreapi/journey.go index 391bdbd..11d2a45 100644 --- a/packs/coreapi/journey.go +++ b/packs/coreapi/journey.go @@ -10,6 +10,7 @@ import ( journey "github.com/veritrans/midtrans-cli/internal/journey" "github.com/veritrans/midtrans-cli/internal/operations" "github.com/veritrans/midtrans-cli/internal/sandbox" + "github.com/veritrans/midtrans-cli/internal/secrets" ) type ChargeExecutor interface { @@ -32,8 +33,9 @@ type JourneyRunner struct { } type Handler struct { - definition journey.Definition - runner JourneyRunner + definition journey.Definition + runner JourneyRunner + runnerOverride bool } func NewCard3DSHandler() Handler { @@ -68,17 +70,12 @@ func newHandler(id, intent string) Handler { Intent: intent, RequiredInputs: []string{"order_id", "amount"}, }, - runner: JourneyRunner{ - Charge: Client{}, - Status: Client{}, - Refund: Client{}, - Now: func() time.Time { return time.Now().UTC() }, - }, } } func (h Handler) WithRunner(runner JourneyRunner) Handler { h.runner = runner + h.runnerOverride = true if h.runner.Now == nil { h.runner.Now = func() time.Time { return time.Now().UTC() } } @@ -97,21 +94,21 @@ func (h Handler) Plan(_ context.Context, request journey.Request, _ journey.Runt } } -func (h Handler) Execute(ctx context.Context, request journey.Request, _ journey.Runtime) journey.Outcome { - return h.run(ctx, request) +func (h Handler) Execute(ctx context.Context, request journey.Request, runtime journey.Runtime) journey.Outcome { + return h.run(ctx, request, runtime) } -func (h Handler) Resume(ctx context.Context, request journey.Request, _ journey.Runtime, _ operations.Record) journey.Outcome { - return h.run(ctx, request) +func (h Handler) Resume(ctx context.Context, request journey.Request, runtime journey.Runtime, _ operations.Record) journey.Outcome { + return h.run(ctx, request, runtime) } -func (h Handler) run(ctx context.Context, request journey.Request) journey.Outcome { +func (h Handler) run(ctx context.Context, request journey.Request, runtime journey.Runtime) journey.Outcome { if request.OperationID == "" || request.ManifestHash == "" || request.Input.OrderID == "" || request.Input.Amount <= 0 { return inputRequired("order_id and amount are required") } switch h.definition.Intent { case "refund": - return h.runRefund(ctx, request) + return h.runRefund(ctx, request, runtime) case "card-3ds", "saved-card", "installment": if request.Input.PaymentTokenReference == "" { return inputRequired("payment_token_reference is required") @@ -125,27 +122,31 @@ func (h Handler) run(ctx context.Context, request journey.Request) journey.Outco request.Input.Method = "bni" } } - if h.runner.Status == nil || h.runner.Charge == nil { + runner, tokenID, outcome := h.runtimeRunner(ctx, request, runtime) + if outcome != nil { + return *outcome + } + if runner.Status == nil || runner.Charge == nil { return blockedOutcome("journey dependencies are unavailable") } - status, err := h.runner.Status.Status(ctx, request.Input.OrderID) + status, err := runner.Status.Status(ctx, request.Input.OrderID) if err == nil && !status.NotFound { return h.evaluateStatus(status) } - response, err := h.runner.Charge.Charge(ctx, ChargeRequest{ + response, err := runner.Charge.Charge(ctx, ChargeRequest{ OperationID: request.OperationID, OrderID: request.Input.OrderID, GrossAmount: request.Input.Amount, Method: h.definition.Intent, - TokenID: request.Input.PaymentTokenReference, + TokenID: tokenID, Bank: request.Input.Method, Store: request.Input.Method, }) if err != nil { var ambiguous sandbox.AmbiguousOperationError if errors.As(err, &ambiguous) { - reconciled, statusErr := h.runner.Status.Status(ctx, request.Input.OrderID) + reconciled, statusErr := runner.Status.Status(ctx, request.Input.OrderID) if statusErr != nil || reconciled.NotFound { return journey.Outcome{ State: journey.Reconciling, @@ -156,17 +157,21 @@ func (h Handler) run(ctx context.Context, request journey.Request) journey.Outco } return blockedOutcome("sandbox mutation failed") } - return h.evaluateCharge(request, response) + return h.evaluateCharge(request, response, runner.Now) } -func (h Handler) runRefund(ctx context.Context, request journey.Request) journey.Outcome { +func (h Handler) runRefund(ctx context.Context, request journey.Request, runtime journey.Runtime) journey.Outcome { if request.Input.CustomerReference == "" { return inputRequired("customer_reference is required as a stable refund key") } - if h.runner.Refund == nil { + runner, _, outcome := h.runtimeRunner(ctx, request, runtime) + if outcome != nil { + return *outcome + } + if runner.Refund == nil { return blockedOutcome("refund execution is unavailable") } - response, err := h.runner.Refund.Refund(ctx, RefundRequest{ + response, err := runner.Refund.Refund(ctx, RefundRequest{ OperationID: request.OperationID, OrderID: request.Input.OrderID, Method: request.Input.Method, @@ -187,7 +192,14 @@ func (h Handler) runRefund(ctx context.Context, request journey.Request) journey } } -func (h Handler) evaluateCharge(request journey.Request, response ChargeResponse) journey.Outcome { +func (h Handler) evaluateCharge( + request journey.Request, + response ChargeResponse, + now func() time.Time, +) journey.Outcome { + if now == nil { + now = func() time.Time { return time.Now().UTC() } + } switch h.definition.Intent { case "card-3ds", "saved-card", "installment": if response.RedirectURL != "" { @@ -201,6 +213,7 @@ func (h Handler) evaluateCharge(request journey.Request, response ChargeResponse Type: "browser", URL: response.RedirectURL, Instructions: "complete the Core API 3DS authentication and rerun this journey", + ExpiresAt: now().Add(15 * time.Minute), ResumeCommand: "midtrans test " + h.definition.Intent + " --execute", }, } @@ -267,24 +280,86 @@ func (h Handler) evaluateStatus(status StatusResponse) journey.Outcome { } } -func inputRequired(message string) journey.Outcome { +func (h Handler) runtimeRunner( + ctx context.Context, + request journey.Request, + runtime journey.Runtime, +) (JourneyRunner, string, *journey.Outcome) { + if h.runnerOverride { + return h.runner, request.Input.PaymentTokenReference, nil + } + integration, ok := request.Manifest.IntegrationFor("core-api") + if !ok { + outcome := blockedFinding("CAPABILITY_UNAVAILABLE", "core-api integration is not configured for this project") + return JourneyRunner{}, "", &outcome + } + credentials, ok := request.Manifest.CredentialSetFor(integration.Credentials) + if !ok || credentials.ServerKey == "" { + outcome := blockedFinding("CREDENTIAL_MISSING", "the configured core-api server-key reference is not set") + return JourneyRunner{}, "", &outcome + } + if runtime.ResolveCredential == nil || runtime.HTTP == nil { + outcome := blockedFinding("JOURNEY_EXECUTION_BLOCKED", "journey runtime dependencies are unavailable") + return JourneyRunner{}, "", &outcome + } + rawServerKey, err := runtime.ResolveCredential(ctx, request.ProjectDir, credentials.ServerKey) + if err != nil { + outcome := blockedFinding("CREDENTIAL_RESOLUTION_FAILED", "unable to resolve the configured core-api server-key reference") + return JourneyRunner{}, "", &outcome + } + runner := JourneyRunner{ + Charge: Client{HTTP: runtime.HTTP, ServerKey: secrets.NewValue(string(rawServerKey))}, + Status: Client{HTTP: runtime.HTTP, ServerKey: secrets.NewValue(string(rawServerKey))}, + Refund: Client{HTTP: runtime.HTTP, ServerKey: secrets.NewValue(string(rawServerKey))}, + Now: runtimeNow(runtime), + } + rawServerKey = nil + tokenID := "" + if requiresResolvedToken(h.definition.Intent) { + rawToken, err := runtime.ResolveCredential(ctx, request.ProjectDir, request.Input.PaymentTokenReference) + if err != nil { + outcome := blockedFinding("CREDENTIAL_RESOLUTION_FAILED", "unable to resolve the configured payment-token reference") + return JourneyRunner{}, "", &outcome + } + tokenID = string(rawToken) + rawToken = nil + } + return runner, tokenID, nil +} + +func runtimeNow(runtime journey.Runtime) func() time.Time { + if runtime.Now != nil { + return runtime.Now + } + return func() time.Time { return time.Now().UTC() } +} + +func requiresResolvedToken(intent string) bool { + return intent == "card-3ds" || intent == "saved-card" || intent == "installment" +} + +func blockedFinding(code, message string) journey.Outcome { return journey.Outcome{ State: journey.Blocked, Finding: &contracts.Finding{ - Code: "JOURNEY_INPUT_REQUIRED", + Code: code, Severity: "blocking", Message: message, }, } } -func blockedOutcome(message string) journey.Outcome { +func inputRequired(message string) journey.Outcome { return journey.Outcome{ State: journey.Blocked, Finding: &contracts.Finding{ - Code: "JOURNEY_EXECUTION_BLOCKED", + Code: "JOURNEY_INPUT_REQUIRED", Severity: "blocking", Message: message, }, } } + +func blockedOutcome(message string) journey.Outcome { + return blockedFinding("JOURNEY_EXECUTION_BLOCKED", message) +} diff --git a/packs/coreapi/journey_test.go b/packs/coreapi/journey_test.go index a33ae3b..f1acdf3 100644 --- a/packs/coreapi/journey_test.go +++ b/packs/coreapi/journey_test.go @@ -4,13 +4,14 @@ import ( "context" "encoding/json" "errors" + "net/http" "testing" "time" "github.com/veritrans/midtrans-cli/internal/contracts" journeypkg "github.com/veritrans/midtrans-cli/internal/journey" "github.com/veritrans/midtrans-cli/internal/manifest" - "github.com/veritrans/midtrans-cli/internal/operations" + "github.com/veritrans/midtrans-cli/internal/sandbox" "github.com/veritrans/midtrans-cli/packs/coreapi" ) @@ -73,6 +74,80 @@ func TestCardJourneyProducesBrowserActionFrom3DSRedirect(t *testing.T) { } } +func TestCardJourneyBuildsProductionRunnerFromRuntimeAndResolvedTokenReference(t *testing.T) { + serverKeyRef := "env:MIDTRANS_SERVER_KEY" + tokenRef := "env:MIDTRANS_PAYMENT_TOKEN" + var resolvedRefs []string + httpCalls := 0 + + handler := coreapi.NewCard3DSHandler() + outcome := handler.Execute(context.Background(), journeypkg.Request{ + OperationID: "operation-card", + ProjectDir: "/merchant", + ManifestHash: "manifest-hash", + Manifest: validCoreManifest(), + Input: journeypkg.Input{ + OrderID: "order-card", + Amount: 10000, + PaymentTokenReference: tokenRef, + }, + }, journeypkg.Runtime{ + Now: func() time.Time { return time.Unix(1700000000, 0).UTC() }, + HTTP: appDoerFunc(func(request *http.Request) (*http.Response, error) { + httpCalls++ + if request.Method == http.MethodGet { + return coreResponse(http.StatusNotFound, `{"status_code":"404","status_message":"not found"}`), nil + } + username, password, ok := request.BasicAuth() + if !ok || username != coreServerKeyCanary || password != "" { + t.Fatal("request did not use resolved Basic auth") + } + var payload struct { + CreditCard struct { + TokenID string `json:"token_id"` + } `json:"credit_card"` + } + if err := json.NewDecoder(request.Body).Decode(&payload); err != nil { + t.Fatal(err) + } + if payload.CreditCard.TokenID != "tokn_resolved_123" { + t.Fatalf("token_id = %q", payload.CreditCard.TokenID) + } + if payload.CreditCard.TokenID == tokenRef { + t.Fatalf("reference string leaked into token_id: %q", payload.CreditCard.TokenID) + } + return coreResponse(http.StatusCreated, `{"status_code":"201","transaction_status":"pending","order_id":"order-card","payment_type":"credit_card","gross_amount":"10000.00","redirect_url":"https://api.sandbox.midtrans.com/v2/3ds/redirect/order-card"}`), nil + }), + ResolveCredential: func(_ context.Context, projectDir, reference string) ([]byte, error) { + if projectDir != "/merchant" { + t.Fatalf("projectDir = %q", projectDir) + } + resolvedRefs = append(resolvedRefs, reference) + switch reference { + case serverKeyRef: + return []byte(coreServerKeyCanary), nil + case tokenRef: + return []byte("tokn_resolved_123"), nil + default: + return nil, errors.New("unexpected reference") + } + }, + }) + + if outcome.State != journeypkg.AwaitingUserAction { + t.Fatalf("state = %q", outcome.State) + } + if outcome.Action == nil || outcome.Action.ExpiresAt != time.Unix(1700000000, 0).UTC().Add(15*time.Minute) { + t.Fatalf("action = %#v", outcome.Action) + } + if httpCalls != 2 { + t.Fatalf("httpCalls = %d, want 2 (status then charge)", httpCalls) + } + if len(resolvedRefs) != 2 || resolvedRefs[0] != serverKeyRef || resolvedRefs[1] != tokenRef { + t.Fatalf("resolvedRefs = %#v", resolvedRefs) + } +} + func TestOTCJourneyReturnsSafeInstructionsWithoutRawProviderPayload(t *testing.T) { handler := coreapi.NewOTCHandler().WithRunner(coreapi.JourneyRunner{ Charge: stubCharge(func(context.Context, coreapi.ChargeRequest) (coreapi.ChargeResponse, error) { @@ -162,11 +237,21 @@ func TestRefundJourneyRequiresStableRefundKeyAndUsesMethodSpecificPath(t *testin } func TestCardJourneyReconcilesAmbiguousMutationByStatusBeforeRetry(t *testing.T) { + statusCalls := 0 + chargeCalls := 0 handler := coreapi.NewCard3DSHandler().WithRunner(coreapi.JourneyRunner{ Charge: stubCharge(func(context.Context, coreapi.ChargeRequest) (coreapi.ChargeResponse, error) { - return coreapi.ChargeResponse{}, sandboxAmbiguous("operation-card") + chargeCalls++ + return coreapi.ChargeResponse{}, sandbox.AmbiguousOperationError{ + OperationID: "operation-card", + Cause: errors.New("sandbox request transport failed"), + } }), Status: stubStatus(func(context.Context, string) (coreapi.StatusResponse, error) { + statusCalls++ + if statusCalls == 1 { + return coreapi.StatusResponse{OrderID: "order-card", NotFound: true}, nil + } return coreapi.StatusResponse{ OrderID: "order-card", TransactionStatus: "capture", @@ -189,6 +274,9 @@ func TestCardJourneyReconcilesAmbiguousMutationByStatusBeforeRetry(t *testing.T) if outcome.State != journeypkg.Passed { t.Fatalf("state = %q", outcome.State) } + if chargeCalls != 1 || statusCalls != 2 { + t.Fatalf("chargeCalls = %d, statusCalls = %d", chargeCalls, statusCalls) + } } type stubCharge func(context.Context, coreapi.ChargeRequest) (coreapi.ChargeResponse, error) @@ -209,16 +297,6 @@ func (s stubRefund) Refund(ctx context.Context, request coreapi.RefundRequest) ( return s(ctx, request) } -func sandboxAmbiguous(operationID string) error { - return errors.New("replace me") -} - -type stubLedger struct{} - -func (stubLedger) Load(context.Context, string) (operations.Record, bool, error) { return operations.Record{}, false, nil } -func (stubLedger) Reserve(context.Context, operations.Record) (bool, error) { return true, nil } -func (stubLedger) Save(context.Context, operations.Record) error { return nil } - func validCoreManifest() manifest.Manifest { value := manifest.Default() value.Application.BaseURL = "http://127.0.0.1:8080" @@ -244,6 +322,12 @@ func validCoreManifest() manifest.Manifest { return value } +type appDoerFunc func(*http.Request) (*http.Response, error) + +func (f appDoerFunc) Do(request *http.Request) (*http.Response, error) { + return f(request) +} + func requireFindingCode(t *testing.T, findings []contracts.Finding, code string) { t.Helper() for _, finding := range findings { From 4124a905158f7d085346873e902756e52087987c Mon Sep 17 00:00:00 2001 From: "m.salis" Date: Mon, 27 Jul 2026 10:53:10 +0700 Subject: [PATCH 41/73] feat: add Payment Link journeys --- cmd/midtrans/main.go | 3 +- contracts/capabilities-v1.json | 14 ++ contracts/public-sources-v1.json | 89 ++++--- internal/app/app_test.go | 28 +-- internal/app/commands_agent.go | 3 + internal/app/commands_checkout.go | 5 + internal/journey/types.go | 1 + internal/packs/registry_test.go | 17 +- packs/paymentlink/client.go | 224 ++++++++++++++++++ packs/paymentlink/client_test.go | 285 +++++++++++++++++++++++ packs/paymentlink/journey.go | 272 +++++++++++++++++++++ packs/paymentlink/journey_test.go | 279 ++++++++++++++++++++++ packs/paymentlink/pack.go | 75 ++++++ packs/paymentlink/pack_test.go | 35 +++ testdata/paymentlink/create-success.json | 5 + tools/source-baseline/main.go | 8 +- tools/source-drift/main.go | 2 + 17 files changed, 1295 insertions(+), 50 deletions(-) create mode 100644 packs/paymentlink/client.go create mode 100644 packs/paymentlink/client_test.go create mode 100644 packs/paymentlink/journey.go create mode 100644 packs/paymentlink/journey_test.go create mode 100644 packs/paymentlink/pack.go create mode 100644 packs/paymentlink/pack_test.go create mode 100644 testdata/paymentlink/create-success.json diff --git a/cmd/midtrans/main.go b/cmd/midtrans/main.go index a75a9a7..fd26b21 100644 --- a/cmd/midtrans/main.go +++ b/cmd/midtrans/main.go @@ -10,11 +10,12 @@ import ( "github.com/veritrans/midtrans-cli/internal/version" "github.com/veritrans/midtrans-cli/packs/common" "github.com/veritrans/midtrans-cli/packs/coreapi" + "github.com/veritrans/midtrans-cli/packs/paymentlink" "github.com/veritrans/midtrans-cli/packs/snap" ) func main() { - registry, err := packs.NewRegistry(common.New(), snap.New(), coreapi.New()) + registry, err := packs.NewRegistry(common.New(), snap.New(), coreapi.New(), paymentlink.New()) if err != nil { fmt.Fprintln(os.Stderr, err) os.Exit(6) diff --git a/contracts/capabilities-v1.json b/contracts/capabilities-v1.json index 2724313..9e4b74c 100644 --- a/contracts/capabilities-v1.json +++ b/contracts/capabilities-v1.json @@ -47,6 +47,20 @@ "common.webhook-idempotency", "common.status-reconciliation" ] + }, + { + "id": "payment-link", + "version": "0.1.0", + "capabilities": [ + "payment-link.create.verify.v1", + "payment-link.reusable.verify.v1", + "payment-link.verify.v1" + ], + "journeys": [ + "payment-link.create", + "payment-link.reusable", + "payment-link.verify" + ] } ] } diff --git a/contracts/public-sources-v1.json b/contracts/public-sources-v1.json index db2987c..0dd8250 100644 --- a/contracts/public-sources-v1.json +++ b/contracts/public-sources-v1.json @@ -8,8 +8,8 @@ "snap.token.create", "snap.basic-auth" ], - "sha256": "ae91cc09afb68a167647a8e11cdd75ffd6363793a047b7502b8b5ba992cc7595", - "retrieved_at": "2026-07-27T03:21:36.455Z" + "sha256": "d3ec2a0cdb48dbbaac4ea3befa52f63c38b199ae9175de5a1c9f5bef0b9a326a", + "retrieved_at": "2026-07-27T03:50:40.060693Z" }, { "id": "snap-js", @@ -18,8 +18,8 @@ "snap.checkout.popup", "snap.checkout.embed" ], - "sha256": "13c8b07f1ca7e74486517e1b9be2d01ab84d755e24e356dec515c19c0913b379", - "retrieved_at": "2026-07-27T03:21:36.455Z" + "sha256": "6e11f9887455c60c410c4144902720586eef50c609236021172097f9957072b8", + "retrieved_at": "2026-07-27T03:50:40.060693Z" }, { "id": "snap-integration", @@ -28,8 +28,8 @@ "snap.checkout.redirect", "snap.mobile.webview" ], - "sha256": "9ecdd17c7e3cddb878cd58e7142faff84aac36bb314ed042aa2944dd8714f531", - "retrieved_at": "2026-07-27T03:21:36.455Z" + "sha256": "5ff69bfd753ab75f1f97784e6617400966371b57dc35f242b80aaca05da182be", + "retrieved_at": "2026-07-27T03:50:40.060693Z" }, { "id": "technical-faq", @@ -38,8 +38,8 @@ "snap.mobile.deeplink-return", "snap.mobile.real-device-proof" ], - "sha256": "666834e999999857612a354aa2a6bfdddc2aabb74b704e5776b72db4d1716c40", - "retrieved_at": "2026-07-27T03:21:36.455Z" + "sha256": "5ef8562f1a6873b0abea8962a6c0daffced9fc1e7232ccb00a739afab7afb94f", + "retrieved_at": "2026-07-27T03:50:40.060693Z" }, { "id": "http-notifications", @@ -48,8 +48,8 @@ "snap.notification.signature", "common.webhook-idempotency" ], - "sha256": "8fb8d416caf5065d8ff436a3dbd81b31c40e0dcedae17433121193c7bcc33687", - "retrieved_at": "2026-07-27T03:21:36.455Z" + "sha256": "33a4aa11866b61dd75f2feda776471b19069c3c90931a1fac2c92d6d159ef9de", + "retrieved_at": "2026-07-27T03:50:40.060693Z" }, { "id": "get-transaction-status", @@ -58,8 +58,8 @@ "snap.status.reconcile", "snap.mobile.status.reconcile" ], - "sha256": "8f1a5a0b72228c0a0e46488d12c1f735c9bee3b3859789cb664644d039f955f1", - "retrieved_at": "2026-07-27T03:21:36.455Z" + "sha256": "18df39352623ff1b17a0e0d86e3ab702e2a196d8fc95288dd9604870b6ac8960", + "retrieved_at": "2026-07-27T03:50:40.060693Z" }, { "id": "coreapi-card-charge", @@ -68,8 +68,8 @@ "coreapi.card.charge", "coreapi.basic-auth" ], - "sha256": "1a193621f3ab09d960d0451f3ab956d6a674cf2c9e8ed104cd334ea71b23caee", - "retrieved_at": "2026-07-27T03:21:36.455Z" + "sha256": "4f5285ce983781e2b0b8d54f9f937452bad10aeb88f19d4a54a098521d7d60c6", + "retrieved_at": "2026-07-27T03:50:40.060693Z" }, { "id": "coreapi-card-3ds", @@ -78,8 +78,8 @@ "coreapi.card.3ds", "coreapi.card.redirect" ], - "sha256": "2bfb83e704060bcbabcdfad9c1358f9546ce5799967fb50feb96c6e7dc4ea44d", - "retrieved_at": "2026-07-27T03:21:36.455Z" + "sha256": "1d692482e67472e74aec333777b655a919c76ce0df9414f519f61d8f8ad6abdd", + "retrieved_at": "2026-07-27T03:50:40.060693Z" }, { "id": "coreapi-one-click", @@ -87,8 +87,8 @@ "rules": [ "coreapi.saved-card.token-only" ], - "sha256": "778f94cacf35ea88a56ede3689d44fa1955726d3d4c50e4c96bffcccf9117b75", - "retrieved_at": "2026-07-27T03:21:36.455Z" + "sha256": "a6d8c2e029af48b1ba86751607507f462f9a649630d0b9e801d41663bec44321", + "retrieved_at": "2026-07-27T03:50:40.060693Z" }, { "id": "coreapi-alfamart", @@ -97,8 +97,8 @@ "coreapi.otc.charge", "coreapi.otc.payment-code" ], - "sha256": "39f3a1ad32637de78d00e8b76c98c07c4c18ab9b212b5bfd8cc82e13daefe9d7", - "retrieved_at": "2026-07-27T03:21:36.455Z" + "sha256": "b9d702fe6803abadef28cbcec90bce2874db4e7af2224051f848223c78fe0e39", + "retrieved_at": "2026-07-27T03:50:40.060693Z" }, { "id": "coreapi-bni-va", @@ -107,8 +107,8 @@ "coreapi.va.charge", "coreapi.va.instructions" ], - "sha256": "725348b5b28a854175b8abdd98c4505c46f1c344fec0d4e4c307fd4d3694dc40", - "retrieved_at": "2026-07-27T03:21:36.455Z" + "sha256": "d3941c35f810e85f40fc543dbaff545499debd45c06d17bf493df31cb7706167", + "retrieved_at": "2026-07-27T03:50:40.060693Z" }, { "id": "coreapi-status", @@ -117,8 +117,8 @@ "coreapi.status.reconcile", "coreapi.refund.status" ], - "sha256": "8f1a5a0b72228c0a0e46488d12c1f735c9bee3b3859789cb664644d039f955f1", - "retrieved_at": "2026-07-27T03:21:36.455Z" + "sha256": "18df39352623ff1b17a0e0d86e3ab702e2a196d8fc95288dd9604870b6ac8960", + "retrieved_at": "2026-07-27T03:50:40.060693Z" }, { "id": "coreapi-refund", @@ -127,8 +127,8 @@ "coreapi.refund.async", "coreapi.refund.idempotency" ], - "sha256": "8f0fb963db7d8302af49935aaebf4318bf3b3880c33b7cacb1ba3f955b7099c2", - "retrieved_at": "2026-07-27T03:21:36.455Z" + "sha256": "8f7622a76a344b11df0d76b658561ef1b09e9b04f422b27d5ced8e1b0e205da8", + "retrieved_at": "2026-07-27T03:50:40.060693Z" }, { "id": "coreapi-direct-refund", @@ -136,8 +136,8 @@ "rules": [ "coreapi.refund.direct" ], - "sha256": "6a01c94b4e36c4395bf599adcbd605c807db954741a389f99b2b85c42d8d59d0", - "retrieved_at": "2026-07-27T03:21:36.455Z" + "sha256": "15b9be4ffe2bbceeee8d47645a42ff8c32c61750c9cb44ce27637c4c6afe74be", + "retrieved_at": "2026-07-27T03:50:40.060693Z" }, { "id": "coreapi-notifications", @@ -146,8 +146,37 @@ "coreapi.notification.signature", "common.webhook-idempotency" ], - "sha256": "8fb8d416caf5065d8ff436a3dbd81b31c40e0dcedae17433121193c7bcc33687", - "retrieved_at": "2026-07-27T03:21:36.455Z" + "sha256": "33a4aa11866b61dd75f2feda776471b19069c3c90931a1fac2c92d6d159ef9de", + "retrieved_at": "2026-07-27T03:50:40.060693Z" + }, + { + "id": "payment-link-overview", + "url": "https://docs.midtrans.com/docs/payment-link-via-api", + "rules": [ + "paymentlink.create", + "paymentlink.reusable" + ], + "sha256": "fddbb1cef82fd3d8e41c8f72936bb05972bc7941be76a942664fa513aefeee59", + "retrieved_at": "2026-07-27T03:50:40.060693Z" + }, + { + "id": "payment-link-status", + "url": "https://docs.midtrans.com/reference/get-transaction-status", + "rules": [ + "paymentlink.status.reconcile" + ], + "sha256": "18df39352623ff1b17a0e0d86e3ab702e2a196d8fc95288dd9604870b6ac8960", + "retrieved_at": "2026-07-27T03:50:40.060693Z" + }, + { + "id": "payment-link-notifications", + "url": "https://docs.midtrans.com/docs/https-notification-webhooks", + "rules": [ + "paymentlink.notification.signature", + "common.webhook-idempotency" + ], + "sha256": "33a4aa11866b61dd75f2feda776471b19069c3c90931a1fac2c92d6d159ef9de", + "retrieved_at": "2026-07-27T03:50:40.060693Z" } ] } diff --git a/internal/app/app_test.go b/internal/app/app_test.go index 201b471..c48abc9 100644 --- a/internal/app/app_test.go +++ b/internal/app/app_test.go @@ -25,6 +25,7 @@ import ( "github.com/veritrans/midtrans-cli/internal/version" "github.com/veritrans/midtrans-cli/packs/common" "github.com/veritrans/midtrans-cli/packs/coreapi" + "github.com/veritrans/midtrans-cli/packs/paymentlink" "github.com/veritrans/midtrans-cli/packs/snap" ) @@ -47,18 +48,19 @@ func TestCapabilitiesJSON(t *testing.T) { if result.CLIVersion != "0.1.0-test" { t.Fatalf("cli version = %q", result.CLIVersion) } - if len(result.Capabilities) != 11 || + if len(result.Capabilities) != 14 || result.Capabilities[0].ID != "common.capabilities.v1" || - result.Capabilities[10].ID != "snap.webhook.verify.v1" { + result.Capabilities[13].ID != "snap.webhook.verify.v1" { t.Fatalf("unexpected capabilities: %#v", result.Capabilities) } - if len(result.Packs) != 3 || + if len(result.Packs) != 4 || result.Packs[0].ID != "common" || result.Packs[1].ID != "core-api" || - result.Packs[2].ID != "snap" { + result.Packs[2].ID != "payment-link" || + result.Packs[3].ID != "snap" { t.Fatalf("unexpected packs: %#v", result.Packs) } - if len(result.Journeys) != 10 || result.Journeys[8] != "snap.checkout" || result.Journeys[9] != "snap.mobile-webview" { + if len(result.Journeys) != 13 || result.Journeys[11] != "snap.checkout" || result.Journeys[12] != "snap.mobile-webview" { t.Fatalf("unexpected journeys: %#v", result.Journeys) } } @@ -80,8 +82,8 @@ func TestAgentCapabilitiesPreservesCapabilityContract(t *testing.T) { ) if exit != 0 || result.SchemaVersion != "1.0" || - len(result.Capabilities) != 11 || - len(result.Journeys) != 10 { + len(result.Capabilities) != 14 || + len(result.Journeys) != 13 { t.Fatalf("exit = %d, result = %#v", exit, result) } } @@ -2277,8 +2279,8 @@ func TestWebhookVerifySupportsCoreAPIOnlyManifestAndExplicitProductForHybrid(t * ClientKey: "env:MIDTRANS_CORE_CLIENT_KEY", } value.Integrations["core-api"] = manifest.Integration{ - ConfigVersion: 1, - Credentials: "core-classic", + ConfigVersion: 1, + Credentials: "core-classic", PaymentMethods: []string{"card"}, Callbacks: map[string]string{ "notification": "/api/payments/midtrans/core-notification", @@ -2381,7 +2383,7 @@ func TestMerchantCoreAPIExecuteResolvesServerKeyAndPaymentTokenReference(t *test return &http.Response{ StatusCode: http.StatusCreated, Header: make(http.Header), - Body: io.NopCloser(strings.NewReader(`{"status_code":"201","transaction_status":"pending","order_id":"coreapi-order-001","payment_type":"credit_card","gross_amount":"10000.00","redirect_url":"https://api.sandbox.midtrans.com/v2/3ds/redirect/coreapi-order-001"}`)), + Body: io.NopCloser(strings.NewReader(`{"status_code":"201","transaction_status":"pending","order_id":"coreapi-order-001","payment_type":"credit_card","gross_amount":"10000.00","redirect_url":"https://api.sandbox.midtrans.com/v2/3ds/redirect/coreapi-order-001"}`)), }, nil default: t.Fatalf("unexpected method %s", request.Method) @@ -3069,7 +3071,7 @@ func assertSchemaFieldsMatchType( func testRegistry(t *testing.T) *packs.Registry { t.Helper() - registry, err := packs.NewRegistry(common.New(), snap.New(), coreapi.New()) + registry, err := packs.NewRegistry(common.New(), snap.New(), coreapi.New(), paymentlink.New()) if err != nil { t.Fatal(err) } @@ -3149,8 +3151,8 @@ func configureCoreAPIManifestProject(t *testing.T, project string, baseURL strin } delete(value.Integrations, "snap") value.Integrations["core-api"] = manifest.Integration{ - ConfigVersion: 1, - Credentials: "classic", + ConfigVersion: 1, + Credentials: "classic", PaymentMethods: []string{"card", "virtual-account", "otc"}, Callbacks: map[string]string{ "notification": "/api/payments/midtrans/notification", diff --git a/internal/app/commands_agent.go b/internal/app/commands_agent.go index 3378d4e..549dde3 100644 --- a/internal/app/commands_agent.go +++ b/internal/app/commands_agent.go @@ -76,6 +76,7 @@ type genericJourneyFlags struct { customerReference string paymentTokenReference string amount int64 + usageLimit int reusable bool execute bool } @@ -94,6 +95,7 @@ func (f *genericJourneyFlags) bind(command *cobra.Command, includeExecute bool, command.Flags().StringVar(&f.customerReference, "customer-reference", "", "safe customer reference") command.Flags().StringVar(&f.paymentTokenReference, "payment-token-reference", "", "safe payment token reference") command.Flags().BoolVar(&f.reusable, "reusable", false, "request a reusable payment resource") + command.Flags().IntVar(&f.usageLimit, "usage-limit", 0, "explicit reusable payment usage limit") if includeExecute { command.Flags().BoolVar(&f.execute, "execute", false, "execute the planned mutation") } @@ -112,6 +114,7 @@ func (f *genericJourneyFlags) toRunRequest(flags *globalFlags, execute bool) jou Input: journeypkg.Input{ OrderID: f.orderID, Amount: f.amount, + UsageLimit: f.usageLimit, Method: f.method, CustomerReference: f.customerReference, PaymentTokenReference: f.paymentTokenReference, diff --git a/internal/app/commands_checkout.go b/internal/app/commands_checkout.go index 56ba2fe..7c52819 100644 --- a/internal/app/commands_checkout.go +++ b/internal/app/commands_checkout.go @@ -42,6 +42,7 @@ type merchantJourneyFlags struct { customerReference string paymentTokenReference string amount int64 + usageLimit int reusable bool execute bool } @@ -57,6 +58,7 @@ func (f *merchantJourneyFlags) bind(command *cobra.Command) { command.Flags().StringVar(&f.customerReference, "customer-reference", "", "safe customer reference") command.Flags().StringVar(&f.paymentTokenReference, "payment-token-reference", "", "safe payment token reference") command.Flags().BoolVar(&f.reusable, "reusable", false, "request a reusable payment resource") + command.Flags().IntVar(&f.usageLimit, "usage-limit", 0, "explicit reusable payment usage limit") command.Flags().StringVar(&f.product, "product", "", "product override when no manifest route is configured") command.Flags().BoolVar(&f.execute, "execute", false, "execute the reviewed Sandbox plan") } @@ -123,6 +125,7 @@ func runMerchantJourney( request.method, request.customerReference, request.paymentTokenReference, + request.usageLimit, request.reusable, ), Execute: request.execute, @@ -230,11 +233,13 @@ func journeyInput( method string, customerReference string, paymentTokenReference string, + usageLimit int, reusable bool, ) journey.Input { return journey.Input{ OrderID: orderID, Amount: amount, + UsageLimit: usageLimit, Method: method, CustomerReference: customerReference, PaymentTokenReference: paymentTokenReference, diff --git a/internal/journey/types.go b/internal/journey/types.go index 312fc04..cc1e226 100644 --- a/internal/journey/types.go +++ b/internal/journey/types.go @@ -33,6 +33,7 @@ type Definition struct { type Input struct { OrderID string `json:"order_id,omitempty"` Amount int64 `json:"amount,omitempty"` + UsageLimit int `json:"usage_limit,omitempty"` Method string `json:"method,omitempty"` CustomerReference string `json:"customer_reference,omitempty"` PaymentTokenReference string `json:"payment_token_reference,omitempty"` diff --git a/internal/packs/registry_test.go b/internal/packs/registry_test.go index 9388713..43f2296 100644 --- a/internal/packs/registry_test.go +++ b/internal/packs/registry_test.go @@ -13,16 +13,17 @@ import ( "github.com/veritrans/midtrans-cli/internal/packs" "github.com/veritrans/midtrans-cli/packs/common" "github.com/veritrans/midtrans-cli/packs/coreapi" + "github.com/veritrans/midtrans-cli/packs/paymentlink" "github.com/veritrans/midtrans-cli/packs/snap" ) func TestRegistryAggregatesCapabilities(t *testing.T) { - registry, err := packs.NewRegistry(common.New(), snap.New(), coreapi.New()) + registry, err := packs.NewRegistry(common.New(), snap.New(), coreapi.New(), paymentlink.New()) if err != nil { t.Fatal(err) } capabilities := registry.Capabilities() - if len(capabilities) != 11 { + if len(capabilities) != 14 { t.Fatalf("capabilities = %#v", capabilities) } if _, ok := registry.Get("snap"); !ok { @@ -41,6 +42,9 @@ func TestRegistryAggregatesCapabilities(t *testing.T) { "core-api.refund.verify.v1", "core-api.saved-card.verify.v1", "core-api.virtual-account.verify.v1", + "payment-link.create.verify.v1", + "payment-link.reusable.verify.v1", + "payment-link.verify.v1", "snap.checkout.verify.v1", "snap.mobile.verify.v1", "snap.plan.v1", @@ -74,7 +78,7 @@ func TestRegistryRejectsDuplicateJourneyIDs(t *testing.T) { } func TestRegistryJourneyRouting(t *testing.T) { - registry, err := packs.NewRegistry(common.New(), snap.New(), coreapi.New()) + registry, err := packs.NewRegistry(common.New(), snap.New(), coreapi.New(), paymentlink.New()) if err != nil { t.Fatal(err) } @@ -92,7 +96,7 @@ func TestRegistryJourneyRouting(t *testing.T) { } func TestRegistryAggregatesDeterministicMetadata(t *testing.T) { - registry, err := packs.NewRegistry(snap.New(), common.New(), coreapi.New()) + registry, err := packs.NewRegistry(snap.New(), common.New(), coreapi.New(), paymentlink.New()) if err != nil { t.Fatal(err) } @@ -106,6 +110,9 @@ func TestRegistryAggregatesDeterministicMetadata(t *testing.T) { "core-api.refund", "core-api.saved-card", "core-api.virtual-account", + "payment-link.create", + "payment-link.reusable", + "payment-link.verify", "snap.checkout", "snap.mobile-webview", } @@ -116,7 +123,7 @@ func TestRegistryAggregatesDeterministicMetadata(t *testing.T) { if got := registry.SensitiveKeys(); !reflect.DeepEqual(got, wantSensitiveKeys) { t.Fatalf("sensitive keys = %#v, want %#v", got, wantSensitiveKeys) } - wantVersions := []string{"common@0.1.0", "core-api@0.1.0", "snap@0.1.0"} + wantVersions := []string{"common@0.1.0", "core-api@0.1.0", "payment-link@0.1.0", "snap@0.1.0"} versions := registry.Versions() gotVersions := make([]string, 0, len(versions)) for _, version := range versions { diff --git a/packs/paymentlink/client.go b/packs/paymentlink/client.go new file mode 100644 index 0000000..8e82f76 --- /dev/null +++ b/packs/paymentlink/client.go @@ -0,0 +1,224 @@ +package paymentlink + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "io" + "net" + "net/http" + "net/url" + + "github.com/veritrans/midtrans-cli/internal/sandbox" + "github.com/veritrans/midtrans-cli/internal/secrets" +) + +const ( + createSandboxURL = "https://api.sandbox.midtrans.com/v1/payment-links" + statusSandboxBaseURL = "https://api.sandbox.midtrans.com/v2/" + maxResponseBytes = 1 << 20 + errTransportSafeCause = "sandbox request transport failed" +) + +type Client struct { + HTTP sandbox.Doer + ServerKey secrets.Value +} + +type CreateRequest struct { + OperationID string + OrderID string + GrossAmount int64 + UsageLimit int + Reusable bool +} + +type CreateResponse struct { + OrderID string `json:"order_id"` + TransactionID string `json:"transaction_id"` + PaymentURL string `json:"payment_url"` +} + +type StatusResponse struct { + OrderID string `json:"order_id"` + TransactionID string `json:"transaction_id,omitempty"` + TransactionStatus string `json:"transaction_status,omitempty"` + StatusCode string `json:"status_code,omitempty"` + PaymentType string `json:"payment_type,omitempty"` + GrossAmount string `json:"gross_amount,omitempty"` + NotFound bool `json:"not_found,omitempty"` +} + +func (c Client) Create(ctx context.Context, input CreateRequest) (CreateResponse, error) { + if c.HTTP == nil || input.OperationID == "" || input.OrderID == "" || input.GrossAmount < 0 { + return CreateResponse{}, errors.New("SANDBOX_REQUEST_INVALID") + } + if input.Reusable && input.UsageLimit <= 0 { + return CreateResponse{}, errors.New("SANDBOX_REQUEST_INVALID") + } + serverKey, err := c.ServerKey.SandboxServerKey() + if err != nil { + return CreateResponse{}, err + } + payload, err := json.Marshal(createPayload(input)) + if err != nil { + return CreateResponse{}, errors.New("SANDBOX_REQUEST_INVALID") + } + request, err := http.NewRequestWithContext( + ctx, + http.MethodPost, + createSandboxURL, + bytes.NewReader(payload), + ) + if err != nil { + return CreateResponse{}, errors.New("SANDBOX_REQUEST_INVALID") + } + request.Header.Set("Content-Type", "application/json") + request.SetBasicAuth(serverKey, "") + + response, err := c.HTTP.Do(request) + if err != nil { + if isTimeoutError(err) { + return CreateResponse{}, sandbox.AmbiguousOperationError{ + OperationID: input.OperationID, + Cause: errors.New(errTransportSafeCause), + } + } + return CreateResponse{}, errors.New(errTransportSafeCause) + } + return decodeCreateResponse(response) +} + +func (c Client) Status(ctx context.Context, orderID string) (StatusResponse, error) { + if c.HTTP == nil || orderID == "" { + return StatusResponse{}, errors.New("SANDBOX_REQUEST_INVALID") + } + serverKey, err := c.ServerKey.SandboxServerKey() + if err != nil { + return StatusResponse{}, err + } + request, err := http.NewRequestWithContext( + ctx, + http.MethodGet, + statusSandboxBaseURL+url.PathEscape(orderID)+"/status", + nil, + ) + if err != nil { + return StatusResponse{}, errors.New("SANDBOX_REQUEST_INVALID") + } + request.SetBasicAuth(serverKey, "") + + response, err := c.HTTP.Do(request) + if err != nil { + return StatusResponse{}, errors.New(errTransportSafeCause) + } + if response == nil || response.Body == nil { + return StatusResponse{}, errors.New("SANDBOX_RESPONSE_INVALID") + } + defer response.Body.Close() + if isRedirect(response.StatusCode) { + return StatusResponse{}, errors.New("SANDBOX_RESPONSE_REDIRECTED") + } + if response.StatusCode == http.StatusNotFound { + return StatusResponse{OrderID: orderID, NotFound: true}, nil + } + if response.StatusCode < http.StatusOK || response.StatusCode >= http.StatusMultipleChoices { + return StatusResponse{}, sandbox.ResponseError{ + Operation: "paymentlink.status", + StatusCode: response.StatusCode, + } + } + + var result struct { + OrderID string `json:"order_id"` + TransactionID string `json:"transaction_id"` + TransactionStatus string `json:"transaction_status"` + StatusCode string `json:"status_code"` + PaymentType string `json:"payment_type"` + GrossAmount string `json:"gross_amount"` + PaymentURL string `json:"payment_url"` + } + if err := decodeBounded(response.Body, &result, false); err != nil { + return StatusResponse{}, err + } + if result.OrderID == "" || result.TransactionStatus == "" || result.StatusCode == "" { + return StatusResponse{}, errors.New("SANDBOX_RESPONSE_INVALID") + } + return StatusResponse{ + OrderID: result.OrderID, + TransactionID: result.TransactionID, + TransactionStatus: result.TransactionStatus, + StatusCode: result.StatusCode, + PaymentType: result.PaymentType, + GrossAmount: result.GrossAmount, + }, nil +} + +func createPayload(input CreateRequest) any { + transactionDetails := map[string]any{ + "order_id": input.OrderID, + } + if input.GrossAmount > 0 { + transactionDetails["gross_amount"] = input.GrossAmount + } + payload := map[string]any{ + "transaction_details": transactionDetails, + } + if input.UsageLimit > 0 { + payload["usage_limit"] = input.UsageLimit + } + return payload +} + +func decodeCreateResponse(response *http.Response) (CreateResponse, error) { + if response == nil || response.Body == nil { + return CreateResponse{}, errors.New("SANDBOX_RESPONSE_INVALID") + } + defer response.Body.Close() + if isRedirect(response.StatusCode) { + return CreateResponse{}, errors.New("SANDBOX_RESPONSE_REDIRECTED") + } + if response.StatusCode < http.StatusOK || response.StatusCode >= http.StatusMultipleChoices { + return CreateResponse{}, sandbox.ResponseError{ + Operation: "paymentlink.create", + StatusCode: response.StatusCode, + } + } + var result CreateResponse + if err := decodeBounded(response.Body, &result, false); err != nil { + return CreateResponse{}, err + } + if result.OrderID == "" || result.TransactionID == "" || result.PaymentURL == "" { + return CreateResponse{}, errors.New("SANDBOX_RESPONSE_INVALID") + } + return result, nil +} + +func decodeBounded(reader io.Reader, target any, allowUnknown bool) error { + decoder := json.NewDecoder(io.LimitReader(reader, maxResponseBytes+1)) + if !allowUnknown { + decoder.DisallowUnknownFields() + } + if err := decoder.Decode(target); err != nil { + return errors.New("SANDBOX_RESPONSE_INVALID") + } + if err := decoder.Decode(&struct{}{}); err != io.EOF { + return errors.New("SANDBOX_RESPONSE_INVALID") + } + return nil +} + +func isRedirect(statusCode int) bool { + return statusCode >= http.StatusMultipleChoices && + statusCode < http.StatusBadRequest +} + +func isTimeoutError(err error) bool { + var timeout interface{ Timeout() bool } + if errors.As(err, &timeout) { + return timeout.Timeout() + } + var netErr net.Error + return errors.As(err, &netErr) && netErr.Timeout() +} diff --git a/packs/paymentlink/client_test.go b/packs/paymentlink/client_test.go new file mode 100644 index 0000000..ed58f22 --- /dev/null +++ b/packs/paymentlink/client_test.go @@ -0,0 +1,285 @@ +package paymentlink_test + +import ( + "bytes" + "context" + "encoding/base64" + "encoding/json" + "errors" + "io" + "net/http" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/veritrans/midtrans-cli/internal/secrets" + "github.com/veritrans/midtrans-cli/packs/paymentlink" +) + +const paymentLinkServerKeyCanary = "SB-Mid-server-PAYMENT-LINK-CANARY-DO-NOT-PRINT" + +type paymentLinkRecordingDoer struct { + request *http.Request + body []byte + do func(*http.Request) (*http.Response, error) +} + +func (d *paymentLinkRecordingDoer) Do(request *http.Request) (*http.Response, error) { + d.request = request + if request.Body != nil { + body, err := io.ReadAll(request.Body) + if err != nil { + return nil, err + } + d.body = body + request.Body = io.NopCloser(bytes.NewReader(body)) + } + return d.do(request) +} + +func TestClientCreateUsesSandboxHostBasicAuthAndSafeReusablePayload(t *testing.T) { + fixture, err := os.ReadFile(filepath.Join("..", "..", "testdata", "paymentlink", "create-success.json")) + if err != nil { + t.Fatal(err) + } + doer := &paymentLinkRecordingDoer{ + do: func(*http.Request) (*http.Response, error) { + return paymentLinkResponse(http.StatusCreated, string(fixture)), nil + }, + } + client := paymentlink.Client{ + HTTP: doer, + ServerKey: secrets.NewValue(paymentLinkServerKeyCanary), + } + + got, err := client.Create(context.Background(), paymentlink.CreateRequest{ + OperationID: "payment-link-op-001", + OrderID: "merchant-order-001", + GrossAmount: 12500, + UsageLimit: 3, + }) + if err != nil { + t.Fatal(err) + } + if doer.request.URL.String() != "https://api.sandbox.midtrans.com/v1/payment-links" { + t.Fatalf("request URL = %q", doer.request.URL.String()) + } + if doer.request.Method != http.MethodPost { + t.Fatalf("method = %q", doer.request.Method) + } + if got := doer.request.Header.Get("Content-Type"); got != "application/json" { + t.Fatalf("Content-Type = %q", got) + } + wantAuthorization := "Basic " + + base64.StdEncoding.EncodeToString([]byte(paymentLinkServerKeyCanary+":")) + if got := doer.request.Header.Get("Authorization"); got != wantAuthorization { + t.Fatalf("Authorization = %q", got) + } + + var payload struct { + TransactionDetails struct { + OrderID string `json:"order_id"` + GrossAmount int64 `json:"gross_amount"` + } `json:"transaction_details"` + UsageLimit int `json:"usage_limit"` + } + if err := json.Unmarshal(doer.body, &payload); err != nil { + t.Fatal(err) + } + if payload.TransactionDetails.OrderID != "merchant-order-001" || + payload.TransactionDetails.GrossAmount != 12500 || + payload.UsageLimit != 3 { + t.Fatalf("request payload = %#v", payload) + } + if got.OrderID != "merchant-order-001" || + got.TransactionID != "trx-payment-link-001" || + got.PaymentURL != "https://app.sandbox.midtrans.com/payment-links/plink-001" { + t.Fatalf("response = %#v", got) + } +} + +func TestClientCreateOmitsFixedAmountForDynamicLinks(t *testing.T) { + doer := &paymentLinkRecordingDoer{ + do: func(*http.Request) (*http.Response, error) { + return paymentLinkResponse(http.StatusCreated, `{ + "order_id":"merchant-order-dynamic", + "transaction_id":"trx-payment-link-dynamic", + "payment_url":"https://app.sandbox.midtrans.com/payment-links/plink-dynamic" + }`), nil + }, + } + client := paymentlink.Client{ + HTTP: doer, + ServerKey: secrets.NewValue(paymentLinkServerKeyCanary), + } + + if _, err := client.Create(context.Background(), paymentlink.CreateRequest{ + OperationID: "payment-link-op-dynamic", + OrderID: "merchant-order-dynamic", + }); err != nil { + t.Fatal(err) + } + + var payload map[string]any + if err := json.Unmarshal(doer.body, &payload); err != nil { + t.Fatal(err) + } + details, ok := payload["transaction_details"].(map[string]any) + if !ok { + t.Fatalf("payload = %#v", payload) + } + if _, ok := details["gross_amount"]; ok { + t.Fatalf("dynamic payload retained fixed gross_amount: %#v", payload) + } +} + +func TestClientCreateRejectsReusableRequestsWithoutExplicitUsageLimit(t *testing.T) { + client := paymentlink.Client{ + HTTP: &paymentLinkRecordingDoer{do: func(*http.Request) (*http.Response, error) { return nil, nil }}, + ServerKey: secrets.NewValue(paymentLinkServerKeyCanary), + } + + _, err := client.Create(context.Background(), paymentlink.CreateRequest{ + OperationID: "payment-link-op-invalid", + OrderID: "merchant-order-invalid", + Reusable: true, + }) + if err == nil || err.Error() != "SANDBOX_REQUEST_INVALID" { + t.Fatalf("error = %v, want SANDBOX_REQUEST_INVALID", err) + } +} + +func TestClientStatusEscapesOrderIDAndParsesSafeFields(t *testing.T) { + doer := &paymentLinkRecordingDoer{ + do: func(*http.Request) (*http.Response, error) { + return paymentLinkResponse(http.StatusOK, `{ + "order_id":"merchant order/with spaces", + "transaction_id":"trx-status-001", + "transaction_status":"settlement", + "status_code":"200", + "payment_type":"payment_link", + "gross_amount":"12500.00", + "payment_url":"https://app.sandbox.midtrans.com/payment-links/should-not-persist" + }`), nil + }, + } + client := paymentlink.Client{ + HTTP: doer, + ServerKey: secrets.NewValue(paymentLinkServerKeyCanary), + } + + got, err := client.Status(context.Background(), "merchant order/with spaces") + if err != nil { + t.Fatal(err) + } + if doer.request.URL.String() != + "https://api.sandbox.midtrans.com/v2/merchant%20order%2Fwith%20spaces/status" { + t.Fatalf("request URL = %q", doer.request.URL.String()) + } + if got.OrderID != "merchant order/with spaces" || + got.TransactionID != "trx-status-001" || + got.TransactionStatus != "settlement" || + got.StatusCode != "200" || + got.NotFound { + t.Fatalf("response = %#v", got) + } + encoded, err := json.Marshal(got) + if err != nil { + t.Fatal(err) + } + if bytes.Contains(encoded, []byte("payment-links")) { + t.Fatalf("status result retained payment URL: %s", encoded) + } +} + +func TestClientRejectsProductionServerKeyBeforeHTTP(t *testing.T) { + doer := &paymentLinkRecordingDoer{ + do: func(*http.Request) (*http.Response, error) { + t.Fatal("production credential reached HTTP boundary") + return nil, nil + }, + } + client := paymentlink.Client{ + HTTP: doer, + ServerKey: secrets.NewValue("Mid-server-PRODUCTION-CANARY-DO-NOT-PRINT"), + } + + _, createErr := client.Create(context.Background(), paymentlink.CreateRequest{ + OperationID: "payment-link-op-production", + OrderID: "merchant-order-production", + GrossAmount: 10000, + }) + if !errors.Is(createErr, secrets.ErrSandboxServerKeyRequired) { + t.Fatalf("create error = %v, want sandbox credential policy", createErr) + } + _, statusErr := client.Status(context.Background(), "merchant-order-production") + if !errors.Is(statusErr, secrets.ErrSandboxServerKeyRequired) { + t.Fatalf("status error = %v, want sandbox credential policy", statusErr) + } + if doer.request != nil { + t.Fatalf("production credential created request %s", doer.request.URL) + } +} + +func TestClientResponsesAreBoundedAndStrictlyParsed(t *testing.T) { + tests := []struct { + name string + status bool + body string + }{ + { + name: "create rejects unknown field", + body: `{"order_id":"merchant-order-001","transaction_id":"trx-001","payment_url":"https://example.test","unknown":"x"}`, + }, + { + name: "create rejects trailing object", + body: `{"order_id":"merchant-order-001","transaction_id":"trx-001","payment_url":"https://example.test"}{}`, + }, + { + name: "create rejects missing payment url", + body: `{"order_id":"merchant-order-001","transaction_id":"trx-001"}`, + }, + { + name: "status rejects wrong safe field type", + status: true, + body: `{"order_id":"merchant-order-001","transaction_id":false,"transaction_status":"settlement","status_code":"200"}`, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + doer := &paymentLinkRecordingDoer{ + do: func(*http.Request) (*http.Response, error) { + return paymentLinkResponse(http.StatusOK, test.body), nil + }, + } + client := paymentlink.Client{ + HTTP: doer, + ServerKey: secrets.NewValue(paymentLinkServerKeyCanary), + } + + var err error + if test.status { + _, err = client.Status(context.Background(), "merchant-order-001") + } else { + _, err = client.Create(context.Background(), paymentlink.CreateRequest{ + OperationID: "payment-link-op-invalid-response", + OrderID: "merchant-order-001", + GrossAmount: 10000, + }) + } + if err == nil || !strings.Contains(err.Error(), "SANDBOX_RESPONSE_INVALID") { + t.Fatalf("error = %v, want SANDBOX_RESPONSE_INVALID", err) + } + }) + } +} + +func paymentLinkResponse(statusCode int, body string) *http.Response { + return &http.Response{ + StatusCode: statusCode, + Header: make(http.Header), + Body: io.NopCloser(strings.NewReader(body)), + } +} diff --git a/packs/paymentlink/journey.go b/packs/paymentlink/journey.go new file mode 100644 index 0000000..4ecff61 --- /dev/null +++ b/packs/paymentlink/journey.go @@ -0,0 +1,272 @@ +package paymentlink + +import ( + "context" + "strconv" + "time" + + "github.com/veritrans/midtrans-cli/internal/contracts" + journey "github.com/veritrans/midtrans-cli/internal/journey" + "github.com/veritrans/midtrans-cli/internal/operations" + "github.com/veritrans/midtrans-cli/internal/secrets" +) + +type Creator interface { + Create(context.Context, CreateRequest) (CreateResponse, error) +} + +type StatusGetter interface { + Status(context.Context, string) (StatusResponse, error) +} + +type JourneyRunner struct { + Create Creator + Status StatusGetter + Now func() time.Time +} + +type Handler struct { + definition journey.Definition + runner JourneyRunner + runnerOverride bool +} + +func NewCreateHandler() Handler { + return newHandler("payment-link.create", "payment-link-create") +} + +func NewReusableHandler() Handler { + return newHandler("payment-link.reusable", "payment-link-reusable") +} + +func NewVerifyHandler() Handler { + handler := newHandler("payment-link.verify", "payment-link-verify") + handler.definition.RequiredInputs = []string{"order_id"} + return handler +} + +func newHandler(id, intent string) Handler { + return Handler{ + definition: journey.Definition{ + ID: id, + Product: "payment-link", + Intent: intent, + RequiredInputs: []string{"order_id"}, + }, + } +} + +func (h Handler) WithRunner(runner JourneyRunner) Handler { + h.runner = runner + h.runnerOverride = true + if h.runner.Now == nil { + h.runner.Now = func() time.Time { return time.Now().UTC() } + } + return h +} + +func (h Handler) Definition() journey.Definition { return h.definition } + +func (h Handler) Plan(_ context.Context, request journey.Request, _ journey.Runtime) journey.Outcome { + safeData := baseSafeData(h.definition.ID, request) + return journey.Outcome{ + State: journey.Planned, + SafeData: safeData, + } +} + +func (h Handler) Execute(ctx context.Context, request journey.Request, runtime journey.Runtime) journey.Outcome { + return h.run(ctx, request, runtime) +} + +func (h Handler) Resume(ctx context.Context, request journey.Request, runtime journey.Runtime, _ operations.Record) journey.Outcome { + return h.run(ctx, request, runtime) +} + +func (h Handler) run(ctx context.Context, request journey.Request, runtime journey.Runtime) journey.Outcome { + if request.OperationID == "" || request.ManifestHash == "" || request.Input.OrderID == "" { + return inputRequired("order_id is required") + } + switch h.definition.ID { + case "payment-link.create": + if request.Input.Amount <= 0 { + return inputRequired("a positive amount is required for fixed payment links") + } + case "payment-link.reusable": + if request.Input.Amount <= 0 { + return inputRequired("a positive amount is required for reusable payment links") + } + if request.Input.UsageLimit <= 0 { + return inputRequired("usage_limit is required for reusable payment links") + } + } + + runner, outcome := h.runtimeRunner(ctx, request, runtime) + if outcome != nil { + return *outcome + } + if runner.Status == nil { + return blockedOutcome("journey dependencies are unavailable") + } + + status, err := runner.Status.Status(ctx, request.Input.OrderID) + if err == nil && !status.NotFound { + return h.evaluateStatus(request, status) + } + if h.definition.ID == "payment-link.verify" { + return blockedOutcome("payment link status is unavailable") + } + if runner.Create == nil { + return blockedOutcome("payment link creation is unavailable") + } + created, err := runner.Create.Create(ctx, CreateRequest{ + OperationID: request.OperationID, + OrderID: request.Input.OrderID, + GrossAmount: request.Input.Amount, + UsageLimit: request.Input.UsageLimit, + Reusable: h.definition.ID == "payment-link.reusable", + }) + if err != nil { + return blockedOutcome("payment link request failed") + } + return journey.Outcome{ + State: journey.AwaitingUserAction, + SafeData: withCreateMetadata( + baseSafeData(h.definition.ID, request), + created.TransactionID, + ), + Action: &journey.Action{ + Type: "browser", + URL: created.PaymentURL, + Instructions: "complete the hosted Payment Link payment and rerun this journey", + ExpiresAt: runtimeNow(runtime)().Add(15 * time.Minute), + ResumeCommand: "midtrans agent resume --operation " + request.OperationID, + }, + } +} + +func (h Handler) evaluateStatus(request journey.Request, status StatusResponse) journey.Outcome { + if status.OrderID == "" { + return blockedOutcome("provider status was invalid") + } + switch status.TransactionStatus { + case "capture", "settlement": + safeData := withCreateMetadata(baseSafeData(h.definition.ID, request), status.TransactionID) + safeData["provider_status"] = status.TransactionStatus + safeData["status_code"] = status.StatusCode + if h.definition.ID == "payment-link.verify" { + delete(safeData, "gross_amount") + } + return journey.Outcome{ + State: journey.Passed, + SafeData: safeData, + } + case "pending": + return journey.Outcome{ + State: journey.Reconciling, + SafeData: map[string]any{ + "order_id": request.Input.OrderID, + }, + } + default: + return blockedOutcome("provider status blocked the transaction") + } +} + +func (h Handler) runtimeRunner( + ctx context.Context, + request journey.Request, + runtime journey.Runtime, +) (JourneyRunner, *journey.Outcome) { + if h.runnerOverride { + return h.runner, nil + } + integration, ok := request.Manifest.IntegrationFor("payment-link") + if !ok { + outcome := blockedFinding("CAPABILITY_UNAVAILABLE", "payment-link integration is not configured for this project") + return JourneyRunner{}, &outcome + } + credentials, ok := request.Manifest.CredentialSetFor(integration.Credentials) + if !ok || credentials.ServerKey == "" { + outcome := blockedFinding("CREDENTIAL_MISSING", "the configured payment-link server-key reference is not set") + return JourneyRunner{}, &outcome + } + if runtime.ResolveCredential == nil || runtime.HTTP == nil { + outcome := blockedFinding("JOURNEY_EXECUTION_BLOCKED", "journey runtime dependencies are unavailable") + return JourneyRunner{}, &outcome + } + rawServerKey, err := runtime.ResolveCredential(ctx, request.ProjectDir, credentials.ServerKey) + if err != nil { + outcome := blockedFinding("CREDENTIAL_RESOLUTION_FAILED", "unable to resolve the configured payment-link server-key reference") + return JourneyRunner{}, &outcome + } + client := Client{ + HTTP: runtime.HTTP, + ServerKey: secrets.NewValue(string(rawServerKey)), + } + rawServerKey = nil + return JourneyRunner{ + Create: client, + Status: client, + Now: runtimeNow(runtime), + }, nil +} + +func baseSafeData(journeyID string, request journey.Request) map[string]any { + safeData := map[string]any{ + "order_id": request.Input.OrderID, + } + switch journeyID { + case "payment-link.create", "payment-link.reusable": + safeData["creation_channel"] = "api" + if request.Input.Amount > 0 { + safeData["gross_amount"] = strconv.FormatInt(request.Input.Amount, 10) + } + case "payment-link.verify": + safeData["creation_channel"] = "dashboard" + } + if request.Input.UsageLimit > 0 { + safeData["usage_limit"] = strconv.Itoa(request.Input.UsageLimit) + } + return safeData +} + +func withCreateMetadata(safeData map[string]any, transactionID string) map[string]any { + if transactionID != "" { + safeData["transaction_id"] = transactionID + } + return safeData +} + +func runtimeNow(runtime journey.Runtime) func() time.Time { + if runtime.Now != nil { + return runtime.Now + } + return func() time.Time { return time.Now().UTC() } +} + +func blockedFinding(code, message string) journey.Outcome { + return journey.Outcome{ + State: journey.Blocked, + Finding: &contracts.Finding{ + Code: code, + Severity: "blocking", + Message: message, + }, + } +} + +func inputRequired(message string) journey.Outcome { + return journey.Outcome{ + State: journey.Blocked, + Finding: &contracts.Finding{ + Code: "JOURNEY_INPUT_REQUIRED", + Severity: "blocking", + Message: message, + }, + } +} + +func blockedOutcome(message string) journey.Outcome { + return blockedFinding("JOURNEY_EXECUTION_BLOCKED", message) +} diff --git a/packs/paymentlink/journey_test.go b/packs/paymentlink/journey_test.go new file mode 100644 index 0000000..a8eb71a --- /dev/null +++ b/packs/paymentlink/journey_test.go @@ -0,0 +1,279 @@ +package paymentlink_test + +import ( + "context" + "errors" + "net/http" + "strings" + "testing" + "time" + + journeypkg "github.com/veritrans/midtrans-cli/internal/journey" + "github.com/veritrans/midtrans-cli/internal/manifest" + "github.com/veritrans/midtrans-cli/internal/operations" + "github.com/veritrans/midtrans-cli/packs/paymentlink" +) + +type fakePaymentLinkCreator struct { + calls int + requests []paymentlink.CreateRequest + response paymentlink.CreateResponse + err error +} + +func (f *fakePaymentLinkCreator) Create(_ context.Context, input paymentlink.CreateRequest) (paymentlink.CreateResponse, error) { + f.calls++ + f.requests = append(f.requests, input) + return f.response, f.err +} + +type fakePaymentLinkStatusGetter struct { + calls int + orderIDs []string + responses []paymentlink.StatusResponse + errors []error +} + +func (f *fakePaymentLinkStatusGetter) Status(_ context.Context, orderID string) (paymentlink.StatusResponse, error) { + index := f.calls + f.calls++ + f.orderIDs = append(f.orderIDs, orderID) + var response paymentlink.StatusResponse + if index < len(f.responses) { + response = f.responses[index] + } + var err error + if index < len(f.errors) { + err = f.errors[index] + } + return response, err +} + +func TestCreateJourneyRequiresPositiveFixedAmount(t *testing.T) { + handler := paymentlink.NewCreateHandler() + outcome := handler.Execute(context.Background(), paymentLinkRequest(0), journeypkg.Runtime{}) + if outcome.State != journeypkg.Blocked || + outcome.Finding == nil || + outcome.Finding.Code != "JOURNEY_INPUT_REQUIRED" { + t.Fatalf("outcome = %#v", outcome) + } +} + +func TestCreateJourneyReturnsBrowserActionWithoutPersistingPaymentURL(t *testing.T) { + handler := paymentlink.NewCreateHandler().WithRunner(paymentlink.JourneyRunner{ + Create: &fakePaymentLinkCreator{response: paymentlink.CreateResponse{ + OrderID: "merchant-order-001", + TransactionID: "trx-payment-link-001", + PaymentURL: "https://app.sandbox.midtrans.com/payment-links/plink-001", + }}, + Status: &fakePaymentLinkStatusGetter{responses: []paymentlink.StatusResponse{{OrderID: "merchant-order-001", NotFound: true}}}, + Now: fixedNow, + }) + + outcome := handler.Execute(context.Background(), paymentLinkRequest(12500), journeypkg.Runtime{}) + if outcome.State != journeypkg.AwaitingUserAction || outcome.Action == nil { + t.Fatalf("outcome = %#v", outcome) + } + if outcome.Action.URL != "https://app.sandbox.midtrans.com/payment-links/plink-001" { + t.Fatalf("action = %#v", outcome.Action) + } + if got := outcome.SafeData["payment_url"]; got != nil { + t.Fatalf("safe data retained payment_url: %#v", outcome.SafeData) + } + if outcome.SafeData["order_id"] != "merchant-order-001" || + outcome.SafeData["gross_amount"] != "12500" { + t.Fatalf("safe data = %#v", outcome.SafeData) + } +} + +func TestReusableJourneyRequiresExplicitUsageLimit(t *testing.T) { + handler := paymentlink.NewReusableHandler() + request := paymentLinkRequest(12500) + request.Input.Reusable = true + outcome := handler.Execute(context.Background(), request, journeypkg.Runtime{}) + if outcome.State != journeypkg.Blocked || + outcome.Finding == nil || + outcome.Finding.Code != "JOURNEY_INPUT_REQUIRED" { + t.Fatalf("outcome = %#v", outcome) + } +} + +func TestReusableJourneyReconcilesByTransactionIDNotLinkIDAlone(t *testing.T) { + create := &fakePaymentLinkCreator{response: paymentlink.CreateResponse{ + OrderID: "merchant-order-002", + TransactionID: "trx-payment-link-002", + PaymentURL: "https://app.sandbox.midtrans.com/payment-links/plink-reusable", + }} + status := &fakePaymentLinkStatusGetter{responses: []paymentlink.StatusResponse{ + {OrderID: "merchant-order-002", NotFound: true}, + {OrderID: "merchant-order-002", TransactionID: "trx-payment-link-002", TransactionStatus: "settlement", StatusCode: "200"}, + }} + handler := paymentlink.NewReusableHandler().WithRunner(paymentlink.JourneyRunner{ + Create: create, + Status: status, + Now: fixedNow, + }) + request := paymentLinkRequest(12500) + request.Input.Reusable = true + request.Input.UsageLimit = 3 + + first := handler.Execute(context.Background(), request, journeypkg.Runtime{}) + if first.State != journeypkg.AwaitingUserAction { + t.Fatalf("first outcome = %#v", first) + } + resumed := handler.Resume(context.Background(), request, journeypkg.Runtime{}, operations.Record{}) + if resumed.State != journeypkg.Passed { + t.Fatalf("resumed outcome = %#v", resumed) + } + if resumed.SafeData["transaction_id"] != "trx-payment-link-002" || + resumed.SafeData["usage_limit"] != "3" { + t.Fatalf("safe data = %#v", resumed.SafeData) + } +} + +func TestVerifyJourneyRepresentsDashboardCreatedLinksSafely(t *testing.T) { + handler := paymentlink.NewVerifyHandler().WithRunner(paymentlink.JourneyRunner{ + Status: &fakePaymentLinkStatusGetter{responses: []paymentlink.StatusResponse{{ + OrderID: "merchant-order-dashboard", + TransactionID: "trx-dashboard-001", + TransactionStatus: "settlement", + StatusCode: "200", + GrossAmount: "98000.00", + }}}, + }) + + request := paymentLinkRequest(0) + request.Input.OrderID = "merchant-order-dashboard" + outcome := handler.Execute(context.Background(), request, journeypkg.Runtime{}) + if outcome.State != journeypkg.Passed { + t.Fatalf("outcome = %#v", outcome) + } + if outcome.SafeData["creation_channel"] != "dashboard" || + outcome.SafeData["transaction_id"] != "trx-dashboard-001" { + t.Fatalf("safe data = %#v", outcome.SafeData) + } + if _, ok := outcome.SafeData["gross_amount"]; ok { + t.Fatalf("dashboard verify retained fixed gross amount proof: %#v", outcome.SafeData) + } +} + +func TestJourneyBuildsRunnerFromRuntimeCredentialReference(t *testing.T) { + handler := paymentlink.NewCreateHandler() + request := paymentLinkRequest(12500) + request.Manifest = manifest.Manifest{ + CredentialSets: map[string]manifest.CredentialSet{ + "sandbox-classic": { + Type: "classic", + Environment: "sandbox", + ServerKey: "env:MIDTRANS_SERVER_KEY", + }, + }, + Integrations: map[string]manifest.Integration{ + "payment-link": { + Credentials: "sandbox-classic", + }, + }, + } + var resolved []string + var requestURL string + runtime := journeypkg.Runtime{ + ResolveCredential: func(_ context.Context, _ string, reference string) ([]byte, error) { + resolved = append(resolved, reference) + return []byte(paymentLinkServerKeyCanary), nil + }, + HTTP: appDoerFunc(func(request *http.Request) (*http.Response, error) { + requestURL = request.URL.String() + return paymentLinkResponse(http.StatusCreated, `{ + "order_id":"merchant-order-001", + "transaction_id":"trx-payment-link-runtime", + "payment_url":"https://app.sandbox.midtrans.com/payment-links/plink-runtime" + }`), nil + }), + Now: fixedNow, + } + + outcome := handler.Execute(context.Background(), request, runtime) + if outcome.State != journeypkg.AwaitingUserAction { + t.Fatalf("outcome = %#v", outcome) + } + if len(resolved) != 1 || resolved[0] != "env:MIDTRANS_SERVER_KEY" { + t.Fatalf("resolved = %#v", resolved) + } + if requestURL != "https://api.sandbox.midtrans.com/v1/payment-links" { + t.Fatalf("request URL = %q", requestURL) + } +} + +func TestJourneyBlocksWhenRuntimeDependenciesAreUnavailable(t *testing.T) { + handler := paymentlink.NewCreateHandler() + request := paymentLinkRequest(12500) + request.Manifest = manifest.Manifest{ + CredentialSets: map[string]manifest.CredentialSet{ + "sandbox-classic": { + Type: "classic", + Environment: "sandbox", + ServerKey: "env:MIDTRANS_SERVER_KEY", + }, + }, + Integrations: map[string]manifest.Integration{ + "payment-link": { + Credentials: "sandbox-classic", + }, + }, + } + + outcome := handler.Execute(context.Background(), request, journeypkg.Runtime{}) + if outcome.State != journeypkg.Blocked || + outcome.Finding == nil || + outcome.Finding.Code != "JOURNEY_EXECUTION_BLOCKED" { + t.Fatalf("outcome = %#v", outcome) + } +} + +func TestJourneyDoesNotLeakSensitiveReferences(t *testing.T) { + handler := paymentlink.NewCreateHandler().WithRunner(paymentlink.JourneyRunner{ + Create: &fakePaymentLinkCreator{err: errors.New("transport canary should not leak")}, + Status: &fakePaymentLinkStatusGetter{responses: []paymentlink.StatusResponse{{OrderID: "merchant-order-001", NotFound: true}}}, + }) + + outcome := handler.Execute(context.Background(), paymentLinkRequest(12500), journeypkg.Runtime{}) + if outcome.Finding == nil || !strings.Contains(outcome.Finding.Message, "payment link request failed") { + t.Fatalf("outcome = %#v", outcome) + } +} + +func paymentLinkRequest(amount int64) journeypkg.Request { + return journeypkg.Request{ + OperationID: "op_payment_link_test", + ProjectDir: tTempDirUnsafe(), + ManifestHash: "manifest-hash", + Manifest: manifest.Manifest{ + Integrations: map[string]manifest.Integration{ + "payment-link": {Credentials: "sandbox-classic"}, + }, + CredentialSets: map[string]manifest.CredentialSet{ + "sandbox-classic": { + Type: "classic", + Environment: "sandbox", + ServerKey: "env:MIDTRANS_SERVER_KEY", + }, + }, + }, + Input: journeypkg.Input{ + OrderID: "merchant-order-001", + Amount: amount, + }, + } +} + +func fixedNow() time.Time { + return time.Date(2026, 7, 27, 10, 0, 0, 0, time.UTC) +} + +func tTempDirUnsafe() string { return "." } + +type appDoerFunc func(*http.Request) (*http.Response, error) + +func (f appDoerFunc) Do(request *http.Request) (*http.Response, error) { + return f(request) +} diff --git a/packs/paymentlink/pack.go b/packs/paymentlink/pack.go new file mode 100644 index 0000000..a5822f7 --- /dev/null +++ b/packs/paymentlink/pack.go @@ -0,0 +1,75 @@ +package paymentlink + +import ( + "github.com/veritrans/midtrans-cli/internal/contracts" + "github.com/veritrans/midtrans-cli/internal/inspection" + "github.com/veritrans/midtrans-cli/internal/journey" + "github.com/veritrans/midtrans-cli/internal/manifest" + "github.com/veritrans/midtrans-cli/internal/packs" +) + +type Pack struct{} + +func New() Pack { return Pack{} } + +func (Pack) Descriptor() packs.Descriptor { + return packs.Descriptor{ + ID: "payment-link", + Version: "0.1.0", + Capabilities: []contracts.Capability{ + {ID: "payment-link.create.verify.v1", Description: "run and verify an API-created fixed Payment Link journey", Pack: "payment-link"}, + {ID: "payment-link.reusable.verify.v1", Description: "run and verify a reusable Payment Link journey", Pack: "payment-link"}, + {ID: "payment-link.verify.v1", Description: "verify a dashboard-created or externally created Payment Link by order reference", Pack: "payment-link"}, + }, + Journeys: []string{ + "payment-link.create", + "payment-link.reusable", + "payment-link.verify", + }, + SandboxHosts: []string{"api.sandbox.midtrans.com"}, + SensitiveKeys: []string{ + "signature_key", + }, + Sources: []contracts.PublicSource{ + {ID: "payment-link-overview", URL: "https://docs.midtrans.com/docs/payment-link-via-api", Rules: []string{"paymentlink.create", "paymentlink.reusable"}}, + {ID: "payment-link-status", URL: "https://docs.midtrans.com/reference/get-transaction-status", Rules: []string{"paymentlink.status.reconcile"}}, + {ID: "payment-link-notifications", URL: "https://docs.midtrans.com/docs/https-notification-webhooks", Rules: []string{"paymentlink.notification.signature", "common.webhook-idempotency"}}, + }, + } +} + +func (Pack) Evaluate(value manifest.Manifest, report inspection.Report) []contracts.Finding { + integration, ok := value.IntegrationFor("payment-link") + if !ok { + return []contracts.Finding{{ + Code: "PAYMENT_LINK_PRODUCT_NOT_SELECTED", + Severity: "blocking", + Message: "integrations must include payment-link", + }} + } + if integration.Callbacks["notification"] == "" { + return []contracts.Finding{{ + Code: "PAYMENT_LINK_NOTIFICATION_ROUTE_MISSING", + Severity: "blocking", + Message: "integrations.payment-link.callbacks.notification is required", + }} + } + credentials, hasCredentials := value.CredentialSetFor(integration.Credentials) + if hasCredentials && credentials.ServerKey != "" && len(report.Facts) > 0 && + !report.Has("midtrans.server-key-reference") { + return []contracts.Finding{{ + Code: "PAYMENT_LINK_SERVER_KEY_REFERENCE_NOT_FOUND", + Severity: "warning", + Message: "repository inspection did not find the configured server-key reference", + }} + } + return nil +} + +func (Pack) Handlers() []journey.Handler { + return []journey.Handler{ + NewCreateHandler(), + NewReusableHandler(), + NewVerifyHandler(), + } +} diff --git a/packs/paymentlink/pack_test.go b/packs/paymentlink/pack_test.go new file mode 100644 index 0000000..4857e6e --- /dev/null +++ b/packs/paymentlink/pack_test.go @@ -0,0 +1,35 @@ +package paymentlink_test + +import ( + "reflect" + "testing" + + "github.com/veritrans/midtrans-cli/packs/paymentlink" +) + +func TestPackDescriptorPublishesPaymentLinkJourneysAndCapabilities(t *testing.T) { + descriptor := paymentlink.New().Descriptor() + wantCapabilities := []string{ + "payment-link.create.verify.v1", + "payment-link.reusable.verify.v1", + "payment-link.verify.v1", + } + gotCapabilities := make([]string, 0, len(descriptor.Capabilities)) + for _, capability := range descriptor.Capabilities { + gotCapabilities = append(gotCapabilities, capability.ID) + } + if !reflect.DeepEqual(gotCapabilities, wantCapabilities) { + t.Fatalf("capabilities = %#v, want %#v", gotCapabilities, wantCapabilities) + } + wantJourneys := []string{ + "payment-link.create", + "payment-link.reusable", + "payment-link.verify", + } + if !reflect.DeepEqual(descriptor.Journeys, wantJourneys) { + t.Fatalf("journeys = %#v, want %#v", descriptor.Journeys, wantJourneys) + } + if !reflect.DeepEqual(descriptor.SandboxHosts, []string{"api.sandbox.midtrans.com"}) { + t.Fatalf("sandbox hosts = %#v", descriptor.SandboxHosts) + } +} diff --git a/testdata/paymentlink/create-success.json b/testdata/paymentlink/create-success.json new file mode 100644 index 0000000..5625f33 --- /dev/null +++ b/testdata/paymentlink/create-success.json @@ -0,0 +1,5 @@ +{ + "order_id": "merchant-order-001", + "transaction_id": "trx-payment-link-001", + "payment_url": "https://app.sandbox.midtrans.com/payment-links/plink-001" +} diff --git a/tools/source-baseline/main.go b/tools/source-baseline/main.go index f11d169..8cfdb0b 100644 --- a/tools/source-baseline/main.go +++ b/tools/source-baseline/main.go @@ -8,6 +8,8 @@ import ( "time" "github.com/veritrans/midtrans-cli/internal/sourceprovenance" + "github.com/veritrans/midtrans-cli/packs/coreapi" + "github.com/veritrans/midtrans-cli/packs/paymentlink" "github.com/veritrans/midtrans-cli/packs/snap" ) @@ -20,7 +22,11 @@ func main() { os.Exit(2) } - sources := snap.New().Descriptor().Sources + sources := append( + snap.New().Descriptor().Sources, + coreapi.New().Descriptor().Sources..., + ) + sources = append(sources, paymentlink.New().Descriptor().Sources...) baseline, err := sourceprovenance.Generate(context.Background(), sources, time.Now()) if err != nil { fmt.Fprintln(os.Stderr, err) diff --git a/tools/source-drift/main.go b/tools/source-drift/main.go index ba579d0..6fa377c 100644 --- a/tools/source-drift/main.go +++ b/tools/source-drift/main.go @@ -8,6 +8,7 @@ import ( "github.com/veritrans/midtrans-cli/internal/sourceprovenance" "github.com/veritrans/midtrans-cli/packs/coreapi" + "github.com/veritrans/midtrans-cli/packs/paymentlink" "github.com/veritrans/midtrans-cli/packs/snap" ) @@ -29,6 +30,7 @@ func main() { snap.New().Descriptor().Sources, coreapi.New().Descriptor().Sources..., ) + sources = append(sources, paymentlink.New().Descriptor().Sources...) if err := sourceprovenance.ValidateBaseline(baseline, sources); err != nil { fmt.Fprintln(os.Stderr, err) os.Exit(1) From ca55bdee6ec81de18411e8415e3ae27e604df0ba Mon Sep 17 00:00:00 2001 From: "m.salis" Date: Mon, 27 Jul 2026 11:09:44 +0700 Subject: [PATCH 42/73] fix: restore payment link resume and routing --- .../task-7-report.md | 55 ++++++++++++ internal/app/app_test.go | 90 +++++++++++++++++++ internal/app/commands_checkout.go | 12 +-- internal/app/commands_test.go | 64 ++++++++++++- packs/paymentlink/journey.go | 65 ++++++++++++-- packs/paymentlink/journey_test.go | 72 ++++++++++++++- 6 files changed, 337 insertions(+), 21 deletions(-) create mode 100644 .superpowers/sdd/2026-07-26-midtrans-cli-multi-product-parity/task-7-report.md diff --git a/.superpowers/sdd/2026-07-26-midtrans-cli-multi-product-parity/task-7-report.md b/.superpowers/sdd/2026-07-26-midtrans-cli-multi-product-parity/task-7-report.md new file mode 100644 index 0000000..4043d91 --- /dev/null +++ b/.superpowers/sdd/2026-07-26-midtrans-cli-multi-product-parity/task-7-report.md @@ -0,0 +1,55 @@ +Status: completed + +Commit: `4124a90` (`feat: add Payment Link journeys`) + +Files: +- Added `packs/paymentlink/client.go`, `packs/paymentlink/client_test.go` +- Added `packs/paymentlink/journey.go`, `packs/paymentlink/journey_test.go` +- Added `packs/paymentlink/pack.go`, `packs/paymentlink/pack_test.go` +- Added `testdata/paymentlink/create-success.json` +- Updated `cmd/midtrans/main.go` +- Updated `internal/journey/types.go` +- Updated `internal/app/commands_agent.go` +- Updated `internal/app/commands_checkout.go` +- Updated `internal/app/app_test.go` +- Updated `internal/packs/registry_test.go` +- Updated `contracts/capabilities-v1.json` +- Updated `contracts/public-sources-v1.json` +- Updated `tools/source-baseline/main.go` +- Updated `tools/source-drift/main.go` + +Tests: +- `go test ./packs/paymentlink -count=1` +- `go test ./packs/paymentlink ./internal/app ./test/e2e -count=1` +- `go test ./... -count=1` + +Assumptions: +- Added a new safe generic input `usage_limit` and corresponding `--usage-limit` flag because the existing journey input model had no honest way to represent reusable Payment Link limits. +- Split Payment Link exact journeys into `payment-link.create`, `payment-link.reusable`, and `payment-link.verify`; this keeps reusable enforcement and dashboard verification explicit while preserving generic agent journey routing. +- `payment-link.verify` represents externally or dashboard-created links by order reference only and reports `creation_channel: dashboard` without treating `gross_amount` as fixed proof. + +Blockers / concerns: +- Core task scope is complete and committed. +- Additional release-gate check `go run ./tools/source-drift --baseline contracts/public-sources-v1.json` still reports `source drift: technical-faq`. This persisted after wiring Payment Link into the drift tooling and regenerating the committed baseline, so it appears to be an unrelated volatile docs-source issue rather than a Task 7 implementation failure. + +Fix round 1: + +Status: completed + +Commit: pending + +Files: +- Updated `packs/paymentlink/journey.go` +- Updated `packs/paymentlink/journey_test.go` +- Updated `internal/app/commands_checkout.go` +- Updated `internal/app/commands_test.go` +- Updated `internal/app/app_test.go` + +Tests: +- `go test ./packs/paymentlink ./internal/app -run 'ResumeRehydratesSafeReferencesWithoutFreshInput|ReusableJourneyReconcilesByTransactionIDNotLinkIDAlone|ReusableJourneyBlocksWhenStatusTransactionIDDoesNotMatchStoredReference|MerchantPaymentLinkIntentExecutesThroughGenericJourneyRuntime' -count=1` +- `go test ./internal/app -run 'AgentResumeRehydratesPaymentLinkOperationFromRecordedSafeReferences' -count=1` +- `go test ./packs/paymentlink ./internal/app ./test/e2e -count=1` +- `go test ./... -count=1` + +Blockers / concerns: +- None for this fix round. diff --git a/internal/app/app_test.go b/internal/app/app_test.go index c48abc9..2204fec 100644 --- a/internal/app/app_test.go +++ b/internal/app/app_test.go @@ -190,6 +190,96 @@ func TestAgentResumeUsesRecordedHandler(t *testing.T) { } } +func TestAgentResumeRehydratesPaymentLinkOperationFromRecordedSafeReferences(t *testing.T) { + project := createJourneyProject(t, "http://127.0.0.1:1") + configureManifest(t, project, func(value *manifest.Manifest) { + value.Integrations["payment-link"] = manifest.Integration{ + ConfigVersion: 1, + Credentials: "classic", + Callbacks: map[string]string{ + "notification": "/midtrans/payment-link/notification", + }, + } + }) + statusLookups := []string{} + deps := app.Dependencies{ + Version: version.Info{Version: "0.1.0-test"}, + Packs: testRegistry(t), + Getenv: func(key string) (string, bool) { + return journeyServerKeyCanary, key == "MIDTRANS_SERVER_KEY" + }, + HTTP: appDoerFunc(func(request *http.Request) (*http.Response, error) { + assertJourneyBasicAuth(t, request) + switch request.URL.String() { + case "https://api.sandbox.midtrans.com/v2/payment-link-order-resume/status": + statusLookups = append(statusLookups, "payment-link-order-resume") + return journeyHTTPResponse(http.StatusNotFound, []byte(`{"status_code":"404"}`)), nil + case "https://api.sandbox.midtrans.com/v1/payment-links": + return journeyHTTPResponse(http.StatusCreated, []byte(`{ + "order_id":"payment-link-order-resume", + "transaction_id":"trx-payment-link-resume", + "payment_url":"https://app.sandbox.midtrans.com/payment-links/plink-resume" + }`)), nil + case "https://api.sandbox.midtrans.com/v2/trx-payment-link-resume/status": + statusLookups = append(statusLookups, "trx-payment-link-resume") + return journeyHTTPResponse(http.StatusOK, []byte(`{ + "order_id":"payment-link-order-resume", + "transaction_id":"trx-payment-link-resume", + "transaction_status":"settlement", + "status_code":"200", + "payment_type":"payment_link", + "gross_amount":"12500.00" + }`)), nil + default: + t.Fatalf("unexpected request: %s %s", request.Method, request.URL.String()) + return nil, nil + } + }), + } + + first, exit := executeJSONWithDependencies( + t, + deps, + "agent", "run", + "--journey", "payment-link.reusable", + "--amount", "12500", + "--usage-limit", "3", + "--order-id", "payment-link-order-resume", + "--operation", "op_payment_link_resume", + "--execute", + "--project-dir", project, + ) + if exit != 3 { + t.Fatalf("run exit = %d, result = %#v", exit, first) + } + firstData := requireJourneyData(t, first) + if firstData["state"] != "checkout_required" || firstData["transaction_id"] != "trx-payment-link-resume" { + t.Fatalf("first data = %#v", firstData) + } + + resumed, exit := executeJSONWithDependencies( + t, + deps, + "agent", "resume", + "--operation", "op_payment_link_resume", + "--project-dir", project, + ) + if exit != 0 || resumed.Command != "agent.resume" { + t.Fatalf("exit = %d, result = %#v", exit, resumed) + } + data := requireJourneyData(t, resumed) + if data["journey"] != "payment-link.reusable" || + data["state"] != "verified" || + data["operation_id"] != "op_payment_link_resume" || + data["transaction_id"] != "trx-payment-link-resume" || + data["usage_limit"] != "3" { + t.Fatalf("data = %#v", data) + } + if !reflect.DeepEqual(statusLookups, []string{"payment-link-order-resume", "trx-payment-link-resume"}) { + t.Fatalf("status lookups = %#v", statusLookups) + } +} + func TestLegacyCapabilitiesJSONRemainsCompatibleAndHidden(t *testing.T) { legacy, legacyExit := executeJSON( t, "capabilities", "--json", "--non-interactive", diff --git a/internal/app/commands_checkout.go b/internal/app/commands_checkout.go index 7c52819..877950f 100644 --- a/internal/app/commands_checkout.go +++ b/internal/app/commands_checkout.go @@ -173,17 +173,7 @@ func runRoutedMerchantJourney( return result } if handler.Definition().ID != "snap.checkout" { - if handler.Definition().Product == "core-api" { - return runGenericJourney(ctx, request, deps) - } - result := contracts.NewResult(request.Command, contracts.StatusBlocked) - result.CLIVersion = deps.Version.Version - result.ManifestVersion = value.SchemaVersion - result.Findings = []contracts.Finding{{ - Code: "CAPABILITY_UNAVAILABLE", Severity: "blocking", - Message: "requested journey is not implemented in this CLI build", - }} - return result + return runGenericJourney(ctx, request, deps) } return runCheckout(ctx, checkoutRequest{ Command: request.Command, diff --git a/internal/app/commands_test.go b/internal/app/commands_test.go index 055295e..11f2b23 100644 --- a/internal/app/commands_test.go +++ b/internal/app/commands_test.go @@ -190,8 +190,68 @@ func TestMerchantGenericIntentUsesGenericCommandIdentityAndListingNextAction(t * if exit != 3 || result.Command != "test.refund_status" { t.Fatalf("exit = %d, result = %#v", exit, result) } - if len(result.Findings) != 1 || result.Findings[0].Code != "CAPABILITY_UNAVAILABLE" { - t.Fatalf("findings = %#v", result.Findings) + data := requireJourneyData(t, result) + if len(result.Findings) != 0 || data["journey"] != "alt.refund-status" || data["merchant_reference"] != "refund-001" { + t.Fatalf("result = %#v", result) + } +} + +func TestMerchantPaymentLinkIntentExecutesThroughGenericJourneyRuntime(t *testing.T) { + project := createJourneyProject(t, "http://127.0.0.1:1") + configureManifest(t, project, func(value *manifest.Manifest) { + value.Integrations["payment-link"] = manifest.Integration{ + ConfigVersion: 1, + Credentials: "classic", + Callbacks: map[string]string{ + "notification": "/midtrans/payment-link/notification", + }, + } + value.Routing["payment-link-create"] = "payment-link" + }) + statusCalls := 0 + createCalls := 0 + result, exit := executeJSONWithDependencies( + t, + app.Dependencies{ + Version: version.Info{Version: "0.1.0-test"}, + Packs: testRegistry(t), + Getenv: func(key string) (string, bool) { + return journeyServerKeyCanary, key == "MIDTRANS_SERVER_KEY" + }, + HTTP: appDoerFunc(func(request *http.Request) (*http.Response, error) { + assertJourneyBasicAuth(t, request) + switch request.URL.String() { + case "https://api.sandbox.midtrans.com/v2/payment-link-order-001/status": + statusCalls++ + return journeyHTTPResponse(http.StatusNotFound, []byte(`{"status_code":"404"}`)), nil + case "https://api.sandbox.midtrans.com/v1/payment-links": + createCalls++ + return journeyHTTPResponse(http.StatusCreated, []byte(`{ + "order_id":"payment-link-order-001", + "transaction_id":"trx-payment-link-001", + "payment_url":"https://app.sandbox.midtrans.com/payment-links/plink-001" + }`)), nil + default: + t.Fatalf("unexpected request: %s %s", request.Method, request.URL.String()) + return nil, nil + } + }), + }, + "test", "payment-link-create", + "--amount", "10000", + "--order-id", "payment-link-order-001", + "--execute", + "--project-dir", project, + ) + if exit != 3 || result.Command != "test.payment_link_create" { + t.Fatalf("exit = %d, result = %#v", exit, result) + } + data := requireJourneyData(t, result) + if data["journey"] != "payment-link.create" || data["product"] != "payment-link" || data["state"] != "checkout_required" { + t.Fatalf("data = %#v", data) + } + if statusCalls != 1 || createCalls != 1 { + t.Fatalf("status calls = %d, create calls = %d", statusCalls, createCalls) } } diff --git a/packs/paymentlink/journey.go b/packs/paymentlink/journey.go index 4ecff61..33a0622 100644 --- a/packs/paymentlink/journey.go +++ b/packs/paymentlink/journey.go @@ -76,14 +76,20 @@ func (h Handler) Plan(_ context.Context, request journey.Request, _ journey.Runt } func (h Handler) Execute(ctx context.Context, request journey.Request, runtime journey.Runtime) journey.Outcome { - return h.run(ctx, request, runtime) + return h.run(ctx, request, runtime, nil) } -func (h Handler) Resume(ctx context.Context, request journey.Request, runtime journey.Runtime, _ operations.Record) journey.Outcome { - return h.run(ctx, request, runtime) +func (h Handler) Resume(ctx context.Context, request journey.Request, runtime journey.Runtime, record operations.Record) journey.Outcome { + return h.run(ctx, request, runtime, &record) } -func (h Handler) run(ctx context.Context, request journey.Request, runtime journey.Runtime) journey.Outcome { +func (h Handler) run( + ctx context.Context, + request journey.Request, + runtime journey.Runtime, + record *operations.Record, +) journey.Outcome { + request = rehydrateRequest(request, record) if request.OperationID == "" || request.ManifestHash == "" || request.Input.OrderID == "" { return inputRequired("order_id is required") } @@ -109,9 +115,10 @@ func (h Handler) run(ctx context.Context, request journey.Request, runtime journ return blockedOutcome("journey dependencies are unavailable") } - status, err := runner.Status.Status(ctx, request.Input.OrderID) + statusKey := statusLookupKey(request, record) + status, err := runner.Status.Status(ctx, statusKey) if err == nil && !status.NotFound { - return h.evaluateStatus(request, status) + return h.evaluateStatus(request, record, status) } if h.definition.ID == "payment-link.verify" { return blockedOutcome("payment link status is unavailable") @@ -145,10 +152,18 @@ func (h Handler) run(ctx context.Context, request journey.Request, runtime journ } } -func (h Handler) evaluateStatus(request journey.Request, status StatusResponse) journey.Outcome { +func (h Handler) evaluateStatus( + request journey.Request, + record *operations.Record, + status StatusResponse, +) journey.Outcome { if status.OrderID == "" { return blockedOutcome("provider status was invalid") } + if expected := expectedTransactionID(request, record); expected != "" && + status.TransactionID != "" && status.TransactionID != expected { + return blockedOutcome("provider status did not match the recorded payment link transaction") + } switch status.TransactionStatus { case "capture", "settlement": safeData := withCreateMetadata(baseSafeData(h.definition.ID, request), status.TransactionID) @@ -238,6 +253,42 @@ func withCreateMetadata(safeData map[string]any, transactionID string) map[strin return safeData } +func rehydrateRequest(request journey.Request, record *operations.Record) journey.Request { + if record == nil || record.SafeReferences == nil { + return request + } + if request.Input.OrderID == "" { + request.Input.OrderID = record.SafeReferences["order_id"] + } + if request.Input.Amount <= 0 { + if amount, err := strconv.ParseInt(record.SafeReferences["gross_amount"], 10, 64); err == nil { + request.Input.Amount = amount + } + } + if request.Input.UsageLimit <= 0 { + if usageLimit, err := strconv.Atoi(record.SafeReferences["usage_limit"]); err == nil { + request.Input.UsageLimit = usageLimit + } + } + return request +} + +func statusLookupKey(request journey.Request, record *operations.Record) string { + if request.Input.Reusable || request.Input.UsageLimit > 0 { + if transactionID := expectedTransactionID(request, record); transactionID != "" { + return transactionID + } + } + return request.Input.OrderID +} + +func expectedTransactionID(request journey.Request, record *operations.Record) string { + if record != nil && record.SafeReferences != nil && record.SafeReferences["transaction_id"] != "" { + return record.SafeReferences["transaction_id"] + } + return "" +} + func runtimeNow(runtime journey.Runtime) func() time.Time { if runtime.Now != nil { return runtime.Now diff --git a/packs/paymentlink/journey_test.go b/packs/paymentlink/journey_test.go index a8eb71a..dce6eff 100644 --- a/packs/paymentlink/journey_test.go +++ b/packs/paymentlink/journey_test.go @@ -121,7 +121,15 @@ func TestReusableJourneyReconcilesByTransactionIDNotLinkIDAlone(t *testing.T) { if first.State != journeypkg.AwaitingUserAction { t.Fatalf("first outcome = %#v", first) } - resumed := handler.Resume(context.Background(), request, journeypkg.Runtime{}, operations.Record{}) + resumed := handler.Resume(context.Background(), request, journeypkg.Runtime{}, operations.Record{ + SafeReferences: map[string]string{ + "order_id": "merchant-order-002", + "gross_amount": "12500", + "usage_limit": "3", + "creation_channel": "api", + "transaction_id": "trx-payment-link-002", + }, + }) if resumed.State != journeypkg.Passed { t.Fatalf("resumed outcome = %#v", resumed) } @@ -129,6 +137,68 @@ func TestReusableJourneyReconcilesByTransactionIDNotLinkIDAlone(t *testing.T) { resumed.SafeData["usage_limit"] != "3" { t.Fatalf("safe data = %#v", resumed.SafeData) } + if len(status.orderIDs) != 2 || status.orderIDs[1] != "trx-payment-link-002" { + t.Fatalf("status order IDs = %#v", status.orderIDs) + } +} + +func TestResumeRehydratesSafeReferencesWithoutFreshInput(t *testing.T) { + handler := paymentlink.NewReusableHandler().WithRunner(paymentlink.JourneyRunner{ + Status: &fakePaymentLinkStatusGetter{responses: []paymentlink.StatusResponse{{ + OrderID: "merchant-order-resume", + TransactionID: "trx-payment-link-resume", + TransactionStatus: "settlement", + StatusCode: "200", + }}}, + }) + + request := paymentLinkRequest(0) + request.Input = journeypkg.Input{} + outcome := handler.Resume(context.Background(), request, journeypkg.Runtime{}, operations.Record{ + SafeReferences: map[string]string{ + "order_id": "merchant-order-resume", + "gross_amount": "12500", + "usage_limit": "7", + "creation_channel": "api", + "transaction_id": "trx-payment-link-resume", + }, + }) + if outcome.State != journeypkg.Passed { + t.Fatalf("outcome = %#v", outcome) + } + if outcome.SafeData["order_id"] != "merchant-order-resume" || + outcome.SafeData["gross_amount"] != "12500" || + outcome.SafeData["usage_limit"] != "7" || + outcome.SafeData["creation_channel"] != "api" || + outcome.SafeData["transaction_id"] != "trx-payment-link-resume" { + t.Fatalf("safe data = %#v", outcome.SafeData) + } +} + +func TestReusableJourneyBlocksWhenStatusTransactionIDDoesNotMatchStoredReference(t *testing.T) { + handler := paymentlink.NewReusableHandler().WithRunner(paymentlink.JourneyRunner{ + Status: &fakePaymentLinkStatusGetter{responses: []paymentlink.StatusResponse{{ + OrderID: "merchant-order-mismatch", + TransactionID: "trx-other", + TransactionStatus: "settlement", + StatusCode: "200", + }}}, + }) + + request := paymentLinkRequest(0) + request.Input = journeypkg.Input{} + outcome := handler.Resume(context.Background(), request, journeypkg.Runtime{}, operations.Record{ + SafeReferences: map[string]string{ + "order_id": "merchant-order-mismatch", + "gross_amount": "12500", + "usage_limit": "3", + "creation_channel": "api", + "transaction_id": "trx-expected", + }, + }) + if outcome.State != journeypkg.Blocked || outcome.Finding == nil || outcome.Finding.Code != "JOURNEY_EXECUTION_BLOCKED" { + t.Fatalf("outcome = %#v", outcome) + } } func TestVerifyJourneyRepresentsDashboardCreatedLinksSafely(t *testing.T) { From 9188f701dd578c18b0875e1c6187aa26a7a6c7ec Mon Sep 17 00:00:00 2001 From: "m.salis" Date: Mon, 27 Jul 2026 11:10:01 +0700 Subject: [PATCH 43/73] docs: finalize task 7 fix report --- .../task-7-report.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.superpowers/sdd/2026-07-26-midtrans-cli-multi-product-parity/task-7-report.md b/.superpowers/sdd/2026-07-26-midtrans-cli-multi-product-parity/task-7-report.md index 4043d91..6574945 100644 --- a/.superpowers/sdd/2026-07-26-midtrans-cli-multi-product-parity/task-7-report.md +++ b/.superpowers/sdd/2026-07-26-midtrans-cli-multi-product-parity/task-7-report.md @@ -36,7 +36,7 @@ Fix round 1: Status: completed -Commit: pending +Commit: `ca55bde` (`fix: restore payment link resume and routing`) Files: - Updated `packs/paymentlink/journey.go` From 59d542260ef12aa196f5865af2f3a382cfaffec0 Mon Sep 17 00:00:00 2001 From: "m.salis" Date: Mon, 27 Jul 2026 11:12:00 +0700 Subject: [PATCH 44/73] docs: correct BI-SNAP status method --- .../plans/2026-07-26-midtrans-cli-multi-product-parity.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/superpowers/plans/2026-07-26-midtrans-cli-multi-product-parity.md b/docs/superpowers/plans/2026-07-26-midtrans-cli-multi-product-parity.md index 9d8f4be..dc8ce15 100644 --- a/docs/superpowers/plans/2026-07-26-midtrans-cli-multi-product-parity.md +++ b/docs/superpowers/plans/2026-07-26-midtrans-cli-multi-product-parity.md @@ -875,7 +875,7 @@ Assert exact endpoints and service codes: POST /v1.0/qr/qr-mpm-generate service 47 POST /v1.0/transfer-va/create-va service 27 POST /v1.0/debit/payment-host-to-host service 54 -GET /v1.0/debit/status service 55 +POST /v1.0/debit/status service 55 POST /v1.0/debit/refund service 58 ``` From cf7e97f80498a1701fff05395aa424d48cffbcbc Mon Sep 17 00:00:00 2001 From: "m.salis" Date: Mon, 27 Jul 2026 11:21:20 +0700 Subject: [PATCH 45/73] feat: implement BI-SNAP protocol security --- .../task-8-report.md | 39 +++++ packs/bisnap/client.go | 133 +++++++++++++++ packs/bisnap/client_test.go | 136 ++++++++++++++++ packs/bisnap/endpoints.go | 13 ++ packs/bisnap/notification.go | 63 ++++++++ packs/bisnap/notification_test.go | 122 ++++++++++++++ packs/bisnap/signature.go | 130 +++++++++++++++ packs/bisnap/signature_test.go | 153 ++++++++++++++++++ testdata/bisnap/private_key_pkcs8.pem | 28 ++++ testdata/bisnap/public_key_pkix.pem | 9 ++ 10 files changed, 826 insertions(+) create mode 100644 .superpowers/sdd/2026-07-26-midtrans-cli-multi-product-parity/task-8-report.md create mode 100644 packs/bisnap/client.go create mode 100644 packs/bisnap/client_test.go create mode 100644 packs/bisnap/endpoints.go create mode 100644 packs/bisnap/notification.go create mode 100644 packs/bisnap/notification_test.go create mode 100644 packs/bisnap/signature.go create mode 100644 packs/bisnap/signature_test.go create mode 100644 testdata/bisnap/private_key_pkcs8.pem create mode 100644 testdata/bisnap/public_key_pkix.pem diff --git a/.superpowers/sdd/2026-07-26-midtrans-cli-multi-product-parity/task-8-report.md b/.superpowers/sdd/2026-07-26-midtrans-cli-multi-product-parity/task-8-report.md new file mode 100644 index 0000000..0aa20c6 --- /dev/null +++ b/.superpowers/sdd/2026-07-26-midtrans-cli-multi-product-parity/task-8-report.md @@ -0,0 +1,39 @@ +# Task 8 Report + +## Status + +Completed on July 27, 2026. + +## Scope Delivered + +- Added `packs/bisnap` protocol helpers for BI-SNAP access-token signing, transactional signing, notification verification, sandbox request building, endpoint constants, and notification route metadata. +- Added sanitized RSA fixtures under `testdata/bisnap/`. +- Kept the signer and client responsibilities separate so the protocol foundation is usable without changing manifest shape in this task. + +## TDD Notes + +- RED captured with: + +```sh +go test ./packs/bisnap -run 'TestSign|TestVerify|TestPad' -count=1 +``` + +- Initial failure was the expected missing Task 8 surface: + `Client`, `Request`, `SignAccessToken`, `SignTransaction`, + `VerifyNotification`, `NotificationRouteForPath`, and + `VerifyNotificationCallback`. + +## Tests + +Passed: + +```sh +go test ./packs/bisnap -run 'TestSign|TestVerify|TestPad' -count=1 +go test ./packs/bisnap -count=1 +go test ./... -count=1 +``` + +## Concerns + +- The current manifest model does not carry a BI-SNAP `client_secret`, so Task 8 keeps the transactional signer and signed-request builder generic and local to the pack without widening manifest validation in this task. +- Product journeys, access-token response handling, and webhook response serialization remain for later tasks by design. diff --git a/packs/bisnap/client.go b/packs/bisnap/client.go new file mode 100644 index 0000000..3133d35 --- /dev/null +++ b/packs/bisnap/client.go @@ -0,0 +1,133 @@ +package bisnap + +import ( + "bytes" + "context" + "errors" + "net/http" + "strconv" + "strings" + "time" +) + +type Request struct { + Method string + Path string + AccessToken string + CustomerToken string + Body []byte + UseApplicationHost bool +} + +type Client struct { + ClientID string + PartnerID string + ChannelID string + PrivateKeyPEM []byte + ClientSecret []byte + Now func() time.Time + NewExternalID func() (string, error) +} + +func (c Client) NewAccessTokenRequest(ctx context.Context) (*http.Request, error) { + timestamp := c.now().Format(time.RFC3339) + signature, err := SignAccessToken(c.PrivateKeyPEM, c.ClientID, timestamp) + if err != nil { + return nil, err + } + body := []byte(`{"grantType":"client_credentials"}`) + request, err := http.NewRequestWithContext( + ctx, + http.MethodPost, + sandboxApplicationBaseURL+accessTokenPath, + bytes.NewReader(body), + ) + if err != nil { + return nil, errRequestInvalid + } + request.Header.Set("Content-Type", "application/json") + request.Header.Set("X-CLIENT-KEY", c.ClientID) + request.Header.Set("X-TIMESTAMP", timestamp) + request.Header.Set("X-SIGNATURE", signature) + return request, nil +} + +func (c Client) NewTransactionRequest(ctx context.Context, input Request) (*http.Request, error) { + if input.Method == "" || input.Path == "" || input.AccessToken == "" { + return nil, errRequestInvalid + } + if err := validateChannelID(c.ChannelID); err != nil { + return nil, err + } + if !strings.HasPrefix(input.Path, "/") { + return nil, errRequestInvalid + } + if len(c.ClientSecret) == 0 || c.PartnerID == "" { + return nil, errRequestInvalid + } + if c.NewExternalID == nil { + return nil, errRequestInvalid + } + externalID, err := c.NewExternalID() + if err != nil || externalID == "" { + return nil, errRequestInvalid + } + timestamp := c.now().Format(time.RFC3339) + body := append([]byte(nil), input.Body...) + signature := SignTransaction( + c.ClientSecret, + input.Method, + input.Path, + input.AccessToken, + body, + timestamp, + ) + baseURL := sandboxAPIBaseURL + if input.UseApplicationHost { + baseURL = sandboxApplicationBaseURL + } + request, err := http.NewRequestWithContext( + ctx, + input.Method, + baseURL+input.Path, + bytes.NewReader(body), + ) + if err != nil { + return nil, errRequestInvalid + } + request.Header.Set("Content-Type", "application/json") + request.Header.Set("Authorization", "Bearer "+input.AccessToken) + request.Header.Set("X-TIMESTAMP", timestamp) + request.Header.Set("X-SIGNATURE", signature) + request.Header.Set("X-PARTNER-ID", c.PartnerID) + request.Header.Set("X-EXTERNAL-ID", externalID) + request.Header.Set("CHANNEL-ID", c.ChannelID) + if input.CustomerToken != "" { + request.Header.Set("Authorization-Customer", "Bearer "+input.CustomerToken) + } + return request, nil +} + +func (c Client) now() time.Time { + if c.Now != nil { + return c.Now() + } + return time.Now().UTC() +} + +func validateChannelID(value string) error { + if len(value) != 5 { + return errRequestInvalid + } + if _, err := strconv.Atoi(value); err != nil { + return errRequestInvalid + } + return nil +} + +func redactError(err error) error { + if err == nil { + return nil + } + return errors.New(errRequestInvalid.Error()) +} diff --git a/packs/bisnap/client_test.go b/packs/bisnap/client_test.go new file mode 100644 index 0000000..662cf9e --- /dev/null +++ b/packs/bisnap/client_test.go @@ -0,0 +1,136 @@ +package bisnap_test + +import ( + "context" + "io" + "testing" + "time" + + "github.com/veritrans/midtrans-cli/packs/bisnap" +) + +func TestClientNewAccessTokenRequestUsesSandboxAppHostAndExactHeaders(t *testing.T) { + client := bisnap.Client{ + ClientID: accessClientID, + PartnerID: "G123456", + ChannelID: "12345", + PrivateKeyPEM: fixtureBytes(t, "private_key_pkcs8.pem"), + ClientSecret: []byte(clientSecretCanary), + Now: fixedNow, + NewExternalID: func() (string, error) { return "ext-123", nil }, + } + + request, err := client.NewAccessTokenRequest(context.Background()) + if err != nil { + t.Fatal(err) + } + if request.Method != "POST" { + t.Fatalf("method = %q", request.Method) + } + if request.URL.String() != "https://merchants-app.sbx.midtrans.com/v1.0/access-token/b2b" { + t.Fatalf("url = %q", request.URL.String()) + } + if got := request.Header.Get("Content-Type"); got != "application/json" { + t.Fatalf("Content-Type = %q", got) + } + if got := request.Header.Get("X-CLIENT-KEY"); got != accessClientID { + t.Fatalf("X-CLIENT-KEY = %q", got) + } + if got := request.Header.Get("X-TIMESTAMP"); got != accessTimestamp { + t.Fatalf("X-TIMESTAMP = %q", got) + } + if got := request.Header.Get("X-SIGNATURE"); got != wantAccessTokenSignature { + t.Fatalf("X-SIGNATURE = %q", got) + } + body, err := io.ReadAll(request.Body) + if err != nil { + t.Fatal(err) + } + if string(body) != "{\"grantType\":\"client_credentials\"}" { + t.Fatalf("body = %q", body) + } +} + +func TestClientNewTransactionRequestUsesSandboxAPIHostAndConditionalHeaders(t *testing.T) { + client := bisnap.Client{ + ClientID: accessClientID, + PartnerID: "G123456", + ChannelID: "12345", + PrivateKeyPEM: fixtureBytes(t, "private_key_pkcs8.pem"), + ClientSecret: []byte(clientSecretCanary), + Now: fixedNow, + NewExternalID: func() (string, error) { return "550e8400-e29b-41d4-a716-446655440000", nil }, + } + + request, err := client.NewTransactionRequest(context.Background(), bisnap.Request{ + Method: "POST", + Path: transactionPath, + AccessToken: accessTokenCanary, + Body: []byte(transactionBody), + }) + if err != nil { + t.Fatal(err) + } + if request.URL.String() != "https://merchants.sbx.midtrans.com/v1.0/qr/qr-mpm-generate" { + t.Fatalf("url = %q", request.URL.String()) + } + if got := request.Header.Get("Authorization"); got != "Bearer "+accessTokenCanary { + t.Fatalf("Authorization = %q", got) + } + if got := request.Header.Get("X-SIGNATURE"); got != wantTransactionSignature { + t.Fatalf("X-SIGNATURE = %q", got) + } + if got := request.Header.Get("X-PARTNER-ID"); got != "G123456" { + t.Fatalf("X-PARTNER-ID = %q", got) + } + if got := request.Header.Get("X-EXTERNAL-ID"); got != "550e8400-e29b-41d4-a716-446655440000" { + t.Fatalf("X-EXTERNAL-ID = %q", got) + } + if got := request.Header.Get("CHANNEL-ID"); got != "12345" { + t.Fatalf("CHANNEL-ID = %q", got) + } + if got := request.Header.Get("Authorization-Customer"); got != "" { + t.Fatalf("Authorization-Customer = %q", got) + } + body, err := io.ReadAll(request.Body) + if err != nil { + t.Fatal(err) + } + if string(body) != transactionBody { + t.Fatalf("body = %q", body) + } +} + +func TestClientNewTransactionRequestAddsAuthorizationCustomer(t *testing.T) { + client := bisnap.Client{ + ClientID: accessClientID, + PartnerID: "G123456", + ChannelID: "12345", + PrivateKeyPEM: fixtureBytes(t, "private_key_pkcs8.pem"), + ClientSecret: []byte(clientSecretCanary), + Now: fixedNow, + NewExternalID: func() (string, error) { return "ext-456", nil }, + } + + request, err := client.NewTransactionRequest(context.Background(), bisnap.Request{ + Method: "POST", + Path: "/v1.0/registration-account-inquiry", + AccessToken: accessTokenCanary, + CustomerToken: "CUSTOMER-TOKEN-CANARY-DO-NOT-PRINT", + Body: []byte("{}"), + UseApplicationHost: true, + }) + if err != nil { + t.Fatal(err) + } + if request.URL.String() != "https://merchants-app.sbx.midtrans.com/v1.0/registration-account-inquiry" { + t.Fatalf("url = %q", request.URL.String()) + } + if got := request.Header.Get("Authorization-Customer"); got != "Bearer CUSTOMER-TOKEN-CANARY-DO-NOT-PRINT" { + t.Fatalf("Authorization-Customer = %q", got) + } +} + +func fixedNow() time.Time { + return time.Date(2026, 7, 27, 8, 9, 10, 0, time.FixedZone("WIB", 7*60*60)) +} diff --git a/packs/bisnap/endpoints.go b/packs/bisnap/endpoints.go new file mode 100644 index 0000000..a2072da --- /dev/null +++ b/packs/bisnap/endpoints.go @@ -0,0 +1,13 @@ +package bisnap + +const ( + sandboxAPIBaseURL = "https://merchants.sbx.midtrans.com" + sandboxApplicationBaseURL = "https://merchants-app.sbx.midtrans.com" + + accessTokenPath = "/v1.0/access-token/b2b" + qrisGeneratePath = "/v1.0/qr/qr-mpm-generate" + virtualAccountCreatePath = "/v1.0/transfer-va/create-va" + debitPaymentHostToHostPath = "/v1.0/debit/payment-host-to-host" + debitStatusPath = "/v1.0/debit/status" + debitRefundPath = "/v1.0/debit/refund" +) diff --git a/packs/bisnap/notification.go b/packs/bisnap/notification.go new file mode 100644 index 0000000..5eb50df --- /dev/null +++ b/packs/bisnap/notification.go @@ -0,0 +1,63 @@ +package bisnap + +import "net/http" + +type NotificationRoute struct { + Path string + Aliases []string + SuccessCode string + FailureCode string +} + +var notificationRoutes = []NotificationRoute{ + { + Path: "/v1.0/qr/qr-mpm-notify", + SuccessCode: "2005200", + FailureCode: "4015200", + }, + { + Path: "/v1.0/va/notify", + Aliases: []string{"/v1.0/transfer-va/payment"}, + SuccessCode: "2002500", + FailureCode: "4012500", + }, + { + Path: "/v1.0/debit/notify", + SuccessCode: "2005600", + FailureCode: "4015600", + }, + { + Path: "/v1.0/registration-account/notify", + }, +} + +func NotificationRouteForPath(path string) (NotificationRoute, bool) { + for _, route := range notificationRoutes { + if route.Path == path { + return route, true + } + for _, alias := range route.Aliases { + if alias == path { + return route, true + } + } + } + return NotificationRoute{}, false +} + +func VerifyNotificationCallback( + publicKeyPEM []byte, + path string, + body []byte, + timestamp string, + signature string, +) (NotificationRoute, error) { + route, ok := NotificationRouteForPath(path) + if !ok { + return NotificationRoute{}, errWebhookRoute + } + if err := VerifyNotification(publicKeyPEM, http.MethodPost, path, body, timestamp, signature); err != nil { + return NotificationRoute{}, err + } + return route, nil +} diff --git a/packs/bisnap/notification_test.go b/packs/bisnap/notification_test.go new file mode 100644 index 0000000..a622e04 --- /dev/null +++ b/packs/bisnap/notification_test.go @@ -0,0 +1,122 @@ +package bisnap_test + +import ( + "crypto" + "crypto/rand" + "crypto/rsa" + "crypto/sha256" + "crypto/x509" + "encoding/base64" + "encoding/pem" + "strings" + "testing" + + "github.com/veritrans/midtrans-cli/packs/bisnap" +) + +func TestNotificationRouteForPathReturnsCodesAndAlias(t *testing.T) { + route, ok := bisnap.NotificationRouteForPath("/v1.0/va/notify") + if !ok { + t.Fatal("route not found") + } + if route.SuccessCode != "2002500" || route.FailureCode != "4012500" { + t.Fatalf("route = %#v", route) + } + + alias, ok := bisnap.NotificationRouteForPath("/v1.0/transfer-va/payment") + if !ok { + t.Fatal("alias route not found") + } + if alias.Path != "/v1.0/va/notify" { + t.Fatalf("alias path = %q", alias.Path) + } +} + +func TestVerifyNotificationCallbackUsesLiteralAliasPath(t *testing.T) { + privateKey := mustFixturePrivateKey(t) + body := []byte(transactionBody) + timestamp := accessTimestamp + signature := signNotificationForTest( + t, + privateKey, + "POST", + "/v1.0/transfer-va/payment", + body, + timestamp, + ) + + route, err := bisnap.VerifyNotificationCallback( + fixtureBytes(t, "public_key_pkix.pem"), + "/v1.0/transfer-va/payment", + body, + timestamp, + signature, + ) + if err != nil { + t.Fatal(err) + } + if route.Path != "/v1.0/va/notify" { + t.Fatalf("route = %#v", route) + } +} + +func TestVerifyNotificationCallbackRejectsUnknownRoute(t *testing.T) { + _, err := bisnap.VerifyNotificationCallback( + fixtureBytes(t, "public_key_pkix.pem"), + "/v1.0/not-a-real-path", + []byte("{}"), + accessTimestamp, + wantNotificationSignature, + ) + if err == nil || !strings.Contains(err.Error(), "WEBHOOK_ROUTE_INVALID") { + t.Fatalf("err = %v", err) + } +} + +func signNotificationForTest( + t *testing.T, + privateKey *rsa.PrivateKey, + method string, + path string, + body []byte, + timestamp string, +) string { + t.Helper() + bodyHash := sha256.Sum256(body) + message := method + ":" + path + ":" + strings.ToLower( + hexString(bodyHash[:]), + ) + ":" + timestamp + digest := sha256.Sum256([]byte(message)) + signature, err := rsa.SignPKCS1v15(rand.Reader, privateKey, crypto.SHA256, digest[:]) + if err != nil { + t.Fatal(err) + } + return base64.StdEncoding.EncodeToString(signature) +} + +func mustFixturePrivateKey(t *testing.T) *rsa.PrivateKey { + t.Helper() + block, _ := pem.Decode(fixtureBytes(t, "private_key_pkcs8.pem")) + if block == nil { + t.Fatal("private key PEM missing") + } + key, err := x509.ParsePKCS8PrivateKey(block.Bytes) + if err != nil { + t.Fatal(err) + } + privateKey, ok := key.(*rsa.PrivateKey) + if !ok { + t.Fatal("private key is not RSA") + } + return privateKey +} + +func hexString(data []byte) string { + const hexdigits = "0123456789abcdef" + buf := make([]byte, len(data)*2) + for i, value := range data { + buf[i*2] = hexdigits[value>>4] + buf[i*2+1] = hexdigits[value&0x0f] + } + return string(buf) +} diff --git a/packs/bisnap/signature.go b/packs/bisnap/signature.go new file mode 100644 index 0000000..944e01f --- /dev/null +++ b/packs/bisnap/signature.go @@ -0,0 +1,130 @@ +package bisnap + +import ( + "crypto" + "crypto/hmac" + "crypto/rsa" + "crypto/sha256" + "crypto/sha512" + "crypto/x509" + "encoding/base64" + "encoding/hex" + "encoding/pem" + "errors" + "fmt" + "strings" +) + +var ( + errKeyInvalid = errors.New("BISNAP_KEY_INVALID") + errRequestInvalid = errors.New("SANDBOX_REQUEST_INVALID") + errWebhookInvalid = errors.New("WEBHOOK_SIGNATURE_INVALID") + errWebhookRoute = errors.New("WEBHOOK_ROUTE_INVALID") +) + +func SignAccessToken(privateKeyPEM []byte, clientID, timestamp string) (string, error) { + if clientID == "" || timestamp == "" { + return "", errRequestInvalid + } + privateKey, err := parsePrivateKey(privateKeyPEM) + if err != nil { + return "", err + } + payload := []byte(clientID + "|" + timestamp) + digest := sha256.Sum256(payload) + signature, err := rsa.SignPKCS1v15(nil, privateKey, crypto.SHA256, digest[:]) + if err != nil { + return "", errKeyInvalid + } + return base64.StdEncoding.EncodeToString(signature), nil +} + +func SignTransaction(clientSecret []byte, method, path, accessToken string, body []byte, timestamp string) string { + bodyHash := sha256.Sum256(body) + payload := method + ":" + path + ":" + accessToken + ":" + hex.EncodeToString(bodyHash[:]) + ":" + timestamp + mac := hmac.New(sha512.New, clientSecret) + _, _ = mac.Write([]byte(payload)) + return hex.EncodeToString(mac.Sum(nil)) +} + +func VerifyNotification(publicKeyPEM []byte, method, path string, body []byte, timestamp, signature string) error { + if method == "" || path == "" || timestamp == "" || signature == "" { + return errWebhookInvalid + } + publicKey, err := parsePublicKey(publicKeyPEM) + if err != nil { + return err + } + signatureBytes, err := base64.StdEncoding.DecodeString(signature) + if err != nil { + return errWebhookInvalid + } + bodyHash := sha256.Sum256(body) + payload := []byte(method + ":" + path + ":" + hex.EncodeToString(bodyHash[:]) + ":" + timestamp) + digest := sha256.Sum256(payload) + if err := rsa.VerifyPKCS1v15(publicKey, crypto.SHA256, digest[:], signatureBytes); err != nil { + return errWebhookInvalid + } + return nil +} + +func PadPartnerServiceID(value string) (string, error) { + if value == "" || len(value) > 8 { + return "", errRequestInvalid + } + return fmt.Sprintf("%8s", value), nil +} + +func parsePrivateKey(privateKeyPEM []byte) (*rsa.PrivateKey, error) { + block, rest := pem.Decode(privateKeyPEM) + if block == nil || strings.TrimSpace(string(rest)) != "" { + return nil, errKeyInvalid + } + switch block.Type { + case "RSA PRIVATE KEY": + privateKey, err := x509.ParsePKCS1PrivateKey(block.Bytes) + if err != nil { + return nil, errKeyInvalid + } + return privateKey, nil + case "PRIVATE KEY": + key, err := x509.ParsePKCS8PrivateKey(block.Bytes) + if err != nil { + return nil, errKeyInvalid + } + privateKey, ok := key.(*rsa.PrivateKey) + if !ok { + return nil, errKeyInvalid + } + return privateKey, nil + default: + return nil, errKeyInvalid + } +} + +func parsePublicKey(publicKeyPEM []byte) (*rsa.PublicKey, error) { + block, rest := pem.Decode(publicKeyPEM) + if block == nil || strings.TrimSpace(string(rest)) != "" { + return nil, errKeyInvalid + } + switch block.Type { + case "PUBLIC KEY": + key, err := x509.ParsePKIXPublicKey(block.Bytes) + if err != nil { + return nil, errKeyInvalid + } + publicKey, ok := key.(*rsa.PublicKey) + if !ok { + return nil, errKeyInvalid + } + return publicKey, nil + case "RSA PUBLIC KEY": + publicKey, err := x509.ParsePKCS1PublicKey(block.Bytes) + if err != nil { + return nil, errKeyInvalid + } + return publicKey, nil + default: + return nil, errKeyInvalid + } +} diff --git a/packs/bisnap/signature_test.go b/packs/bisnap/signature_test.go new file mode 100644 index 0000000..ffd9f58 --- /dev/null +++ b/packs/bisnap/signature_test.go @@ -0,0 +1,153 @@ +package bisnap_test + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/veritrans/midtrans-cli/packs/bisnap" +) + +const ( + accessTimestamp = "2026-07-27T08:09:10+07:00" + accessClientID = "midtrans-client-123" + transactionPath = "/v1.0/qr/qr-mpm-generate" + transactionMethod = "POST" + transactionBody = "{\"foo\":\"bar\",\"amount\":12500}" + accessTokenCanary = "ACCESS-TOKEN-CANARY-DO-NOT-PRINT" + clientSecretCanary = "CLIENT-SECRET-CANARY-DO-NOT-PRINT" + + wantAccessTokenSignature = "X8ozgLWxhdK8nP4YnkNcJHhXObRBeExU68M+JYyAHVaGN8Qcq2T5DeIYchvXmfNz88PWEWWZCusR5tH2jwkIexTn01UOpReEW4oJfHMDv8attospztZhq3HOjg6xGPcmN4+vaeJcRW5FkXe0tPIXg2UdC5Xzuh/Qx8NPLt6g2mH3UbR6jVhziE5oU8TpR3EIYZhmBm5CgFneCF+e9GR1xb48W7/4LgbdjAXl9XP/ViPLK8XBBYNbxPA1aV23KFgkwwVa3hFQBkbRExRQlwi2ykDV683LsLRhci9hS2ujrvyBrINGUW5gft9FdSNJTRMbpiNBL5rBIjRRz8RnkS09ng==" + wantTransactionSignature = "959251441e404f2826aa0b5c7afac01d6957de90061b9bf066b150cd6fdc41f05fe6d6c0058ab19112e1175c33c1b72382fe3a57bdd6825beea1e3d76d7301a5" + wantNotificationSignature = "GCNDcBbxZUpiXVlu6pWhdaAMgSyllXX5EbTRZ1BHsPZTe8l2VO+Tgqz4iuysLVwzYfgoKEhgETFscMRJ8mC7kkYMryVVCDo3BLOu+teZt5YaocMLG0dR6Wj4apgcYFIeDX8bbWi4tgjmTqS5SagLYPLoErnmBj19Dq5lWHExjHuHklJpjV7BqYArB6ao2wCzE0Oy1Wc5NXqaBxvNNpUdUy/qaV6vSvIAyagfgyELmD1hx870ow0fURiRGNn562B9FQoNkMSQeI8b32jGJNVjDT84GcfFsbx9+CnjSGL64N6B4batqfia062G6F1iKpWHh+Zv73q/XI8XugA97iKrug==" +) + +func TestSignAccessTokenMatchesFixedVector(t *testing.T) { + got, err := bisnap.SignAccessToken( + fixtureBytes(t, "private_key_pkcs8.pem"), + accessClientID, + accessTimestamp, + ) + if err != nil { + t.Fatal(err) + } + if got != wantAccessTokenSignature { + t.Fatalf("signature = %q", got) + } +} + +func TestSignTransactionUsesExactSerializedBody(t *testing.T) { + got := bisnap.SignTransaction( + []byte(clientSecretCanary), + transactionMethod, + transactionPath, + accessTokenCanary, + []byte(transactionBody), + accessTimestamp, + ) + if got != wantTransactionSignature { + t.Fatalf("signature = %q", got) + } + + changedWhitespace := bisnap.SignTransaction( + []byte(clientSecretCanary), + transactionMethod, + transactionPath, + accessTokenCanary, + []byte("{\"foo\":\"bar\", \"amount\":12500}"), + accessTimestamp, + ) + if changedWhitespace == got { + t.Fatal("transaction signature normalized request body bytes") + } +} + +func TestVerifyNotificationMatchesFixedVectorAndLiteralPath(t *testing.T) { + err := bisnap.VerifyNotification( + fixtureBytes(t, "public_key_pkix.pem"), + transactionMethod, + "/v1.0/qr/qr-mpm-notify", + []byte(transactionBody), + accessTimestamp, + wantNotificationSignature, + ) + if err != nil { + t.Fatal(err) + } + + err = bisnap.VerifyNotification( + fixtureBytes(t, "public_key_pkix.pem"), + transactionMethod, + "/v1.0/transfer-va/payment", + []byte(transactionBody), + accessTimestamp, + wantNotificationSignature, + ) + if err == nil || !strings.Contains(err.Error(), "WEBHOOK_SIGNATURE_INVALID") { + t.Fatalf("err = %v", err) + } +} + +func TestSignatureFamiliesRemainDistinct(t *testing.T) { + accessTokenSignature, err := bisnap.SignAccessToken( + fixtureBytes(t, "private_key_pkcs8.pem"), + accessClientID, + accessTimestamp, + ) + if err != nil { + t.Fatal(err) + } + if accessTokenSignature == wantTransactionSignature || + accessTokenSignature == wantNotificationSignature || + wantTransactionSignature == wantNotificationSignature { + t.Fatal("signature families were interchangeable") + } + + err = bisnap.VerifyNotification( + fixtureBytes(t, "public_key_pkix.pem"), + transactionMethod, + "/v1.0/qr/qr-mpm-notify", + []byte(transactionBody), + accessTimestamp, + accessTokenSignature, + ) + if err == nil || !strings.Contains(err.Error(), "WEBHOOK_SIGNATURE_INVALID") { + t.Fatalf("err = %v", err) + } +} + +func TestSignAccessTokenRejectsUnsupportedPEMType(t *testing.T) { + _, err := bisnap.SignAccessToken( + []byte("-----BEGIN CERTIFICATE-----\nZm9v\n-----END CERTIFICATE-----\n"), + accessClientID, + accessTimestamp, + ) + if err == nil || !strings.Contains(err.Error(), "BISNAP_KEY_INVALID") { + t.Fatalf("err = %v", err) + } +} + +func TestPadPartnerServiceIDLeftPadsToEightCharacters(t *testing.T) { + padded, err := bisnap.PadPartnerServiceID("123") + if err != nil { + t.Fatal(err) + } + if padded != " 123" { + t.Fatalf("padded = %q", padded) + } + + _, err = bisnap.PadPartnerServiceID("123456789") + if err == nil || !strings.Contains(err.Error(), "SANDBOX_REQUEST_INVALID") { + t.Fatalf("err = %v", err) + } +} + +func fixtureBytes(t *testing.T, name string) []byte { + t.Helper() + data, err := os.ReadFile(filepath.Join("..", "..", "testdata", "bisnap", name)) + if err != nil { + t.Fatal(err) + } + return data +} diff --git a/testdata/bisnap/private_key_pkcs8.pem b/testdata/bisnap/private_key_pkcs8.pem new file mode 100644 index 0000000..a7e1418 --- /dev/null +++ b/testdata/bisnap/private_key_pkcs8.pem @@ -0,0 +1,28 @@ +-----BEGIN PRIVATE KEY----- +MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQCoWj1psFeSn+YG +Jp42OrCkIArR0ZfecGJQ3AFNpsUXYKrLq0hg5aPckDPTsREw+KQ0legWnxualJV3 +4BjwNrOA4tl8tSqNUVx2dHSE/DWBagDB/uoM26FDM3eObwHqcL/BmxBbk2TrOAsJ +ahFYPlQyth/reWbpyiAOtOZAcany5NVa9JVJvd84ZETfLjzGdNBx9b3zH/k1MeHr +1FDsfqlM/Uhzt2VsIB1zkwskYvtz8uW6yGWKIPUbyez3rKU0GCSXFZMlL/s4E9tg +uqdvsCwaBfN0gTgosdJR8S226RrIN0iw6pANJ0APrPagnkhgYQIwCk3FY7xDcoX7 +TD6mAiHBAgMBAAECggEAKYW4R+z6bGuLrFCyDOYE0zYj9QQg1PgbB557o89SJSXu +ejVQsLVy6N+YKMovV0i8F0wx5gJwKHwlMV/QRs73Dv7kbWGxkRFUINMwOeyKtdST +Q0XALFOvPoffIP44Fr6gTPwV2MBNz8YO0s+aX41f7vIEhWt3+omjxnzPnW1rYUCw +I19rQjQ2HFx4dspvXiagEP4H/shUovUlNBE7AOp4ergIUNFmE5Pm4sjGLRdhG5AA +Y4t9nnIkF1GeCVyU2orjNnSzivXVhCiT7XugrG9wV9BRwOSqMmZ2MaDYw/OPCGUv +lal06zjRgwWiPKjNdxgcC2ZD3vRq3tWWqAxfTS+DkQKBgQDsyl+CuXBPwAf8rATw +QcA68s2gssUdcsgtIzgqXehj6dnX750cXPueh1fbXNu5RWUja5UHQTRpCC6FZ6sp +IZhEGYKwF/BPt4Rkr0/PAJPVW1GKyBTAHKfoZezUk5OrGvkrBEtmTSxwMGKobS+T +5kJZMu7GmjdO9uaCH5OIRJCmBwKBgQC2Ao6h5ogXaV5sQhDwJNMFQNmLNxm+P9Gp +fO5EjRtJCvI7OYMFgwr9ZDkEezrCa5swrGQxdxxF/NZMl2BZTn8m+oCscuea320w +h/46b74PjPbGij1Jf8UbBGwUIvLQuwyz5Z7uogz7ZYs4TwFT/uC3gPeOb0VbswqA +sk468upH9wKBgGEwMwGBwVJKXDzeEezW3+KSE8oYdgU/PmAga4YgIykXK52QsKia +lYAcxve3Zkvl6rweBP4ESlGt1QJWaY8pRcj1u4kxWiYuCb4Voqkw8HyKza4rrzCm +0lf1tb4OkHaTTJ/WVGg51rp5cZT1s6h9ws+/svd7BqZ3emcANNqiYchzAoGBAI6S +2Dn4hLiaII36/puurOJbl02XPwIpqcnzhA2M30fLXE6KUZkaupwdSxC4myG6+xkY +oW+iFzK3yQK8PYwXkkDtT6hGZiiKRlUS4lHSQHab/J8voKyXesYcI7FuYvig3WV6 +RJI3vKWdOH5GXQr4B/2W99TKUvFvAZYolmFU8bKnAoGBAJeO8l9TMr5TWWdOK1V5 +QrihGFr5E7Kic/ZUO7Bkis09sM7zA9dHHdbG3f3eFlHICg3TyfCTU+zVa8UjZkYQ +JsDtiY2AM7fySiDK+oxxF1U23pM4xsPCU3YTuNIOKvANlNAuIERKWdESUoKrCHzX +Af6h3uc3P69dRWkzanV1U1cw +-----END PRIVATE KEY----- diff --git a/testdata/bisnap/public_key_pkix.pem b/testdata/bisnap/public_key_pkix.pem new file mode 100644 index 0000000..cbb2801 --- /dev/null +++ b/testdata/bisnap/public_key_pkix.pem @@ -0,0 +1,9 @@ +-----BEGIN PUBLIC KEY----- +MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAqFo9abBXkp/mBiaeNjqw +pCAK0dGX3nBiUNwBTabFF2Cqy6tIYOWj3JAz07ERMPikNJXoFp8bmpSVd+AY8Daz +gOLZfLUqjVFcdnR0hPw1gWoAwf7qDNuhQzN3jm8B6nC/wZsQW5Nk6zgLCWoRWD5U +MrYf63lm6cogDrTmQHGp8uTVWvSVSb3fOGRE3y48xnTQcfW98x/5NTHh69RQ7H6p +TP1Ic7dlbCAdc5MLJGL7c/LlushliiD1G8ns96ylNBgklxWTJS/7OBPbYLqnb7As +GgXzdIE4KLHSUfEttukayDdIsOqQDSdAD6z2oJ5IYGECMApNxWO8Q3KF+0w+pgIh +wQIDAQAB +-----END PUBLIC KEY----- From 2ba36730d1fdf3bfbe714a935e0f1e413a0e30ce Mon Sep 17 00:00:00 2001 From: "m.salis" Date: Mon, 27 Jul 2026 11:27:53 +0700 Subject: [PATCH 46/73] fix: align BI-SNAP signing and headers --- .../task-8-report.md | 16 +++++ packs/bisnap/client.go | 25 ++++---- packs/bisnap/client_test.go | 60 +++++++++++++++++++ packs/bisnap/notification.go | 4 +- packs/bisnap/notification_test.go | 8 +++ packs/bisnap/signature.go | 2 +- packs/bisnap/signature_test.go | 3 +- 7 files changed, 104 insertions(+), 14 deletions(-) diff --git a/.superpowers/sdd/2026-07-26-midtrans-cli-multi-product-parity/task-8-report.md b/.superpowers/sdd/2026-07-26-midtrans-cli-multi-product-parity/task-8-report.md index 0aa20c6..8f4aa0a 100644 --- a/.superpowers/sdd/2026-07-26-midtrans-cli-multi-product-parity/task-8-report.md +++ b/.superpowers/sdd/2026-07-26-midtrans-cli-multi-product-parity/task-8-report.md @@ -33,6 +33,22 @@ go test ./packs/bisnap -count=1 go test ./... -count=1 ``` +## Fix Round 1 + +- Updated transactional `X-SIGNATURE` generation to match the current official Midtrans public spec: Base64-encoded raw `HMAC_SHA512`, not lowercase hex. +- Added explicit BI-SNAP device surface for transactional requests via `Client.DeviceID` and optional `Request.DeviceID` override, and now set mandatory `X-DEVICE-ID`. +- Tightened `CHANNEL-ID` validation to exactly 5 ASCII digits. +- Added service-88 response codes for `/v1.0/registration-account/notify`: + - success `2008800` + - unauthorized `4018800` + +Validated again with: + +```sh +go test ./packs/bisnap -count=1 +go test ./... -count=1 +``` + ## Concerns - The current manifest model does not carry a BI-SNAP `client_secret`, so Task 8 keeps the transactional signer and signed-request builder generic and local to the pack without widening manifest validation in this task. diff --git a/packs/bisnap/client.go b/packs/bisnap/client.go index 3133d35..cb4762c 100644 --- a/packs/bisnap/client.go +++ b/packs/bisnap/client.go @@ -3,9 +3,7 @@ package bisnap import ( "bytes" "context" - "errors" "net/http" - "strconv" "strings" "time" ) @@ -15,6 +13,7 @@ type Request struct { Path string AccessToken string CustomerToken string + DeviceID string Body []byte UseApplicationHost bool } @@ -23,6 +22,7 @@ type Client struct { ClientID string PartnerID string ChannelID string + DeviceID string PrivateKeyPEM []byte ClientSecret []byte Now func() time.Time @@ -65,6 +65,13 @@ func (c Client) NewTransactionRequest(ctx context.Context, input Request) (*http if len(c.ClientSecret) == 0 || c.PartnerID == "" { return nil, errRequestInvalid } + deviceID := strings.TrimSpace(input.DeviceID) + if deviceID == "" { + deviceID = strings.TrimSpace(c.DeviceID) + } + if deviceID == "" { + return nil, errRequestInvalid + } if c.NewExternalID == nil { return nil, errRequestInvalid } @@ -102,6 +109,7 @@ func (c Client) NewTransactionRequest(ctx context.Context, input Request) (*http request.Header.Set("X-PARTNER-ID", c.PartnerID) request.Header.Set("X-EXTERNAL-ID", externalID) request.Header.Set("CHANNEL-ID", c.ChannelID) + request.Header.Set("X-DEVICE-ID", deviceID) if input.CustomerToken != "" { request.Header.Set("Authorization-Customer", "Bearer "+input.CustomerToken) } @@ -119,15 +127,10 @@ func validateChannelID(value string) error { if len(value) != 5 { return errRequestInvalid } - if _, err := strconv.Atoi(value); err != nil { - return errRequestInvalid + for _, r := range value { + if r < '0' || r > '9' { + return errRequestInvalid + } } return nil } - -func redactError(err error) error { - if err == nil { - return nil - } - return errors.New(errRequestInvalid.Error()) -} diff --git a/packs/bisnap/client_test.go b/packs/bisnap/client_test.go index 662cf9e..03e0924 100644 --- a/packs/bisnap/client_test.go +++ b/packs/bisnap/client_test.go @@ -14,6 +14,7 @@ func TestClientNewAccessTokenRequestUsesSandboxAppHostAndExactHeaders(t *testing ClientID: accessClientID, PartnerID: "G123456", ChannelID: "12345", + DeviceID: deviceIDCanary, PrivateKeyPEM: fixtureBytes(t, "private_key_pkcs8.pem"), ClientSecret: []byte(clientSecretCanary), Now: fixedNow, @@ -56,6 +57,7 @@ func TestClientNewTransactionRequestUsesSandboxAPIHostAndConditionalHeaders(t *t ClientID: accessClientID, PartnerID: "G123456", ChannelID: "12345", + DeviceID: deviceIDCanary, PrivateKeyPEM: fixtureBytes(t, "private_key_pkcs8.pem"), ClientSecret: []byte(clientSecretCanary), Now: fixedNow, @@ -89,6 +91,9 @@ func TestClientNewTransactionRequestUsesSandboxAPIHostAndConditionalHeaders(t *t if got := request.Header.Get("CHANNEL-ID"); got != "12345" { t.Fatalf("CHANNEL-ID = %q", got) } + if got := request.Header.Get("X-DEVICE-ID"); got != deviceIDCanary { + t.Fatalf("X-DEVICE-ID = %q", got) + } if got := request.Header.Get("Authorization-Customer"); got != "" { t.Fatalf("Authorization-Customer = %q", got) } @@ -106,6 +111,7 @@ func TestClientNewTransactionRequestAddsAuthorizationCustomer(t *testing.T) { ClientID: accessClientID, PartnerID: "G123456", ChannelID: "12345", + DeviceID: deviceIDCanary, PrivateKeyPEM: fixtureBytes(t, "private_key_pkcs8.pem"), ClientSecret: []byte(clientSecretCanary), Now: fixedNow, @@ -129,6 +135,60 @@ func TestClientNewTransactionRequestAddsAuthorizationCustomer(t *testing.T) { if got := request.Header.Get("Authorization-Customer"); got != "Bearer CUSTOMER-TOKEN-CANARY-DO-NOT-PRINT" { t.Fatalf("Authorization-Customer = %q", got) } + if got := request.Header.Get("X-DEVICE-ID"); got != deviceIDCanary { + t.Fatalf("X-DEVICE-ID = %q", got) + } +} + +func TestClientNewTransactionRequestRejectsUnsafeChannelIDs(t *testing.T) { + tests := []string{"+1234", "-1234", "12345", "12a45"} + for _, channelID := range tests { + client := bisnap.Client{ + ClientID: accessClientID, + PartnerID: "G123456", + ChannelID: channelID, + DeviceID: deviceIDCanary, + PrivateKeyPEM: fixtureBytes(t, "private_key_pkcs8.pem"), + ClientSecret: []byte(clientSecretCanary), + Now: fixedNow, + NewExternalID: func() (string, error) { return "ext-invalid", nil }, + } + + _, err := client.NewTransactionRequest(context.Background(), bisnap.Request{ + Method: "POST", + Path: transactionPath, + AccessToken: accessTokenCanary, + Body: []byte(transactionBody), + }) + if err == nil { + t.Fatalf("channel ID %q was accepted", channelID) + } + } +} + +func TestClientNewTransactionRequestRequiresDeviceID(t *testing.T) { + client := bisnap.Client{ + ClientID: accessClientID, + PartnerID: "G123456", + ChannelID: "12345", + PrivateKeyPEM: fixtureBytes(t, "private_key_pkcs8.pem"), + ClientSecret: []byte(clientSecretCanary), + Now: fixedNow, + NewExternalID: func() (string, error) { return "ext-missing-device", nil }, + } + + _, err := client.NewTransactionRequest(context.Background(), bisnap.Request{ + Method: "POST", + Path: transactionPath, + AccessToken: accessTokenCanary, + Body: []byte(transactionBody), + }) + if err == nil { + t.Fatal("missing device ID was accepted") + } + if got := err.Error(); got != "SANDBOX_REQUEST_INVALID" { + t.Fatalf("err = %q", got) + } } func fixedNow() time.Time { diff --git a/packs/bisnap/notification.go b/packs/bisnap/notification.go index 5eb50df..3fdfdce 100644 --- a/packs/bisnap/notification.go +++ b/packs/bisnap/notification.go @@ -27,7 +27,9 @@ var notificationRoutes = []NotificationRoute{ FailureCode: "4015600", }, { - Path: "/v1.0/registration-account/notify", + Path: "/v1.0/registration-account/notify", + SuccessCode: "2008800", + FailureCode: "4018800", }, } diff --git a/packs/bisnap/notification_test.go b/packs/bisnap/notification_test.go index a622e04..97e4b47 100644 --- a/packs/bisnap/notification_test.go +++ b/packs/bisnap/notification_test.go @@ -30,6 +30,14 @@ func TestNotificationRouteForPathReturnsCodesAndAlias(t *testing.T) { if alias.Path != "/v1.0/va/notify" { t.Fatalf("alias path = %q", alias.Path) } + + accountLink, ok := bisnap.NotificationRouteForPath("/v1.0/registration-account/notify") + if !ok { + t.Fatal("account-link route not found") + } + if accountLink.SuccessCode != "2008800" || accountLink.FailureCode != "4018800" { + t.Fatalf("account-link route = %#v", accountLink) + } } func TestVerifyNotificationCallbackUsesLiteralAliasPath(t *testing.T) { diff --git a/packs/bisnap/signature.go b/packs/bisnap/signature.go index 944e01f..e9153c0 100644 --- a/packs/bisnap/signature.go +++ b/packs/bisnap/signature.go @@ -44,7 +44,7 @@ func SignTransaction(clientSecret []byte, method, path, accessToken string, body payload := method + ":" + path + ":" + accessToken + ":" + hex.EncodeToString(bodyHash[:]) + ":" + timestamp mac := hmac.New(sha512.New, clientSecret) _, _ = mac.Write([]byte(payload)) - return hex.EncodeToString(mac.Sum(nil)) + return base64.StdEncoding.EncodeToString(mac.Sum(nil)) } func VerifyNotification(publicKeyPEM []byte, method, path string, body []byte, timestamp, signature string) error { diff --git a/packs/bisnap/signature_test.go b/packs/bisnap/signature_test.go index ffd9f58..c509f97 100644 --- a/packs/bisnap/signature_test.go +++ b/packs/bisnap/signature_test.go @@ -17,9 +17,10 @@ const ( transactionBody = "{\"foo\":\"bar\",\"amount\":12500}" accessTokenCanary = "ACCESS-TOKEN-CANARY-DO-NOT-PRINT" clientSecretCanary = "CLIENT-SECRET-CANARY-DO-NOT-PRINT" + deviceIDCanary = "Mozilla/5.0 (X11; Linux x86_64) DEVICE-CANARY-DO-NOT-PRINT" wantAccessTokenSignature = "X8ozgLWxhdK8nP4YnkNcJHhXObRBeExU68M+JYyAHVaGN8Qcq2T5DeIYchvXmfNz88PWEWWZCusR5tH2jwkIexTn01UOpReEW4oJfHMDv8attospztZhq3HOjg6xGPcmN4+vaeJcRW5FkXe0tPIXg2UdC5Xzuh/Qx8NPLt6g2mH3UbR6jVhziE5oU8TpR3EIYZhmBm5CgFneCF+e9GR1xb48W7/4LgbdjAXl9XP/ViPLK8XBBYNbxPA1aV23KFgkwwVa3hFQBkbRExRQlwi2ykDV683LsLRhci9hS2ujrvyBrINGUW5gft9FdSNJTRMbpiNBL5rBIjRRz8RnkS09ng==" - wantTransactionSignature = "959251441e404f2826aa0b5c7afac01d6957de90061b9bf066b150cd6fdc41f05fe6d6c0058ab19112e1175c33c1b72382fe3a57bdd6825beea1e3d76d7301a5" + wantTransactionSignature = "lZJRRB5ATygmqgtcevrAHWlX3pAGG5vwZrFQzW/cQfBf5tbABYqxkRLhF1wzwbcjgv46V73WglvuoePXbXMBpQ==" wantNotificationSignature = "GCNDcBbxZUpiXVlu6pWhdaAMgSyllXX5EbTRZ1BHsPZTe8l2VO+Tgqz4iuysLVwzYfgoKEhgETFscMRJ8mC7kkYMryVVCDo3BLOu+teZt5YaocMLG0dR6Wj4apgcYFIeDX8bbWi4tgjmTqS5SagLYPLoErnmBj19Dq5lWHExjHuHklJpjV7BqYArB6ao2wCzE0Oy1Wc5NXqaBxvNNpUdUy/qaV6vSvIAyagfgyELmD1hx870ow0fURiRGNn562B9FQoNkMSQeI8b32jGJNVjDT84GcfFsbx9+CnjSGL64N6B4batqfia062G6F1iKpWHh+Zv73q/XI8XugA97iKrug==" ) From 9d58117bd97cd2e0881f9fb8aac40039bc1d3390 Mon Sep 17 00:00:00 2001 From: "m.salis" Date: Mon, 27 Jul 2026 11:47:02 +0700 Subject: [PATCH 47/73] feat: add BI-SNAP payment journeys --- .../task-9-report.md | 58 +++ cmd/midtrans/main.go | 3 +- contracts/capabilities-v1.json | 18 + internal/app/app_test.go | 26 +- internal/manifest/model.go | 1 + internal/manifest/validate.go | 5 +- packs/bisnap/client.go | 320 ++++++++++++++ packs/bisnap/direct_debit.go | 20 + packs/bisnap/endpoints.go | 2 + packs/bisnap/journey.go | 389 ++++++++++++++++++ packs/bisnap/journey_test.go | 313 ++++++++++++++ packs/bisnap/pack.go | 81 ++++ packs/bisnap/pack_test.go | 45 ++ packs/bisnap/qris.go | 13 + packs/bisnap/virtual_account.go | 15 + schemas/manifest-v1.schema.json | 6 + 16 files changed, 1301 insertions(+), 14 deletions(-) create mode 100644 .superpowers/sdd/2026-07-26-midtrans-cli-multi-product-parity/task-9-report.md create mode 100644 packs/bisnap/direct_debit.go create mode 100644 packs/bisnap/journey.go create mode 100644 packs/bisnap/journey_test.go create mode 100644 packs/bisnap/pack.go create mode 100644 packs/bisnap/pack_test.go create mode 100644 packs/bisnap/qris.go create mode 100644 packs/bisnap/virtual_account.go diff --git a/.superpowers/sdd/2026-07-26-midtrans-cli-multi-product-parity/task-9-report.md b/.superpowers/sdd/2026-07-26-midtrans-cli-multi-product-parity/task-9-report.md new file mode 100644 index 0000000..7f1b185 --- /dev/null +++ b/.superpowers/sdd/2026-07-26-midtrans-cli-multi-product-parity/task-9-report.md @@ -0,0 +1,58 @@ +# Task 9 Report + +## Status + +Implemented BI-SNAP pack registration and executable journeys for: + +- `bisnap.qris-payment` +- `bisnap.virtual-account` +- `bisnap.direct-debit` +- `bisnap.status` +- `bisnap.refund` + +## What changed + +- Added BI-SNAP pack descriptor and journey handlers in `packs/bisnap/`. +- Extended the existing BI-SNAP client with runtime B2B token exchange plus QRIS, VA, debit status, create, and refund calls. +- Enforced runtime credential resolution for `client_id`, `client_secret`, `partner_id`, `channel_id`, `device_id`, and `private_key`. +- Added manifest/schema support for BI-SNAP `device_id`, and made `client_secret` + `device_id` required for BI-SNAP credential sets. +- Registered BI-SNAP in the CLI registry and capability contract baseline. +- Added targeted BI-SNAP tests covering: + - exact create/refund endpoint + service codes + - POST `/v1.0/debit/status` + - product-specific status endpoint selection + - one-time debit without `Authorization-Customer` + - QRIS safe-display behavior + - VA partner-service left padding + - runtime credential resolution and B2B exchange + +## Validation + +RED first: + +```sh +go test ./packs/bisnap -run 'TestPackDescriptor|TestQRISJourney|TestVirtualAccountJourney|TestDirectDebitJourney|TestRefundJourney' -count=1 +``` + +GREEN/focused: + +```sh +go test ./packs/bisnap ./internal/app ./test/e2e ./internal/manifest -count=1 +``` + +Full: + +```sh +go test ./... -count=1 +``` + +All passed on July 27, 2026. + +## Assumptions + +- For BI-SNAP status payloads whose exact request-body field set was not fully pinned in the brief, I used a minimal typed request plus `additionalInfo` maps where needed instead of guessing a broader fixed schema. +- The implementation currently performs a fresh B2B token exchange per BI-SNAP API call within a journey run. Tokens are not persisted or logged. + +## Concerns + +- `contracts/public-sources-v1.json` was left unchanged. The repo already reports unrelated source-baseline drift across existing entries via `go run ./tools/source-drift --baseline contracts/public-sources-v1.json`, so regenerating that file in this task would have caused broad unrelated churn outside the BI-SNAP implementation scope. diff --git a/cmd/midtrans/main.go b/cmd/midtrans/main.go index fd26b21..9bdcaf7 100644 --- a/cmd/midtrans/main.go +++ b/cmd/midtrans/main.go @@ -8,6 +8,7 @@ import ( "github.com/veritrans/midtrans-cli/internal/app" "github.com/veritrans/midtrans-cli/internal/packs" "github.com/veritrans/midtrans-cli/internal/version" + "github.com/veritrans/midtrans-cli/packs/bisnap" "github.com/veritrans/midtrans-cli/packs/common" "github.com/veritrans/midtrans-cli/packs/coreapi" "github.com/veritrans/midtrans-cli/packs/paymentlink" @@ -15,7 +16,7 @@ import ( ) func main() { - registry, err := packs.NewRegistry(common.New(), snap.New(), coreapi.New(), paymentlink.New()) + registry, err := packs.NewRegistry(common.New(), snap.New(), coreapi.New(), paymentlink.New(), bisnap.New()) if err != nil { fmt.Fprintln(os.Stderr, err) os.Exit(6) diff --git a/contracts/capabilities-v1.json b/contracts/capabilities-v1.json index 9e4b74c..49639ef 100644 --- a/contracts/capabilities-v1.json +++ b/contracts/capabilities-v1.json @@ -12,6 +12,24 @@ ], "journeys": [] }, + { + "id": "bisnap", + "version": "0.1.0", + "capabilities": [ + "bisnap.qris.verify.v1", + "bisnap.virtual-account.verify.v1", + "bisnap.direct-debit.verify.v1", + "bisnap.status.verify.v1", + "bisnap.refund.verify.v1" + ], + "journeys": [ + "bisnap.qris-payment", + "bisnap.virtual-account", + "bisnap.direct-debit", + "bisnap.status", + "bisnap.refund" + ] + }, { "id": "core-api", "version": "0.1.0", diff --git a/internal/app/app_test.go b/internal/app/app_test.go index 2204fec..402cc7a 100644 --- a/internal/app/app_test.go +++ b/internal/app/app_test.go @@ -23,6 +23,7 @@ import ( "github.com/veritrans/midtrans-cli/internal/packs" "github.com/veritrans/midtrans-cli/internal/update" "github.com/veritrans/midtrans-cli/internal/version" + "github.com/veritrans/midtrans-cli/packs/bisnap" "github.com/veritrans/midtrans-cli/packs/common" "github.com/veritrans/midtrans-cli/packs/coreapi" "github.com/veritrans/midtrans-cli/packs/paymentlink" @@ -48,19 +49,20 @@ func TestCapabilitiesJSON(t *testing.T) { if result.CLIVersion != "0.1.0-test" { t.Fatalf("cli version = %q", result.CLIVersion) } - if len(result.Capabilities) != 14 || - result.Capabilities[0].ID != "common.capabilities.v1" || - result.Capabilities[13].ID != "snap.webhook.verify.v1" { + if len(result.Capabilities) != 19 || + result.Capabilities[0].ID != "bisnap.direct-debit.verify.v1" || + result.Capabilities[18].ID != "snap.webhook.verify.v1" { t.Fatalf("unexpected capabilities: %#v", result.Capabilities) } - if len(result.Packs) != 4 || - result.Packs[0].ID != "common" || - result.Packs[1].ID != "core-api" || - result.Packs[2].ID != "payment-link" || - result.Packs[3].ID != "snap" { + if len(result.Packs) != 5 || + result.Packs[0].ID != "bisnap" || + result.Packs[1].ID != "common" || + result.Packs[2].ID != "core-api" || + result.Packs[3].ID != "payment-link" || + result.Packs[4].ID != "snap" { t.Fatalf("unexpected packs: %#v", result.Packs) } - if len(result.Journeys) != 13 || result.Journeys[11] != "snap.checkout" || result.Journeys[12] != "snap.mobile-webview" { + if len(result.Journeys) != 18 || result.Journeys[16] != "snap.checkout" || result.Journeys[17] != "snap.mobile-webview" { t.Fatalf("unexpected journeys: %#v", result.Journeys) } } @@ -82,8 +84,8 @@ func TestAgentCapabilitiesPreservesCapabilityContract(t *testing.T) { ) if exit != 0 || result.SchemaVersion != "1.0" || - len(result.Capabilities) != 14 || - len(result.Journeys) != 13 { + len(result.Capabilities) != 19 || + len(result.Journeys) != 18 { t.Fatalf("exit = %d, result = %#v", exit, result) } } @@ -3161,7 +3163,7 @@ func assertSchemaFieldsMatchType( func testRegistry(t *testing.T) *packs.Registry { t.Helper() - registry, err := packs.NewRegistry(common.New(), snap.New(), coreapi.New(), paymentlink.New()) + registry, err := packs.NewRegistry(common.New(), snap.New(), coreapi.New(), paymentlink.New(), bisnap.New()) if err != nil { t.Fatal(err) } diff --git a/internal/manifest/model.go b/internal/manifest/model.go index ba701d1..95065a9 100644 --- a/internal/manifest/model.go +++ b/internal/manifest/model.go @@ -37,6 +37,7 @@ type CredentialSet struct { ClientSecret string `yaml:"client_secret,omitempty" json:"client_secret,omitempty"` PartnerID string `yaml:"partner_id,omitempty" json:"partner_id,omitempty"` ChannelID string `yaml:"channel_id,omitempty" json:"channel_id,omitempty"` + DeviceID string `yaml:"device_id,omitempty" json:"device_id,omitempty"` PrivateKey string `yaml:"private_key,omitempty" json:"private_key,omitempty"` MidtransPublicKey string `yaml:"midtrans_public_key,omitempty" json:"midtrans_public_key,omitempty"` } diff --git a/internal/manifest/validate.go b/internal/manifest/validate.go index 6cdcd87..8c3e891 100644 --- a/internal/manifest/validate.go +++ b/internal/manifest/validate.go @@ -17,7 +17,7 @@ var ( var requiredCredentialReferencesByType = map[string][]string{ "classic": {"server_key", "client_key"}, - "bisnap": {"client_id", "partner_id", "channel_id", "private_key", "midtrans_public_key"}, + "bisnap": {"client_id", "client_secret", "partner_id", "channel_id", "device_id", "private_key", "midtrans_public_key"}, } func Validate(value Manifest) []contracts.Finding { @@ -223,6 +223,7 @@ func credentialReferences(set CredentialSet) []string { set.ClientSecret, set.PartnerID, set.ChannelID, + set.DeviceID, set.PrivateKey, set.MidtransPublicKey, } @@ -264,6 +265,8 @@ func credentialReferenceForKey(set CredentialSet, key string) string { return set.PartnerID case "channel_id": return set.ChannelID + case "device_id": + return set.DeviceID case "private_key": return set.PrivateKey case "midtrans_public_key": diff --git a/packs/bisnap/client.go b/packs/bisnap/client.go index cb4762c..69f967b 100644 --- a/packs/bisnap/client.go +++ b/packs/bisnap/client.go @@ -3,9 +3,15 @@ package bisnap import ( "bytes" "context" + "encoding/json" + "errors" + "io" "net/http" + "strconv" "strings" "time" + + "github.com/veritrans/midtrans-cli/internal/sandbox" ) type Request struct { @@ -19,6 +25,7 @@ type Request struct { } type Client struct { + HTTP sandbox.Doer ClientID string PartnerID string ChannelID string @@ -29,6 +36,56 @@ type Client struct { NewExternalID func() (string, error) } +const bisnapMaxResponseBytes = 64 << 10 + +type amountDetails struct { + Value string `json:"value"` + Currency string `json:"currency"` +} + +type CreateRequest struct { + OperationID string + Product string + OrderID string + Amount int64 + Method string +} + +type CreateResponse struct { + OrderID string + ProviderReference string + ActionURL string + VirtualAccountNo string + PartnerServiceID string +} + +type StatusRequest struct { + Product string + OrderID string + Method string +} + +type StatusResponse struct { + OrderID string + ProviderReference string + LatestTransactionStatus string + ResponseCode string + NotFound bool +} + +type RefundRequest struct { + OperationID string + OrderID string + Amount int64 + RefundNo string +} + +type RefundResponse struct { + OrderID string + RefundNo string + ResponseCode string +} + func (c Client) NewAccessTokenRequest(ctx context.Context) (*http.Request, error) { timestamp := c.now().Format(time.RFC3339) signature, err := SignAccessToken(c.PrivateKeyPEM, c.ClientID, timestamp) @@ -134,3 +191,266 @@ func validateChannelID(value string) error { } return nil } + +func (c Client) AccessToken(ctx context.Context) (string, error) { + if c.HTTP == nil { + return "", errRequestInvalid + } + request, err := c.NewAccessTokenRequest(ctx) + if err != nil { + return "", err + } + response, err := c.HTTP.Do(request) + if err != nil { + return "", errors.New("sandbox request transport failed") + } + var result struct { + AccessToken string `json:"accessToken"` + } + if err := decodeBISNAPResponse(response, &result); err != nil { + return "", err + } + if result.AccessToken == "" { + return "", errors.New("SANDBOX_RESPONSE_INVALID") + } + return result.AccessToken, nil +} + +func (c Client) Create(ctx context.Context, input CreateRequest) (CreateResponse, error) { + if c.HTTP == nil || input.OperationID == "" || input.OrderID == "" || input.Amount <= 0 { + return CreateResponse{}, errRequestInvalid + } + accessToken, err := c.AccessToken(ctx) + if err != nil { + return CreateResponse{}, err + } + var ( + path string + payload any + ) + switch input.Product { + case "qris": + path = qrisGeneratePath + payload = qrisCreateRequest{ + PartnerReferenceNo: input.OrderID, + ServiceCode: "47", + Amount: amountFromInt(input.Amount), + AdditionalInfo: map[string]any{ + "originalPartnerReferenceNo": input.OrderID, + }, + } + case "virtual-account": + partnerServiceID, err := PadPartnerServiceID("123") + if err != nil { + return CreateResponse{}, err + } + path = virtualAccountCreatePath + payload = vaCreateRequest{ + PartnerServiceId: partnerServiceID, + CustomerNo: input.OrderID, + TrxId: input.OrderID, + TotalAmount: amountFromInt(input.Amount), + ServiceCode: "27", + AdditionalInfo: map[string]any{ + "bank": input.Method, + }, + } + default: + path = debitPaymentHostToHostPath + payload = debitCreateRequest{ + PartnerReferenceNo: input.OrderID, + ServiceCode: "54", + Amount: amountFromInt(input.Amount), + AdditionalInfo: map[string]any{ + "paymentType": input.Method, + }, + } + } + body, err := json.Marshal(payload) + if err != nil { + return CreateResponse{}, errRequestInvalid + } + request, err := c.NewTransactionRequest(ctx, Request{ + Method: http.MethodPost, + Path: path, + AccessToken: accessToken, + Body: body, + }) + if err != nil { + return CreateResponse{}, err + } + response, err := c.HTTP.Do(request) + if err != nil { + if isTimeoutError(err) { + return CreateResponse{}, sandbox.AmbiguousOperationError{ + OperationID: input.OperationID, + Cause: errors.New("sandbox request transport failed"), + } + } + return CreateResponse{}, errors.New("sandbox request transport failed") + } + var result struct { + PartnerReferenceNo string `json:"partnerReferenceNo"` + ReferenceNo string `json:"referenceNo"` + WebRedirectURL string `json:"webRedirectUrl"` + VirtualAccountNo string `json:"virtualAccountNo"` + PartnerServiceID string `json:"partnerServiceId"` + TrxID string `json:"trxId"` + } + if err := decodeBISNAPResponse(response, &result); err != nil { + return CreateResponse{}, err + } + providerReference := firstNonEmpty(result.ReferenceNo, result.PartnerReferenceNo, result.TrxID) + return CreateResponse{ + OrderID: input.OrderID, + ProviderReference: providerReference, + ActionURL: result.WebRedirectURL, + VirtualAccountNo: result.VirtualAccountNo, + PartnerServiceID: result.PartnerServiceID, + }, nil +} + +func (c Client) Status(ctx context.Context, input StatusRequest) (StatusResponse, error) { + if c.HTTP == nil || input.OrderID == "" { + return StatusResponse{}, errRequestInvalid + } + accessToken, err := c.AccessToken(ctx) + if err != nil { + return StatusResponse{}, err + } + var ( + path string + payload any + ) + switch input.Product { + case "qris": + path = qrisQueryPath + payload = qrisQueryRequest{OriginalPartnerReferenceNo: input.OrderID, ServiceCode: "51"} + case "virtual-account": + path = virtualAccountStatusPath + payload = vaStatusRequest{OriginalPartnerReferenceNo: input.OrderID, ServiceCode: "17"} + default: + path = debitStatusPath + payload = debitStatusRequest{OriginalReferenceNo: input.OrderID, ServiceCode: "55"} + } + body, err := json.Marshal(payload) + if err != nil { + return StatusResponse{}, errRequestInvalid + } + request, err := c.NewTransactionRequest(ctx, Request{ + Method: http.MethodPost, + Path: path, + AccessToken: accessToken, + Body: body, + }) + if err != nil { + return StatusResponse{}, err + } + response, err := c.HTTP.Do(request) + if err != nil { + return StatusResponse{}, errors.New("sandbox request transport failed") + } + if response != nil && response.StatusCode == http.StatusNotFound { + return StatusResponse{OrderID: input.OrderID, NotFound: true}, nil + } + var result struct { + ResponseCode string `json:"responseCode"` + LatestTransactionStatus string `json:"latestTransactionStatus"` + ReferenceNo string `json:"referenceNo"` + TrxID string `json:"trxId"` + } + if err := decodeBISNAPResponse(response, &result); err != nil { + return StatusResponse{}, err + } + if strings.HasPrefix(result.ResponseCode, "404") { + return StatusResponse{OrderID: input.OrderID, NotFound: true}, nil + } + return StatusResponse{ + OrderID: input.OrderID, + ProviderReference: firstNonEmpty(result.ReferenceNo, result.TrxID), + LatestTransactionStatus: result.LatestTransactionStatus, + ResponseCode: result.ResponseCode, + }, nil +} + +func (c Client) Refund(ctx context.Context, input RefundRequest) (RefundResponse, error) { + if c.HTTP == nil || input.OperationID == "" || input.OrderID == "" || input.Amount <= 0 || input.RefundNo == "" { + return RefundResponse{}, errRequestInvalid + } + accessToken, err := c.AccessToken(ctx) + if err != nil { + return RefundResponse{}, err + } + body, err := json.Marshal(debitRefundRequest{ + OriginalReferenceNo: input.OrderID, + RefundNo: input.RefundNo, + ServiceCode: "58", + RefundAmount: amountFromInt(input.Amount), + }) + if err != nil { + return RefundResponse{}, errRequestInvalid + } + request, err := c.NewTransactionRequest(ctx, Request{ + Method: http.MethodPost, + Path: debitRefundPath, + AccessToken: accessToken, + Body: body, + }) + if err != nil { + return RefundResponse{}, err + } + response, err := c.HTTP.Do(request) + if err != nil { + return RefundResponse{}, errors.New("sandbox request transport failed") + } + var result struct { + ResponseCode string `json:"responseCode"` + OriginalReferenceNo string `json:"originalReferenceNo"` + RefundNo string `json:"refundNo"` + } + if err := decodeBISNAPResponse(response, &result); err != nil { + return RefundResponse{}, err + } + return RefundResponse{ + OrderID: firstNonEmpty(result.OriginalReferenceNo, input.OrderID), + RefundNo: firstNonEmpty(result.RefundNo, input.RefundNo), + ResponseCode: result.ResponseCode, + }, nil +} + +func decodeBISNAPResponse(response *http.Response, target any) error { + if response == nil || response.Body == nil { + return errors.New("SANDBOX_RESPONSE_INVALID") + } + defer response.Body.Close() + if response.StatusCode < http.StatusOK || response.StatusCode >= http.StatusMultipleChoices { + return sandbox.ResponseError{Operation: "bisnap", StatusCode: response.StatusCode} + } + data, err := io.ReadAll(io.LimitReader(response.Body, bisnapMaxResponseBytes+1)) + if err != nil || len(data) > bisnapMaxResponseBytes { + return errors.New("SANDBOX_RESPONSE_INVALID") + } + decoder := json.NewDecoder(bytes.NewReader(data)) + if err := decoder.Decode(target); err != nil { + return errors.New("SANDBOX_RESPONSE_INVALID") + } + return nil +} + +func amountFromInt(value int64) amountDetails { + return amountDetails{Value: strconv.FormatInt(value, 10) + ".00", Currency: "IDR"} +} + +func firstNonEmpty(values ...string) string { + for _, value := range values { + if value != "" { + return value + } + } + return "" +} + +func isTimeoutError(err error) bool { + var timeout interface{ Timeout() bool } + return errors.As(err, &timeout) && timeout.Timeout() +} diff --git a/packs/bisnap/direct_debit.go b/packs/bisnap/direct_debit.go new file mode 100644 index 0000000..3a31d6b --- /dev/null +++ b/packs/bisnap/direct_debit.go @@ -0,0 +1,20 @@ +package bisnap + +type debitStatusRequest struct { + OriginalReferenceNo string `json:"originalReferenceNo"` + ServiceCode string `json:"serviceCode"` +} + +type debitCreateRequest struct { + PartnerReferenceNo string `json:"partnerReferenceNo"` + ServiceCode string `json:"serviceCode"` + Amount amountDetails `json:"amount"` + AdditionalInfo map[string]any `json:"additionalInfo,omitempty"` +} + +type debitRefundRequest struct { + OriginalReferenceNo string `json:"originalReferenceNo"` + RefundNo string `json:"refundNo"` + ServiceCode string `json:"serviceCode"` + RefundAmount amountDetails `json:"refundAmount"` +} diff --git a/packs/bisnap/endpoints.go b/packs/bisnap/endpoints.go index a2072da..62a6b02 100644 --- a/packs/bisnap/endpoints.go +++ b/packs/bisnap/endpoints.go @@ -6,7 +6,9 @@ const ( accessTokenPath = "/v1.0/access-token/b2b" qrisGeneratePath = "/v1.0/qr/qr-mpm-generate" + qrisQueryPath = "/v1.0/qr/qr-mpm-query" virtualAccountCreatePath = "/v1.0/transfer-va/create-va" + virtualAccountStatusPath = "/v1.0/transfer-va/status" debitPaymentHostToHostPath = "/v1.0/debit/payment-host-to-host" debitStatusPath = "/v1.0/debit/status" debitRefundPath = "/v1.0/debit/refund" diff --git a/packs/bisnap/journey.go b/packs/bisnap/journey.go new file mode 100644 index 0000000..a465f9a --- /dev/null +++ b/packs/bisnap/journey.go @@ -0,0 +1,389 @@ +package bisnap + +import ( + "context" + "errors" + "strconv" + "strings" + "time" + + "github.com/veritrans/midtrans-cli/internal/contracts" + journey "github.com/veritrans/midtrans-cli/internal/journey" + "github.com/veritrans/midtrans-cli/internal/operations" + "github.com/veritrans/midtrans-cli/internal/sandbox" +) + +const qrisSimulatorURL = "https://simulator.sandbox.midtrans.com/qris/index" + +type JourneyRunner struct { + Client Client + Now func() time.Time +} + +type Handler struct { + definition journey.Definition + runner JourneyRunner + runnerOverride bool +} + +func NewQRISHandler() Handler { return newHandler("bisnap.qris-payment", "qris-payment") } +func NewVirtualAccountHandler() Handler { + return newHandler("bisnap.virtual-account", "virtual-account") +} +func NewDirectDebitHandler() Handler { return newHandler("bisnap.direct-debit", "direct-debit") } +func NewStatusHandler() Handler { return newHandler("bisnap.status", "status") } +func NewRefundHandler() Handler { return newHandler("bisnap.refund", "refund") } + +func newHandler(id, intent string) Handler { + return Handler{ + definition: journey.Definition{ + ID: id, + Product: "bisnap", + Intent: intent, + RequiredInputs: []string{"order_id"}, + }, + } +} + +func (h Handler) WithRunner(runner JourneyRunner) Handler { + h.runner = runner + h.runnerOverride = true + if h.runner.Now == nil { + h.runner.Now = func() time.Time { return time.Now().UTC() } + } + return h +} + +func (h Handler) Definition() journey.Definition { return h.definition } + +func (h Handler) Plan(_ context.Context, request journey.Request, _ journey.Runtime) journey.Outcome { + safeData := map[string]any{ + "order_id": request.Input.OrderID, + "method": request.Input.Method, + } + if request.Input.Amount > 0 { + safeData["gross_amount"] = strconv.FormatInt(request.Input.Amount, 10) + } + return journey.Outcome{State: journey.Planned, SafeData: safeData} +} + +func (h Handler) Execute(ctx context.Context, request journey.Request, runtime journey.Runtime) journey.Outcome { + return h.run(ctx, request, runtime, nil) +} + +func (h Handler) Resume(ctx context.Context, request journey.Request, runtime journey.Runtime, record operations.Record) journey.Outcome { + return h.run(ctx, request, runtime, &record) +} + +func (h Handler) run(ctx context.Context, request journey.Request, runtime journey.Runtime, record *operations.Record) journey.Outcome { + request = rehydrateRequest(request, record) + if request.OperationID == "" || request.ManifestHash == "" || request.Input.OrderID == "" { + return inputRequired("order_id is required") + } + if h.definition.Intent != "status" && h.definition.Intent != "refund" && request.Input.Amount <= 0 { + return inputRequired("a positive amount is required") + } + runner, outcome := h.runtimeRunner(ctx, request, runtime) + if outcome != nil { + return *outcome + } + switch h.definition.Intent { + case "status": + return runStatus(ctx, request, runner) + case "refund": + return runRefund(ctx, request, runner) + default: + return h.runMutation(ctx, request, runner) + } +} + +func (h Handler) runMutation(ctx context.Context, request journey.Request, runner JourneyRunner) journey.Outcome { + product := journeyProduct(h.definition.Intent, request.Input.Method) + status, err := runner.Client.Status(ctx, StatusRequest{ + Product: product, + OrderID: request.Input.OrderID, + Method: request.Input.Method, + }) + if err == nil && !status.NotFound { + return evaluateStatus(status) + } + created, err := runner.Client.Create(ctx, CreateRequest{ + OperationID: request.OperationID, + Product: product, + OrderID: request.Input.OrderID, + Amount: request.Input.Amount, + Method: request.Input.Method, + }) + if err != nil { + var ambiguous sandbox.AmbiguousOperationError + if errors.As(err, &ambiguous) { + reconciled, statusErr := runner.Client.Status(ctx, StatusRequest{ + Product: product, + OrderID: request.Input.OrderID, + Method: request.Input.Method, + }) + if statusErr == nil && !reconciled.NotFound { + return evaluateStatus(reconciled) + } + return journey.Outcome{ + State: journey.Reconciling, + SafeData: map[string]any{ + "order_id": request.Input.OrderID, + "method": request.Input.Method, + }, + } + } + return blockedOutcome("sandbox mutation failed") + } + return h.awaitingActionOutcome(request, created, runner.Now) +} + +func runStatus(ctx context.Context, request journey.Request, runner JourneyRunner) journey.Outcome { + if request.Input.Method == "" { + return inputRequired("method is required") + } + status, err := runner.Client.Status(ctx, StatusRequest{ + Product: journeyProduct(request.Input.Method, request.Input.Method), + OrderID: request.Input.OrderID, + Method: request.Input.Method, + }) + if err != nil { + return blockedOutcome("provider status is unavailable") + } + if status.NotFound { + return journey.Outcome{ + State: journey.Reconciling, + SafeData: map[string]any{ + "order_id": request.Input.OrderID, + "method": request.Input.Method, + }, + } + } + return evaluateStatus(status) +} + +func runRefund(ctx context.Context, request journey.Request, runner JourneyRunner) journey.Outcome { + if request.Input.CustomerReference == "" { + return inputRequired("customer_reference is required as a stable refund key") + } + response, err := runner.Client.Refund(ctx, RefundRequest{ + OperationID: request.OperationID, + OrderID: request.Input.OrderID, + Amount: request.Input.Amount, + RefundNo: request.Input.CustomerReference, + }) + if err != nil { + return blockedOutcome("refund request failed") + } + return journey.Outcome{ + State: journey.Passed, + SafeData: map[string]any{ + "order_id": response.OrderID, + "refund_no": response.RefundNo, + "status_code": response.ResponseCode, + }, + } +} + +func (h Handler) awaitingActionOutcome(request journey.Request, created CreateResponse, now func() time.Time) journey.Outcome { + if now == nil { + now = func() time.Time { return time.Now().UTC() } + } + safeData := map[string]any{ + "order_id": request.Input.OrderID, + "gross_amount": strconv.FormatInt(request.Input.Amount, 10), + "method": request.Input.Method, + } + if created.ProviderReference != "" { + safeData["provider_reference"] = created.ProviderReference + } + switch h.definition.Intent { + case "qris-payment": + return journey.Outcome{ + State: journey.AwaitingUserAction, + SafeData: safeData, + Action: &journey.Action{ + Type: "browser", + URL: qrisSimulatorURL, + Instructions: "complete the QRIS payment in the Midtrans sandbox simulator and rerun this journey", + ExpiresAt: now().Add(15 * time.Minute), + ResumeCommand: "midtrans agent resume --operation " + request.OperationID, + }, + } + case "virtual-account": + if created.VirtualAccountNo != "" { + safeData["va_number"] = created.VirtualAccountNo + } + if created.PartnerServiceID != "" { + safeData["partner_service_id"] = created.PartnerServiceID + } + return journey.Outcome{State: journey.AwaitingUserAction, SafeData: safeData} + default: + return journey.Outcome{ + State: journey.AwaitingUserAction, + SafeData: safeData, + Action: &journey.Action{ + Type: "browser", + URL: created.ActionURL, + Instructions: "complete the one-time direct debit flow and rerun this journey", + ExpiresAt: now().Add(15 * time.Minute), + ResumeCommand: "midtrans agent resume --operation " + request.OperationID, + }, + } + } +} + +func evaluateStatus(status StatusResponse) journey.Outcome { + if status.OrderID == "" { + return blockedOutcome("provider status was invalid") + } + switch status.LatestTransactionStatus { + case "00": + return journey.Outcome{ + State: journey.Passed, + SafeData: map[string]any{ + "order_id": status.OrderID, + "status_code": status.ResponseCode, + "provider_reference": status.ProviderReference, + }, + } + default: + return journey.Outcome{ + State: journey.Reconciling, + SafeData: map[string]any{ + "order_id": status.OrderID, + "provider_reference": status.ProviderReference, + }, + } + } +} + +func (h Handler) runtimeRunner(ctx context.Context, request journey.Request, runtime journey.Runtime) (JourneyRunner, *journey.Outcome) { + if h.runnerOverride { + return h.runner, nil + } + integration, ok := request.Manifest.IntegrationFor("bisnap") + if !ok { + outcome := blockedFinding("CAPABILITY_UNAVAILABLE", "bisnap integration is not configured for this project") + return JourneyRunner{}, &outcome + } + credentials, ok := request.Manifest.CredentialSetFor(integration.Credentials) + if !ok || credentials.ClientID == "" || credentials.ClientSecret == "" || credentials.PartnerID == "" || + credentials.ChannelID == "" || credentials.DeviceID == "" || credentials.PrivateKey == "" { + outcome := blockedFinding("CREDENTIAL_MISSING", "the configured bisnap credential references are incomplete") + return JourneyRunner{}, &outcome + } + if runtime.ResolveCredential == nil || runtime.HTTP == nil { + outcome := blockedFinding("JOURNEY_EXECUTION_BLOCKED", "journey runtime dependencies are unavailable") + return JourneyRunner{}, &outcome + } + resolve := func(reference string) ([]byte, *journey.Outcome) { + value, err := runtime.ResolveCredential(ctx, request.ProjectDir, reference) + if err != nil { + outcome := blockedFinding("CREDENTIAL_RESOLUTION_FAILED", "unable to resolve the configured bisnap credential reference") + return nil, &outcome + } + return value, nil + } + clientID, outcome := resolve(credentials.ClientID) + if outcome != nil { + return JourneyRunner{}, outcome + } + clientSecret, outcome := resolve(credentials.ClientSecret) + if outcome != nil { + return JourneyRunner{}, outcome + } + partnerID, outcome := resolve(credentials.PartnerID) + if outcome != nil { + return JourneyRunner{}, outcome + } + channelID, outcome := resolve(credentials.ChannelID) + if outcome != nil { + return JourneyRunner{}, outcome + } + deviceID, outcome := resolve(credentials.DeviceID) + if outcome != nil { + return JourneyRunner{}, outcome + } + privateKey, outcome := resolve(credentials.PrivateKey) + if outcome != nil { + return JourneyRunner{}, outcome + } + return JourneyRunner{ + Client: Client{ + HTTP: runtime.HTTP, + ClientID: string(clientID), + ClientSecret: clientSecret, + PartnerID: string(partnerID), + ChannelID: string(channelID), + DeviceID: string(deviceID), + PrivateKeyPEM: privateKey, + Now: runtimeNow(runtime), + NewExternalID: func() (string, error) { return request.OperationID, nil }, + }, + Now: runtimeNow(runtime), + }, nil +} + +func rehydrateRequest(request journey.Request, record *operations.Record) journey.Request { + if record == nil || record.SafeReferences == nil { + return request + } + if request.Input.OrderID == "" { + request.Input.OrderID = record.SafeReferences["order_id"] + } + if request.Input.Method == "" { + request.Input.Method = record.SafeReferences["method"] + } + if request.Input.Amount <= 0 { + if amount, err := strconv.ParseInt(record.SafeReferences["gross_amount"], 10, 64); err == nil { + request.Input.Amount = amount + } + } + return request +} + +func journeyProduct(intent, method string) string { + switch { + case intent == "qris-payment" || method == "qris": + return "qris" + case intent == "virtual-account" || isVirtualAccountMethod(method): + return "virtual-account" + default: + return "direct-debit" + } +} + +func isVirtualAccountMethod(method string) bool { + switch strings.ToLower(method) { + case "bca", "bni", "bri", "permata", "cimb": + return true + default: + return false + } +} + +func runtimeNow(runtime journey.Runtime) func() time.Time { + if runtime.Now != nil { + return runtime.Now + } + return func() time.Time { return time.Now().UTC() } +} + +func blockedFinding(code, message string) journey.Outcome { + return journey.Outcome{ + State: journey.Blocked, + Finding: &contracts.Finding{ + Code: code, + Severity: "blocking", + Message: message, + }, + } +} + +func blockedOutcome(message string) journey.Outcome { + return blockedFinding("JOURNEY_EXECUTION_BLOCKED", message) +} +func inputRequired(message string) journey.Outcome { + return blockedFinding("JOURNEY_INPUT_REQUIRED", message) +} diff --git a/packs/bisnap/journey_test.go b/packs/bisnap/journey_test.go new file mode 100644 index 0000000..8123b72 --- /dev/null +++ b/packs/bisnap/journey_test.go @@ -0,0 +1,313 @@ +package bisnap_test + +import ( + "context" + "io" + "net/http" + "strings" + "testing" + "time" + + journeypkg "github.com/veritrans/midtrans-cli/internal/journey" + "github.com/veritrans/midtrans-cli/internal/manifest" + "github.com/veritrans/midtrans-cli/packs/bisnap" +) + +func TestQRISJourneyUsesProductStatusEndpointAndReturnsSimulatorAction(t *testing.T) { + var requests []*http.Request + var bodies []string + + handler := bisnap.NewQRISHandler() + outcome := handler.Execute(context.Background(), bisnapRequest("order-qris", 12500, "qris"), journeypkg.Runtime{ + Now: func() time.Time { return time.Unix(1700000000, 0).UTC() }, + ResolveCredential: func(_ context.Context, _ string, reference string) ([]byte, error) { + switch reference { + case "env:MIDTRANS_BISNAP_CLIENT_ID": + return []byte("CLIENT-ID-CANARY-DO-NOT-PRINT"), nil + case "env:MIDTRANS_BISNAP_CLIENT_SECRET": + return []byte("CLIENT-SECRET-CANARY-DO-NOT-PRINT"), nil + case "env:MIDTRANS_BISNAP_PARTNER_ID": + return []byte("G123456"), nil + case "env:MIDTRANS_BISNAP_CHANNEL_ID": + return []byte("12345"), nil + case "env:MIDTRANS_BISNAP_DEVICE_ID": + return []byte("device-canary"), nil + case "file:./secrets/bisnap-private.pem": + return fixtureBytes(t, "private_key_pkcs8.pem"), nil + case "file:./secrets/bisnap-public.pem": + return fixtureBytes(t, "public_key_pkix.pem"), nil + default: + t.Fatalf("unexpected reference %q", reference) + return nil, nil + } + }, + HTTP: appDoerFunc(func(request *http.Request) (*http.Response, error) { + requests = append(requests, request) + body, err := io.ReadAll(request.Body) + if err != nil { + t.Fatal(err) + } + bodies = append(bodies, string(body)) + switch request.URL.Path { + case "/v1.0/access-token/b2b": + return bisnapResponse(http.StatusOK, `{"accessToken":"ACCESS-TOKEN-CANARY-DO-NOT-PRINT"}`), nil + case "/v1.0/qr/qr-mpm-query": + return bisnapResponse(http.StatusNotFound, `{"responseCode":"4044701","responseMessage":"not found"}`), nil + case "/v1.0/qr/qr-mpm-generate": + return bisnapResponse(http.StatusOK, `{ + "responseCode":"2004700", + "responseMessage":"Successful", + "partnerReferenceNo":"partner-qris-001", + "qrUrl":"https://api.sandbox.midtrans.com/v2/qris/qr-001", + "qrImage":"https://api.sandbox.midtrans.com/v2/qris/qr-001.png", + "qrContent":"000201010211" + }`), nil + default: + t.Fatalf("unexpected path %q", request.URL.Path) + return nil, nil + } + }), + }) + + if outcome.State != journeypkg.AwaitingUserAction { + t.Fatalf("outcome = %#v", outcome) + } + if outcome.Action == nil || !strings.Contains(outcome.Action.URL, "simulator") { + t.Fatalf("action = %#v", outcome.Action) + } + if got := outcome.SafeData["qr_url"]; got != nil { + t.Fatalf("safe data leaked qr_url: %#v", outcome.SafeData) + } + if got := outcome.SafeData["qr_content"]; got != nil { + t.Fatalf("safe data leaked qr_content: %#v", outcome.SafeData) + } + if requests[1].URL.Path != "/v1.0/qr/qr-mpm-query" { + t.Fatalf("status path = %q", requests[1].URL.Path) + } + if requests[3].URL.Path != "/v1.0/qr/qr-mpm-generate" { + t.Fatalf("create path = %q", requests[3].URL.Path) + } + if !strings.Contains(bodies[3], `"serviceCode":"47"`) { + t.Fatalf("generate payload = %s", bodies[3]) + } +} + +func TestVirtualAccountJourneyUsesVAStatusEndpointAndStoresSafeDisplayFacts(t *testing.T) { + var requests []*http.Request + var bodies []string + + handler := bisnap.NewVirtualAccountHandler() + outcome := handler.Execute(context.Background(), bisnapRequest("order-va", 88000, "bca"), journeypkg.Runtime{ + ResolveCredential: bisnapResolveCredential(t), + HTTP: appDoerFunc(func(request *http.Request) (*http.Response, error) { + requests = append(requests, request) + body, err := io.ReadAll(request.Body) + if err != nil { + t.Fatal(err) + } + bodies = append(bodies, string(body)) + switch request.URL.Path { + case "/v1.0/access-token/b2b": + return bisnapResponse(http.StatusOK, `{"accessToken":"ACCESS-TOKEN-CANARY-DO-NOT-PRINT"}`), nil + case "/v1.0/transfer-va/status": + return bisnapResponse(http.StatusNotFound, `{"responseCode":"4042701","responseMessage":"not found"}`), nil + case "/v1.0/transfer-va/create-va": + return bisnapResponse(http.StatusOK, `{ + "responseCode":"2002700", + "responseMessage":"Successful", + "partnerServiceId":"123", + "virtualAccountNo":"1234567890123456", + "trxId":"trx-va-001" + }`), nil + default: + t.Fatalf("unexpected path %q", request.URL.Path) + return nil, nil + } + }), + }) + + if outcome.State != journeypkg.AwaitingUserAction { + t.Fatalf("outcome = %#v", outcome) + } + if outcome.SafeData["va_number"] != "1234567890123456" { + t.Fatalf("safe data = %#v", outcome.SafeData) + } + if outcome.SafeData["provider_reference"] != "trx-va-001" { + t.Fatalf("safe data = %#v", outcome.SafeData) + } + if requests[1].URL.Path != "/v1.0/transfer-va/status" { + t.Fatalf("status path = %q", requests[1].URL.Path) + } + if !strings.Contains(bodies[3], `"serviceCode":"27"`) { + t.Fatalf("create payload = %s", bodies[3]) + } + if !strings.Contains(bodies[3], `"partnerServiceId":" 123"`) { + t.Fatalf("create payload = %s", bodies[3]) + } +} + +func TestDirectDebitJourneyBuildsRuntimeRequestsWithoutAuthorizationCustomerAndRecoversViaDebitStatus(t *testing.T) { + var requests []*http.Request + + handler := bisnap.NewDirectDebitHandler() + outcome := handler.Execute(context.Background(), bisnapRequest("order-dd", 45000, "gopay"), journeypkg.Runtime{ + ResolveCredential: bisnapResolveCredential(t), + HTTP: appDoerFunc(func(request *http.Request) (*http.Response, error) { + requests = append(requests, request) + switch request.URL.Path { + case "/v1.0/access-token/b2b": + return bisnapResponse(http.StatusOK, `{"accessToken":"ACCESS-TOKEN-CANARY-DO-NOT-PRINT"}`), nil + case "/v1.0/debit/status": + return bisnapResponse(http.StatusNotFound, `{"responseCode":"4045501","responseMessage":"not found"}`), nil + case "/v1.0/debit/payment-host-to-host": + if got := request.Header.Get("Authorization-Customer"); got != "" { + t.Fatalf("Authorization-Customer = %q", got) + } + return bisnapResponse(http.StatusOK, `{ + "responseCode":"2005400", + "responseMessage":"Successful", + "webRedirectUrl":"https://simulator.sandbox.midtrans.com/gopay/web/redirect", + "referenceNo":"provider-dd-001" + }`), nil + default: + t.Fatalf("unexpected path %q", request.URL.Path) + return nil, nil + } + }), + }) + + if outcome.State != journeypkg.AwaitingUserAction { + t.Fatalf("outcome = %#v", outcome) + } + if outcome.Action == nil || outcome.Action.URL != "https://simulator.sandbox.midtrans.com/gopay/web/redirect" { + t.Fatalf("action = %#v", outcome.Action) + } + if requests[1].URL.Path != "/v1.0/debit/status" || requests[3].URL.Path != "/v1.0/debit/payment-host-to-host" { + t.Fatalf("paths = %q %q", requests[1].URL.Path, requests[3].URL.Path) + } +} + +func TestRefundJourneyUsesDebitRefundEndpointAndStableReference(t *testing.T) { + var requests []*http.Request + var bodies []string + + handler := bisnap.NewRefundHandler() + outcome := handler.Execute(context.Background(), journeypkg.Request{ + OperationID: "operation-refund", + ProjectDir: "/merchant", + ManifestHash: "manifest-hash", + Manifest: validBISNAPManifest(), + Input: journeypkg.Input{ + OrderID: "order-refund", + Amount: 12000, + Method: "direct-debit", + CustomerReference: "refund-001", + }, + }, journeypkg.Runtime{ + ResolveCredential: bisnapResolveCredential(t), + HTTP: appDoerFunc(func(request *http.Request) (*http.Response, error) { + requests = append(requests, request) + body, err := io.ReadAll(request.Body) + if err != nil { + t.Fatal(err) + } + bodies = append(bodies, string(body)) + switch request.URL.Path { + case "/v1.0/access-token/b2b": + return bisnapResponse(http.StatusOK, `{"accessToken":"ACCESS-TOKEN-CANARY-DO-NOT-PRINT"}`), nil + case "/v1.0/debit/refund": + return bisnapResponse(http.StatusOK, `{ + "responseCode":"2005800", + "responseMessage":"Successful", + "originalReferenceNo":"order-refund", + "refundNo":"refund-001" + }`), nil + default: + t.Fatalf("unexpected path %q", request.URL.Path) + return nil, nil + } + }), + }) + + if outcome.State != journeypkg.Passed { + t.Fatalf("outcome = %#v", outcome) + } + if requests[1].URL.Path != "/v1.0/debit/refund" { + t.Fatalf("refund path = %q", requests[1].URL.Path) + } + if !strings.Contains(bodies[1], `"serviceCode":"58"`) || !strings.Contains(bodies[1], `"refundNo":"refund-001"`) { + t.Fatalf("refund payload = %s", bodies[1]) + } +} + +type appDoerFunc func(*http.Request) (*http.Response, error) + +func (f appDoerFunc) Do(request *http.Request) (*http.Response, error) { return f(request) } + +func bisnapRequest(orderID string, amount int64, method string) journeypkg.Request { + return journeypkg.Request{ + OperationID: "operation-" + orderID, + ProjectDir: "/merchant", + ManifestHash: "manifest-hash", + Manifest: validBISNAPManifest(), + Input: journeypkg.Input{ + OrderID: orderID, + Amount: amount, + Method: method, + }, + } +} + +func validBISNAPManifest() manifest.Manifest { + return manifest.Manifest{ + CredentialSets: map[string]manifest.CredentialSet{ + "bisnap": { + Type: "bisnap", + Environment: "sandbox", + ClientID: "env:MIDTRANS_BISNAP_CLIENT_ID", + ClientSecret: "env:MIDTRANS_BISNAP_CLIENT_SECRET", + PartnerID: "env:MIDTRANS_BISNAP_PARTNER_ID", + ChannelID: "env:MIDTRANS_BISNAP_CHANNEL_ID", + DeviceID: "env:MIDTRANS_BISNAP_DEVICE_ID", + PrivateKey: "file:./secrets/bisnap-private.pem", + MidtransPublicKey: "file:./secrets/bisnap-public.pem", + }, + }, + Integrations: map[string]manifest.Integration{ + "bisnap": {ConfigVersion: 1, Credentials: "bisnap", Callbacks: map[string]string{"notification": "/midtrans/bisnap/notification"}}, + }, + } +} + +func bisnapResolveCredential(t *testing.T) func(context.Context, string, string) ([]byte, error) { + t.Helper() + return func(_ context.Context, _ string, reference string) ([]byte, error) { + switch reference { + case "env:MIDTRANS_BISNAP_CLIENT_ID": + return []byte("CLIENT-ID-CANARY-DO-NOT-PRINT"), nil + case "env:MIDTRANS_BISNAP_CLIENT_SECRET": + return []byte("CLIENT-SECRET-CANARY-DO-NOT-PRINT"), nil + case "env:MIDTRANS_BISNAP_PARTNER_ID": + return []byte("G123456"), nil + case "env:MIDTRANS_BISNAP_CHANNEL_ID": + return []byte("12345"), nil + case "env:MIDTRANS_BISNAP_DEVICE_ID": + return []byte("device-canary"), nil + case "file:./secrets/bisnap-private.pem": + return fixtureBytes(t, "private_key_pkcs8.pem"), nil + case "file:./secrets/bisnap-public.pem": + return fixtureBytes(t, "public_key_pkix.pem"), nil + default: + t.Fatalf("unexpected reference %q", reference) + return nil, nil + } + } +} + +func bisnapResponse(status int, body string) *http.Response { + return &http.Response{ + StatusCode: status, + Body: io.NopCloser(strings.NewReader(body)), + Header: make(http.Header), + } +} diff --git a/packs/bisnap/pack.go b/packs/bisnap/pack.go new file mode 100644 index 0000000..3c3e58c --- /dev/null +++ b/packs/bisnap/pack.go @@ -0,0 +1,81 @@ +package bisnap + +import ( + "github.com/veritrans/midtrans-cli/internal/contracts" + "github.com/veritrans/midtrans-cli/internal/inspection" + "github.com/veritrans/midtrans-cli/internal/journey" + "github.com/veritrans/midtrans-cli/internal/manifest" + "github.com/veritrans/midtrans-cli/internal/packs" +) + +type Pack struct{} + +func New() Pack { return Pack{} } + +func (Pack) Descriptor() packs.Descriptor { + return packs.Descriptor{ + ID: "bisnap", + Version: "0.1.0", + Capabilities: []contracts.Capability{ + {ID: "bisnap.qris.verify.v1", Description: "run and verify a BI-SNAP QRIS journey", Pack: "bisnap"}, + {ID: "bisnap.virtual-account.verify.v1", Description: "run and verify a BI-SNAP virtual-account journey", Pack: "bisnap"}, + {ID: "bisnap.direct-debit.verify.v1", Description: "run and verify a BI-SNAP one-time direct-debit journey", Pack: "bisnap"}, + {ID: "bisnap.status.verify.v1", Description: "verify a BI-SNAP product status journey", Pack: "bisnap"}, + {ID: "bisnap.refund.verify.v1", Description: "run and verify a BI-SNAP refund journey", Pack: "bisnap"}, + }, + Journeys: []string{ + "bisnap.qris-payment", + "bisnap.virtual-account", + "bisnap.direct-debit", + "bisnap.status", + "bisnap.refund", + }, + SandboxHosts: []string{ + "merchants.sbx.midtrans.com", + "merchants-app.sbx.midtrans.com", + "simulator.sandbox.midtrans.com", + }, + SensitiveKeys: []string{ + "access_token", + "client_secret", + "token", + "signature", + }, + Sources: []contracts.PublicSource{ + {ID: "bisnap-overview", URL: "https://docs.midtrans.com/reference/midtrans-bisnap-overview", Rules: []string{"bisnap.signing.verify.v1"}}, + {ID: "bisnap-qris", URL: "https://docs.midtrans.com/reference/qris-mpm", Rules: []string{"bisnap.qris.create", "bisnap.qris.status"}}, + {ID: "bisnap-virtual-account", URL: "https://docs.midtrans.com/reference/virtual-account-api-bank-transfer", Rules: []string{"bisnap.virtual-account.create", "bisnap.virtual-account.status"}}, + {ID: "bisnap-direct-debit", URL: "https://docs.midtrans.com/reference/direct-debit-api", Rules: []string{"bisnap.direct-debit.create", "bisnap.direct-debit.status", "bisnap.refund"}}, + {ID: "bisnap-notifications", URL: "https://docs.midtrans.com/docs/https-notification-webhooks", Rules: []string{"bisnap.notification.signature", "common.webhook-idempotency"}}, + }, + } +} + +func (Pack) Evaluate(value manifest.Manifest, _ inspection.Report) []contracts.Finding { + integration, ok := value.IntegrationFor("bisnap") + if !ok { + return []contracts.Finding{{ + Code: "BISNAP_PRODUCT_NOT_SELECTED", + Severity: "blocking", + Message: "integrations must include bisnap", + }} + } + if integration.Callbacks["notification"] == "" { + return []contracts.Finding{{ + Code: "BISNAP_NOTIFICATION_ROUTE_MISSING", + Severity: "blocking", + Message: "integrations.bisnap.callbacks.notification is required", + }} + } + return nil +} + +func (Pack) Handlers() []journey.Handler { + return []journey.Handler{ + NewQRISHandler(), + NewVirtualAccountHandler(), + NewDirectDebitHandler(), + NewStatusHandler(), + NewRefundHandler(), + } +} diff --git a/packs/bisnap/pack_test.go b/packs/bisnap/pack_test.go new file mode 100644 index 0000000..775f466 --- /dev/null +++ b/packs/bisnap/pack_test.go @@ -0,0 +1,45 @@ +package bisnap_test + +import ( + "reflect" + "testing" + + "github.com/veritrans/midtrans-cli/packs/bisnap" +) + +func TestPackDescriptorIncludesBISNAPCapabilitiesAndJourneys(t *testing.T) { + descriptor := bisnap.New().Descriptor() + + if descriptor.ID != "bisnap" { + t.Fatalf("descriptor.ID = %q", descriptor.ID) + } + if descriptor.Version != "0.1.0" { + t.Fatalf("descriptor.Version = %q", descriptor.Version) + } + + wantCapabilities := []string{ + "bisnap.qris.verify.v1", + "bisnap.virtual-account.verify.v1", + "bisnap.direct-debit.verify.v1", + "bisnap.status.verify.v1", + "bisnap.refund.verify.v1", + } + var gotCapabilities []string + for _, capability := range descriptor.Capabilities { + gotCapabilities = append(gotCapabilities, capability.ID) + } + if !reflect.DeepEqual(gotCapabilities, wantCapabilities) { + t.Fatalf("capabilities = %#v", gotCapabilities) + } + + wantJourneys := []string{ + "bisnap.qris-payment", + "bisnap.virtual-account", + "bisnap.direct-debit", + "bisnap.status", + "bisnap.refund", + } + if !reflect.DeepEqual(descriptor.Journeys, wantJourneys) { + t.Fatalf("journeys = %#v", descriptor.Journeys) + } +} diff --git a/packs/bisnap/qris.go b/packs/bisnap/qris.go new file mode 100644 index 0000000..cc4eb11 --- /dev/null +++ b/packs/bisnap/qris.go @@ -0,0 +1,13 @@ +package bisnap + +type qrisQueryRequest struct { + OriginalPartnerReferenceNo string `json:"originalPartnerReferenceNo"` + ServiceCode string `json:"serviceCode"` +} + +type qrisCreateRequest struct { + PartnerReferenceNo string `json:"partnerReferenceNo"` + ServiceCode string `json:"serviceCode"` + Amount amountDetails `json:"amount"` + AdditionalInfo map[string]any `json:"additionalInfo,omitempty"` +} diff --git a/packs/bisnap/virtual_account.go b/packs/bisnap/virtual_account.go new file mode 100644 index 0000000..1292483 --- /dev/null +++ b/packs/bisnap/virtual_account.go @@ -0,0 +1,15 @@ +package bisnap + +type vaStatusRequest struct { + OriginalPartnerReferenceNo string `json:"originalPartnerReferenceNo"` + ServiceCode string `json:"serviceCode"` +} + +type vaCreateRequest struct { + PartnerServiceId string `json:"partnerServiceId"` + CustomerNo string `json:"customerNo"` + TrxId string `json:"trxId"` + TotalAmount amountDetails `json:"totalAmount"` + ServiceCode string `json:"serviceCode"` + AdditionalInfo map[string]any `json:"additionalInfo,omitempty"` +} diff --git a/schemas/manifest-v1.schema.json b/schemas/manifest-v1.schema.json index 9894b8d..f46a449 100644 --- a/schemas/manifest-v1.schema.json +++ b/schemas/manifest-v1.schema.json @@ -122,8 +122,10 @@ "then": { "required": [ "client_id", + "client_secret", "partner_id", "channel_id", + "device_id", "private_key", "midtrans_public_key" ] @@ -164,6 +166,10 @@ "type": "string", "pattern": "^(env:[A-Z][A-Z0-9_]*|file:\\./(?:[A-Za-z0-9_][A-Za-z0-9._-]*)(?:/[A-Za-z0-9_][A-Za-z0-9._-]*)*)$" }, + "device_id": { + "type": "string", + "pattern": "^(env:[A-Z][A-Z0-9_]*|file:\\./(?:[A-Za-z0-9_][A-Za-z0-9._-]*)(?:/[A-Za-z0-9_][A-Za-z0-9._-]*)*)$" + }, "private_key": { "type": "string", "pattern": "^(env:[A-Z][A-Z0-9_]*|file:\\./(?:[A-Za-z0-9_][A-Za-z0-9._-]*)(?:/[A-Za-z0-9_][A-Za-z0-9._-]*)*)$" From fc00adf2b1303e229441fec52af8e8c3468db971 Mon Sep 17 00:00:00 2001 From: "m.salis" Date: Mon, 27 Jul 2026 12:03:21 +0700 Subject: [PATCH 48/73] fix: require BI-SNAP evidence for status verification --- .../task-9-report.md | 24 +- contracts/public-sources-v1.json | 122 ++++--- internal/app/commands_checkout.go | 13 +- internal/app/commands_test.go | 185 +++++++++++ internal/app/journey_runner.go | 41 ++- internal/journey/types.go | 1 + internal/sourceprovenance/baseline_test.go | 19 ++ internal/sourceprovenance/catalog.go | 17 + packs/bisnap/client.go | 18 ++ packs/bisnap/journey.go | 106 ++++++- packs/bisnap/journey_test.go | 298 +++++++++++++++++- packs/bisnap/pack.go | 8 +- tools/source-baseline/main.go | 9 +- tools/source-drift/main.go | 9 +- 14 files changed, 765 insertions(+), 105 deletions(-) create mode 100644 internal/sourceprovenance/catalog.go diff --git a/.superpowers/sdd/2026-07-26-midtrans-cli-multi-product-parity/task-9-report.md b/.superpowers/sdd/2026-07-26-midtrans-cli-multi-product-parity/task-9-report.md index 7f1b185..c4827a2 100644 --- a/.superpowers/sdd/2026-07-26-midtrans-cli-multi-product-parity/task-9-report.md +++ b/.superpowers/sdd/2026-07-26-midtrans-cli-multi-product-parity/task-9-report.md @@ -25,6 +25,13 @@ Implemented BI-SNAP pack registration and executable journeys for: - QRIS safe-display behavior - VA partner-service left padding - runtime credential resolution and B2B exchange +- Fix round 1 added: + - typed `evidence.Bundle` handoff on `journey.Request` for generic journey execution + - proof gating so BI-SNAP status `latestTransactionStatus: 00` never passes without verified evidence + - required pass proofs `bisnap.notification` and `bisnap.merchant-persistence` + - QR artifact fallback selection recorded as safe kind/reference only + - generic merchant `status` intent allowed without `--amount` + - BI-SNAP public sources included in tool aggregation and committed source baseline ## Validation @@ -40,6 +47,19 @@ GREEN/focused: go test ./packs/bisnap ./internal/app ./test/e2e ./internal/manifest -count=1 ``` +Fix round 1 focused: + +```sh +go test ./packs/bisnap ./internal/app ./internal/sourceprovenance ./internal/manifest ./test/e2e -count=1 +``` + +Requested fix round 1 validation: + +```sh +go test ./packs/bisnap ./internal/app ./internal/manifest ./test/e2e -count=1 +go test ./... -count=1 +``` + Full: ```sh @@ -50,9 +70,9 @@ All passed on July 27, 2026. ## Assumptions -- For BI-SNAP status payloads whose exact request-body field set was not fully pinned in the brief, I used a minimal typed request plus `additionalInfo` maps where needed instead of guessing a broader fixed schema. +- For BI-SNAP status payloads whose exact request-body field set was not fully pinned in the brief, I used minimal typed request bodies and only preserved explicit safe artifact decisions instead of storing raw QR payloads. - The implementation currently performs a fresh B2B token exchange per BI-SNAP API call within a journey run. Tokens are not persisted or logged. ## Concerns -- `contracts/public-sources-v1.json` was left unchanged. The repo already reports unrelated source-baseline drift across existing entries via `go run ./tools/source-drift --baseline contracts/public-sources-v1.json`, so regenerating that file in this task would have caused broad unrelated churn outside the BI-SNAP implementation scope. +- `contracts/public-sources-v1.json` was regenerated to include BI-SNAP sources. If unrelated future drift reappears, `go run ./tools/source-drift --baseline contracts/public-sources-v1.json` now covers BI-SNAP as well as the pre-existing packs. diff --git a/contracts/public-sources-v1.json b/contracts/public-sources-v1.json index 0dd8250..abc66c3 100644 --- a/contracts/public-sources-v1.json +++ b/contracts/public-sources-v1.json @@ -8,8 +8,8 @@ "snap.token.create", "snap.basic-auth" ], - "sha256": "d3ec2a0cdb48dbbaac4ea3befa52f63c38b199ae9175de5a1c9f5bef0b9a326a", - "retrieved_at": "2026-07-27T03:50:40.060693Z" + "sha256": "c3ba04532182ddd529a740e44d99012bcea372459fef23b96ef773106a8de3f4", + "retrieved_at": "2026-07-27T05:02:08.464875Z" }, { "id": "snap-js", @@ -18,8 +18,8 @@ "snap.checkout.popup", "snap.checkout.embed" ], - "sha256": "6e11f9887455c60c410c4144902720586eef50c609236021172097f9957072b8", - "retrieved_at": "2026-07-27T03:50:40.060693Z" + "sha256": "6e3eda531f18d081ac392cc1cbde5deafb73589448ab8e5fd8f32eb872e65b0e", + "retrieved_at": "2026-07-27T05:02:08.464875Z" }, { "id": "snap-integration", @@ -28,8 +28,8 @@ "snap.checkout.redirect", "snap.mobile.webview" ], - "sha256": "5ff69bfd753ab75f1f97784e6617400966371b57dc35f242b80aaca05da182be", - "retrieved_at": "2026-07-27T03:50:40.060693Z" + "sha256": "71d80ae1c1458fea76c593a138ffa164c9c194de549f7553687635cb01c440b8", + "retrieved_at": "2026-07-27T05:02:08.464875Z" }, { "id": "technical-faq", @@ -38,8 +38,8 @@ "snap.mobile.deeplink-return", "snap.mobile.real-device-proof" ], - "sha256": "5ef8562f1a6873b0abea8962a6c0daffced9fc1e7232ccb00a739afab7afb94f", - "retrieved_at": "2026-07-27T03:50:40.060693Z" + "sha256": "e2534e1633638fc6f23f014c6abf71441aaa1b71d3de1a98afecad9271f9e4be", + "retrieved_at": "2026-07-27T05:02:08.464875Z" }, { "id": "http-notifications", @@ -48,8 +48,8 @@ "snap.notification.signature", "common.webhook-idempotency" ], - "sha256": "33a4aa11866b61dd75f2feda776471b19069c3c90931a1fac2c92d6d159ef9de", - "retrieved_at": "2026-07-27T03:50:40.060693Z" + "sha256": "38d48e63e1748126258827be9540f025bd221b1623f627fa461ffe54528d8ccd", + "retrieved_at": "2026-07-27T05:02:08.464875Z" }, { "id": "get-transaction-status", @@ -58,8 +58,8 @@ "snap.status.reconcile", "snap.mobile.status.reconcile" ], - "sha256": "18df39352623ff1b17a0e0d86e3ab702e2a196d8fc95288dd9604870b6ac8960", - "retrieved_at": "2026-07-27T03:50:40.060693Z" + "sha256": "a91005e80e5c9c2cd9979c435f95e306435b5acebb67c6a9c065c947bde951b2", + "retrieved_at": "2026-07-27T05:02:08.464875Z" }, { "id": "coreapi-card-charge", @@ -68,8 +68,8 @@ "coreapi.card.charge", "coreapi.basic-auth" ], - "sha256": "4f5285ce983781e2b0b8d54f9f937452bad10aeb88f19d4a54a098521d7d60c6", - "retrieved_at": "2026-07-27T03:50:40.060693Z" + "sha256": "c4a28c4d715e9a784d82a30bbc2ea08d6625fe4a4abff7e2d44bbfaf183b78d7", + "retrieved_at": "2026-07-27T05:02:08.464875Z" }, { "id": "coreapi-card-3ds", @@ -78,8 +78,8 @@ "coreapi.card.3ds", "coreapi.card.redirect" ], - "sha256": "1d692482e67472e74aec333777b655a919c76ce0df9414f519f61d8f8ad6abdd", - "retrieved_at": "2026-07-27T03:50:40.060693Z" + "sha256": "ad4684366321b7e3ce63c0323d172a7f33e68507f7377256dc263f61cfaf1a2a", + "retrieved_at": "2026-07-27T05:02:08.464875Z" }, { "id": "coreapi-one-click", @@ -87,8 +87,8 @@ "rules": [ "coreapi.saved-card.token-only" ], - "sha256": "a6d8c2e029af48b1ba86751607507f462f9a649630d0b9e801d41663bec44321", - "retrieved_at": "2026-07-27T03:50:40.060693Z" + "sha256": "939154500068ed6239307325448f7023fa261fb47957a9925e5fd3ed2b20fd45", + "retrieved_at": "2026-07-27T05:02:08.464875Z" }, { "id": "coreapi-alfamart", @@ -97,8 +97,8 @@ "coreapi.otc.charge", "coreapi.otc.payment-code" ], - "sha256": "b9d702fe6803abadef28cbcec90bce2874db4e7af2224051f848223c78fe0e39", - "retrieved_at": "2026-07-27T03:50:40.060693Z" + "sha256": "b26c2474ba069baa2d7b773797a2d3cff4fa678efe5342b68592425d0a5d5235", + "retrieved_at": "2026-07-27T05:02:08.464875Z" }, { "id": "coreapi-bni-va", @@ -107,8 +107,8 @@ "coreapi.va.charge", "coreapi.va.instructions" ], - "sha256": "d3941c35f810e85f40fc543dbaff545499debd45c06d17bf493df31cb7706167", - "retrieved_at": "2026-07-27T03:50:40.060693Z" + "sha256": "ad6910f9758488421dab28b33900a6b7cad169e6cbe4953c8625cc33fc842961", + "retrieved_at": "2026-07-27T05:02:08.464875Z" }, { "id": "coreapi-status", @@ -117,8 +117,8 @@ "coreapi.status.reconcile", "coreapi.refund.status" ], - "sha256": "18df39352623ff1b17a0e0d86e3ab702e2a196d8fc95288dd9604870b6ac8960", - "retrieved_at": "2026-07-27T03:50:40.060693Z" + "sha256": "a91005e80e5c9c2cd9979c435f95e306435b5acebb67c6a9c065c947bde951b2", + "retrieved_at": "2026-07-27T05:02:08.464875Z" }, { "id": "coreapi-refund", @@ -127,8 +127,8 @@ "coreapi.refund.async", "coreapi.refund.idempotency" ], - "sha256": "8f7622a76a344b11df0d76b658561ef1b09e9b04f422b27d5ced8e1b0e205da8", - "retrieved_at": "2026-07-27T03:50:40.060693Z" + "sha256": "27be8dfec8e3a9fbd0aa8915025beabc4b585055e0e4066e6bf5dff0425af10f", + "retrieved_at": "2026-07-27T05:02:08.464875Z" }, { "id": "coreapi-direct-refund", @@ -136,8 +136,8 @@ "rules": [ "coreapi.refund.direct" ], - "sha256": "15b9be4ffe2bbceeee8d47645a42ff8c32c61750c9cb44ce27637c4c6afe74be", - "retrieved_at": "2026-07-27T03:50:40.060693Z" + "sha256": "b9abecbbd9642b758034f241bb7b994bcd62f47000f75cf06f0a4f5ac7992fd7", + "retrieved_at": "2026-07-27T05:02:08.464875Z" }, { "id": "coreapi-notifications", @@ -146,8 +146,8 @@ "coreapi.notification.signature", "common.webhook-idempotency" ], - "sha256": "33a4aa11866b61dd75f2feda776471b19069c3c90931a1fac2c92d6d159ef9de", - "retrieved_at": "2026-07-27T03:50:40.060693Z" + "sha256": "38d48e63e1748126258827be9540f025bd221b1623f627fa461ffe54528d8ccd", + "retrieved_at": "2026-07-27T05:02:08.464875Z" }, { "id": "payment-link-overview", @@ -156,8 +156,8 @@ "paymentlink.create", "paymentlink.reusable" ], - "sha256": "fddbb1cef82fd3d8e41c8f72936bb05972bc7941be76a942664fa513aefeee59", - "retrieved_at": "2026-07-27T03:50:40.060693Z" + "sha256": "1cfceda094f7de9b3a042f95acae38ca78b72eff0f051513bcd6068c94a20ab6", + "retrieved_at": "2026-07-27T05:02:08.464875Z" }, { "id": "payment-link-status", @@ -165,8 +165,8 @@ "rules": [ "paymentlink.status.reconcile" ], - "sha256": "18df39352623ff1b17a0e0d86e3ab702e2a196d8fc95288dd9604870b6ac8960", - "retrieved_at": "2026-07-27T03:50:40.060693Z" + "sha256": "a91005e80e5c9c2cd9979c435f95e306435b5acebb67c6a9c065c947bde951b2", + "retrieved_at": "2026-07-27T05:02:08.464875Z" }, { "id": "payment-link-notifications", @@ -175,8 +175,58 @@ "paymentlink.notification.signature", "common.webhook-idempotency" ], - "sha256": "33a4aa11866b61dd75f2feda776471b19069c3c90931a1fac2c92d6d159ef9de", - "retrieved_at": "2026-07-27T03:50:40.060693Z" + "sha256": "38d48e63e1748126258827be9540f025bd221b1623f627fa461ffe54528d8ccd", + "retrieved_at": "2026-07-27T05:02:08.464875Z" + }, + { + "id": "bisnap-overview", + "url": "https://docs.midtrans.com/reference/core-api-snap-open-api-overview", + "rules": [ + "bisnap.signing.verify.v1" + ], + "sha256": "42b94bb13823e0e3122a5ec5e73f3a002a969911d05230022c0f08eed9b36288", + "retrieved_at": "2026-07-27T05:02:08.464875Z" + }, + { + "id": "bisnap-qris", + "url": "https://docs.midtrans.com/reference/mpm-api-qris", + "rules": [ + "bisnap.qris.create", + "bisnap.qris.status" + ], + "sha256": "d298987413b18d45a7c3015b233ca364b6625f785a9be75f7866862bfa0f8a65", + "retrieved_at": "2026-07-27T05:02:08.464875Z" + }, + { + "id": "bisnap-virtual-account", + "url": "https://docs.midtrans.com/reference/virtual-account-api-bank-transfer", + "rules": [ + "bisnap.virtual-account.create", + "bisnap.virtual-account.status" + ], + "sha256": "db4f536cec443b37688f67a4e8495e8d22ca159644a3113141ee822806a0c69a", + "retrieved_at": "2026-07-27T05:02:08.464875Z" + }, + { + "id": "bisnap-direct-debit", + "url": "https://docs.midtrans.com/reference/direct-debit-api-gopay", + "rules": [ + "bisnap.direct-debit.create", + "bisnap.direct-debit.status", + "bisnap.refund" + ], + "sha256": "7816e56ae9fb3d87d8e36d18b7d03f297a08808292b1ae4555841b2b041082b1", + "retrieved_at": "2026-07-27T05:02:08.464875Z" + }, + { + "id": "bisnap-notifications", + "url": "https://docs.midtrans.com/reference/payment-notification-api", + "rules": [ + "bisnap.notification.signature", + "common.webhook-idempotency" + ], + "sha256": "3b61e701807e10b9f954ef43aa5871e7c8172036b2163c16cda23b8d91b65a80", + "retrieved_at": "2026-07-27T05:02:08.464875Z" } ] } diff --git a/internal/app/commands_checkout.go b/internal/app/commands_checkout.go index 877950f..1eea9b3 100644 --- a/internal/app/commands_checkout.go +++ b/internal/app/commands_checkout.go @@ -39,6 +39,7 @@ type merchantJourneyFlags struct { product string orderID string method string + evidencePath string customerReference string paymentTokenReference string amount int64 @@ -55,6 +56,7 @@ func (f *merchantJourneyFlags) bind(command *cobra.Command) { command.Flags().Int64Var(&f.amount, "amount", 0, "Sandbox amount in IDR") command.Flags().StringVar(&f.orderID, "order-id", "", "existing merchant order reference") command.Flags().StringVar(&f.method, "method", "", "payment method") + command.Flags().StringVar(&f.evidencePath, "evidence", "", "checksummed evidence JSON file") command.Flags().StringVar(&f.customerReference, "customer-reference", "", "safe customer reference") command.Flags().StringVar(&f.paymentTokenReference, "payment-token-reference", "", "safe payment token reference") command.Flags().BoolVar(&f.reusable, "reusable", false, "request a reusable payment resource") @@ -100,7 +102,7 @@ func runMerchantJourney( intent string, ) contracts.Result { commandName := request.commandNameForIntent(intent) - if request.amount <= 0 { + if request.amount <= 0 && intent != "status" { result := contracts.NewResult(commandName, contracts.StatusBlocked) result.CLIVersion = deps.Version.Version result.Findings = []contracts.Finding{{ @@ -115,10 +117,11 @@ func runMerchantJourney( orderID = deps.NewOrderID() } journeyRequest := journeyRunRequest{ - Command: commandName, - ProjectDir: flags.projectDir, - Intent: intent, - Product: request.product, + Command: commandName, + ProjectDir: flags.projectDir, + Intent: intent, + Product: request.product, + EvidencePath: request.evidencePath, Input: journeyInput( orderID, request.amount, diff --git a/internal/app/commands_test.go b/internal/app/commands_test.go index 11f2b23..92fc381 100644 --- a/internal/app/commands_test.go +++ b/internal/app/commands_test.go @@ -3,6 +3,8 @@ package app_test import ( "bytes" "context" + "crypto/sha256" + "encoding/hex" "net/http" "net/http/httptest" "os" @@ -11,9 +13,11 @@ import ( "regexp" "strings" "testing" + "time" "github.com/veritrans/midtrans-cli/internal/app" "github.com/veritrans/midtrans-cli/internal/contracts" + "github.com/veritrans/midtrans-cli/internal/evidence" "github.com/veritrans/midtrans-cli/internal/inspection" "github.com/veritrans/midtrans-cli/internal/journey" "github.com/veritrans/midtrans-cli/internal/manifest" @@ -255,6 +259,114 @@ func TestMerchantPaymentLinkIntentExecutesThroughGenericJourneyRuntime(t *testin } } +func TestMerchantBISNAPStatusIntentDoesNotRequireAmountButRemainsBlockedWithoutProofs(t *testing.T) { + project := createJourneyProject(t, "http://127.0.0.1:1") + configureManifest(t, project, func(value *manifest.Manifest) { + value.CredentialSets["bisnap"] = manifest.CredentialSet{ + Type: "bisnap", + Environment: "sandbox", + ClientID: "env:MIDTRANS_BISNAP_CLIENT_ID", + ClientSecret: "env:MIDTRANS_BISNAP_CLIENT_SECRET", + PartnerID: "env:MIDTRANS_BISNAP_PARTNER_ID", + ChannelID: "env:MIDTRANS_BISNAP_CHANNEL_ID", + DeviceID: "env:MIDTRANS_BISNAP_DEVICE_ID", + PrivateKey: "file:./secrets/bisnap-private.pem", + MidtransPublicKey: "file:./secrets/bisnap-public.pem", + } + value.Integrations["bisnap"] = manifest.Integration{ + ConfigVersion: 1, + Credentials: "bisnap", + Callbacks: map[string]string{"notification": "/midtrans/bisnap/notification"}, + } + value.Routing["status"] = "bisnap" + }) + result, exit := executeJSONWithDependencies( + t, + app.Dependencies{ + Version: version.Info{Version: "0.1.0-test"}, + Packs: testRegistry(t), + ResolveCredential: bisnapCredentialResolverForAppTests(), + HTTP: appDoerFunc(func(request *http.Request) (*http.Response, error) { + switch request.URL.Path { + case "/v1.0/access-token/b2b": + return journeyHTTPResponse(http.StatusOK, []byte(`{"accessToken":"ACCESS-TOKEN-CANARY-DO-NOT-PRINT"}`)), nil + case "/v1.0/qr/qr-mpm-query": + return journeyHTTPResponse(http.StatusOK, []byte(`{"responseCode":"2005100","latestTransactionStatus":"00","referenceNo":"provider-status-001"}`)), nil + default: + t.Fatalf("unexpected request path: %s", request.URL.Path) + return nil, nil + } + }), + }, + "test", "status", "--method", "qris", "--order-id", "order-status", "--execute", "--project-dir", project, + ) + if exit != 3 || result.Command != "test.status" { + t.Fatalf("exit = %d, result = %#v", exit, result) + } + if len(result.Findings) == 0 || result.Findings[0].Code != "BISNAP_EVIDENCE_REQUIRED" { + t.Fatalf("findings = %#v", result.Findings) + } + if requireJourneyData(t, result)["state"] == "verified" { + t.Fatalf("result = %#v", result) + } +} + +func TestMerchantBISNAPStatusIntentPassesWithEvidenceBundle(t *testing.T) { + project := createJourneyProject(t, "http://127.0.0.1:1") + configureManifest(t, project, func(value *manifest.Manifest) { + value.CredentialSets["bisnap"] = manifest.CredentialSet{ + Type: "bisnap", + Environment: "sandbox", + ClientID: "env:MIDTRANS_BISNAP_CLIENT_ID", + ClientSecret: "env:MIDTRANS_BISNAP_CLIENT_SECRET", + PartnerID: "env:MIDTRANS_BISNAP_PARTNER_ID", + ChannelID: "env:MIDTRANS_BISNAP_CHANNEL_ID", + DeviceID: "env:MIDTRANS_BISNAP_DEVICE_ID", + PrivateKey: "file:./secrets/bisnap-private.pem", + MidtransPublicKey: "file:./secrets/bisnap-public.pem", + } + value.Integrations["bisnap"] = manifest.Integration{ + ConfigVersion: 1, + Credentials: "bisnap", + Callbacks: map[string]string{"notification": "/midtrans/bisnap/notification"}, + } + value.Routing["status"] = "bisnap" + }) + evidencePath := writeBISNAPEvidenceBundle( + t, + project, + operations.CanonicalOperationID("bisnap.status:order-status"), + "pass", + "pass", + ) + result, exit := executeJSONWithDependencies( + t, + app.Dependencies{ + Version: version.Info{Version: "0.1.0-test"}, + Packs: testRegistry(t), + ResolveCredential: bisnapCredentialResolverForAppTests(), + HTTP: appDoerFunc(func(request *http.Request) (*http.Response, error) { + switch request.URL.Path { + case "/v1.0/access-token/b2b": + return journeyHTTPResponse(http.StatusOK, []byte(`{"accessToken":"ACCESS-TOKEN-CANARY-DO-NOT-PRINT"}`)), nil + case "/v1.0/qr/qr-mpm-query": + return journeyHTTPResponse(http.StatusOK, []byte(`{"responseCode":"2005100","latestTransactionStatus":"00","referenceNo":"provider-status-001"}`)), nil + default: + t.Fatalf("unexpected request path: %s", request.URL.Path) + return nil, nil + } + }), + }, + "test", "status", "--method", "qris", "--order-id", "order-status", "--evidence", evidencePath, "--execute", "--project-dir", project, + ) + if exit != 0 { + t.Fatalf("exit = %d, result = %#v", exit, result) + } + if requireJourneyData(t, result)["state"] != "verified" { + t.Fatalf("result = %#v", result) + } +} + func TestMerchantGenericIntentInvalidAmountUsesIntentDerivedCommandIdentity(t *testing.T) { project := createJourneyProject(t, "http://127.0.0.1:1") configureManifest(t, project, func(value *manifest.Manifest) { @@ -288,6 +400,79 @@ func TestMerchantGenericIntentInvalidAmountUsesIntentDerivedCommandIdentity(t *t } } +func bisnapCredentialResolverForAppTests() func(context.Context, string, string) ([]byte, error) { + return func(_ context.Context, _ string, reference string) ([]byte, error) { + switch reference { + case "env:MIDTRANS_BISNAP_CLIENT_ID": + return []byte("CLIENT-ID-CANARY-DO-NOT-PRINT"), nil + case "env:MIDTRANS_BISNAP_CLIENT_SECRET": + return []byte("CLIENT-SECRET-CANARY-DO-NOT-PRINT"), nil + case "env:MIDTRANS_BISNAP_PARTNER_ID": + return []byte("G123456"), nil + case "env:MIDTRANS_BISNAP_CHANNEL_ID": + return []byte("12345"), nil + case "env:MIDTRANS_BISNAP_DEVICE_ID": + return []byte("device-canary"), nil + case "file:./secrets/bisnap-private.pem": + return os.ReadFile(filepath.Join("..", "..", "testdata", "bisnap", "private_key_pkcs8.pem")) + case "file:./secrets/bisnap-public.pem": + return os.ReadFile(filepath.Join("..", "..", "testdata", "bisnap", "public_key_pkix.pem")) + default: + return nil, os.ErrNotExist + } + } +} + +func writeBISNAPEvidenceBundle(t *testing.T, project, operationID, notificationStatus, persistenceStatus string) string { + t.Helper() + now := time.Now().UTC() + manifestBytes, err := os.ReadFile(filepath.Join(project, ".midtrans", "manifest.yaml")) + if err != nil { + t.Fatal(err) + } + sum := sha256.Sum256(manifestBytes) + path, err := (evidence.Store{ProjectDir: project}).Write(evidence.Bundle{ + SchemaVersion: evidence.SchemaVersion, + CLIVersion: "0.1.0-test", + ManifestVersion: 1, + PackID: "bisnap", + PackVersion: "0.1.0", + ManifestHash: hex.EncodeToString(sum[:]), + RepositoryCommit: strings.Repeat("a", 40), + Journey: "bisnap.status", + Environment: "sandbox", + StartedAt: now.Add(-time.Second), + CompletedAt: now, + SafeReferences: map[string]string{"order_id": "order-status"}, + Proofs: []evidence.Proof{ + { + ID: "bisnap.notification", + OperationID: operationID, + Stage: "provider_notification", + Level: evidence.ProofSandbox, + Source: "midtrans_notification", + ObservedAt: now, + Status: notificationStatus, + Summary: map[string]any{"route": "/v1.0/qr/qr-mpm-notify"}, + }, + { + ID: "bisnap.merchant-persistence", + OperationID: operationID, + Stage: "merchant_persistence", + Level: evidence.ProofLocal, + Source: "merchant_application", + ObservedAt: now, + Status: persistenceStatus, + Summary: map[string]any{"payment_status": "paid"}, + }, + }, + }) + if err != nil { + t.Fatal(err) + } + return path +} + func TestGenericJourneyResultPreservesReservedEnvelopeFieldsAgainstMaliciousSafeData(t *testing.T) { project := createJourneyProject(t, "http://127.0.0.1:1") configureManifest(t, project, func(value *manifest.Manifest) { diff --git a/internal/app/journey_runner.go b/internal/app/journey_runner.go index b612606..148d5b1 100644 --- a/internal/app/journey_runner.go +++ b/internal/app/journey_runner.go @@ -7,20 +7,22 @@ import ( "time" "github.com/veritrans/midtrans-cli/internal/contracts" + "github.com/veritrans/midtrans-cli/internal/evidence" journeypkg "github.com/veritrans/midtrans-cli/internal/journey" "github.com/veritrans/midtrans-cli/internal/manifest" "github.com/veritrans/midtrans-cli/internal/operations" ) type journeyRunRequest struct { - Command string - ProjectDir string - JourneyID string - Intent string - Product string - OperationID string - Input journeypkg.Input - Execute bool + Command string + ProjectDir string + JourneyID string + Intent string + Product string + EvidencePath string + OperationID string + Input journeypkg.Input + Execute bool } func runGenericJourney( @@ -70,11 +72,28 @@ func runGenericJourney( SensitiveKeys: deps.Packs.SensitiveKeys(), }, } + var bundle evidence.Bundle + if request.EvidencePath != "" { + loaded, err := (evidence.Store{ProjectDir: request.ProjectDir}).Read(request.EvidencePath) + if err != nil { + result := contracts.NewResult(request.Command, contracts.StatusError) + result.CLIVersion = deps.Version.Version + result.ManifestVersion = value.SchemaVersion + result.Findings = []contracts.Finding{{ + Code: "EVIDENCE_INVALID", + Severity: "blocking", + Message: "evidence could not be read, validated, or written safely", + }} + return result + } + bundle = loaded + } journeyRequest := journeypkg.Request{ OperationID: request.OperationID, ProjectDir: request.ProjectDir, ManifestHash: manifestHash, Manifest: value, + Evidence: bundle, Input: request.Input, } var outcome journeypkg.Outcome @@ -220,6 +239,12 @@ func genericJourneyResult( if outcome.Action != nil { data["action"] = outcome.Action } + if len(outcome.MissingEvidence) != 0 && outcome.State != journeypkg.Passed { + result.NextActions = []contracts.NextAction{{ + Action: "provide_evidence_bundle", + Description: "rerun this journey with --evidence after collecting the missing proof", + }} + } return result } diff --git a/internal/journey/types.go b/internal/journey/types.go index cc1e226..0f40cd7 100644 --- a/internal/journey/types.go +++ b/internal/journey/types.go @@ -45,6 +45,7 @@ type Request struct { ProjectDir string ManifestHash string Manifest manifest.Manifest + Evidence evidence.Bundle Input Input } diff --git a/internal/sourceprovenance/baseline_test.go b/internal/sourceprovenance/baseline_test.go index 13391ea..0b8b010 100644 --- a/internal/sourceprovenance/baseline_test.go +++ b/internal/sourceprovenance/baseline_test.go @@ -141,3 +141,22 @@ func TestChangedSourceIDsDoesNotExposeDigests(t *testing.T) { t.Fatalf("ChangedSourceIDs = %#v, want [source-a]", got) } } + +func TestAllPublicSourcesIncludesBISNAPPackEntries(t *testing.T) { + sources := AllPublicSources() + ids := make(map[string]bool, len(sources)) + for _, source := range sources { + ids[source.ID] = true + } + for _, id := range []string{ + "bisnap-overview", + "bisnap-qris", + "bisnap-virtual-account", + "bisnap-direct-debit", + "bisnap-notifications", + } { + if !ids[id] { + t.Fatalf("missing source %q in aggregated catalog", id) + } + } +} diff --git a/internal/sourceprovenance/catalog.go b/internal/sourceprovenance/catalog.go new file mode 100644 index 0000000..c169b46 --- /dev/null +++ b/internal/sourceprovenance/catalog.go @@ -0,0 +1,17 @@ +package sourceprovenance + +import ( + "github.com/veritrans/midtrans-cli/internal/contracts" + "github.com/veritrans/midtrans-cli/packs/bisnap" + "github.com/veritrans/midtrans-cli/packs/coreapi" + "github.com/veritrans/midtrans-cli/packs/paymentlink" + "github.com/veritrans/midtrans-cli/packs/snap" +) + +func AllPublicSources() []contracts.PublicSource { + sources := append([]contracts.PublicSource{}, snap.New().Descriptor().Sources...) + sources = append(sources, coreapi.New().Descriptor().Sources...) + sources = append(sources, paymentlink.New().Descriptor().Sources...) + sources = append(sources, bisnap.New().Descriptor().Sources...) + return sources +} diff --git a/packs/bisnap/client.go b/packs/bisnap/client.go index 69f967b..7c8820b 100644 --- a/packs/bisnap/client.go +++ b/packs/bisnap/client.go @@ -57,6 +57,7 @@ type CreateResponse struct { ActionURL string VirtualAccountNo string PartnerServiceID string + QRArtifactKind string } type StatusRequest struct { @@ -293,6 +294,9 @@ func (c Client) Create(ctx context.Context, input CreateRequest) (CreateResponse PartnerReferenceNo string `json:"partnerReferenceNo"` ReferenceNo string `json:"referenceNo"` WebRedirectURL string `json:"webRedirectUrl"` + QRURL string `json:"qrUrl"` + QRImage string `json:"qrImage"` + QRContent string `json:"qrContent"` VirtualAccountNo string `json:"virtualAccountNo"` PartnerServiceID string `json:"partnerServiceId"` TrxID string `json:"trxId"` @@ -307,6 +311,7 @@ func (c Client) Create(ctx context.Context, input CreateRequest) (CreateResponse ActionURL: result.WebRedirectURL, VirtualAccountNo: result.VirtualAccountNo, PartnerServiceID: result.PartnerServiceID, + QRArtifactKind: preferredQRArtifactKind(result.QRURL, result.QRImage, result.QRContent), }, nil } @@ -454,3 +459,16 @@ func isTimeoutError(err error) bool { var timeout interface{ Timeout() bool } return errors.As(err, &timeout) && timeout.Timeout() } + +func preferredQRArtifactKind(qrURL, qrImage, qrContent string) string { + switch { + case qrURL != "": + return "qr_url" + case qrImage != "": + return "qr_image" + case qrContent != "": + return "qr_content" + default: + return "" + } +} diff --git a/packs/bisnap/journey.go b/packs/bisnap/journey.go index a465f9a..23d6b75 100644 --- a/packs/bisnap/journey.go +++ b/packs/bisnap/journey.go @@ -8,6 +8,7 @@ import ( "time" "github.com/veritrans/midtrans-cli/internal/contracts" + "github.com/veritrans/midtrans-cli/internal/evidence" journey "github.com/veritrans/midtrans-cli/internal/journey" "github.com/veritrans/midtrans-cli/internal/operations" "github.com/veritrans/midtrans-cli/internal/sandbox" @@ -159,7 +160,7 @@ func runStatus(ctx context.Context, request journey.Request, runner JourneyRunne }, } } - return evaluateStatus(status) + return evaluateVerifiedStatus(request, status) } func runRefund(ctx context.Context, request journey.Request, runner JourneyRunner) journey.Outcome { @@ -199,6 +200,10 @@ func (h Handler) awaitingActionOutcome(request journey.Request, created CreateRe } switch h.definition.Intent { case "qris-payment": + if created.QRArtifactKind != "" { + safeData["qr_artifact_kind"] = created.QRArtifactKind + safeData["qr_artifact_reference"] = "provider_generated" + } return journey.Outcome{ State: journey.AwaitingUserAction, SafeData: safeData, @@ -237,25 +242,92 @@ func evaluateStatus(status StatusResponse) journey.Outcome { if status.OrderID == "" { return blockedOutcome("provider status was invalid") } - switch status.LatestTransactionStatus { - case "00": - return journey.Outcome{ - State: journey.Passed, - SafeData: map[string]any{ - "order_id": status.OrderID, - "status_code": status.ResponseCode, - "provider_reference": status.ProviderReference, - }, + return journey.Outcome{ + State: journey.Reconciling, + SafeData: map[string]any{ + "order_id": status.OrderID, + "provider_reference": status.ProviderReference, + }, + } +} + +func evaluateVerifiedStatus(request journey.Request, status StatusResponse) journey.Outcome { + base := journey.Outcome{ + State: journey.Reconciling, + SafeData: map[string]any{ + "order_id": status.OrderID, + "provider_reference": status.ProviderReference, + "status_code": status.ResponseCode, + }, + MissingEvidence: []string{"bisnap.notification", "bisnap.merchant-persistence"}, + Finding: &contracts.Finding{ + Code: "BISNAP_EVIDENCE_REQUIRED", + Severity: "blocking", + Message: "latestTransactionStatus 00 requires verified BI-SNAP notification proof and merchant persistence proof", + }, + } + if status.OrderID == "" { + return blockedOutcome("provider status was invalid") + } + if status.LatestTransactionStatus != "00" { + base.MissingEvidence = nil + base.Finding = nil + return evaluateStatus(status) + } + proofs, ok := validatedStatusProofs(request, status.OrderID) + if !ok { + return base + } + return journey.Outcome{ + State: journey.Passed, + SafeData: base.SafeData, + Proofs: proofs, + } +} + +func validatedStatusProofs(request journey.Request, orderID string) ([]evidence.Proof, bool) { + bundle := request.Evidence + if bundle.SchemaVersion == "" { + return nil, false + } + if err := evidence.Validate(bundle); err != nil { + return nil, false + } + if bundle.Environment != "sandbox" || + bundle.ManifestVersion != 1 || + bundle.ManifestHash != request.ManifestHash || + bundle.PackID != "bisnap" || + (bundle.Journey != "bisnap.status" && bundle.Journey != "bisnap.qris-payment" && bundle.Journey != "bisnap.virtual-account" && bundle.Journey != "bisnap.direct-debit") || + bundle.SafeReferences["order_id"] != orderID { + return nil, false + } + var notificationProof *evidence.Proof + var persistenceProof *evidence.Proof + for _, proof := range bundle.Proofs { + if proof.OperationID != request.OperationID { + continue } - default: - return journey.Outcome{ - State: journey.Reconciling, - SafeData: map[string]any{ - "order_id": status.OrderID, - "provider_reference": status.ProviderReference, - }, + switch proof.ID { + case "bisnap.notification": + if proof.Level == evidence.ProofSandbox && proof.Status == "pass" { + proofCopy := proof + notificationProof = &proofCopy + } else { + return nil, false + } + case "bisnap.merchant-persistence": + if proof.Level == evidence.ProofLocal && proof.Status == "pass" { + proofCopy := proof + persistenceProof = &proofCopy + } else { + return nil, false + } } } + if notificationProof == nil || persistenceProof == nil { + return nil, false + } + return []evidence.Proof{*notificationProof, *persistenceProof}, true } func (h Handler) runtimeRunner(ctx context.Context, request journey.Request, runtime journey.Runtime) (JourneyRunner, *journey.Outcome) { diff --git a/packs/bisnap/journey_test.go b/packs/bisnap/journey_test.go index 8123b72..46a475b 100644 --- a/packs/bisnap/journey_test.go +++ b/packs/bisnap/journey_test.go @@ -2,12 +2,17 @@ package bisnap_test import ( "context" + "crypto/sha256" + "encoding/hex" "io" "net/http" + "os" + "path/filepath" "strings" "testing" "time" + "github.com/veritrans/midtrans-cli/internal/evidence" journeypkg "github.com/veritrans/midtrans-cli/internal/journey" "github.com/veritrans/midtrans-cli/internal/manifest" "github.com/veritrans/midtrans-cli/packs/bisnap" @@ -92,6 +97,70 @@ func TestQRISJourneyUsesProductStatusEndpointAndReturnsSimulatorAction(t *testin } } +func TestQRISJourneyRecordsSafeArtifactPreferenceAcrossFallbacks(t *testing.T) { + tests := []struct { + name string + body string + wantKind string + }{ + { + name: "prefers qrUrl", + body: `{"responseCode":"2004700","partnerReferenceNo":"partner-qris-001","qrUrl":"https://api.sandbox.midtrans.com/v2/qris/qr-001","qrImage":"https://api.sandbox.midtrans.com/v2/qris/qr-001.png","qrContent":"000201"}`, + wantKind: "qr_url", + }, + { + name: "falls back to qrImage", + body: `{"responseCode":"2004700","partnerReferenceNo":"partner-qris-001","qrImage":"https://api.sandbox.midtrans.com/v2/qris/qr-001.png","qrContent":"000201"}`, + wantKind: "qr_image", + }, + { + name: "falls back to qrContent", + body: `{"responseCode":"2004700","partnerReferenceNo":"partner-qris-001","qrContent":"000201"}`, + wantKind: "qr_content", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + handler := bisnap.NewQRISHandler() + outcome := handler.Execute(context.Background(), bisnapRequest("order-qris-fallback", 12500, "qris"), journeypkg.Runtime{ + ResolveCredential: bisnapResolveCredential(t), + HTTP: appDoerFunc(func(request *http.Request) (*http.Response, error) { + switch request.URL.Path { + case "/v1.0/access-token/b2b": + return bisnapResponse(http.StatusOK, `{"accessToken":"ACCESS-TOKEN-CANARY-DO-NOT-PRINT"}`), nil + case "/v1.0/qr/qr-mpm-query": + return bisnapResponse(http.StatusNotFound, `{"responseCode":"4044701"}`), nil + case "/v1.0/qr/qr-mpm-generate": + return bisnapResponse(http.StatusOK, test.body), nil + default: + t.Fatalf("unexpected path %q", request.URL.Path) + return nil, nil + } + }), + }) + if outcome.State != journeypkg.AwaitingUserAction { + t.Fatalf("outcome = %#v", outcome) + } + if outcome.SafeData["qr_artifact_kind"] != test.wantKind { + t.Fatalf("safe data = %#v", outcome.SafeData) + } + if outcome.SafeData["qr_artifact_reference"] != "provider_generated" { + t.Fatalf("safe data = %#v", outcome.SafeData) + } + if _, ok := outcome.SafeData["qr_url"]; ok { + t.Fatalf("safe data leaked qr_url: %#v", outcome.SafeData) + } + if _, ok := outcome.SafeData["qr_image"]; ok { + t.Fatalf("safe data leaked qr_image: %#v", outcome.SafeData) + } + if _, ok := outcome.SafeData["qr_content"]; ok { + t.Fatalf("safe data leaked qr_content: %#v", outcome.SafeData) + } + }) + } +} + func TestVirtualAccountJourneyUsesVAStatusEndpointAndStoresSafeDisplayFacts(t *testing.T) { var requests []*http.Request var bodies []string @@ -240,6 +309,135 @@ func TestRefundJourneyUsesDebitRefundEndpointAndStableReference(t *testing.T) { } } +func TestStatusJourneyDoesNotPassLatestTransactionStatusWithoutProofs(t *testing.T) { + handler := bisnap.NewStatusHandler() + outcome := handler.Execute(context.Background(), journeypkg.Request{ + OperationID: "operation-status", + ProjectDir: "/merchant", + ManifestHash: "manifest-hash", + Manifest: validBISNAPManifest(), + Input: journeypkg.Input{ + OrderID: "order-status", + Method: "qris", + }, + }, journeypkg.Runtime{ + ResolveCredential: bisnapResolveCredential(t), + HTTP: appDoerFunc(func(request *http.Request) (*http.Response, error) { + switch request.URL.Path { + case "/v1.0/access-token/b2b": + return bisnapResponse(http.StatusOK, `{"accessToken":"ACCESS-TOKEN-CANARY-DO-NOT-PRINT"}`), nil + case "/v1.0/qr/qr-mpm-query": + return bisnapResponse(http.StatusOK, `{"responseCode":"2005100","latestTransactionStatus":"00","referenceNo":"provider-status-001"}`), nil + default: + t.Fatalf("unexpected path %q", request.URL.Path) + return nil, nil + } + }), + }) + if outcome.State == journeypkg.Passed { + t.Fatalf("outcome = %#v", outcome) + } + if len(outcome.MissingEvidence) != 2 { + t.Fatalf("missing evidence = %#v", outcome.MissingEvidence) + } +} + +func TestStatusJourneyPassesWithVerifiedEvidenceProofs(t *testing.T) { + projectDir := createBISNAPEvidenceProject(t) + handler := bisnap.NewStatusHandler() + outcome := handler.Execute(context.Background(), journeypkg.Request{ + OperationID: "operation-status", + ProjectDir: projectDir, + ManifestHash: manifestHashForProject(t, projectDir), + Manifest: validBISNAPManifest(), + Evidence: verifiedBISNAPEvidenceBundle(t, projectDir, "pass", "pass"), + Input: journeypkg.Input{ + OrderID: "order-status", + Method: "qris", + }, + }, journeypkg.Runtime{ + ResolveCredential: bisnapResolveCredential(t), + HTTP: appDoerFunc(func(request *http.Request) (*http.Response, error) { + switch request.URL.Path { + case "/v1.0/access-token/b2b": + return bisnapResponse(http.StatusOK, `{"accessToken":"ACCESS-TOKEN-CANARY-DO-NOT-PRINT"}`), nil + case "/v1.0/qr/qr-mpm-query": + return bisnapResponse(http.StatusOK, `{"responseCode":"2005100","latestTransactionStatus":"00","referenceNo":"provider-status-001"}`), nil + default: + t.Fatalf("unexpected path %q", request.URL.Path) + return nil, nil + } + }), + }) + if outcome.State != journeypkg.Passed { + t.Fatalf("outcome = %#v", outcome) + } +} + +func TestStatusJourneyDoesNotPassWithFailedNotificationProof(t *testing.T) { + projectDir := createBISNAPEvidenceProject(t) + handler := bisnap.NewStatusHandler() + outcome := handler.Execute(context.Background(), journeypkg.Request{ + OperationID: "operation-status", + ProjectDir: projectDir, + ManifestHash: manifestHashForProject(t, projectDir), + Manifest: validBISNAPManifest(), + Evidence: verifiedBISNAPEvidenceBundle(t, projectDir, "fail", "pass"), + Input: journeypkg.Input{ + OrderID: "order-status", + Method: "qris", + }, + }, journeypkg.Runtime{ + ResolveCredential: bisnapResolveCredential(t), + HTTP: appDoerFunc(func(request *http.Request) (*http.Response, error) { + switch request.URL.Path { + case "/v1.0/access-token/b2b": + return bisnapResponse(http.StatusOK, `{"accessToken":"ACCESS-TOKEN-CANARY-DO-NOT-PRINT"}`), nil + case "/v1.0/qr/qr-mpm-query": + return bisnapResponse(http.StatusOK, `{"responseCode":"2005100","latestTransactionStatus":"00","referenceNo":"provider-status-001"}`), nil + default: + t.Fatalf("unexpected path %q", request.URL.Path) + return nil, nil + } + }), + }) + if outcome.State == journeypkg.Passed { + t.Fatalf("outcome = %#v", outcome) + } +} + +func TestStatusJourneyRequiresMerchantPersistenceProof(t *testing.T) { + projectDir := createBISNAPEvidenceProject(t) + handler := bisnap.NewStatusHandler() + outcome := handler.Execute(context.Background(), journeypkg.Request{ + OperationID: "operation-status", + ProjectDir: projectDir, + ManifestHash: manifestHashForProject(t, projectDir), + Manifest: validBISNAPManifest(), + Evidence: verifiedBISNAPEvidenceBundle(t, projectDir, "pass", "blocked"), + Input: journeypkg.Input{ + OrderID: "order-status", + Method: "qris", + }, + }, journeypkg.Runtime{ + ResolveCredential: bisnapResolveCredential(t), + HTTP: appDoerFunc(func(request *http.Request) (*http.Response, error) { + switch request.URL.Path { + case "/v1.0/access-token/b2b": + return bisnapResponse(http.StatusOK, `{"accessToken":"ACCESS-TOKEN-CANARY-DO-NOT-PRINT"}`), nil + case "/v1.0/qr/qr-mpm-query": + return bisnapResponse(http.StatusOK, `{"responseCode":"2005100","latestTransactionStatus":"00","referenceNo":"provider-status-001"}`), nil + default: + t.Fatalf("unexpected path %q", request.URL.Path) + return nil, nil + } + }), + }) + if outcome.State == journeypkg.Passed { + t.Fatalf("outcome = %#v", outcome) + } +} + type appDoerFunc func(*http.Request) (*http.Response, error) func (f appDoerFunc) Do(request *http.Request) (*http.Response, error) { return f(request) } @@ -259,24 +457,25 @@ func bisnapRequest(orderID string, amount int64, method string) journeypkg.Reque } func validBISNAPManifest() manifest.Manifest { - return manifest.Manifest{ - CredentialSets: map[string]manifest.CredentialSet{ - "bisnap": { - Type: "bisnap", - Environment: "sandbox", - ClientID: "env:MIDTRANS_BISNAP_CLIENT_ID", - ClientSecret: "env:MIDTRANS_BISNAP_CLIENT_SECRET", - PartnerID: "env:MIDTRANS_BISNAP_PARTNER_ID", - ChannelID: "env:MIDTRANS_BISNAP_CHANNEL_ID", - DeviceID: "env:MIDTRANS_BISNAP_DEVICE_ID", - PrivateKey: "file:./secrets/bisnap-private.pem", - MidtransPublicKey: "file:./secrets/bisnap-public.pem", - }, - }, - Integrations: map[string]manifest.Integration{ - "bisnap": {ConfigVersion: 1, Credentials: "bisnap", Callbacks: map[string]string{"notification": "/midtrans/bisnap/notification"}}, - }, + value := manifest.Default() + value.Application.BaseURL = "http://127.0.0.1:3101" + value.CredentialSets["bisnap"] = manifest.CredentialSet{ + Type: "bisnap", + Environment: "sandbox", + ClientID: "env:MIDTRANS_BISNAP_CLIENT_ID", + ClientSecret: "env:MIDTRANS_BISNAP_CLIENT_SECRET", + PartnerID: "env:MIDTRANS_BISNAP_PARTNER_ID", + ChannelID: "env:MIDTRANS_BISNAP_CHANNEL_ID", + DeviceID: "env:MIDTRANS_BISNAP_DEVICE_ID", + PrivateKey: "file:./secrets/bisnap-private.pem", + MidtransPublicKey: "file:./secrets/bisnap-public.pem", } + value.Integrations["bisnap"] = manifest.Integration{ + ConfigVersion: 1, + Credentials: "bisnap", + Callbacks: map[string]string{"notification": "/midtrans/bisnap/notification"}, + } + return value } func bisnapResolveCredential(t *testing.T) func(context.Context, string, string) ([]byte, error) { @@ -311,3 +510,68 @@ func bisnapResponse(status int, body string) *http.Response { Header: make(http.Header), } } + +func createBISNAPEvidenceProject(t *testing.T) string { + t.Helper() + projectDir := t.TempDir() + if _, err := manifest.Init(projectDir); err != nil { + t.Fatal(err) + } + if err := manifest.Save(projectDir, validBISNAPManifest()); err != nil { + t.Fatal(err) + } + return projectDir +} + +func manifestHashForProject(t *testing.T, projectDir string) string { + t.Helper() + data, err := os.ReadFile(filepath.Join(projectDir, ".midtrans", "manifest.yaml")) + if err != nil { + t.Fatal(err) + } + sum := sha256.Sum256(data) + return hex.EncodeToString(sum[:]) +} + +func verifiedBISNAPEvidenceBundle(t *testing.T, projectDir, notificationStatus, persistenceStatus string) evidence.Bundle { + t.Helper() + now := time.Now().UTC() + return evidence.Bundle{ + SchemaVersion: evidence.SchemaVersion, + CLIVersion: "0.1.0-test", + ManifestVersion: 1, + PackID: "bisnap", + PackVersion: "0.1.0", + ManifestHash: manifestHashForProject(t, projectDir), + RepositoryCommit: strings.Repeat("a", 40), + Journey: "bisnap.status", + Environment: "sandbox", + StartedAt: now.Add(-time.Second), + CompletedAt: now, + SafeReferences: map[string]string{ + "order_id": "order-status", + }, + Proofs: []evidence.Proof{ + { + ID: "bisnap.notification", + OperationID: "operation-status", + Stage: "provider_notification", + Level: evidence.ProofSandbox, + Source: "midtrans_notification", + ObservedAt: now, + Status: notificationStatus, + Summary: map[string]any{"route": "/v1.0/qr/qr-mpm-notify"}, + }, + { + ID: "bisnap.merchant-persistence", + OperationID: "operation-status", + Stage: "merchant_persistence", + Level: evidence.ProofLocal, + Source: "merchant_application", + ObservedAt: now, + Status: persistenceStatus, + Summary: map[string]any{"payment_status": "paid"}, + }, + }, + } +} diff --git a/packs/bisnap/pack.go b/packs/bisnap/pack.go index 3c3e58c..1a9b8b6 100644 --- a/packs/bisnap/pack.go +++ b/packs/bisnap/pack.go @@ -42,11 +42,11 @@ func (Pack) Descriptor() packs.Descriptor { "signature", }, Sources: []contracts.PublicSource{ - {ID: "bisnap-overview", URL: "https://docs.midtrans.com/reference/midtrans-bisnap-overview", Rules: []string{"bisnap.signing.verify.v1"}}, - {ID: "bisnap-qris", URL: "https://docs.midtrans.com/reference/qris-mpm", Rules: []string{"bisnap.qris.create", "bisnap.qris.status"}}, + {ID: "bisnap-overview", URL: "https://docs.midtrans.com/reference/core-api-snap-open-api-overview", Rules: []string{"bisnap.signing.verify.v1"}}, + {ID: "bisnap-qris", URL: "https://docs.midtrans.com/reference/mpm-api-qris", Rules: []string{"bisnap.qris.create", "bisnap.qris.status"}}, {ID: "bisnap-virtual-account", URL: "https://docs.midtrans.com/reference/virtual-account-api-bank-transfer", Rules: []string{"bisnap.virtual-account.create", "bisnap.virtual-account.status"}}, - {ID: "bisnap-direct-debit", URL: "https://docs.midtrans.com/reference/direct-debit-api", Rules: []string{"bisnap.direct-debit.create", "bisnap.direct-debit.status", "bisnap.refund"}}, - {ID: "bisnap-notifications", URL: "https://docs.midtrans.com/docs/https-notification-webhooks", Rules: []string{"bisnap.notification.signature", "common.webhook-idempotency"}}, + {ID: "bisnap-direct-debit", URL: "https://docs.midtrans.com/reference/direct-debit-api-gopay", Rules: []string{"bisnap.direct-debit.create", "bisnap.direct-debit.status", "bisnap.refund"}}, + {ID: "bisnap-notifications", URL: "https://docs.midtrans.com/reference/payment-notification-api", Rules: []string{"bisnap.notification.signature", "common.webhook-idempotency"}}, }, } } diff --git a/tools/source-baseline/main.go b/tools/source-baseline/main.go index 8cfdb0b..c7ac579 100644 --- a/tools/source-baseline/main.go +++ b/tools/source-baseline/main.go @@ -8,9 +8,6 @@ import ( "time" "github.com/veritrans/midtrans-cli/internal/sourceprovenance" - "github.com/veritrans/midtrans-cli/packs/coreapi" - "github.com/veritrans/midtrans-cli/packs/paymentlink" - "github.com/veritrans/midtrans-cli/packs/snap" ) func main() { @@ -22,11 +19,7 @@ func main() { os.Exit(2) } - sources := append( - snap.New().Descriptor().Sources, - coreapi.New().Descriptor().Sources..., - ) - sources = append(sources, paymentlink.New().Descriptor().Sources...) + sources := sourceprovenance.AllPublicSources() baseline, err := sourceprovenance.Generate(context.Background(), sources, time.Now()) if err != nil { fmt.Fprintln(os.Stderr, err) diff --git a/tools/source-drift/main.go b/tools/source-drift/main.go index 6fa377c..7ae4a9a 100644 --- a/tools/source-drift/main.go +++ b/tools/source-drift/main.go @@ -7,9 +7,6 @@ import ( "os" "github.com/veritrans/midtrans-cli/internal/sourceprovenance" - "github.com/veritrans/midtrans-cli/packs/coreapi" - "github.com/veritrans/midtrans-cli/packs/paymentlink" - "github.com/veritrans/midtrans-cli/packs/snap" ) func main() { @@ -26,11 +23,7 @@ func main() { fmt.Fprintln(os.Stderr, "baseline could not be read") os.Exit(1) } - sources := append( - snap.New().Descriptor().Sources, - coreapi.New().Descriptor().Sources..., - ) - sources = append(sources, paymentlink.New().Descriptor().Sources...) + sources := sourceprovenance.AllPublicSources() if err := sourceprovenance.ValidateBaseline(baseline, sources); err != nil { fmt.Fprintln(os.Stderr, err) os.Exit(1) From de61d258be3a548342792e2bcfe092e0a3994caa Mon Sep 17 00:00:00 2001 From: "m.salis" Date: Mon, 27 Jul 2026 12:10:42 +0700 Subject: [PATCH 49/73] fix: tighten BI-SNAP evidence bindings --- .../task-9-report.md | 12 ++ internal/app/commands_agent.go | 17 +- internal/app/commands_test.go | 187 +++++++++++++++++- internal/app/journey_runner.go | 10 +- packs/bisnap/journey.go | 56 +++++- packs/bisnap/journey_test.go | 95 ++++++++- 6 files changed, 353 insertions(+), 24 deletions(-) diff --git a/.superpowers/sdd/2026-07-26-midtrans-cli-multi-product-parity/task-9-report.md b/.superpowers/sdd/2026-07-26-midtrans-cli-multi-product-parity/task-9-report.md index c4827a2..8429ce1 100644 --- a/.superpowers/sdd/2026-07-26-midtrans-cli-multi-product-parity/task-9-report.md +++ b/.superpowers/sdd/2026-07-26-midtrans-cli-multi-product-parity/task-9-report.md @@ -32,6 +32,11 @@ Implemented BI-SNAP pack registration and executable journeys for: - QR artifact fallback selection recorded as safe kind/reference only - generic merchant `status` intent allowed without `--amount` - BI-SNAP public sources included in tool aggregation and committed source baseline +- Fix round 2 added: + - exact BI-SNAP proof binding for route, stage, source, order, provider reference, and `latest_transaction_status` + - exact merchant persistence proof binding for stage, source, order, provider reference, and `payment_status: paid` + - `--evidence` support for exact `agent run` and `agent resume` generic journeys + - app tests for valid agent evidence consumption and unsafe evidence-path rejection ## Validation @@ -60,6 +65,13 @@ go test ./packs/bisnap ./internal/app ./internal/manifest ./test/e2e -count=1 go test ./... -count=1 ``` +Requested fix round 2 validation: + +```sh +go test ./packs/bisnap ./internal/app -count=1 +go test ./... -count=1 +``` + Full: ```sh diff --git a/internal/app/commands_agent.go b/internal/app/commands_agent.go index 549dde3..4cb1a73 100644 --- a/internal/app/commands_agent.go +++ b/internal/app/commands_agent.go @@ -53,15 +53,17 @@ func newAgentRunCommand(flags *globalFlags, deps Dependencies) *cobra.Command { func newAgentResumeCommand(flags *globalFlags, deps Dependencies) *cobra.Command { var operationID string + var evidencePath string command := &cobra.Command{ Use: "resume", Short: "resume an existing payment journey operation", Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, _ []string) error { - return writeResult(deps, flags, resumeGenericJourney(cmd.Context(), flags.projectDir, operationID, deps)) + return writeResult(deps, flags, resumeGenericJourney(cmd.Context(), flags.projectDir, operationID, evidencePath, deps)) }, } command.Flags().StringVar(&operationID, "operation", "", "existing journey operation ID") + command.Flags().StringVar(&evidencePath, "evidence", "", "checksummed evidence JSON file") _ = command.MarkFlagRequired("operation") return withProjectMode(command, project.Existing, "agent.resume") } @@ -73,6 +75,7 @@ type genericJourneyFlags struct { orderID string operationID string method string + evidencePath string customerReference string paymentTokenReference string amount int64 @@ -92,6 +95,7 @@ func (f *genericJourneyFlags) bind(command *cobra.Command, includeExecute bool, command.Flags().StringVar(&f.orderID, "order-id", "", "safe merchant order reference") command.Flags().Int64Var(&f.amount, "amount", 0, "amount in IDR") command.Flags().StringVar(&f.method, "method", "", "payment method") + command.Flags().StringVar(&f.evidencePath, "evidence", "", "checksummed evidence JSON file") command.Flags().StringVar(&f.customerReference, "customer-reference", "", "safe customer reference") command.Flags().StringVar(&f.paymentTokenReference, "payment-token-reference", "", "safe payment token reference") command.Flags().BoolVar(&f.reusable, "reusable", false, "request a reusable payment resource") @@ -106,11 +110,12 @@ func (f *genericJourneyFlags) bind(command *cobra.Command, includeExecute bool, func (f *genericJourneyFlags) toRunRequest(flags *globalFlags, execute bool) journeyRunRequest { return journeyRunRequest{ - Command: f.command, - ProjectDir: flags.projectDir, - JourneyID: f.journeyID, - Product: f.product, - OperationID: f.operationID, + Command: f.command, + ProjectDir: flags.projectDir, + JourneyID: f.journeyID, + Product: f.product, + EvidencePath: f.evidencePath, + OperationID: f.operationID, Input: journeypkg.Input{ OrderID: f.orderID, Amount: f.amount, diff --git a/internal/app/commands_test.go b/internal/app/commands_test.go index 92fc381..115ff41 100644 --- a/internal/app/commands_test.go +++ b/internal/app/commands_test.go @@ -336,6 +336,8 @@ func TestMerchantBISNAPStatusIntentPassesWithEvidenceBundle(t *testing.T) { t, project, operations.CanonicalOperationID("bisnap.status:order-status"), + "qris", + "provider-status-001", "pass", "pass", ) @@ -367,6 +369,170 @@ func TestMerchantBISNAPStatusIntentPassesWithEvidenceBundle(t *testing.T) { } } +func TestAgentRunBISNAPStatusPassesWithEvidenceBundle(t *testing.T) { + project := createJourneyProject(t, "http://127.0.0.1:1") + configureManifest(t, project, func(value *manifest.Manifest) { + value.CredentialSets["bisnap"] = manifest.CredentialSet{ + Type: "bisnap", + Environment: "sandbox", + ClientID: "env:MIDTRANS_BISNAP_CLIENT_ID", + ClientSecret: "env:MIDTRANS_BISNAP_CLIENT_SECRET", + PartnerID: "env:MIDTRANS_BISNAP_PARTNER_ID", + ChannelID: "env:MIDTRANS_BISNAP_CHANNEL_ID", + DeviceID: "env:MIDTRANS_BISNAP_DEVICE_ID", + PrivateKey: "file:./secrets/bisnap-private.pem", + MidtransPublicKey: "file:./secrets/bisnap-public.pem", + } + value.Integrations["bisnap"] = manifest.Integration{ + ConfigVersion: 1, + Credentials: "bisnap", + Callbacks: map[string]string{"notification": "/midtrans/bisnap/notification"}, + } + }) + operationID := operations.CanonicalOperationID("agent-operation") + evidencePath := writeBISNAPEvidenceBundle(t, project, operationID, "qris", "provider-status-001", "pass", "pass") + result, exit := executeJSONWithDependencies( + t, + app.Dependencies{ + Version: version.Info{Version: "0.1.0-test"}, + Packs: testRegistry(t), + ResolveCredential: bisnapCredentialResolverForAppTests(), + HTTP: appDoerFunc(func(request *http.Request) (*http.Response, error) { + switch request.URL.Path { + case "/v1.0/access-token/b2b": + return journeyHTTPResponse(http.StatusOK, []byte(`{"accessToken":"ACCESS-TOKEN-CANARY-DO-NOT-PRINT"}`)), nil + case "/v1.0/qr/qr-mpm-query": + return journeyHTTPResponse(http.StatusOK, []byte(`{"responseCode":"2005100","latestTransactionStatus":"00","referenceNo":"provider-status-001"}`)), nil + default: + t.Fatalf("unexpected request path: %s", request.URL.Path) + return nil, nil + } + }), + }, + "agent", "run", + "--journey", "bisnap.status", + "--operation", operationID, + "--method", "qris", + "--order-id", "order-status", + "--evidence", evidencePath, + "--execute", + "--project-dir", project, + ) + if exit != 0 || result.Command != "agent.run" { + t.Fatalf("exit = %d, result = %#v", exit, result) + } + if requireJourneyData(t, result)["state"] != "verified" { + t.Fatalf("result = %#v", result) + } +} + +func TestAgentResumeBISNAPStatusPassesWithEvidenceBundle(t *testing.T) { + project := createJourneyProject(t, "http://127.0.0.1:1") + configureManifest(t, project, func(value *manifest.Manifest) { + value.CredentialSets["bisnap"] = manifest.CredentialSet{ + Type: "bisnap", + Environment: "sandbox", + ClientID: "env:MIDTRANS_BISNAP_CLIENT_ID", + ClientSecret: "env:MIDTRANS_BISNAP_CLIENT_SECRET", + PartnerID: "env:MIDTRANS_BISNAP_PARTNER_ID", + ChannelID: "env:MIDTRANS_BISNAP_CHANNEL_ID", + DeviceID: "env:MIDTRANS_BISNAP_DEVICE_ID", + PrivateKey: "file:./secrets/bisnap-private.pem", + MidtransPublicKey: "file:./secrets/bisnap-public.pem", + } + value.Integrations["bisnap"] = manifest.Integration{ + ConfigVersion: 1, + Credentials: "bisnap", + Callbacks: map[string]string{"notification": "/midtrans/bisnap/notification"}, + } + }) + deps := app.Dependencies{ + Version: version.Info{Version: "0.1.0-test"}, + Packs: testRegistry(t), + ResolveCredential: bisnapCredentialResolverForAppTests(), + HTTP: appDoerFunc(func(request *http.Request) (*http.Response, error) { + switch request.URL.Path { + case "/v1.0/access-token/b2b": + return journeyHTTPResponse(http.StatusOK, []byte(`{"accessToken":"ACCESS-TOKEN-CANARY-DO-NOT-PRINT"}`)), nil + case "/v1.0/qr/qr-mpm-query": + return journeyHTTPResponse(http.StatusOK, []byte(`{"responseCode":"2005100","latestTransactionStatus":"00","referenceNo":"provider-status-001"}`)), nil + default: + t.Fatalf("unexpected request path: %s", request.URL.Path) + return nil, nil + } + }), + } + seedOperationID := operations.CanonicalOperationID("agent-operation") + first, exit := executeJSONWithDependencies( + t, deps, + "agent", "run", + "--journey", "bisnap.status", + "--operation", seedOperationID, + "--method", "qris", + "--order-id", "order-status", + "--execute", + "--project-dir", project, + ) + if exit != 3 { + t.Fatalf("first exit = %d, result = %#v", exit, first) + } + operationID := requireJourneyData(t, first)["operation_id"].(string) + evidencePath := writeBISNAPEvidenceBundle(t, project, operationID, "qris", "provider-status-001", "pass", "pass") + resumed, exit := executeJSONWithDependencies( + t, deps, + "agent", "resume", + "--operation", operationID, + "--evidence", evidencePath, + "--project-dir", project, + ) + if exit != 0 || resumed.Command != "agent.resume" { + t.Fatalf("exit = %d, result = %#v", exit, resumed) + } + if requireJourneyData(t, resumed)["state"] != "verified" { + t.Fatalf("result = %#v", resumed) + } +} + +func TestAgentJourneyEvidencePathMustStayInsideProject(t *testing.T) { + project := createJourneyProject(t, "http://127.0.0.1:1") + configureManifest(t, project, func(value *manifest.Manifest) { + value.CredentialSets["bisnap"] = manifest.CredentialSet{ + Type: "bisnap", + Environment: "sandbox", + ClientID: "env:MIDTRANS_BISNAP_CLIENT_ID", + ClientSecret: "env:MIDTRANS_BISNAP_CLIENT_SECRET", + PartnerID: "env:MIDTRANS_BISNAP_PARTNER_ID", + ChannelID: "env:MIDTRANS_BISNAP_CHANNEL_ID", + DeviceID: "env:MIDTRANS_BISNAP_DEVICE_ID", + PrivateKey: "file:./secrets/bisnap-private.pem", + MidtransPublicKey: "file:./secrets/bisnap-public.pem", + } + value.Integrations["bisnap"] = manifest.Integration{ + ConfigVersion: 1, + Credentials: "bisnap", + Callbacks: map[string]string{"notification": "/midtrans/bisnap/notification"}, + } + }) + outside := filepath.Join(t.TempDir(), "evidence.json") + if err := os.WriteFile(outside, []byte(`{}`), 0o600); err != nil { + t.Fatal(err) + } + result, exit := executeJSONWithDependencies( + t, + app.Dependencies{Version: version.Info{Version: "0.1.0-test"}, Packs: testRegistry(t)}, + "agent", "run", + "--journey", "bisnap.status", + "--method", "qris", + "--order-id", "order-status", + "--evidence", outside, + "--execute", + "--project-dir", project, + ) + if exit != 6 || len(result.Findings) != 1 || result.Findings[0].Code != "EVIDENCE_INVALID" { + t.Fatalf("exit = %d, result = %#v", exit, result) + } +} + func TestMerchantGenericIntentInvalidAmountUsesIntentDerivedCommandIdentity(t *testing.T) { project := createJourneyProject(t, "http://127.0.0.1:1") configureManifest(t, project, func(value *manifest.Manifest) { @@ -423,7 +589,7 @@ func bisnapCredentialResolverForAppTests() func(context.Context, string, string) } } -func writeBISNAPEvidenceBundle(t *testing.T, project, operationID, notificationStatus, persistenceStatus string) string { +func writeBISNAPEvidenceBundle(t *testing.T, project, operationID, method, providerReference, notificationStatus, persistenceStatus string) string { t.Helper() now := time.Now().UTC() manifestBytes, err := os.ReadFile(filepath.Join(project, ".midtrans", "manifest.yaml")) @@ -431,6 +597,12 @@ func writeBISNAPEvidenceBundle(t *testing.T, project, operationID, notificationS t.Fatal(err) } sum := sha256.Sum256(manifestBytes) + route := "/v1.0/qr/qr-mpm-notify" + if method == "bca" { + route = "/v1.0/va/notify" + } else if method != "qris" { + route = "/v1.0/debit/notify" + } path, err := (evidence.Store{ProjectDir: project}).Write(evidence.Bundle{ SchemaVersion: evidence.SchemaVersion, CLIVersion: "0.1.0-test", @@ -453,7 +625,12 @@ func writeBISNAPEvidenceBundle(t *testing.T, project, operationID, notificationS Source: "midtrans_notification", ObservedAt: now, Status: notificationStatus, - Summary: map[string]any{"route": "/v1.0/qr/qr-mpm-notify"}, + Summary: map[string]any{ + "route": route, + "order_id": "order-status", + "provider_reference": providerReference, + "latest_transaction_status": "00", + }, }, { ID: "bisnap.merchant-persistence", @@ -463,7 +640,11 @@ func writeBISNAPEvidenceBundle(t *testing.T, project, operationID, notificationS Source: "merchant_application", ObservedAt: now, Status: persistenceStatus, - Summary: map[string]any{"payment_status": "paid"}, + Summary: map[string]any{ + "order_id": "order-status", + "provider_reference": providerReference, + "payment_status": "paid", + }, }, }, }) diff --git a/internal/app/journey_runner.go b/internal/app/journey_runner.go index 148d5b1..aa6ba25 100644 --- a/internal/app/journey_runner.go +++ b/internal/app/journey_runner.go @@ -109,6 +109,7 @@ func resumeGenericJourney( ctx context.Context, projectDir string, operationID string, + evidencePath string, deps Dependencies, ) contracts.Result { value, invalid := loadValidatedManifest("agent.resume", projectDir, deps) @@ -127,10 +128,11 @@ func resumeGenericJourney( return result } return runGenericJourney(ctx, journeyRunRequest{ - Command: "agent.resume", - ProjectDir: projectDir, - JourneyID: record.JourneyID, - OperationID: operationID, + Command: "agent.resume", + ProjectDir: projectDir, + JourneyID: record.JourneyID, + EvidencePath: evidencePath, + OperationID: operationID, }, deps) } diff --git a/packs/bisnap/journey.go b/packs/bisnap/journey.go index 23d6b75..a5357b1 100644 --- a/packs/bisnap/journey.go +++ b/packs/bisnap/journey.go @@ -274,7 +274,7 @@ func evaluateVerifiedStatus(request journey.Request, status StatusResponse) jour base.Finding = nil return evaluateStatus(status) } - proofs, ok := validatedStatusProofs(request, status.OrderID) + proofs, ok := validatedStatusProofs(request, status) if !ok { return base } @@ -285,7 +285,7 @@ func evaluateVerifiedStatus(request journey.Request, status StatusResponse) jour } } -func validatedStatusProofs(request journey.Request, orderID string) ([]evidence.Proof, bool) { +func validatedStatusProofs(request journey.Request, status StatusResponse) ([]evidence.Proof, bool) { bundle := request.Evidence if bundle.SchemaVersion == "" { return nil, false @@ -298,7 +298,11 @@ func validatedStatusProofs(request journey.Request, orderID string) ([]evidence. bundle.ManifestHash != request.ManifestHash || bundle.PackID != "bisnap" || (bundle.Journey != "bisnap.status" && bundle.Journey != "bisnap.qris-payment" && bundle.Journey != "bisnap.virtual-account" && bundle.Journey != "bisnap.direct-debit") || - bundle.SafeReferences["order_id"] != orderID { + bundle.SafeReferences["order_id"] != status.OrderID { + return nil, false + } + expectedRoute := expectedNotificationRoute(request.Input.Method) + if expectedRoute == "" { return nil, false } var notificationProof *evidence.Proof @@ -309,14 +313,27 @@ func validatedStatusProofs(request journey.Request, orderID string) ([]evidence. } switch proof.ID { case "bisnap.notification": - if proof.Level == evidence.ProofSandbox && proof.Status == "pass" { + if proof.Level == evidence.ProofSandbox && + proof.Status == "pass" && + proof.Stage == "provider_notification" && + proof.Source == "midtrans_notification" && + summaryString(proof.Summary, "route") == expectedRoute && + summaryString(proof.Summary, "order_id") == status.OrderID && + summaryString(proof.Summary, "latest_transaction_status") == "00" && + matchesOptionalReference(summaryString(proof.Summary, "provider_reference"), status.ProviderReference) { proofCopy := proof notificationProof = &proofCopy } else { return nil, false } case "bisnap.merchant-persistence": - if proof.Level == evidence.ProofLocal && proof.Status == "pass" { + if proof.Level == evidence.ProofLocal && + proof.Status == "pass" && + proof.Stage == "merchant_persistence" && + proof.Source == "merchant_application" && + summaryString(proof.Summary, "order_id") == status.OrderID && + matchesOptionalReference(summaryString(proof.Summary, "provider_reference"), status.ProviderReference) && + summaryString(proof.Summary, "payment_status") == "paid" { proofCopy := proof persistenceProof = &proofCopy } else { @@ -330,6 +347,35 @@ func validatedStatusProofs(request journey.Request, orderID string) ([]evidence. return []evidence.Proof{*notificationProof, *persistenceProof}, true } +func expectedNotificationRoute(method string) string { + switch journeyProduct(method, method) { + case "qris": + return "/v1.0/qr/qr-mpm-notify" + case "virtual-account": + return "/v1.0/va/notify" + default: + return "/v1.0/debit/notify" + } +} + +func summaryString(summary map[string]any, key string) string { + if summary == nil { + return "" + } + value, ok := summary[key].(string) + if !ok { + return "" + } + return value +} + +func matchesOptionalReference(summaryReference, statusReference string) bool { + if statusReference == "" { + return summaryReference == "" + } + return summaryReference == statusReference +} + func (h Handler) runtimeRunner(ctx context.Context, request journey.Request, runtime journey.Runtime) (JourneyRunner, *journey.Outcome) { if h.runnerOverride { return h.runner, nil diff --git a/packs/bisnap/journey_test.go b/packs/bisnap/journey_test.go index 46a475b..f44d736 100644 --- a/packs/bisnap/journey_test.go +++ b/packs/bisnap/journey_test.go @@ -350,7 +350,7 @@ func TestStatusJourneyPassesWithVerifiedEvidenceProofs(t *testing.T) { ProjectDir: projectDir, ManifestHash: manifestHashForProject(t, projectDir), Manifest: validBISNAPManifest(), - Evidence: verifiedBISNAPEvidenceBundle(t, projectDir, "pass", "pass"), + Evidence: verifiedBISNAPEvidenceBundle(t, projectDir, "qris", "provider-status-001", "pass", "pass"), Input: journeypkg.Input{ OrderID: "order-status", Method: "qris", @@ -382,7 +382,7 @@ func TestStatusJourneyDoesNotPassWithFailedNotificationProof(t *testing.T) { ProjectDir: projectDir, ManifestHash: manifestHashForProject(t, projectDir), Manifest: validBISNAPManifest(), - Evidence: verifiedBISNAPEvidenceBundle(t, projectDir, "fail", "pass"), + Evidence: verifiedBISNAPEvidenceBundle(t, projectDir, "qris", "provider-status-001", "fail", "pass"), Input: journeypkg.Input{ OrderID: "order-status", Method: "qris", @@ -414,7 +414,75 @@ func TestStatusJourneyRequiresMerchantPersistenceProof(t *testing.T) { ProjectDir: projectDir, ManifestHash: manifestHashForProject(t, projectDir), Manifest: validBISNAPManifest(), - Evidence: verifiedBISNAPEvidenceBundle(t, projectDir, "pass", "blocked"), + Evidence: verifiedBISNAPEvidenceBundle(t, projectDir, "qris", "provider-status-001", "pass", "blocked"), + Input: journeypkg.Input{ + OrderID: "order-status", + Method: "qris", + }, + }, journeypkg.Runtime{ + ResolveCredential: bisnapResolveCredential(t), + HTTP: appDoerFunc(func(request *http.Request) (*http.Response, error) { + switch request.URL.Path { + case "/v1.0/access-token/b2b": + return bisnapResponse(http.StatusOK, `{"accessToken":"ACCESS-TOKEN-CANARY-DO-NOT-PRINT"}`), nil + case "/v1.0/qr/qr-mpm-query": + return bisnapResponse(http.StatusOK, `{"responseCode":"2005100","latestTransactionStatus":"00","referenceNo":"provider-status-001"}`), nil + default: + t.Fatalf("unexpected path %q", request.URL.Path) + return nil, nil + } + }), + }) + if outcome.State == journeypkg.Passed { + t.Fatalf("outcome = %#v", outcome) + } +} + +func TestStatusJourneyRejectsNotificationProofWithWrongRoute(t *testing.T) { + projectDir := createBISNAPEvidenceProject(t) + bundle := verifiedBISNAPEvidenceBundle(t, projectDir, "qris", "provider-status-001", "pass", "pass") + bundle.Proofs[0].Summary["route"] = "/v1.0/debit/notify" + handler := bisnap.NewStatusHandler() + outcome := handler.Execute(context.Background(), journeypkg.Request{ + OperationID: "operation-status", + ProjectDir: projectDir, + ManifestHash: manifestHashForProject(t, projectDir), + Manifest: validBISNAPManifest(), + Evidence: bundle, + Input: journeypkg.Input{ + OrderID: "order-status", + Method: "qris", + }, + }, journeypkg.Runtime{ + ResolveCredential: bisnapResolveCredential(t), + HTTP: appDoerFunc(func(request *http.Request) (*http.Response, error) { + switch request.URL.Path { + case "/v1.0/access-token/b2b": + return bisnapResponse(http.StatusOK, `{"accessToken":"ACCESS-TOKEN-CANARY-DO-NOT-PRINT"}`), nil + case "/v1.0/qr/qr-mpm-query": + return bisnapResponse(http.StatusOK, `{"responseCode":"2005100","latestTransactionStatus":"00","referenceNo":"provider-status-001"}`), nil + default: + t.Fatalf("unexpected path %q", request.URL.Path) + return nil, nil + } + }), + }) + if outcome.State == journeypkg.Passed { + t.Fatalf("outcome = %#v", outcome) + } +} + +func TestStatusJourneyRejectsPersistenceProofWithNonPaidState(t *testing.T) { + projectDir := createBISNAPEvidenceProject(t) + bundle := verifiedBISNAPEvidenceBundle(t, projectDir, "qris", "provider-status-001", "pass", "pass") + bundle.Proofs[1].Summary["payment_status"] = "settlement" + handler := bisnap.NewStatusHandler() + outcome := handler.Execute(context.Background(), journeypkg.Request{ + OperationID: "operation-status", + ProjectDir: projectDir, + ManifestHash: manifestHashForProject(t, projectDir), + Manifest: validBISNAPManifest(), + Evidence: bundle, Input: journeypkg.Input{ OrderID: "order-status", Method: "qris", @@ -533,9 +601,15 @@ func manifestHashForProject(t *testing.T, projectDir string) string { return hex.EncodeToString(sum[:]) } -func verifiedBISNAPEvidenceBundle(t *testing.T, projectDir, notificationStatus, persistenceStatus string) evidence.Bundle { +func verifiedBISNAPEvidenceBundle(t *testing.T, projectDir, method, providerReference, notificationStatus, persistenceStatus string) evidence.Bundle { t.Helper() now := time.Now().UTC() + route := "/v1.0/qr/qr-mpm-notify" + if method == "bca" { + route = "/v1.0/va/notify" + } else if method != "qris" { + route = "/v1.0/debit/notify" + } return evidence.Bundle{ SchemaVersion: evidence.SchemaVersion, CLIVersion: "0.1.0-test", @@ -560,7 +634,12 @@ func verifiedBISNAPEvidenceBundle(t *testing.T, projectDir, notificationStatus, Source: "midtrans_notification", ObservedAt: now, Status: notificationStatus, - Summary: map[string]any{"route": "/v1.0/qr/qr-mpm-notify"}, + Summary: map[string]any{ + "route": route, + "order_id": "order-status", + "provider_reference": providerReference, + "latest_transaction_status": "00", + }, }, { ID: "bisnap.merchant-persistence", @@ -570,7 +649,11 @@ func verifiedBISNAPEvidenceBundle(t *testing.T, projectDir, notificationStatus, Source: "merchant_application", ObservedAt: now, Status: persistenceStatus, - Summary: map[string]any{"payment_status": "paid"}, + Summary: map[string]any{ + "order_id": "order-status", + "provider_reference": providerReference, + "payment_status": "paid", + }, }, }, } From 282af91de12dc78d48bc104f213bc942ab1ac45e Mon Sep 17 00:00:00 2001 From: "m.salis" Date: Mon, 27 Jul 2026 12:29:24 +0700 Subject: [PATCH 50/73] feat: add GoPay tokenization journeys --- .../task-10-report.md | 77 +++ cmd/midtrans/main.go | 3 +- contracts/capabilities-v1.json | 18 + contracts/public-sources-v1.json | 31 ++ internal/app/app_test.go | 20 +- internal/sourceprovenance/catalog.go | 2 + packs/gopaytokenization/client.go | 308 +++++++++++ packs/gopaytokenization/client_test.go | 110 ++++ packs/gopaytokenization/journey.go | 507 ++++++++++++++++++ packs/gopaytokenization/journey_test.go | 251 +++++++++ packs/gopaytokenization/pack.go | 90 ++++ packs/gopaytokenization/pack_test.go | 36 ++ packs/gopaytokenization/seamless.go | 37 ++ packs/gopaytokenization/seamless_test.go | 63 +++ packs/gopaytokenization/test_helpers_test.go | 16 + testdata/gopaytokenization/README.md | 2 + 16 files changed, 1561 insertions(+), 10 deletions(-) create mode 100644 .superpowers/sdd/2026-07-26-midtrans-cli-multi-product-parity/task-10-report.md create mode 100644 packs/gopaytokenization/client.go create mode 100644 packs/gopaytokenization/client_test.go create mode 100644 packs/gopaytokenization/journey.go create mode 100644 packs/gopaytokenization/journey_test.go create mode 100644 packs/gopaytokenization/pack.go create mode 100644 packs/gopaytokenization/pack_test.go create mode 100644 packs/gopaytokenization/seamless.go create mode 100644 packs/gopaytokenization/seamless_test.go create mode 100644 packs/gopaytokenization/test_helpers_test.go create mode 100644 testdata/gopaytokenization/README.md diff --git a/.superpowers/sdd/2026-07-26-midtrans-cli-multi-product-parity/task-10-report.md b/.superpowers/sdd/2026-07-26-midtrans-cli-multi-product-parity/task-10-report.md new file mode 100644 index 0000000..efdc9ed --- /dev/null +++ b/.superpowers/sdd/2026-07-26-midtrans-cli-multi-product-parity/task-10-report.md @@ -0,0 +1,77 @@ +# Task 10 Report + +Date: 2026-07-27 + +## Outcome + +Implemented the new `gopay-tokenization` pack and registered it in the CLI. +The pack now covers: + +- `gopay-tokenization.account-linking` +- `gopay-tokenization.binding-inquiry` +- `gopay-tokenization.wallet-payment` +- `gopay-tokenization.paylater` +- `gopay-tokenization.unlink` + +## What Changed + +- Added `packs/gopaytokenization/pack.go` with descriptor, capabilities, journeys, sandbox hosts, sensitive keys, and public-source declarations. +- Added `packs/gopaytokenization/client.go` with BI-SNAP-backed signing/access-token infrastructure and request builders for: + - GET auth code on `merchants-app.sbx.midtrans.com` + - POST `/v1.0/registration-account-binding` + - POST `/v1.0/registration-account-inquiry` + - POST `/v1.0/registration-account-unbinding` + - POST `/v1.0/debit/payment-host-to-host` +- Added `packs/gopaytokenization/journey.go` with: + - account-link planning and resume gating via state hash plus auth-code reference + - binding inquiry + - tokenized wallet and GoPayLater payment flows + - active payment-option selection in memory only + - inquiry immediately before payment with rotated customer token use + - unlink flow with merchant-state-clearing evidence requirement +- Added `packs/gopaytokenization/seamless.go` for `/v1.0/registration-account/notify` route metadata and signature verification wiring. +- Registered the pack in [cmd/midtrans/main.go](/Users/salis/Goto/Code/midtrans/codex/midtrans-cli-merchant-experience/cmd/midtrans/main.go). +- Updated [contracts/capabilities-v1.json](/Users/salis/Goto/Code/midtrans/codex/midtrans-cli-merchant-experience/contracts/capabilities-v1.json), [contracts/public-sources-v1.json](/Users/salis/Goto/Code/midtrans/codex/midtrans-cli-merchant-experience/contracts/public-sources-v1.json), and [internal/sourceprovenance/catalog.go](/Users/salis/Goto/Code/midtrans/codex/midtrans-cli-merchant-experience/internal/sourceprovenance/catalog.go). +- Updated [internal/app/app_test.go](/Users/salis/Goto/Code/midtrans/codex/midtrans-cli-merchant-experience/internal/app/app_test.go) so the runtime capability contract tests include the new pack. + +## Validation + +RED checkpoint: + +```sh +go test ./packs/gopaytokenization -count=1 +``` + +Initial result: failed because the package had only tests and no production Go files. + +Focused validation: + +```sh +go test ./packs/gopaytokenization ./packs/bisnap ./internal/app -count=1 +go test ./internal/sourceprovenance ./internal/app ./packs/gopaytokenization -count=1 +``` + +Result: passed. + +Full validation: + +```sh +go test ./... -count=1 +``` + +Result: passed. + +## Behavioral Guarantees Now Covered + +- Auth-code flow uses the merchant-app sandbox host. +- Binding, inquiry, unbinding, and tokenized payment hit the required BI-SNAP paths. +- Tokenized payment sends `Authorization-Customer`; one-time access-token exchange does not. +- Inquiry runs immediately before payment. +- Rotated active option/customer token data is used in-memory for payment only. +- GoPayLater requires an active `PAY_LATER` option. +- Account-link persistence stores only safe state references. +- Auth code, customer authorization token, payment-option token, and authorization references are not persisted or rendered in safe data. + +## Commit + +Planned commit message: `feat: add GoPay tokenization journeys` diff --git a/cmd/midtrans/main.go b/cmd/midtrans/main.go index 9bdcaf7..aef287e 100644 --- a/cmd/midtrans/main.go +++ b/cmd/midtrans/main.go @@ -11,12 +11,13 @@ import ( "github.com/veritrans/midtrans-cli/packs/bisnap" "github.com/veritrans/midtrans-cli/packs/common" "github.com/veritrans/midtrans-cli/packs/coreapi" + "github.com/veritrans/midtrans-cli/packs/gopaytokenization" "github.com/veritrans/midtrans-cli/packs/paymentlink" "github.com/veritrans/midtrans-cli/packs/snap" ) func main() { - registry, err := packs.NewRegistry(common.New(), snap.New(), coreapi.New(), paymentlink.New(), bisnap.New()) + registry, err := packs.NewRegistry(common.New(), snap.New(), coreapi.New(), paymentlink.New(), bisnap.New(), gopaytokenization.New()) if err != nil { fmt.Fprintln(os.Stderr, err) os.Exit(6) diff --git a/contracts/capabilities-v1.json b/contracts/capabilities-v1.json index 49639ef..737f6cf 100644 --- a/contracts/capabilities-v1.json +++ b/contracts/capabilities-v1.json @@ -79,6 +79,24 @@ "payment-link.reusable", "payment-link.verify" ] + }, + { + "id": "gopay-tokenization", + "version": "0.1.0", + "capabilities": [ + "gopay-tokenization.account-linking.verify.v1", + "gopay-tokenization.binding-inquiry.verify.v1", + "gopay-tokenization.paylater.verify.v1", + "gopay-tokenization.unlink.verify.v1", + "gopay-tokenization.wallet-payment.verify.v1" + ], + "journeys": [ + "gopay-tokenization.account-linking", + "gopay-tokenization.binding-inquiry", + "gopay-tokenization.wallet-payment", + "gopay-tokenization.paylater", + "gopay-tokenization.unlink" + ] } ] } diff --git a/contracts/public-sources-v1.json b/contracts/public-sources-v1.json index abc66c3..9b917da 100644 --- a/contracts/public-sources-v1.json +++ b/contracts/public-sources-v1.json @@ -227,6 +227,37 @@ ], "sha256": "3b61e701807e10b9f954ef43aa5871e7c8172036b2163c16cda23b8d91b65a80", "retrieved_at": "2026-07-27T05:02:08.464875Z" + }, + { + "id": "gopay-tokenization-overview", + "url": "https://docs.midtrans.com/reference/core-api-snap-open-api-overview", + "rules": [ + "gopaytokenization.signing.verify.v1" + ], + "sha256": "42b94bb13823e0e3122a5ec5e73f3a002a969911d05230022c0f08eed9b36288", + "retrieved_at": "2026-07-27T05:02:08.464875Z" + }, + { + "id": "gopay-tokenization-bind", + "url": "https://docs.midtrans.com/reference/direct-debit-api-gopay", + "rules": [ + "gopaytokenization.linking.bind", + "gopaytokenization.linking.inquiry", + "gopaytokenization.wallet.charge", + "gopaytokenization.unlink" + ], + "sha256": "7816e56ae9fb3d87d8e36d18b7d03f297a08808292b1ae4555841b2b041082b1", + "retrieved_at": "2026-07-27T05:02:08.464875Z" + }, + { + "id": "gopay-tokenization-notifications", + "url": "https://docs.midtrans.com/reference/payment-notification-api", + "rules": [ + "gopaytokenization.notification.signature", + "common.webhook-idempotency" + ], + "sha256": "3b61e701807e10b9f954ef43aa5871e7c8172036b2163c16cda23b8d91b65a80", + "retrieved_at": "2026-07-27T05:02:08.464875Z" } ] } diff --git a/internal/app/app_test.go b/internal/app/app_test.go index 402cc7a..a7964a5 100644 --- a/internal/app/app_test.go +++ b/internal/app/app_test.go @@ -26,6 +26,7 @@ import ( "github.com/veritrans/midtrans-cli/packs/bisnap" "github.com/veritrans/midtrans-cli/packs/common" "github.com/veritrans/midtrans-cli/packs/coreapi" + "github.com/veritrans/midtrans-cli/packs/gopaytokenization" "github.com/veritrans/midtrans-cli/packs/paymentlink" "github.com/veritrans/midtrans-cli/packs/snap" ) @@ -49,20 +50,21 @@ func TestCapabilitiesJSON(t *testing.T) { if result.CLIVersion != "0.1.0-test" { t.Fatalf("cli version = %q", result.CLIVersion) } - if len(result.Capabilities) != 19 || + if len(result.Capabilities) != 24 || result.Capabilities[0].ID != "bisnap.direct-debit.verify.v1" || - result.Capabilities[18].ID != "snap.webhook.verify.v1" { + result.Capabilities[23].ID != "snap.webhook.verify.v1" { t.Fatalf("unexpected capabilities: %#v", result.Capabilities) } - if len(result.Packs) != 5 || + if len(result.Packs) != 6 || result.Packs[0].ID != "bisnap" || result.Packs[1].ID != "common" || result.Packs[2].ID != "core-api" || - result.Packs[3].ID != "payment-link" || - result.Packs[4].ID != "snap" { + result.Packs[3].ID != "gopay-tokenization" || + result.Packs[4].ID != "payment-link" || + result.Packs[5].ID != "snap" { t.Fatalf("unexpected packs: %#v", result.Packs) } - if len(result.Journeys) != 18 || result.Journeys[16] != "snap.checkout" || result.Journeys[17] != "snap.mobile-webview" { + if len(result.Journeys) != 23 || result.Journeys[21] != "snap.checkout" || result.Journeys[22] != "snap.mobile-webview" { t.Fatalf("unexpected journeys: %#v", result.Journeys) } } @@ -84,8 +86,8 @@ func TestAgentCapabilitiesPreservesCapabilityContract(t *testing.T) { ) if exit != 0 || result.SchemaVersion != "1.0" || - len(result.Capabilities) != 19 || - len(result.Journeys) != 18 { + len(result.Capabilities) != 24 || + len(result.Journeys) != 23 { t.Fatalf("exit = %d, result = %#v", exit, result) } } @@ -3163,7 +3165,7 @@ func assertSchemaFieldsMatchType( func testRegistry(t *testing.T) *packs.Registry { t.Helper() - registry, err := packs.NewRegistry(common.New(), snap.New(), coreapi.New(), paymentlink.New(), bisnap.New()) + registry, err := packs.NewRegistry(common.New(), snap.New(), coreapi.New(), paymentlink.New(), bisnap.New(), gopaytokenization.New()) if err != nil { t.Fatal(err) } diff --git a/internal/sourceprovenance/catalog.go b/internal/sourceprovenance/catalog.go index c169b46..22fb76a 100644 --- a/internal/sourceprovenance/catalog.go +++ b/internal/sourceprovenance/catalog.go @@ -4,6 +4,7 @@ import ( "github.com/veritrans/midtrans-cli/internal/contracts" "github.com/veritrans/midtrans-cli/packs/bisnap" "github.com/veritrans/midtrans-cli/packs/coreapi" + "github.com/veritrans/midtrans-cli/packs/gopaytokenization" "github.com/veritrans/midtrans-cli/packs/paymentlink" "github.com/veritrans/midtrans-cli/packs/snap" ) @@ -13,5 +14,6 @@ func AllPublicSources() []contracts.PublicSource { sources = append(sources, coreapi.New().Descriptor().Sources...) sources = append(sources, paymentlink.New().Descriptor().Sources...) sources = append(sources, bisnap.New().Descriptor().Sources...) + sources = append(sources, gopaytokenization.New().Descriptor().Sources...) return sources } diff --git a/packs/gopaytokenization/client.go b/packs/gopaytokenization/client.go new file mode 100644 index 0000000..1c5639f --- /dev/null +++ b/packs/gopaytokenization/client.go @@ -0,0 +1,308 @@ +package gopaytokenization + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "io" + "net/http" + "net/url" + "strconv" + "strings" + "time" + + "github.com/veritrans/midtrans-cli/internal/sandbox" + "github.com/veritrans/midtrans-cli/packs/bisnap" +) + +const gopayMaxResponseBytes = 64 << 10 + +const ( + sandboxAPIBaseURL = "https://merchants.sbx.midtrans.com" + sandboxApplicationBaseURL = "https://merchants-app.sbx.midtrans.com" + getAuthCodePath = "/partner/app" + bindingPath = "/v1.0/registration-account-binding" + inquiryPath = "/v1.0/registration-account-inquiry" + unbindPath = "/v1.0/registration-account-unbinding" + paymentPath = "/v1.0/debit/payment-host-to-host" + accountNotifyPath = "/v1.0/registration-account/notify" + defaultServiceCode = "54" +) + +type amountDetails struct { + Value string `json:"value"` + Currency string `json:"currency"` +} + +type Client struct { + HTTP sandbox.Doer + ClientID string + PartnerID string + ChannelID string + DeviceID string + PrivateKeyPEM []byte + ClientSecret []byte + Now func() time.Time + NewExternalID func() (string, error) +} + +type GetAuthCodeInput struct { + StateHash string + MerchantHandle string +} + +type BindingRequestInput struct { + AccessToken string + AuthCode string +} + +type InquiryRequestInput struct { + AccessToken string + CustomerToken string +} + +type UnbindRequestInput struct { + AccessToken string + CustomerToken string +} + +type PaymentRequestInput struct { + AccessToken string + CustomerToken string + OrderID string + Amount int64 + PaymentOptionToken string + PaymentOptionType string +} + +type BindingResponse struct { + ResponseCode string `json:"responseCode"` + ResponseMessage string `json:"responseMessage"` + CustomerToken string `json:"customerToken"` + AuthorizationReference string `json:"authorizationReference"` +} + +type InquiryResponse struct { + ResponseCode string `json:"responseCode"` + ResponseMessage string `json:"responseMessage"` + AccessTokenInfo AccessTokenInfo `json:"accessTokenInfo"` + PaymentOptions []PaymentOption `json:"paymentOptions"` +} + +type AccessTokenInfo struct { + AccessToken string `json:"accessToken"` +} + +type PaymentOption struct { + PaymentOptionType string `json:"paymentOptionType"` + PaymentOptionToken string `json:"paymentOptionToken"` + Status string `json:"status"` +} + +type PaymentResponse struct { + ResponseCode string `json:"responseCode"` + ResponseMessage string `json:"responseMessage"` + PartnerReferenceNo string `json:"partnerReferenceNo"` + ReferenceNo string `json:"referenceNo"` + WebRedirectURL string `json:"webRedirectUrl"` +} + +func (c Client) now() time.Time { + if c.Now != nil { + return c.Now() + } + return time.Now().UTC() +} + +func (c Client) NewGetAuthCodeRequest(ctx context.Context, input GetAuthCodeInput) (*http.Request, error) { + if strings.TrimSpace(input.StateHash) == "" || strings.TrimSpace(input.MerchantHandle) == "" { + return nil, bisnapRequestInvalid() + } + values := url.Values{} + values.Set("state", input.StateHash) + values.Set("merchant_id", input.MerchantHandle) + request, err := http.NewRequestWithContext( + ctx, + http.MethodGet, + sandboxApplicationBaseURL+getAuthCodePath+"?"+values.Encode(), + nil, + ) + if err != nil { + return nil, bisnapRequestInvalid() + } + return request, nil +} + +func (c Client) NewBindingRequest(ctx context.Context, input BindingRequestInput) (*http.Request, error) { + body, err := json.Marshal(map[string]any{ + "authCode": input.AuthCode, + }) + if err != nil { + return nil, bisnapRequestInvalid() + } + return c.newTransactionRequest(ctx, http.MethodPost, bindingPath, input.AccessToken, "", body, true) +} + +func (c Client) NewInquiryRequest(ctx context.Context, input InquiryRequestInput) (*http.Request, error) { + body, err := json.Marshal(map[string]any{}) + if err != nil { + return nil, bisnapRequestInvalid() + } + return c.newTransactionRequest(ctx, http.MethodPost, inquiryPath, input.AccessToken, input.CustomerToken, body, true) +} + +func (c Client) NewUnbindRequest(ctx context.Context, input UnbindRequestInput) (*http.Request, error) { + body, err := json.Marshal(map[string]any{}) + if err != nil { + return nil, bisnapRequestInvalid() + } + return c.newTransactionRequest(ctx, http.MethodPost, unbindPath, input.AccessToken, input.CustomerToken, body, true) +} + +func (c Client) NewPaymentRequest(ctx context.Context, input PaymentRequestInput) (*http.Request, error) { + body, err := json.Marshal(map[string]any{ + "partnerReferenceNo": input.OrderID, + "serviceCode": defaultServiceCode, + "amount": amountDetails{Value: amountValue(input.Amount), Currency: "IDR"}, + "additionalInfo": map[string]any{ + "paymentOptionToken": input.PaymentOptionToken, + "paymentOptionType": input.PaymentOptionType, + }, + }) + if err != nil { + return nil, bisnapRequestInvalid() + } + return c.newTransactionRequest(ctx, http.MethodPost, paymentPath, input.AccessToken, input.CustomerToken, body, false) +} + +func (c Client) AccessToken(ctx context.Context) (string, error) { + bridge := c.bisnapClient() + return bridge.AccessToken(ctx) +} + +func (c Client) Binding(ctx context.Context, input BindingRequestInput) (BindingResponse, error) { + if c.HTTP == nil { + return BindingResponse{}, bisnapRequestInvalid() + } + request, err := c.NewBindingRequest(ctx, input) + if err != nil { + return BindingResponse{}, err + } + response, err := c.HTTP.Do(request) + if err != nil { + return BindingResponse{}, errors.New("sandbox request transport failed") + } + var result BindingResponse + if err := decodeGoPayResponse(response, &result); err != nil { + return BindingResponse{}, err + } + return result, nil +} + +func (c Client) Inquiry(ctx context.Context, input InquiryRequestInput) (InquiryResponse, error) { + if c.HTTP == nil { + return InquiryResponse{}, bisnapRequestInvalid() + } + request, err := c.NewInquiryRequest(ctx, input) + if err != nil { + return InquiryResponse{}, err + } + response, err := c.HTTP.Do(request) + if err != nil { + return InquiryResponse{}, errors.New("sandbox request transport failed") + } + var result InquiryResponse + if err := decodeGoPayResponse(response, &result); err != nil { + return InquiryResponse{}, err + } + return result, nil +} + +func (c Client) Unbind(ctx context.Context, input UnbindRequestInput) error { + if c.HTTP == nil { + return bisnapRequestInvalid() + } + request, err := c.NewUnbindRequest(ctx, input) + if err != nil { + return err + } + response, err := c.HTTP.Do(request) + if err != nil { + return errors.New("sandbox request transport failed") + } + var result map[string]any + return decodeGoPayResponse(response, &result) +} + +func (c Client) Payment(ctx context.Context, input PaymentRequestInput) (PaymentResponse, error) { + if c.HTTP == nil { + return PaymentResponse{}, bisnapRequestInvalid() + } + request, err := c.NewPaymentRequest(ctx, input) + if err != nil { + return PaymentResponse{}, err + } + response, err := c.HTTP.Do(request) + if err != nil { + return PaymentResponse{}, errors.New("sandbox request transport failed") + } + var result PaymentResponse + if err := decodeGoPayResponse(response, &result); err != nil { + return PaymentResponse{}, err + } + return result, nil +} + +func (c Client) newTransactionRequest(ctx context.Context, method, path, accessToken, customerToken string, body []byte, useApplicationHost bool) (*http.Request, error) { + bridge := c.bisnapClient() + return bridge.NewTransactionRequest(ctx, bisnap.Request{ + Method: method, + Path: path, + AccessToken: accessToken, + CustomerToken: customerToken, + Body: append([]byte(nil), body...), + UseApplicationHost: useApplicationHost, + }) +} + +func (c Client) bisnapClient() bisnap.Client { + return bisnap.Client{ + HTTP: c.HTTP, + ClientID: c.ClientID, + PartnerID: c.PartnerID, + ChannelID: c.ChannelID, + DeviceID: c.DeviceID, + PrivateKeyPEM: c.PrivateKeyPEM, + ClientSecret: c.ClientSecret, + Now: c.Now, + NewExternalID: c.NewExternalID, + } +} + +func decodeGoPayResponse(response *http.Response, target any) error { + if response == nil || response.Body == nil { + return errors.New("SANDBOX_RESPONSE_INVALID") + } + defer response.Body.Close() + if response.StatusCode < http.StatusOK || response.StatusCode >= http.StatusMultipleChoices { + return sandbox.ResponseError{Operation: "gopay-tokenization", StatusCode: response.StatusCode} + } + data, err := io.ReadAll(io.LimitReader(response.Body, gopayMaxResponseBytes+1)) + if err != nil || len(data) > gopayMaxResponseBytes { + return errors.New("SANDBOX_RESPONSE_INVALID") + } + decoder := json.NewDecoder(bytes.NewReader(data)) + if err := decoder.Decode(target); err != nil { + return errors.New("SANDBOX_RESPONSE_INVALID") + } + return nil +} + +func amountValue(value int64) string { + return strconv.FormatInt(value, 10) + ".00" +} + +func bisnapRequestInvalid() error { + return errors.New("SANDBOX_REQUEST_INVALID") +} diff --git a/packs/gopaytokenization/client_test.go b/packs/gopaytokenization/client_test.go new file mode 100644 index 0000000..0988bd9 --- /dev/null +++ b/packs/gopaytokenization/client_test.go @@ -0,0 +1,110 @@ +package gopaytokenization_test + +import ( + "context" + "io" + "net/http" + "strings" + "testing" + "time" + + "github.com/veritrans/midtrans-cli/packs/gopaytokenization" +) + +func TestClientUsesSandboxHostsAndSeparatesTokenizedHeaders(t *testing.T) { + client := gopaytokenization.Client{ + ClientID: "midtrans-client-123", + PartnerID: "G123456", + ChannelID: "12345", + DeviceID: "device-canary", + PrivateKeyPEM: fixtureBytes(t, "private_key_pkcs8.pem"), + ClientSecret: []byte("CLIENT-SECRET-CANARY-DO-NOT-PRINT"), + Now: fixedNow, + NewExternalID: func() (string, error) { return "op_gopay_123", nil }, + } + + authCodeRequest, err := client.NewGetAuthCodeRequest(context.Background(), gopaytokenization.GetAuthCodeInput{ + StateHash: "state-hash-123", + MerchantHandle: "demo-merchant", + }) + if err != nil { + t.Fatal(err) + } + if authCodeRequest.Method != http.MethodGet { + t.Fatalf("auth code method = %q", authCodeRequest.Method) + } + if authCodeRequest.URL.Host != "merchants-app.sbx.midtrans.com" { + t.Fatalf("auth code host = %q", authCodeRequest.URL.Host) + } + if got := authCodeRequest.URL.Query().Get("state"); got != "state-hash-123" { + t.Fatalf("state = %q", got) + } + + bindingRequest, err := client.NewBindingRequest(context.Background(), gopaytokenization.BindingRequestInput{ + AccessToken: "ACCESS-TOKEN-CANARY-DO-NOT-PRINT", + AuthCode: "AUTH-CODE-CANARY-DO-NOT-PRINT", + }) + if err != nil { + t.Fatal(err) + } + if bindingRequest.Method != http.MethodPost || bindingRequest.URL.String() != "https://merchants-app.sbx.midtrans.com/v1.0/registration-account-binding" { + t.Fatalf("binding request = %s %s", bindingRequest.Method, bindingRequest.URL) + } + if got := bindingRequest.Header.Get("Authorization-Customer"); got != "" { + t.Fatalf("binding Authorization-Customer = %q", got) + } + + inquiryRequest, err := client.NewInquiryRequest(context.Background(), gopaytokenization.InquiryRequestInput{ + AccessToken: "ACCESS-TOKEN-CANARY-DO-NOT-PRINT", + CustomerToken: "CUSTOMER-TOKEN-CANARY-DO-NOT-PRINT", + }) + if err != nil { + t.Fatal(err) + } + if inquiryRequest.Method != http.MethodPost || inquiryRequest.URL.String() != "https://merchants-app.sbx.midtrans.com/v1.0/registration-account-inquiry" { + t.Fatalf("inquiry request = %s %s", inquiryRequest.Method, inquiryRequest.URL) + } + if got := inquiryRequest.Header.Get("Authorization-Customer"); got != "Bearer CUSTOMER-TOKEN-CANARY-DO-NOT-PRINT" { + t.Fatalf("inquiry Authorization-Customer = %q", got) + } + + unbindRequest, err := client.NewUnbindRequest(context.Background(), gopaytokenization.UnbindRequestInput{ + AccessToken: "ACCESS-TOKEN-CANARY-DO-NOT-PRINT", + CustomerToken: "CUSTOMER-TOKEN-CANARY-DO-NOT-PRINT", + }) + if err != nil { + t.Fatal(err) + } + if unbindRequest.Method != http.MethodPost || unbindRequest.URL.String() != "https://merchants-app.sbx.midtrans.com/v1.0/registration-account-unbinding" { + t.Fatalf("unbind request = %s %s", unbindRequest.Method, unbindRequest.URL) + } + + paymentRequest, err := client.NewPaymentRequest(context.Background(), gopaytokenization.PaymentRequestInput{ + AccessToken: "ACCESS-TOKEN-CANARY-DO-NOT-PRINT", + CustomerToken: "ROTATED-CUSTOMER-TOKEN-CANARY-DO-NOT-PRINT", + OrderID: "order-gopay-001", + Amount: 45000, + PaymentOptionToken: "OPTION-TOKEN-CANARY-DO-NOT-PRINT", + PaymentOptionType: "GOPAY_WALLET", + }) + if err != nil { + t.Fatal(err) + } + if paymentRequest.Method != http.MethodPost || paymentRequest.URL.String() != "https://merchants.sbx.midtrans.com/v1.0/debit/payment-host-to-host" { + t.Fatalf("payment request = %s %s", paymentRequest.Method, paymentRequest.URL) + } + if got := paymentRequest.Header.Get("Authorization-Customer"); got != "Bearer ROTATED-CUSTOMER-TOKEN-CANARY-DO-NOT-PRINT" { + t.Fatalf("payment Authorization-Customer = %q", got) + } + body, err := io.ReadAll(paymentRequest.Body) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(body), `"paymentOptionToken":"OPTION-TOKEN-CANARY-DO-NOT-PRINT"`) { + t.Fatalf("payment body = %s", body) + } +} + +func fixedNow() time.Time { + return time.Date(2026, 7, 27, 8, 9, 10, 0, time.FixedZone("WIB", 7*60*60)) +} diff --git a/packs/gopaytokenization/journey.go b/packs/gopaytokenization/journey.go new file mode 100644 index 0000000..614dd19 --- /dev/null +++ b/packs/gopaytokenization/journey.go @@ -0,0 +1,507 @@ +package gopaytokenization + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "errors" + "strconv" + "strings" + "time" + + "github.com/veritrans/midtrans-cli/internal/contracts" + journey "github.com/veritrans/midtrans-cli/internal/journey" + "github.com/veritrans/midtrans-cli/internal/manifest" + "github.com/veritrans/midtrans-cli/internal/operations" +) + +type JourneyRunner struct { + Client Client + Now func() time.Time + ResolveCredential func(context.Context, string, string) ([]byte, error) +} + +const defaultCustomerTokenReference = "env:MIDTRANS_GOPAY_CUSTOMER_TOKEN" + +type Handler struct { + definition journey.Definition + runner JourneyRunner + runnerOverride bool +} + +func NewAccountLinkingHandler() Handler { + return newHandler("gopay-tokenization.account-linking", "gopay-linking") +} +func NewBindingInquiryHandler() Handler { + return newHandler("gopay-tokenization.binding-inquiry", "binding-inquiry") +} +func NewWalletPaymentHandler() Handler { + return newHandler("gopay-tokenization.wallet-payment", "wallet-payment") +} +func NewPayLaterHandler() Handler { return newHandler("gopay-tokenization.paylater", "paylater") } +func NewUnlinkHandler() Handler { return newHandler("gopay-tokenization.unlink", "unlink") } + +func newHandler(id, intent string) Handler { + return Handler{ + definition: journey.Definition{ + ID: id, + Product: "gopay-tokenization", + Intent: intent, + RequiredInputs: []string{"order_id"}, + }, + } +} + +func (h Handler) WithRunner(runner JourneyRunner) Handler { + h.runner = runner + h.runnerOverride = true + if h.runner.Now == nil { + h.runner.Now = func() time.Time { return time.Now().UTC() } + } + return h +} + +func (h Handler) Definition() journey.Definition { return h.definition } + +func (h Handler) Plan(_ context.Context, request journey.Request, _ journey.Runtime) journey.Outcome { + safeData := map[string]any{ + "order_id": request.Input.OrderID, + } + if request.Input.Amount > 0 { + safeData["gross_amount"] = strconv.FormatInt(request.Input.Amount, 10) + } + if request.Input.Method != "" { + safeData["method"] = request.Input.Method + } + return journey.Outcome{State: journey.Planned, SafeData: safeData} +} + +func (h Handler) Execute(ctx context.Context, request journey.Request, runtime journey.Runtime) journey.Outcome { + return h.run(ctx, request, runtime, nil) +} + +func (h Handler) Resume(ctx context.Context, request journey.Request, runtime journey.Runtime, record operations.Record) journey.Outcome { + return h.run(ctx, request, runtime, &record) +} + +func (h Handler) run(ctx context.Context, request journey.Request, runtime journey.Runtime, record *operations.Record) journey.Outcome { + request = rehydrateRequest(request, record) + if request.OperationID == "" || request.ManifestHash == "" || request.Input.OrderID == "" { + return inputRequired("order_id is required") + } + if h.definition.ID == "gopay-tokenization.account-linking" && record == nil { + integration, ok := request.Manifest.IntegrationFor("gopay-tokenization") + if !ok { + return blockedFinding("CAPABILITY_UNAVAILABLE", "gopay-tokenization integration is not configured for this project") + } + return h.runAccountLinking(ctx, request, JourneyRunner{Now: runtimeNow(runtime)}, integration, nil) + } + if h.definition.ID == "gopay-tokenization.account-linking" && record != nil { + if request.Input.PaymentTokenReference == "" { + return inputRequired("payment_token_reference must contain the auth_code credential reference") + } + if !hasStateValidationProof(request, record.SafeReferences["state_hash"]) { + return inputRequired("evidence must include a successful merchant state validation proof") + } + } + runner, integration, credentials, outcome := h.runtimeRunner(ctx, request, runtime) + if outcome != nil { + return *outcome + } + switch h.definition.ID { + case "gopay-tokenization.account-linking": + return h.runAccountLinking(ctx, request, runner, integration, record) + case "gopay-tokenization.binding-inquiry": + return h.runBindingInquiry(ctx, request, runner) + case "gopay-tokenization.wallet-payment": + if request.Input.Amount <= 0 { + return inputRequired("a positive amount is required") + } + return h.runTokenizedPayment(ctx, request, runner, "GOPAY_WALLET") + case "gopay-tokenization.paylater": + if request.Input.Amount <= 0 { + return inputRequired("a positive amount is required") + } + return h.runTokenizedPayment(ctx, request, runner, "PAY_LATER") + case "gopay-tokenization.unlink": + return h.runUnlink(ctx, request, runner) + default: + _ = credentials + return blockedOutcome("journey definition is unsupported") + } +} + +func (h Handler) runAccountLinking(ctx context.Context, request journey.Request, runner JourneyRunner, integration manifest.Integration, record *operations.Record) journey.Outcome { + if record == nil { + merchantHandle := integration.Callbacks["account_linking"] + stateHash := computeStateHash(request.OperationID, request.Input.OrderID, merchantHandle) + authRequest, err := runner.Client.NewGetAuthCodeRequest(ctx, GetAuthCodeInput{ + StateHash: stateHash, + MerchantHandle: merchantHandle, + }) + if err != nil { + return blockedOutcome("auth-code request could not be prepared") + } + return journey.Outcome{ + State: journey.AwaitingUserAction, + SafeData: map[string]any{ + "order_id": request.Input.OrderID, + "state_hash": stateHash, + "merchant_id": merchantHandle, + }, + Action: &journey.Action{ + Type: "browser", + URL: authRequest.URL.String(), + Instructions: "complete GoPay account linking in the Midtrans merchant app and resume with an auth-code credential reference plus local state-validation proof", + ExpiresAt: runner.now().Add(15 * time.Minute), + ResumeCommand: "midtrans agent resume --operation " + request.OperationID, + }, + } + } + if request.Input.PaymentTokenReference == "" { + return inputRequired("payment_token_reference must contain the auth_code credential reference") + } + if !hasStateValidationProof(request, record.SafeReferences["state_hash"]) { + return inputRequired("evidence must include a successful merchant state validation proof") + } + accessToken, err := runner.Client.AccessToken(ctx) + if err != nil { + return blockedOutcome("provider access token is unavailable") + } + rawAuthCode, err := runner.resolveCredential(ctx, request.ProjectDir, request.Input.PaymentTokenReference) + if err != nil { + return blockedFinding("CREDENTIAL_RESOLUTION_FAILED", "unable to resolve the auth_code credential reference") + } + response, err := runner.Client.Binding(ctx, BindingRequestInput{ + AccessToken: accessToken, + AuthCode: strings.TrimSpace(string(rawAuthCode)), + }) + if err != nil { + return blockedOutcome("account binding failed") + } + safeData := map[string]any{ + "order_id": request.Input.OrderID, + "state_hash": record.SafeReferences["state_hash"], + "merchant_id": record.SafeReferences["merchant_id"], + "response_code": response.ResponseCode, + "auth_code_ref": request.Input.PaymentTokenReference, + "credential_kind": "customer_authorization_token", + } + return journey.Outcome{ + State: journey.Passed, + SafeData: safeData, + Finding: &contracts.Finding{ + Code: "CUSTOMER_TOKEN_REQUIRED", + Severity: "info", + Message: "persist the returned customer authorization token in your merchant application and rerun tokenized journeys using its credential reference", + }, + } +} + +func (h Handler) runBindingInquiry(ctx context.Context, request journey.Request, runner JourneyRunner) journey.Outcome { + customerReference := request.Input.PaymentTokenReference + if customerReference == "" { + return inputRequired("payment_token_reference is required") + } + accessToken, customerToken, outcome := resolveCustomerToken(ctx, request, runner) + if outcome != nil { + return *outcome + } + inquiry, err := runner.Client.Inquiry(ctx, InquiryRequestInput{ + AccessToken: accessToken, + CustomerToken: customerToken, + }) + if err != nil { + return blockedOutcome("binding inquiry failed") + } + return journey.Outcome{ + State: journey.Passed, + SafeData: map[string]any{ + "order_id": request.Input.OrderID, + "response_code": inquiry.ResponseCode, + "payment_option_count": strconv.Itoa(len(inquiry.PaymentOptions)), + "customer_token_ref": customerReference, + "rotated_token_present": strconv.FormatBool(strings.TrimSpace(inquiry.AccessTokenInfo.AccessToken) != ""), + }, + } +} + +func (h Handler) runTokenizedPayment(ctx context.Context, request journey.Request, runner JourneyRunner, optionType string) journey.Outcome { + accessToken, customerToken, outcome := resolveCustomerToken(ctx, request, runner) + if outcome != nil { + return *outcome + } + inquiry, err := runner.Client.Inquiry(ctx, InquiryRequestInput{ + AccessToken: accessToken, + CustomerToken: customerToken, + }) + if err != nil { + return blockedOutcome("binding inquiry failed") + } + paymentOption, ok := activePaymentOption(inquiry.PaymentOptions, optionType) + if !ok { + return blockedOutcome("the requested payment option is not active") + } + customerTokenForPayment := strings.TrimSpace(inquiry.AccessTokenInfo.AccessToken) + if customerTokenForPayment == "" { + customerTokenForPayment = customerToken + } + paymentAccessToken, err := runner.Client.AccessToken(ctx) + if err != nil { + return blockedOutcome("provider access token is unavailable") + } + payment, err := runner.Client.Payment(ctx, PaymentRequestInput{ + AccessToken: paymentAccessToken, + CustomerToken: customerTokenForPayment, + OrderID: request.Input.OrderID, + Amount: request.Input.Amount, + PaymentOptionToken: paymentOption.PaymentOptionToken, + PaymentOptionType: optionType, + }) + if err != nil { + return blockedOutcome("tokenized payment failed") + } + return journey.Outcome{ + State: journey.AwaitingUserAction, + SafeData: map[string]any{ + "order_id": request.Input.OrderID, + "gross_amount": strconv.FormatInt(request.Input.Amount, 10), + "payment_option": optionType, + "provider_reference": payment.ReferenceNo, + }, + Action: &journey.Action{ + Type: "browser", + URL: payment.WebRedirectURL, + Instructions: "complete the tokenized GoPay payment and rerun this journey", + ExpiresAt: runner.now().Add(15 * time.Minute), + ResumeCommand: "midtrans agent resume --operation " + request.OperationID, + }, + } +} + +func (h Handler) runUnlink(ctx context.Context, request journey.Request, runner JourneyRunner) journey.Outcome { + accessToken, customerToken, outcome := resolveCustomerToken(ctx, request, runner) + if outcome != nil { + return *outcome + } + if err := runner.Client.Unbind(ctx, UnbindRequestInput{ + AccessToken: accessToken, + CustomerToken: customerToken, + }); err != nil { + return blockedOutcome("account unlink failed") + } + return journey.Outcome{ + State: journey.Reconciling, + SafeData: map[string]any{ + "order_id": request.Input.OrderID, + "customer_token_ref": request.Input.PaymentTokenReference, + }, + MissingEvidence: []string{ + "gopay-tokenization.account-cleared", + }, + Finding: &contracts.Finding{ + Code: "MERCHANT_STATE_CLEAR_REQUIRED", + Severity: "blocking", + Message: "merchant application must clear the stored linked state after unlinking", + }, + } +} + +func (h Handler) runtimeRunner(ctx context.Context, request journey.Request, runtime journey.Runtime) (JourneyRunner, manifest.Integration, manifest.CredentialSet, *journey.Outcome) { + if h.runnerOverride { + integration, _ := request.Manifest.IntegrationFor("gopay-tokenization") + credentials, _ := request.Manifest.CredentialSetForIntegration("gopay-tokenization") + return h.runner, integration, credentials, nil + } + integration, ok := request.Manifest.IntegrationFor("gopay-tokenization") + if !ok { + outcome := blockedFinding("CAPABILITY_UNAVAILABLE", "gopay-tokenization integration is not configured for this project") + return JourneyRunner{}, manifest.Integration{}, manifest.CredentialSet{}, &outcome + } + credentials, ok := request.Manifest.CredentialSetFor(integration.Credentials) + if !ok || credentials.ClientID == "" || credentials.ClientSecret == "" || credentials.PartnerID == "" || credentials.ChannelID == "" || credentials.DeviceID == "" || credentials.PrivateKey == "" { + outcome := blockedFinding("CREDENTIAL_MISSING", "the configured gopay-tokenization BI-SNAP credential set is incomplete") + return JourneyRunner{}, manifest.Integration{}, manifest.CredentialSet{}, &outcome + } + if runtime.ResolveCredential == nil || runtime.HTTP == nil { + outcome := blockedFinding("JOURNEY_EXECUTION_BLOCKED", "journey runtime dependencies are unavailable") + return JourneyRunner{}, manifest.Integration{}, manifest.CredentialSet{}, &outcome + } + rawClientID, err := runtime.ResolveCredential(ctx, request.ProjectDir, credentials.ClientID) + if err != nil { + outcome := blockedFinding("CREDENTIAL_RESOLUTION_FAILED", "unable to resolve the configured client_id reference") + return JourneyRunner{}, manifest.Integration{}, manifest.CredentialSet{}, &outcome + } + rawClientSecret, err := runtime.ResolveCredential(ctx, request.ProjectDir, credentials.ClientSecret) + if err != nil { + outcome := blockedFinding("CREDENTIAL_RESOLUTION_FAILED", "unable to resolve the configured client_secret reference") + return JourneyRunner{}, manifest.Integration{}, manifest.CredentialSet{}, &outcome + } + rawPartnerID, err := runtime.ResolveCredential(ctx, request.ProjectDir, credentials.PartnerID) + if err != nil { + outcome := blockedFinding("CREDENTIAL_RESOLUTION_FAILED", "unable to resolve the configured partner_id reference") + return JourneyRunner{}, manifest.Integration{}, manifest.CredentialSet{}, &outcome + } + rawChannelID, err := runtime.ResolveCredential(ctx, request.ProjectDir, credentials.ChannelID) + if err != nil { + outcome := blockedFinding("CREDENTIAL_RESOLUTION_FAILED", "unable to resolve the configured channel_id reference") + return JourneyRunner{}, manifest.Integration{}, manifest.CredentialSet{}, &outcome + } + rawDeviceID, err := runtime.ResolveCredential(ctx, request.ProjectDir, credentials.DeviceID) + if err != nil { + outcome := blockedFinding("CREDENTIAL_RESOLUTION_FAILED", "unable to resolve the configured device_id reference") + return JourneyRunner{}, manifest.Integration{}, manifest.CredentialSet{}, &outcome + } + rawPrivateKey, err := runtime.ResolveCredential(ctx, request.ProjectDir, credentials.PrivateKey) + if err != nil { + outcome := blockedFinding("CREDENTIAL_RESOLUTION_FAILED", "unable to resolve the configured private_key reference") + return JourneyRunner{}, manifest.Integration{}, manifest.CredentialSet{}, &outcome + } + runner := JourneyRunner{ + Client: Client{ + HTTP: runtime.HTTP, + ClientID: strings.TrimSpace(string(rawClientID)), + PartnerID: strings.TrimSpace(string(rawPartnerID)), + ChannelID: strings.TrimSpace(string(rawChannelID)), + DeviceID: strings.TrimSpace(string(rawDeviceID)), + PrivateKeyPEM: append([]byte(nil), rawPrivateKey...), + ClientSecret: append([]byte(nil), rawClientSecret...), + Now: runtime.Now, + NewExternalID: newExternalIDGenerator(request.OperationID), + }, + Now: runtimeNow(runtime), + ResolveCredential: runtime.ResolveCredential, + } + return runner, integration, credentials, nil +} + +func resolveCustomerToken(ctx context.Context, request journey.Request, runner JourneyRunner) (string, string, *journey.Outcome) { + reference := request.Input.PaymentTokenReference + if reference == "" { + reference = defaultCustomerTokenReference + } + accessToken, err := runner.Client.AccessToken(ctx) + if err != nil { + outcome := blockedOutcome("provider access token is unavailable") + return "", "", &outcome + } + rawCustomerToken, err := runner.resolveCredential(ctx, request.ProjectDir, reference) + if err != nil { + outcome := blockedFinding("CREDENTIAL_RESOLUTION_FAILED", "unable to resolve the customer authorization token reference") + return "", "", &outcome + } + return accessToken, strings.TrimSpace(string(rawCustomerToken)), nil +} + +func activePaymentOption(options []PaymentOption, optionType string) (PaymentOption, bool) { + for _, option := range options { + if strings.EqualFold(option.PaymentOptionType, optionType) && strings.EqualFold(option.Status, "ACTIVE") { + return option, true + } + } + return PaymentOption{}, false +} + +func computeStateHash(values ...string) string { + hash := sha256.Sum256([]byte(strings.Join(values, "|"))) + return hex.EncodeToString(hash[:]) +} + +func rehydrateRequest(request journey.Request, record *operations.Record) journey.Request { + if record == nil || record.SafeReferences == nil { + return request + } + if request.Input.OrderID == "" { + request.Input.OrderID = record.SafeReferences["order_id"] + } + if request.Input.Amount <= 0 { + if amount, err := strconv.ParseInt(record.SafeReferences["gross_amount"], 10, 64); err == nil { + request.Input.Amount = amount + } + } + return request +} + +func newExternalIDGenerator(operationID string) func() (string, error) { + return func() (string, error) { + return operationID, nil + } +} + +func runtimeNow(runtime journey.Runtime) func() time.Time { + if runtime.Now != nil { + return runtime.Now + } + return func() time.Time { return time.Now().UTC() } +} + +func (r JourneyRunner) now() time.Time { + if r.Now != nil { + return r.Now() + } + return time.Now().UTC() +} + +func (r JourneyRunner) resolveCredential(ctx context.Context, projectDir, reference string) ([]byte, error) { + if r.ResolveCredential == nil { + return nil, blockedCredentialUnavailable() + } + return r.ResolveCredential(ctx, projectDir, reference) +} + +func hasStateValidationProof(request journey.Request, expectedStateHash string) bool { + if request.Evidence.SchemaVersion == "" || expectedStateHash == "" { + return false + } + for _, proof := range request.Evidence.Proofs { + if proof.OperationID != request.OperationID { + continue + } + if proof.Status != "pass" || proof.Source != "merchant_application" { + continue + } + if summaryString(proof.Summary, "state_hash") == expectedStateHash && + (summaryString(proof.Summary, "status") == "success" || summaryString(proof.Summary, "auth_code_reference") != "") { + return true + } + } + return false +} + +func summaryString(summary map[string]any, key string) string { + if summary == nil { + return "" + } + value, _ := summary[key].(string) + return value +} + +func blockedFinding(code, message string) journey.Outcome { + return journey.Outcome{ + State: journey.Blocked, + Finding: &contracts.Finding{ + Code: code, + Severity: "blocking", + Message: message, + }, + } +} + +func inputRequired(message string) journey.Outcome { + return journey.Outcome{ + State: journey.Blocked, + Finding: &contracts.Finding{ + Code: "JOURNEY_INPUT_REQUIRED", + Severity: "blocking", + Message: message, + }, + } +} + +func blockedOutcome(message string) journey.Outcome { + return blockedFinding("JOURNEY_EXECUTION_BLOCKED", message) +} + +func blockedCredentialUnavailable() error { + return errors.New("CREDENTIAL_RESOLUTION_FAILED") +} diff --git a/packs/gopaytokenization/journey_test.go b/packs/gopaytokenization/journey_test.go new file mode 100644 index 0000000..4b4676e --- /dev/null +++ b/packs/gopaytokenization/journey_test.go @@ -0,0 +1,251 @@ +package gopaytokenization_test + +import ( + "context" + "io" + "net/http" + "strings" + "testing" + + "github.com/veritrans/midtrans-cli/internal/evidence" + journeypkg "github.com/veritrans/midtrans-cli/internal/journey" + "github.com/veritrans/midtrans-cli/internal/manifest" + "github.com/veritrans/midtrans-cli/internal/operations" + "github.com/veritrans/midtrans-cli/packs/gopaytokenization" +) + +func TestAccountLinkingResumeRequiresStateProofAndAuthCodeReference(t *testing.T) { + handler := gopaytokenization.NewAccountLinkingHandler() + request := gopayRequest("gopay-tokenization.account-linking", "link-order", 0, "") + + first := handler.Execute(context.Background(), request, journeypkg.Runtime{ + ResolveCredential: gopayResolveCredential(t), + Now: fixedNow, + }) + if first.State != journeypkg.AwaitingUserAction { + t.Fatalf("first = %#v", first) + } + if first.Action == nil || first.Action.URL == "" { + t.Fatalf("action = %#v", first.Action) + } + if _, ok := first.SafeData["state_hash"]; !ok { + t.Fatalf("safe data = %#v", first.SafeData) + } + if _, ok := first.SafeData["auth_code"]; ok { + t.Fatalf("safe data leaked auth code: %#v", first.SafeData) + } + + record := operations.Record{ + SchemaVersion: 1, + OperationID: "op_gopay_link", + JourneyID: "gopay-tokenization.account-linking", + PackID: "gopay-tokenization", + ManifestHash: strings.Repeat("a", 64), + State: string(journeypkg.AwaitingUserAction), + SafeReferences: map[string]string{ + "order_id": "link-order", + "state_hash": first.SafeData["state_hash"].(string), + "merchant_id": "demo-merchant", + }, + StartedAt: fixedNow(), + UpdatedAt: fixedNow(), + } + resumed := handler.Resume(context.Background(), request, journeypkg.Runtime{ + ResolveCredential: gopayResolveCredential(t), + Now: fixedNow, + }, record) + if resumed.State != journeypkg.Blocked { + t.Fatalf("resumed = %#v", resumed) + } + if resumed.Finding == nil || resumed.Finding.Code != "JOURNEY_INPUT_REQUIRED" { + t.Fatalf("finding = %#v", resumed.Finding) + } +} + +func TestWalletPaymentRunsInquiryImmediatelyBeforePaymentAndUsesRotatedToken(t *testing.T) { + var paths []string + var customerHeaders []string + var paymentBodies []string + + handler := gopaytokenization.NewWalletPaymentHandler() + outcome := handler.Execute(context.Background(), gopayRequest("gopay-tokenization.wallet-payment", "wallet-order", 45000, "gopay"), journeypkg.Runtime{ + ResolveCredential: gopayResolveCredentialWithAuthToken(t), + Now: fixedNow, + HTTP: appDoerFunc(func(request *http.Request) (*http.Response, error) { + paths = append(paths, request.URL.Path) + customerHeaders = append(customerHeaders, request.Header.Get("Authorization-Customer")) + body, err := io.ReadAll(request.Body) + if err != nil { + t.Fatal(err) + } + paymentBodies = append(paymentBodies, string(body)) + switch request.URL.Path { + case "/v1.0/access-token/b2b": + return gopayResponse(http.StatusOK, `{"accessToken":"ACCESS-TOKEN-CANARY-DO-NOT-PRINT"}`), nil + case "/v1.0/registration-account-inquiry": + return gopayResponse(http.StatusOK, `{ + "responseCode":"2008800", + "accessTokenInfo":{"accessToken":"ROTATED-CUSTOMER-TOKEN-CANARY-DO-NOT-PRINT"}, + "paymentOptions":[ + {"paymentOptionType":"PAY_LATER","paymentOptionToken":"PAYLATER-TOKEN-CANARY-DO-NOT-PRINT","status":"INACTIVE"}, + {"paymentOptionType":"GOPAY_WALLET","paymentOptionToken":"WALLET-TOKEN-CANARY-DO-NOT-PRINT","status":"ACTIVE"} + ] + }`), nil + case "/v1.0/debit/payment-host-to-host": + return gopayResponse(http.StatusOK, `{ + "responseCode":"2005600", + "partnerReferenceNo":"wallet-order", + "referenceNo":"provider-wallet-001", + "webRedirectUrl":"https://simulator.sandbox.midtrans.com/gopay/web/redirect" + }`), nil + default: + t.Fatalf("unexpected path %q", request.URL.Path) + return nil, nil + } + }), + }) + + if outcome.State != journeypkg.AwaitingUserAction { + t.Fatalf("outcome = %#v", outcome) + } + if len(paths) != 4 || paths[1] != "/v1.0/registration-account-inquiry" || paths[3] != "/v1.0/debit/payment-host-to-host" { + t.Fatalf("paths = %#v", paths) + } + if customerHeaders[1] != "Bearer CUSTOMER-TOKEN-CANARY-DO-NOT-PRINT" { + t.Fatalf("inquiry customer header = %#v", customerHeaders) + } + if customerHeaders[3] != "Bearer ROTATED-CUSTOMER-TOKEN-CANARY-DO-NOT-PRINT" { + t.Fatalf("payment customer header = %#v", customerHeaders) + } + if !strings.Contains(paymentBodies[3], `"paymentOptionToken":"WALLET-TOKEN-CANARY-DO-NOT-PRINT"`) { + t.Fatalf("payment body = %s", paymentBodies[3]) + } + for _, key := range []string{"customer_authorization_token", "payment_option_token", "auth_code", "authorization_reference"} { + if _, ok := outcome.SafeData[key]; ok { + t.Fatalf("safe data leaked %q: %#v", key, outcome.SafeData) + } + } +} + +func TestPayLaterRequiresActivePayLaterOption(t *testing.T) { + handler := gopaytokenization.NewPayLaterHandler() + outcome := handler.Execute(context.Background(), gopayRequest("gopay-tokenization.paylater", "paylater-order", 65000, "gopaylater"), journeypkg.Runtime{ + ResolveCredential: gopayResolveCredentialWithAuthToken(t), + Now: fixedNow, + HTTP: appDoerFunc(func(request *http.Request) (*http.Response, error) { + switch request.URL.Path { + case "/v1.0/access-token/b2b": + return gopayResponse(http.StatusOK, `{"accessToken":"ACCESS-TOKEN-CANARY-DO-NOT-PRINT"}`), nil + case "/v1.0/registration-account-inquiry": + return gopayResponse(http.StatusOK, `{ + "responseCode":"2008800", + "accessTokenInfo":{"accessToken":"ROTATED-CUSTOMER-TOKEN-CANARY-DO-NOT-PRINT"}, + "paymentOptions":[ + {"paymentOptionType":"PAY_LATER","paymentOptionToken":"PAYLATER-TOKEN-CANARY-DO-NOT-PRINT","status":"INACTIVE"} + ] + }`), nil + default: + t.Fatalf("unexpected path %q", request.URL.Path) + return nil, nil + } + }), + }) + + if outcome.State != journeypkg.Blocked { + t.Fatalf("outcome = %#v", outcome) + } + if outcome.Finding == nil || outcome.Finding.Code != "JOURNEY_EXECUTION_BLOCKED" { + t.Fatalf("finding = %#v", outcome.Finding) + } +} + +func gopayRequest(journeyID, orderID string, amount int64, method string) journeypkg.Request { + return journeypkg.Request{ + OperationID: "op_gopay_test", + ProjectDir: "/tmp/project", + ManifestHash: strings.Repeat("a", 64), + Manifest: validGoPayManifest(), + Evidence: evidence.Bundle{}, + Input: journeypkg.Input{ + OrderID: orderID, + Amount: amount, + Method: method, + }, + } +} + +func validGoPayManifest() manifest.Manifest { + value := manifest.Default() + value.Application.BaseURL = "http://127.0.0.1:3101" + value.CredentialSets["bisnap"] = manifest.CredentialSet{ + Type: "bisnap", + Environment: "sandbox", + ClientID: "env:MIDTRANS_BISNAP_CLIENT_ID", + ClientSecret: "env:MIDTRANS_BISNAP_CLIENT_SECRET", + PartnerID: "env:MIDTRANS_BISNAP_PARTNER_ID", + ChannelID: "env:MIDTRANS_BISNAP_CHANNEL_ID", + DeviceID: "env:MIDTRANS_BISNAP_DEVICE_ID", + PrivateKey: "file:./secrets/bisnap-private.pem", + MidtransPublicKey: "file:./secrets/bisnap-public.pem", + } + value.Integrations["gopay-tokenization"] = manifest.Integration{ + ConfigVersion: 1, + Credentials: "bisnap", + Capabilities: []string{"account-linking", "wallet-payment"}, + Callbacks: map[string]string{ + "account_linking": "/api/payments/midtrans/gopay/account", + "payment": "/api/payments/midtrans/gopay/payment", + "return": "/payments/gopay/return", + }, + } + return value +} + +func gopayResolveCredential(t *testing.T) func(context.Context, string, string) ([]byte, error) { + t.Helper() + return func(_ context.Context, _ string, reference string) ([]byte, error) { + switch reference { + case "env:MIDTRANS_BISNAP_CLIENT_ID": + return []byte("CLIENT-ID-CANARY-DO-NOT-PRINT"), nil + case "env:MIDTRANS_BISNAP_CLIENT_SECRET": + return []byte("CLIENT-SECRET-CANARY-DO-NOT-PRINT"), nil + case "env:MIDTRANS_BISNAP_PARTNER_ID": + return []byte("G123456"), nil + case "env:MIDTRANS_BISNAP_CHANNEL_ID": + return []byte("12345"), nil + case "env:MIDTRANS_BISNAP_DEVICE_ID": + return []byte("device-canary"), nil + case "file:./secrets/bisnap-private.pem": + return fixtureBytes(t, "private_key_pkcs8.pem"), nil + case "file:./secrets/bisnap-public.pem": + return fixtureBytes(t, "public_key_pkix.pem"), nil + default: + t.Fatalf("unexpected reference %q", reference) + return nil, nil + } + } +} + +func gopayResolveCredentialWithAuthToken(t *testing.T) func(context.Context, string, string) ([]byte, error) { + base := gopayResolveCredential(t) + return func(ctx context.Context, projectDir, reference string) ([]byte, error) { + if reference == "env:MIDTRANS_GOPAY_CUSTOMER_TOKEN" { + return []byte("CUSTOMER-TOKEN-CANARY-DO-NOT-PRINT"), nil + } + return base(ctx, projectDir, reference) + } +} + +func gopayResponse(status int, body string) *http.Response { + return &http.Response{ + StatusCode: status, + Body: io.NopCloser(strings.NewReader(body)), + Header: make(http.Header), + } +} + +type appDoerFunc func(*http.Request) (*http.Response, error) + +func (f appDoerFunc) Do(request *http.Request) (*http.Response, error) { + return f(request) +} diff --git a/packs/gopaytokenization/pack.go b/packs/gopaytokenization/pack.go new file mode 100644 index 0000000..5e5f400 --- /dev/null +++ b/packs/gopaytokenization/pack.go @@ -0,0 +1,90 @@ +package gopaytokenization + +import ( + "github.com/veritrans/midtrans-cli/internal/contracts" + "github.com/veritrans/midtrans-cli/internal/inspection" + "github.com/veritrans/midtrans-cli/internal/journey" + "github.com/veritrans/midtrans-cli/internal/manifest" + "github.com/veritrans/midtrans-cli/internal/packs" +) + +type Pack struct{} + +func New() Pack { return Pack{} } + +func (Pack) Descriptor() packs.Descriptor { + return packs.Descriptor{ + ID: "gopay-tokenization", + Version: "0.1.0", + Capabilities: []contracts.Capability{ + {ID: "gopay-tokenization.account-linking.verify.v1", Description: "run and verify a GoPay account-linking journey", Pack: "gopay-tokenization"}, + {ID: "gopay-tokenization.binding-inquiry.verify.v1", Description: "run and verify a GoPay binding-inquiry journey", Pack: "gopay-tokenization"}, + {ID: "gopay-tokenization.paylater.verify.v1", Description: "run and verify a GoPayLater tokenized payment journey", Pack: "gopay-tokenization"}, + {ID: "gopay-tokenization.unlink.verify.v1", Description: "run and verify a GoPay unlink journey", Pack: "gopay-tokenization"}, + {ID: "gopay-tokenization.wallet-payment.verify.v1", Description: "run and verify a tokenized GoPay wallet-payment journey", Pack: "gopay-tokenization"}, + }, + Journeys: []string{ + "gopay-tokenization.account-linking", + "gopay-tokenization.binding-inquiry", + "gopay-tokenization.wallet-payment", + "gopay-tokenization.paylater", + "gopay-tokenization.unlink", + }, + SandboxHosts: []string{ + "merchants.sbx.midtrans.com", + "merchants-app.sbx.midtrans.com", + "simulator.sandbox.midtrans.com", + }, + SensitiveKeys: []string{ + "access_token", + "client_secret", + "customer_authorization_token", + "payment_option_token", + "auth_code", + "authorization_reference", + "signature", + "token", + }, + Sources: []contracts.PublicSource{ + {ID: "gopay-tokenization-overview", URL: "https://docs.midtrans.com/reference/core-api-snap-open-api-overview", Rules: []string{"gopaytokenization.signing.verify.v1"}}, + {ID: "gopay-tokenization-bind", URL: "https://docs.midtrans.com/reference/direct-debit-api-gopay", Rules: []string{"gopaytokenization.linking.bind", "gopaytokenization.linking.inquiry", "gopaytokenization.wallet.charge", "gopaytokenization.unlink"}}, + {ID: "gopay-tokenization-notifications", URL: "https://docs.midtrans.com/reference/payment-notification-api", Rules: []string{"gopaytokenization.notification.signature", "common.webhook-idempotency"}}, + }, + } +} + +func (Pack) Evaluate(value manifest.Manifest, _ inspection.Report) []contracts.Finding { + integration, ok := value.IntegrationFor("gopay-tokenization") + if !ok { + return []contracts.Finding{{ + Code: "GOPAY_TOKENIZATION_PRODUCT_NOT_SELECTED", + Severity: "blocking", + Message: "integrations must include gopay-tokenization", + }} + } + if integration.Callbacks["account_linking"] == "" { + return []contracts.Finding{{ + Code: "GOPAY_TOKENIZATION_LINKING_ROUTE_MISSING", + Severity: "blocking", + Message: "integrations.gopay-tokenization.callbacks.account_linking is required", + }} + } + if integration.Callbacks["payment"] == "" { + return []contracts.Finding{{ + Code: "GOPAY_TOKENIZATION_PAYMENT_ROUTE_MISSING", + Severity: "blocking", + Message: "integrations.gopay-tokenization.callbacks.payment is required", + }} + } + return nil +} + +func (Pack) Handlers() []journey.Handler { + return []journey.Handler{ + NewAccountLinkingHandler(), + NewBindingInquiryHandler(), + NewWalletPaymentHandler(), + NewPayLaterHandler(), + NewUnlinkHandler(), + } +} diff --git a/packs/gopaytokenization/pack_test.go b/packs/gopaytokenization/pack_test.go new file mode 100644 index 0000000..4dd6411 --- /dev/null +++ b/packs/gopaytokenization/pack_test.go @@ -0,0 +1,36 @@ +package gopaytokenization_test + +import ( + "reflect" + "testing" + + "github.com/veritrans/midtrans-cli/packs/gopaytokenization" +) + +func TestPackDescriptorPublishesGoPayTokenizationJourneysAndCapabilities(t *testing.T) { + descriptor := gopaytokenization.New().Descriptor() + wantCapabilities := []string{ + "gopay-tokenization.account-linking.verify.v1", + "gopay-tokenization.binding-inquiry.verify.v1", + "gopay-tokenization.paylater.verify.v1", + "gopay-tokenization.unlink.verify.v1", + "gopay-tokenization.wallet-payment.verify.v1", + } + gotCapabilities := make([]string, 0, len(descriptor.Capabilities)) + for _, capability := range descriptor.Capabilities { + gotCapabilities = append(gotCapabilities, capability.ID) + } + if !reflect.DeepEqual(gotCapabilities, wantCapabilities) { + t.Fatalf("capabilities = %#v", gotCapabilities) + } + wantJourneys := []string{ + "gopay-tokenization.account-linking", + "gopay-tokenization.binding-inquiry", + "gopay-tokenization.wallet-payment", + "gopay-tokenization.paylater", + "gopay-tokenization.unlink", + } + if !reflect.DeepEqual(descriptor.Journeys, wantJourneys) { + t.Fatalf("journeys = %#v", descriptor.Journeys) + } +} diff --git a/packs/gopaytokenization/seamless.go b/packs/gopaytokenization/seamless.go new file mode 100644 index 0000000..57faa16 --- /dev/null +++ b/packs/gopaytokenization/seamless.go @@ -0,0 +1,37 @@ +package gopaytokenization + +import ( + "net/http" + + "github.com/veritrans/midtrans-cli/packs/bisnap" +) + +type NotificationRoute struct { + Path string + SuccessCode string + FailureCode string +} + +var accountNotificationRoute = NotificationRoute{ + Path: accountNotifyPath, + SuccessCode: "2008800", + FailureCode: "4018800", +} + +func NotificationRouteForPath(path string) (NotificationRoute, bool) { + if path == accountNotifyPath { + return accountNotificationRoute, true + } + return NotificationRoute{}, false +} + +func VerifyNotificationCallback(publicKeyPEM []byte, path string, body []byte, timestamp, signature string) (NotificationRoute, error) { + route, ok := NotificationRouteForPath(path) + if !ok { + return NotificationRoute{}, bisnap.VerifyNotification(publicKeyPEM, http.MethodPost, path, body, timestamp, signature) + } + if err := bisnap.VerifyNotification(publicKeyPEM, http.MethodPost, path, body, timestamp, signature); err != nil { + return NotificationRoute{}, err + } + return route, nil +} diff --git a/packs/gopaytokenization/seamless_test.go b/packs/gopaytokenization/seamless_test.go new file mode 100644 index 0000000..55f11b2 --- /dev/null +++ b/packs/gopaytokenization/seamless_test.go @@ -0,0 +1,63 @@ +package gopaytokenization_test + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" + + journeypkg "github.com/veritrans/midtrans-cli/internal/journey" + "github.com/veritrans/midtrans-cli/internal/operations" + "github.com/veritrans/midtrans-cli/packs/gopaytokenization" +) + +func TestAccountLinkingPersistenceStoresOnlySafeState(t *testing.T) { + projectDir := t.TempDir() + handler := gopaytokenization.NewAccountLinkingHandler() + engine := journeypkg.Engine{ + Store: operations.Store{ProjectDir: projectDir}, + Runtime: journeypkg.Runtime{ + Now: fixedNow, + NewOperationID: func() string { return "op_gopay_persist" }, + SensitiveKeys: []string{ + "auth_code", + "customer_authorization_token", + "payment_option_token", + "authorization_reference", + }, + }, + } + + outcome := engine.Run(context.Background(), handler, gopayRequest("gopay-tokenization.account-linking", "persist-order", 0, ""), true) + if outcome.State != journeypkg.AwaitingUserAction { + t.Fatalf("outcome = %#v", outcome) + } + + recordPath := filepath.Join(projectDir, ".midtrans", "operations") + entries, err := os.ReadDir(recordPath) + if err != nil { + t.Fatal(err) + } + if len(entries) != 1 { + t.Fatalf("entries = %d", len(entries)) + } + data, err := os.ReadFile(filepath.Join(recordPath, entries[0].Name())) + if err != nil { + t.Fatal(err) + } + text := string(data) + for _, secret := range []string{ + "AUTH-CODE", + "CUSTOMER-TOKEN", + "PAYLATER-TOKEN", + "authorization_reference", + } { + if strings.Contains(text, secret) { + t.Fatalf("record leaked %q: %s", secret, text) + } + } + if !strings.Contains(text, "state_hash") { + t.Fatalf("record missing safe state hash: %s", text) + } +} diff --git a/packs/gopaytokenization/test_helpers_test.go b/packs/gopaytokenization/test_helpers_test.go new file mode 100644 index 0000000..5a8c7de --- /dev/null +++ b/packs/gopaytokenization/test_helpers_test.go @@ -0,0 +1,16 @@ +package gopaytokenization_test + +import ( + "os" + "path/filepath" + "testing" +) + +func fixtureBytes(t *testing.T, name string) []byte { + t.Helper() + data, err := os.ReadFile(filepath.Join("..", "..", "testdata", "bisnap", name)) + if err != nil { + t.Fatal(err) + } + return data +} diff --git a/testdata/gopaytokenization/README.md b/testdata/gopaytokenization/README.md new file mode 100644 index 0000000..95a5af3 --- /dev/null +++ b/testdata/gopaytokenization/README.md @@ -0,0 +1,2 @@ +GoPay tokenization fixtures live in `testdata/bisnap` until this pack needs +pack-specific provider payload samples. From e56efbcf4aa2b7f6c0b2fe72297e897073e2226d Mon Sep 17 00:00:00 2001 From: "m.salis" Date: Mon, 27 Jul 2026 12:47:23 +0700 Subject: [PATCH 51/73] fix: align GoPay tokenization contracts --- .../task-10-report.md | 26 +++ contracts/public-sources-v1.json | 47 +++-- internal/manifest/model.go | 1 + internal/manifest/validate.go | 3 + packs/gopaytokenization/client.go | 135 ++++++++++---- packs/gopaytokenization/client_test.go | 127 +++++++++++-- packs/gopaytokenization/journey.go | 148 +++++++++++---- packs/gopaytokenization/journey_test.go | 172 ++++++++++++++++-- packs/gopaytokenization/pack.go | 9 +- packs/gopaytokenization/seamless_test.go | 8 +- schemas/manifest-v1.schema.json | 4 + 11 files changed, 570 insertions(+), 110 deletions(-) diff --git a/.superpowers/sdd/2026-07-26-midtrans-cli-multi-product-parity/task-10-report.md b/.superpowers/sdd/2026-07-26-midtrans-cli-multi-product-parity/task-10-report.md index efdc9ed..df60213 100644 --- a/.superpowers/sdd/2026-07-26-midtrans-cli-multi-product-parity/task-10-report.md +++ b/.superpowers/sdd/2026-07-26-midtrans-cli-multi-product-parity/task-10-report.md @@ -75,3 +75,29 @@ Result: passed. ## Commit Planned commit message: `feat: add GoPay tokenization journeys` + +## Fix Round 1 + +Addressed the official-contract corrections from reviewer follow-up: + +- Corrected Get Auth Code to `GET https://merchants-app.sbx.midtrans.com/v1.0/get-auth-code`. +- Switched binding, inquiry, unbind, and payment to `https://merchants.sbx.midtrans.com`. +- Added a distinct manifest credential reference `merchant_id` for GoPay tokenization credential sets. +- Bound account-link state through deterministic compact `seamlessData` JSON plus `seamlessSign`. +- Corrected binding body to include `merchantId`, `authCode`, and `grantType: AUTHORIZATION_CODE`. +- Corrected binding response parsing to `accessTokenInfo.accessToken`. +- Corrected inquiry response parsing to `additionalInfo.accessToken` and `additionalInfo.paymentOptions[] {name, active, token}`. +- Corrected tokenized payment body to include `chargeToken`, `urlParams`, and `payOptionDetails[]` with nested `additionalInfo.paymentOptionToken`. +- Removed credential references from GoPay SafeData and persisted operation records. +- Required exact `auth_code_reference_hash` plus `state_hash` proof binding for account-link resume. +- Added unlink fallback inquiry handling for ambiguous unbind attempts. +- Replaced GoPay public-source URLs with the reviewer-specified official page set. + +Fix-round validation: + +```sh +go test ./packs/gopaytokenization ./packs/bisnap ./internal/app ./internal/manifest ./internal/sourceprovenance -count=1 +go test ./... -count=1 +``` + +Result: passed. diff --git a/contracts/public-sources-v1.json b/contracts/public-sources-v1.json index 9b917da..7d29e89 100644 --- a/contracts/public-sources-v1.json +++ b/contracts/public-sources-v1.json @@ -229,29 +229,54 @@ "retrieved_at": "2026-07-27T05:02:08.464875Z" }, { - "id": "gopay-tokenization-overview", - "url": "https://docs.midtrans.com/reference/core-api-snap-open-api-overview", + "id": "gopay-tokenization-get-auth-code", + "url": "https://docs.midtrans.com/reference/get-auth-code-api", "rules": [ - "gopaytokenization.signing.verify.v1" + "gopaytokenization.linking.get-auth-code" ], - "sha256": "42b94bb13823e0e3122a5ec5e73f3a002a969911d05230022c0f08eed9b36288", + "sha256": "", "retrieved_at": "2026-07-27T05:02:08.464875Z" }, { - "id": "gopay-tokenization-bind", - "url": "https://docs.midtrans.com/reference/direct-debit-api-gopay", + "id": "gopay-tokenization-bind-account", + "url": "https://docs.midtrans.com/reference/bind-account-api", + "rules": [ + "gopaytokenization.linking.bind" + ], + "sha256": "", + "retrieved_at": "2026-07-27T05:02:08.464875Z" + }, + { + "id": "gopay-tokenization-binding-inquiry", + "url": "https://docs.midtrans.com/reference/account-binding-inquiry-api", + "rules": [ + "gopaytokenization.linking.inquiry" + ], + "sha256": "", + "retrieved_at": "2026-07-27T05:02:08.464875Z" + }, + { + "id": "gopay-tokenization-direct-debit", + "url": "https://docs.midtrans.com/reference/direct-debit-api-gopay-tokenization", "rules": [ - "gopaytokenization.linking.bind", - "gopaytokenization.linking.inquiry", "gopaytokenization.wallet.charge", + "gopaytokenization.paylater.charge" + ], + "sha256": "", + "retrieved_at": "2026-07-27T05:02:08.464875Z" + }, + { + "id": "gopay-tokenization-unbind", + "url": "https://docs.midtrans.com/reference/unbind-api", + "rules": [ "gopaytokenization.unlink" ], - "sha256": "7816e56ae9fb3d87d8e36d18b7d03f297a08808292b1ae4555841b2b041082b1", + "sha256": "", "retrieved_at": "2026-07-27T05:02:08.464875Z" }, { - "id": "gopay-tokenization-notifications", - "url": "https://docs.midtrans.com/reference/payment-notification-api", + "id": "gopay-tokenization-account-notifications", + "url": "https://docs.midtrans.com/reference/account-linking-and-unlinking-notification", "rules": [ "gopaytokenization.notification.signature", "common.webhook-idempotency" diff --git a/internal/manifest/model.go b/internal/manifest/model.go index 95065a9..0f8ca8f 100644 --- a/internal/manifest/model.go +++ b/internal/manifest/model.go @@ -38,6 +38,7 @@ type CredentialSet struct { PartnerID string `yaml:"partner_id,omitempty" json:"partner_id,omitempty"` ChannelID string `yaml:"channel_id,omitempty" json:"channel_id,omitempty"` DeviceID string `yaml:"device_id,omitempty" json:"device_id,omitempty"` + MerchantID string `yaml:"merchant_id,omitempty" json:"merchant_id,omitempty"` PrivateKey string `yaml:"private_key,omitempty" json:"private_key,omitempty"` MidtransPublicKey string `yaml:"midtrans_public_key,omitempty" json:"midtrans_public_key,omitempty"` } diff --git a/internal/manifest/validate.go b/internal/manifest/validate.go index 8c3e891..f712df8 100644 --- a/internal/manifest/validate.go +++ b/internal/manifest/validate.go @@ -224,6 +224,7 @@ func credentialReferences(set CredentialSet) []string { set.PartnerID, set.ChannelID, set.DeviceID, + set.MerchantID, set.PrivateKey, set.MidtransPublicKey, } @@ -267,6 +268,8 @@ func credentialReferenceForKey(set CredentialSet, key string) string { return set.ChannelID case "device_id": return set.DeviceID + case "merchant_id": + return set.MerchantID case "private_key": return set.PrivateKey case "midtrans_public_key": diff --git a/packs/gopaytokenization/client.go b/packs/gopaytokenization/client.go index 1c5639f..6fe28a7 100644 --- a/packs/gopaytokenization/client.go +++ b/packs/gopaytokenization/client.go @@ -3,6 +3,9 @@ package gopaytokenization import ( "bytes" "context" + "crypto/hmac" + "crypto/sha512" + "encoding/base64" "encoding/json" "errors" "io" @@ -21,7 +24,7 @@ const gopayMaxResponseBytes = 64 << 10 const ( sandboxAPIBaseURL = "https://merchants.sbx.midtrans.com" sandboxApplicationBaseURL = "https://merchants-app.sbx.midtrans.com" - getAuthCodePath = "/partner/app" + getAuthCodePath = "/v1.0/get-auth-code" bindingPath = "/v1.0/registration-account-binding" inquiryPath = "/v1.0/registration-account-inquiry" unbindPath = "/v1.0/registration-account-unbinding" @@ -35,12 +38,37 @@ type amountDetails struct { Currency string `json:"currency"` } +type urlParam struct { + URL string `json:"url"` + Type string `json:"type"` +} + +type payOptionAdditionalInfo struct { + PaymentOptionToken string `json:"paymentOptionToken"` +} + +type payOptionDetail struct { + PayMethod string `json:"payMethod"` + PayOption string `json:"payOption"` + TransAmount amountDetails `json:"transAmount"` + AdditionalInfo payOptionAdditionalInfo `json:"additionalInfo"` +} + +type paymentRequestBody struct { + PartnerReferenceNo string `json:"partnerReferenceNo"` + MerchantID string `json:"merchantId"` + ChargeToken string `json:"chargeToken"` + URLParams []urlParam `json:"urlParams"` + PayOptionDetails []payOptionDetail `json:"payOptionDetails"` +} + type Client struct { HTTP sandbox.Doer ClientID string PartnerID string ChannelID string DeviceID string + MerchantID string PrivateKeyPEM []byte ClientSecret []byte Now func() time.Time @@ -48,12 +76,16 @@ type Client struct { } type GetAuthCodeInput struct { - StateHash string - MerchantHandle string + StateHash string + MerchantID string + RedirectURL string + Scopes []string + Lang string } type BindingRequestInput struct { AccessToken string + MerchantID string AuthCode string } @@ -64,40 +96,46 @@ type InquiryRequestInput struct { type UnbindRequestInput struct { AccessToken string + MerchantID string CustomerToken string } type PaymentRequestInput struct { AccessToken string CustomerToken string + MerchantID string OrderID string Amount int64 PaymentOptionToken string PaymentOptionType string + RedirectURL string } type BindingResponse struct { - ResponseCode string `json:"responseCode"` - ResponseMessage string `json:"responseMessage"` - CustomerToken string `json:"customerToken"` - AuthorizationReference string `json:"authorizationReference"` + ResponseCode string `json:"responseCode"` + ResponseMessage string `json:"responseMessage"` + AccessTokenInfo BindingAccessTokenInfo `json:"accessTokenInfo"` +} + +type BindingAccessTokenInfo struct { + AccessToken string `json:"accessToken"` } type InquiryResponse struct { - ResponseCode string `json:"responseCode"` - ResponseMessage string `json:"responseMessage"` - AccessTokenInfo AccessTokenInfo `json:"accessTokenInfo"` - PaymentOptions []PaymentOption `json:"paymentOptions"` + ResponseCode string `json:"responseCode"` + ResponseMessage string `json:"responseMessage"` + AdditionalInfo InquiryAdditionalInfo `json:"additionalInfo"` } -type AccessTokenInfo struct { - AccessToken string `json:"accessToken"` +type InquiryAdditionalInfo struct { + AccessToken string `json:"accessToken"` + PaymentOptions []PaymentOption `json:"paymentOptions"` } type PaymentOption struct { - PaymentOptionType string `json:"paymentOptionType"` - PaymentOptionToken string `json:"paymentOptionToken"` - Status string `json:"status"` + Name string `json:"name"` + Active bool `json:"active"` + Token string `json:"token"` } type PaymentResponse struct { @@ -116,12 +154,27 @@ func (c Client) now() time.Time { } func (c Client) NewGetAuthCodeRequest(ctx context.Context, input GetAuthCodeInput) (*http.Request, error) { - if strings.TrimSpace(input.StateHash) == "" || strings.TrimSpace(input.MerchantHandle) == "" { + if strings.TrimSpace(input.StateHash) == "" || strings.TrimSpace(input.MerchantID) == "" || strings.TrimSpace(input.RedirectURL) == "" { + return nil, bisnapRequestInvalid() + } + if len(c.ClientSecret) == 0 { + return nil, bisnapRequestInvalid() + } + seamlessData, err := json.Marshal(map[string]string{"state_hash": strings.TrimSpace(input.StateHash)}) + if err != nil { return nil, bisnapRequestInvalid() } values := url.Values{} - values.Set("state", input.StateHash) - values.Set("merchant_id", input.MerchantHandle) + values.Set("merchantId", input.MerchantID) + values.Set("redirectURL", input.RedirectURL) + values.Set("scopes", strings.Join(input.Scopes, ",")) + lang := strings.TrimSpace(input.Lang) + if lang == "" { + lang = "en" + } + values.Set("lang", lang) + values.Set("seamlessData", string(seamlessData)) + values.Set("seamlessSign", signSeamlessData(c.ClientSecret, seamlessData)) request, err := http.NewRequestWithContext( ctx, http.MethodGet, @@ -136,12 +189,14 @@ func (c Client) NewGetAuthCodeRequest(ctx context.Context, input GetAuthCodeInpu func (c Client) NewBindingRequest(ctx context.Context, input BindingRequestInput) (*http.Request, error) { body, err := json.Marshal(map[string]any{ - "authCode": input.AuthCode, + "merchantId": input.MerchantID, + "authCode": input.AuthCode, + "grantType": "AUTHORIZATION_CODE", }) if err != nil { return nil, bisnapRequestInvalid() } - return c.newTransactionRequest(ctx, http.MethodPost, bindingPath, input.AccessToken, "", body, true) + return c.newTransactionRequest(ctx, http.MethodPost, bindingPath, input.AccessToken, "", body, false) } func (c Client) NewInquiryRequest(ctx context.Context, input InquiryRequestInput) (*http.Request, error) { @@ -149,26 +204,36 @@ func (c Client) NewInquiryRequest(ctx context.Context, input InquiryRequestInput if err != nil { return nil, bisnapRequestInvalid() } - return c.newTransactionRequest(ctx, http.MethodPost, inquiryPath, input.AccessToken, input.CustomerToken, body, true) + return c.newTransactionRequest(ctx, http.MethodPost, inquiryPath, input.AccessToken, input.CustomerToken, body, false) } func (c Client) NewUnbindRequest(ctx context.Context, input UnbindRequestInput) (*http.Request, error) { - body, err := json.Marshal(map[string]any{}) + body, err := json.Marshal(map[string]any{ + "merchantId": input.MerchantID, + }) if err != nil { return nil, bisnapRequestInvalid() } - return c.newTransactionRequest(ctx, http.MethodPost, unbindPath, input.AccessToken, input.CustomerToken, body, true) + return c.newTransactionRequest(ctx, http.MethodPost, unbindPath, input.AccessToken, input.CustomerToken, body, false) } func (c Client) NewPaymentRequest(ctx context.Context, input PaymentRequestInput) (*http.Request, error) { - body, err := json.Marshal(map[string]any{ - "partnerReferenceNo": input.OrderID, - "serviceCode": defaultServiceCode, - "amount": amountDetails{Value: amountValue(input.Amount), Currency: "IDR"}, - "additionalInfo": map[string]any{ - "paymentOptionToken": input.PaymentOptionToken, - "paymentOptionType": input.PaymentOptionType, - }, + body, err := json.Marshal(paymentRequestBody{ + PartnerReferenceNo: input.OrderID, + MerchantID: input.MerchantID, + ChargeToken: input.CustomerToken, + URLParams: []urlParam{{ + URL: input.RedirectURL, + Type: "PAY_RETURN", + }}, + PayOptionDetails: []payOptionDetail{{ + PayMethod: "GOPAY", + PayOption: input.PaymentOptionType, + TransAmount: amountDetails{Value: amountValue(input.Amount), Currency: "IDR"}, + AdditionalInfo: payOptionAdditionalInfo{ + PaymentOptionToken: input.PaymentOptionToken, + }, + }}, }) if err != nil { return nil, bisnapRequestInvalid() @@ -254,6 +319,12 @@ func (c Client) Payment(ctx context.Context, input PaymentRequestInput) (Payment return result, nil } +func signSeamlessData(secret []byte, seamlessData []byte) string { + mac := hmac.New(sha512.New, secret) + _, _ = mac.Write(seamlessData) + return base64.StdEncoding.EncodeToString(mac.Sum(nil)) +} + func (c Client) newTransactionRequest(ctx context.Context, method, path, accessToken, customerToken string, body []byte, useApplicationHost bool) (*http.Request, error) { bridge := c.bisnapClient() return bridge.NewTransactionRequest(ctx, bisnap.Request{ diff --git a/packs/gopaytokenization/client_test.go b/packs/gopaytokenization/client_test.go index 0988bd9..ec7a24d 100644 --- a/packs/gopaytokenization/client_test.go +++ b/packs/gopaytokenization/client_test.go @@ -2,6 +2,7 @@ package gopaytokenization_test import ( "context" + "encoding/json" "io" "net/http" "strings" @@ -24,8 +25,11 @@ func TestClientUsesSandboxHostsAndSeparatesTokenizedHeaders(t *testing.T) { } authCodeRequest, err := client.NewGetAuthCodeRequest(context.Background(), gopaytokenization.GetAuthCodeInput{ - StateHash: "state-hash-123", - MerchantHandle: "demo-merchant", + StateHash: "state-hash-123", + MerchantID: "demo-merchant", + RedirectURL: "http://127.0.0.1:3101/payments/gopay/return", + Scopes: []string{"PAYMENT_ONETIME", "PAYMENT_BINDING"}, + Lang: "id", }) if err != nil { t.Fatal(err) @@ -33,26 +37,56 @@ func TestClientUsesSandboxHostsAndSeparatesTokenizedHeaders(t *testing.T) { if authCodeRequest.Method != http.MethodGet { t.Fatalf("auth code method = %q", authCodeRequest.Method) } - if authCodeRequest.URL.Host != "merchants-app.sbx.midtrans.com" { - t.Fatalf("auth code host = %q", authCodeRequest.URL.Host) + if authCodeRequest.URL.String() == "" || authCodeRequest.URL.Host != "merchants-app.sbx.midtrans.com" || authCodeRequest.URL.Path != "/v1.0/get-auth-code" { + t.Fatalf("auth code url = %s", authCodeRequest.URL) } - if got := authCodeRequest.URL.Query().Get("state"); got != "state-hash-123" { - t.Fatalf("state = %q", got) + query := authCodeRequest.URL.Query() + if got := query.Get("merchantId"); got != "demo-merchant" { + t.Fatalf("merchantId = %q", got) + } + if got := query.Get("redirectURL"); got != "http://127.0.0.1:3101/payments/gopay/return" { + t.Fatalf("redirectURL = %q", got) + } + if got := query.Get("scopes"); got != "PAYMENT_ONETIME,PAYMENT_BINDING" { + t.Fatalf("scopes = %q", got) + } + if got := query.Get("lang"); got != "id" { + t.Fatalf("lang = %q", got) + } + var seamlessData map[string]string + if err := json.Unmarshal([]byte(query.Get("seamlessData")), &seamlessData); err != nil { + t.Fatalf("seamlessData = %q err=%v", query.Get("seamlessData"), err) + } + if seamlessData["state_hash"] != "state-hash-123" { + t.Fatalf("seamlessData = %#v", seamlessData) + } + if query.Get("seamlessSign") == "" { + t.Fatalf("seamlessSign is empty") } bindingRequest, err := client.NewBindingRequest(context.Background(), gopaytokenization.BindingRequestInput{ AccessToken: "ACCESS-TOKEN-CANARY-DO-NOT-PRINT", + MerchantID: "demo-merchant", AuthCode: "AUTH-CODE-CANARY-DO-NOT-PRINT", }) if err != nil { t.Fatal(err) } - if bindingRequest.Method != http.MethodPost || bindingRequest.URL.String() != "https://merchants-app.sbx.midtrans.com/v1.0/registration-account-binding" { + if bindingRequest.Method != http.MethodPost || bindingRequest.URL.String() != "https://merchants.sbx.midtrans.com/v1.0/registration-account-binding" { t.Fatalf("binding request = %s %s", bindingRequest.Method, bindingRequest.URL) } if got := bindingRequest.Header.Get("Authorization-Customer"); got != "" { t.Fatalf("binding Authorization-Customer = %q", got) } + bindingBody, err := io.ReadAll(bindingRequest.Body) + if err != nil { + t.Fatal(err) + } + for _, fragment := range []string{`"merchantId":"demo-merchant"`, `"authCode":"AUTH-CODE-CANARY-DO-NOT-PRINT"`, `"grantType":"AUTHORIZATION_CODE"`} { + if !strings.Contains(string(bindingBody), fragment) { + t.Fatalf("binding body missing %q: %s", fragment, bindingBody) + } + } inquiryRequest, err := client.NewInquiryRequest(context.Background(), gopaytokenization.InquiryRequestInput{ AccessToken: "ACCESS-TOKEN-CANARY-DO-NOT-PRINT", @@ -61,7 +95,7 @@ func TestClientUsesSandboxHostsAndSeparatesTokenizedHeaders(t *testing.T) { if err != nil { t.Fatal(err) } - if inquiryRequest.Method != http.MethodPost || inquiryRequest.URL.String() != "https://merchants-app.sbx.midtrans.com/v1.0/registration-account-inquiry" { + if inquiryRequest.Method != http.MethodPost || inquiryRequest.URL.String() != "https://merchants.sbx.midtrans.com/v1.0/registration-account-inquiry" { t.Fatalf("inquiry request = %s %s", inquiryRequest.Method, inquiryRequest.URL) } if got := inquiryRequest.Header.Get("Authorization-Customer"); got != "Bearer CUSTOMER-TOKEN-CANARY-DO-NOT-PRINT" { @@ -70,22 +104,32 @@ func TestClientUsesSandboxHostsAndSeparatesTokenizedHeaders(t *testing.T) { unbindRequest, err := client.NewUnbindRequest(context.Background(), gopaytokenization.UnbindRequestInput{ AccessToken: "ACCESS-TOKEN-CANARY-DO-NOT-PRINT", + MerchantID: "demo-merchant", CustomerToken: "CUSTOMER-TOKEN-CANARY-DO-NOT-PRINT", }) if err != nil { t.Fatal(err) } - if unbindRequest.Method != http.MethodPost || unbindRequest.URL.String() != "https://merchants-app.sbx.midtrans.com/v1.0/registration-account-unbinding" { + if unbindRequest.Method != http.MethodPost || unbindRequest.URL.String() != "https://merchants.sbx.midtrans.com/v1.0/registration-account-unbinding" { t.Fatalf("unbind request = %s %s", unbindRequest.Method, unbindRequest.URL) } + unbindBody, err := io.ReadAll(unbindRequest.Body) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(unbindBody), `"merchantId":"demo-merchant"`) { + t.Fatalf("unbind body = %s", unbindBody) + } paymentRequest, err := client.NewPaymentRequest(context.Background(), gopaytokenization.PaymentRequestInput{ AccessToken: "ACCESS-TOKEN-CANARY-DO-NOT-PRINT", CustomerToken: "ROTATED-CUSTOMER-TOKEN-CANARY-DO-NOT-PRINT", + MerchantID: "demo-merchant", OrderID: "order-gopay-001", Amount: 45000, PaymentOptionToken: "OPTION-TOKEN-CANARY-DO-NOT-PRINT", PaymentOptionType: "GOPAY_WALLET", + RedirectURL: "http://127.0.0.1:3101/payments/gopay/return", }) if err != nil { t.Fatal(err) @@ -100,8 +144,69 @@ func TestClientUsesSandboxHostsAndSeparatesTokenizedHeaders(t *testing.T) { if err != nil { t.Fatal(err) } - if !strings.Contains(string(body), `"paymentOptionToken":"OPTION-TOKEN-CANARY-DO-NOT-PRINT"`) { - t.Fatalf("payment body = %s", body) + for _, fragment := range []string{ + `"chargeToken":"ROTATED-CUSTOMER-TOKEN-CANARY-DO-NOT-PRINT"`, + `"merchantId":"demo-merchant"`, + `"partnerReferenceNo":"order-gopay-001"`, + `"urlParams":[{"url":"http://127.0.0.1:3101/payments/gopay/return","type":"PAY_RETURN"}]`, + `"payMethod":"GOPAY"`, + `"payOption":"GOPAY_WALLET"`, + `"paymentOptionToken":"OPTION-TOKEN-CANARY-DO-NOT-PRINT"`, + `"transAmount":{"value":"45000.00","currency":"IDR"}`, + } { + if !strings.Contains(string(body), fragment) { + t.Fatalf("payment body missing %q: %s", fragment, body) + } + } +} + +func TestClientParsesDocumentedBindingAndInquiryResponses(t *testing.T) { + client := gopaytokenization.Client{ + HTTP: appDoerFunc(func(request *http.Request) (*http.Response, error) { + switch request.URL.Path { + case "/v1.0/registration-account-binding": + return gopayResponse(http.StatusOK, `{"responseCode":"2008800","accessTokenInfo":{"accessToken":"BOUND-CUSTOMER-TOKEN"}}`), nil + case "/v1.0/registration-account-inquiry": + return gopayResponse(http.StatusOK, `{"responseCode":"2008800","additionalInfo":{"accessToken":"ROTATED-CUSTOMER-TOKEN","paymentOptions":[{"name":"PAY_LATER","active":false,"token":"PAYLATER-TOKEN"},{"name":"GOPAY_WALLET","active":true,"token":"WALLET-TOKEN"}]}}`), nil + default: + t.Fatalf("unexpected path %q", request.URL.Path) + return nil, nil + } + }), + ClientID: "midtrans-client-123", + PartnerID: "G123456", + ChannelID: "12345", + DeviceID: "device-canary", + PrivateKeyPEM: fixtureBytes(t, "private_key_pkcs8.pem"), + ClientSecret: []byte("CLIENT-SECRET-CANARY-DO-NOT-PRINT"), + Now: fixedNow, + NewExternalID: func() (string, error) { return "op_gopay_123", nil }, + } + + binding, err := client.Binding(context.Background(), gopaytokenization.BindingRequestInput{ + AccessToken: "ACCESS-TOKEN-CANARY-DO-NOT-PRINT", + MerchantID: "demo-merchant", + AuthCode: "AUTH-CODE-CANARY-DO-NOT-PRINT", + }) + if err != nil { + t.Fatal(err) + } + if binding.AccessTokenInfo.AccessToken != "BOUND-CUSTOMER-TOKEN" { + t.Fatalf("binding = %#v", binding) + } + + inquiry, err := client.Inquiry(context.Background(), gopaytokenization.InquiryRequestInput{ + AccessToken: "ACCESS-TOKEN-CANARY-DO-NOT-PRINT", + CustomerToken: "BOUND-CUSTOMER-TOKEN", + }) + if err != nil { + t.Fatal(err) + } + if inquiry.AdditionalInfo.AccessToken != "ROTATED-CUSTOMER-TOKEN" { + t.Fatalf("inquiry = %#v", inquiry) + } + if len(inquiry.AdditionalInfo.PaymentOptions) != 2 || inquiry.AdditionalInfo.PaymentOptions[1].Name != "GOPAY_WALLET" || !inquiry.AdditionalInfo.PaymentOptions[1].Active { + t.Fatalf("payment options = %#v", inquiry.AdditionalInfo.PaymentOptions) } } diff --git a/packs/gopaytokenization/journey.go b/packs/gopaytokenization/journey.go index 614dd19..4d94734 100644 --- a/packs/gopaytokenization/journey.go +++ b/packs/gopaytokenization/journey.go @@ -21,8 +21,6 @@ type JourneyRunner struct { ResolveCredential func(context.Context, string, string) ([]byte, error) } -const defaultCustomerTokenReference = "env:MIDTRANS_GOPAY_CUSTOMER_TOKEN" - type Handler struct { definition journey.Definition runner JourneyRunner @@ -94,13 +92,30 @@ func (h Handler) run(ctx context.Context, request journey.Request, runtime journ if !ok { return blockedFinding("CAPABILITY_UNAVAILABLE", "gopay-tokenization integration is not configured for this project") } - return h.runAccountLinking(ctx, request, JourneyRunner{Now: runtimeNow(runtime)}, integration, nil) + credentials, ok := request.Manifest.CredentialSetFor(integration.Credentials) + if !ok || credentials.ClientSecret == "" { + return blockedFinding("CREDENTIAL_MISSING", "the configured gopay-tokenization client_secret reference is not set") + } + if runtime.ResolveCredential == nil { + return blockedFinding("JOURNEY_EXECUTION_BLOCKED", "journey runtime dependencies are unavailable") + } + rawClientSecret, err := runtime.ResolveCredential(ctx, request.ProjectDir, credentials.ClientSecret) + if err != nil { + return blockedFinding("CREDENTIAL_RESOLUTION_FAILED", "unable to resolve the configured client_secret reference") + } + return h.runAccountLinking(ctx, request, JourneyRunner{ + Now: runtimeNow(runtime), + ResolveCredential: runtime.ResolveCredential, + Client: Client{ + ClientSecret: append([]byte(nil), rawClientSecret...), + }, + }, integration, nil) } if h.definition.ID == "gopay-tokenization.account-linking" && record != nil { if request.Input.PaymentTokenReference == "" { return inputRequired("payment_token_reference must contain the auth_code credential reference") } - if !hasStateValidationProof(request, record.SafeReferences["state_hash"]) { + if !hasStateValidationProof(request, record.SafeReferences["state_hash"], request.Input.PaymentTokenReference) { return inputRequired("evidence must include a successful merchant state validation proof") } } @@ -133,11 +148,18 @@ func (h Handler) run(ctx context.Context, request journey.Request, runtime journ func (h Handler) runAccountLinking(ctx context.Context, request journey.Request, runner JourneyRunner, integration manifest.Integration, record *operations.Record) journey.Outcome { if record == nil { - merchantHandle := integration.Callbacks["account_linking"] - stateHash := computeStateHash(request.OperationID, request.Input.OrderID, merchantHandle) + merchantID, outcome := resolveMerchantID(ctx, request, runner) + if outcome != nil { + return *outcome + } + redirectURL := callbackURL(request.Manifest.Application.BaseURL, integration.Callbacks["account_linking"]) + stateHash := computeStateHash(request.OperationID, request.Input.OrderID, merchantID, redirectURL) authRequest, err := runner.Client.NewGetAuthCodeRequest(ctx, GetAuthCodeInput{ - StateHash: stateHash, - MerchantHandle: merchantHandle, + StateHash: stateHash, + MerchantID: merchantID, + RedirectURL: redirectURL, + Scopes: []string{"PAYMENT_BINDING"}, + Lang: "id", }) if err != nil { return blockedOutcome("auth-code request could not be prepared") @@ -145,9 +167,8 @@ func (h Handler) runAccountLinking(ctx context.Context, request journey.Request, return journey.Outcome{ State: journey.AwaitingUserAction, SafeData: map[string]any{ - "order_id": request.Input.OrderID, - "state_hash": stateHash, - "merchant_id": merchantHandle, + "order_id": request.Input.OrderID, + "state_hash": stateHash, }, Action: &journey.Action{ Type: "browser", @@ -161,9 +182,13 @@ func (h Handler) runAccountLinking(ctx context.Context, request journey.Request, if request.Input.PaymentTokenReference == "" { return inputRequired("payment_token_reference must contain the auth_code credential reference") } - if !hasStateValidationProof(request, record.SafeReferences["state_hash"]) { + if !hasStateValidationProof(request, record.SafeReferences["state_hash"], request.Input.PaymentTokenReference) { return inputRequired("evidence must include a successful merchant state validation proof") } + merchantID, outcome := resolveMerchantID(ctx, request, runner) + if outcome != nil { + return *outcome + } accessToken, err := runner.Client.AccessToken(ctx) if err != nil { return blockedOutcome("provider access token is unavailable") @@ -174,6 +199,7 @@ func (h Handler) runAccountLinking(ctx context.Context, request journey.Request, } response, err := runner.Client.Binding(ctx, BindingRequestInput{ AccessToken: accessToken, + MerchantID: merchantID, AuthCode: strings.TrimSpace(string(rawAuthCode)), }) if err != nil { @@ -182,9 +208,7 @@ func (h Handler) runAccountLinking(ctx context.Context, request journey.Request, safeData := map[string]any{ "order_id": request.Input.OrderID, "state_hash": record.SafeReferences["state_hash"], - "merchant_id": record.SafeReferences["merchant_id"], "response_code": response.ResponseCode, - "auth_code_ref": request.Input.PaymentTokenReference, "credential_kind": "customer_authorization_token", } return journey.Outcome{ @@ -199,10 +223,6 @@ func (h Handler) runAccountLinking(ctx context.Context, request journey.Request, } func (h Handler) runBindingInquiry(ctx context.Context, request journey.Request, runner JourneyRunner) journey.Outcome { - customerReference := request.Input.PaymentTokenReference - if customerReference == "" { - return inputRequired("payment_token_reference is required") - } accessToken, customerToken, outcome := resolveCustomerToken(ctx, request, runner) if outcome != nil { return *outcome @@ -219,9 +239,8 @@ func (h Handler) runBindingInquiry(ctx context.Context, request journey.Request, SafeData: map[string]any{ "order_id": request.Input.OrderID, "response_code": inquiry.ResponseCode, - "payment_option_count": strconv.Itoa(len(inquiry.PaymentOptions)), - "customer_token_ref": customerReference, - "rotated_token_present": strconv.FormatBool(strings.TrimSpace(inquiry.AccessTokenInfo.AccessToken) != ""), + "payment_option_count": strconv.Itoa(len(inquiry.AdditionalInfo.PaymentOptions)), + "rotated_token_present": strconv.FormatBool(strings.TrimSpace(inquiry.AdditionalInfo.AccessToken) != ""), }, } } @@ -238,25 +257,32 @@ func (h Handler) runTokenizedPayment(ctx context.Context, request journey.Reques if err != nil { return blockedOutcome("binding inquiry failed") } - paymentOption, ok := activePaymentOption(inquiry.PaymentOptions, optionType) + paymentOption, ok := activePaymentOption(inquiry.AdditionalInfo.PaymentOptions, optionType) if !ok { return blockedOutcome("the requested payment option is not active") } - customerTokenForPayment := strings.TrimSpace(inquiry.AccessTokenInfo.AccessToken) + customerTokenForPayment := strings.TrimSpace(inquiry.AdditionalInfo.AccessToken) if customerTokenForPayment == "" { - customerTokenForPayment = customerToken + return blockedOutcome("binding inquiry did not return a rotated customer token") } paymentAccessToken, err := runner.Client.AccessToken(ctx) if err != nil { return blockedOutcome("provider access token is unavailable") } + merchantID, outcome := resolveMerchantID(ctx, request, runner) + if outcome != nil { + return *outcome + } + redirectURL := callbackURL(request.Manifest.Application.BaseURL, request.Manifest.Integrations["gopay-tokenization"].Callbacks["payment"]) payment, err := runner.Client.Payment(ctx, PaymentRequestInput{ AccessToken: paymentAccessToken, CustomerToken: customerTokenForPayment, + MerchantID: merchantID, OrderID: request.Input.OrderID, Amount: request.Input.Amount, - PaymentOptionToken: paymentOption.PaymentOptionToken, + PaymentOptionToken: paymentOption.Token, PaymentOptionType: optionType, + RedirectURL: redirectURL, }) if err != nil { return blockedOutcome("tokenized payment failed") @@ -284,17 +310,33 @@ func (h Handler) runUnlink(ctx context.Context, request journey.Request, runner if outcome != nil { return *outcome } + merchantID, outcome := resolveMerchantID(ctx, request, runner) + if outcome != nil { + return *outcome + } if err := runner.Client.Unbind(ctx, UnbindRequestInput{ AccessToken: accessToken, + MerchantID: merchantID, CustomerToken: customerToken, }); err != nil { - return blockedOutcome("account unlink failed") + inquiry, inquiryErr := runner.Client.Inquiry(ctx, InquiryRequestInput{ + AccessToken: accessToken, + CustomerToken: customerToken, + }) + if inquiryErr != nil { + return blockedOutcome("account unlink failed") + } + if _, ok := activePaymentOption(inquiry.AdditionalInfo.PaymentOptions, "GOPAY_WALLET"); ok { + return blockedOutcome("account unlink remains linked after inquiry fallback") + } + if _, ok := activePaymentOption(inquiry.AdditionalInfo.PaymentOptions, "PAY_LATER"); ok { + return blockedOutcome("account unlink remains linked after inquiry fallback") + } } return journey.Outcome{ State: journey.Reconciling, SafeData: map[string]any{ - "order_id": request.Input.OrderID, - "customer_token_ref": request.Input.PaymentTokenReference, + "order_id": request.Input.OrderID, }, MissingEvidence: []string{ "gopay-tokenization.account-cleared", @@ -319,7 +361,7 @@ func (h Handler) runtimeRunner(ctx context.Context, request journey.Request, run return JourneyRunner{}, manifest.Integration{}, manifest.CredentialSet{}, &outcome } credentials, ok := request.Manifest.CredentialSetFor(integration.Credentials) - if !ok || credentials.ClientID == "" || credentials.ClientSecret == "" || credentials.PartnerID == "" || credentials.ChannelID == "" || credentials.DeviceID == "" || credentials.PrivateKey == "" { + if !ok || credentials.ClientID == "" || credentials.ClientSecret == "" || credentials.PartnerID == "" || credentials.ChannelID == "" || credentials.DeviceID == "" || credentials.MerchantID == "" || credentials.PrivateKey == "" { outcome := blockedFinding("CREDENTIAL_MISSING", "the configured gopay-tokenization BI-SNAP credential set is incomplete") return JourneyRunner{}, manifest.Integration{}, manifest.CredentialSet{}, &outcome } @@ -352,6 +394,11 @@ func (h Handler) runtimeRunner(ctx context.Context, request journey.Request, run outcome := blockedFinding("CREDENTIAL_RESOLUTION_FAILED", "unable to resolve the configured device_id reference") return JourneyRunner{}, manifest.Integration{}, manifest.CredentialSet{}, &outcome } + rawMerchantID, err := runtime.ResolveCredential(ctx, request.ProjectDir, credentials.MerchantID) + if err != nil { + outcome := blockedFinding("CREDENTIAL_RESOLUTION_FAILED", "unable to resolve the configured merchant_id reference") + return JourneyRunner{}, manifest.Integration{}, manifest.CredentialSet{}, &outcome + } rawPrivateKey, err := runtime.ResolveCredential(ctx, request.ProjectDir, credentials.PrivateKey) if err != nil { outcome := blockedFinding("CREDENTIAL_RESOLUTION_FAILED", "unable to resolve the configured private_key reference") @@ -364,6 +411,7 @@ func (h Handler) runtimeRunner(ctx context.Context, request journey.Request, run PartnerID: strings.TrimSpace(string(rawPartnerID)), ChannelID: strings.TrimSpace(string(rawChannelID)), DeviceID: strings.TrimSpace(string(rawDeviceID)), + MerchantID: strings.TrimSpace(string(rawMerchantID)), PrivateKeyPEM: append([]byte(nil), rawPrivateKey...), ClientSecret: append([]byte(nil), rawClientSecret...), Now: runtime.Now, @@ -376,16 +424,16 @@ func (h Handler) runtimeRunner(ctx context.Context, request journey.Request, run } func resolveCustomerToken(ctx context.Context, request journey.Request, runner JourneyRunner) (string, string, *journey.Outcome) { - reference := request.Input.PaymentTokenReference - if reference == "" { - reference = defaultCustomerTokenReference + if request.Input.PaymentTokenReference == "" { + outcome := inputRequired("payment_token_reference is required") + return "", "", &outcome } accessToken, err := runner.Client.AccessToken(ctx) if err != nil { outcome := blockedOutcome("provider access token is unavailable") return "", "", &outcome } - rawCustomerToken, err := runner.resolveCredential(ctx, request.ProjectDir, reference) + rawCustomerToken, err := runner.resolveCredential(ctx, request.ProjectDir, request.Input.PaymentTokenReference) if err != nil { outcome := blockedFinding("CREDENTIAL_RESOLUTION_FAILED", "unable to resolve the customer authorization token reference") return "", "", &outcome @@ -395,7 +443,7 @@ func resolveCustomerToken(ctx context.Context, request journey.Request, runner J func activePaymentOption(options []PaymentOption, optionType string) (PaymentOption, bool) { for _, option := range options { - if strings.EqualFold(option.PaymentOptionType, optionType) && strings.EqualFold(option.Status, "ACTIVE") { + if strings.EqualFold(option.Name, optionType) && option.Active { return option, true } } @@ -449,25 +497,51 @@ func (r JourneyRunner) resolveCredential(ctx context.Context, projectDir, refere return r.ResolveCredential(ctx, projectDir, reference) } -func hasStateValidationProof(request journey.Request, expectedStateHash string) bool { +func hasStateValidationProof(request journey.Request, expectedStateHash, authCodeReference string) bool { if request.Evidence.SchemaVersion == "" || expectedStateHash == "" { return false } + expectedReferenceHash := computeStateHash(authCodeReference) for _, proof := range request.Evidence.Proofs { if proof.OperationID != request.OperationID { continue } - if proof.Status != "pass" || proof.Source != "merchant_application" { + if proof.Stage != "account_linking_return" || proof.Status != "pass" || proof.Source != "merchant_application" { continue } if summaryString(proof.Summary, "state_hash") == expectedStateHash && - (summaryString(proof.Summary, "status") == "success" || summaryString(proof.Summary, "auth_code_reference") != "") { + summaryString(proof.Summary, "auth_code_reference_hash") == expectedReferenceHash { return true } } return false } +func resolveMerchantID(ctx context.Context, request journey.Request, runner JourneyRunner) (string, *journey.Outcome) { + credentials, ok := request.Manifest.CredentialSetForIntegration("gopay-tokenization") + if !ok || credentials.MerchantID == "" { + outcome := blockedFinding("CREDENTIAL_MISSING", "the configured gopay-tokenization merchant_id reference is not set") + return "", &outcome + } + rawMerchantID, err := runner.resolveCredential(ctx, request.ProjectDir, credentials.MerchantID) + if err != nil { + outcome := blockedFinding("CREDENTIAL_RESOLUTION_FAILED", "unable to resolve the configured merchant_id reference") + return "", &outcome + } + return strings.TrimSpace(string(rawMerchantID)), nil +} + +func callbackURL(baseURL, route string) string { + base := strings.TrimRight(baseURL, "/") + if strings.HasPrefix(route, "http://") || strings.HasPrefix(route, "https://") { + return route + } + if route == "" { + return base + } + return base + route +} + func summaryString(summary map[string]any, key string) string { if summary == nil { return "" diff --git a/packs/gopaytokenization/journey_test.go b/packs/gopaytokenization/journey_test.go index 4b4676e..30abbe7 100644 --- a/packs/gopaytokenization/journey_test.go +++ b/packs/gopaytokenization/journey_test.go @@ -2,6 +2,8 @@ package gopaytokenization_test import ( "context" + "crypto/sha256" + "encoding/hex" "io" "net/http" "strings" @@ -28,6 +30,9 @@ func TestAccountLinkingResumeRequiresStateProofAndAuthCodeReference(t *testing.T if first.Action == nil || first.Action.URL == "" { t.Fatalf("action = %#v", first.Action) } + if first.Action != nil && !strings.Contains(first.Action.URL, "/v1.0/get-auth-code") { + t.Fatalf("action url = %q", first.Action.URL) + } if _, ok := first.SafeData["state_hash"]; !ok { t.Fatalf("safe data = %#v", first.SafeData) } @@ -62,13 +67,85 @@ func TestAccountLinkingResumeRequiresStateProofAndAuthCodeReference(t *testing.T } } +func TestAccountLinkingResumeRequiresBoundProofHashAndDoesNotPersistCredentialReference(t *testing.T) { + handler := gopaytokenization.NewAccountLinkingHandler() + request := gopayRequest("gopay-tokenization.account-linking", "link-order", 0, "") + + first := handler.Execute(context.Background(), request, journeypkg.Runtime{ + ResolveCredential: gopayResolveCredential(t), + Now: fixedNow, + }) + record := operations.Record{ + SchemaVersion: 1, + OperationID: "op_gopay_test", + JourneyID: "gopay-tokenization.account-linking", + PackID: "gopay-tokenization", + ManifestHash: strings.Repeat("a", 64), + State: string(journeypkg.AwaitingUserAction), + SafeReferences: map[string]string{ + "order_id": "link-order", + "state_hash": first.SafeData["state_hash"].(string), + }, + StartedAt: fixedNow(), + UpdatedAt: fixedNow(), + } + + request.Evidence = evidence.Bundle{ + SchemaVersion: evidence.SchemaVersion, + ManifestVersion: 1, + PackID: "gopay-tokenization", + ManifestHash: request.ManifestHash, + Journey: "gopay-tokenization.account-linking", + Environment: "sandbox", + Proofs: []evidence.Proof{{ + ID: "gopay-tokenization.state-validation", + OperationID: "op_gopay_test", + Stage: "account_linking_return", + Level: evidence.ProofLocal, + Source: "merchant_application", + Status: "pass", + Summary: map[string]any{ + "state_hash": first.SafeData["state_hash"].(string), + "auth_code_reference_hash": sha256Hex("env:MIDTRANS_GOPAY_AUTH_CODE"), + }, + }}, + } + request.Input.PaymentTokenReference = "env:MIDTRANS_GOPAY_AUTH_CODE" + + outcome := handler.Resume(context.Background(), request, journeypkg.Runtime{ + ResolveCredential: gopayResolveCredential(t), + Now: fixedNow, + HTTP: appDoerFunc(func(request *http.Request) (*http.Response, error) { + switch request.URL.Path { + case "/v1.0/access-token/b2b": + return gopayResponse(http.StatusOK, `{"accessToken":"ACCESS-TOKEN-CANARY-DO-NOT-PRINT"}`), nil + case "/v1.0/registration-account-binding": + return gopayResponse(http.StatusOK, `{"responseCode":"2008800","accessTokenInfo":{"accessToken":"BOUND-CUSTOMER-TOKEN"}}`), nil + default: + t.Fatalf("unexpected path %q", request.URL.Path) + return nil, nil + } + }), + }, record) + if outcome.State != journeypkg.Passed { + t.Fatalf("outcome = %#v", outcome) + } + for _, key := range []string{"auth_code_ref", "customer_token_ref", "payment_token_reference"} { + if _, ok := outcome.SafeData[key]; ok { + t.Fatalf("safe data leaked %q: %#v", key, outcome.SafeData) + } + } +} + func TestWalletPaymentRunsInquiryImmediatelyBeforePaymentAndUsesRotatedToken(t *testing.T) { var paths []string var customerHeaders []string var paymentBodies []string handler := gopaytokenization.NewWalletPaymentHandler() - outcome := handler.Execute(context.Background(), gopayRequest("gopay-tokenization.wallet-payment", "wallet-order", 45000, "gopay"), journeypkg.Runtime{ + request := gopayRequest("gopay-tokenization.wallet-payment", "wallet-order", 45000, "gopay") + request.Input.PaymentTokenReference = "env:MIDTRANS_GOPAY_CUSTOMER_TOKEN" + outcome := handler.Execute(context.Background(), request, journeypkg.Runtime{ ResolveCredential: gopayResolveCredentialWithAuthToken(t), Now: fixedNow, HTTP: appDoerFunc(func(request *http.Request) (*http.Response, error) { @@ -85,11 +162,10 @@ func TestWalletPaymentRunsInquiryImmediatelyBeforePaymentAndUsesRotatedToken(t * case "/v1.0/registration-account-inquiry": return gopayResponse(http.StatusOK, `{ "responseCode":"2008800", - "accessTokenInfo":{"accessToken":"ROTATED-CUSTOMER-TOKEN-CANARY-DO-NOT-PRINT"}, - "paymentOptions":[ - {"paymentOptionType":"PAY_LATER","paymentOptionToken":"PAYLATER-TOKEN-CANARY-DO-NOT-PRINT","status":"INACTIVE"}, - {"paymentOptionType":"GOPAY_WALLET","paymentOptionToken":"WALLET-TOKEN-CANARY-DO-NOT-PRINT","status":"ACTIVE"} - ] + "additionalInfo":{"accessToken":"ROTATED-CUSTOMER-TOKEN-CANARY-DO-NOT-PRINT","paymentOptions":[ + {"name":"PAY_LATER","token":"PAYLATER-TOKEN-CANARY-DO-NOT-PRINT","active":false}, + {"name":"GOPAY_WALLET","token":"WALLET-TOKEN-CANARY-DO-NOT-PRINT","active":true} + ]} }`), nil case "/v1.0/debit/payment-host-to-host": return gopayResponse(http.StatusOK, `{ @@ -117,19 +193,47 @@ func TestWalletPaymentRunsInquiryImmediatelyBeforePaymentAndUsesRotatedToken(t * if customerHeaders[3] != "Bearer ROTATED-CUSTOMER-TOKEN-CANARY-DO-NOT-PRINT" { t.Fatalf("payment customer header = %#v", customerHeaders) } - if !strings.Contains(paymentBodies[3], `"paymentOptionToken":"WALLET-TOKEN-CANARY-DO-NOT-PRINT"`) { - t.Fatalf("payment body = %s", paymentBodies[3]) + for _, fragment := range []string{`"chargeToken":"ROTATED-CUSTOMER-TOKEN-CANARY-DO-NOT-PRINT"`, `"payOption":"GOPAY_WALLET"`, `"paymentOptionToken":"WALLET-TOKEN-CANARY-DO-NOT-PRINT"`} { + if !strings.Contains(paymentBodies[3], fragment) { + t.Fatalf("payment body missing %q: %s", fragment, paymentBodies[3]) + } } - for _, key := range []string{"customer_authorization_token", "payment_option_token", "auth_code", "authorization_reference"} { + for _, key := range []string{"customer_authorization_token", "payment_option_token", "auth_code", "authorization_reference", "customer_token_ref", "payment_token_reference"} { if _, ok := outcome.SafeData[key]; ok { t.Fatalf("safe data leaked %q: %#v", key, outcome.SafeData) } } } +func TestWalletPaymentRequiresRotatedInquiryToken(t *testing.T) { + handler := gopaytokenization.NewWalletPaymentHandler() + request := gopayRequest("gopay-tokenization.wallet-payment", "wallet-order", 45000, "gopay") + request.Input.PaymentTokenReference = "env:MIDTRANS_GOPAY_CUSTOMER_TOKEN" + outcome := handler.Execute(context.Background(), request, journeypkg.Runtime{ + ResolveCredential: gopayResolveCredentialWithAuthToken(t), + Now: fixedNow, + HTTP: appDoerFunc(func(request *http.Request) (*http.Response, error) { + switch request.URL.Path { + case "/v1.0/access-token/b2b": + return gopayResponse(http.StatusOK, `{"accessToken":"ACCESS-TOKEN-CANARY-DO-NOT-PRINT"}`), nil + case "/v1.0/registration-account-inquiry": + return gopayResponse(http.StatusOK, `{"responseCode":"2008800","additionalInfo":{"accessToken":"","paymentOptions":[{"name":"GOPAY_WALLET","token":"WALLET-TOKEN-CANARY-DO-NOT-PRINT","active":true}]}}`), nil + default: + t.Fatalf("unexpected path %q", request.URL.Path) + return nil, nil + } + }), + }) + if outcome.State != journeypkg.Blocked || outcome.Finding == nil || outcome.Finding.Code != "JOURNEY_EXECUTION_BLOCKED" { + t.Fatalf("outcome = %#v", outcome) + } +} + func TestPayLaterRequiresActivePayLaterOption(t *testing.T) { handler := gopaytokenization.NewPayLaterHandler() - outcome := handler.Execute(context.Background(), gopayRequest("gopay-tokenization.paylater", "paylater-order", 65000, "gopaylater"), journeypkg.Runtime{ + request := gopayRequest("gopay-tokenization.paylater", "paylater-order", 65000, "gopaylater") + request.Input.PaymentTokenReference = "env:MIDTRANS_GOPAY_CUSTOMER_TOKEN" + outcome := handler.Execute(context.Background(), request, journeypkg.Runtime{ ResolveCredential: gopayResolveCredentialWithAuthToken(t), Now: fixedNow, HTTP: appDoerFunc(func(request *http.Request) (*http.Response, error) { @@ -139,10 +243,9 @@ func TestPayLaterRequiresActivePayLaterOption(t *testing.T) { case "/v1.0/registration-account-inquiry": return gopayResponse(http.StatusOK, `{ "responseCode":"2008800", - "accessTokenInfo":{"accessToken":"ROTATED-CUSTOMER-TOKEN-CANARY-DO-NOT-PRINT"}, - "paymentOptions":[ - {"paymentOptionType":"PAY_LATER","paymentOptionToken":"PAYLATER-TOKEN-CANARY-DO-NOT-PRINT","status":"INACTIVE"} - ] + "additionalInfo":{"accessToken":"ROTATED-CUSTOMER-TOKEN-CANARY-DO-NOT-PRINT","paymentOptions":[ + {"name":"PAY_LATER","token":"PAYLATER-TOKEN-CANARY-DO-NOT-PRINT","active":false} + ]} }`), nil default: t.Fatalf("unexpected path %q", request.URL.Path) @@ -159,6 +262,37 @@ func TestPayLaterRequiresActivePayLaterOption(t *testing.T) { } } +func TestUnlinkFallsBackToInquiryAfterAmbiguousUnbind(t *testing.T) { + handler := gopaytokenization.NewUnlinkHandler() + request := gopayRequest("gopay-tokenization.unlink", "unlink-order", 0, "") + request.Input.PaymentTokenReference = "env:MIDTRANS_GOPAY_CUSTOMER_TOKEN" + var paths []string + outcome := handler.Execute(context.Background(), request, journeypkg.Runtime{ + ResolveCredential: gopayResolveCredentialWithAuthToken(t), + Now: fixedNow, + HTTP: appDoerFunc(func(request *http.Request) (*http.Response, error) { + paths = append(paths, request.URL.Path) + switch request.URL.Path { + case "/v1.0/access-token/b2b": + return gopayResponse(http.StatusOK, `{"accessToken":"ACCESS-TOKEN-CANARY-DO-NOT-PRINT"}`), nil + case "/v1.0/registration-account-unbinding": + return nil, context.DeadlineExceeded + case "/v1.0/registration-account-inquiry": + return gopayResponse(http.StatusOK, `{"responseCode":"2008800","additionalInfo":{"accessToken":"ROTATED-CUSTOMER-TOKEN-CANARY-DO-NOT-PRINT","paymentOptions":[]}}`), nil + default: + t.Fatalf("unexpected path %q", request.URL.Path) + return nil, nil + } + }), + }) + if outcome.State != journeypkg.Reconciling { + t.Fatalf("outcome = %#v", outcome) + } + if len(paths) != 3 || paths[2] != "/v1.0/registration-account-inquiry" { + t.Fatalf("paths = %#v", paths) + } +} + func gopayRequest(journeyID, orderID string, amount int64, method string) journeypkg.Request { return journeypkg.Request{ OperationID: "op_gopay_test", @@ -185,6 +319,7 @@ func validGoPayManifest() manifest.Manifest { PartnerID: "env:MIDTRANS_BISNAP_PARTNER_ID", ChannelID: "env:MIDTRANS_BISNAP_CHANNEL_ID", DeviceID: "env:MIDTRANS_BISNAP_DEVICE_ID", + MerchantID: "env:MIDTRANS_GOPAY_MERCHANT_ID", PrivateKey: "file:./secrets/bisnap-private.pem", MidtransPublicKey: "file:./secrets/bisnap-public.pem", } @@ -215,10 +350,14 @@ func gopayResolveCredential(t *testing.T) func(context.Context, string, string) return []byte("12345"), nil case "env:MIDTRANS_BISNAP_DEVICE_ID": return []byte("device-canary"), nil + case "env:MIDTRANS_GOPAY_MERCHANT_ID": + return []byte("demo-merchant"), nil case "file:./secrets/bisnap-private.pem": return fixtureBytes(t, "private_key_pkcs8.pem"), nil case "file:./secrets/bisnap-public.pem": return fixtureBytes(t, "public_key_pkix.pem"), nil + case "env:MIDTRANS_GOPAY_AUTH_CODE": + return []byte("AUTH-CODE-CANARY-DO-NOT-PRINT"), nil default: t.Fatalf("unexpected reference %q", reference) return nil, nil @@ -226,6 +365,11 @@ func gopayResolveCredential(t *testing.T) func(context.Context, string, string) } } +func sha256Hex(value string) string { + sum := sha256.Sum256([]byte(value)) + return hex.EncodeToString(sum[:]) +} + func gopayResolveCredentialWithAuthToken(t *testing.T) func(context.Context, string, string) ([]byte, error) { base := gopayResolveCredential(t) return func(ctx context.Context, projectDir, reference string) ([]byte, error) { diff --git a/packs/gopaytokenization/pack.go b/packs/gopaytokenization/pack.go index 5e5f400..54df044 100644 --- a/packs/gopaytokenization/pack.go +++ b/packs/gopaytokenization/pack.go @@ -46,9 +46,12 @@ func (Pack) Descriptor() packs.Descriptor { "token", }, Sources: []contracts.PublicSource{ - {ID: "gopay-tokenization-overview", URL: "https://docs.midtrans.com/reference/core-api-snap-open-api-overview", Rules: []string{"gopaytokenization.signing.verify.v1"}}, - {ID: "gopay-tokenization-bind", URL: "https://docs.midtrans.com/reference/direct-debit-api-gopay", Rules: []string{"gopaytokenization.linking.bind", "gopaytokenization.linking.inquiry", "gopaytokenization.wallet.charge", "gopaytokenization.unlink"}}, - {ID: "gopay-tokenization-notifications", URL: "https://docs.midtrans.com/reference/payment-notification-api", Rules: []string{"gopaytokenization.notification.signature", "common.webhook-idempotency"}}, + {ID: "gopay-tokenization-get-auth-code", URL: "https://docs.midtrans.com/reference/get-auth-code-api", Rules: []string{"gopaytokenization.linking.get-auth-code"}}, + {ID: "gopay-tokenization-bind-account", URL: "https://docs.midtrans.com/reference/bind-account-api", Rules: []string{"gopaytokenization.linking.bind"}}, + {ID: "gopay-tokenization-binding-inquiry", URL: "https://docs.midtrans.com/reference/account-binding-inquiry-api", Rules: []string{"gopaytokenization.linking.inquiry"}}, + {ID: "gopay-tokenization-direct-debit", URL: "https://docs.midtrans.com/reference/direct-debit-api-gopay-tokenization", Rules: []string{"gopaytokenization.wallet.charge", "gopaytokenization.paylater.charge"}}, + {ID: "gopay-tokenization-unbind", URL: "https://docs.midtrans.com/reference/unbind-api", Rules: []string{"gopaytokenization.unlink"}}, + {ID: "gopay-tokenization-account-notifications", URL: "https://docs.midtrans.com/reference/account-linking-and-unlinking-notification", Rules: []string{"gopaytokenization.notification.signature", "common.webhook-idempotency"}}, }, } } diff --git a/packs/gopaytokenization/seamless_test.go b/packs/gopaytokenization/seamless_test.go index 55f11b2..6e9a15c 100644 --- a/packs/gopaytokenization/seamless_test.go +++ b/packs/gopaytokenization/seamless_test.go @@ -18,8 +18,9 @@ func TestAccountLinkingPersistenceStoresOnlySafeState(t *testing.T) { engine := journeypkg.Engine{ Store: operations.Store{ProjectDir: projectDir}, Runtime: journeypkg.Runtime{ - Now: fixedNow, - NewOperationID: func() string { return "op_gopay_persist" }, + ResolveCredential: gopayResolveCredential(t), + Now: fixedNow, + NewOperationID: func() string { return "op_gopay_persist" }, SensitiveKeys: []string{ "auth_code", "customer_authorization_token", @@ -52,6 +53,9 @@ func TestAccountLinkingPersistenceStoresOnlySafeState(t *testing.T) { "CUSTOMER-TOKEN", "PAYLATER-TOKEN", "authorization_reference", + "auth_code_ref", + "customer_token_ref", + "payment_token_reference", } { if strings.Contains(text, secret) { t.Fatalf("record leaked %q: %s", secret, text) diff --git a/schemas/manifest-v1.schema.json b/schemas/manifest-v1.schema.json index f46a449..eb4a4f6 100644 --- a/schemas/manifest-v1.schema.json +++ b/schemas/manifest-v1.schema.json @@ -170,6 +170,10 @@ "type": "string", "pattern": "^(env:[A-Z][A-Z0-9_]*|file:\\./(?:[A-Za-z0-9_][A-Za-z0-9._-]*)(?:/[A-Za-z0-9_][A-Za-z0-9._-]*)*)$" }, + "merchant_id": { + "type": "string", + "pattern": "^(env:[A-Z][A-Z0-9_]*|file:\\./(?:[A-Za-z0-9_][A-Za-z0-9._-]*)(?:/[A-Za-z0-9_][A-Za-z0-9._-]*)*)$" + }, "private_key": { "type": "string", "pattern": "^(env:[A-Z][A-Z0-9_]*|file:\\./(?:[A-Za-z0-9_][A-Za-z0-9._-]*)(?:/[A-Za-z0-9_][A-Za-z0-9._-]*)*)$" From 628d4978a171d4043819c45bbc51b329dc02f0ab Mon Sep 17 00:00:00 2001 From: "m.salis" Date: Mon, 27 Jul 2026 12:57:43 +0700 Subject: [PATCH 52/73] fix: tighten GoPay auth code contracts --- .../task-10-report.md | 30 ++++ contracts/public-sources-v1.json | 128 +++++++++--------- internal/app/commands_agent.go | 3 + internal/app/commands_checkout.go | 5 + internal/journey/types.go | 1 + packs/gopaytokenization/client.go | 35 +++-- packs/gopaytokenization/client_test.go | 37 +++-- packs/gopaytokenization/journey.go | 46 +++++-- packs/gopaytokenization/journey_test.go | 17 ++- packs/gopaytokenization/pack.go | 6 +- packs/gopaytokenization/seamless_test.go | 4 +- packs/gopaytokenization/signature.go | 60 ++++++++ 12 files changed, 260 insertions(+), 112 deletions(-) create mode 100644 packs/gopaytokenization/signature.go diff --git a/.superpowers/sdd/2026-07-26-midtrans-cli-multi-product-parity/task-10-report.md b/.superpowers/sdd/2026-07-26-midtrans-cli-multi-product-parity/task-10-report.md index df60213..de03363 100644 --- a/.superpowers/sdd/2026-07-26-midtrans-cli-multi-product-parity/task-10-report.md +++ b/.superpowers/sdd/2026-07-26-midtrans-cli-multi-product-parity/task-10-report.md @@ -53,6 +53,36 @@ go test ./internal/sourceprovenance ./internal/app ./packs/gopaytokenization -co Result: passed. +## Fix Round 2 + +Addressed the remaining critical Get Auth Code and source-provenance corrections: + +- Added mandatory `state` query binding equal to the generated state hash. +- Switched `seamlessData` from compact JSON to deterministic URL-form encoding: + - `mobileNumber=&paymentType=gopay` +- Switched `seamlessSign` from client-secret HMAC to Base64 `SHA256withRSA` over the exact raw `seamlessData` string using the configured merchant private key. +- Added GoPay signature helper coverage with a fixed vector for PKCS#8 private-key signing. +- Added typed `journey.Input.MobileNumberReference` and CLI `--mobile-number-reference` on merchant and agent journey surfaces. +- Required `mobile_number_reference` for GoPay account-link planning and execution. +- Ensured the mobile number reference and resolved mobile number never enter SafeData, operation storage, or evidence. +- Corrected GoPay source URLs to the exact working official slugs: + - `get-auth-code-api` + - `binding-api` + - `binding-inquiry-api` + - `direct-debit-api-gopay-tokenization` + - `unbind-api` + - `account-linking-unlinking-notification` +- Regenerated the public-source baseline so the GoPay entries now carry non-empty valid SHA-256 digests. + +Fix-round validation: + +```sh +go test ./packs/gopaytokenization ./internal/app ./internal/manifest ./internal/sourceprovenance -count=1 +go test ./... -count=1 +``` + +Result: passed. + Full validation: ```sh diff --git a/contracts/public-sources-v1.json b/contracts/public-sources-v1.json index 7d29e89..a957e06 100644 --- a/contracts/public-sources-v1.json +++ b/contracts/public-sources-v1.json @@ -8,8 +8,8 @@ "snap.token.create", "snap.basic-auth" ], - "sha256": "c3ba04532182ddd529a740e44d99012bcea372459fef23b96ef773106a8de3f4", - "retrieved_at": "2026-07-27T05:02:08.464875Z" + "sha256": "f9a33a2c1add8e51a2efcbbcb66b89822a0e214dc4bd49148f25f8f2e4d750da", + "retrieved_at": "2026-07-27T05:55:55.351598Z" }, { "id": "snap-js", @@ -18,8 +18,8 @@ "snap.checkout.popup", "snap.checkout.embed" ], - "sha256": "6e3eda531f18d081ac392cc1cbde5deafb73589448ab8e5fd8f32eb872e65b0e", - "retrieved_at": "2026-07-27T05:02:08.464875Z" + "sha256": "ecdb903009964df0a75f4a21c3c13c844ba47a322acb191cac1eb45460aa43fe", + "retrieved_at": "2026-07-27T05:55:55.351598Z" }, { "id": "snap-integration", @@ -28,8 +28,8 @@ "snap.checkout.redirect", "snap.mobile.webview" ], - "sha256": "71d80ae1c1458fea76c593a138ffa164c9c194de549f7553687635cb01c440b8", - "retrieved_at": "2026-07-27T05:02:08.464875Z" + "sha256": "8252e622a81c388c0a082059d18ce6f94f878e7549a75d0767bff7088db4dffd", + "retrieved_at": "2026-07-27T05:55:55.351598Z" }, { "id": "technical-faq", @@ -38,8 +38,8 @@ "snap.mobile.deeplink-return", "snap.mobile.real-device-proof" ], - "sha256": "e2534e1633638fc6f23f014c6abf71441aaa1b71d3de1a98afecad9271f9e4be", - "retrieved_at": "2026-07-27T05:02:08.464875Z" + "sha256": "bea498e7b507f5380d535a199c1d9421da2dc15e08ad01222454400b7abeb4d2", + "retrieved_at": "2026-07-27T05:55:55.351598Z" }, { "id": "http-notifications", @@ -48,8 +48,8 @@ "snap.notification.signature", "common.webhook-idempotency" ], - "sha256": "38d48e63e1748126258827be9540f025bd221b1623f627fa461ffe54528d8ccd", - "retrieved_at": "2026-07-27T05:02:08.464875Z" + "sha256": "a12432b47c37a3ecfb73254faad6433033f4c870b80ee894fb520e43db974402", + "retrieved_at": "2026-07-27T05:55:55.351598Z" }, { "id": "get-transaction-status", @@ -58,8 +58,8 @@ "snap.status.reconcile", "snap.mobile.status.reconcile" ], - "sha256": "a91005e80e5c9c2cd9979c435f95e306435b5acebb67c6a9c065c947bde951b2", - "retrieved_at": "2026-07-27T05:02:08.464875Z" + "sha256": "ba1a9f56ccfc7e2b87b1250b74b119c10b55fe19e1c4cd6432ca75f8b414d860", + "retrieved_at": "2026-07-27T05:55:55.351598Z" }, { "id": "coreapi-card-charge", @@ -68,8 +68,8 @@ "coreapi.card.charge", "coreapi.basic-auth" ], - "sha256": "c4a28c4d715e9a784d82a30bbc2ea08d6625fe4a4abff7e2d44bbfaf183b78d7", - "retrieved_at": "2026-07-27T05:02:08.464875Z" + "sha256": "dfc2d535d30fe927e80783c6977b1f088a4bcfb7dfdcf396030d181a2719c912", + "retrieved_at": "2026-07-27T05:55:55.351598Z" }, { "id": "coreapi-card-3ds", @@ -78,8 +78,8 @@ "coreapi.card.3ds", "coreapi.card.redirect" ], - "sha256": "ad4684366321b7e3ce63c0323d172a7f33e68507f7377256dc263f61cfaf1a2a", - "retrieved_at": "2026-07-27T05:02:08.464875Z" + "sha256": "25dfee11803ad09252e27a3184812cb869f488f009d72afb27478f3484c8ae69", + "retrieved_at": "2026-07-27T05:55:55.351598Z" }, { "id": "coreapi-one-click", @@ -87,8 +87,8 @@ "rules": [ "coreapi.saved-card.token-only" ], - "sha256": "939154500068ed6239307325448f7023fa261fb47957a9925e5fd3ed2b20fd45", - "retrieved_at": "2026-07-27T05:02:08.464875Z" + "sha256": "af46f795daa26129b90f4f6d3aa3a924bd20830e8596b9a280b844c15e72afa3", + "retrieved_at": "2026-07-27T05:55:55.351598Z" }, { "id": "coreapi-alfamart", @@ -97,8 +97,8 @@ "coreapi.otc.charge", "coreapi.otc.payment-code" ], - "sha256": "b26c2474ba069baa2d7b773797a2d3cff4fa678efe5342b68592425d0a5d5235", - "retrieved_at": "2026-07-27T05:02:08.464875Z" + "sha256": "2a3d47bca0e2d2b162bea0ba403b11f3023e0af4eb61ee41116cabf8369555da", + "retrieved_at": "2026-07-27T05:55:55.351598Z" }, { "id": "coreapi-bni-va", @@ -107,8 +107,8 @@ "coreapi.va.charge", "coreapi.va.instructions" ], - "sha256": "ad6910f9758488421dab28b33900a6b7cad169e6cbe4953c8625cc33fc842961", - "retrieved_at": "2026-07-27T05:02:08.464875Z" + "sha256": "8ed91ba7ff4f8d22f539d8105077d202453f09cb9616f96d1c5c897521ed8563", + "retrieved_at": "2026-07-27T05:55:55.351598Z" }, { "id": "coreapi-status", @@ -117,8 +117,8 @@ "coreapi.status.reconcile", "coreapi.refund.status" ], - "sha256": "a91005e80e5c9c2cd9979c435f95e306435b5acebb67c6a9c065c947bde951b2", - "retrieved_at": "2026-07-27T05:02:08.464875Z" + "sha256": "ba1a9f56ccfc7e2b87b1250b74b119c10b55fe19e1c4cd6432ca75f8b414d860", + "retrieved_at": "2026-07-27T05:55:55.351598Z" }, { "id": "coreapi-refund", @@ -127,8 +127,8 @@ "coreapi.refund.async", "coreapi.refund.idempotency" ], - "sha256": "27be8dfec8e3a9fbd0aa8915025beabc4b585055e0e4066e6bf5dff0425af10f", - "retrieved_at": "2026-07-27T05:02:08.464875Z" + "sha256": "e7f8e485e78baa74acf01693f06c259949235ac136deff6dd7d8581795914b86", + "retrieved_at": "2026-07-27T05:55:55.351598Z" }, { "id": "coreapi-direct-refund", @@ -136,8 +136,8 @@ "rules": [ "coreapi.refund.direct" ], - "sha256": "b9abecbbd9642b758034f241bb7b994bcd62f47000f75cf06f0a4f5ac7992fd7", - "retrieved_at": "2026-07-27T05:02:08.464875Z" + "sha256": "a5d979e0c0135b7051e5ddb57003187cae5f783f2ecd00dfd13d21ba54ba5571", + "retrieved_at": "2026-07-27T05:55:55.351598Z" }, { "id": "coreapi-notifications", @@ -146,8 +146,8 @@ "coreapi.notification.signature", "common.webhook-idempotency" ], - "sha256": "38d48e63e1748126258827be9540f025bd221b1623f627fa461ffe54528d8ccd", - "retrieved_at": "2026-07-27T05:02:08.464875Z" + "sha256": "a12432b47c37a3ecfb73254faad6433033f4c870b80ee894fb520e43db974402", + "retrieved_at": "2026-07-27T05:55:55.351598Z" }, { "id": "payment-link-overview", @@ -156,8 +156,8 @@ "paymentlink.create", "paymentlink.reusable" ], - "sha256": "1cfceda094f7de9b3a042f95acae38ca78b72eff0f051513bcd6068c94a20ab6", - "retrieved_at": "2026-07-27T05:02:08.464875Z" + "sha256": "6b0c5544ea6bfae53ee07c8d9dff7740e765a4bd5e0e7872e476022dab7d2c06", + "retrieved_at": "2026-07-27T05:55:55.351598Z" }, { "id": "payment-link-status", @@ -165,8 +165,8 @@ "rules": [ "paymentlink.status.reconcile" ], - "sha256": "a91005e80e5c9c2cd9979c435f95e306435b5acebb67c6a9c065c947bde951b2", - "retrieved_at": "2026-07-27T05:02:08.464875Z" + "sha256": "ba1a9f56ccfc7e2b87b1250b74b119c10b55fe19e1c4cd6432ca75f8b414d860", + "retrieved_at": "2026-07-27T05:55:55.351598Z" }, { "id": "payment-link-notifications", @@ -175,8 +175,8 @@ "paymentlink.notification.signature", "common.webhook-idempotency" ], - "sha256": "38d48e63e1748126258827be9540f025bd221b1623f627fa461ffe54528d8ccd", - "retrieved_at": "2026-07-27T05:02:08.464875Z" + "sha256": "a12432b47c37a3ecfb73254faad6433033f4c870b80ee894fb520e43db974402", + "retrieved_at": "2026-07-27T05:55:55.351598Z" }, { "id": "bisnap-overview", @@ -184,8 +184,8 @@ "rules": [ "bisnap.signing.verify.v1" ], - "sha256": "42b94bb13823e0e3122a5ec5e73f3a002a969911d05230022c0f08eed9b36288", - "retrieved_at": "2026-07-27T05:02:08.464875Z" + "sha256": "ce9d05eaa2c1cc5777f0728ba2f66b6d91d611a68aa095a4f1508f4b4a2d17c8", + "retrieved_at": "2026-07-27T05:55:55.351598Z" }, { "id": "bisnap-qris", @@ -194,8 +194,8 @@ "bisnap.qris.create", "bisnap.qris.status" ], - "sha256": "d298987413b18d45a7c3015b233ca364b6625f785a9be75f7866862bfa0f8a65", - "retrieved_at": "2026-07-27T05:02:08.464875Z" + "sha256": "bce428e20acc6a8f286929b7306c7a486ccc7ba5e7ed6db3c158e8368003ff88", + "retrieved_at": "2026-07-27T05:55:55.351598Z" }, { "id": "bisnap-virtual-account", @@ -204,8 +204,8 @@ "bisnap.virtual-account.create", "bisnap.virtual-account.status" ], - "sha256": "db4f536cec443b37688f67a4e8495e8d22ca159644a3113141ee822806a0c69a", - "retrieved_at": "2026-07-27T05:02:08.464875Z" + "sha256": "ebca23cb92df737d9d46e39ffca1d00aca868071a4af89a727df66dfa76d6c3b", + "retrieved_at": "2026-07-27T05:55:55.351598Z" }, { "id": "bisnap-direct-debit", @@ -215,8 +215,8 @@ "bisnap.direct-debit.status", "bisnap.refund" ], - "sha256": "7816e56ae9fb3d87d8e36d18b7d03f297a08808292b1ae4555841b2b041082b1", - "retrieved_at": "2026-07-27T05:02:08.464875Z" + "sha256": "2f3ebabf4b1df8f3cf2a9143557b0b8ca4c4cf2fb984f68523f30b7e6ba9613f", + "retrieved_at": "2026-07-27T05:55:55.351598Z" }, { "id": "bisnap-notifications", @@ -225,8 +225,8 @@ "bisnap.notification.signature", "common.webhook-idempotency" ], - "sha256": "3b61e701807e10b9f954ef43aa5871e7c8172036b2163c16cda23b8d91b65a80", - "retrieved_at": "2026-07-27T05:02:08.464875Z" + "sha256": "29ee27095c97e6e8aae14a3777227abcc34df1535571476e3fab16f16614b877", + "retrieved_at": "2026-07-27T05:55:55.351598Z" }, { "id": "gopay-tokenization-get-auth-code", @@ -234,26 +234,26 @@ "rules": [ "gopaytokenization.linking.get-auth-code" ], - "sha256": "", - "retrieved_at": "2026-07-27T05:02:08.464875Z" + "sha256": "9d30e1c9003c980550ff53d7e2dd26595c1ee36d6356204d92ede218d42510a3", + "retrieved_at": "2026-07-27T05:55:55.351598Z" }, { - "id": "gopay-tokenization-bind-account", - "url": "https://docs.midtrans.com/reference/bind-account-api", + "id": "gopay-tokenization-binding-api", + "url": "https://docs.midtrans.com/reference/binding-api", "rules": [ "gopaytokenization.linking.bind" ], - "sha256": "", - "retrieved_at": "2026-07-27T05:02:08.464875Z" + "sha256": "50b4543fea221cc80059fdb2524ad2057e4f59a1bb1800a6432e296eacad75a7", + "retrieved_at": "2026-07-27T05:55:55.351598Z" }, { - "id": "gopay-tokenization-binding-inquiry", - "url": "https://docs.midtrans.com/reference/account-binding-inquiry-api", + "id": "gopay-tokenization-binding-inquiry-api", + "url": "https://docs.midtrans.com/reference/binding-inquiry-api", "rules": [ "gopaytokenization.linking.inquiry" ], - "sha256": "", - "retrieved_at": "2026-07-27T05:02:08.464875Z" + "sha256": "530e5b3e0c283bff497955f305c38ab78df6a45f38fed4bdcb16c4a4bbdba389", + "retrieved_at": "2026-07-27T05:55:55.351598Z" }, { "id": "gopay-tokenization-direct-debit", @@ -262,8 +262,8 @@ "gopaytokenization.wallet.charge", "gopaytokenization.paylater.charge" ], - "sha256": "", - "retrieved_at": "2026-07-27T05:02:08.464875Z" + "sha256": "e25d64b0f8a3152e4ebd734685fd7687fae84b03e79bce6d8052fff45d9b8161", + "retrieved_at": "2026-07-27T05:55:55.351598Z" }, { "id": "gopay-tokenization-unbind", @@ -271,18 +271,18 @@ "rules": [ "gopaytokenization.unlink" ], - "sha256": "", - "retrieved_at": "2026-07-27T05:02:08.464875Z" + "sha256": "88e9392889442296dd009b82f648b3277b1c73dbd3d314459388ac1544cd3b3a", + "retrieved_at": "2026-07-27T05:55:55.351598Z" }, { - "id": "gopay-tokenization-account-notifications", - "url": "https://docs.midtrans.com/reference/account-linking-and-unlinking-notification", + "id": "gopay-tokenization-account-linking-unlinking-notification", + "url": "https://docs.midtrans.com/reference/account-linking-unlinking-notification", "rules": [ "gopaytokenization.notification.signature", "common.webhook-idempotency" ], - "sha256": "3b61e701807e10b9f954ef43aa5871e7c8172036b2163c16cda23b8d91b65a80", - "retrieved_at": "2026-07-27T05:02:08.464875Z" + "sha256": "2b54c5642de744e6222aef98d4e147a40bd77297becd1ee8e19d5395514502a1", + "retrieved_at": "2026-07-27T05:55:55.351598Z" } ] } diff --git a/internal/app/commands_agent.go b/internal/app/commands_agent.go index 4cb1a73..cae3a01 100644 --- a/internal/app/commands_agent.go +++ b/internal/app/commands_agent.go @@ -78,6 +78,7 @@ type genericJourneyFlags struct { evidencePath string customerReference string paymentTokenReference string + mobileNumberReference string amount int64 usageLimit int reusable bool @@ -98,6 +99,7 @@ func (f *genericJourneyFlags) bind(command *cobra.Command, includeExecute bool, command.Flags().StringVar(&f.evidencePath, "evidence", "", "checksummed evidence JSON file") command.Flags().StringVar(&f.customerReference, "customer-reference", "", "safe customer reference") command.Flags().StringVar(&f.paymentTokenReference, "payment-token-reference", "", "safe payment token reference") + command.Flags().StringVar(&f.mobileNumberReference, "mobile-number-reference", "", "safe mobile number reference") command.Flags().BoolVar(&f.reusable, "reusable", false, "request a reusable payment resource") command.Flags().IntVar(&f.usageLimit, "usage-limit", 0, "explicit reusable payment usage limit") if includeExecute { @@ -123,6 +125,7 @@ func (f *genericJourneyFlags) toRunRequest(flags *globalFlags, execute bool) jou Method: f.method, CustomerReference: f.customerReference, PaymentTokenReference: f.paymentTokenReference, + MobileNumberReference: f.mobileNumberReference, Reusable: f.reusable, }, Execute: execute, diff --git a/internal/app/commands_checkout.go b/internal/app/commands_checkout.go index 1eea9b3..208b2db 100644 --- a/internal/app/commands_checkout.go +++ b/internal/app/commands_checkout.go @@ -42,6 +42,7 @@ type merchantJourneyFlags struct { evidencePath string customerReference string paymentTokenReference string + mobileNumberReference string amount int64 usageLimit int reusable bool @@ -59,6 +60,7 @@ func (f *merchantJourneyFlags) bind(command *cobra.Command) { command.Flags().StringVar(&f.evidencePath, "evidence", "", "checksummed evidence JSON file") command.Flags().StringVar(&f.customerReference, "customer-reference", "", "safe customer reference") command.Flags().StringVar(&f.paymentTokenReference, "payment-token-reference", "", "safe payment token reference") + command.Flags().StringVar(&f.mobileNumberReference, "mobile-number-reference", "", "safe mobile number reference") command.Flags().BoolVar(&f.reusable, "reusable", false, "request a reusable payment resource") command.Flags().IntVar(&f.usageLimit, "usage-limit", 0, "explicit reusable payment usage limit") command.Flags().StringVar(&f.product, "product", "", "product override when no manifest route is configured") @@ -128,6 +130,7 @@ func runMerchantJourney( request.method, request.customerReference, request.paymentTokenReference, + request.mobileNumberReference, request.usageLimit, request.reusable, ), @@ -226,6 +229,7 @@ func journeyInput( method string, customerReference string, paymentTokenReference string, + mobileNumberReference string, usageLimit int, reusable bool, ) journey.Input { @@ -236,6 +240,7 @@ func journeyInput( Method: method, CustomerReference: customerReference, PaymentTokenReference: paymentTokenReference, + MobileNumberReference: mobileNumberReference, Reusable: reusable, } } diff --git a/internal/journey/types.go b/internal/journey/types.go index 0f40cd7..dda19b5 100644 --- a/internal/journey/types.go +++ b/internal/journey/types.go @@ -37,6 +37,7 @@ type Input struct { Method string `json:"method,omitempty"` CustomerReference string `json:"customer_reference,omitempty"` PaymentTokenReference string `json:"payment_token_reference,omitempty"` + MobileNumberReference string `json:"mobile_number_reference,omitempty"` Reusable bool `json:"reusable,omitempty"` } diff --git a/packs/gopaytokenization/client.go b/packs/gopaytokenization/client.go index 6fe28a7..63332d4 100644 --- a/packs/gopaytokenization/client.go +++ b/packs/gopaytokenization/client.go @@ -3,9 +3,6 @@ package gopaytokenization import ( "bytes" "context" - "crypto/hmac" - "crypto/sha512" - "encoding/base64" "encoding/json" "errors" "io" @@ -76,11 +73,12 @@ type Client struct { } type GetAuthCodeInput struct { - StateHash string - MerchantID string - RedirectURL string - Scopes []string - Lang string + StateHash string + MerchantID string + RedirectURL string + MobileNumber string + Scopes []string + Lang string } type BindingRequestInput struct { @@ -154,17 +152,22 @@ func (c Client) now() time.Time { } func (c Client) NewGetAuthCodeRequest(ctx context.Context, input GetAuthCodeInput) (*http.Request, error) { - if strings.TrimSpace(input.StateHash) == "" || strings.TrimSpace(input.MerchantID) == "" || strings.TrimSpace(input.RedirectURL) == "" { + if strings.TrimSpace(input.StateHash) == "" || strings.TrimSpace(input.MerchantID) == "" || strings.TrimSpace(input.RedirectURL) == "" || strings.TrimSpace(input.MobileNumber) == "" { return nil, bisnapRequestInvalid() } - if len(c.ClientSecret) == 0 { + if len(c.PrivateKeyPEM) == 0 { return nil, bisnapRequestInvalid() } - seamlessData, err := json.Marshal(map[string]string{"state_hash": strings.TrimSpace(input.StateHash)}) + seamlessValues := url.Values{} + seamlessValues.Set("mobileNumber", strings.TrimSpace(input.MobileNumber)) + seamlessValues.Set("paymentType", "gopay") + seamlessData := seamlessValues.Encode() + seamlessSign, err := SignSeamlessData(c.PrivateKeyPEM, seamlessData) if err != nil { return nil, bisnapRequestInvalid() } values := url.Values{} + values.Set("state", input.StateHash) values.Set("merchantId", input.MerchantID) values.Set("redirectURL", input.RedirectURL) values.Set("scopes", strings.Join(input.Scopes, ",")) @@ -173,8 +176,8 @@ func (c Client) NewGetAuthCodeRequest(ctx context.Context, input GetAuthCodeInpu lang = "en" } values.Set("lang", lang) - values.Set("seamlessData", string(seamlessData)) - values.Set("seamlessSign", signSeamlessData(c.ClientSecret, seamlessData)) + values.Set("seamlessData", seamlessData) + values.Set("seamlessSign", seamlessSign) request, err := http.NewRequestWithContext( ctx, http.MethodGet, @@ -319,12 +322,6 @@ func (c Client) Payment(ctx context.Context, input PaymentRequestInput) (Payment return result, nil } -func signSeamlessData(secret []byte, seamlessData []byte) string { - mac := hmac.New(sha512.New, secret) - _, _ = mac.Write(seamlessData) - return base64.StdEncoding.EncodeToString(mac.Sum(nil)) -} - func (c Client) newTransactionRequest(ctx context.Context, method, path, accessToken, customerToken string, body []byte, useApplicationHost bool) (*http.Request, error) { bridge := c.bisnapClient() return bridge.NewTransactionRequest(ctx, bisnap.Request{ diff --git a/packs/gopaytokenization/client_test.go b/packs/gopaytokenization/client_test.go index ec7a24d..96d1832 100644 --- a/packs/gopaytokenization/client_test.go +++ b/packs/gopaytokenization/client_test.go @@ -2,7 +2,6 @@ package gopaytokenization_test import ( "context" - "encoding/json" "io" "net/http" "strings" @@ -25,11 +24,12 @@ func TestClientUsesSandboxHostsAndSeparatesTokenizedHeaders(t *testing.T) { } authCodeRequest, err := client.NewGetAuthCodeRequest(context.Background(), gopaytokenization.GetAuthCodeInput{ - StateHash: "state-hash-123", - MerchantID: "demo-merchant", - RedirectURL: "http://127.0.0.1:3101/payments/gopay/return", - Scopes: []string{"PAYMENT_ONETIME", "PAYMENT_BINDING"}, - Lang: "id", + StateHash: "state-hash-123", + MerchantID: "demo-merchant", + RedirectURL: "http://127.0.0.1:3101/payments/gopay/return", + MobileNumber: "08123456789", + Scopes: []string{"PAYMENT_ONETIME", "PAYMENT_BINDING"}, + Lang: "id", }) if err != nil { t.Fatal(err) @@ -44,6 +44,9 @@ func TestClientUsesSandboxHostsAndSeparatesTokenizedHeaders(t *testing.T) { if got := query.Get("merchantId"); got != "demo-merchant" { t.Fatalf("merchantId = %q", got) } + if got := query.Get("state"); got != "state-hash-123" { + t.Fatalf("state = %q", got) + } if got := query.Get("redirectURL"); got != "http://127.0.0.1:3101/payments/gopay/return" { t.Fatalf("redirectURL = %q", got) } @@ -53,12 +56,8 @@ func TestClientUsesSandboxHostsAndSeparatesTokenizedHeaders(t *testing.T) { if got := query.Get("lang"); got != "id" { t.Fatalf("lang = %q", got) } - var seamlessData map[string]string - if err := json.Unmarshal([]byte(query.Get("seamlessData")), &seamlessData); err != nil { - t.Fatalf("seamlessData = %q err=%v", query.Get("seamlessData"), err) - } - if seamlessData["state_hash"] != "state-hash-123" { - t.Fatalf("seamlessData = %#v", seamlessData) + if got := query.Get("seamlessData"); got != "mobileNumber=08123456789&paymentType=gopay" { + t.Fatalf("seamlessData = %q", got) } if query.Get("seamlessSign") == "" { t.Fatalf("seamlessSign is empty") @@ -160,6 +159,20 @@ func TestClientUsesSandboxHostsAndSeparatesTokenizedHeaders(t *testing.T) { } } +func TestSignSeamlessDataMatchesFixedVector(t *testing.T) { + got, err := gopaytokenization.SignSeamlessData( + fixtureBytes(t, "private_key_pkcs8.pem"), + "mobileNumber=08123456789&paymentType=gopay", + ) + if err != nil { + t.Fatal(err) + } + const want = "Ui00uNw/Y9dUxUtNgEv8CDgWAIEfP2cs/KqDzLjit2V1bqCwUW1OSGbDbRlFgYC6wcYHZ3JI6E13ms0PaN34vPLlmc2PEi3O3yqH8Zght/uuHAS2rESoh5v3vUg3DVMm8A6TXrfz5wE2S9wQWb4pS0Y2hzp4FqhnYHsp0YeJ+TI7IH/fVqEbAVRG7oNpmg1PKOgsv6UAh4OKjh6PiGuRle8KjKDmwnYAHJWJ+yfTF9PCpsnblha7nn4JA/zkXxmpmi86jv+npajTtvBSiJvbdOn9i9lwOArMuexG2t/DCFSrNd/mMYdljYsBfubgqNjSkIckAjX8FBwvEWIEOFf+BA==" + if got != want { + t.Fatalf("signature = %q, want %q", got, want) + } +} + func TestClientParsesDocumentedBindingAndInquiryResponses(t *testing.T) { client := gopaytokenization.Client{ HTTP: appDoerFunc(func(request *http.Request) (*http.Response, error) { diff --git a/packs/gopaytokenization/journey.go b/packs/gopaytokenization/journey.go index 4d94734..52f6832 100644 --- a/packs/gopaytokenization/journey.go +++ b/packs/gopaytokenization/journey.go @@ -62,6 +62,9 @@ func (h Handler) WithRunner(runner JourneyRunner) Handler { func (h Handler) Definition() journey.Definition { return h.definition } func (h Handler) Plan(_ context.Context, request journey.Request, _ journey.Runtime) journey.Outcome { + if h.definition.ID == "gopay-tokenization.account-linking" && strings.TrimSpace(request.Input.MobileNumberReference) == "" { + return inputRequired("mobile_number_reference is required for account linking") + } safeData := map[string]any{ "order_id": request.Input.OrderID, } @@ -93,21 +96,21 @@ func (h Handler) run(ctx context.Context, request journey.Request, runtime journ return blockedFinding("CAPABILITY_UNAVAILABLE", "gopay-tokenization integration is not configured for this project") } credentials, ok := request.Manifest.CredentialSetFor(integration.Credentials) - if !ok || credentials.ClientSecret == "" { - return blockedFinding("CREDENTIAL_MISSING", "the configured gopay-tokenization client_secret reference is not set") + if !ok || credentials.PrivateKey == "" { + return blockedFinding("CREDENTIAL_MISSING", "the configured gopay-tokenization private_key reference is not set") } if runtime.ResolveCredential == nil { return blockedFinding("JOURNEY_EXECUTION_BLOCKED", "journey runtime dependencies are unavailable") } - rawClientSecret, err := runtime.ResolveCredential(ctx, request.ProjectDir, credentials.ClientSecret) + rawPrivateKey, err := runtime.ResolveCredential(ctx, request.ProjectDir, credentials.PrivateKey) if err != nil { - return blockedFinding("CREDENTIAL_RESOLUTION_FAILED", "unable to resolve the configured client_secret reference") + return blockedFinding("CREDENTIAL_RESOLUTION_FAILED", "unable to resolve the configured private_key reference") } return h.runAccountLinking(ctx, request, JourneyRunner{ Now: runtimeNow(runtime), ResolveCredential: runtime.ResolveCredential, Client: Client{ - ClientSecret: append([]byte(nil), rawClientSecret...), + PrivateKeyPEM: append([]byte(nil), rawPrivateKey...), }, }, integration, nil) } @@ -154,12 +157,17 @@ func (h Handler) runAccountLinking(ctx context.Context, request journey.Request, } redirectURL := callbackURL(request.Manifest.Application.BaseURL, integration.Callbacks["account_linking"]) stateHash := computeStateHash(request.OperationID, request.Input.OrderID, merchantID, redirectURL) + mobileNumber, outcome := resolveMobileNumber(ctx, request, runner) + if outcome != nil { + return *outcome + } authRequest, err := runner.Client.NewGetAuthCodeRequest(ctx, GetAuthCodeInput{ - StateHash: stateHash, - MerchantID: merchantID, - RedirectURL: redirectURL, - Scopes: []string{"PAYMENT_BINDING"}, - Lang: "id", + StateHash: stateHash, + MerchantID: merchantID, + RedirectURL: redirectURL, + MobileNumber: mobileNumber, + Scopes: []string{"PAYMENT_BINDING"}, + Lang: "id", }) if err != nil { return blockedOutcome("auth-code request could not be prepared") @@ -531,6 +539,24 @@ func resolveMerchantID(ctx context.Context, request journey.Request, runner Jour return strings.TrimSpace(string(rawMerchantID)), nil } +func resolveMobileNumber(ctx context.Context, request journey.Request, runner JourneyRunner) (string, *journey.Outcome) { + if strings.TrimSpace(request.Input.MobileNumberReference) == "" { + outcome := inputRequired("mobile_number_reference is required for account linking") + return "", &outcome + } + rawMobileNumber, err := runner.resolveCredential(ctx, request.ProjectDir, request.Input.MobileNumberReference) + if err != nil { + outcome := blockedFinding("CREDENTIAL_RESOLUTION_FAILED", "unable to resolve the mobile_number_reference") + return "", &outcome + } + mobileNumber := strings.TrimSpace(string(rawMobileNumber)) + if mobileNumber == "" { + outcome := blockedFinding("CREDENTIAL_RESOLUTION_FAILED", "unable to resolve the mobile_number_reference") + return "", &outcome + } + return mobileNumber, nil +} + func callbackURL(baseURL, route string) string { base := strings.TrimRight(baseURL, "/") if strings.HasPrefix(route, "http://") || strings.HasPrefix(route, "https://") { diff --git a/packs/gopaytokenization/journey_test.go b/packs/gopaytokenization/journey_test.go index 30abbe7..ced97a1 100644 --- a/packs/gopaytokenization/journey_test.go +++ b/packs/gopaytokenization/journey_test.go @@ -19,6 +19,7 @@ import ( func TestAccountLinkingResumeRequiresStateProofAndAuthCodeReference(t *testing.T) { handler := gopaytokenization.NewAccountLinkingHandler() request := gopayRequest("gopay-tokenization.account-linking", "link-order", 0, "") + request.Input.MobileNumberReference = "env:MIDTRANS_GOPAY_MOBILE_NUMBER" first := handler.Execute(context.Background(), request, journeypkg.Runtime{ ResolveCredential: gopayResolveCredential(t), @@ -48,9 +49,8 @@ func TestAccountLinkingResumeRequiresStateProofAndAuthCodeReference(t *testing.T ManifestHash: strings.Repeat("a", 64), State: string(journeypkg.AwaitingUserAction), SafeReferences: map[string]string{ - "order_id": "link-order", - "state_hash": first.SafeData["state_hash"].(string), - "merchant_id": "demo-merchant", + "order_id": "link-order", + "state_hash": first.SafeData["state_hash"].(string), }, StartedAt: fixedNow(), UpdatedAt: fixedNow(), @@ -67,9 +67,18 @@ func TestAccountLinkingResumeRequiresStateProofAndAuthCodeReference(t *testing.T } } +func TestAccountLinkingPlanRequiresMobileNumberReference(t *testing.T) { + handler := gopaytokenization.NewAccountLinkingHandler() + outcome := handler.Plan(context.Background(), gopayRequest("gopay-tokenization.account-linking", "link-order", 0, ""), journeypkg.Runtime{}) + if outcome.State != journeypkg.Blocked || outcome.Finding == nil || outcome.Finding.Code != "JOURNEY_INPUT_REQUIRED" { + t.Fatalf("outcome = %#v", outcome) + } +} + func TestAccountLinkingResumeRequiresBoundProofHashAndDoesNotPersistCredentialReference(t *testing.T) { handler := gopaytokenization.NewAccountLinkingHandler() request := gopayRequest("gopay-tokenization.account-linking", "link-order", 0, "") + request.Input.MobileNumberReference = "env:MIDTRANS_GOPAY_MOBILE_NUMBER" first := handler.Execute(context.Background(), request, journeypkg.Runtime{ ResolveCredential: gopayResolveCredential(t), @@ -352,6 +361,8 @@ func gopayResolveCredential(t *testing.T) func(context.Context, string, string) return []byte("device-canary"), nil case "env:MIDTRANS_GOPAY_MERCHANT_ID": return []byte("demo-merchant"), nil + case "env:MIDTRANS_GOPAY_MOBILE_NUMBER": + return []byte("08123456789"), nil case "file:./secrets/bisnap-private.pem": return fixtureBytes(t, "private_key_pkcs8.pem"), nil case "file:./secrets/bisnap-public.pem": diff --git a/packs/gopaytokenization/pack.go b/packs/gopaytokenization/pack.go index 54df044..7575c65 100644 --- a/packs/gopaytokenization/pack.go +++ b/packs/gopaytokenization/pack.go @@ -47,11 +47,11 @@ func (Pack) Descriptor() packs.Descriptor { }, Sources: []contracts.PublicSource{ {ID: "gopay-tokenization-get-auth-code", URL: "https://docs.midtrans.com/reference/get-auth-code-api", Rules: []string{"gopaytokenization.linking.get-auth-code"}}, - {ID: "gopay-tokenization-bind-account", URL: "https://docs.midtrans.com/reference/bind-account-api", Rules: []string{"gopaytokenization.linking.bind"}}, - {ID: "gopay-tokenization-binding-inquiry", URL: "https://docs.midtrans.com/reference/account-binding-inquiry-api", Rules: []string{"gopaytokenization.linking.inquiry"}}, + {ID: "gopay-tokenization-binding-api", URL: "https://docs.midtrans.com/reference/binding-api", Rules: []string{"gopaytokenization.linking.bind"}}, + {ID: "gopay-tokenization-binding-inquiry-api", URL: "https://docs.midtrans.com/reference/binding-inquiry-api", Rules: []string{"gopaytokenization.linking.inquiry"}}, {ID: "gopay-tokenization-direct-debit", URL: "https://docs.midtrans.com/reference/direct-debit-api-gopay-tokenization", Rules: []string{"gopaytokenization.wallet.charge", "gopaytokenization.paylater.charge"}}, {ID: "gopay-tokenization-unbind", URL: "https://docs.midtrans.com/reference/unbind-api", Rules: []string{"gopaytokenization.unlink"}}, - {ID: "gopay-tokenization-account-notifications", URL: "https://docs.midtrans.com/reference/account-linking-and-unlinking-notification", Rules: []string{"gopaytokenization.notification.signature", "common.webhook-idempotency"}}, + {ID: "gopay-tokenization-account-linking-unlinking-notification", URL: "https://docs.midtrans.com/reference/account-linking-unlinking-notification", Rules: []string{"gopaytokenization.notification.signature", "common.webhook-idempotency"}}, }, } } diff --git a/packs/gopaytokenization/seamless_test.go b/packs/gopaytokenization/seamless_test.go index 6e9a15c..c7feab2 100644 --- a/packs/gopaytokenization/seamless_test.go +++ b/packs/gopaytokenization/seamless_test.go @@ -30,7 +30,9 @@ func TestAccountLinkingPersistenceStoresOnlySafeState(t *testing.T) { }, } - outcome := engine.Run(context.Background(), handler, gopayRequest("gopay-tokenization.account-linking", "persist-order", 0, ""), true) + request := gopayRequest("gopay-tokenization.account-linking", "persist-order", 0, "") + request.Input.MobileNumberReference = "env:MIDTRANS_GOPAY_MOBILE_NUMBER" + outcome := engine.Run(context.Background(), handler, request, true) if outcome.State != journeypkg.AwaitingUserAction { t.Fatalf("outcome = %#v", outcome) } diff --git a/packs/gopaytokenization/signature.go b/packs/gopaytokenization/signature.go new file mode 100644 index 0000000..270fc09 --- /dev/null +++ b/packs/gopaytokenization/signature.go @@ -0,0 +1,60 @@ +package gopaytokenization + +import ( + "crypto" + "crypto/rsa" + "crypto/sha256" + "crypto/x509" + "encoding/base64" + "encoding/pem" + "errors" + "strings" +) + +var ( + errKeyInvalid = errors.New("GOPAY_TOKENIZATION_KEY_INVALID") + errRequestInvalid = errors.New("SANDBOX_REQUEST_INVALID") +) + +func SignSeamlessData(privateKeyPEM []byte, seamlessData string) (string, error) { + if strings.TrimSpace(seamlessData) == "" { + return "", errRequestInvalid + } + privateKey, err := parsePrivateKey(privateKeyPEM) + if err != nil { + return "", err + } + digest := sha256.Sum256([]byte(seamlessData)) + signature, err := rsa.SignPKCS1v15(nil, privateKey, crypto.SHA256, digest[:]) + if err != nil { + return "", errKeyInvalid + } + return base64.StdEncoding.EncodeToString(signature), nil +} + +func parsePrivateKey(privateKeyPEM []byte) (*rsa.PrivateKey, error) { + block, rest := pem.Decode(privateKeyPEM) + if block == nil || strings.TrimSpace(string(rest)) != "" { + return nil, errKeyInvalid + } + switch block.Type { + case "RSA PRIVATE KEY": + privateKey, err := x509.ParsePKCS1PrivateKey(block.Bytes) + if err != nil { + return nil, errKeyInvalid + } + return privateKey, nil + case "PRIVATE KEY": + key, err := x509.ParsePKCS8PrivateKey(block.Bytes) + if err != nil { + return nil, errKeyInvalid + } + privateKey, ok := key.(*rsa.PrivateKey) + if !ok { + return nil, errKeyInvalid + } + return privateKey, nil + default: + return nil, errKeyInvalid + } +} From 710d0dc0d40699f3e2cbdded02a4f10e7efad594 Mon Sep 17 00:00:00 2001 From: "m.salis" Date: Mon, 27 Jul 2026 13:28:16 +0700 Subject: [PATCH 53/73] feat: add subscription lifecycle journeys --- .../task-11-report.md | 92 ++++ cmd/midtrans/main.go | 3 +- contracts/capabilities-v1.json | 18 + contracts/public-sources-v1.json | 176 +++++--- internal/app/app_test.go | 18 +- internal/app/commands_agent.go | 12 + internal/app/commands_checkout.go | 20 + internal/journey/types.go | 4 + internal/sourceprovenance/baseline_test.go | 14 +- internal/sourceprovenance/catalog.go | 2 + packs/subscription/client.go | 267 ++++++++++++ packs/subscription/client_test.go | 186 ++++++++ packs/subscription/journey.go | 401 ++++++++++++++++++ packs/subscription/journey_test.go | 251 +++++++++++ packs/subscription/pack.go | 81 ++++ packs/subscription/pack_test.go | 50 +++ testdata/subscription/README.md | 1 + 17 files changed, 1528 insertions(+), 68 deletions(-) create mode 100644 .superpowers/sdd/2026-07-26-midtrans-cli-multi-product-parity/task-11-report.md create mode 100644 packs/subscription/client.go create mode 100644 packs/subscription/client_test.go create mode 100644 packs/subscription/journey.go create mode 100644 packs/subscription/journey_test.go create mode 100644 packs/subscription/pack.go create mode 100644 packs/subscription/pack_test.go create mode 100644 testdata/subscription/README.md diff --git a/.superpowers/sdd/2026-07-26-midtrans-cli-multi-product-parity/task-11-report.md b/.superpowers/sdd/2026-07-26-midtrans-cli-multi-product-parity/task-11-report.md new file mode 100644 index 0000000..c9c6f7b --- /dev/null +++ b/.superpowers/sdd/2026-07-26-midtrans-cli-multi-product-parity/task-11-report.md @@ -0,0 +1,92 @@ +# Task 11 Report + +Date: 2026-07-27 + +## Outcome + +Implemented the classic Subscription API lifecycle slice for Task 11 and +registered it in the CLI as a dedicated `subscription` pack. + +Delivered journeys: + +- `subscription.create` +- `subscription.verify` +- `subscription.disable` +- `subscription.enable` +- `subscription.cancel` + +This slice intentionally stops at the classic Subscription API lifecycle. It +does not add the broader recurring-verification follow-up inside `core-api`, +`bisnap`, or `gopay-tokenization`. + +## What Changed + +- Added `packs/subscription/pack.go` with the published capabilities, journeys, + sandbox host, sensitive-key policy, and public-source declarations. +- Added `packs/subscription/client.go` with classic Basic Auth calls for: + - `POST /v1/subscriptions` + - `GET /v1/subscriptions/{id}` + - `PATCH /v1/subscriptions/{id}` + - `POST /v1/subscriptions/{id}/disable` + - `POST /v1/subscriptions/{id}/enable` + - `POST /v1/subscriptions/{id}/cancel` +- Added `packs/subscription/journey.go` with: + - typed schedule fields already introduced on the shared input surface + - in-memory saved-token resolution only + - safe persistence limited to subscription ID and schedule facts + - status-before-mutation for disable/enable/cancel + - no blind retry after ambiguous mutations + - update support through `subscription.create` when `subscription_id` is supplied +- Added focused tests in `packs/subscription/{client_test.go,journey_test.go,pack_test.go}` plus `testdata/subscription/README.md`. +- Registered the pack in `cmd/midtrans/main.go`. +- Updated `internal/sourceprovenance/catalog.go`, + `contracts/capabilities-v1.json`, and regenerated + `contracts/public-sources-v1.json`. +- Updated `internal/app/app_test.go` and `internal/sourceprovenance/baseline_test.go` + for the expanded runtime/public contract set. + +## Validation + +RED checkpoint: + +```sh +go test ./packs/subscription -count=1 +``` + +Initial result: the package only existed as a new scaffold, then passed after +the lifecycle implementation landed. + +Focused validation: + +```sh +go test ./packs/subscription -count=1 +go test ./internal/app ./internal/sourceprovenance ./packs/subscription ./cmd/midtrans -count=1 +``` + +Result: passed. + +Full validation: + +```sh +go test ./... -count=1 +``` + +Result: passed on Monday, July 27, 2026. + +## Behavioral Guarantees Now Covered + +- Subscription lifecycle calls stay on `https://api.sandbox.midtrans.com`. +- Classic server-key Basic Auth is used for every subscription endpoint. +- Saved payment tokens are resolved at runtime and never persisted. +- Only safe schedule facts and `subscription_id` are stored in operation records. +- `disable`, `enable`, and `cancel` reconcile current subscription status before mutating. +- Ambiguous mutation results reconcile via `GET /v1/subscriptions/{id}` instead of retrying blindly. +- `PATCH /v1/subscriptions/{id}` is exercised through the same exact create/update handler path. + +## Commit + +Committed as: + +```text +feat: add subscription lifecycle journeys +``` diff --git a/cmd/midtrans/main.go b/cmd/midtrans/main.go index aef287e..1ec3b95 100644 --- a/cmd/midtrans/main.go +++ b/cmd/midtrans/main.go @@ -14,10 +14,11 @@ import ( "github.com/veritrans/midtrans-cli/packs/gopaytokenization" "github.com/veritrans/midtrans-cli/packs/paymentlink" "github.com/veritrans/midtrans-cli/packs/snap" + "github.com/veritrans/midtrans-cli/packs/subscription" ) func main() { - registry, err := packs.NewRegistry(common.New(), snap.New(), coreapi.New(), paymentlink.New(), bisnap.New(), gopaytokenization.New()) + registry, err := packs.NewRegistry(common.New(), snap.New(), coreapi.New(), paymentlink.New(), bisnap.New(), gopaytokenization.New(), subscription.New()) if err != nil { fmt.Fprintln(os.Stderr, err) os.Exit(6) diff --git a/contracts/capabilities-v1.json b/contracts/capabilities-v1.json index 737f6cf..4cbf524 100644 --- a/contracts/capabilities-v1.json +++ b/contracts/capabilities-v1.json @@ -97,6 +97,24 @@ "gopay-tokenization.paylater", "gopay-tokenization.unlink" ] + }, + { + "id": "subscription", + "version": "0.1.0", + "capabilities": [ + "subscription.create.verify.v1", + "subscription.verify.v1", + "subscription.disable.verify.v1", + "subscription.enable.verify.v1", + "subscription.cancel.verify.v1" + ], + "journeys": [ + "subscription.create", + "subscription.verify", + "subscription.disable", + "subscription.enable", + "subscription.cancel" + ] } ] } diff --git a/contracts/public-sources-v1.json b/contracts/public-sources-v1.json index a957e06..523ba66 100644 --- a/contracts/public-sources-v1.json +++ b/contracts/public-sources-v1.json @@ -8,8 +8,8 @@ "snap.token.create", "snap.basic-auth" ], - "sha256": "f9a33a2c1add8e51a2efcbbcb66b89822a0e214dc4bd49148f25f8f2e4d750da", - "retrieved_at": "2026-07-27T05:55:55.351598Z" + "sha256": "aed4cbc37d29b4f8f5f6d9c543a9a01c54f8defaf7a2a9226a3c6b26f6d363d8", + "retrieved_at": "2026-07-27T06:26:47.408497Z" }, { "id": "snap-js", @@ -18,8 +18,8 @@ "snap.checkout.popup", "snap.checkout.embed" ], - "sha256": "ecdb903009964df0a75f4a21c3c13c844ba47a322acb191cac1eb45460aa43fe", - "retrieved_at": "2026-07-27T05:55:55.351598Z" + "sha256": "a801f05cc371c116e8c17d0569eadbe4a1f0bac2cc2df016b5f9b555dbe6de5e", + "retrieved_at": "2026-07-27T06:26:47.408497Z" }, { "id": "snap-integration", @@ -28,8 +28,8 @@ "snap.checkout.redirect", "snap.mobile.webview" ], - "sha256": "8252e622a81c388c0a082059d18ce6f94f878e7549a75d0767bff7088db4dffd", - "retrieved_at": "2026-07-27T05:55:55.351598Z" + "sha256": "f869c0b53fb15ebbc9698dbb82d27f81e1c16f9f9717e2d8bbe9dd8103ef3639", + "retrieved_at": "2026-07-27T06:26:47.408497Z" }, { "id": "technical-faq", @@ -38,8 +38,8 @@ "snap.mobile.deeplink-return", "snap.mobile.real-device-proof" ], - "sha256": "bea498e7b507f5380d535a199c1d9421da2dc15e08ad01222454400b7abeb4d2", - "retrieved_at": "2026-07-27T05:55:55.351598Z" + "sha256": "f865bf332992b374daad931cf071aa138a54d21a459037b82a3241847ce00857", + "retrieved_at": "2026-07-27T06:26:47.408497Z" }, { "id": "http-notifications", @@ -48,8 +48,8 @@ "snap.notification.signature", "common.webhook-idempotency" ], - "sha256": "a12432b47c37a3ecfb73254faad6433033f4c870b80ee894fb520e43db974402", - "retrieved_at": "2026-07-27T05:55:55.351598Z" + "sha256": "f8565e0da1ffd4d38bfefb5a89b08a9cd731d00bd804efcb0b59b8f84e8a7889", + "retrieved_at": "2026-07-27T06:26:47.408497Z" }, { "id": "get-transaction-status", @@ -58,8 +58,8 @@ "snap.status.reconcile", "snap.mobile.status.reconcile" ], - "sha256": "ba1a9f56ccfc7e2b87b1250b74b119c10b55fe19e1c4cd6432ca75f8b414d860", - "retrieved_at": "2026-07-27T05:55:55.351598Z" + "sha256": "5690ce474550c96a76593dfcc2487e75259418d0b2f62b1edfdae7a36ebde8c1", + "retrieved_at": "2026-07-27T06:26:47.408497Z" }, { "id": "coreapi-card-charge", @@ -68,8 +68,8 @@ "coreapi.card.charge", "coreapi.basic-auth" ], - "sha256": "dfc2d535d30fe927e80783c6977b1f088a4bcfb7dfdcf396030d181a2719c912", - "retrieved_at": "2026-07-27T05:55:55.351598Z" + "sha256": "e7d190b2163f58f3c807f21a3512c1628ebe74847381c87b4fad912a70469471", + "retrieved_at": "2026-07-27T06:26:47.408497Z" }, { "id": "coreapi-card-3ds", @@ -78,8 +78,8 @@ "coreapi.card.3ds", "coreapi.card.redirect" ], - "sha256": "25dfee11803ad09252e27a3184812cb869f488f009d72afb27478f3484c8ae69", - "retrieved_at": "2026-07-27T05:55:55.351598Z" + "sha256": "d4c8ed1683ce00715079515eada4d475f821b4b6a809e23afba1ccd37dd3e8bb", + "retrieved_at": "2026-07-27T06:26:47.408497Z" }, { "id": "coreapi-one-click", @@ -87,8 +87,8 @@ "rules": [ "coreapi.saved-card.token-only" ], - "sha256": "af46f795daa26129b90f4f6d3aa3a924bd20830e8596b9a280b844c15e72afa3", - "retrieved_at": "2026-07-27T05:55:55.351598Z" + "sha256": "291ccb1ad64fef8b49efca7a94e7012bd105cb85a09ee354b9d58d22e77f89d6", + "retrieved_at": "2026-07-27T06:26:47.408497Z" }, { "id": "coreapi-alfamart", @@ -97,8 +97,8 @@ "coreapi.otc.charge", "coreapi.otc.payment-code" ], - "sha256": "2a3d47bca0e2d2b162bea0ba403b11f3023e0af4eb61ee41116cabf8369555da", - "retrieved_at": "2026-07-27T05:55:55.351598Z" + "sha256": "233b55825dc6af757b0f335edb38e462ef17f093e40d5a535271e8dbe4f6ddf1", + "retrieved_at": "2026-07-27T06:26:47.408497Z" }, { "id": "coreapi-bni-va", @@ -107,8 +107,8 @@ "coreapi.va.charge", "coreapi.va.instructions" ], - "sha256": "8ed91ba7ff4f8d22f539d8105077d202453f09cb9616f96d1c5c897521ed8563", - "retrieved_at": "2026-07-27T05:55:55.351598Z" + "sha256": "f092e0b178ced9fb5e0a9eea77a3ca66654be1a8e48cbbf3246b083d4e7ab376", + "retrieved_at": "2026-07-27T06:26:47.408497Z" }, { "id": "coreapi-status", @@ -117,8 +117,8 @@ "coreapi.status.reconcile", "coreapi.refund.status" ], - "sha256": "ba1a9f56ccfc7e2b87b1250b74b119c10b55fe19e1c4cd6432ca75f8b414d860", - "retrieved_at": "2026-07-27T05:55:55.351598Z" + "sha256": "5690ce474550c96a76593dfcc2487e75259418d0b2f62b1edfdae7a36ebde8c1", + "retrieved_at": "2026-07-27T06:26:47.408497Z" }, { "id": "coreapi-refund", @@ -127,8 +127,8 @@ "coreapi.refund.async", "coreapi.refund.idempotency" ], - "sha256": "e7f8e485e78baa74acf01693f06c259949235ac136deff6dd7d8581795914b86", - "retrieved_at": "2026-07-27T05:55:55.351598Z" + "sha256": "a631255fcc1f3bb071c32cde6a0ab927634fb97affa263829601c2276c4d8c14", + "retrieved_at": "2026-07-27T06:26:47.408497Z" }, { "id": "coreapi-direct-refund", @@ -136,8 +136,8 @@ "rules": [ "coreapi.refund.direct" ], - "sha256": "a5d979e0c0135b7051e5ddb57003187cae5f783f2ecd00dfd13d21ba54ba5571", - "retrieved_at": "2026-07-27T05:55:55.351598Z" + "sha256": "5d8efa9e94b4bf5e810bb3ba0757a2521824f665ba0939c865732034a35b9c8a", + "retrieved_at": "2026-07-27T06:26:47.408497Z" }, { "id": "coreapi-notifications", @@ -146,8 +146,8 @@ "coreapi.notification.signature", "common.webhook-idempotency" ], - "sha256": "a12432b47c37a3ecfb73254faad6433033f4c870b80ee894fb520e43db974402", - "retrieved_at": "2026-07-27T05:55:55.351598Z" + "sha256": "f8565e0da1ffd4d38bfefb5a89b08a9cd731d00bd804efcb0b59b8f84e8a7889", + "retrieved_at": "2026-07-27T06:26:47.408497Z" }, { "id": "payment-link-overview", @@ -156,8 +156,8 @@ "paymentlink.create", "paymentlink.reusable" ], - "sha256": "6b0c5544ea6bfae53ee07c8d9dff7740e765a4bd5e0e7872e476022dab7d2c06", - "retrieved_at": "2026-07-27T05:55:55.351598Z" + "sha256": "1809d01e850c5caf7eed101a26e1d5530e01e52372c1909406223601744a2976", + "retrieved_at": "2026-07-27T06:26:47.408497Z" }, { "id": "payment-link-status", @@ -165,8 +165,8 @@ "rules": [ "paymentlink.status.reconcile" ], - "sha256": "ba1a9f56ccfc7e2b87b1250b74b119c10b55fe19e1c4cd6432ca75f8b414d860", - "retrieved_at": "2026-07-27T05:55:55.351598Z" + "sha256": "5690ce474550c96a76593dfcc2487e75259418d0b2f62b1edfdae7a36ebde8c1", + "retrieved_at": "2026-07-27T06:26:47.408497Z" }, { "id": "payment-link-notifications", @@ -175,8 +175,8 @@ "paymentlink.notification.signature", "common.webhook-idempotency" ], - "sha256": "a12432b47c37a3ecfb73254faad6433033f4c870b80ee894fb520e43db974402", - "retrieved_at": "2026-07-27T05:55:55.351598Z" + "sha256": "f8565e0da1ffd4d38bfefb5a89b08a9cd731d00bd804efcb0b59b8f84e8a7889", + "retrieved_at": "2026-07-27T06:26:47.408497Z" }, { "id": "bisnap-overview", @@ -184,8 +184,8 @@ "rules": [ "bisnap.signing.verify.v1" ], - "sha256": "ce9d05eaa2c1cc5777f0728ba2f66b6d91d611a68aa095a4f1508f4b4a2d17c8", - "retrieved_at": "2026-07-27T05:55:55.351598Z" + "sha256": "fc348733022acfb2e29d77708dc74d33041b4053ee92e3dc80d1033e2162f903", + "retrieved_at": "2026-07-27T06:26:47.408497Z" }, { "id": "bisnap-qris", @@ -194,8 +194,8 @@ "bisnap.qris.create", "bisnap.qris.status" ], - "sha256": "bce428e20acc6a8f286929b7306c7a486ccc7ba5e7ed6db3c158e8368003ff88", - "retrieved_at": "2026-07-27T05:55:55.351598Z" + "sha256": "ed2e6a71971549412dca481e463f058f8697dbe7a86c9a59757794d6531ac383", + "retrieved_at": "2026-07-27T06:26:47.408497Z" }, { "id": "bisnap-virtual-account", @@ -204,8 +204,8 @@ "bisnap.virtual-account.create", "bisnap.virtual-account.status" ], - "sha256": "ebca23cb92df737d9d46e39ffca1d00aca868071a4af89a727df66dfa76d6c3b", - "retrieved_at": "2026-07-27T05:55:55.351598Z" + "sha256": "a36a0c9060b067d751251972c4f321dbd957c3dc1f239feb2520467ff2492667", + "retrieved_at": "2026-07-27T06:26:47.408497Z" }, { "id": "bisnap-direct-debit", @@ -215,8 +215,8 @@ "bisnap.direct-debit.status", "bisnap.refund" ], - "sha256": "2f3ebabf4b1df8f3cf2a9143557b0b8ca4c4cf2fb984f68523f30b7e6ba9613f", - "retrieved_at": "2026-07-27T05:55:55.351598Z" + "sha256": "921a9ded97c836096d37f8faf1539678eeb51df8544c2615453830760c1a7f97", + "retrieved_at": "2026-07-27T06:26:47.408497Z" }, { "id": "bisnap-notifications", @@ -225,8 +225,8 @@ "bisnap.notification.signature", "common.webhook-idempotency" ], - "sha256": "29ee27095c97e6e8aae14a3777227abcc34df1535571476e3fab16f16614b877", - "retrieved_at": "2026-07-27T05:55:55.351598Z" + "sha256": "c96e1eb49a532a9ab44325a89d4609adc2a1bfd2bd80926b69b3dbb14b6e1b09", + "retrieved_at": "2026-07-27T06:26:47.408497Z" }, { "id": "gopay-tokenization-get-auth-code", @@ -234,8 +234,8 @@ "rules": [ "gopaytokenization.linking.get-auth-code" ], - "sha256": "9d30e1c9003c980550ff53d7e2dd26595c1ee36d6356204d92ede218d42510a3", - "retrieved_at": "2026-07-27T05:55:55.351598Z" + "sha256": "55329f74c98a5977f6dbd00a439d3d6db6b5e92bbeb02b3c3fe1cae3a2e913e9", + "retrieved_at": "2026-07-27T06:26:47.408497Z" }, { "id": "gopay-tokenization-binding-api", @@ -243,8 +243,8 @@ "rules": [ "gopaytokenization.linking.bind" ], - "sha256": "50b4543fea221cc80059fdb2524ad2057e4f59a1bb1800a6432e296eacad75a7", - "retrieved_at": "2026-07-27T05:55:55.351598Z" + "sha256": "0d4e8b6d9fb4baf535ca69ababd7159b17d2ebfd268c0a776cf90028cd67e9dd", + "retrieved_at": "2026-07-27T06:26:47.408497Z" }, { "id": "gopay-tokenization-binding-inquiry-api", @@ -252,8 +252,8 @@ "rules": [ "gopaytokenization.linking.inquiry" ], - "sha256": "530e5b3e0c283bff497955f305c38ab78df6a45f38fed4bdcb16c4a4bbdba389", - "retrieved_at": "2026-07-27T05:55:55.351598Z" + "sha256": "3cc21ba35f29a7db861c47a32443bec76ec4cf6295139a5d9fd72954470e3b14", + "retrieved_at": "2026-07-27T06:26:47.408497Z" }, { "id": "gopay-tokenization-direct-debit", @@ -262,8 +262,8 @@ "gopaytokenization.wallet.charge", "gopaytokenization.paylater.charge" ], - "sha256": "e25d64b0f8a3152e4ebd734685fd7687fae84b03e79bce6d8052fff45d9b8161", - "retrieved_at": "2026-07-27T05:55:55.351598Z" + "sha256": "1fbbd43a427439131a99190533fa4a8efc393b75530b8dfb5dbc4f696836ab89", + "retrieved_at": "2026-07-27T06:26:47.408497Z" }, { "id": "gopay-tokenization-unbind", @@ -271,8 +271,8 @@ "rules": [ "gopaytokenization.unlink" ], - "sha256": "88e9392889442296dd009b82f648b3277b1c73dbd3d314459388ac1544cd3b3a", - "retrieved_at": "2026-07-27T05:55:55.351598Z" + "sha256": "d6098fccb6453a5c844c8d8ea58a2f4d8eeab0391e25a3bec0976f37fba0ef87", + "retrieved_at": "2026-07-27T06:26:47.408497Z" }, { "id": "gopay-tokenization-account-linking-unlinking-notification", @@ -281,8 +281,68 @@ "gopaytokenization.notification.signature", "common.webhook-idempotency" ], - "sha256": "2b54c5642de744e6222aef98d4e147a40bd77297becd1ee8e19d5395514502a1", - "retrieved_at": "2026-07-27T05:55:55.351598Z" + "sha256": "c7040f48e6c9a31543b4b1ff38db6169dde9f384a651fd6558d527eea7d10365", + "retrieved_at": "2026-07-27T06:26:47.408497Z" + }, + { + "id": "subscription-create", + "url": "https://docs.midtrans.com/reference/create-subscription", + "rules": [ + "subscription.create", + "subscription.basic-auth" + ], + "sha256": "ff27aaeb3a0c240f3d6e0de2f394312acb38603a6335309236d7d5a18a7c489d", + "retrieved_at": "2026-07-27T06:26:47.408497Z" + }, + { + "id": "subscription-update", + "url": "https://docs.midtrans.com/reference/update-subscription", + "rules": [ + "subscription.update", + "subscription.safe-schedule" + ], + "sha256": "dd9f1d9bbc1b580a4c6449d7a577e15e0ed358755620d0c2eafb88ea26d3da0d", + "retrieved_at": "2026-07-27T06:26:47.408497Z" + }, + { + "id": "subscription-get", + "url": "https://docs.midtrans.com/reference/get-subscription", + "rules": [ + "subscription.status", + "subscription.status-before-mutation" + ], + "sha256": "13b8f0dbe41afe776083cfff8beb97d164931c0d6a953e9d1b4a1c4cd96eda63", + "retrieved_at": "2026-07-27T06:26:47.408497Z" + }, + { + "id": "subscription-disable", + "url": "https://docs.midtrans.com/reference/disable-subscription", + "rules": [ + "subscription.disable", + "subscription.no-blind-retry" + ], + "sha256": "e55306449ca0129a620365a33b738d4501672b2cd02d9a4175eb39541e5a08c8", + "retrieved_at": "2026-07-27T06:26:47.408497Z" + }, + { + "id": "subscription-enable", + "url": "https://docs.midtrans.com/reference/enable-subscription", + "rules": [ + "subscription.enable", + "subscription.no-blind-retry" + ], + "sha256": "124ada6c11a6e3a1e48de6159edcff2fd9d133d79ef47aef647189c7fceb66c5", + "retrieved_at": "2026-07-27T06:26:47.408497Z" + }, + { + "id": "subscription-cancel", + "url": "https://docs.midtrans.com/reference/cancel-subscription", + "rules": [ + "subscription.cancel", + "subscription.no-blind-retry" + ], + "sha256": "089421d43ee32f7fc3bfe3bf789cd8fdab27b5abdc8b8f34a61c32a520694e21", + "retrieved_at": "2026-07-27T06:26:47.408497Z" } ] } diff --git a/internal/app/app_test.go b/internal/app/app_test.go index a7964a5..f71464d 100644 --- a/internal/app/app_test.go +++ b/internal/app/app_test.go @@ -29,6 +29,7 @@ import ( "github.com/veritrans/midtrans-cli/packs/gopaytokenization" "github.com/veritrans/midtrans-cli/packs/paymentlink" "github.com/veritrans/midtrans-cli/packs/snap" + "github.com/veritrans/midtrans-cli/packs/subscription" ) func TestCapabilitiesJSON(t *testing.T) { @@ -50,21 +51,22 @@ func TestCapabilitiesJSON(t *testing.T) { if result.CLIVersion != "0.1.0-test" { t.Fatalf("cli version = %q", result.CLIVersion) } - if len(result.Capabilities) != 24 || + if len(result.Capabilities) != 29 || result.Capabilities[0].ID != "bisnap.direct-debit.verify.v1" || - result.Capabilities[23].ID != "snap.webhook.verify.v1" { + result.Capabilities[28].ID != "subscription.verify.v1" { t.Fatalf("unexpected capabilities: %#v", result.Capabilities) } - if len(result.Packs) != 6 || + if len(result.Packs) != 7 || result.Packs[0].ID != "bisnap" || result.Packs[1].ID != "common" || result.Packs[2].ID != "core-api" || result.Packs[3].ID != "gopay-tokenization" || result.Packs[4].ID != "payment-link" || - result.Packs[5].ID != "snap" { + result.Packs[5].ID != "snap" || + result.Packs[6].ID != "subscription" { t.Fatalf("unexpected packs: %#v", result.Packs) } - if len(result.Journeys) != 23 || result.Journeys[21] != "snap.checkout" || result.Journeys[22] != "snap.mobile-webview" { + if len(result.Journeys) != 28 || result.Journeys[26] != "subscription.enable" || result.Journeys[27] != "subscription.verify" { t.Fatalf("unexpected journeys: %#v", result.Journeys) } } @@ -86,8 +88,8 @@ func TestAgentCapabilitiesPreservesCapabilityContract(t *testing.T) { ) if exit != 0 || result.SchemaVersion != "1.0" || - len(result.Capabilities) != 24 || - len(result.Journeys) != 23 { + len(result.Capabilities) != 29 || + len(result.Journeys) != 28 { t.Fatalf("exit = %d, result = %#v", exit, result) } } @@ -3165,7 +3167,7 @@ func assertSchemaFieldsMatchType( func testRegistry(t *testing.T) *packs.Registry { t.Helper() - registry, err := packs.NewRegistry(common.New(), snap.New(), coreapi.New(), paymentlink.New(), bisnap.New(), gopaytokenization.New()) + registry, err := packs.NewRegistry(common.New(), snap.New(), coreapi.New(), paymentlink.New(), bisnap.New(), gopaytokenization.New(), subscription.New()) if err != nil { t.Fatal(err) } diff --git a/internal/app/commands_agent.go b/internal/app/commands_agent.go index cae3a01..288bbf1 100644 --- a/internal/app/commands_agent.go +++ b/internal/app/commands_agent.go @@ -77,10 +77,14 @@ type genericJourneyFlags struct { method string evidencePath string customerReference string + subscriptionID string + scheduleUnit string + scheduleStart string paymentTokenReference string mobileNumberReference string amount int64 usageLimit int + scheduleInterval int reusable bool execute bool } @@ -98,6 +102,10 @@ func (f *genericJourneyFlags) bind(command *cobra.Command, includeExecute bool, command.Flags().StringVar(&f.method, "method", "", "payment method") command.Flags().StringVar(&f.evidencePath, "evidence", "", "checksummed evidence JSON file") command.Flags().StringVar(&f.customerReference, "customer-reference", "", "safe customer reference") + command.Flags().StringVar(&f.subscriptionID, "subscription-id", "", "safe subscription identifier") + command.Flags().IntVar(&f.scheduleInterval, "schedule-interval", 0, "subscription schedule interval") + command.Flags().StringVar(&f.scheduleUnit, "schedule-unit", "", "subscription schedule unit") + command.Flags().StringVar(&f.scheduleStart, "schedule-start", "", "subscription schedule start timestamp") command.Flags().StringVar(&f.paymentTokenReference, "payment-token-reference", "", "safe payment token reference") command.Flags().StringVar(&f.mobileNumberReference, "mobile-number-reference", "", "safe mobile number reference") command.Flags().BoolVar(&f.reusable, "reusable", false, "request a reusable payment resource") @@ -120,9 +128,13 @@ func (f *genericJourneyFlags) toRunRequest(flags *globalFlags, execute bool) jou OperationID: f.operationID, Input: journeypkg.Input{ OrderID: f.orderID, + SubscriptionID: f.subscriptionID, Amount: f.amount, UsageLimit: f.usageLimit, Method: f.method, + ScheduleInterval: f.scheduleInterval, + ScheduleUnit: f.scheduleUnit, + ScheduleStart: f.scheduleStart, CustomerReference: f.customerReference, PaymentTokenReference: f.paymentTokenReference, MobileNumberReference: f.mobileNumberReference, diff --git a/internal/app/commands_checkout.go b/internal/app/commands_checkout.go index 208b2db..8f8851a 100644 --- a/internal/app/commands_checkout.go +++ b/internal/app/commands_checkout.go @@ -41,10 +41,14 @@ type merchantJourneyFlags struct { method string evidencePath string customerReference string + subscriptionID string + scheduleUnit string + scheduleStart string paymentTokenReference string mobileNumberReference string amount int64 usageLimit int + scheduleInterval int reusable bool execute bool } @@ -59,6 +63,10 @@ func (f *merchantJourneyFlags) bind(command *cobra.Command) { command.Flags().StringVar(&f.method, "method", "", "payment method") command.Flags().StringVar(&f.evidencePath, "evidence", "", "checksummed evidence JSON file") command.Flags().StringVar(&f.customerReference, "customer-reference", "", "safe customer reference") + command.Flags().StringVar(&f.subscriptionID, "subscription-id", "", "safe subscription identifier") + command.Flags().IntVar(&f.scheduleInterval, "schedule-interval", 0, "subscription schedule interval") + command.Flags().StringVar(&f.scheduleUnit, "schedule-unit", "", "subscription schedule unit") + command.Flags().StringVar(&f.scheduleStart, "schedule-start", "", "subscription schedule start timestamp") command.Flags().StringVar(&f.paymentTokenReference, "payment-token-reference", "", "safe payment token reference") command.Flags().StringVar(&f.mobileNumberReference, "mobile-number-reference", "", "safe mobile number reference") command.Flags().BoolVar(&f.reusable, "reusable", false, "request a reusable payment resource") @@ -126,8 +134,12 @@ func runMerchantJourney( EvidencePath: request.evidencePath, Input: journeyInput( orderID, + request.subscriptionID, request.amount, request.method, + request.scheduleInterval, + request.scheduleUnit, + request.scheduleStart, request.customerReference, request.paymentTokenReference, request.mobileNumberReference, @@ -225,8 +237,12 @@ func shouldConfirmMerchantJourney(flags *globalFlags, deps Dependencies, execute func journeyInput( orderID string, + subscriptionID string, amount int64, method string, + scheduleInterval int, + scheduleUnit string, + scheduleStart string, customerReference string, paymentTokenReference string, mobileNumberReference string, @@ -235,9 +251,13 @@ func journeyInput( ) journey.Input { return journey.Input{ OrderID: orderID, + SubscriptionID: subscriptionID, Amount: amount, UsageLimit: usageLimit, Method: method, + ScheduleInterval: scheduleInterval, + ScheduleUnit: scheduleUnit, + ScheduleStart: scheduleStart, CustomerReference: customerReference, PaymentTokenReference: paymentTokenReference, MobileNumberReference: mobileNumberReference, diff --git a/internal/journey/types.go b/internal/journey/types.go index dda19b5..30faab6 100644 --- a/internal/journey/types.go +++ b/internal/journey/types.go @@ -32,9 +32,13 @@ type Definition struct { type Input struct { OrderID string `json:"order_id,omitempty"` + SubscriptionID string `json:"subscription_id,omitempty"` Amount int64 `json:"amount,omitempty"` UsageLimit int `json:"usage_limit,omitempty"` Method string `json:"method,omitempty"` + ScheduleInterval int `json:"schedule_interval,omitempty"` + ScheduleUnit string `json:"schedule_unit,omitempty"` + ScheduleStart string `json:"schedule_start,omitempty"` CustomerReference string `json:"customer_reference,omitempty"` PaymentTokenReference string `json:"payment_token_reference,omitempty"` MobileNumberReference string `json:"mobile_number_reference,omitempty"` diff --git a/internal/sourceprovenance/baseline_test.go b/internal/sourceprovenance/baseline_test.go index 0b8b010..f57edd6 100644 --- a/internal/sourceprovenance/baseline_test.go +++ b/internal/sourceprovenance/baseline_test.go @@ -142,7 +142,7 @@ func TestChangedSourceIDsDoesNotExposeDigests(t *testing.T) { } } -func TestAllPublicSourcesIncludesBISNAPPackEntries(t *testing.T) { +func TestAllPublicSourcesIncludesPackEntries(t *testing.T) { sources := AllPublicSources() ids := make(map[string]bool, len(sources)) for _, source := range sources { @@ -159,4 +159,16 @@ func TestAllPublicSourcesIncludesBISNAPPackEntries(t *testing.T) { t.Fatalf("missing source %q in aggregated catalog", id) } } + for _, id := range []string{ + "subscription-create", + "subscription-update", + "subscription-get", + "subscription-disable", + "subscription-enable", + "subscription-cancel", + } { + if !ids[id] { + t.Fatalf("missing source %q in aggregated catalog", id) + } + } } diff --git a/internal/sourceprovenance/catalog.go b/internal/sourceprovenance/catalog.go index 22fb76a..57aecfa 100644 --- a/internal/sourceprovenance/catalog.go +++ b/internal/sourceprovenance/catalog.go @@ -7,6 +7,7 @@ import ( "github.com/veritrans/midtrans-cli/packs/gopaytokenization" "github.com/veritrans/midtrans-cli/packs/paymentlink" "github.com/veritrans/midtrans-cli/packs/snap" + "github.com/veritrans/midtrans-cli/packs/subscription" ) func AllPublicSources() []contracts.PublicSource { @@ -15,5 +16,6 @@ func AllPublicSources() []contracts.PublicSource { sources = append(sources, paymentlink.New().Descriptor().Sources...) sources = append(sources, bisnap.New().Descriptor().Sources...) sources = append(sources, gopaytokenization.New().Descriptor().Sources...) + sources = append(sources, subscription.New().Descriptor().Sources...) return sources } diff --git a/packs/subscription/client.go b/packs/subscription/client.go new file mode 100644 index 0000000..09de410 --- /dev/null +++ b/packs/subscription/client.go @@ -0,0 +1,267 @@ +package subscription + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net" + "net/http" + "net/url" + "strings" + + "github.com/veritrans/midtrans-cli/internal/sandbox" + "github.com/veritrans/midtrans-cli/internal/secrets" +) + +const ( + subscriptionSandboxURL = "https://api.sandbox.midtrans.com/v1/subscriptions" + maxResponseBytes = 1 << 20 +) + +type Client struct { + HTTP sandbox.Doer + ServerKey secrets.Value +} + +type Schedule struct { + Interval int `json:"interval"` + Unit string `json:"interval_unit"` + Start string `json:"start_time,omitempty"` +} + +type UpsertRequest struct { + OperationID string + SubscriptionID string + Name string + Amount int64 + Token string + Schedule Schedule +} + +type SubscriptionResponse struct { + ID string `json:"id"` + Name string `json:"name,omitempty"` + Status string `json:"status"` + Amount string `json:"amount,omitempty"` + Token string `json:"token,omitempty"` + Schedule Schedule `json:"schedule"` + NotFound bool `json:"not_found,omitempty"` +} + +type MutationRequest struct { + OperationID string + SubscriptionID string +} + +func (c Client) Create(ctx context.Context, input UpsertRequest) (SubscriptionResponse, error) { + return c.upsert(ctx, http.MethodPost, subscriptionSandboxURL, "subscription.create", input) +} + +func (c Client) Update(ctx context.Context, input UpsertRequest) (SubscriptionResponse, error) { + if strings.TrimSpace(input.SubscriptionID) == "" { + return SubscriptionResponse{}, errors.New("SANDBOX_REQUEST_INVALID") + } + return c.upsert( + ctx, + http.MethodPatch, + subscriptionSandboxURL+"/"+url.PathEscape(strings.TrimSpace(input.SubscriptionID)), + "subscription.update", + input, + ) +} + +func (c Client) Get(ctx context.Context, subscriptionID string) (SubscriptionResponse, error) { + if c.HTTP == nil || strings.TrimSpace(subscriptionID) == "" { + return SubscriptionResponse{}, errors.New("SANDBOX_REQUEST_INVALID") + } + serverKey, err := c.ServerKey.SandboxServerKey() + if err != nil { + return SubscriptionResponse{}, err + } + request, err := http.NewRequestWithContext( + ctx, + http.MethodGet, + subscriptionSandboxURL+"/"+url.PathEscape(strings.TrimSpace(subscriptionID)), + nil, + ) + if err != nil { + return SubscriptionResponse{}, errors.New("SANDBOX_REQUEST_INVALID") + } + request.SetBasicAuth(serverKey, "") + response, err := c.HTTP.Do(request) + if err != nil { + return SubscriptionResponse{}, errors.New("sandbox request transport failed") + } + return decodeSubscriptionResponse(response, subscriptionID, "subscription.get") +} + +func (c Client) Disable(ctx context.Context, input MutationRequest) (SubscriptionResponse, error) { + return c.mutate(ctx, "disable", "subscription.disable", input) +} + +func (c Client) Enable(ctx context.Context, input MutationRequest) (SubscriptionResponse, error) { + return c.mutate(ctx, "enable", "subscription.enable", input) +} + +func (c Client) Cancel(ctx context.Context, input MutationRequest) (SubscriptionResponse, error) { + return c.mutate(ctx, "cancel", "subscription.cancel", input) +} + +func (c Client) upsert(ctx context.Context, method, requestURL, operation string, input UpsertRequest) (SubscriptionResponse, error) { + if c.HTTP == nil || strings.TrimSpace(input.OperationID) == "" || strings.TrimSpace(input.Name) == "" || + input.Amount <= 0 || strings.TrimSpace(input.Token) == "" || input.Schedule.Interval <= 0 || + strings.TrimSpace(input.Schedule.Unit) == "" || strings.TrimSpace(input.Schedule.Start) == "" { + return SubscriptionResponse{}, errors.New("SANDBOX_REQUEST_INVALID") + } + serverKey, err := c.ServerKey.SandboxServerKey() + if err != nil { + return SubscriptionResponse{}, err + } + payload, err := json.Marshal(map[string]any{ + "name": input.Name, + "amount": input.Amount, + "currency": "IDR", + "payment_type": "credit_card", + "token": input.Token, + "schedule": map[string]any{ + "interval": input.Schedule.Interval, + "interval_unit": input.Schedule.Unit, + "start_time": input.Schedule.Start, + }, + }) + if err != nil { + return SubscriptionResponse{}, errors.New("SANDBOX_REQUEST_INVALID") + } + request, err := http.NewRequestWithContext(ctx, method, requestURL, bytes.NewReader(payload)) + if err != nil { + return SubscriptionResponse{}, errors.New("SANDBOX_REQUEST_INVALID") + } + request.Header.Set("Content-Type", "application/json") + request.SetBasicAuth(serverKey, "") + response, err := c.HTTP.Do(request) + if err != nil { + if isTimeoutError(err) { + return SubscriptionResponse{}, sandbox.AmbiguousOperationError{ + OperationID: input.OperationID, + Cause: errors.New("sandbox request transport failed"), + } + } + return SubscriptionResponse{}, errors.New("sandbox request transport failed") + } + return decodeSubscriptionResponse(response, input.SubscriptionID, operation) +} + +func (c Client) mutate(ctx context.Context, action, operation string, input MutationRequest) (SubscriptionResponse, error) { + if c.HTTP == nil || strings.TrimSpace(input.OperationID) == "" || strings.TrimSpace(input.SubscriptionID) == "" { + return SubscriptionResponse{}, errors.New("SANDBOX_REQUEST_INVALID") + } + serverKey, err := c.ServerKey.SandboxServerKey() + if err != nil { + return SubscriptionResponse{}, err + } + request, err := http.NewRequestWithContext( + ctx, + http.MethodPost, + subscriptionSandboxURL+"/"+url.PathEscape(strings.TrimSpace(input.SubscriptionID))+"/"+action, + http.NoBody, + ) + if err != nil { + return SubscriptionResponse{}, errors.New("SANDBOX_REQUEST_INVALID") + } + request.SetBasicAuth(serverKey, "") + response, err := c.HTTP.Do(request) + if err != nil { + if isTimeoutError(err) { + return SubscriptionResponse{}, sandbox.AmbiguousOperationError{ + OperationID: input.OperationID, + Cause: errors.New("sandbox request transport failed"), + } + } + return SubscriptionResponse{}, errors.New("sandbox request transport failed") + } + return decodeSubscriptionResponse(response, input.SubscriptionID, operation) +} + +func decodeSubscriptionResponse(response *http.Response, subscriptionID, operation string) (SubscriptionResponse, error) { + if response == nil || response.Body == nil { + return SubscriptionResponse{}, errors.New("SANDBOX_RESPONSE_INVALID") + } + defer response.Body.Close() + if response.StatusCode >= http.StatusMultipleChoices && response.StatusCode < http.StatusBadRequest { + return SubscriptionResponse{}, errors.New("SANDBOX_RESPONSE_REDIRECTED") + } + if response.StatusCode == http.StatusNotFound { + return SubscriptionResponse{ID: subscriptionID, NotFound: true}, nil + } + if response.StatusCode < http.StatusOK || response.StatusCode >= http.StatusMultipleChoices { + return SubscriptionResponse{}, sandbox.ResponseError{ + Operation: operation, + StatusCode: response.StatusCode, + } + } + var result struct { + ID string `json:"id"` + Name string `json:"name"` + Status string `json:"status"` + Amount any `json:"amount"` + Token string `json:"token"` + Schedule struct { + Interval int `json:"interval"` + Unit string `json:"interval_unit"` + Start string `json:"start_time"` + } `json:"schedule"` + } + if err := decodeBounded(response.Body, &result); err != nil { + return SubscriptionResponse{}, err + } + if strings.TrimSpace(result.ID) == "" || strings.TrimSpace(result.Status) == "" || result.Schedule.Interval <= 0 || strings.TrimSpace(result.Schedule.Unit) == "" { + return SubscriptionResponse{}, errors.New("SANDBOX_RESPONSE_INVALID") + } + return SubscriptionResponse{ + ID: strings.TrimSpace(result.ID), + Name: strings.TrimSpace(result.Name), + Status: strings.TrimSpace(result.Status), + Amount: normalizeAmount(result.Amount), + Token: strings.TrimSpace(result.Token), + Schedule: Schedule{ + Interval: result.Schedule.Interval, + Unit: strings.TrimSpace(result.Schedule.Unit), + Start: strings.TrimSpace(result.Schedule.Start), + }, + }, nil +} + +func normalizeAmount(value any) string { + switch typed := value.(type) { + case string: + return strings.TrimSpace(typed) + case float64: + return fmt.Sprintf("%.0f", typed) + default: + return "" + } +} + +func decodeBounded(reader io.Reader, target any) error { + decoder := json.NewDecoder(io.LimitReader(reader, maxResponseBytes+1)) + decoder.DisallowUnknownFields() + if err := decoder.Decode(target); err != nil { + return errors.New("SANDBOX_RESPONSE_INVALID") + } + if err := decoder.Decode(&struct{}{}); err != io.EOF { + return errors.New("SANDBOX_RESPONSE_INVALID") + } + return nil +} + +func isTimeoutError(err error) bool { + var timeout interface{ Timeout() bool } + if errors.As(err, &timeout) { + return timeout.Timeout() + } + var netErr net.Error + return errors.As(err, &netErr) && netErr.Timeout() +} diff --git a/packs/subscription/client_test.go b/packs/subscription/client_test.go new file mode 100644 index 0000000..bc781fe --- /dev/null +++ b/packs/subscription/client_test.go @@ -0,0 +1,186 @@ +package subscription_test + +import ( + "bytes" + "context" + "encoding/base64" + "encoding/json" + "errors" + "io" + "net/http" + "testing" + + "github.com/veritrans/midtrans-cli/internal/sandbox" + "github.com/veritrans/midtrans-cli/internal/secrets" + "github.com/veritrans/midtrans-cli/packs/subscription" +) + +const subscriptionServerKeyCanary = "SB-Mid-server-SUBSCRIPTION-CANARY-DO-NOT-PRINT" + +type subscriptionRecordingDoer struct { + request *http.Request + body []byte + do func(*http.Request) (*http.Response, error) +} + +func (d *subscriptionRecordingDoer) Do(request *http.Request) (*http.Response, error) { + d.request = request + if request.Body != nil { + body, err := io.ReadAll(request.Body) + if err != nil { + return nil, err + } + d.body = body + request.Body = io.NopCloser(bytes.NewReader(body)) + } + return d.do(request) +} + +func TestClientCreateUsesFixedSandboxHostBasicAuthAndSchedulePayload(t *testing.T) { + doer := &subscriptionRecordingDoer{ + do: func(*http.Request) (*http.Response, error) { + return subscriptionResponse(http.StatusCreated, `{ + "id":"sub-123", + "name":"merchant-order-001", + "status":"active", + "amount":"15000", + "schedule":{"interval":1,"interval_unit":"month","start_time":"2026-08-01 00:00:00 +0700"} + }`), nil + }, + } + client := subscription.Client{ + HTTP: doer, + ServerKey: secrets.NewValue(subscriptionServerKeyCanary), + } + + got, err := client.Create(context.Background(), subscription.UpsertRequest{ + OperationID: "op_subscription_create", + Name: "merchant-order-001", + Amount: 15000, + Token: "saved-token-123", + Schedule: subscription.Schedule{ + Interval: 1, + Unit: "month", + Start: "2026-08-01 00:00:00 +0700", + }, + }) + if err != nil { + t.Fatal(err) + } + if doer.request.URL.String() != "https://api.sandbox.midtrans.com/v1/subscriptions" { + t.Fatalf("request URL = %q", doer.request.URL.String()) + } + if doer.request.Method != http.MethodPost { + t.Fatalf("method = %q", doer.request.Method) + } + wantAuthorization := "Basic " + base64.StdEncoding.EncodeToString([]byte(subscriptionServerKeyCanary+":")) + if got := doer.request.Header.Get("Authorization"); got != wantAuthorization { + t.Fatalf("Authorization = %q", got) + } + var payload struct { + Name string `json:"name"` + Amount int64 `json:"amount"` + Currency string `json:"currency"` + PaymentType string `json:"payment_type"` + Token string `json:"token"` + Schedule struct { + Interval int `json:"interval"` + Unit string `json:"interval_unit"` + Start string `json:"start_time"` + } `json:"schedule"` + } + if err := json.Unmarshal(doer.body, &payload); err != nil { + t.Fatal(err) + } + if payload.Name != "merchant-order-001" || payload.Amount != 15000 || payload.Currency != "IDR" || + payload.PaymentType != "credit_card" || payload.Token != "saved-token-123" || + payload.Schedule.Interval != 1 || payload.Schedule.Unit != "month" || payload.Schedule.Start != "2026-08-01 00:00:00 +0700" { + t.Fatalf("payload = %#v", payload) + } + if got.ID != "sub-123" || got.Status != "active" { + t.Fatalf("response = %#v", got) + } +} + +func TestClientUpdateAndMutationsUseExactClassicEndpoints(t *testing.T) { + calls := 0 + doer := &subscriptionRecordingDoer{ + do: func(request *http.Request) (*http.Response, error) { + calls++ + switch calls { + case 1: + if request.Method != http.MethodPatch || request.URL.String() != "https://api.sandbox.midtrans.com/v1/subscriptions/sub-123" { + t.Fatalf("update request = %s %s", request.Method, request.URL.String()) + } + case 2: + if request.Method != http.MethodGet || request.URL.String() != "https://api.sandbox.midtrans.com/v1/subscriptions/sub-123" { + t.Fatalf("get request = %s %s", request.Method, request.URL.String()) + } + case 3: + if request.Method != http.MethodPost || request.URL.String() != "https://api.sandbox.midtrans.com/v1/subscriptions/sub-123/disable" { + t.Fatalf("disable request = %s %s", request.Method, request.URL.String()) + } + } + return subscriptionResponse(http.StatusOK, `{ + "id":"sub-123", + "name":"merchant-order-001", + "status":"inactive", + "amount":"15000", + "schedule":{"interval":1,"interval_unit":"month","start_time":"2026-08-01 00:00:00 +0700"} + }`), nil + }, + } + client := subscription.Client{ + HTTP: doer, + ServerKey: secrets.NewValue(subscriptionServerKeyCanary), + } + if _, err := client.Update(context.Background(), subscription.UpsertRequest{ + OperationID: "op_subscription_update", + SubscriptionID: "sub-123", + Name: "merchant-order-001", + Amount: 15000, + Token: "saved-token-123", + Schedule: subscription.Schedule{ + Interval: 1, + Unit: "month", + Start: "2026-08-01 00:00:00 +0700", + }, + }); err != nil { + t.Fatal(err) + } + if _, err := client.Get(context.Background(), "sub-123"); err != nil { + t.Fatal(err) + } + if _, err := client.Disable(context.Background(), subscription.MutationRequest{ + OperationID: "op_subscription_disable", + SubscriptionID: "sub-123", + }); err != nil { + t.Fatal(err) + } +} + +func TestClientMutationTimeoutIsAmbiguousAndRedacted(t *testing.T) { + doer := &subscriptionRecordingDoer{ + do: func(*http.Request) (*http.Response, error) { + return nil, timeoutError{message: "timeout-" + subscriptionServerKeyCanary} + }, + } + client := subscription.Client{ + HTTP: doer, + ServerKey: secrets.NewValue(subscriptionServerKeyCanary), + } + _, err := client.Disable(context.Background(), subscription.MutationRequest{ + OperationID: "op_subscription_disable", + SubscriptionID: "sub-123", + }) + var ambiguous sandbox.AmbiguousOperationError + if !errors.As(err, &ambiguous) { + t.Fatalf("error = %T %v", err, err) + } + if ambiguous.OperationID != "op_subscription_disable" { + t.Fatalf("operation = %q", ambiguous.OperationID) + } + if got := ambiguous.Error(); got == "" || bytes.Contains([]byte(got), []byte(subscriptionServerKeyCanary)) { + t.Fatalf("ambiguous error leaked secret: %q", got) + } +} diff --git a/packs/subscription/journey.go b/packs/subscription/journey.go new file mode 100644 index 0000000..eac00f1 --- /dev/null +++ b/packs/subscription/journey.go @@ -0,0 +1,401 @@ +package subscription + +import ( + "context" + "errors" + "strconv" + "strings" + "time" + + "github.com/veritrans/midtrans-cli/internal/contracts" + journey "github.com/veritrans/midtrans-cli/internal/journey" + "github.com/veritrans/midtrans-cli/internal/operations" + "github.com/veritrans/midtrans-cli/internal/sandbox" + "github.com/veritrans/midtrans-cli/internal/secrets" +) + +type Creator interface { + Create(context.Context, UpsertRequest) (SubscriptionResponse, error) +} + +type Updater interface { + Update(context.Context, UpsertRequest) (SubscriptionResponse, error) +} + +type Getter interface { + Get(context.Context, string) (SubscriptionResponse, error) +} + +type Disabler interface { + Disable(context.Context, MutationRequest) (SubscriptionResponse, error) +} + +type Enabler interface { + Enable(context.Context, MutationRequest) (SubscriptionResponse, error) +} + +type Canceler interface { + Cancel(context.Context, MutationRequest) (SubscriptionResponse, error) +} + +type JourneyRunner struct { + Create Creator + Update Updater + Get Getter + Disable Disabler + Enable Enabler + Cancel Canceler + Now func() time.Time +} + +type Handler struct { + definition journey.Definition + runner JourneyRunner + runnerOverride bool +} + +func NewCreateHandler() Handler { return newHandler("subscription.create", "subscription") } +func NewVerifyHandler() Handler { return newHandler("subscription.verify", "subscription-verify") } +func NewDisableHandler() Handler { return newHandler("subscription.disable", "subscription-disable") } +func NewEnableHandler() Handler { return newHandler("subscription.enable", "subscription-enable") } +func NewCancelHandler() Handler { return newHandler("subscription.cancel", "subscription-cancel") } + +func newHandler(id, intent string) Handler { + required := []string{"subscription_id"} + if id == "subscription.create" { + required = []string{"order_id", "amount", "payment_token_reference", "schedule_interval", "schedule_unit", "schedule_start"} + } + return Handler{ + definition: journey.Definition{ + ID: id, + Product: "subscription", + Intent: intent, + RequiredInputs: required, + }, + } +} + +func (h Handler) WithRunner(runner JourneyRunner) Handler { + h.runner = runner + h.runnerOverride = true + if h.runner.Now == nil { + h.runner.Now = func() time.Time { return time.Now().UTC() } + } + return h +} + +func (h Handler) Definition() journey.Definition { return h.definition } + +func (h Handler) Plan(_ context.Context, request journey.Request, _ journey.Runtime) journey.Outcome { + request = rehydrateRequest(request, nil) + return journey.Outcome{State: journey.Planned, SafeData: safeDataForRequest(request)} +} + +func (h Handler) Execute(ctx context.Context, request journey.Request, runtime journey.Runtime) journey.Outcome { + return h.run(ctx, request, runtime, nil) +} + +func (h Handler) Resume(ctx context.Context, request journey.Request, runtime journey.Runtime, record operations.Record) journey.Outcome { + return h.run(ctx, request, runtime, &record) +} + +func (h Handler) run(ctx context.Context, request journey.Request, runtime journey.Runtime, record *operations.Record) journey.Outcome { + request = rehydrateRequest(request, record) + if request.OperationID == "" || request.ManifestHash == "" { + return inputRequired("operation_id and manifest state are required") + } + switch h.definition.ID { + case "subscription.create": + if strings.TrimSpace(request.Input.OrderID) == "" || request.Input.Amount <= 0 || + request.Input.ScheduleInterval <= 0 || strings.TrimSpace(request.Input.ScheduleUnit) == "" || + strings.TrimSpace(request.Input.ScheduleStart) == "" || strings.TrimSpace(request.Input.PaymentTokenReference) == "" { + return inputRequired("order_id, amount, payment_token_reference, schedule_interval, schedule_unit, and schedule_start are required") + } + case "subscription.verify", "subscription.disable", "subscription.enable", "subscription.cancel": + if strings.TrimSpace(request.Input.SubscriptionID) == "" { + return inputRequired("subscription_id is required") + } + } + runner, token, outcome := h.runtimeRunner(ctx, request, runtime) + if outcome != nil { + return *outcome + } + switch h.definition.ID { + case "subscription.create": + return h.runUpsert(ctx, request, runner, token) + case "subscription.verify": + return h.runVerify(ctx, request, runner) + case "subscription.disable": + return h.runMutation(ctx, request, runner, "inactive") + case "subscription.enable": + return h.runMutation(ctx, request, runner, "active") + case "subscription.cancel": + return h.runMutation(ctx, request, runner, "canceled") + default: + return blockedOutcome("journey definition is unsupported") + } +} + +func (h Handler) runUpsert(ctx context.Context, request journey.Request, runner JourneyRunner, token string) journey.Outcome { + input := UpsertRequest{ + OperationID: request.OperationID, + SubscriptionID: request.Input.SubscriptionID, + Name: request.Input.OrderID, + Amount: request.Input.Amount, + Token: token, + Schedule: Schedule{ + Interval: request.Input.ScheduleInterval, + Unit: request.Input.ScheduleUnit, + Start: request.Input.ScheduleStart, + }, + } + call := runner.Create.Create + if strings.TrimSpace(request.Input.SubscriptionID) != "" { + if runner.Get == nil || runner.Update == nil { + return blockedOutcome("subscription update dependencies are unavailable") + } + status, err := runner.Get.Get(ctx, request.Input.SubscriptionID) + if err != nil { + return blockedOutcome("subscription status is unavailable") + } + if status.NotFound { + return blockedOutcome("subscription id is unavailable in sandbox") + } + call = runner.Update.Update + } + response, err := call(ctx, input) + if err != nil { + var ambiguous sandbox.AmbiguousOperationError + if errors.As(err, &ambiguous) { + if strings.TrimSpace(request.Input.SubscriptionID) == "" || runner.Get == nil { + return journey.Outcome{State: journey.Reconciling, SafeData: safeDataForRequest(request)} + } + reconciled, statusErr := runner.Get.Get(ctx, request.Input.SubscriptionID) + if statusErr != nil || reconciled.NotFound { + return journey.Outcome{State: journey.Reconciling, SafeData: safeDataForRequest(request)} + } + return evaluateStatus(reconciled) + } + return blockedOutcome("subscription mutation failed") + } + return evaluateStatus(response) +} + +func (h Handler) runVerify(ctx context.Context, request journey.Request, runner JourneyRunner) journey.Outcome { + if runner.Get == nil { + return blockedOutcome("subscription status is unavailable") + } + status, err := runner.Get.Get(ctx, request.Input.SubscriptionID) + if err != nil || status.NotFound { + return blockedOutcome("subscription status is unavailable") + } + return evaluateStatus(status) +} + +func (h Handler) runMutation(ctx context.Context, request journey.Request, runner JourneyRunner, target string) journey.Outcome { + if strings.TrimSpace(request.Input.SubscriptionID) == "" { + return inputRequired("subscription_id is required") + } + if runner.Get == nil { + return blockedOutcome("subscription status is unavailable") + } + current, err := runner.Get.Get(ctx, request.Input.SubscriptionID) + if err != nil || current.NotFound { + return blockedOutcome("subscription status is unavailable") + } + if statusMatchesTarget(current.Status, target) { + return evaluateStatus(current) + } + input := MutationRequest{OperationID: request.OperationID, SubscriptionID: request.Input.SubscriptionID} + var response SubscriptionResponse + switch h.definition.ID { + case "subscription.disable": + if runner.Disable == nil { + return blockedOutcome("subscription disable is unavailable") + } + response, err = runner.Disable.Disable(ctx, input) + case "subscription.enable": + if runner.Enable == nil { + return blockedOutcome("subscription enable is unavailable") + } + response, err = runner.Enable.Enable(ctx, input) + case "subscription.cancel": + if runner.Cancel == nil { + return blockedOutcome("subscription cancel is unavailable") + } + response, err = runner.Cancel.Cancel(ctx, input) + } + if err != nil { + var ambiguous sandbox.AmbiguousOperationError + if errors.As(err, &ambiguous) { + reconciled, statusErr := runner.Get.Get(ctx, request.Input.SubscriptionID) + if statusErr != nil || reconciled.NotFound { + return journey.Outcome{State: journey.Reconciling, SafeData: safeDataForRequest(request)} + } + if statusMatchesTarget(reconciled.Status, target) { + return evaluateStatus(reconciled) + } + return blockedOutcome("subscription mutation remained unverified after status reconciliation") + } + return blockedOutcome("subscription mutation failed") + } + return evaluateStatus(response) +} + +func (h Handler) runtimeRunner(ctx context.Context, request journey.Request, runtime journey.Runtime) (JourneyRunner, string, *journey.Outcome) { + if h.runnerOverride { + return h.runner, request.Input.PaymentTokenReference, nil + } + integration, ok := request.Manifest.IntegrationFor("subscription") + if !ok { + outcome := blockedFinding("CAPABILITY_UNAVAILABLE", "subscription integration is not configured for this project") + return JourneyRunner{}, "", &outcome + } + credentials, ok := request.Manifest.CredentialSetFor(integration.Credentials) + if !ok || credentials.ServerKey == "" { + outcome := blockedFinding("CREDENTIAL_MISSING", "the configured subscription server-key reference is not set") + return JourneyRunner{}, "", &outcome + } + if runtime.ResolveCredential == nil || runtime.HTTP == nil { + outcome := blockedFinding("JOURNEY_EXECUTION_BLOCKED", "journey runtime dependencies are unavailable") + return JourneyRunner{}, "", &outcome + } + rawServerKey, err := runtime.ResolveCredential(ctx, request.ProjectDir, credentials.ServerKey) + if err != nil { + outcome := blockedFinding("CREDENTIAL_RESOLUTION_FAILED", "unable to resolve the configured subscription server-key reference") + return JourneyRunner{}, "", &outcome + } + client := Client{ + HTTP: runtime.HTTP, + ServerKey: secrets.NewValue(string(rawServerKey)), + } + rawServerKey = nil + token := "" + if h.definition.ID == "subscription.create" { + rawToken, err := runtime.ResolveCredential(ctx, request.ProjectDir, request.Input.PaymentTokenReference) + if err != nil { + outcome := blockedFinding("CREDENTIAL_RESOLUTION_FAILED", "unable to resolve the configured payment-token reference") + return JourneyRunner{}, "", &outcome + } + token = strings.TrimSpace(string(rawToken)) + rawToken = nil + } + return JourneyRunner{ + Create: client, + Update: client, + Get: client, + Disable: client, + Enable: client, + Cancel: client, + Now: runtimeNow(runtime), + }, token, nil +} + +func evaluateStatus(status SubscriptionResponse) journey.Outcome { + if strings.TrimSpace(status.ID) == "" || strings.TrimSpace(status.Status) == "" { + return blockedOutcome("provider status was invalid") + } + switch strings.ToLower(strings.TrimSpace(status.Status)) { + case "active", "inactive", "canceled": + return journey.Outcome{ + State: journey.Passed, + SafeData: map[string]any{ + "subscription_id": status.ID, + "provider_status": status.Status, + "schedule_interval": strconv.Itoa(status.Schedule.Interval), + "schedule_unit": status.Schedule.Unit, + "schedule_start": status.Schedule.Start, + "amount": status.Amount, + }, + } + case "pending": + return journey.Outcome{ + State: journey.Reconciling, + SafeData: map[string]any{"subscription_id": status.ID}, + } + default: + return blockedOutcome("provider status blocked the subscription") + } +} + +func safeDataForRequest(request journey.Request) map[string]any { + data := map[string]any{} + if request.Input.OrderID != "" { + data["order_id"] = request.Input.OrderID + } + if request.Input.SubscriptionID != "" { + data["subscription_id"] = request.Input.SubscriptionID + } + if request.Input.Amount > 0 { + data["amount"] = strconv.FormatInt(request.Input.Amount, 10) + } + if request.Input.ScheduleInterval > 0 { + data["schedule_interval"] = strconv.Itoa(request.Input.ScheduleInterval) + } + if request.Input.ScheduleUnit != "" { + data["schedule_unit"] = request.Input.ScheduleUnit + } + if request.Input.ScheduleStart != "" { + data["schedule_start"] = request.Input.ScheduleStart + } + return data +} + +func rehydrateRequest(request journey.Request, record *operations.Record) journey.Request { + if record == nil || record.SafeReferences == nil { + return request + } + if request.Input.OrderID == "" { + request.Input.OrderID = record.SafeReferences["order_id"] + } + if request.Input.SubscriptionID == "" { + request.Input.SubscriptionID = record.SafeReferences["subscription_id"] + } + if request.Input.Amount <= 0 { + if amount, err := strconv.ParseInt(record.SafeReferences["amount"], 10, 64); err == nil { + request.Input.Amount = amount + } + } + if request.Input.ScheduleInterval <= 0 { + if interval, err := strconv.Atoi(record.SafeReferences["schedule_interval"]); err == nil { + request.Input.ScheduleInterval = interval + } + } + if request.Input.ScheduleUnit == "" { + request.Input.ScheduleUnit = record.SafeReferences["schedule_unit"] + } + if request.Input.ScheduleStart == "" { + request.Input.ScheduleStart = record.SafeReferences["schedule_start"] + } + return request +} + +func runtimeNow(runtime journey.Runtime) func() time.Time { + if runtime.Now != nil { + return runtime.Now + } + return func() time.Time { return time.Now().UTC() } +} + +func statusMatchesTarget(status, target string) bool { + return strings.EqualFold(strings.TrimSpace(status), target) +} + +func blockedFinding(code, message string) journey.Outcome { + return journey.Outcome{ + State: journey.Blocked, + Finding: &contracts.Finding{ + Code: code, + Severity: "blocking", + Message: message, + }, + } +} + +func inputRequired(message string) journey.Outcome { + return blockedFinding("JOURNEY_INPUT_REQUIRED", message) +} + +func blockedOutcome(message string) journey.Outcome { + return blockedFinding("JOURNEY_EXECUTION_BLOCKED", message) +} diff --git a/packs/subscription/journey_test.go b/packs/subscription/journey_test.go new file mode 100644 index 0000000..d6bba10 --- /dev/null +++ b/packs/subscription/journey_test.go @@ -0,0 +1,251 @@ +package subscription_test + +import ( + "context" + "encoding/json" + "errors" + "io" + "net/http" + "strings" + "testing" + + journeypkg "github.com/veritrans/midtrans-cli/internal/journey" + "github.com/veritrans/midtrans-cli/internal/manifest" + "github.com/veritrans/midtrans-cli/internal/operations" + "github.com/veritrans/midtrans-cli/internal/sandbox" + "github.com/veritrans/midtrans-cli/packs/subscription" +) + +func TestCreateJourneyBlocksExecutionWithoutSavedTokenAndSchedule(t *testing.T) { + handler := subscription.NewCreateHandler() + outcome := handler.Execute(context.Background(), journeypkg.Request{ + OperationID: "op_subscription_create", + ManifestHash: strings.Repeat("a", 64), + Input: journeypkg.Input{ + OrderID: "merchant-order-001", + Amount: 15000, + }, + }, journeypkg.Runtime{}) + if outcome.State != journeypkg.Blocked || outcome.Finding == nil || outcome.Finding.Code != "JOURNEY_INPUT_REQUIRED" { + t.Fatalf("outcome = %#v", outcome) + } +} + +func TestCreateJourneyResolvesServerKeyAndSavedTokenReference(t *testing.T) { + handler := subscription.NewCreateHandler() + var resolved []string + outcome := handler.Execute(context.Background(), journeypkg.Request{ + OperationID: "op_subscription_create", + ProjectDir: "/merchant", + ManifestHash: strings.Repeat("a", 64), + Manifest: validSubscriptionManifest(), + Input: journeypkg.Input{ + OrderID: "merchant-order-001", + Amount: 15000, + ScheduleInterval: 1, + ScheduleUnit: "month", + ScheduleStart: "2026-08-01 00:00:00 +0700", + PaymentTokenReference: "env:MIDTRANS_SAVED_TOKEN", + }, + }, journeypkg.Runtime{ + HTTP: appDoerFunc(func(request *http.Request) (*http.Response, error) { + username, password, ok := request.BasicAuth() + if !ok || username != subscriptionServerKeyCanary || password != "" { + t.Fatal("request did not use resolved basic auth") + } + var payload struct { + Token string `json:"token"` + } + if err := json.NewDecoder(request.Body).Decode(&payload); err != nil { + t.Fatal(err) + } + if payload.Token != "saved-token-123" { + t.Fatalf("token = %q", payload.Token) + } + return subscriptionResponse(http.StatusCreated, `{ + "id":"sub-123", + "name":"merchant-order-001", + "status":"active", + "amount":"15000", + "schedule":{"interval":1,"interval_unit":"month","start_time":"2026-08-01 00:00:00 +0700"} + }`), nil + }), + ResolveCredential: func(_ context.Context, projectDir, reference string) ([]byte, error) { + if projectDir != "/merchant" { + t.Fatalf("projectDir = %q", projectDir) + } + resolved = append(resolved, reference) + switch reference { + case "env:MIDTRANS_SERVER_KEY": + return []byte(subscriptionServerKeyCanary), nil + case "env:MIDTRANS_SAVED_TOKEN": + return []byte("saved-token-123"), nil + default: + return nil, errors.New("unexpected reference") + } + }, + }) + if outcome.State != journeypkg.Passed { + t.Fatalf("outcome = %#v", outcome) + } + if outcome.SafeData["subscription_id"] != "sub-123" || outcome.SafeData["schedule_interval"] != "1" { + t.Fatalf("safe data = %#v", outcome.SafeData) + } + if len(resolved) != 2 || resolved[0] != "env:MIDTRANS_SERVER_KEY" || resolved[1] != "env:MIDTRANS_SAVED_TOKEN" { + t.Fatalf("resolved = %#v", resolved) + } +} + +func TestDisableJourneyChecksStatusBeforeMutation(t *testing.T) { + disableCalls := 0 + handler := subscription.NewDisableHandler().WithRunner(subscription.JourneyRunner{ + Get: stubSubscriptionGet(func(context.Context, string) (subscription.SubscriptionResponse, error) { + return subscription.SubscriptionResponse{ + ID: "sub-123", + Status: "inactive", + Schedule: subscription.Schedule{ + Interval: 1, + Unit: "month", + Start: "2026-08-01 00:00:00 +0700", + }, + }, nil + }), + Disable: stubSubscriptionDisable(func(context.Context, subscription.MutationRequest) (subscription.SubscriptionResponse, error) { + disableCalls++ + return subscription.SubscriptionResponse{}, nil + }), + }) + outcome := handler.Execute(context.Background(), journeypkg.Request{ + OperationID: "op_subscription_disable", + ManifestHash: strings.Repeat("a", 64), + Input: journeypkg.Input{ + SubscriptionID: "sub-123", + }, + }, journeypkg.Runtime{}) + if outcome.State != journeypkg.Passed { + t.Fatalf("outcome = %#v", outcome) + } + if disableCalls != 0 { + t.Fatalf("disableCalls = %d", disableCalls) + } +} + +func TestDisableJourneyReconcilesAmbiguousMutationByStatusBeforeRetry(t *testing.T) { + statusCalls := 0 + handler := subscription.NewDisableHandler().WithRunner(subscription.JourneyRunner{ + Get: stubSubscriptionGet(func(context.Context, string) (subscription.SubscriptionResponse, error) { + statusCalls++ + if statusCalls == 1 { + return subscription.SubscriptionResponse{ + ID: "sub-123", + Status: "active", + Schedule: subscription.Schedule{ + Interval: 1, + Unit: "month", + Start: "2026-08-01 00:00:00 +0700", + }, + }, nil + } + return subscription.SubscriptionResponse{ + ID: "sub-123", + Status: "inactive", + Schedule: subscription.Schedule{ + Interval: 1, + Unit: "month", + Start: "2026-08-01 00:00:00 +0700", + }, + }, nil + }), + Disable: stubSubscriptionDisable(func(context.Context, subscription.MutationRequest) (subscription.SubscriptionResponse, error) { + return subscription.SubscriptionResponse{}, sandbox.AmbiguousOperationError{ + OperationID: "op_subscription_disable", + Cause: errors.New("sandbox request transport failed"), + } + }), + }) + outcome := handler.Execute(context.Background(), journeypkg.Request{ + OperationID: "op_subscription_disable", + ManifestHash: strings.Repeat("a", 64), + Input: journeypkg.Input{ + SubscriptionID: "sub-123", + }, + }, journeypkg.Runtime{}) + if outcome.State != journeypkg.Passed || outcome.SafeData["provider_status"] != "inactive" { + t.Fatalf("outcome = %#v", outcome) + } +} + +func TestVerifyJourneyRehydratesSubscriptionIDFromRecord(t *testing.T) { + handler := subscription.NewVerifyHandler().WithRunner(subscription.JourneyRunner{ + Get: stubSubscriptionGet(func(_ context.Context, id string) (subscription.SubscriptionResponse, error) { + if id != "sub-123" { + t.Fatalf("id = %q", id) + } + return subscription.SubscriptionResponse{ + ID: "sub-123", + Status: "active", + Schedule: subscription.Schedule{ + Interval: 1, + Unit: "month", + Start: "2026-08-01 00:00:00 +0700", + }, + }, nil + }), + }) + outcome := handler.Resume(context.Background(), journeypkg.Request{ + OperationID: "op_subscription_verify", + ManifestHash: strings.Repeat("a", 64), + }, journeypkg.Runtime{}, operations.Record{ + SafeReferences: map[string]string{"subscription_id": "sub-123"}, + }) + if outcome.State != journeypkg.Passed { + t.Fatalf("outcome = %#v", outcome) + } +} + +func validSubscriptionManifest() manifest.Manifest { + value := manifest.Default() + value.CredentialSets["classic"] = manifest.CredentialSet{ + Type: "classic", + Environment: "sandbox", + ServerKey: "env:MIDTRANS_SERVER_KEY", + } + value.Integrations["subscription"] = manifest.Integration{ + ConfigVersion: 1, + Credentials: "classic", + } + value.Routing["subscription"] = "subscription" + return value +} + +type appDoerFunc func(*http.Request) (*http.Response, error) + +func (f appDoerFunc) Do(request *http.Request) (*http.Response, error) { + return f(request) +} + +type stubSubscriptionGet func(context.Context, string) (subscription.SubscriptionResponse, error) + +func (f stubSubscriptionGet) Get(ctx context.Context, id string) (subscription.SubscriptionResponse, error) { + return f(ctx, id) +} + +type stubSubscriptionDisable func(context.Context, subscription.MutationRequest) (subscription.SubscriptionResponse, error) + +func (f stubSubscriptionDisable) Disable(ctx context.Context, input subscription.MutationRequest) (subscription.SubscriptionResponse, error) { + return f(ctx, input) +} + +type timeoutError struct{ message string } + +func (e timeoutError) Error() string { return e.message } +func (e timeoutError) Timeout() bool { return true } +func (e timeoutError) Temporary() bool { return true } + +func subscriptionResponse(status int, body string) *http.Response { + return &http.Response{ + StatusCode: status, + Body: io.NopCloser(strings.NewReader(body)), + Header: make(http.Header), + } +} diff --git a/packs/subscription/pack.go b/packs/subscription/pack.go new file mode 100644 index 0000000..4eea0ce --- /dev/null +++ b/packs/subscription/pack.go @@ -0,0 +1,81 @@ +package subscription + +import ( + "github.com/veritrans/midtrans-cli/internal/contracts" + "github.com/veritrans/midtrans-cli/internal/inspection" + "github.com/veritrans/midtrans-cli/internal/journey" + "github.com/veritrans/midtrans-cli/internal/manifest" + "github.com/veritrans/midtrans-cli/internal/packs" +) + +type Pack struct{} + +func New() Pack { return Pack{} } + +func (Pack) Descriptor() packs.Descriptor { + return packs.Descriptor{ + ID: "subscription", + Version: "0.1.0", + Capabilities: []contracts.Capability{ + {ID: "subscription.create.verify.v1", Description: "create or update a classic Subscription API schedule", Pack: "subscription"}, + {ID: "subscription.verify.v1", Description: "verify a classic Subscription API schedule by subscription ID", Pack: "subscription"}, + {ID: "subscription.disable.verify.v1", Description: "disable a classic Subscription API schedule", Pack: "subscription"}, + {ID: "subscription.enable.verify.v1", Description: "enable a classic Subscription API schedule", Pack: "subscription"}, + {ID: "subscription.cancel.verify.v1", Description: "cancel a classic Subscription API schedule", Pack: "subscription"}, + }, + Journeys: []string{ + "subscription.create", + "subscription.verify", + "subscription.disable", + "subscription.enable", + "subscription.cancel", + }, + SandboxHosts: []string{"api.sandbox.midtrans.com"}, + SensitiveKeys: []string{"signature_key"}, + Sources: []contracts.PublicSource{ + {ID: "subscription-create", URL: "https://docs.midtrans.com/reference/create-subscription", Rules: []string{"subscription.create", "subscription.basic-auth"}}, + {ID: "subscription-update", URL: "https://docs.midtrans.com/reference/update-subscription", Rules: []string{"subscription.update", "subscription.safe-schedule"}}, + {ID: "subscription-get", URL: "https://docs.midtrans.com/reference/get-subscription", Rules: []string{"subscription.status", "subscription.status-before-mutation"}}, + {ID: "subscription-disable", URL: "https://docs.midtrans.com/reference/disable-subscription", Rules: []string{"subscription.disable", "subscription.no-blind-retry"}}, + {ID: "subscription-enable", URL: "https://docs.midtrans.com/reference/enable-subscription", Rules: []string{"subscription.enable", "subscription.no-blind-retry"}}, + {ID: "subscription-cancel", URL: "https://docs.midtrans.com/reference/cancel-subscription", Rules: []string{"subscription.cancel", "subscription.no-blind-retry"}}, + }, + } +} + +func (Pack) Evaluate(value manifest.Manifest, report inspection.Report) []contracts.Finding { + integration, ok := value.IntegrationFor("subscription") + if !ok { + return []contracts.Finding{{ + Code: "SUBSCRIPTION_PRODUCT_NOT_SELECTED", + Severity: "blocking", + Message: "integrations must include subscription", + }} + } + credentials, hasCredentials := value.CredentialSetFor(integration.Credentials) + if !hasCredentials || credentials.ServerKey == "" { + return []contracts.Finding{{ + Code: "SUBSCRIPTION_SERVER_KEY_MISSING", + Severity: "blocking", + Message: "integrations.subscription must reference a classic server-key credential set", + }} + } + if len(report.Facts) > 0 && !report.Has("midtrans.server-key-reference") { + return []contracts.Finding{{ + Code: "SUBSCRIPTION_SERVER_KEY_REFERENCE_NOT_FOUND", + Severity: "warning", + Message: "repository inspection did not find the configured subscription server-key reference", + }} + } + return nil +} + +func (Pack) Handlers() []journey.Handler { + return []journey.Handler{ + NewCreateHandler(), + NewVerifyHandler(), + NewDisableHandler(), + NewEnableHandler(), + NewCancelHandler(), + } +} diff --git a/packs/subscription/pack_test.go b/packs/subscription/pack_test.go new file mode 100644 index 0000000..80ed644 --- /dev/null +++ b/packs/subscription/pack_test.go @@ -0,0 +1,50 @@ +package subscription_test + +import ( + "reflect" + "testing" + + "github.com/veritrans/midtrans-cli/internal/inspection" + "github.com/veritrans/midtrans-cli/internal/manifest" + "github.com/veritrans/midtrans-cli/packs/subscription" +) + +func TestPackDescriptorPublishesSubscriptionJourneysAndCapabilities(t *testing.T) { + descriptor := subscription.New().Descriptor() + wantCapabilities := []string{ + "subscription.create.verify.v1", + "subscription.verify.v1", + "subscription.disable.verify.v1", + "subscription.enable.verify.v1", + "subscription.cancel.verify.v1", + } + gotCapabilities := make([]string, 0, len(descriptor.Capabilities)) + for _, capability := range descriptor.Capabilities { + gotCapabilities = append(gotCapabilities, capability.ID) + } + if !reflect.DeepEqual(gotCapabilities, wantCapabilities) { + t.Fatalf("capabilities = %#v", gotCapabilities) + } + wantJourneys := []string{ + "subscription.create", + "subscription.verify", + "subscription.disable", + "subscription.enable", + "subscription.cancel", + } + if !reflect.DeepEqual(descriptor.Journeys, wantJourneys) { + t.Fatalf("journeys = %#v", descriptor.Journeys) + } + if !reflect.DeepEqual(descriptor.SandboxHosts, []string{"api.sandbox.midtrans.com"}) { + t.Fatalf("sandbox hosts = %#v", descriptor.SandboxHosts) + } +} + +func TestSubscriptionEvaluationRequiresConfiguredServerKey(t *testing.T) { + value := validSubscriptionManifest() + value.CredentialSets["classic"] = manifest.CredentialSet{Type: "classic", Environment: "sandbox"} + findings := subscription.New().Evaluate(value, inspection.Report{}) + if len(findings) != 1 || findings[0].Code != "SUBSCRIPTION_SERVER_KEY_MISSING" { + t.Fatalf("findings = %#v", findings) + } +} diff --git a/testdata/subscription/README.md b/testdata/subscription/README.md new file mode 100644 index 0000000..9b261bd --- /dev/null +++ b/testdata/subscription/README.md @@ -0,0 +1 @@ +Fixtures for classic Subscription API lifecycle coverage. From ff9b8443a99bd1feee64c2a3524fca711b0427f9 Mon Sep 17 00:00:00 2001 From: "m.salis" Date: Mon, 27 Jul 2026 13:39:52 +0700 Subject: [PATCH 54/73] feat: add Core API recurring verification --- contracts/capabilities-v1.json | 2 + contracts/public-sources-v1.json | 5 +- internal/app/app_test.go | 10 +- internal/packs/registry_test.go | 4 +- packs/coreapi/client.go | 21 ++-- packs/coreapi/journey.go | 182 ++++++++++++++++++++++++++++++- packs/coreapi/journey_test.go | 146 +++++++++++++++++++++++++ packs/coreapi/pack.go | 9 +- packs/coreapi/pack_test.go | 2 + 9 files changed, 358 insertions(+), 23 deletions(-) diff --git a/contracts/capabilities-v1.json b/contracts/capabilities-v1.json index 4cbf524..f8e4c6f 100644 --- a/contracts/capabilities-v1.json +++ b/contracts/capabilities-v1.json @@ -38,6 +38,7 @@ "core-api.saved-card.verify.v1", "core-api.installment.verify.v1", "core-api.otc.verify.v1", + "core-api.recurring.verify.v1", "core-api.virtual-account.verify.v1", "core-api.refund.verify.v1" ], @@ -46,6 +47,7 @@ "core-api.saved-card", "core-api.installment", "core-api.otc", + "core-api.recurring", "core-api.virtual-account", "core-api.refund" ] diff --git a/contracts/public-sources-v1.json b/contracts/public-sources-v1.json index 523ba66..62671b9 100644 --- a/contracts/public-sources-v1.json +++ b/contracts/public-sources-v1.json @@ -85,7 +85,8 @@ "id": "coreapi-one-click", "url": "https://docs.midtrans.com/reference/card-feature-one-click", "rules": [ - "coreapi.saved-card.token-only" + "coreapi.saved-card.token-only", + "coreapi.recurring.saved-card-token" ], "sha256": "291ccb1ad64fef8b49efca7a94e7012bd105cb85a09ee354b9d58d22e77f89d6", "retrieved_at": "2026-07-27T06:26:47.408497Z" @@ -115,6 +116,7 @@ "url": "https://docs.midtrans.com/reference/get-transaction-status", "rules": [ "coreapi.status.reconcile", + "coreapi.recurring.status", "coreapi.refund.status" ], "sha256": "5690ce474550c96a76593dfcc2487e75259418d0b2f62b1edfdae7a36ebde8c1", @@ -144,6 +146,7 @@ "url": "https://docs.midtrans.com/docs/https-notification-webhooks", "rules": [ "coreapi.notification.signature", + "coreapi.recurring.notification", "common.webhook-idempotency" ], "sha256": "f8565e0da1ffd4d38bfefb5a89b08a9cd731d00bd804efcb0b59b8f84e8a7889", diff --git a/internal/app/app_test.go b/internal/app/app_test.go index f71464d..98b82c3 100644 --- a/internal/app/app_test.go +++ b/internal/app/app_test.go @@ -51,9 +51,9 @@ func TestCapabilitiesJSON(t *testing.T) { if result.CLIVersion != "0.1.0-test" { t.Fatalf("cli version = %q", result.CLIVersion) } - if len(result.Capabilities) != 29 || + if len(result.Capabilities) != 30 || result.Capabilities[0].ID != "bisnap.direct-debit.verify.v1" || - result.Capabilities[28].ID != "subscription.verify.v1" { + result.Capabilities[29].ID != "subscription.verify.v1" { t.Fatalf("unexpected capabilities: %#v", result.Capabilities) } if len(result.Packs) != 7 || @@ -66,7 +66,7 @@ func TestCapabilitiesJSON(t *testing.T) { result.Packs[6].ID != "subscription" { t.Fatalf("unexpected packs: %#v", result.Packs) } - if len(result.Journeys) != 28 || result.Journeys[26] != "subscription.enable" || result.Journeys[27] != "subscription.verify" { + if len(result.Journeys) != 29 || result.Journeys[27] != "subscription.enable" || result.Journeys[28] != "subscription.verify" { t.Fatalf("unexpected journeys: %#v", result.Journeys) } } @@ -88,8 +88,8 @@ func TestAgentCapabilitiesPreservesCapabilityContract(t *testing.T) { ) if exit != 0 || result.SchemaVersion != "1.0" || - len(result.Capabilities) != 29 || - len(result.Journeys) != 28 { + len(result.Capabilities) != 30 || + len(result.Journeys) != 29 { t.Fatalf("exit = %d, result = %#v", exit, result) } } diff --git a/internal/packs/registry_test.go b/internal/packs/registry_test.go index 43f2296..4aa03b4 100644 --- a/internal/packs/registry_test.go +++ b/internal/packs/registry_test.go @@ -23,7 +23,7 @@ func TestRegistryAggregatesCapabilities(t *testing.T) { t.Fatal(err) } capabilities := registry.Capabilities() - if len(capabilities) != 14 { + if len(capabilities) != 15 { t.Fatalf("capabilities = %#v", capabilities) } if _, ok := registry.Get("snap"); !ok { @@ -39,6 +39,7 @@ func TestRegistryAggregatesCapabilities(t *testing.T) { "core-api.card-3ds.verify.v1", "core-api.installment.verify.v1", "core-api.otc.verify.v1", + "core-api.recurring.verify.v1", "core-api.refund.verify.v1", "core-api.saved-card.verify.v1", "core-api.virtual-account.verify.v1", @@ -107,6 +108,7 @@ func TestRegistryAggregatesDeterministicMetadata(t *testing.T) { "core-api.card-3ds", "core-api.installment", "core-api.otc", + "core-api.recurring", "core-api.refund", "core-api.saved-card", "core-api.virtual-account", diff --git a/packs/coreapi/client.go b/packs/coreapi/client.go index 964ca64..9e5435d 100644 --- a/packs/coreapi/client.go +++ b/packs/coreapi/client.go @@ -47,20 +47,21 @@ type ChargeRequest struct { } type ChargeResponse struct { - OrderID string `json:"order_id"` - TransactionStatus string `json:"transaction_status"` - FraudStatus string `json:"fraud_status,omitempty"` - StatusCode string `json:"status_code"` - PaymentType string `json:"payment_type,omitempty"` - GrossAmount string `json:"gross_amount,omitempty"` - RedirectURL string `json:"redirect_url,omitempty"` - PaymentCode string `json:"payment_code,omitempty"` - Store string `json:"store,omitempty"` + OrderID string `json:"order_id"` + TransactionStatus string `json:"transaction_status"` + FraudStatus string `json:"fraud_status,omitempty"` + StatusCode string `json:"status_code"` + PaymentType string `json:"payment_type,omitempty"` + GrossAmount string `json:"gross_amount,omitempty"` + RedirectURL string `json:"redirect_url,omitempty"` + PaymentCode string `json:"payment_code,omitempty"` + Store string `json:"store,omitempty"` VANumbers []string `json:"va_numbers,omitempty"` } type StatusResponse struct { OrderID string `json:"order_id"` + TransactionID string `json:"transaction_id,omitempty"` TransactionStatus string `json:"transaction_status"` FraudStatus string `json:"fraud_status,omitempty"` StatusCode string `json:"status_code"` @@ -164,6 +165,7 @@ func (c Client) Status(ctx context.Context, orderID string) (StatusResponse, err var result struct { OrderID string `json:"order_id"` + TransactionID string `json:"transaction_id"` TransactionStatus string `json:"transaction_status"` FraudStatus string `json:"fraud_status"` StatusCode string `json:"status_code"` @@ -178,6 +180,7 @@ func (c Client) Status(ctx context.Context, orderID string) (StatusResponse, err } return StatusResponse{ OrderID: result.OrderID, + TransactionID: result.TransactionID, TransactionStatus: result.TransactionStatus, FraudStatus: result.FraudStatus, StatusCode: result.StatusCode, diff --git a/packs/coreapi/journey.go b/packs/coreapi/journey.go index 11d2a45..68e1ef1 100644 --- a/packs/coreapi/journey.go +++ b/packs/coreapi/journey.go @@ -2,11 +2,14 @@ package coreapi import ( "context" + "crypto/sha256" "errors" + "fmt" "strconv" "time" "github.com/veritrans/midtrans-cli/internal/contracts" + "github.com/veritrans/midtrans-cli/internal/evidence" journey "github.com/veritrans/midtrans-cli/internal/journey" "github.com/veritrans/midtrans-cli/internal/operations" "github.com/veritrans/midtrans-cli/internal/sandbox" @@ -58,6 +61,10 @@ func NewVirtualAccountHandler() Handler { return newHandler("core-api.virtual-account", "virtual-account") } +func NewRecurringHandler() Handler { + return newHandler("core-api.recurring", "recurring") +} + func NewRefundHandler() Handler { return newHandler("core-api.refund", "refund") } @@ -109,6 +116,10 @@ func (h Handler) run(ctx context.Context, request journey.Request, runtime journ switch h.definition.Intent { case "refund": return h.runRefund(ctx, request, runtime) + case "recurring": + if request.Input.PaymentTokenReference == "" { + return inputRequired("payment_token_reference is required") + } case "card-3ds", "saved-card", "installment": if request.Input.PaymentTokenReference == "" { return inputRequired("payment_token_reference is required") @@ -126,6 +137,9 @@ func (h Handler) run(ctx context.Context, request journey.Request, runtime journ if outcome != nil { return *outcome } + if h.definition.Intent == "recurring" { + return h.runRecurringVerify(ctx, request, runner, tokenID) + } if runner.Status == nil || runner.Charge == nil { return blockedOutcome("journey dependencies are unavailable") } @@ -160,6 +174,69 @@ func (h Handler) run(ctx context.Context, request journey.Request, runtime journ return h.evaluateCharge(request, response, runner.Now) } +func (h Handler) runRecurringVerify( + ctx context.Context, + request journey.Request, + runner JourneyRunner, + tokenID string, +) journey.Outcome { + if runner.Status == nil { + return blockedOutcome("provider status is unavailable") + } + status, err := runner.Status.Status(ctx, request.Input.OrderID) + if err != nil || status.NotFound { + return journey.Outcome{ + State: journey.Reconciling, + SafeData: map[string]any{"order_id": request.Input.OrderID}, + } + } + switch status.TransactionStatus { + case "capture": + if status.FraudStatus != "" && status.FraudStatus != "accept" { + return blockedOutcome("provider status blocked the transaction") + } + fallthrough + case "settlement": + base := journey.Outcome{ + State: journey.Reconciling, + SafeData: map[string]any{ + "order_id": status.OrderID, + "provider_status": status.TransactionStatus, + "status_code": status.StatusCode, + }, + MissingEvidence: []string{ + "core-api.recurring.charge-attempt", + "core-api.recurring.notification", + "core-api.recurring.merchant-persistence", + }, + Finding: &contracts.Finding{ + Code: "CORE_API_RECURRING_EVIDENCE_REQUIRED", + Severity: "blocking", + Message: "verified recurring charge evidence requires merchant scheduler attempt, recurring notification, and merchant persistence or dunning proof", + }, + } + if status.TransactionID != "" { + base.SafeData["provider_reference"] = status.TransactionID + } + proofs, ok := validatedRecurringProofs(request, status, tokenID) + if !ok { + return base + } + base.State = journey.Passed + base.Proofs = proofs + base.MissingEvidence = nil + base.Finding = nil + return base + case "pending": + return journey.Outcome{ + State: journey.Reconciling, + SafeData: map[string]any{"order_id": status.OrderID}, + } + default: + return blockedOutcome("provider status blocked the transaction") + } +} + func (h Handler) runRefund(ctx context.Context, request journey.Request, runtime journey.Runtime) journey.Outcome { if request.Input.CustomerReference == "" { return inputRequired("customer_reference is required as a stable refund key") @@ -184,9 +261,9 @@ func (h Handler) runRefund(ctx context.Context, request journey.Request, runtime return journey.Outcome{ State: journey.Passed, SafeData: map[string]any{ - "order_id": response.OrderID, - "refund_key": response.RefundKey, - "status_code": response.StatusCode, + "order_id": response.OrderID, + "refund_key": response.RefundKey, + "status_code": response.StatusCode, "provider_status": response.TransactionStatus, }, } @@ -280,6 +357,80 @@ func (h Handler) evaluateStatus(status StatusResponse) journey.Outcome { } } +func validatedRecurringProofs(request journey.Request, status StatusResponse, tokenID string) ([]evidence.Proof, bool) { + bundle := request.Evidence + if bundle.SchemaVersion == "" { + return nil, false + } + if err := evidence.Validate(bundle); err != nil { + return nil, false + } + if bundle.Environment != "sandbox" || + bundle.ManifestVersion != 1 || + bundle.ManifestHash != request.ManifestHash || + bundle.PackID != "core-api" || + bundle.Journey != "core-api.recurring" || + bundle.SafeReferences["order_id"] != status.OrderID { + return nil, false + } + expectedTokenHash := sha256Hex(tokenID) + var attemptProof *evidence.Proof + var notificationProof *evidence.Proof + var persistenceProof *evidence.Proof + for _, proof := range bundle.Proofs { + if proof.OperationID != request.OperationID { + continue + } + switch proof.ID { + case "core-api.recurring.charge-attempt": + if proof.Level == evidence.ProofLocal && + proof.Status == "pass" && + proof.Stage == "merchant_scheduler_attempt" && + proof.Source == "merchant_application" && + summaryString(proof.Summary, "order_id") == status.OrderID && + summaryString(proof.Summary, "payment_token_hash") == expectedTokenHash && + summaryString(proof.Summary, "gross_amount") == strconv.FormatInt(request.Input.Amount, 10) && + summaryString(proof.Summary, "scheduler_state") == "attempted" { + proofCopy := proof + attemptProof = &proofCopy + } else { + return nil, false + } + case "core-api.recurring.notification": + if proof.Level == evidence.ProofSandbox && + proof.Status == "pass" && + proof.Stage == "provider_notification" && + proof.Source == "midtrans_notification" && + summaryString(proof.Summary, "order_id") == status.OrderID && + summaryString(proof.Summary, "transaction_status") == status.TransactionStatus && + matchesOptionalReference(summaryString(proof.Summary, "transaction_id"), status.TransactionID) { + proofCopy := proof + notificationProof = &proofCopy + } else { + return nil, false + } + case "core-api.recurring.merchant-persistence": + if proof.Level == evidence.ProofLocal && + proof.Status == "pass" && + proof.Stage == "merchant_persistence" && + proof.Source == "merchant_application" && + summaryString(proof.Summary, "order_id") == status.OrderID && + matchesOptionalReference(summaryString(proof.Summary, "transaction_id"), status.TransactionID) && + summaryString(proof.Summary, "payment_status") == "paid" && + summaryString(proof.Summary, "dunning_outcome") != "" { + proofCopy := proof + persistenceProof = &proofCopy + } else { + return nil, false + } + } + } + if attemptProof == nil || notificationProof == nil || persistenceProof == nil { + return nil, false + } + return []evidence.Proof{*attemptProof, *notificationProof, *persistenceProof}, true +} + func (h Handler) runtimeRunner( ctx context.Context, request journey.Request, @@ -335,7 +486,30 @@ func runtimeNow(runtime journey.Runtime) func() time.Time { } func requiresResolvedToken(intent string) bool { - return intent == "card-3ds" || intent == "saved-card" || intent == "installment" + return intent == "card-3ds" || intent == "saved-card" || intent == "installment" || intent == "recurring" +} + +func sha256Hex(value string) string { + sum := sha256.Sum256([]byte(value)) + return fmt.Sprintf("%x", sum[:]) +} + +func summaryString(summary map[string]any, key string) string { + if summary == nil { + return "" + } + value, ok := summary[key].(string) + if !ok { + return "" + } + return value +} + +func matchesOptionalReference(summaryReference, statusReference string) bool { + if statusReference == "" { + return summaryReference == "" + } + return summaryReference == statusReference } func blockedFinding(code, message string) journey.Outcome { diff --git a/packs/coreapi/journey_test.go b/packs/coreapi/journey_test.go index f1acdf3..dd58f9a 100644 --- a/packs/coreapi/journey_test.go +++ b/packs/coreapi/journey_test.go @@ -2,13 +2,17 @@ package coreapi_test import ( "context" + "crypto/sha256" "encoding/json" "errors" + "fmt" "net/http" + "strings" "testing" "time" "github.com/veritrans/midtrans-cli/internal/contracts" + "github.com/veritrans/midtrans-cli/internal/evidence" journeypkg "github.com/veritrans/midtrans-cli/internal/journey" "github.com/veritrans/midtrans-cli/internal/manifest" "github.com/veritrans/midtrans-cli/internal/sandbox" @@ -279,6 +283,148 @@ func TestCardJourneyReconcilesAmbiguousMutationByStatusBeforeRetry(t *testing.T) } } +func TestRecurringJourneyRequiresBoundMerchantEvidenceAndDoesNotLeakTokenReference(t *testing.T) { + handler := coreapi.NewRecurringHandler() + request := journeypkg.Request{ + OperationID: "operation-recurring", + ProjectDir: "/merchant", + ManifestHash: strings.Repeat("a", 64), + Manifest: validCoreManifest(), + Input: journeypkg.Input{ + OrderID: "order-recurring", + Amount: 10000, + PaymentTokenReference: "env:MIDTRANS_SAVED_CARD_TOKEN", + }, + } + + blocked := handler.Execute(context.Background(), request, journeypkg.Runtime{ + HTTP: appDoerFunc(func(request *http.Request) (*http.Response, error) { + switch request.Method { + case http.MethodGet: + return coreResponse(http.StatusOK, `{"status_code":"200","transaction_status":"settlement","order_id":"order-recurring","payment_type":"credit_card","gross_amount":"10000.00"}`), nil + default: + t.Fatalf("unexpected mutation %s %s", request.Method, request.URL.String()) + return nil, nil + } + }), + ResolveCredential: func(_ context.Context, _ string, reference string) ([]byte, error) { + switch reference { + case "env:MIDTRANS_SERVER_KEY": + return []byte(coreServerKeyCanary), nil + case "env:MIDTRANS_SAVED_CARD_TOKEN": + return []byte("saved-card-token-canary"), nil + default: + return nil, errors.New("unexpected reference") + } + }, + }) + if blocked.State != journeypkg.Reconciling { + t.Fatalf("blocked = %#v", blocked) + } + if len(blocked.MissingEvidence) != 3 { + t.Fatalf("missing evidence = %#v", blocked.MissingEvidence) + } + + request.Evidence = evidence.Bundle{ + SchemaVersion: evidence.SchemaVersion, + CLIVersion: "0.1.0-test", + ManifestVersion: 1, + PackID: "core-api", + PackVersion: "0.1.0", + ManifestHash: request.ManifestHash, + RepositoryCommit: strings.Repeat("b", 40), + Journey: "core-api.recurring", + Environment: "sandbox", + StartedAt: time.Unix(1700000000, 0).UTC(), + CompletedAt: time.Unix(1700000060, 0).UTC(), + SafeReferences: map[string]string{ + "order_id": "order-recurring", + "provider_transaction_id": "txn-recurring-001", + }, + Proofs: []evidence.Proof{ + { + ID: "core-api.recurring.charge-attempt", + OperationID: "operation-recurring", + Stage: "merchant_scheduler_attempt", + Level: evidence.ProofLocal, + Source: "merchant_application", + ObservedAt: time.Unix(1700000005, 0).UTC(), + Status: "pass", + Summary: map[string]any{ + "order_id": "order-recurring", + "payment_token_hash": sha256Hex("saved-card-token-canary"), + "gross_amount": "10000", + "scheduler_state": "attempted", + }, + }, + { + ID: "core-api.recurring.notification", + OperationID: "operation-recurring", + Stage: "provider_notification", + Level: evidence.ProofSandbox, + Source: "midtrans_notification", + ObservedAt: time.Unix(1700000010, 0).UTC(), + Status: "pass", + Summary: map[string]any{ + "order_id": "order-recurring", + "transaction_status": "settlement", + "transaction_id": "txn-recurring-001", + }, + }, + { + ID: "core-api.recurring.merchant-persistence", + OperationID: "operation-recurring", + Stage: "merchant_persistence", + Level: evidence.ProofLocal, + Source: "merchant_application", + ObservedAt: time.Unix(1700000015, 0).UTC(), + Status: "pass", + Summary: map[string]any{ + "order_id": "order-recurring", + "transaction_id": "txn-recurring-001", + "payment_status": "paid", + "dunning_outcome": "collected", + }, + }, + }, + } + + passed := handler.Execute(context.Background(), request, journeypkg.Runtime{ + HTTP: appDoerFunc(func(request *http.Request) (*http.Response, error) { + switch request.Method { + case http.MethodGet: + return coreResponse(http.StatusOK, `{"status_code":"200","transaction_status":"settlement","transaction_id":"txn-recurring-001","order_id":"order-recurring","payment_type":"credit_card","gross_amount":"10000.00"}`), nil + default: + t.Fatalf("unexpected mutation %s %s", request.Method, request.URL.String()) + return nil, nil + } + }), + ResolveCredential: func(_ context.Context, _ string, reference string) ([]byte, error) { + switch reference { + case "env:MIDTRANS_SERVER_KEY": + return []byte(coreServerKeyCanary), nil + case "env:MIDTRANS_SAVED_CARD_TOKEN": + return []byte("saved-card-token-canary"), nil + default: + return nil, errors.New("unexpected reference") + } + }, + }) + if passed.State != journeypkg.Passed { + t.Fatalf("passed = %#v", passed) + } + for _, key := range []string{"payment_token_reference", "payment_token_reference_hash", "token_id"} { + if _, ok := passed.SafeData[key]; ok { + t.Fatalf("safe data leaked %q: %#v", key, passed.SafeData) + } + } +} + +func sha256Hex(value string) string { + sum := sha256.Sum256([]byte(value)) + return fmt.Sprintf("%x", sum[:]) +} + type stubCharge func(context.Context, coreapi.ChargeRequest) (coreapi.ChargeResponse, error) func (s stubCharge) Charge(ctx context.Context, request coreapi.ChargeRequest) (coreapi.ChargeResponse, error) { diff --git a/packs/coreapi/pack.go b/packs/coreapi/pack.go index a6843a2..7e00a80 100644 --- a/packs/coreapi/pack.go +++ b/packs/coreapi/pack.go @@ -21,6 +21,7 @@ func (Pack) Descriptor() packs.Descriptor { {ID: "core-api.saved-card.verify.v1", Description: "run and verify a Core API saved-card journey", Pack: "core-api"}, {ID: "core-api.installment.verify.v1", Description: "run and verify a Core API installment journey", Pack: "core-api"}, {ID: "core-api.otc.verify.v1", Description: "run and verify a Core API OTC journey", Pack: "core-api"}, + {ID: "core-api.recurring.verify.v1", Description: "verify a merchant-driven Core API recurring charge journey", Pack: "core-api"}, {ID: "core-api.virtual-account.verify.v1", Description: "run and verify a Core API virtual-account journey", Pack: "core-api"}, {ID: "core-api.refund.verify.v1", Description: "run and verify a Core API refund journey", Pack: "core-api"}, }, @@ -29,6 +30,7 @@ func (Pack) Descriptor() packs.Descriptor { "core-api.saved-card", "core-api.installment", "core-api.otc", + "core-api.recurring", "core-api.virtual-account", "core-api.refund", }, @@ -37,13 +39,13 @@ func (Pack) Descriptor() packs.Descriptor { Sources: []contracts.PublicSource{ {ID: "coreapi-card-charge", URL: "https://docs.midtrans.com/reference/charge-transactions-on-card", Rules: []string{"coreapi.card.charge", "coreapi.basic-auth"}}, {ID: "coreapi-card-3ds", URL: "https://docs.midtrans.com/reference/card-feature-3d-secure-3ds", Rules: []string{"coreapi.card.3ds", "coreapi.card.redirect"}}, - {ID: "coreapi-one-click", URL: "https://docs.midtrans.com/reference/card-feature-one-click", Rules: []string{"coreapi.saved-card.token-only"}}, + {ID: "coreapi-one-click", URL: "https://docs.midtrans.com/reference/card-feature-one-click", Rules: []string{"coreapi.saved-card.token-only", "coreapi.recurring.saved-card-token"}}, {ID: "coreapi-alfamart", URL: "https://docs.midtrans.com/reference/alfamart-1", Rules: []string{"coreapi.otc.charge", "coreapi.otc.payment-code"}}, {ID: "coreapi-bni-va", URL: "https://docs.midtrans.com/reference/bni-virtual-account-1", Rules: []string{"coreapi.va.charge", "coreapi.va.instructions"}}, - {ID: "coreapi-status", URL: "https://docs.midtrans.com/reference/get-transaction-status", Rules: []string{"coreapi.status.reconcile", "coreapi.refund.status"}}, + {ID: "coreapi-status", URL: "https://docs.midtrans.com/reference/get-transaction-status", Rules: []string{"coreapi.status.reconcile", "coreapi.recurring.status", "coreapi.refund.status"}}, {ID: "coreapi-refund", URL: "https://docs.midtrans.com/reference/refund-transaction", Rules: []string{"coreapi.refund.async", "coreapi.refund.idempotency"}}, {ID: "coreapi-direct-refund", URL: "https://docs.midtrans.com/reference/direct-refund-transaction", Rules: []string{"coreapi.refund.direct"}}, - {ID: "coreapi-notifications", URL: "https://docs.midtrans.com/docs/https-notification-webhooks", Rules: []string{"coreapi.notification.signature", "common.webhook-idempotency"}}, + {ID: "coreapi-notifications", URL: "https://docs.midtrans.com/docs/https-notification-webhooks", Rules: []string{"coreapi.notification.signature", "coreapi.recurring.notification", "common.webhook-idempotency"}}, }, } } @@ -73,6 +75,7 @@ func (Pack) Handlers() []journey.Handler { NewSavedCardHandler(), NewInstallmentHandler(), NewOTCHandler(), + NewRecurringHandler(), NewVirtualAccountHandler(), NewRefundHandler(), } diff --git a/packs/coreapi/pack_test.go b/packs/coreapi/pack_test.go index 7f40088..cf3574f 100644 --- a/packs/coreapi/pack_test.go +++ b/packs/coreapi/pack_test.go @@ -26,6 +26,7 @@ func TestCoreAPIDescriptorMatchesCompiledContract(t *testing.T) { "core-api.saved-card.verify.v1", "core-api.installment.verify.v1", "core-api.otc.verify.v1", + "core-api.recurring.verify.v1", "core-api.virtual-account.verify.v1", "core-api.refund.verify.v1", } @@ -38,6 +39,7 @@ func TestCoreAPIDescriptorMatchesCompiledContract(t *testing.T) { "core-api.saved-card", "core-api.installment", "core-api.otc", + "core-api.recurring", "core-api.virtual-account", "core-api.refund", } From e89e209de8b5659a4ee349c68f40b347910b6203 Mon Sep 17 00:00:00 2001 From: "m.salis" Date: Mon, 27 Jul 2026 13:50:16 +0700 Subject: [PATCH 55/73] feat: add BI-SNAP recurring verification --- .../task-11-report.md | 77 +++++ contracts/capabilities-v1.json | 2 + contracts/public-sources-v1.json | 5 +- internal/app/app_test.go | 10 +- packs/bisnap/journey.go | 176 ++++++++++ packs/bisnap/journey_test.go | 304 ++++++++++++++++++ packs/bisnap/pack.go | 9 +- packs/bisnap/pack_test.go | 2 + 8 files changed, 576 insertions(+), 9 deletions(-) diff --git a/.superpowers/sdd/2026-07-26-midtrans-cli-multi-product-parity/task-11-report.md b/.superpowers/sdd/2026-07-26-midtrans-cli-multi-product-parity/task-11-report.md index c9c6f7b..ab1919a 100644 --- a/.superpowers/sdd/2026-07-26-midtrans-cli-multi-product-parity/task-11-report.md +++ b/.superpowers/sdd/2026-07-26-midtrans-cli-multi-product-parity/task-11-report.md @@ -90,3 +90,80 @@ Committed as: ```text feat: add subscription lifecycle journeys ``` + +## Slice B2: BI-SNAP recurring verification + +Slice B2 continues after Core API recurring verification commit +`ff9b8443a99bd1feee64c2a3524fca711b0427f9` and adds the BI-SNAP recurring +verification adapter only. + +Scope: + +- `packs/bisnap/pack.go` +- `packs/bisnap/journey.go` +- `packs/bisnap/{pack_test.go,journey_test.go}` +- `contracts/capabilities-v1.json` +- `contracts/public-sources-v1.json` +- `internal/app/app_test.go` + +What changed: + +- Added capability `bisnap.recurring.verify.v1` and journey + `bisnap.recurring`. +- Added `NewRecurringHandler()` as a read-only BI-SNAP recurring verifier. +- The recurring journey: + - requires `payment_token_reference` + - resolves the configured bind/customer token reference in memory only + - never creates charges and never schedules recurring work + - uses BI-SNAP product status only through the existing debit status path + - requires a tightly bound sandbox evidence bundle before passing +- Required proofs are: + - `bisnap.recurring.scheduler-attempt` + - `bisnap.recurring.transaction-signature` + - `bisnap.notification` + - `bisnap.merchant-persistence` +- Proof validation now binds to: + - sandbox environment + - manifest hash + - journey `bisnap.recurring` + - operation ID + - order ID + - provider reference + - token reference hash + - exact stages, sources, and pass status +- Missing or mismatched proofs remain `reconciling`; the recurring verifier + does not false-pass. +- Extended BI-SNAP public-source rules for recurring transaction-signature, + recurring status, and recurring notification coverage. +- Updated runtime capability/journey count assertions in `internal/app/app_test.go`. + +TDD evidence: + +- RED: + + ```sh + go test ./packs/bisnap -count=1 + ``` + + failed with: + `undefined: bisnap.NewRecurringHandler` + +- GREEN: + + ```sh + go test ./packs/bisnap -count=1 + ``` + + passed after the recurring verifier landed. + +Validation: + +- `go test ./packs/bisnap -count=1`: passed +- `go test ./internal/app -count=1`: passed +- `go test ./... -count=1`: passed on Monday, July 27, 2026 + +Commit: + +```text +feat: add BI-SNAP recurring verification +``` diff --git a/contracts/capabilities-v1.json b/contracts/capabilities-v1.json index f8e4c6f..559ccf7 100644 --- a/contracts/capabilities-v1.json +++ b/contracts/capabilities-v1.json @@ -19,6 +19,7 @@ "bisnap.qris.verify.v1", "bisnap.virtual-account.verify.v1", "bisnap.direct-debit.verify.v1", + "bisnap.recurring.verify.v1", "bisnap.status.verify.v1", "bisnap.refund.verify.v1" ], @@ -26,6 +27,7 @@ "bisnap.qris-payment", "bisnap.virtual-account", "bisnap.direct-debit", + "bisnap.recurring", "bisnap.status", "bisnap.refund" ] diff --git a/contracts/public-sources-v1.json b/contracts/public-sources-v1.json index 62671b9..277f13e 100644 --- a/contracts/public-sources-v1.json +++ b/contracts/public-sources-v1.json @@ -185,7 +185,8 @@ "id": "bisnap-overview", "url": "https://docs.midtrans.com/reference/core-api-snap-open-api-overview", "rules": [ - "bisnap.signing.verify.v1" + "bisnap.signing.verify.v1", + "bisnap.recurring.transaction-signature" ], "sha256": "fc348733022acfb2e29d77708dc74d33041b4053ee92e3dc80d1033e2162f903", "retrieved_at": "2026-07-27T06:26:47.408497Z" @@ -216,6 +217,7 @@ "rules": [ "bisnap.direct-debit.create", "bisnap.direct-debit.status", + "bisnap.recurring.status", "bisnap.refund" ], "sha256": "921a9ded97c836096d37f8faf1539678eeb51df8544c2615453830760c1a7f97", @@ -226,6 +228,7 @@ "url": "https://docs.midtrans.com/reference/payment-notification-api", "rules": [ "bisnap.notification.signature", + "bisnap.recurring.notification", "common.webhook-idempotency" ], "sha256": "c96e1eb49a532a9ab44325a89d4609adc2a1bfd2bd80926b69b3dbb14b6e1b09", diff --git a/internal/app/app_test.go b/internal/app/app_test.go index 98b82c3..e096f6a 100644 --- a/internal/app/app_test.go +++ b/internal/app/app_test.go @@ -51,9 +51,9 @@ func TestCapabilitiesJSON(t *testing.T) { if result.CLIVersion != "0.1.0-test" { t.Fatalf("cli version = %q", result.CLIVersion) } - if len(result.Capabilities) != 30 || + if len(result.Capabilities) != 31 || result.Capabilities[0].ID != "bisnap.direct-debit.verify.v1" || - result.Capabilities[29].ID != "subscription.verify.v1" { + result.Capabilities[30].ID != "subscription.verify.v1" { t.Fatalf("unexpected capabilities: %#v", result.Capabilities) } if len(result.Packs) != 7 || @@ -66,7 +66,7 @@ func TestCapabilitiesJSON(t *testing.T) { result.Packs[6].ID != "subscription" { t.Fatalf("unexpected packs: %#v", result.Packs) } - if len(result.Journeys) != 29 || result.Journeys[27] != "subscription.enable" || result.Journeys[28] != "subscription.verify" { + if len(result.Journeys) != 30 || result.Journeys[28] != "subscription.enable" || result.Journeys[29] != "subscription.verify" { t.Fatalf("unexpected journeys: %#v", result.Journeys) } } @@ -88,8 +88,8 @@ func TestAgentCapabilitiesPreservesCapabilityContract(t *testing.T) { ) if exit != 0 || result.SchemaVersion != "1.0" || - len(result.Capabilities) != 30 || - len(result.Journeys) != 29 { + len(result.Capabilities) != 31 || + len(result.Journeys) != 30 { t.Fatalf("exit = %d, result = %#v", exit, result) } } diff --git a/packs/bisnap/journey.go b/packs/bisnap/journey.go index a5357b1..b56271f 100644 --- a/packs/bisnap/journey.go +++ b/packs/bisnap/journey.go @@ -2,7 +2,9 @@ package bisnap import ( "context" + "crypto/sha256" "errors" + "fmt" "strconv" "strings" "time" @@ -32,6 +34,7 @@ func NewVirtualAccountHandler() Handler { return newHandler("bisnap.virtual-account", "virtual-account") } func NewDirectDebitHandler() Handler { return newHandler("bisnap.direct-debit", "direct-debit") } +func NewRecurringHandler() Handler { return newHandler("bisnap.recurring", "recurring") } func NewStatusHandler() Handler { return newHandler("bisnap.status", "status") } func NewRefundHandler() Handler { return newHandler("bisnap.refund", "refund") } @@ -81,6 +84,9 @@ func (h Handler) run(ctx context.Context, request journey.Request, runtime journ if request.OperationID == "" || request.ManifestHash == "" || request.Input.OrderID == "" { return inputRequired("order_id is required") } + if h.definition.Intent == "recurring" && strings.TrimSpace(request.Input.PaymentTokenReference) == "" { + return inputRequired("payment_token_reference is required") + } if h.definition.Intent != "status" && h.definition.Intent != "refund" && request.Input.Amount <= 0 { return inputRequired("a positive amount is required") } @@ -91,6 +97,8 @@ func (h Handler) run(ctx context.Context, request journey.Request, runtime journ switch h.definition.Intent { case "status": return runStatus(ctx, request, runner) + case "recurring": + return runRecurringVerify(ctx, request, runtime, runner) case "refund": return runRefund(ctx, request, runner) default: @@ -186,6 +194,35 @@ func runRefund(ctx context.Context, request journey.Request, runner JourneyRunne } } +func runRecurringVerify( + ctx context.Context, + request journey.Request, + runtime journey.Runtime, + runner JourneyRunner, +) journey.Outcome { + if runtime.ResolveCredential == nil { + return blockedFinding("JOURNEY_EXECUTION_BLOCKED", "journey runtime dependencies are unavailable") + } + if _, err := runtime.ResolveCredential(ctx, request.ProjectDir, request.Input.PaymentTokenReference); err != nil { + return blockedFinding("CREDENTIAL_RESOLUTION_FAILED", "unable to resolve the configured payment-token reference") + } + status, err := runner.Client.Status(ctx, StatusRequest{ + Product: "direct-debit", + OrderID: request.Input.OrderID, + Method: request.Input.Method, + }) + if err != nil { + return blockedOutcome("provider status is unavailable") + } + if status.NotFound { + return journey.Outcome{ + State: journey.Reconciling, + SafeData: map[string]any{"order_id": request.Input.OrderID, "method": request.Input.Method}, + } + } + return evaluateRecurringStatus(request, status) +} + func (h Handler) awaitingActionOutcome(request journey.Request, created CreateResponse, now func() time.Time) journey.Outcome { if now == nil { now = func() time.Time { return time.Now().UTC() } @@ -285,6 +322,45 @@ func evaluateVerifiedStatus(request journey.Request, status StatusResponse) jour } } +func evaluateRecurringStatus(request journey.Request, status StatusResponse) journey.Outcome { + base := journey.Outcome{ + State: journey.Reconciling, + SafeData: map[string]any{ + "order_id": status.OrderID, + "provider_reference": status.ProviderReference, + "status_code": status.ResponseCode, + }, + MissingEvidence: []string{ + "bisnap.recurring.scheduler-attempt", + "bisnap.recurring.transaction-signature", + "bisnap.notification", + "bisnap.merchant-persistence", + }, + Finding: &contracts.Finding{ + Code: "BISNAP_RECURRING_EVIDENCE_REQUIRED", + Severity: "blocking", + Message: "recurring BI-SNAP verification requires scheduler, transaction-signature, notification, and merchant persistence proof", + }, + } + if status.OrderID == "" { + return blockedOutcome("provider status was invalid") + } + if status.LatestTransactionStatus != "00" { + base.MissingEvidence = nil + base.Finding = nil + return evaluateStatus(status) + } + proofs, ok := validatedRecurringProofs(request, status) + if !ok { + return base + } + return journey.Outcome{ + State: journey.Passed, + SafeData: base.SafeData, + Proofs: proofs, + } +} + func validatedStatusProofs(request journey.Request, status StatusResponse) ([]evidence.Proof, bool) { bundle := request.Evidence if bundle.SchemaVersion == "" { @@ -347,6 +423,101 @@ func validatedStatusProofs(request journey.Request, status StatusResponse) ([]ev return []evidence.Proof{*notificationProof, *persistenceProof}, true } +func validatedRecurringProofs(request journey.Request, status StatusResponse) ([]evidence.Proof, bool) { + bundle := request.Evidence + if bundle.SchemaVersion == "" { + return nil, false + } + if err := evidence.Validate(bundle); err != nil { + return nil, false + } + if bundle.Environment != "sandbox" || + bundle.ManifestVersion != 1 || + bundle.ManifestHash != request.ManifestHash || + bundle.PackID != "bisnap" || + bundle.Journey != "bisnap.recurring" || + bundle.SafeReferences["order_id"] != status.OrderID { + return nil, false + } + expectedTokenHash := sha256Hex([]byte(request.Input.PaymentTokenReference)) + expectedRoute := expectedNotificationRoute(request.Input.Method) + var schedulerProof *evidence.Proof + var signatureProof *evidence.Proof + var notificationProof *evidence.Proof + var persistenceProof *evidence.Proof + for _, proof := range bundle.Proofs { + if proof.OperationID != request.OperationID { + continue + } + switch proof.ID { + case "bisnap.recurring.scheduler-attempt": + if proof.Level == evidence.ProofLocal && + proof.Status == "pass" && + proof.Stage == "merchant_scheduler" && + proof.Source == "merchant_application" && + summaryString(proof.Summary, "order_id") == status.OrderID && + summaryString(proof.Summary, "gross_amount") == strconv.FormatInt(request.Input.Amount, 10) && + summaryString(proof.Summary, "token_reference_hash") == expectedTokenHash && + summaryString(proof.Summary, "scheduler_state") == "attempted" { + proofCopy := proof + schedulerProof = &proofCopy + } else { + return nil, false + } + case "bisnap.recurring.transaction-signature": + if proof.Level == evidence.ProofSandbox && + proof.Status == "pass" && + proof.Stage == "midtrans_signed_request" && + proof.Source == "midtrans_signed_request" && + summaryString(proof.Summary, "order_id") == status.OrderID && + matchesOptionalReference(summaryString(proof.Summary, "provider_reference"), status.ProviderReference) && + summaryString(proof.Summary, "request_method") == "POST" && + summaryString(proof.Summary, "request_path") == debitStatusPath && + summaryString(proof.Summary, "service_code") == "55" && + summaryString(proof.Summary, "signature_family") == "transaction" && + summaryString(proof.Summary, "token_reference_hash") == expectedTokenHash { + proofCopy := proof + signatureProof = &proofCopy + } else { + return nil, false + } + case "bisnap.notification": + if proof.Level == evidence.ProofSandbox && + proof.Status == "pass" && + proof.Stage == "provider_notification" && + proof.Source == "midtrans_notification" && + summaryString(proof.Summary, "route") == expectedRoute && + summaryString(proof.Summary, "order_id") == status.OrderID && + summaryString(proof.Summary, "latest_transaction_status") == "00" && + matchesOptionalReference(summaryString(proof.Summary, "provider_reference"), status.ProviderReference) { + proofCopy := proof + notificationProof = &proofCopy + } else { + return nil, false + } + case "bisnap.merchant-persistence": + if proof.Level == evidence.ProofLocal && + proof.Status == "pass" && + proof.Stage == "merchant_persistence" && + proof.Source == "merchant_application" && + summaryString(proof.Summary, "order_id") == status.OrderID && + matchesOptionalReference(summaryString(proof.Summary, "provider_reference"), status.ProviderReference) && + summaryString(proof.Summary, "payment_status") == "paid" && + summaryString(proof.Summary, "dunning_outcome") != "" && + summaryString(proof.Summary, "token_reference_hash") == expectedTokenHash { + proofCopy := proof + persistenceProof = &proofCopy + } else { + return nil, false + } + } + } + if schedulerProof == nil || signatureProof == nil || notificationProof == nil || persistenceProof == nil { + return nil, false + } + return []evidence.Proof{*schedulerProof, *signatureProof, *notificationProof, *persistenceProof}, true +} + func expectedNotificationRoute(method string) string { switch journeyProduct(method, method) { case "qris": @@ -505,3 +676,8 @@ func blockedOutcome(message string) journey.Outcome { func inputRequired(message string) journey.Outcome { return blockedFinding("JOURNEY_INPUT_REQUIRED", message) } + +func sha256Hex(value []byte) string { + sum := sha256.Sum256(value) + return fmt.Sprintf("%x", sum[:]) +} diff --git a/packs/bisnap/journey_test.go b/packs/bisnap/journey_test.go index f44d736..a4e9b77 100644 --- a/packs/bisnap/journey_test.go +++ b/packs/bisnap/journey_test.go @@ -256,6 +256,215 @@ func TestDirectDebitJourneyBuildsRuntimeRequestsWithoutAuthorizationCustomerAndR } } +func TestRecurringJourneyUsesStatusOnlyAndRequiresExactProofs(t *testing.T) { + var requests []*http.Request + + handler := bisnap.NewRecurringHandler() + outcome := handler.Execute(context.Background(), journeypkg.Request{ + OperationID: "operation-recurring", + ProjectDir: "/merchant", + ManifestHash: strings.Repeat("a", 64), + Manifest: validBISNAPManifest(), + Input: journeypkg.Input{ + OrderID: "order-recurring", + Amount: 45000, + Method: "gopay", + PaymentTokenReference: "env:MIDTRANS_BISNAP_BIND_TOKEN", + }, + }, journeypkg.Runtime{ + ResolveCredential: func(_ context.Context, _ string, reference string) ([]byte, error) { + switch reference { + case "env:MIDTRANS_BISNAP_CLIENT_ID": + return []byte("CLIENT-ID-CANARY-DO-NOT-PRINT"), nil + case "env:MIDTRANS_BISNAP_CLIENT_SECRET": + return []byte("CLIENT-SECRET-CANARY-DO-NOT-PRINT"), nil + case "env:MIDTRANS_BISNAP_PARTNER_ID": + return []byte("G123456"), nil + case "env:MIDTRANS_BISNAP_CHANNEL_ID": + return []byte("12345"), nil + case "env:MIDTRANS_BISNAP_DEVICE_ID": + return []byte("device-canary"), nil + case "file:./secrets/bisnap-private.pem": + return fixtureBytes(t, "private_key_pkcs8.pem"), nil + case "file:./secrets/bisnap-public.pem": + return fixtureBytes(t, "public_key_pkix.pem"), nil + case "env:MIDTRANS_BISNAP_BIND_TOKEN": + return []byte("BOUND-CUSTOMER-TOKEN"), nil + default: + t.Fatalf("unexpected reference %q", reference) + return nil, nil + } + }, + HTTP: appDoerFunc(func(request *http.Request) (*http.Response, error) { + requests = append(requests, request) + switch request.URL.Path { + case "/v1.0/access-token/b2b": + return bisnapResponse(http.StatusOK, `{"accessToken":"ACCESS-TOKEN-CANARY-DO-NOT-PRINT"}`), nil + case "/v1.0/debit/status": + return bisnapResponse(http.StatusOK, `{"responseCode":"2005500","latestTransactionStatus":"00","referenceNo":"provider-recurring-001"}`), nil + default: + t.Fatalf("unexpected path %q", request.URL.Path) + return nil, nil + } + }), + }) + + if outcome.State == journeypkg.Passed { + t.Fatalf("outcome = %#v", outcome) + } + if len(requests) != 2 { + t.Fatalf("requests = %d", len(requests)) + } + if requests[0].URL.Path != "/v1.0/access-token/b2b" || requests[1].URL.Path != "/v1.0/debit/status" { + t.Fatalf("paths = %q %q", requests[0].URL.Path, requests[1].URL.Path) + } + if len(outcome.MissingEvidence) != 4 { + t.Fatalf("missing evidence = %#v", outcome.MissingEvidence) + } + for _, key := range []string{"payment_token_reference", "payment_token_reference_hash", "customer_token_reference"} { + if _, ok := outcome.SafeData[key]; ok { + t.Fatalf("safe data leaked %q: %#v", key, outcome.SafeData) + } + } +} + +func TestRecurringJourneyPassesWithExactBoundProofs(t *testing.T) { + projectDir := createBISNAPEvidenceProject(t) + handler := bisnap.NewRecurringHandler() + outcome := handler.Execute(context.Background(), journeypkg.Request{ + OperationID: "operation-recurring", + ProjectDir: projectDir, + ManifestHash: manifestHashForProject(t, projectDir), + Manifest: validBISNAPManifest(), + Evidence: verifiedBISNAPRecurringEvidenceBundle( + t, + projectDir, + "env:MIDTRANS_BISNAP_BIND_TOKEN", + "provider-recurring-001", + "pass", + "pass", + "pass", + "collected", + ), + Input: journeypkg.Input{ + OrderID: "order-recurring", + Amount: 45000, + Method: "gopay", + PaymentTokenReference: "env:MIDTRANS_BISNAP_BIND_TOKEN", + }, + }, journeypkg.Runtime{ + ResolveCredential: func(_ context.Context, _ string, reference string) ([]byte, error) { + switch reference { + case "env:MIDTRANS_BISNAP_CLIENT_ID": + return []byte("CLIENT-ID-CANARY-DO-NOT-PRINT"), nil + case "env:MIDTRANS_BISNAP_CLIENT_SECRET": + return []byte("CLIENT-SECRET-CANARY-DO-NOT-PRINT"), nil + case "env:MIDTRANS_BISNAP_PARTNER_ID": + return []byte("G123456"), nil + case "env:MIDTRANS_BISNAP_CHANNEL_ID": + return []byte("12345"), nil + case "env:MIDTRANS_BISNAP_DEVICE_ID": + return []byte("device-canary"), nil + case "file:./secrets/bisnap-private.pem": + return fixtureBytes(t, "private_key_pkcs8.pem"), nil + case "file:./secrets/bisnap-public.pem": + return fixtureBytes(t, "public_key_pkix.pem"), nil + case "env:MIDTRANS_BISNAP_BIND_TOKEN": + return []byte("BOUND-CUSTOMER-TOKEN"), nil + default: + t.Fatalf("unexpected reference %q", reference) + return nil, nil + } + }, + HTTP: appDoerFunc(func(request *http.Request) (*http.Response, error) { + switch request.URL.Path { + case "/v1.0/access-token/b2b": + return bisnapResponse(http.StatusOK, `{"accessToken":"ACCESS-TOKEN-CANARY-DO-NOT-PRINT"}`), nil + case "/v1.0/debit/status": + return bisnapResponse(http.StatusOK, `{"responseCode":"2005500","latestTransactionStatus":"00","referenceNo":"provider-recurring-001"}`), nil + default: + t.Fatalf("unexpected path %q", request.URL.Path) + return nil, nil + } + }), + }) + + if outcome.State != journeypkg.Passed { + t.Fatalf("outcome = %#v", outcome) + } + for _, key := range []string{"payment_token_reference", "payment_token_reference_hash", "customer_token_reference"} { + if _, ok := outcome.SafeData[key]; ok { + t.Fatalf("safe data leaked %q: %#v", key, outcome.SafeData) + } + } +} + +func TestRecurringJourneyRejectsTokenReferenceHashMismatch(t *testing.T) { + projectDir := createBISNAPEvidenceProject(t) + handler := bisnap.NewRecurringHandler() + outcome := handler.Execute(context.Background(), journeypkg.Request{ + OperationID: "operation-recurring", + ProjectDir: projectDir, + ManifestHash: manifestHashForProject(t, projectDir), + Manifest: validBISNAPManifest(), + Evidence: verifiedBISNAPRecurringEvidenceBundle( + t, + projectDir, + "env:MIDTRANS_BISNAP_BIND_TOKEN_OLD", + "provider-recurring-001", + "pass", + "pass", + "pass", + "collected", + ), + Input: journeypkg.Input{ + OrderID: "order-recurring", + Amount: 45000, + Method: "gopay", + PaymentTokenReference: "env:MIDTRANS_BISNAP_BIND_TOKEN", + }, + }, journeypkg.Runtime{ + ResolveCredential: func(_ context.Context, _ string, reference string) ([]byte, error) { + switch reference { + case "env:MIDTRANS_BISNAP_CLIENT_ID": + return []byte("CLIENT-ID-CANARY-DO-NOT-PRINT"), nil + case "env:MIDTRANS_BISNAP_CLIENT_SECRET": + return []byte("CLIENT-SECRET-CANARY-DO-NOT-PRINT"), nil + case "env:MIDTRANS_BISNAP_PARTNER_ID": + return []byte("G123456"), nil + case "env:MIDTRANS_BISNAP_CHANNEL_ID": + return []byte("12345"), nil + case "env:MIDTRANS_BISNAP_DEVICE_ID": + return []byte("device-canary"), nil + case "file:./secrets/bisnap-private.pem": + return fixtureBytes(t, "private_key_pkcs8.pem"), nil + case "file:./secrets/bisnap-public.pem": + return fixtureBytes(t, "public_key_pkix.pem"), nil + case "env:MIDTRANS_BISNAP_BIND_TOKEN": + return []byte("BOUND-CUSTOMER-TOKEN"), nil + default: + t.Fatalf("unexpected reference %q", reference) + return nil, nil + } + }, + HTTP: appDoerFunc(func(request *http.Request) (*http.Response, error) { + switch request.URL.Path { + case "/v1.0/access-token/b2b": + return bisnapResponse(http.StatusOK, `{"accessToken":"ACCESS-TOKEN-CANARY-DO-NOT-PRINT"}`), nil + case "/v1.0/debit/status": + return bisnapResponse(http.StatusOK, `{"responseCode":"2005500","latestTransactionStatus":"00","referenceNo":"provider-recurring-001"}`), nil + default: + t.Fatalf("unexpected path %q", request.URL.Path) + return nil, nil + } + }), + }) + + if outcome.State == journeypkg.Passed { + t.Fatalf("outcome = %#v", outcome) + } +} + func TestRefundJourneyUsesDebitRefundEndpointAndStableReference(t *testing.T) { var requests []*http.Request var bodies []string @@ -658,3 +867,98 @@ func verifiedBISNAPEvidenceBundle(t *testing.T, projectDir, method, providerRefe }, } } + +func verifiedBISNAPRecurringEvidenceBundle( + t *testing.T, + projectDir, tokenReference, providerReference, schedulerStatus, signatureStatus, notificationStatus, dunningOutcome string, +) evidence.Bundle { + t.Helper() + now := time.Now().UTC() + return evidence.Bundle{ + SchemaVersion: evidence.SchemaVersion, + CLIVersion: "0.1.0-test", + ManifestVersion: 1, + PackID: "bisnap", + PackVersion: "0.1.0", + ManifestHash: manifestHashForProject(t, projectDir), + RepositoryCommit: strings.Repeat("a", 40), + Journey: "bisnap.recurring", + Environment: "sandbox", + StartedAt: now.Add(-time.Second), + CompletedAt: now, + SafeReferences: map[string]string{ + "order_id": "order-recurring", + }, + Proofs: []evidence.Proof{ + { + ID: "bisnap.recurring.scheduler-attempt", + OperationID: "operation-recurring", + Stage: "merchant_scheduler", + Level: evidence.ProofLocal, + Source: "merchant_application", + ObservedAt: now, + Status: schedulerStatus, + Summary: map[string]any{ + "order_id": "order-recurring", + "gross_amount": "45000", + "token_reference_hash": sha256Hex([]byte(tokenReference)), + "scheduler_state": "attempted", + }, + }, + { + ID: "bisnap.recurring.transaction-signature", + OperationID: "operation-recurring", + Stage: "midtrans_signed_request", + Level: evidence.ProofSandbox, + Source: "midtrans_signed_request", + ObservedAt: now, + Status: signatureStatus, + Summary: map[string]any{ + "order_id": "order-recurring", + "provider_reference": providerReference, + "request_method": "POST", + "request_path": "/v1.0/debit/status", + "service_code": "55", + "signature_family": "transaction", + "token_reference_hash": sha256Hex([]byte(tokenReference)), + }, + }, + { + ID: "bisnap.notification", + OperationID: "operation-recurring", + Stage: "provider_notification", + Level: evidence.ProofSandbox, + Source: "midtrans_notification", + ObservedAt: now, + Status: notificationStatus, + Summary: map[string]any{ + "route": "/v1.0/debit/notify", + "order_id": "order-recurring", + "provider_reference": providerReference, + "latest_transaction_status": "00", + }, + }, + { + ID: "bisnap.merchant-persistence", + OperationID: "operation-recurring", + Stage: "merchant_persistence", + Level: evidence.ProofLocal, + Source: "merchant_application", + ObservedAt: now, + Status: "pass", + Summary: map[string]any{ + "order_id": "order-recurring", + "provider_reference": providerReference, + "payment_status": "paid", + "dunning_outcome": dunningOutcome, + "token_reference_hash": sha256Hex([]byte(tokenReference)), + }, + }, + }, + } +} + +func sha256Hex(value []byte) string { + sum := sha256.Sum256(value) + return hex.EncodeToString(sum[:]) +} diff --git a/packs/bisnap/pack.go b/packs/bisnap/pack.go index 1a9b8b6..8a55a9e 100644 --- a/packs/bisnap/pack.go +++ b/packs/bisnap/pack.go @@ -20,6 +20,7 @@ func (Pack) Descriptor() packs.Descriptor { {ID: "bisnap.qris.verify.v1", Description: "run and verify a BI-SNAP QRIS journey", Pack: "bisnap"}, {ID: "bisnap.virtual-account.verify.v1", Description: "run and verify a BI-SNAP virtual-account journey", Pack: "bisnap"}, {ID: "bisnap.direct-debit.verify.v1", Description: "run and verify a BI-SNAP one-time direct-debit journey", Pack: "bisnap"}, + {ID: "bisnap.recurring.verify.v1", Description: "verify a merchant-driven BI-SNAP recurring charge journey", Pack: "bisnap"}, {ID: "bisnap.status.verify.v1", Description: "verify a BI-SNAP product status journey", Pack: "bisnap"}, {ID: "bisnap.refund.verify.v1", Description: "run and verify a BI-SNAP refund journey", Pack: "bisnap"}, }, @@ -27,6 +28,7 @@ func (Pack) Descriptor() packs.Descriptor { "bisnap.qris-payment", "bisnap.virtual-account", "bisnap.direct-debit", + "bisnap.recurring", "bisnap.status", "bisnap.refund", }, @@ -42,11 +44,11 @@ func (Pack) Descriptor() packs.Descriptor { "signature", }, Sources: []contracts.PublicSource{ - {ID: "bisnap-overview", URL: "https://docs.midtrans.com/reference/core-api-snap-open-api-overview", Rules: []string{"bisnap.signing.verify.v1"}}, + {ID: "bisnap-overview", URL: "https://docs.midtrans.com/reference/core-api-snap-open-api-overview", Rules: []string{"bisnap.signing.verify.v1", "bisnap.recurring.transaction-signature"}}, {ID: "bisnap-qris", URL: "https://docs.midtrans.com/reference/mpm-api-qris", Rules: []string{"bisnap.qris.create", "bisnap.qris.status"}}, {ID: "bisnap-virtual-account", URL: "https://docs.midtrans.com/reference/virtual-account-api-bank-transfer", Rules: []string{"bisnap.virtual-account.create", "bisnap.virtual-account.status"}}, - {ID: "bisnap-direct-debit", URL: "https://docs.midtrans.com/reference/direct-debit-api-gopay", Rules: []string{"bisnap.direct-debit.create", "bisnap.direct-debit.status", "bisnap.refund"}}, - {ID: "bisnap-notifications", URL: "https://docs.midtrans.com/reference/payment-notification-api", Rules: []string{"bisnap.notification.signature", "common.webhook-idempotency"}}, + {ID: "bisnap-direct-debit", URL: "https://docs.midtrans.com/reference/direct-debit-api-gopay", Rules: []string{"bisnap.direct-debit.create", "bisnap.direct-debit.status", "bisnap.recurring.status", "bisnap.refund"}}, + {ID: "bisnap-notifications", URL: "https://docs.midtrans.com/reference/payment-notification-api", Rules: []string{"bisnap.notification.signature", "bisnap.recurring.notification", "common.webhook-idempotency"}}, }, } } @@ -75,6 +77,7 @@ func (Pack) Handlers() []journey.Handler { NewQRISHandler(), NewVirtualAccountHandler(), NewDirectDebitHandler(), + NewRecurringHandler(), NewStatusHandler(), NewRefundHandler(), } diff --git a/packs/bisnap/pack_test.go b/packs/bisnap/pack_test.go index 775f466..cf56cae 100644 --- a/packs/bisnap/pack_test.go +++ b/packs/bisnap/pack_test.go @@ -21,6 +21,7 @@ func TestPackDescriptorIncludesBISNAPCapabilitiesAndJourneys(t *testing.T) { "bisnap.qris.verify.v1", "bisnap.virtual-account.verify.v1", "bisnap.direct-debit.verify.v1", + "bisnap.recurring.verify.v1", "bisnap.status.verify.v1", "bisnap.refund.verify.v1", } @@ -36,6 +37,7 @@ func TestPackDescriptorIncludesBISNAPCapabilitiesAndJourneys(t *testing.T) { "bisnap.qris-payment", "bisnap.virtual-account", "bisnap.direct-debit", + "bisnap.recurring", "bisnap.status", "bisnap.refund", } From 03668bf5bf136de2ab4d99ebd71b51765ea9bfe7 Mon Sep 17 00:00:00 2001 From: "m.salis" Date: Mon, 27 Jul 2026 15:32:49 +0700 Subject: [PATCH 56/73] feat: add GoPay recurring verification --- .../task-11-report.md | 83 ++++++ contracts/capabilities-v1.json | 2 + contracts/public-sources-v1.json | 7 +- internal/app/app_test.go | 10 +- packs/gopaytokenization/journey.go | 202 ++++++++++++++ packs/gopaytokenization/journey_test.go | 247 ++++++++++++++++++ packs/gopaytokenization/pack.go | 9 +- packs/gopaytokenization/pack_test.go | 2 + 8 files changed, 552 insertions(+), 10 deletions(-) diff --git a/.superpowers/sdd/2026-07-26-midtrans-cli-multi-product-parity/task-11-report.md b/.superpowers/sdd/2026-07-26-midtrans-cli-multi-product-parity/task-11-report.md index ab1919a..4b3cde8 100644 --- a/.superpowers/sdd/2026-07-26-midtrans-cli-multi-product-parity/task-11-report.md +++ b/.superpowers/sdd/2026-07-26-midtrans-cli-multi-product-parity/task-11-report.md @@ -167,3 +167,86 @@ Commit: ```text feat: add BI-SNAP recurring verification ``` + +## Slice B3: GoPay recurring verification + +Slice B3 continues after BI-SNAP recurring verification commit +`e89e209de8b5659a4ee349c68f40b347910b6203` and adds the GoPay recurring +verification adapter only. + +Scope: + +- `packs/gopaytokenization/pack.go` +- `packs/gopaytokenization/journey.go` +- `packs/gopaytokenization/{pack_test.go,journey_test.go}` +- `contracts/capabilities-v1.json` +- `contracts/public-sources-v1.json` +- `internal/app/app_test.go` + +What changed: + +- Added capability `gopay-tokenization.recurring.verify.v1` and journey + `gopay-tokenization.recurring`. +- Added `NewRecurringHandler()` as a read-only GoPay recurring verifier. +- The recurring journey: + - requires `payment_token_reference` + - resolves the configured customer token reference in memory only + - obtains a fresh B2B access token and runs Binding Inquiry on every execute + and resume + - requires a rotated inquiry token plus an active payment option selected by + `method`: + `gopay -> GOPAY_WALLET`, `gopaylater/paylater -> PAY_LATER` + - never creates charges and never schedules recurring work + - requires a tightly bound sandbox evidence bundle before passing +- Required proofs are: + - `gopay-tokenization.recurring.scheduler-attempt` + - `gopay-tokenization.recurring.binding-inquiry` + - `gopay-tokenization.recurring.notification` + - `gopay-tokenization.recurring.merchant-persistence` +- Proof validation binds to: + - sandbox environment + - manifest hash + - journey `gopay-tokenization.recurring` + - operation ID + - order ID + - provider reference + - customer token reference hash + - rotated token hash + - payment option hash + - exact stages, sources, and pass status +- Safe output never includes raw token values, token references, rotated tokens, + or option tokens. +- Extended GoPay public-source rules for recurring inquiry, option-selection, + and notification coverage. +- Updated runtime capability/journey count assertions in `internal/app/app_test.go`. + +TDD evidence: + +- RED: + + ```sh + go test ./packs/gopaytokenization -count=1 + ``` + + failed with: + `undefined: gopaytokenization.NewRecurringHandler` + +- GREEN: + + ```sh + go test ./packs/gopaytokenization -count=1 + ``` + + passed after the recurring verifier landed. + +Validation: + +- `go test ./packs/gopaytokenization -count=1`: passed +- `go test ./internal/app -count=1`: passed +- `go test ./... -count=1`: passed on Monday, July 27, 2026 + +Commit: + +```text +feat: add GoPay recurring verification +``` diff --git a/contracts/capabilities-v1.json b/contracts/capabilities-v1.json index 559ccf7..3e13abf 100644 --- a/contracts/capabilities-v1.json +++ b/contracts/capabilities-v1.json @@ -90,6 +90,7 @@ "capabilities": [ "gopay-tokenization.account-linking.verify.v1", "gopay-tokenization.binding-inquiry.verify.v1", + "gopay-tokenization.recurring.verify.v1", "gopay-tokenization.paylater.verify.v1", "gopay-tokenization.unlink.verify.v1", "gopay-tokenization.wallet-payment.verify.v1" @@ -97,6 +98,7 @@ "journeys": [ "gopay-tokenization.account-linking", "gopay-tokenization.binding-inquiry", + "gopay-tokenization.recurring", "gopay-tokenization.wallet-payment", "gopay-tokenization.paylater", "gopay-tokenization.unlink" diff --git a/contracts/public-sources-v1.json b/contracts/public-sources-v1.json index 277f13e..7389c38 100644 --- a/contracts/public-sources-v1.json +++ b/contracts/public-sources-v1.json @@ -256,7 +256,8 @@ "id": "gopay-tokenization-binding-inquiry-api", "url": "https://docs.midtrans.com/reference/binding-inquiry-api", "rules": [ - "gopaytokenization.linking.inquiry" + "gopaytokenization.linking.inquiry", + "gopaytokenization.recurring.inquiry" ], "sha256": "3cc21ba35f29a7db861c47a32443bec76ec4cf6295139a5d9fd72954470e3b14", "retrieved_at": "2026-07-27T06:26:47.408497Z" @@ -266,7 +267,8 @@ "url": "https://docs.midtrans.com/reference/direct-debit-api-gopay-tokenization", "rules": [ "gopaytokenization.wallet.charge", - "gopaytokenization.paylater.charge" + "gopaytokenization.paylater.charge", + "gopaytokenization.recurring.option-selection" ], "sha256": "1fbbd43a427439131a99190533fa4a8efc393b75530b8dfb5dbc4f696836ab89", "retrieved_at": "2026-07-27T06:26:47.408497Z" @@ -285,6 +287,7 @@ "url": "https://docs.midtrans.com/reference/account-linking-unlinking-notification", "rules": [ "gopaytokenization.notification.signature", + "gopaytokenization.recurring.notification", "common.webhook-idempotency" ], "sha256": "c7040f48e6c9a31543b4b1ff38db6169dde9f384a651fd6558d527eea7d10365", diff --git a/internal/app/app_test.go b/internal/app/app_test.go index e096f6a..90aa601 100644 --- a/internal/app/app_test.go +++ b/internal/app/app_test.go @@ -51,9 +51,9 @@ func TestCapabilitiesJSON(t *testing.T) { if result.CLIVersion != "0.1.0-test" { t.Fatalf("cli version = %q", result.CLIVersion) } - if len(result.Capabilities) != 31 || + if len(result.Capabilities) != 32 || result.Capabilities[0].ID != "bisnap.direct-debit.verify.v1" || - result.Capabilities[30].ID != "subscription.verify.v1" { + result.Capabilities[31].ID != "subscription.verify.v1" { t.Fatalf("unexpected capabilities: %#v", result.Capabilities) } if len(result.Packs) != 7 || @@ -66,7 +66,7 @@ func TestCapabilitiesJSON(t *testing.T) { result.Packs[6].ID != "subscription" { t.Fatalf("unexpected packs: %#v", result.Packs) } - if len(result.Journeys) != 30 || result.Journeys[28] != "subscription.enable" || result.Journeys[29] != "subscription.verify" { + if len(result.Journeys) != 31 || result.Journeys[29] != "subscription.enable" || result.Journeys[30] != "subscription.verify" { t.Fatalf("unexpected journeys: %#v", result.Journeys) } } @@ -88,8 +88,8 @@ func TestAgentCapabilitiesPreservesCapabilityContract(t *testing.T) { ) if exit != 0 || result.SchemaVersion != "1.0" || - len(result.Capabilities) != 31 || - len(result.Journeys) != 30 { + len(result.Capabilities) != 32 || + len(result.Journeys) != 31 { t.Fatalf("exit = %d, result = %#v", exit, result) } } diff --git a/packs/gopaytokenization/journey.go b/packs/gopaytokenization/journey.go index 52f6832..17b0309 100644 --- a/packs/gopaytokenization/journey.go +++ b/packs/gopaytokenization/journey.go @@ -10,6 +10,7 @@ import ( "time" "github.com/veritrans/midtrans-cli/internal/contracts" + "github.com/veritrans/midtrans-cli/internal/evidence" journey "github.com/veritrans/midtrans-cli/internal/journey" "github.com/veritrans/midtrans-cli/internal/manifest" "github.com/veritrans/midtrans-cli/internal/operations" @@ -33,6 +34,9 @@ func NewAccountLinkingHandler() Handler { func NewBindingInquiryHandler() Handler { return newHandler("gopay-tokenization.binding-inquiry", "binding-inquiry") } +func NewRecurringHandler() Handler { + return newHandler("gopay-tokenization.recurring", "recurring") +} func NewWalletPaymentHandler() Handler { return newHandler("gopay-tokenization.wallet-payment", "wallet-payment") } @@ -131,6 +135,11 @@ func (h Handler) run(ctx context.Context, request journey.Request, runtime journ return h.runAccountLinking(ctx, request, runner, integration, record) case "gopay-tokenization.binding-inquiry": return h.runBindingInquiry(ctx, request, runner) + case "gopay-tokenization.recurring": + if request.Input.Amount <= 0 { + return inputRequired("a positive amount is required") + } + return h.runRecurringVerify(ctx, request, runner, integration) case "gopay-tokenization.wallet-payment": if request.Input.Amount <= 0 { return inputRequired("a positive amount is required") @@ -313,6 +322,74 @@ func (h Handler) runTokenizedPayment(ctx context.Context, request journey.Reques } } +func (h Handler) runRecurringVerify( + ctx context.Context, + request journey.Request, + runner JourneyRunner, + integration manifest.Integration, +) journey.Outcome { + optionType, ok := recurringOptionType(request.Input.Method) + if !ok { + return inputRequired("method must select gopay or gopaylater") + } + accessToken, customerToken, outcome := resolveCustomerToken(ctx, request, runner) + if outcome != nil { + return *outcome + } + inquiry, err := runner.Client.Inquiry(ctx, InquiryRequestInput{ + AccessToken: accessToken, + CustomerToken: customerToken, + }) + if err != nil { + return blockedOutcome("binding inquiry failed") + } + paymentOption, ok := activePaymentOption(inquiry.AdditionalInfo.PaymentOptions, optionType) + if !ok { + return blockedOutcome("the requested payment option is not active") + } + rotatedToken := strings.TrimSpace(inquiry.AdditionalInfo.AccessToken) + if rotatedToken == "" { + return blockedOutcome("binding inquiry did not return a rotated customer token") + } + base := journey.Outcome{ + State: journey.Reconciling, + SafeData: map[string]any{ + "order_id": request.Input.OrderID, + "gross_amount": strconv.FormatInt(request.Input.Amount, 10), + "payment_option": optionType, + }, + MissingEvidence: []string{ + "gopay-tokenization.recurring.scheduler-attempt", + "gopay-tokenization.recurring.binding-inquiry", + "gopay-tokenization.recurring.notification", + "gopay-tokenization.recurring.merchant-persistence", + }, + Finding: &contracts.Finding{ + Code: "GOPAY_RECURRING_EVIDENCE_REQUIRED", + Severity: "blocking", + Message: "recurring GoPay verification requires scheduler, fresh inquiry, notification, and merchant persistence proof", + }, + } + proofs, providerReference, passed := validatedRecurringProofs( + request, + integration, + optionType, + rotatedToken, + paymentOption.Token, + ) + if providerReference != "" { + base.SafeData["provider_reference"] = providerReference + } + if !passed { + return base + } + base.State = journey.Passed + base.Proofs = proofs + base.MissingEvidence = nil + base.Finding = nil + return base +} + func (h Handler) runUnlink(ctx context.Context, request journey.Request, runner JourneyRunner) journey.Outcome { accessToken, customerToken, outcome := resolveCustomerToken(ctx, request, runner) if outcome != nil { @@ -458,6 +535,17 @@ func activePaymentOption(options []PaymentOption, optionType string) (PaymentOpt return PaymentOption{}, false } +func recurringOptionType(method string) (string, bool) { + switch strings.ToLower(strings.TrimSpace(method)) { + case "gopay", "gopay_wallet", "wallet": + return "GOPAY_WALLET", true + case "gopaylater", "paylater", "pay_later": + return "PAY_LATER", true + default: + return "", false + } +} + func computeStateHash(values ...string) string { hash := sha256.Sum256([]byte(strings.Join(values, "|"))) return hex.EncodeToString(hash[:]) @@ -576,6 +664,120 @@ func summaryString(summary map[string]any, key string) string { return value } +func validatedRecurringProofs( + request journey.Request, + integration manifest.Integration, + optionType, rotatedToken, optionToken string, +) ([]evidence.Proof, string, bool) { + bundle := request.Evidence + if bundle.SchemaVersion == "" { + return nil, "", false + } + if err := evidence.Validate(bundle); err != nil { + return nil, "", false + } + providerReference := bundle.SafeReferences["provider_transaction_id"] + if bundle.Environment != "sandbox" || + bundle.ManifestVersion != 1 || + bundle.ManifestHash != request.ManifestHash || + bundle.PackID != "gopay-tokenization" || + bundle.Journey != "gopay-tokenization.recurring" || + bundle.SafeReferences["order_id"] != request.Input.OrderID || + providerReference == "" { + return nil, "", false + } + expectedReferenceHash := sha256Hex(request.Input.PaymentTokenReference) + expectedRotatedHash := sha256Hex(rotatedToken) + expectedOptionHash := sha256Hex(optionToken) + expectedRoute := integration.Callbacks["payment"] + if expectedRoute == "" { + return nil, "", false + } + var schedulerProof *evidence.Proof + var inquiryProof *evidence.Proof + var notificationProof *evidence.Proof + var persistenceProof *evidence.Proof + for _, proof := range bundle.Proofs { + if proof.OperationID != request.OperationID { + continue + } + switch proof.ID { + case "gopay-tokenization.recurring.scheduler-attempt": + if proof.Level == evidence.ProofLocal && + proof.Status == "pass" && + proof.Stage == "merchant_scheduler_attempt" && + proof.Source == "merchant_application" && + summaryString(proof.Summary, "order_id") == request.Input.OrderID && + summaryString(proof.Summary, "gross_amount") == strconv.FormatInt(request.Input.Amount, 10) && + summaryString(proof.Summary, "customer_token_reference_hash") == expectedReferenceHash && + summaryString(proof.Summary, "payment_option_type") == optionType && + summaryString(proof.Summary, "scheduler_state") == "attempted" { + proofCopy := proof + schedulerProof = &proofCopy + } else { + return nil, providerReference, false + } + case "gopay-tokenization.recurring.binding-inquiry": + if proof.Level == evidence.ProofSandbox && + proof.Status == "pass" && + proof.Stage == "provider_binding_inquiry" && + proof.Source == "midtrans_api" && + summaryString(proof.Summary, "order_id") == request.Input.OrderID && + summaryString(proof.Summary, "customer_token_reference_hash") == expectedReferenceHash && + summaryString(proof.Summary, "rotated_token_hash") == expectedRotatedHash && + summaryString(proof.Summary, "payment_option_hash") == expectedOptionHash && + summaryString(proof.Summary, "payment_option_type") == optionType { + proofCopy := proof + inquiryProof = &proofCopy + } else { + return nil, providerReference, false + } + case "gopay-tokenization.recurring.notification": + if proof.Level == evidence.ProofSandbox && + proof.Status == "pass" && + proof.Stage == "provider_notification" && + proof.Source == "midtrans_notification" && + summaryString(proof.Summary, "order_id") == request.Input.OrderID && + summaryString(proof.Summary, "provider_reference") == providerReference && + summaryString(proof.Summary, "route") == expectedRoute && + summaryString(proof.Summary, "payment_option_type") == optionType && + summaryString(proof.Summary, "customer_token_reference_hash") == expectedReferenceHash { + proofCopy := proof + notificationProof = &proofCopy + } else { + return nil, providerReference, false + } + case "gopay-tokenization.recurring.merchant-persistence": + if proof.Level == evidence.ProofLocal && + proof.Status == "pass" && + proof.Stage == "merchant_persistence" && + proof.Source == "merchant_application" && + summaryString(proof.Summary, "order_id") == request.Input.OrderID && + summaryString(proof.Summary, "provider_reference") == providerReference && + summaryString(proof.Summary, "payment_status") == "paid" && + summaryString(proof.Summary, "dunning_outcome") != "" && + summaryString(proof.Summary, "customer_token_reference_hash") == expectedReferenceHash && + summaryString(proof.Summary, "rotated_token_hash") == expectedRotatedHash && + summaryString(proof.Summary, "payment_option_hash") == expectedOptionHash && + summaryString(proof.Summary, "payment_option_type") == optionType { + proofCopy := proof + persistenceProof = &proofCopy + } else { + return nil, providerReference, false + } + } + } + if schedulerProof == nil || inquiryProof == nil || notificationProof == nil || persistenceProof == nil { + return nil, providerReference, false + } + return []evidence.Proof{*schedulerProof, *inquiryProof, *notificationProof, *persistenceProof}, providerReference, true +} + +func sha256Hex(value string) string { + sum := sha256.Sum256([]byte(value)) + return hex.EncodeToString(sum[:]) +} + func blockedFinding(code, message string) journey.Outcome { return journey.Outcome{ State: journey.Blocked, diff --git a/packs/gopaytokenization/journey_test.go b/packs/gopaytokenization/journey_test.go index ced97a1..00fff1b 100644 --- a/packs/gopaytokenization/journey_test.go +++ b/packs/gopaytokenization/journey_test.go @@ -8,6 +8,7 @@ import ( "net/http" "strings" "testing" + "time" "github.com/veritrans/midtrans-cli/internal/evidence" journeypkg "github.com/veritrans/midtrans-cli/internal/journey" @@ -271,6 +272,160 @@ func TestPayLaterRequiresActivePayLaterOption(t *testing.T) { } } +func TestRecurringJourneyRunsFreshInquiryAndRequiresBoundProofs(t *testing.T) { + var paths []string + var customerHeaders []string + + handler := gopaytokenization.NewRecurringHandler() + request := gopayRequest("gopay-tokenization.recurring", "recurring-order", 45000, "gopay") + request.Input.PaymentTokenReference = "env:MIDTRANS_GOPAY_CUSTOMER_TOKEN" + outcome := handler.Execute(context.Background(), request, journeypkg.Runtime{ + ResolveCredential: gopayResolveCredentialWithAuthToken(t), + Now: fixedNow, + HTTP: appDoerFunc(func(request *http.Request) (*http.Response, error) { + paths = append(paths, request.URL.Path) + customerHeaders = append(customerHeaders, request.Header.Get("Authorization-Customer")) + switch request.URL.Path { + case "/v1.0/access-token/b2b": + return gopayResponse(http.StatusOK, `{"accessToken":"ACCESS-TOKEN-CANARY-DO-NOT-PRINT"}`), nil + case "/v1.0/registration-account-inquiry": + return gopayResponse(http.StatusOK, `{ + "responseCode":"2008800", + "additionalInfo":{"accessToken":"ROTATED-CUSTOMER-TOKEN-CANARY-DO-NOT-PRINT","paymentOptions":[ + {"name":"PAY_LATER","token":"PAYLATER-TOKEN-CANARY-DO-NOT-PRINT","active":false}, + {"name":"GOPAY_WALLET","token":"WALLET-TOKEN-CANARY-DO-NOT-PRINT","active":true} + ]} + }`), nil + default: + t.Fatalf("unexpected path %q", request.URL.Path) + return nil, nil + } + }), + }) + + if outcome.State == journeypkg.Passed { + t.Fatalf("outcome = %#v", outcome) + } + if len(paths) != 2 || paths[0] != "/v1.0/access-token/b2b" || paths[1] != "/v1.0/registration-account-inquiry" { + t.Fatalf("paths = %#v", paths) + } + if customerHeaders[1] != "Bearer CUSTOMER-TOKEN-CANARY-DO-NOT-PRINT" { + t.Fatalf("customer headers = %#v", customerHeaders) + } + if len(outcome.MissingEvidence) != 4 { + t.Fatalf("missing evidence = %#v", outcome.MissingEvidence) + } + for _, key := range []string{ + "customer_authorization_token", + "payment_option_token", + "rotated_token_hash", + "payment_token_reference", + "customer_token_reference", + } { + if _, ok := outcome.SafeData[key]; ok { + t.Fatalf("safe data leaked %q: %#v", key, outcome.SafeData) + } + } +} + +func TestRecurringJourneyPassesWithBoundInquiryAndNotificationProofs(t *testing.T) { + handler := gopaytokenization.NewRecurringHandler() + request := gopayRequest("gopay-tokenization.recurring", "recurring-order", 45000, "gopay") + request.Input.PaymentTokenReference = "env:MIDTRANS_GOPAY_CUSTOMER_TOKEN" + request.Evidence = verifiedRecurringGoPayEvidenceBundle( + "gopay-tokenization.recurring", + request.ManifestHash, + "operation-recurring", + "env:MIDTRANS_GOPAY_CUSTOMER_TOKEN", + "ROTATED-CUSTOMER-TOKEN-CANARY-DO-NOT-PRINT", + "WALLET-TOKEN-CANARY-DO-NOT-PRINT", + "GOPAY_WALLET", + "provider-recurring-001", + "/api/payments/midtrans/gopay/payment", + "collected", + ) + request.OperationID = "operation-recurring" + + outcome := handler.Execute(context.Background(), request, journeypkg.Runtime{ + ResolveCredential: gopayResolveCredentialWithAuthToken(t), + Now: fixedNow, + HTTP: appDoerFunc(func(request *http.Request) (*http.Response, error) { + switch request.URL.Path { + case "/v1.0/access-token/b2b": + return gopayResponse(http.StatusOK, `{"accessToken":"ACCESS-TOKEN-CANARY-DO-NOT-PRINT"}`), nil + case "/v1.0/registration-account-inquiry": + return gopayResponse(http.StatusOK, `{ + "responseCode":"2008800", + "additionalInfo":{"accessToken":"ROTATED-CUSTOMER-TOKEN-CANARY-DO-NOT-PRINT","paymentOptions":[ + {"name":"PAY_LATER","token":"PAYLATER-TOKEN-CANARY-DO-NOT-PRINT","active":false}, + {"name":"GOPAY_WALLET","token":"WALLET-TOKEN-CANARY-DO-NOT-PRINT","active":true} + ]} + }`), nil + default: + t.Fatalf("unexpected path %q", request.URL.Path) + return nil, nil + } + }), + }) + if outcome.State != journeypkg.Passed { + t.Fatalf("outcome = %#v", outcome) + } + for _, key := range []string{ + "customer_authorization_token", + "payment_option_token", + "rotated_token_hash", + "payment_token_reference", + "customer_token_reference", + } { + if _, ok := outcome.SafeData[key]; ok { + t.Fatalf("safe data leaked %q: %#v", key, outcome.SafeData) + } + } +} + +func TestRecurringJourneyRejectsInquiryHashMismatch(t *testing.T) { + handler := gopaytokenization.NewRecurringHandler() + request := gopayRequest("gopay-tokenization.recurring", "recurring-order", 45000, "gopay") + request.Input.PaymentTokenReference = "env:MIDTRANS_GOPAY_CUSTOMER_TOKEN" + request.Evidence = verifiedRecurringGoPayEvidenceBundle( + "gopay-tokenization.recurring", + request.ManifestHash, + "operation-recurring", + "env:MIDTRANS_GOPAY_CUSTOMER_TOKEN", + "ROTATED-OTHER-TOKEN", + "WALLET-TOKEN-CANARY-DO-NOT-PRINT", + "GOPAY_WALLET", + "provider-recurring-001", + "/api/payments/midtrans/gopay/payment", + "collected", + ) + request.OperationID = "operation-recurring" + + outcome := handler.Execute(context.Background(), request, journeypkg.Runtime{ + ResolveCredential: gopayResolveCredentialWithAuthToken(t), + Now: fixedNow, + HTTP: appDoerFunc(func(request *http.Request) (*http.Response, error) { + switch request.URL.Path { + case "/v1.0/access-token/b2b": + return gopayResponse(http.StatusOK, `{"accessToken":"ACCESS-TOKEN-CANARY-DO-NOT-PRINT"}`), nil + case "/v1.0/registration-account-inquiry": + return gopayResponse(http.StatusOK, `{ + "responseCode":"2008800", + "additionalInfo":{"accessToken":"ROTATED-CUSTOMER-TOKEN-CANARY-DO-NOT-PRINT","paymentOptions":[ + {"name":"GOPAY_WALLET","token":"WALLET-TOKEN-CANARY-DO-NOT-PRINT","active":true} + ]} + }`), nil + default: + t.Fatalf("unexpected path %q", request.URL.Path) + return nil, nil + } + }), + }) + if outcome.State == journeypkg.Passed { + t.Fatalf("outcome = %#v", outcome) + } +} + func TestUnlinkFallsBackToInquiryAfterAmbiguousUnbind(t *testing.T) { handler := gopaytokenization.NewUnlinkHandler() request := gopayRequest("gopay-tokenization.unlink", "unlink-order", 0, "") @@ -404,3 +559,95 @@ type appDoerFunc func(*http.Request) (*http.Response, error) func (f appDoerFunc) Do(request *http.Request) (*http.Response, error) { return f(request) } + +func verifiedRecurringGoPayEvidenceBundle( + journeyID, manifestHash, operationID, customerTokenReference, rotatedToken, optionToken, optionType, providerReference, route, dunningOutcome string, +) evidence.Bundle { + now := fixedNow() + return evidence.Bundle{ + SchemaVersion: evidence.SchemaVersion, + CLIVersion: "0.1.0-test", + ManifestVersion: 1, + PackID: "gopay-tokenization", + PackVersion: "0.1.0", + ManifestHash: manifestHash, + RepositoryCommit: strings.Repeat("a", 40), + Journey: journeyID, + Environment: "sandbox", + StartedAt: now.Add(-time.Second), + CompletedAt: now, + SafeReferences: map[string]string{ + "order_id": "recurring-order", + "provider_transaction_id": providerReference, + }, + Proofs: []evidence.Proof{ + { + ID: "gopay-tokenization.recurring.scheduler-attempt", + OperationID: operationID, + Stage: "merchant_scheduler_attempt", + Level: evidence.ProofLocal, + Source: "merchant_application", + ObservedAt: now, + Status: "pass", + Summary: map[string]any{ + "order_id": "recurring-order", + "gross_amount": "45000", + "customer_token_reference_hash": sha256Hex(customerTokenReference), + "payment_option_type": optionType, + "scheduler_state": "attempted", + }, + }, + { + ID: "gopay-tokenization.recurring.binding-inquiry", + OperationID: operationID, + Stage: "provider_binding_inquiry", + Level: evidence.ProofSandbox, + Source: "midtrans_api", + ObservedAt: now, + Status: "pass", + Summary: map[string]any{ + "order_id": "recurring-order", + "customer_token_reference_hash": sha256Hex(customerTokenReference), + "rotated_token_hash": sha256Hex(rotatedToken), + "payment_option_hash": sha256Hex(optionToken), + "payment_option_type": optionType, + }, + }, + { + ID: "gopay-tokenization.recurring.notification", + OperationID: operationID, + Stage: "provider_notification", + Level: evidence.ProofSandbox, + Source: "midtrans_notification", + ObservedAt: now, + Status: "pass", + Summary: map[string]any{ + "order_id": "recurring-order", + "provider_reference": providerReference, + "route": route, + "payment_option_type": optionType, + "customer_token_reference_hash": sha256Hex(customerTokenReference), + }, + }, + { + ID: "gopay-tokenization.recurring.merchant-persistence", + OperationID: operationID, + Stage: "merchant_persistence", + Level: evidence.ProofLocal, + Source: "merchant_application", + ObservedAt: now, + Status: "pass", + Summary: map[string]any{ + "order_id": "recurring-order", + "provider_reference": providerReference, + "payment_status": "paid", + "dunning_outcome": dunningOutcome, + "customer_token_reference_hash": sha256Hex(customerTokenReference), + "rotated_token_hash": sha256Hex(rotatedToken), + "payment_option_hash": sha256Hex(optionToken), + "payment_option_type": optionType, + }, + }, + }, + } +} diff --git a/packs/gopaytokenization/pack.go b/packs/gopaytokenization/pack.go index 7575c65..2883972 100644 --- a/packs/gopaytokenization/pack.go +++ b/packs/gopaytokenization/pack.go @@ -19,6 +19,7 @@ func (Pack) Descriptor() packs.Descriptor { Capabilities: []contracts.Capability{ {ID: "gopay-tokenization.account-linking.verify.v1", Description: "run and verify a GoPay account-linking journey", Pack: "gopay-tokenization"}, {ID: "gopay-tokenization.binding-inquiry.verify.v1", Description: "run and verify a GoPay binding-inquiry journey", Pack: "gopay-tokenization"}, + {ID: "gopay-tokenization.recurring.verify.v1", Description: "verify a merchant-driven GoPay recurring charge journey", Pack: "gopay-tokenization"}, {ID: "gopay-tokenization.paylater.verify.v1", Description: "run and verify a GoPayLater tokenized payment journey", Pack: "gopay-tokenization"}, {ID: "gopay-tokenization.unlink.verify.v1", Description: "run and verify a GoPay unlink journey", Pack: "gopay-tokenization"}, {ID: "gopay-tokenization.wallet-payment.verify.v1", Description: "run and verify a tokenized GoPay wallet-payment journey", Pack: "gopay-tokenization"}, @@ -26,6 +27,7 @@ func (Pack) Descriptor() packs.Descriptor { Journeys: []string{ "gopay-tokenization.account-linking", "gopay-tokenization.binding-inquiry", + "gopay-tokenization.recurring", "gopay-tokenization.wallet-payment", "gopay-tokenization.paylater", "gopay-tokenization.unlink", @@ -48,10 +50,10 @@ func (Pack) Descriptor() packs.Descriptor { Sources: []contracts.PublicSource{ {ID: "gopay-tokenization-get-auth-code", URL: "https://docs.midtrans.com/reference/get-auth-code-api", Rules: []string{"gopaytokenization.linking.get-auth-code"}}, {ID: "gopay-tokenization-binding-api", URL: "https://docs.midtrans.com/reference/binding-api", Rules: []string{"gopaytokenization.linking.bind"}}, - {ID: "gopay-tokenization-binding-inquiry-api", URL: "https://docs.midtrans.com/reference/binding-inquiry-api", Rules: []string{"gopaytokenization.linking.inquiry"}}, - {ID: "gopay-tokenization-direct-debit", URL: "https://docs.midtrans.com/reference/direct-debit-api-gopay-tokenization", Rules: []string{"gopaytokenization.wallet.charge", "gopaytokenization.paylater.charge"}}, + {ID: "gopay-tokenization-binding-inquiry-api", URL: "https://docs.midtrans.com/reference/binding-inquiry-api", Rules: []string{"gopaytokenization.linking.inquiry", "gopaytokenization.recurring.inquiry"}}, + {ID: "gopay-tokenization-direct-debit", URL: "https://docs.midtrans.com/reference/direct-debit-api-gopay-tokenization", Rules: []string{"gopaytokenization.wallet.charge", "gopaytokenization.paylater.charge", "gopaytokenization.recurring.option-selection"}}, {ID: "gopay-tokenization-unbind", URL: "https://docs.midtrans.com/reference/unbind-api", Rules: []string{"gopaytokenization.unlink"}}, - {ID: "gopay-tokenization-account-linking-unlinking-notification", URL: "https://docs.midtrans.com/reference/account-linking-unlinking-notification", Rules: []string{"gopaytokenization.notification.signature", "common.webhook-idempotency"}}, + {ID: "gopay-tokenization-account-linking-unlinking-notification", URL: "https://docs.midtrans.com/reference/account-linking-unlinking-notification", Rules: []string{"gopaytokenization.notification.signature", "gopaytokenization.recurring.notification", "common.webhook-idempotency"}}, }, } } @@ -86,6 +88,7 @@ func (Pack) Handlers() []journey.Handler { return []journey.Handler{ NewAccountLinkingHandler(), NewBindingInquiryHandler(), + NewRecurringHandler(), NewWalletPaymentHandler(), NewPayLaterHandler(), NewUnlinkHandler(), diff --git a/packs/gopaytokenization/pack_test.go b/packs/gopaytokenization/pack_test.go index 4dd6411..84cd196 100644 --- a/packs/gopaytokenization/pack_test.go +++ b/packs/gopaytokenization/pack_test.go @@ -12,6 +12,7 @@ func TestPackDescriptorPublishesGoPayTokenizationJourneysAndCapabilities(t *test wantCapabilities := []string{ "gopay-tokenization.account-linking.verify.v1", "gopay-tokenization.binding-inquiry.verify.v1", + "gopay-tokenization.recurring.verify.v1", "gopay-tokenization.paylater.verify.v1", "gopay-tokenization.unlink.verify.v1", "gopay-tokenization.wallet-payment.verify.v1", @@ -26,6 +27,7 @@ func TestPackDescriptorPublishesGoPayTokenizationJourneysAndCapabilities(t *test wantJourneys := []string{ "gopay-tokenization.account-linking", "gopay-tokenization.binding-inquiry", + "gopay-tokenization.recurring", "gopay-tokenization.wallet-payment", "gopay-tokenization.paylater", "gopay-tokenization.unlink", From 5b7c12d626268868167ec0aa7b2c2eaaa69a3dc4 Mon Sep 17 00:00:00 2001 From: "m.salis" Date: Mon, 27 Jul 2026 15:46:06 +0700 Subject: [PATCH 57/73] fix: align subscription lifecycle contracts --- .../task-11-report.md | 55 +++++ packs/subscription/client.go | 205 +++++++++++------- packs/subscription/client_test.go | 154 +++++++++---- packs/subscription/journey.go | 179 +++++++++------ packs/subscription/journey_test.go | 124 ++++++++++- 5 files changed, 521 insertions(+), 196 deletions(-) diff --git a/.superpowers/sdd/2026-07-26-midtrans-cli-multi-product-parity/task-11-report.md b/.superpowers/sdd/2026-07-26-midtrans-cli-multi-product-parity/task-11-report.md index 4b3cde8..8647383 100644 --- a/.superpowers/sdd/2026-07-26-midtrans-cli-multi-product-parity/task-11-report.md +++ b/.superpowers/sdd/2026-07-26-midtrans-cli-multi-product-parity/task-11-report.md @@ -250,3 +250,58 @@ Commit: ```text feat: add GoPay recurring verification ``` + +## Fix Round 1: Classic subscription lifecycle alignment + +This fix round applies only to the classic Subscription API lifecycle slice on +top of commit `03668bf`. + +What changed: + +- Split the subscription client request/response contracts into: + - `CreateRequest` + - `UpdateRequest` + - `MutationRequest` + - `AcknowledgementResponse` +- Changed update/disable/enable/cancel to decode the documented acknowledgement + response shape `{ "status_message": ... }` instead of treating those + endpoints as full subscription reads. +- Required a post-mutation `GET /v1/subscriptions/{id}` after every successful: + - update + - disable + - enable + - cancel +- Update and lifecycle journeys now pass only after the follow-up GET verifies + the intended provider state/details. +- Kept the no-blind-retry rule for ambiguous mutation transports: + mutations reconcile with GET and never auto-repeat the write. +- Serialized create `amount` in the documented string form. +- Split create/update payload construction: + - create still sends the documented creation fields + - update now sends only selected mutable fields from typed input +- Tightened PATCH body shape so update does not resend create-only fields such + as `token`, `payment_type`, `currency`, or `schedule`. +- Preserved safe persistence and production protections. + +Tests added or tightened: + +- Realistic acknowledgement fixtures for update/disable/enable/cancel. +- PATCH payload tests proving create-only fields are absent. +- Update validation test requiring at least one mutable field. +- Journey tests asserting post-mutation GET verification for update/disable and + reconciliation instead of blind retry for ambiguous mutation outcomes. + +Validation: + +```sh +go test ./packs/subscription ./internal/app -count=1 +go test ./... -count=1 +``` + +Result: passed on Monday, July 27, 2026. + +Commit: + +```text +fix: align subscription lifecycle contracts +``` diff --git a/packs/subscription/client.go b/packs/subscription/client.go index 09de410..462ed57 100644 --- a/packs/subscription/client.go +++ b/packs/subscription/client.go @@ -32,13 +32,19 @@ type Schedule struct { Start string `json:"start_time,omitempty"` } -type UpsertRequest struct { +type CreateRequest struct { + OperationID string + Name string + Amount string + Token string + Schedule Schedule +} + +type UpdateRequest struct { OperationID string SubscriptionID string Name string - Amount int64 - Token string - Schedule Schedule + Amount string } type SubscriptionResponse struct { @@ -51,25 +57,64 @@ type SubscriptionResponse struct { NotFound bool `json:"not_found,omitempty"` } +type AcknowledgementResponse struct { + StatusMessage string `json:"status_message"` +} + type MutationRequest struct { OperationID string SubscriptionID string } -func (c Client) Create(ctx context.Context, input UpsertRequest) (SubscriptionResponse, error) { - return c.upsert(ctx, http.MethodPost, subscriptionSandboxURL, "subscription.create", input) +func (c Client) Create(ctx context.Context, input CreateRequest) (SubscriptionResponse, error) { + if c.HTTP == nil || strings.TrimSpace(input.OperationID) == "" || strings.TrimSpace(input.Name) == "" || + strings.TrimSpace(input.Amount) == "" || strings.TrimSpace(input.Token) == "" || + input.Schedule.Interval <= 0 || strings.TrimSpace(input.Schedule.Unit) == "" || strings.TrimSpace(input.Schedule.Start) == "" { + return SubscriptionResponse{}, errors.New("SANDBOX_REQUEST_INVALID") + } + payload, err := json.Marshal(map[string]any{ + "name": input.Name, + "amount": input.Amount, + "currency": "IDR", + "payment_type": "credit_card", + "token": input.Token, + "schedule": map[string]any{ + "interval": input.Schedule.Interval, + "interval_unit": input.Schedule.Unit, + "start_time": input.Schedule.Start, + }, + }) + if err != nil { + return SubscriptionResponse{}, errors.New("SANDBOX_REQUEST_INVALID") + } + return c.sendSubscriptionRequest(ctx, http.MethodPost, subscriptionSandboxURL, "subscription.create", input.OperationID, "", payload) } -func (c Client) Update(ctx context.Context, input UpsertRequest) (SubscriptionResponse, error) { - if strings.TrimSpace(input.SubscriptionID) == "" { - return SubscriptionResponse{}, errors.New("SANDBOX_REQUEST_INVALID") +func (c Client) Update(ctx context.Context, input UpdateRequest) (AcknowledgementResponse, error) { + if c.HTTP == nil || strings.TrimSpace(input.OperationID) == "" || strings.TrimSpace(input.SubscriptionID) == "" { + return AcknowledgementResponse{}, errors.New("SANDBOX_REQUEST_INVALID") + } + payload := map[string]any{} + if strings.TrimSpace(input.Name) != "" { + payload["name"] = input.Name } - return c.upsert( + if strings.TrimSpace(input.Amount) != "" { + payload["amount"] = input.Amount + } + if len(payload) == 0 { + return AcknowledgementResponse{}, errors.New("SANDBOX_REQUEST_INVALID") + } + encoded, err := json.Marshal(payload) + if err != nil { + return AcknowledgementResponse{}, errors.New("SANDBOX_REQUEST_INVALID") + } + return c.sendAcknowledgementRequest( ctx, http.MethodPatch, subscriptionSandboxURL+"/"+url.PathEscape(strings.TrimSpace(input.SubscriptionID)), "subscription.update", - input, + input.OperationID, + encoded, ) } @@ -77,112 +122,112 @@ func (c Client) Get(ctx context.Context, subscriptionID string) (SubscriptionRes if c.HTTP == nil || strings.TrimSpace(subscriptionID) == "" { return SubscriptionResponse{}, errors.New("SANDBOX_REQUEST_INVALID") } - serverKey, err := c.ServerKey.SandboxServerKey() + response, err := c.send(ctx, http.MethodGet, subscriptionSandboxURL+"/"+url.PathEscape(strings.TrimSpace(subscriptionID)), nil) if err != nil { return SubscriptionResponse{}, err } - request, err := http.NewRequestWithContext( - ctx, - http.MethodGet, - subscriptionSandboxURL+"/"+url.PathEscape(strings.TrimSpace(subscriptionID)), - nil, - ) - if err != nil { - return SubscriptionResponse{}, errors.New("SANDBOX_REQUEST_INVALID") - } - request.SetBasicAuth(serverKey, "") - response, err := c.HTTP.Do(request) - if err != nil { - return SubscriptionResponse{}, errors.New("sandbox request transport failed") - } return decodeSubscriptionResponse(response, subscriptionID, "subscription.get") } -func (c Client) Disable(ctx context.Context, input MutationRequest) (SubscriptionResponse, error) { +func (c Client) Disable(ctx context.Context, input MutationRequest) (AcknowledgementResponse, error) { return c.mutate(ctx, "disable", "subscription.disable", input) } -func (c Client) Enable(ctx context.Context, input MutationRequest) (SubscriptionResponse, error) { +func (c Client) Enable(ctx context.Context, input MutationRequest) (AcknowledgementResponse, error) { return c.mutate(ctx, "enable", "subscription.enable", input) } -func (c Client) Cancel(ctx context.Context, input MutationRequest) (SubscriptionResponse, error) { +func (c Client) Cancel(ctx context.Context, input MutationRequest) (AcknowledgementResponse, error) { return c.mutate(ctx, "cancel", "subscription.cancel", input) } -func (c Client) upsert(ctx context.Context, method, requestURL, operation string, input UpsertRequest) (SubscriptionResponse, error) { - if c.HTTP == nil || strings.TrimSpace(input.OperationID) == "" || strings.TrimSpace(input.Name) == "" || - input.Amount <= 0 || strings.TrimSpace(input.Token) == "" || input.Schedule.Interval <= 0 || - strings.TrimSpace(input.Schedule.Unit) == "" || strings.TrimSpace(input.Schedule.Start) == "" { - return SubscriptionResponse{}, errors.New("SANDBOX_REQUEST_INVALID") +func (c Client) mutate(ctx context.Context, action, operation string, input MutationRequest) (AcknowledgementResponse, error) { + if c.HTTP == nil || strings.TrimSpace(input.OperationID) == "" || strings.TrimSpace(input.SubscriptionID) == "" { + return AcknowledgementResponse{}, errors.New("SANDBOX_REQUEST_INVALID") } - serverKey, err := c.ServerKey.SandboxServerKey() + return c.sendAcknowledgementRequest( + ctx, + http.MethodPost, + subscriptionSandboxURL+"/"+url.PathEscape(strings.TrimSpace(input.SubscriptionID))+"/"+action, + operation, + input.OperationID, + nil, + ) +} + +func (c Client) sendSubscriptionRequest(ctx context.Context, method, requestURL, operation, operationID, subscriptionID string, payload []byte) (SubscriptionResponse, error) { + response, err := c.sendWithAmbiguous(ctx, method, requestURL, operationID, payload) if err != nil { return SubscriptionResponse{}, err } - payload, err := json.Marshal(map[string]any{ - "name": input.Name, - "amount": input.Amount, - "currency": "IDR", - "payment_type": "credit_card", - "token": input.Token, - "schedule": map[string]any{ - "interval": input.Schedule.Interval, - "interval_unit": input.Schedule.Unit, - "start_time": input.Schedule.Start, - }, - }) - if err != nil { - return SubscriptionResponse{}, errors.New("SANDBOX_REQUEST_INVALID") - } - request, err := http.NewRequestWithContext(ctx, method, requestURL, bytes.NewReader(payload)) + return decodeSubscriptionResponse(response, subscriptionID, operation) +} + +func (c Client) sendAcknowledgementRequest(ctx context.Context, method, requestURL, operation, operationID string, payload []byte) (AcknowledgementResponse, error) { + response, err := c.sendWithAmbiguous(ctx, method, requestURL, operationID, payload) if err != nil { - return SubscriptionResponse{}, errors.New("SANDBOX_REQUEST_INVALID") + return AcknowledgementResponse{}, err } - request.Header.Set("Content-Type", "application/json") - request.SetBasicAuth(serverKey, "") - response, err := c.HTTP.Do(request) + return decodeAcknowledgementResponse(response, operation) +} + +func (c Client) sendWithAmbiguous(ctx context.Context, method, requestURL, operationID string, payload []byte) (*http.Response, error) { + response, err := c.send(ctx, method, requestURL, payload) if err != nil { if isTimeoutError(err) { - return SubscriptionResponse{}, sandbox.AmbiguousOperationError{ - OperationID: input.OperationID, + return nil, sandbox.AmbiguousOperationError{ + OperationID: operationID, Cause: errors.New("sandbox request transport failed"), } } - return SubscriptionResponse{}, errors.New("sandbox request transport failed") + return nil, errors.New("sandbox request transport failed") } - return decodeSubscriptionResponse(response, input.SubscriptionID, operation) + return response, nil } -func (c Client) mutate(ctx context.Context, action, operation string, input MutationRequest) (SubscriptionResponse, error) { - if c.HTTP == nil || strings.TrimSpace(input.OperationID) == "" || strings.TrimSpace(input.SubscriptionID) == "" { - return SubscriptionResponse{}, errors.New("SANDBOX_REQUEST_INVALID") - } +func (c Client) send(ctx context.Context, method, requestURL string, payload []byte) (*http.Response, error) { serverKey, err := c.ServerKey.SandboxServerKey() if err != nil { - return SubscriptionResponse{}, err + return nil, err } - request, err := http.NewRequestWithContext( - ctx, - http.MethodPost, - subscriptionSandboxURL+"/"+url.PathEscape(strings.TrimSpace(input.SubscriptionID))+"/"+action, - http.NoBody, - ) + var body io.Reader + if payload != nil { + body = bytes.NewReader(payload) + } + request, err := http.NewRequestWithContext(ctx, method, requestURL, body) if err != nil { - return SubscriptionResponse{}, errors.New("SANDBOX_REQUEST_INVALID") + return nil, errors.New("SANDBOX_REQUEST_INVALID") + } + if payload != nil { + request.Header.Set("Content-Type", "application/json") } request.SetBasicAuth(serverKey, "") - response, err := c.HTTP.Do(request) - if err != nil { - if isTimeoutError(err) { - return SubscriptionResponse{}, sandbox.AmbiguousOperationError{ - OperationID: input.OperationID, - Cause: errors.New("sandbox request transport failed"), - } + return c.HTTP.Do(request) +} + +func decodeAcknowledgementResponse(response *http.Response, operation string) (AcknowledgementResponse, error) { + if response == nil || response.Body == nil { + return AcknowledgementResponse{}, errors.New("SANDBOX_RESPONSE_INVALID") + } + defer response.Body.Close() + if response.StatusCode >= http.StatusMultipleChoices && response.StatusCode < http.StatusBadRequest { + return AcknowledgementResponse{}, errors.New("SANDBOX_RESPONSE_REDIRECTED") + } + if response.StatusCode < http.StatusOK || response.StatusCode >= http.StatusMultipleChoices { + return AcknowledgementResponse{}, sandbox.ResponseError{ + Operation: operation, + StatusCode: response.StatusCode, } - return SubscriptionResponse{}, errors.New("sandbox request transport failed") } - return decodeSubscriptionResponse(response, input.SubscriptionID, operation) + var result AcknowledgementResponse + if err := decodeBounded(response.Body, &result); err != nil { + return AcknowledgementResponse{}, err + } + if strings.TrimSpace(result.StatusMessage) == "" { + return AcknowledgementResponse{}, errors.New("SANDBOX_RESPONSE_INVALID") + } + result.StatusMessage = strings.TrimSpace(result.StatusMessage) + return result, nil } func decodeSubscriptionResponse(response *http.Response, subscriptionID, operation string) (SubscriptionResponse, error) { diff --git a/packs/subscription/client_test.go b/packs/subscription/client_test.go index bc781fe..073931d 100644 --- a/packs/subscription/client_test.go +++ b/packs/subscription/client_test.go @@ -36,7 +36,7 @@ func (d *subscriptionRecordingDoer) Do(request *http.Request) (*http.Response, e return d.do(request) } -func TestClientCreateUsesFixedSandboxHostBasicAuthAndSchedulePayload(t *testing.T) { +func TestClientCreateUsesFixedSandboxHostBasicAuthAndDocumentedPayload(t *testing.T) { doer := &subscriptionRecordingDoer{ do: func(*http.Request) (*http.Response, error) { return subscriptionResponse(http.StatusCreated, `{ @@ -53,10 +53,10 @@ func TestClientCreateUsesFixedSandboxHostBasicAuthAndSchedulePayload(t *testing. ServerKey: secrets.NewValue(subscriptionServerKeyCanary), } - got, err := client.Create(context.Background(), subscription.UpsertRequest{ + got, err := client.Create(context.Background(), subscription.CreateRequest{ OperationID: "op_subscription_create", Name: "merchant-order-001", - Amount: 15000, + Amount: "15000", Token: "saved-token-123", Schedule: subscription.Schedule{ Interval: 1, @@ -79,7 +79,7 @@ func TestClientCreateUsesFixedSandboxHostBasicAuthAndSchedulePayload(t *testing. } var payload struct { Name string `json:"name"` - Amount int64 `json:"amount"` + Amount string `json:"amount"` Currency string `json:"currency"` PaymentType string `json:"payment_type"` Token string `json:"token"` @@ -92,7 +92,7 @@ func TestClientCreateUsesFixedSandboxHostBasicAuthAndSchedulePayload(t *testing. if err := json.Unmarshal(doer.body, &payload); err != nil { t.Fatal(err) } - if payload.Name != "merchant-order-001" || payload.Amount != 15000 || payload.Currency != "IDR" || + if payload.Name != "merchant-order-001" || payload.Amount != "15000" || payload.Currency != "IDR" || payload.PaymentType != "credit_card" || payload.Token != "saved-token-123" || payload.Schedule.Interval != 1 || payload.Schedule.Unit != "month" || payload.Schedule.Start != "2026-08-01 00:00:00 +0700" { t.Fatalf("payload = %#v", payload) @@ -102,60 +102,124 @@ func TestClientCreateUsesFixedSandboxHostBasicAuthAndSchedulePayload(t *testing. } } -func TestClientUpdateAndMutationsUseExactClassicEndpoints(t *testing.T) { - calls := 0 +func TestClientUpdateUsesExactPatchBodyWithoutCreateOnlyFields(t *testing.T) { doer := &subscriptionRecordingDoer{ - do: func(request *http.Request) (*http.Response, error) { - calls++ - switch calls { - case 1: - if request.Method != http.MethodPatch || request.URL.String() != "https://api.sandbox.midtrans.com/v1/subscriptions/sub-123" { - t.Fatalf("update request = %s %s", request.Method, request.URL.String()) - } - case 2: - if request.Method != http.MethodGet || request.URL.String() != "https://api.sandbox.midtrans.com/v1/subscriptions/sub-123" { - t.Fatalf("get request = %s %s", request.Method, request.URL.String()) - } - case 3: - if request.Method != http.MethodPost || request.URL.String() != "https://api.sandbox.midtrans.com/v1/subscriptions/sub-123/disable" { - t.Fatalf("disable request = %s %s", request.Method, request.URL.String()) - } - } - return subscriptionResponse(http.StatusOK, `{ - "id":"sub-123", - "name":"merchant-order-001", - "status":"inactive", - "amount":"15000", - "schedule":{"interval":1,"interval_unit":"month","start_time":"2026-08-01 00:00:00 +0700"} - }`), nil + do: func(*http.Request) (*http.Response, error) { + return subscriptionResponse(http.StatusOK, `{"status_message":"Subscription is updated."}`), nil }, } client := subscription.Client{ HTTP: doer, ServerKey: secrets.NewValue(subscriptionServerKeyCanary), } - if _, err := client.Update(context.Background(), subscription.UpsertRequest{ + + got, err := client.Update(context.Background(), subscription.UpdateRequest{ OperationID: "op_subscription_update", SubscriptionID: "sub-123", - Name: "merchant-order-001", - Amount: 15000, - Token: "saved-token-123", - Schedule: subscription.Schedule{ - Interval: 1, - Unit: "month", - Start: "2026-08-01 00:00:00 +0700", - }, - }); err != nil { + Name: "merchant-order-002", + Amount: "25000", + }) + if err != nil { t.Fatal(err) } - if _, err := client.Get(context.Background(), "sub-123"); err != nil { + if doer.request.Method != http.MethodPatch || doer.request.URL.String() != "https://api.sandbox.midtrans.com/v1/subscriptions/sub-123" { + t.Fatalf("request = %s %s", doer.request.Method, doer.request.URL.String()) + } + var payload map[string]any + if err := json.Unmarshal(doer.body, &payload); err != nil { t.Fatal(err) } - if _, err := client.Disable(context.Background(), subscription.MutationRequest{ - OperationID: "op_subscription_disable", + if payload["name"] != "merchant-order-002" || payload["amount"] != "25000" { + t.Fatalf("payload = %#v", payload) + } + for _, forbidden := range []string{"currency", "payment_type", "token", "schedule"} { + if _, ok := payload[forbidden]; ok { + t.Fatalf("patch payload included forbidden field %q: %#v", forbidden, payload) + } + } + if got.StatusMessage != "Subscription is updated." { + t.Fatalf("ack = %#v", got) + } +} + +func TestClientUpdateRequiresAtLeastOneMutableField(t *testing.T) { + client := subscription.Client{ + HTTP: &subscriptionRecordingDoer{}, + ServerKey: secrets.NewValue(subscriptionServerKeyCanary), + } + _, err := client.Update(context.Background(), subscription.UpdateRequest{ + OperationID: "op_subscription_update", SubscriptionID: "sub-123", - }); err != nil { - t.Fatal(err) + }) + if err == nil || err.Error() != "SANDBOX_REQUEST_INVALID" { + t.Fatalf("err = %v", err) + } +} + +func TestClientLifecycleMutationsDecodeAcknowledgementFixture(t *testing.T) { + tests := []struct { + name string + wantURL string + call func(subscription.Client) (subscription.AcknowledgementResponse, error) + wantStatus string + }{ + { + name: "disable", + wantURL: "https://api.sandbox.midtrans.com/v1/subscriptions/sub-123/disable", + call: func(client subscription.Client) (subscription.AcknowledgementResponse, error) { + return client.Disable(context.Background(), subscription.MutationRequest{ + OperationID: "op_subscription_disable", + SubscriptionID: "sub-123", + }) + }, + wantStatus: "Subscription is disabled.", + }, + { + name: "enable", + wantURL: "https://api.sandbox.midtrans.com/v1/subscriptions/sub-123/enable", + call: func(client subscription.Client) (subscription.AcknowledgementResponse, error) { + return client.Enable(context.Background(), subscription.MutationRequest{ + OperationID: "op_subscription_enable", + SubscriptionID: "sub-123", + }) + }, + wantStatus: "Subscription is enabled.", + }, + { + name: "cancel", + wantURL: "https://api.sandbox.midtrans.com/v1/subscriptions/sub-123/cancel", + call: func(client subscription.Client) (subscription.AcknowledgementResponse, error) { + return client.Cancel(context.Background(), subscription.MutationRequest{ + OperationID: "op_subscription_cancel", + SubscriptionID: "sub-123", + }) + }, + wantStatus: "Subscription is canceled.", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + doer := &subscriptionRecordingDoer{ + do: func(*http.Request) (*http.Response, error) { + return subscriptionResponse(http.StatusOK, `{"status_message":"`+test.wantStatus+`"}`), nil + }, + } + client := subscription.Client{ + HTTP: doer, + ServerKey: secrets.NewValue(subscriptionServerKeyCanary), + } + got, err := test.call(client) + if err != nil { + t.Fatal(err) + } + if doer.request.Method != http.MethodPost || doer.request.URL.String() != test.wantURL { + t.Fatalf("request = %s %s", doer.request.Method, doer.request.URL.String()) + } + if got.StatusMessage != test.wantStatus { + t.Fatalf("ack = %#v", got) + } + }) } } diff --git a/packs/subscription/journey.go b/packs/subscription/journey.go index eac00f1..a1113a2 100644 --- a/packs/subscription/journey.go +++ b/packs/subscription/journey.go @@ -15,11 +15,11 @@ import ( ) type Creator interface { - Create(context.Context, UpsertRequest) (SubscriptionResponse, error) + Create(context.Context, CreateRequest) (SubscriptionResponse, error) } type Updater interface { - Update(context.Context, UpsertRequest) (SubscriptionResponse, error) + Update(context.Context, UpdateRequest) (AcknowledgementResponse, error) } type Getter interface { @@ -27,15 +27,15 @@ type Getter interface { } type Disabler interface { - Disable(context.Context, MutationRequest) (SubscriptionResponse, error) + Disable(context.Context, MutationRequest) (AcknowledgementResponse, error) } type Enabler interface { - Enable(context.Context, MutationRequest) (SubscriptionResponse, error) + Enable(context.Context, MutationRequest) (AcknowledgementResponse, error) } type Canceler interface { - Cancel(context.Context, MutationRequest) (SubscriptionResponse, error) + Cancel(context.Context, MutationRequest) (AcknowledgementResponse, error) } type JourneyRunner struct { @@ -63,7 +63,7 @@ func NewCancelHandler() Handler { return newHandler("subscription.cancel", "sub func newHandler(id, intent string) Handler { required := []string{"subscription_id"} if id == "subscription.create" { - required = []string{"order_id", "amount", "payment_token_reference", "schedule_interval", "schedule_unit", "schedule_start"} + required = []string{"order_id", "payment_token_reference"} } return Handler{ definition: journey.Definition{ @@ -106,10 +106,20 @@ func (h Handler) run(ctx context.Context, request journey.Request, runtime journ } switch h.definition.ID { case "subscription.create": - if strings.TrimSpace(request.Input.OrderID) == "" || request.Input.Amount <= 0 || - request.Input.ScheduleInterval <= 0 || strings.TrimSpace(request.Input.ScheduleUnit) == "" || - strings.TrimSpace(request.Input.ScheduleStart) == "" || strings.TrimSpace(request.Input.PaymentTokenReference) == "" { - return inputRequired("order_id, amount, payment_token_reference, schedule_interval, schedule_unit, and schedule_start are required") + if strings.TrimSpace(request.Input.OrderID) == "" { + return inputRequired("order_id is required") + } + isUpdate := strings.TrimSpace(request.Input.SubscriptionID) != "" + if isUpdate { + if strings.TrimSpace(request.Input.OrderID) == "" && request.Input.Amount <= 0 { + return inputRequired("subscription updates require at least one mutable field") + } + } else { + if request.Input.Amount <= 0 || strings.TrimSpace(request.Input.PaymentTokenReference) == "" || + request.Input.ScheduleInterval <= 0 || strings.TrimSpace(request.Input.ScheduleUnit) == "" || + strings.TrimSpace(request.Input.ScheduleStart) == "" { + return inputRequired("create requires amount, payment_token_reference, schedule_interval, schedule_unit, and schedule_start") + } } case "subscription.verify", "subscription.disable", "subscription.enable", "subscription.cancel": if strings.TrimSpace(request.Input.SubscriptionID) == "" { @@ -122,7 +132,10 @@ func (h Handler) run(ctx context.Context, request journey.Request, runtime journ } switch h.definition.ID { case "subscription.create": - return h.runUpsert(ctx, request, runner, token) + if strings.TrimSpace(request.Input.SubscriptionID) != "" { + return h.runUpdate(ctx, request, runner) + } + return h.runCreate(ctx, request, runner, token) case "subscription.verify": return h.runVerify(ctx, request, runner) case "subscription.disable": @@ -136,49 +149,82 @@ func (h Handler) run(ctx context.Context, request journey.Request, runtime journ } } -func (h Handler) runUpsert(ctx context.Context, request journey.Request, runner JourneyRunner, token string) journey.Outcome { - input := UpsertRequest{ - OperationID: request.OperationID, - SubscriptionID: request.Input.SubscriptionID, - Name: request.Input.OrderID, - Amount: request.Input.Amount, - Token: token, +func (h Handler) runCreate(ctx context.Context, request journey.Request, runner JourneyRunner, token string) journey.Outcome { + if runner.Create == nil { + return blockedOutcome("subscription create is unavailable") + } + response, err := runner.Create.Create(ctx, CreateRequest{ + OperationID: request.OperationID, + Name: request.Input.OrderID, + Amount: strconv.FormatInt(request.Input.Amount, 10), + Token: token, Schedule: Schedule{ Interval: request.Input.ScheduleInterval, Unit: request.Input.ScheduleUnit, Start: request.Input.ScheduleStart, }, + }) + if err != nil { + return h.reconcileAmbiguousCreate(ctx, request, runner, err) } - call := runner.Create.Create - if strings.TrimSpace(request.Input.SubscriptionID) != "" { - if runner.Get == nil || runner.Update == nil { - return blockedOutcome("subscription update dependencies are unavailable") - } - status, err := runner.Get.Get(ctx, request.Input.SubscriptionID) - if err != nil { - return blockedOutcome("subscription status is unavailable") - } - if status.NotFound { - return blockedOutcome("subscription id is unavailable in sandbox") - } - call = runner.Update.Update + return evaluateStatus(response) +} + +func (h Handler) runUpdate(ctx context.Context, request journey.Request, runner JourneyRunner) journey.Outcome { + if runner.Get == nil || runner.Update == nil { + return blockedOutcome("subscription update dependencies are unavailable") } - response, err := call(ctx, input) + current, err := runner.Get.Get(ctx, request.Input.SubscriptionID) + if err != nil || current.NotFound { + return blockedOutcome("subscription status is unavailable") + } + ack, err := runner.Update.Update(ctx, UpdateRequest{ + OperationID: request.OperationID, + SubscriptionID: request.Input.SubscriptionID, + Name: request.Input.OrderID, + Amount: amountString(request.Input.Amount), + }) if err != nil { - var ambiguous sandbox.AmbiguousOperationError - if errors.As(err, &ambiguous) { - if strings.TrimSpace(request.Input.SubscriptionID) == "" || runner.Get == nil { - return journey.Outcome{State: journey.Reconciling, SafeData: safeDataForRequest(request)} - } - reconciled, statusErr := runner.Get.Get(ctx, request.Input.SubscriptionID) - if statusErr != nil || reconciled.NotFound { - return journey.Outcome{State: journey.Reconciling, SafeData: safeDataForRequest(request)} - } - return evaluateStatus(reconciled) - } + return h.reconcileAmbiguousMutation(ctx, request, runner, err) + } + if strings.TrimSpace(ack.StatusMessage) == "" { + return blockedOutcome("subscription acknowledgement was invalid") + } + verified, err := runner.Get.Get(ctx, request.Input.SubscriptionID) + if err != nil || verified.NotFound { + return blockedOutcome("subscription status is unavailable") + } + if request.Input.OrderID != "" && verified.Name != "" && verified.Name != request.Input.OrderID { + return blockedOutcome("subscription update was not reflected by provider status") + } + if request.Input.Amount > 0 && verified.Amount != "" && verified.Amount != amountString(request.Input.Amount) { + return blockedOutcome("subscription update amount was not reflected by provider status") + } + _ = current + return evaluateStatus(verified) +} + +func (h Handler) reconcileAmbiguousCreate(ctx context.Context, request journey.Request, runner JourneyRunner, err error) journey.Outcome { + var ambiguous sandbox.AmbiguousOperationError + if !errors.As(err, &ambiguous) { return blockedOutcome("subscription mutation failed") } - return evaluateStatus(response) + return journey.Outcome{State: journey.Reconciling, SafeData: safeDataForRequest(request)} +} + +func (h Handler) reconcileAmbiguousMutation(ctx context.Context, request journey.Request, runner JourneyRunner, err error) journey.Outcome { + var ambiguous sandbox.AmbiguousOperationError + if !errors.As(err, &ambiguous) { + return blockedOutcome("subscription mutation failed") + } + if runner.Get == nil { + return journey.Outcome{State: journey.Reconciling, SafeData: safeDataForRequest(request)} + } + reconciled, statusErr := runner.Get.Get(ctx, request.Input.SubscriptionID) + if statusErr != nil || reconciled.NotFound { + return journey.Outcome{State: journey.Reconciling, SafeData: safeDataForRequest(request)} + } + return evaluateStatus(reconciled) } func (h Handler) runVerify(ctx context.Context, request journey.Request, runner JourneyRunner) journey.Outcome { @@ -193,9 +239,6 @@ func (h Handler) runVerify(ctx context.Context, request journey.Request, runner } func (h Handler) runMutation(ctx context.Context, request journey.Request, runner JourneyRunner, target string) journey.Outcome { - if strings.TrimSpace(request.Input.SubscriptionID) == "" { - return inputRequired("subscription_id is required") - } if runner.Get == nil { return blockedOutcome("subscription status is unavailable") } @@ -207,39 +250,38 @@ func (h Handler) runMutation(ctx context.Context, request journey.Request, runne return evaluateStatus(current) } input := MutationRequest{OperationID: request.OperationID, SubscriptionID: request.Input.SubscriptionID} - var response SubscriptionResponse + var ack AcknowledgementResponse switch h.definition.ID { case "subscription.disable": if runner.Disable == nil { return blockedOutcome("subscription disable is unavailable") } - response, err = runner.Disable.Disable(ctx, input) + ack, err = runner.Disable.Disable(ctx, input) case "subscription.enable": if runner.Enable == nil { return blockedOutcome("subscription enable is unavailable") } - response, err = runner.Enable.Enable(ctx, input) + ack, err = runner.Enable.Enable(ctx, input) case "subscription.cancel": if runner.Cancel == nil { return blockedOutcome("subscription cancel is unavailable") } - response, err = runner.Cancel.Cancel(ctx, input) + ack, err = runner.Cancel.Cancel(ctx, input) } if err != nil { - var ambiguous sandbox.AmbiguousOperationError - if errors.As(err, &ambiguous) { - reconciled, statusErr := runner.Get.Get(ctx, request.Input.SubscriptionID) - if statusErr != nil || reconciled.NotFound { - return journey.Outcome{State: journey.Reconciling, SafeData: safeDataForRequest(request)} - } - if statusMatchesTarget(reconciled.Status, target) { - return evaluateStatus(reconciled) - } - return blockedOutcome("subscription mutation remained unverified after status reconciliation") - } - return blockedOutcome("subscription mutation failed") + return h.reconcileAmbiguousMutation(ctx, request, runner, err) } - return evaluateStatus(response) + if strings.TrimSpace(ack.StatusMessage) == "" { + return blockedOutcome("subscription acknowledgement was invalid") + } + verified, verifyErr := runner.Get.Get(ctx, request.Input.SubscriptionID) + if verifyErr != nil || verified.NotFound { + return blockedOutcome("subscription status is unavailable") + } + if !statusMatchesTarget(verified.Status, target) { + return blockedOutcome("subscription mutation remained unverified after status reconciliation") + } + return evaluateStatus(verified) } func (h Handler) runtimeRunner(ctx context.Context, request journey.Request, runtime journey.Runtime) (JourneyRunner, string, *journey.Outcome) { @@ -271,7 +313,7 @@ func (h Handler) runtimeRunner(ctx context.Context, request journey.Request, run } rawServerKey = nil token := "" - if h.definition.ID == "subscription.create" { + if h.definition.ID == "subscription.create" && strings.TrimSpace(request.Input.SubscriptionID) == "" { rawToken, err := runtime.ResolveCredential(ctx, request.ProjectDir, request.Input.PaymentTokenReference) if err != nil { outcome := blockedFinding("CREDENTIAL_RESOLUTION_FAILED", "unable to resolve the configured payment-token reference") @@ -381,6 +423,13 @@ func statusMatchesTarget(status, target string) bool { return strings.EqualFold(strings.TrimSpace(status), target) } +func amountString(value int64) string { + if value <= 0 { + return "" + } + return strconv.FormatInt(value, 10) +} + func blockedFinding(code, message string) journey.Outcome { return journey.Outcome{ State: journey.Blocked, diff --git a/packs/subscription/journey_test.go b/packs/subscription/journey_test.go index d6bba10..9a6bd29 100644 --- a/packs/subscription/journey_test.go +++ b/packs/subscription/journey_test.go @@ -96,10 +96,66 @@ func TestCreateJourneyResolvesServerKeyAndSavedTokenReference(t *testing.T) { } } +func TestUpdateJourneyUsesMinimalPatchAndVerifiesWithPostMutationGET(t *testing.T) { + getCalls := 0 + var updateInput subscription.UpdateRequest + handler := subscription.NewCreateHandler().WithRunner(subscription.JourneyRunner{ + Get: stubSubscriptionGet(func(context.Context, string) (subscription.SubscriptionResponse, error) { + getCalls++ + if getCalls == 1 { + return subscription.SubscriptionResponse{ + ID: "sub-123", + Status: "active", + Schedule: subscription.Schedule{ + Interval: 1, + Unit: "month", + Start: "2026-08-01 00:00:00 +0700", + }, + }, nil + } + return subscription.SubscriptionResponse{ + ID: "sub-123", + Status: "active", + Amount: "25000", + Name: "merchant-order-002", + Schedule: subscription.Schedule{ + Interval: 1, + Unit: "month", + Start: "2026-08-01 00:00:00 +0700", + }, + }, nil + }), + Update: stubSubscriptionUpdate(func(_ context.Context, input subscription.UpdateRequest) (subscription.AcknowledgementResponse, error) { + updateInput = input + return subscription.AcknowledgementResponse{StatusMessage: "Subscription is updated."}, nil + }), + }) + outcome := handler.Execute(context.Background(), journeypkg.Request{ + OperationID: "op_subscription_update", + ManifestHash: strings.Repeat("a", 64), + Input: journeypkg.Input{ + SubscriptionID: "sub-123", + OrderID: "merchant-order-002", + Amount: 25000, + }, + }, journeypkg.Runtime{}) + if outcome.State != journeypkg.Passed { + t.Fatalf("outcome = %#v", outcome) + } + if updateInput.Amount != "25000" || updateInput.Name != "merchant-order-002" { + t.Fatalf("update input = %#v", updateInput) + } + if getCalls != 2 { + t.Fatalf("getCalls = %d, want 2", getCalls) + } +} + func TestDisableJourneyChecksStatusBeforeMutation(t *testing.T) { disableCalls := 0 + getCalls := 0 handler := subscription.NewDisableHandler().WithRunner(subscription.JourneyRunner{ Get: stubSubscriptionGet(func(context.Context, string) (subscription.SubscriptionResponse, error) { + getCalls++ return subscription.SubscriptionResponse{ ID: "sub-123", Status: "inactive", @@ -110,9 +166,9 @@ func TestDisableJourneyChecksStatusBeforeMutation(t *testing.T) { }, }, nil }), - Disable: stubSubscriptionDisable(func(context.Context, subscription.MutationRequest) (subscription.SubscriptionResponse, error) { + Disable: stubSubscriptionDisable(func(context.Context, subscription.MutationRequest) (subscription.AcknowledgementResponse, error) { disableCalls++ - return subscription.SubscriptionResponse{}, nil + return subscription.AcknowledgementResponse{}, nil }), }) outcome := handler.Execute(context.Background(), journeypkg.Request{ @@ -128,6 +184,56 @@ func TestDisableJourneyChecksStatusBeforeMutation(t *testing.T) { if disableCalls != 0 { t.Fatalf("disableCalls = %d", disableCalls) } + if getCalls != 1 { + t.Fatalf("getCalls = %d", getCalls) + } +} + +func TestDisableJourneyVerifiesWithPostMutationGET(t *testing.T) { + getCalls := 0 + disableCalls := 0 + handler := subscription.NewDisableHandler().WithRunner(subscription.JourneyRunner{ + Get: stubSubscriptionGet(func(context.Context, string) (subscription.SubscriptionResponse, error) { + getCalls++ + if getCalls == 1 { + return subscription.SubscriptionResponse{ + ID: "sub-123", + Status: "active", + Schedule: subscription.Schedule{ + Interval: 1, + Unit: "month", + Start: "2026-08-01 00:00:00 +0700", + }, + }, nil + } + return subscription.SubscriptionResponse{ + ID: "sub-123", + Status: "inactive", + Schedule: subscription.Schedule{ + Interval: 1, + Unit: "month", + Start: "2026-08-01 00:00:00 +0700", + }, + }, nil + }), + Disable: stubSubscriptionDisable(func(context.Context, subscription.MutationRequest) (subscription.AcknowledgementResponse, error) { + disableCalls++ + return subscription.AcknowledgementResponse{StatusMessage: "Subscription is disabled."}, nil + }), + }) + outcome := handler.Execute(context.Background(), journeypkg.Request{ + OperationID: "op_subscription_disable", + ManifestHash: strings.Repeat("a", 64), + Input: journeypkg.Input{ + SubscriptionID: "sub-123", + }, + }, journeypkg.Runtime{}) + if outcome.State != journeypkg.Passed || outcome.SafeData["provider_status"] != "inactive" { + t.Fatalf("outcome = %#v", outcome) + } + if disableCalls != 1 || getCalls != 2 { + t.Fatalf("disableCalls = %d, getCalls = %d", disableCalls, getCalls) + } } func TestDisableJourneyReconcilesAmbiguousMutationByStatusBeforeRetry(t *testing.T) { @@ -156,8 +262,8 @@ func TestDisableJourneyReconcilesAmbiguousMutationByStatusBeforeRetry(t *testing }, }, nil }), - Disable: stubSubscriptionDisable(func(context.Context, subscription.MutationRequest) (subscription.SubscriptionResponse, error) { - return subscription.SubscriptionResponse{}, sandbox.AmbiguousOperationError{ + Disable: stubSubscriptionDisable(func(context.Context, subscription.MutationRequest) (subscription.AcknowledgementResponse, error) { + return subscription.AcknowledgementResponse{}, sandbox.AmbiguousOperationError{ OperationID: "op_subscription_disable", Cause: errors.New("sandbox request transport failed"), } @@ -230,9 +336,15 @@ func (f stubSubscriptionGet) Get(ctx context.Context, id string) (subscription.S return f(ctx, id) } -type stubSubscriptionDisable func(context.Context, subscription.MutationRequest) (subscription.SubscriptionResponse, error) +type stubSubscriptionUpdate func(context.Context, subscription.UpdateRequest) (subscription.AcknowledgementResponse, error) + +func (f stubSubscriptionUpdate) Update(ctx context.Context, input subscription.UpdateRequest) (subscription.AcknowledgementResponse, error) { + return f(ctx, input) +} + +type stubSubscriptionDisable func(context.Context, subscription.MutationRequest) (subscription.AcknowledgementResponse, error) -func (f stubSubscriptionDisable) Disable(ctx context.Context, input subscription.MutationRequest) (subscription.SubscriptionResponse, error) { +func (f stubSubscriptionDisable) Disable(ctx context.Context, input subscription.MutationRequest) (subscription.AcknowledgementResponse, error) { return f(ctx, input) } From 606fd3b9ccd399235ad35f368f005169fc522fc5 Mon Sep 17 00:00:00 2001 From: "m.salis" Date: Mon, 27 Jul 2026 15:53:56 +0700 Subject: [PATCH 58/73] fix: verify subscription mutation targets --- .../task-11-report.md | 63 +++++ packs/subscription/client.go | 16 +- packs/subscription/client_test.go | 13 +- packs/subscription/journey.go | 87 +++++-- packs/subscription/journey_test.go | 227 +++++++++++++++++- 5 files changed, 376 insertions(+), 30 deletions(-) diff --git a/.superpowers/sdd/2026-07-26-midtrans-cli-multi-product-parity/task-11-report.md b/.superpowers/sdd/2026-07-26-midtrans-cli-multi-product-parity/task-11-report.md index 8647383..d87bdc8 100644 --- a/.superpowers/sdd/2026-07-26-midtrans-cli-multi-product-parity/task-11-report.md +++ b/.superpowers/sdd/2026-07-26-midtrans-cli-multi-product-parity/task-11-report.md @@ -305,3 +305,66 @@ Commit: ```text fix: align subscription lifecycle contracts ``` + +## Fix Round 2: Verify subscription mutation targets + +This fix round applies only to the classic Subscription API lifecycle slice on +top of commit `5b7c12d`. + +What changed: + +- Made ambiguous lifecycle reconciliation target-aware: + - disable passes only after GET verifies `inactive` + - enable passes only after GET verifies `active` + - cancel passes only after GET verifies `canceled` +- If the post-timeout GET remains on a stale valid state, the journey now stays + `reconciling` instead of incorrectly passing. +- Tightened ambiguous update reconciliation so it only passes if the follow-up + GET reflects every requested mutable field that was supplied: + - `name` + - `amount` + - `schedule.interval` +- Stale update details after an ambiguous PATCH now remain `reconciling`. +- Aligned PATCH request shape with the requested official contract: + - always includes `currency: "IDR"` + - always includes the resolved saved-token value + - includes `amount` as a string when provided + - includes `schedule.interval` when supplied + - excludes `payment_type`, `interval_unit`, and `start_time` +- Update now requires `payment_token_reference` and resolves it in memory via + runtime credential resolution just like create, without persisting or + rendering the raw reference or token. +- Post-GET update verification now checks requested `schedule.interval` too. + +Tests added or tightened: + +- Negative ambiguity tests for: + - disable timeout followed by still-`active` + - enable timeout followed by still-`inactive` + - cancel timeout followed by still-`active` + - update timeout followed by stale provider details +- PATCH body-shape tests now assert required: + - `currency` + - `token` + - optional `schedule.interval` +- PATCH tests also assert excluded: + - `payment_type` + - `interval_unit` + - `start_time` +- Journey tests now require `payment_token_reference` for update and verify the + resolved token is passed into the update client contract. + +Validation: + +```sh +go test ./packs/subscription ./internal/app -count=1 +go test ./... -count=1 +``` + +Result: passed on Monday, July 27, 2026. + +Commit: + +```text +fix: verify subscription mutation targets +``` diff --git a/packs/subscription/client.go b/packs/subscription/client.go index 462ed57..5361555 100644 --- a/packs/subscription/client.go +++ b/packs/subscription/client.go @@ -45,6 +45,9 @@ type UpdateRequest struct { SubscriptionID string Name string Amount string + Token string + Currency string + Schedule Schedule } type SubscriptionResponse struct { @@ -94,14 +97,23 @@ func (c Client) Update(ctx context.Context, input UpdateRequest) (Acknowledgemen if c.HTTP == nil || strings.TrimSpace(input.OperationID) == "" || strings.TrimSpace(input.SubscriptionID) == "" { return AcknowledgementResponse{}, errors.New("SANDBOX_REQUEST_INVALID") } - payload := map[string]any{} + if strings.TrimSpace(input.Token) == "" || strings.TrimSpace(input.Currency) == "" { + return AcknowledgementResponse{}, errors.New("SANDBOX_REQUEST_INVALID") + } + payload := map[string]any{ + "currency": input.Currency, + "token": input.Token, + } if strings.TrimSpace(input.Name) != "" { payload["name"] = input.Name } if strings.TrimSpace(input.Amount) != "" { payload["amount"] = input.Amount } - if len(payload) == 0 { + if input.Schedule.Interval > 0 { + payload["schedule"] = map[string]any{"interval": input.Schedule.Interval} + } + if len(payload) == 2 { return AcknowledgementResponse{}, errors.New("SANDBOX_REQUEST_INVALID") } encoded, err := json.Marshal(payload) diff --git a/packs/subscription/client_test.go b/packs/subscription/client_test.go index 073931d..9e4c929 100644 --- a/packs/subscription/client_test.go +++ b/packs/subscription/client_test.go @@ -118,6 +118,9 @@ func TestClientUpdateUsesExactPatchBodyWithoutCreateOnlyFields(t *testing.T) { SubscriptionID: "sub-123", Name: "merchant-order-002", Amount: "25000", + Token: "saved-token-123", + Currency: "IDR", + Schedule: subscription.Schedule{Interval: 3}, }) if err != nil { t.Fatal(err) @@ -129,10 +132,14 @@ func TestClientUpdateUsesExactPatchBodyWithoutCreateOnlyFields(t *testing.T) { if err := json.Unmarshal(doer.body, &payload); err != nil { t.Fatal(err) } - if payload["name"] != "merchant-order-002" || payload["amount"] != "25000" { + if payload["name"] != "merchant-order-002" || payload["amount"] != "25000" || payload["currency"] != "IDR" || payload["token"] != "saved-token-123" { t.Fatalf("payload = %#v", payload) } - for _, forbidden := range []string{"currency", "payment_type", "token", "schedule"} { + schedule, ok := payload["schedule"].(map[string]any) + if !ok || schedule["interval"] != float64(3) || len(schedule) != 1 { + t.Fatalf("schedule = %#v", payload["schedule"]) + } + for _, forbidden := range []string{"payment_type", "interval_unit", "start_time"} { if _, ok := payload[forbidden]; ok { t.Fatalf("patch payload included forbidden field %q: %#v", forbidden, payload) } @@ -150,6 +157,8 @@ func TestClientUpdateRequiresAtLeastOneMutableField(t *testing.T) { _, err := client.Update(context.Background(), subscription.UpdateRequest{ OperationID: "op_subscription_update", SubscriptionID: "sub-123", + Token: "saved-token-123", + Currency: "IDR", }) if err == nil || err.Error() != "SANDBOX_REQUEST_INVALID" { t.Fatalf("err = %v", err) diff --git a/packs/subscription/journey.go b/packs/subscription/journey.go index a1113a2..3fe770b 100644 --- a/packs/subscription/journey.go +++ b/packs/subscription/journey.go @@ -106,15 +106,18 @@ func (h Handler) run(ctx context.Context, request journey.Request, runtime journ } switch h.definition.ID { case "subscription.create": - if strings.TrimSpace(request.Input.OrderID) == "" { - return inputRequired("order_id is required") - } isUpdate := strings.TrimSpace(request.Input.SubscriptionID) != "" if isUpdate { - if strings.TrimSpace(request.Input.OrderID) == "" && request.Input.Amount <= 0 { + if strings.TrimSpace(request.Input.PaymentTokenReference) == "" { + return inputRequired("subscription updates require payment_token_reference") + } + if strings.TrimSpace(request.Input.OrderID) == "" && request.Input.Amount <= 0 && request.Input.ScheduleInterval <= 0 { return inputRequired("subscription updates require at least one mutable field") } } else { + if strings.TrimSpace(request.Input.OrderID) == "" { + return inputRequired("order_id is required") + } if request.Input.Amount <= 0 || strings.TrimSpace(request.Input.PaymentTokenReference) == "" || request.Input.ScheduleInterval <= 0 || strings.TrimSpace(request.Input.ScheduleUnit) == "" || strings.TrimSpace(request.Input.ScheduleStart) == "" { @@ -133,7 +136,7 @@ func (h Handler) run(ctx context.Context, request journey.Request, runtime journ switch h.definition.ID { case "subscription.create": if strings.TrimSpace(request.Input.SubscriptionID) != "" { - return h.runUpdate(ctx, request, runner) + return h.runUpdate(ctx, request, runner, token) } return h.runCreate(ctx, request, runner, token) case "subscription.verify": @@ -170,22 +173,26 @@ func (h Handler) runCreate(ctx context.Context, request journey.Request, runner return evaluateStatus(response) } -func (h Handler) runUpdate(ctx context.Context, request journey.Request, runner JourneyRunner) journey.Outcome { +func (h Handler) runUpdate(ctx context.Context, request journey.Request, runner JourneyRunner, token string) journey.Outcome { if runner.Get == nil || runner.Update == nil { return blockedOutcome("subscription update dependencies are unavailable") } - current, err := runner.Get.Get(ctx, request.Input.SubscriptionID) - if err != nil || current.NotFound { + _, err := runner.Get.Get(ctx, request.Input.SubscriptionID) + if err != nil { return blockedOutcome("subscription status is unavailable") } + // Require an existing subscription before PATCH, but keep provider details out of persisted safe data. ack, err := runner.Update.Update(ctx, UpdateRequest{ OperationID: request.OperationID, SubscriptionID: request.Input.SubscriptionID, Name: request.Input.OrderID, Amount: amountString(request.Input.Amount), + Token: token, + Currency: "IDR", + Schedule: Schedule{Interval: request.Input.ScheduleInterval}, }) if err != nil { - return h.reconcileAmbiguousMutation(ctx, request, runner, err) + return h.reconcileAmbiguousUpdate(ctx, request, runner, err) } if strings.TrimSpace(ack.StatusMessage) == "" { return blockedOutcome("subscription acknowledgement was invalid") @@ -194,13 +201,9 @@ func (h Handler) runUpdate(ctx context.Context, request journey.Request, runner if err != nil || verified.NotFound { return blockedOutcome("subscription status is unavailable") } - if request.Input.OrderID != "" && verified.Name != "" && verified.Name != request.Input.OrderID { - return blockedOutcome("subscription update was not reflected by provider status") - } - if request.Input.Amount > 0 && verified.Amount != "" && verified.Amount != amountString(request.Input.Amount) { - return blockedOutcome("subscription update amount was not reflected by provider status") + if !updateMatchesRequestedFields(request, verified) { + return journey.Outcome{State: journey.Reconciling, SafeData: safeDataForRequest(request)} } - _ = current return evaluateStatus(verified) } @@ -212,7 +215,25 @@ func (h Handler) reconcileAmbiguousCreate(ctx context.Context, request journey.R return journey.Outcome{State: journey.Reconciling, SafeData: safeDataForRequest(request)} } -func (h Handler) reconcileAmbiguousMutation(ctx context.Context, request journey.Request, runner JourneyRunner, err error) journey.Outcome { +func (h Handler) reconcileAmbiguousMutation(ctx context.Context, request journey.Request, runner JourneyRunner, err error, target string) journey.Outcome { + var ambiguous sandbox.AmbiguousOperationError + if !errors.As(err, &ambiguous) { + return blockedOutcome("subscription mutation failed") + } + if runner.Get == nil { + return journey.Outcome{State: journey.Reconciling, SafeData: safeDataForRequest(request)} + } + reconciled, statusErr := runner.Get.Get(ctx, request.Input.SubscriptionID) + if statusErr != nil || reconciled.NotFound { + return journey.Outcome{State: journey.Reconciling, SafeData: safeDataForRequest(request)} + } + if !statusMatchesTarget(reconciled.Status, target) { + return journey.Outcome{State: journey.Reconciling, SafeData: safeDataForRequest(request)} + } + return evaluateStatus(reconciled) +} + +func (h Handler) reconcileAmbiguousUpdate(ctx context.Context, request journey.Request, runner JourneyRunner, err error) journey.Outcome { var ambiguous sandbox.AmbiguousOperationError if !errors.As(err, &ambiguous) { return blockedOutcome("subscription mutation failed") @@ -224,6 +245,9 @@ func (h Handler) reconcileAmbiguousMutation(ctx context.Context, request journey if statusErr != nil || reconciled.NotFound { return journey.Outcome{State: journey.Reconciling, SafeData: safeDataForRequest(request)} } + if !updateMatchesRequestedFields(request, reconciled) { + return journey.Outcome{State: journey.Reconciling, SafeData: safeDataForRequest(request)} + } return evaluateStatus(reconciled) } @@ -269,7 +293,7 @@ func (h Handler) runMutation(ctx context.Context, request journey.Request, runne ack, err = runner.Cancel.Cancel(ctx, input) } if err != nil { - return h.reconcileAmbiguousMutation(ctx, request, runner, err) + return h.reconcileAmbiguousMutation(ctx, request, runner, err, target) } if strings.TrimSpace(ack.StatusMessage) == "" { return blockedOutcome("subscription acknowledgement was invalid") @@ -279,14 +303,24 @@ func (h Handler) runMutation(ctx context.Context, request journey.Request, runne return blockedOutcome("subscription status is unavailable") } if !statusMatchesTarget(verified.Status, target) { - return blockedOutcome("subscription mutation remained unverified after status reconciliation") + return journey.Outcome{State: journey.Reconciling, SafeData: safeDataForRequest(request)} } return evaluateStatus(verified) } func (h Handler) runtimeRunner(ctx context.Context, request journey.Request, runtime journey.Runtime) (JourneyRunner, string, *journey.Outcome) { if h.runnerOverride { - return h.runner, request.Input.PaymentTokenReference, nil + token := request.Input.PaymentTokenReference + if h.definition.ID == "subscription.create" && runtime.ResolveCredential != nil && strings.TrimSpace(request.Input.PaymentTokenReference) != "" { + rawToken, err := runtime.ResolveCredential(ctx, request.ProjectDir, request.Input.PaymentTokenReference) + if err != nil { + outcome := blockedFinding("CREDENTIAL_RESOLUTION_FAILED", "unable to resolve the configured payment-token reference") + return JourneyRunner{}, "", &outcome + } + token = strings.TrimSpace(string(rawToken)) + rawToken = nil + } + return h.runner, token, nil } integration, ok := request.Manifest.IntegrationFor("subscription") if !ok { @@ -313,7 +347,7 @@ func (h Handler) runtimeRunner(ctx context.Context, request journey.Request, run } rawServerKey = nil token := "" - if h.definition.ID == "subscription.create" && strings.TrimSpace(request.Input.SubscriptionID) == "" { + if h.definition.ID == "subscription.create" { rawToken, err := runtime.ResolveCredential(ctx, request.ProjectDir, request.Input.PaymentTokenReference) if err != nil { outcome := blockedFinding("CREDENTIAL_RESOLUTION_FAILED", "unable to resolve the configured payment-token reference") @@ -430,6 +464,19 @@ func amountString(value int64) string { return strconv.FormatInt(value, 10) } +func updateMatchesRequestedFields(request journey.Request, status SubscriptionResponse) bool { + if request.Input.OrderID != "" && status.Name != "" && status.Name != request.Input.OrderID { + return false + } + if request.Input.Amount > 0 && status.Amount != "" && status.Amount != amountString(request.Input.Amount) { + return false + } + if request.Input.ScheduleInterval > 0 && status.Schedule.Interval > 0 && status.Schedule.Interval != request.Input.ScheduleInterval { + return false + } + return true +} + func blockedFinding(code, message string) journey.Outcome { return journey.Outcome{ State: journey.Blocked, diff --git a/packs/subscription/journey_test.go b/packs/subscription/journey_test.go index 9a6bd29..09d3dc6 100644 --- a/packs/subscription/journey_test.go +++ b/packs/subscription/journey_test.go @@ -99,6 +99,7 @@ func TestCreateJourneyResolvesServerKeyAndSavedTokenReference(t *testing.T) { func TestUpdateJourneyUsesMinimalPatchAndVerifiesWithPostMutationGET(t *testing.T) { getCalls := 0 var updateInput subscription.UpdateRequest + var resolved []string handler := subscription.NewCreateHandler().WithRunner(subscription.JourneyRunner{ Get: stubSubscriptionGet(func(context.Context, string) (subscription.SubscriptionResponse, error) { getCalls++ @@ -119,7 +120,7 @@ func TestUpdateJourneyUsesMinimalPatchAndVerifiesWithPostMutationGET(t *testing. Amount: "25000", Name: "merchant-order-002", Schedule: subscription.Schedule{ - Interval: 1, + Interval: 3, Unit: "month", Start: "2026-08-01 00:00:00 +0700", }, @@ -132,24 +133,65 @@ func TestUpdateJourneyUsesMinimalPatchAndVerifiesWithPostMutationGET(t *testing. }) outcome := handler.Execute(context.Background(), journeypkg.Request{ OperationID: "op_subscription_update", + ProjectDir: "/merchant", ManifestHash: strings.Repeat("a", 64), + Manifest: validSubscriptionManifest(), Input: journeypkg.Input{ - SubscriptionID: "sub-123", - OrderID: "merchant-order-002", - Amount: 25000, + SubscriptionID: "sub-123", + OrderID: "merchant-order-002", + Amount: 25000, + ScheduleInterval: 3, + PaymentTokenReference: "env:MIDTRANS_SAVED_TOKEN", }, - }, journeypkg.Runtime{}) + }, journeypkg.Runtime{ + ResolveCredential: func(_ context.Context, projectDir, reference string) ([]byte, error) { + if projectDir != "/merchant" { + t.Fatalf("projectDir = %q", projectDir) + } + resolved = append(resolved, reference) + switch reference { + case "env:MIDTRANS_SERVER_KEY": + return []byte(subscriptionServerKeyCanary), nil + case "env:MIDTRANS_SAVED_TOKEN": + return []byte("saved-token-123"), nil + default: + return nil, errors.New("unexpected reference") + } + }, + HTTP: appDoerFunc(func(*http.Request) (*http.Response, error) { + t.Fatal("runner override should not use runtime HTTP") + return nil, nil + }), + }) if outcome.State != journeypkg.Passed { t.Fatalf("outcome = %#v", outcome) } - if updateInput.Amount != "25000" || updateInput.Name != "merchant-order-002" { + if updateInput.Amount != "25000" || updateInput.Name != "merchant-order-002" || updateInput.Token != "saved-token-123" || updateInput.Currency != "IDR" || updateInput.Schedule.Interval != 3 { t.Fatalf("update input = %#v", updateInput) } + if len(resolved) != 1 || resolved[0] != "env:MIDTRANS_SAVED_TOKEN" { + t.Fatalf("resolved = %#v", resolved) + } if getCalls != 2 { t.Fatalf("getCalls = %d, want 2", getCalls) } } +func TestUpdateJourneyRequiresPaymentTokenReference(t *testing.T) { + handler := subscription.NewCreateHandler() + outcome := handler.Execute(context.Background(), journeypkg.Request{ + OperationID: "op_subscription_update", + ManifestHash: strings.Repeat("a", 64), + Input: journeypkg.Input{ + SubscriptionID: "sub-123", + OrderID: "merchant-order-002", + }, + }, journeypkg.Runtime{}) + if outcome.State != journeypkg.Blocked || outcome.Finding == nil || outcome.Finding.Code != "JOURNEY_INPUT_REQUIRED" { + t.Fatalf("outcome = %#v", outcome) + } +} + func TestDisableJourneyChecksStatusBeforeMutation(t *testing.T) { disableCalls := 0 getCalls := 0 @@ -281,6 +323,167 @@ func TestDisableJourneyReconcilesAmbiguousMutationByStatusBeforeRetry(t *testing } } +func TestDisableJourneyAmbiguousMutationStaysReconcilingWhenStatusRemainsActive(t *testing.T) { + statusCalls := 0 + handler := subscription.NewDisableHandler().WithRunner(subscription.JourneyRunner{ + Get: stubSubscriptionGet(func(context.Context, string) (subscription.SubscriptionResponse, error) { + statusCalls++ + return subscription.SubscriptionResponse{ + ID: "sub-123", + Status: "active", + Schedule: subscription.Schedule{ + Interval: 1, + Unit: "month", + Start: "2026-08-01 00:00:00 +0700", + }, + }, nil + }), + Disable: stubSubscriptionDisable(func(context.Context, subscription.MutationRequest) (subscription.AcknowledgementResponse, error) { + return subscription.AcknowledgementResponse{}, sandbox.AmbiguousOperationError{ + OperationID: "op_subscription_disable", + Cause: errors.New("sandbox request transport failed"), + } + }), + }) + outcome := handler.Execute(context.Background(), journeypkg.Request{ + OperationID: "op_subscription_disable", + ManifestHash: strings.Repeat("a", 64), + Input: journeypkg.Input{SubscriptionID: "sub-123"}, + }, journeypkg.Runtime{}) + if outcome.State != journeypkg.Reconciling { + t.Fatalf("outcome = %#v", outcome) + } + if statusCalls != 2 { + t.Fatalf("statusCalls = %d", statusCalls) + } +} + +func TestEnableJourneyAmbiguousMutationStaysReconcilingWhenStatusRemainsInactive(t *testing.T) { + statusCalls := 0 + handler := subscription.NewEnableHandler().WithRunner(subscription.JourneyRunner{ + Get: stubSubscriptionGet(func(context.Context, string) (subscription.SubscriptionResponse, error) { + statusCalls++ + return subscription.SubscriptionResponse{ + ID: "sub-123", + Status: "inactive", + Schedule: subscription.Schedule{ + Interval: 1, + Unit: "month", + Start: "2026-08-01 00:00:00 +0700", + }, + }, nil + }), + Enable: stubSubscriptionEnable(func(context.Context, subscription.MutationRequest) (subscription.AcknowledgementResponse, error) { + return subscription.AcknowledgementResponse{}, sandbox.AmbiguousOperationError{ + OperationID: "op_subscription_enable", + Cause: errors.New("sandbox request transport failed"), + } + }), + }) + outcome := handler.Execute(context.Background(), journeypkg.Request{ + OperationID: "op_subscription_enable", + ManifestHash: strings.Repeat("a", 64), + Input: journeypkg.Input{SubscriptionID: "sub-123"}, + }, journeypkg.Runtime{}) + if outcome.State != journeypkg.Reconciling { + t.Fatalf("outcome = %#v", outcome) + } + if statusCalls != 2 { + t.Fatalf("statusCalls = %d", statusCalls) + } +} + +func TestCancelJourneyAmbiguousMutationStaysReconcilingWhenStatusRemainsActive(t *testing.T) { + statusCalls := 0 + handler := subscription.NewCancelHandler().WithRunner(subscription.JourneyRunner{ + Get: stubSubscriptionGet(func(context.Context, string) (subscription.SubscriptionResponse, error) { + statusCalls++ + return subscription.SubscriptionResponse{ + ID: "sub-123", + Status: "active", + Schedule: subscription.Schedule{ + Interval: 1, + Unit: "month", + Start: "2026-08-01 00:00:00 +0700", + }, + }, nil + }), + Cancel: stubSubscriptionCancel(func(context.Context, subscription.MutationRequest) (subscription.AcknowledgementResponse, error) { + return subscription.AcknowledgementResponse{}, sandbox.AmbiguousOperationError{ + OperationID: "op_subscription_cancel", + Cause: errors.New("sandbox request transport failed"), + } + }), + }) + outcome := handler.Execute(context.Background(), journeypkg.Request{ + OperationID: "op_subscription_cancel", + ManifestHash: strings.Repeat("a", 64), + Input: journeypkg.Input{SubscriptionID: "sub-123"}, + }, journeypkg.Runtime{}) + if outcome.State != journeypkg.Reconciling { + t.Fatalf("outcome = %#v", outcome) + } + if statusCalls != 2 { + t.Fatalf("statusCalls = %d", statusCalls) + } +} + +func TestUpdateJourneyAmbiguousMutationStaysReconcilingWhenDetailsRemainStale(t *testing.T) { + statusCalls := 0 + handler := subscription.NewCreateHandler().WithRunner(subscription.JourneyRunner{ + Get: stubSubscriptionGet(func(context.Context, string) (subscription.SubscriptionResponse, error) { + statusCalls++ + if statusCalls == 1 { + return subscription.SubscriptionResponse{ + ID: "sub-123", + Status: "active", + Name: "merchant-order-001", + Amount: "15000", + Schedule: subscription.Schedule{ + Interval: 1, + Unit: "month", + Start: "2026-08-01 00:00:00 +0700", + }, + }, nil + } + return subscription.SubscriptionResponse{ + ID: "sub-123", + Status: "active", + Name: "merchant-order-001", + Amount: "15000", + Schedule: subscription.Schedule{ + Interval: 1, + Unit: "month", + Start: "2026-08-01 00:00:00 +0700", + }, + }, nil + }), + Update: stubSubscriptionUpdate(func(context.Context, subscription.UpdateRequest) (subscription.AcknowledgementResponse, error) { + return subscription.AcknowledgementResponse{}, sandbox.AmbiguousOperationError{ + OperationID: "op_subscription_update", + Cause: errors.New("sandbox request transport failed"), + } + }), + }) + outcome := handler.Execute(context.Background(), journeypkg.Request{ + OperationID: "op_subscription_update", + ManifestHash: strings.Repeat("a", 64), + Input: journeypkg.Input{ + SubscriptionID: "sub-123", + OrderID: "merchant-order-002", + Amount: 25000, + ScheduleInterval: 3, + PaymentTokenReference: "env:MIDTRANS_SAVED_TOKEN", + }, + }, journeypkg.Runtime{}) + if outcome.State != journeypkg.Reconciling { + t.Fatalf("outcome = %#v", outcome) + } + if statusCalls != 2 { + t.Fatalf("statusCalls = %d", statusCalls) + } +} + func TestVerifyJourneyRehydratesSubscriptionIDFromRecord(t *testing.T) { handler := subscription.NewVerifyHandler().WithRunner(subscription.JourneyRunner{ Get: stubSubscriptionGet(func(_ context.Context, id string) (subscription.SubscriptionResponse, error) { @@ -348,6 +551,18 @@ func (f stubSubscriptionDisable) Disable(ctx context.Context, input subscription return f(ctx, input) } +type stubSubscriptionEnable func(context.Context, subscription.MutationRequest) (subscription.AcknowledgementResponse, error) + +func (f stubSubscriptionEnable) Enable(ctx context.Context, input subscription.MutationRequest) (subscription.AcknowledgementResponse, error) { + return f(ctx, input) +} + +type stubSubscriptionCancel func(context.Context, subscription.MutationRequest) (subscription.AcknowledgementResponse, error) + +func (f stubSubscriptionCancel) Cancel(ctx context.Context, input subscription.MutationRequest) (subscription.AcknowledgementResponse, error) { + return f(ctx, input) +} + type timeoutError struct{ message string } func (e timeoutError) Error() string { return e.message } From d94b80ca0654b2f453d937a683c6b2a373057a3d Mon Sep 17 00:00:00 2001 From: "m.salis" Date: Mon, 27 Jul 2026 15:59:24 +0700 Subject: [PATCH 59/73] fix: require subscription update amount --- .../task-11-report.md | 47 ++++++++++++++ packs/subscription/client.go | 14 ++--- packs/subscription/client_test.go | 62 ++++++++++++++++++- packs/subscription/journey.go | 4 +- packs/subscription/journey_test.go | 60 ++++++++++++++++++ 5 files changed, 174 insertions(+), 13 deletions(-) diff --git a/.superpowers/sdd/2026-07-26-midtrans-cli-multi-product-parity/task-11-report.md b/.superpowers/sdd/2026-07-26-midtrans-cli-multi-product-parity/task-11-report.md index d87bdc8..491cf5f 100644 --- a/.superpowers/sdd/2026-07-26-midtrans-cli-multi-product-parity/task-11-report.md +++ b/.superpowers/sdd/2026-07-26-midtrans-cli-multi-product-parity/task-11-report.md @@ -368,3 +368,50 @@ Commit: ```text fix: verify subscription mutation targets ``` + +## Fix Round 3: Require subscription update amount + +This fix round applies only to the classic Subscription API lifecycle slice on +top of commit `606fd3b`. + +What changed: + +- Tightened subscription update validation so + `subscription.create` with `subscription_id` set now requires: + - nonempty `order_id` + - positive `amount` + - `payment_token_reference` +- `schedule_interval` remains optional for updates. +- Tightened `Client.Update` validation to require all of: + - nonempty `Name` + - nonempty `Amount` + - `Currency == "IDR"` + - nonempty resolved `Token` +- Invalid update attempts now fail before any GET or PATCH call, preserving the + existing body shape for valid updates. + +Tests added or tightened: + +- Client negative tests for: + - missing amount + - schedule-only update +- Journey negative tests for: + - missing amount + - schedule-only update +- These tests also assert no GET/PATCH mutation path is reached for invalid + updates. + +Validation: + +```sh +go test ./packs/subscription ./internal/app -count=1 +go test ./... -count=1 +``` + +Result: passed on Monday, July 27, 2026. + +Commit: + +```text +fix: require subscription update amount +``` diff --git a/packs/subscription/client.go b/packs/subscription/client.go index 5361555..b1dc5e7 100644 --- a/packs/subscription/client.go +++ b/packs/subscription/client.go @@ -97,25 +97,19 @@ func (c Client) Update(ctx context.Context, input UpdateRequest) (Acknowledgemen if c.HTTP == nil || strings.TrimSpace(input.OperationID) == "" || strings.TrimSpace(input.SubscriptionID) == "" { return AcknowledgementResponse{}, errors.New("SANDBOX_REQUEST_INVALID") } - if strings.TrimSpace(input.Token) == "" || strings.TrimSpace(input.Currency) == "" { + if strings.TrimSpace(input.Name) == "" || strings.TrimSpace(input.Amount) == "" || + strings.TrimSpace(input.Token) == "" || strings.TrimSpace(input.Currency) != "IDR" { return AcknowledgementResponse{}, errors.New("SANDBOX_REQUEST_INVALID") } payload := map[string]any{ "currency": input.Currency, "token": input.Token, - } - if strings.TrimSpace(input.Name) != "" { - payload["name"] = input.Name - } - if strings.TrimSpace(input.Amount) != "" { - payload["amount"] = input.Amount + "name": input.Name, + "amount": input.Amount, } if input.Schedule.Interval > 0 { payload["schedule"] = map[string]any{"interval": input.Schedule.Interval} } - if len(payload) == 2 { - return AcknowledgementResponse{}, errors.New("SANDBOX_REQUEST_INVALID") - } encoded, err := json.Marshal(payload) if err != nil { return AcknowledgementResponse{}, errors.New("SANDBOX_REQUEST_INVALID") diff --git a/packs/subscription/client_test.go b/packs/subscription/client_test.go index 9e4c929..0abedf8 100644 --- a/packs/subscription/client_test.go +++ b/packs/subscription/client_test.go @@ -150,8 +150,14 @@ func TestClientUpdateUsesExactPatchBodyWithoutCreateOnlyFields(t *testing.T) { } func TestClientUpdateRequiresAtLeastOneMutableField(t *testing.T) { + called := false client := subscription.Client{ - HTTP: &subscriptionRecordingDoer{}, + HTTP: &subscriptionRecordingDoer{ + do: func(*http.Request) (*http.Response, error) { + called = true + return subscriptionResponse(http.StatusOK, `{"status_message":"Subscription is updated."}`), nil + }, + }, ServerKey: secrets.NewValue(subscriptionServerKeyCanary), } _, err := client.Update(context.Background(), subscription.UpdateRequest{ @@ -163,6 +169,60 @@ func TestClientUpdateRequiresAtLeastOneMutableField(t *testing.T) { if err == nil || err.Error() != "SANDBOX_REQUEST_INVALID" { t.Fatalf("err = %v", err) } + if called { + t.Fatal("update attempted HTTP call for invalid payload") + } +} + +func TestClientUpdateRequiresNameAndAmount(t *testing.T) { + tests := []struct { + name string + input subscription.UpdateRequest + }{ + { + name: "missing amount", + input: subscription.UpdateRequest{ + OperationID: "op_subscription_update", + SubscriptionID: "sub-123", + Name: "merchant-order-002", + Token: "saved-token-123", + Currency: "IDR", + Schedule: subscription.Schedule{Interval: 3}, + }, + }, + { + name: "schedule only", + input: subscription.UpdateRequest{ + OperationID: "op_subscription_update", + SubscriptionID: "sub-123", + Token: "saved-token-123", + Currency: "IDR", + Schedule: subscription.Schedule{Interval: 3}, + }, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + called := false + client := subscription.Client{ + HTTP: &subscriptionRecordingDoer{ + do: func(*http.Request) (*http.Response, error) { + called = true + return subscriptionResponse(http.StatusOK, `{"status_message":"Subscription is updated."}`), nil + }, + }, + ServerKey: secrets.NewValue(subscriptionServerKeyCanary), + } + _, err := client.Update(context.Background(), test.input) + if err == nil || err.Error() != "SANDBOX_REQUEST_INVALID" { + t.Fatalf("err = %v", err) + } + if called { + t.Fatal("update attempted HTTP call for invalid payload") + } + }) + } } func TestClientLifecycleMutationsDecodeAcknowledgementFixture(t *testing.T) { diff --git a/packs/subscription/journey.go b/packs/subscription/journey.go index 3fe770b..d347382 100644 --- a/packs/subscription/journey.go +++ b/packs/subscription/journey.go @@ -111,8 +111,8 @@ func (h Handler) run(ctx context.Context, request journey.Request, runtime journ if strings.TrimSpace(request.Input.PaymentTokenReference) == "" { return inputRequired("subscription updates require payment_token_reference") } - if strings.TrimSpace(request.Input.OrderID) == "" && request.Input.Amount <= 0 && request.Input.ScheduleInterval <= 0 { - return inputRequired("subscription updates require at least one mutable field") + if strings.TrimSpace(request.Input.OrderID) == "" || request.Input.Amount <= 0 { + return inputRequired("subscription updates require order_id and a positive amount") } } else { if strings.TrimSpace(request.Input.OrderID) == "" { diff --git a/packs/subscription/journey_test.go b/packs/subscription/journey_test.go index 09d3dc6..a38014d 100644 --- a/packs/subscription/journey_test.go +++ b/packs/subscription/journey_test.go @@ -192,6 +192,66 @@ func TestUpdateJourneyRequiresPaymentTokenReference(t *testing.T) { } } +func TestUpdateJourneyRequiresNameAndPositiveAmount(t *testing.T) { + tests := []struct { + name string + input journeypkg.Input + }{ + { + name: "schedule only", + input: journeypkg.Input{ + SubscriptionID: "sub-123", + ScheduleInterval: 3, + PaymentTokenReference: "env:MIDTRANS_SAVED_TOKEN", + }, + }, + { + name: "missing amount", + input: journeypkg.Input{ + SubscriptionID: "sub-123", + OrderID: "merchant-order-002", + PaymentTokenReference: "env:MIDTRANS_SAVED_TOKEN", + }, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + getCalls := 0 + updateCalls := 0 + handler := subscription.NewCreateHandler().WithRunner(subscription.JourneyRunner{ + Get: stubSubscriptionGet(func(context.Context, string) (subscription.SubscriptionResponse, error) { + getCalls++ + return subscription.SubscriptionResponse{ + ID: "sub-123", + Status: "active", + Schedule: subscription.Schedule{ + Interval: 1, + Unit: "month", + Start: "2026-08-01 00:00:00 +0700", + }, + }, nil + }), + Update: stubSubscriptionUpdate(func(context.Context, subscription.UpdateRequest) (subscription.AcknowledgementResponse, error) { + updateCalls++ + return subscription.AcknowledgementResponse{StatusMessage: "Subscription is updated."}, nil + }), + }) + outcome := handler.Execute(context.Background(), journeypkg.Request{ + OperationID: "op_subscription_update", + ManifestHash: strings.Repeat("a", 64), + Input: test.input, + }, journeypkg.Runtime{}) + if outcome.State != journeypkg.Blocked || outcome.Finding == nil || outcome.Finding.Code != "JOURNEY_INPUT_REQUIRED" { + t.Fatalf("outcome = %#v", outcome) + } + if getCalls != 0 || updateCalls != 0 { + t.Fatalf("getCalls = %d, updateCalls = %d", getCalls, updateCalls) + } + }) + } +} + func TestDisableJourneyChecksStatusBeforeMutation(t *testing.T) { disableCalls := 0 getCalls := 0 From 4c5b3b55a1f6747d0b3eca4139dc1d7d3ef51b7e Mon Sep 17 00:00:00 2001 From: "m.salis" Date: Mon, 27 Jul 2026 16:21:26 +0700 Subject: [PATCH 60/73] feat: aggregate hybrid journey evidence --- .../task-12-report.md | 58 +++ internal/app/checkout_runner.go | 5 + internal/app/commands_evidence.go | 42 +- internal/app/commands_evidence_test.go | 274 +++++++++++++ internal/app/commands_verify.go | 371 ++++++++++++++---- internal/evidence/model.go | 25 ++ internal/evidence/store.go | 71 +++- internal/presentation/model.go | 40 +- internal/verify/verify.go | 25 +- schemas/evidence-v1.schema.json | 105 ++++- 10 files changed, 914 insertions(+), 102 deletions(-) create mode 100644 .superpowers/sdd/2026-07-26-midtrans-cli-multi-product-parity/task-12-report.md diff --git a/.superpowers/sdd/2026-07-26-midtrans-cli-multi-product-parity/task-12-report.md b/.superpowers/sdd/2026-07-26-midtrans-cli-multi-product-parity/task-12-report.md new file mode 100644 index 0000000..ca60233 --- /dev/null +++ b/.superpowers/sdd/2026-07-26-midtrans-cli-multi-product-parity/task-12-report.md @@ -0,0 +1,58 @@ +# Task 12 Report + +Date: 2026-07-27 + +## Slice A + +Implemented hybrid evidence aggregation for `midtrans verify` without starting +the production-host enforcement slice. + +## What Changed + +- Extended `internal/evidence` with: + - first-class `operation_id` + - first-class `required_proofs` + - hybrid `Document` support through `journeys[]` + - strict document validation alongside the existing single-bundle path +- Bound verification context to the current: + - manifest version + - manifest hash + - repository revision + - pack ID and pack version + - journey ID + - operation ID consistency across proofs +- Reworked `midtrans verify` to aggregate across + `manifest.verification.required` journeys and keep the final project status at + the weakest required journey. +- Preserved strict proof levels so local-only proof cannot satisfy a + sandbox-required proof. +- Preserved operation/stage facts in `midtrans evidence show` for required + journeys while keeping the single-journey human verify output stable. +- Expanded `schemas/evidence-v1.schema.json` for the hybrid evidence document + shape. +- Added focused hybrid verification tests in + `internal/app/commands_evidence_test.go`. + +## Validation + +Focused: + +```sh +go test ./internal/evidence ./internal/verify ./internal/app -run 'TestHybrid|TestEvidence|TestRun' -count=1 +go test ./internal/evidence ./internal/verify ./internal/app -count=1 +``` + +Result: passed. + +Full: + +```sh +go test ./... -count=1 +``` + +Result: passed on Monday, July 27, 2026. + +## Deferred + +- Production host allowlisting and zero-production mutation enforcement remain + out of scope for Slice A. diff --git a/internal/app/checkout_runner.go b/internal/app/checkout_runner.go index a0208e0..dd45270 100644 --- a/internal/app/checkout_runner.go +++ b/internal/app/checkout_runner.go @@ -326,6 +326,7 @@ func writeJourneyEvidence( ManifestVersion: manifestVersion, PackID: descriptor.ID, PackVersion: descriptor.Version, + OperationID: journey.OperationID, ManifestHash: manifestHash, RepositoryCommit: revision, Journey: "snap.checkout", @@ -334,6 +335,10 @@ func writeJourneyEvidence( CompletedAt: time.Now().UTC(), SafeReferences: safeReferences, Proofs: proofs, + RequiredProofs: []evidence.RequiredProof{ + {ID: "snap.provider-status", Level: evidence.ProofSandbox}, + {ID: "snap.merchant-callback", Level: evidence.ProofLocal}, + }, } if err := evidence.Validate(bundle); err != nil { return "", err diff --git a/internal/app/commands_evidence.go b/internal/app/commands_evidence.go index cf7d694..cdcd951 100644 --- a/internal/app/commands_evidence.go +++ b/internal/app/commands_evidence.go @@ -39,9 +39,14 @@ func newEvidenceShowCommand( Use: "show", Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, args []string) error { - bundle, err := (evidence.Store{ + value, invalidResult := loadValidatedManifest("evidence.show", flags.projectDir, deps) + if invalidResult != nil { + result := *invalidResult + return writeResult(deps, flags, result) + } + document, err := (evidence.Store{ ProjectDir: flags.projectDir, - }).Read(file) + }).ReadDocument(file) if err != nil { return writeResult( deps, @@ -49,13 +54,14 @@ func newEvidenceShowCommand( evidenceFailureResult("evidence.show", deps), ) } + document = selectRequiredEvidenceDocument(document, requiredJourneysForVerification(value)) result := contracts.NewResult( "evidence.show", contracts.StatusPass, ) result.CLIVersion = deps.Version.Version - result.ManifestVersion = bundle.ManifestVersion - result.Data = bundle + result.ManifestVersion = document.ManifestVersion + result.Data = document return writeResult(deps, flags, result) }, } @@ -73,9 +79,9 @@ func newEvidenceExportCommand( Use: "export", Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, args []string) error { - bundle, err := (evidence.Store{ + document, err := (evidence.Store{ ProjectDir: flags.projectDir, - }).Read(file) + }).ReadDocument(file) if err != nil { return writeResult( deps, @@ -84,7 +90,7 @@ func newEvidenceExportCommand( ) } safe, err := evidence.StructuralRedact( - bundle, + document, deps.Packs.SensitiveKeys(), ) if err != nil { @@ -120,7 +126,7 @@ func newEvidenceExportCommand( contracts.StatusPass, ) result.CLIVersion = deps.Version.Version - result.ManifestVersion = bundle.ManifestVersion + result.ManifestVersion = document.ManifestVersion result.Data = map[string]any{"output": outputPath} return writeResult(deps, flags, result) }, @@ -242,3 +248,23 @@ func evidenceFailureResult( }} return result } + +func selectRequiredEvidenceDocument(document evidence.Document, requiredJourneys []string) evidence.Document { + if len(requiredJourneys) == 0 { + return document + } + selected := make([]evidence.Bundle, 0, len(requiredJourneys)) + for _, required := range requiredJourneys { + for _, bundle := range document.Journeys { + if bundle.Journey == required { + selected = append(selected, bundle) + break + } + } + } + if len(selected) == 0 { + return document + } + document.Journeys = selected + return document +} diff --git a/internal/app/commands_evidence_test.go b/internal/app/commands_evidence_test.go index 5fa01a4..7c61cab 100644 --- a/internal/app/commands_evidence_test.go +++ b/internal/app/commands_evidence_test.go @@ -14,6 +14,7 @@ import ( "github.com/veritrans/midtrans-cli/internal/contracts" "github.com/veritrans/midtrans-cli/internal/evidence" "github.com/veritrans/midtrans-cli/internal/inspection" + "github.com/veritrans/midtrans-cli/internal/manifest" ) func TestEvidenceShowReadsChecksummedBundleAndRedactsAgain(t *testing.T) { @@ -242,6 +243,162 @@ func TestVerifyRejectsEvidenceAfterTrackedWorktreeChange(t *testing.T) { } } +func TestHybridVerifyBlocksWhenRequiredJourneyIsBlocked(t *testing.T) { + project := createJourneyProject(t, "http://127.0.0.1:1") + configureManifest(t, project, func(value *manifest.Manifest) { + value.CredentialSets["bisnap"] = manifest.CredentialSet{ + Type: "bisnap", + Environment: "sandbox", + ClientID: "env:MIDTRANS_BISNAP_CLIENT_ID", + ClientSecret: "env:MIDTRANS_BISNAP_CLIENT_SECRET", + PartnerID: "env:MIDTRANS_BISNAP_PARTNER_ID", + ChannelID: "env:MIDTRANS_BISNAP_CHANNEL_ID", + DeviceID: "env:MIDTRANS_BISNAP_DEVICE_ID", + PrivateKey: "file:./keys/private.pem", + MidtransPublicKey: "file:./keys/public.pem", + } + value.Integrations["bisnap"] = manifest.Integration{ + ConfigVersion: 1, + Credentials: "bisnap", + Callbacks: map[string]string{ + "notification": "/api/payments/bisnap/notification", + }, + } + value.Verification.Required = []string{"snap.checkout", "bisnap.status"} + }) + + path := writeHybridEvidence(t, project, false) + result, exit := executeJSON( + t, + "verify", + "--evidence", path, + "--project-dir", project, + "--json", "--non-interactive", + ) + if exit != 3 || result.Status != contracts.StatusBlocked || + !result.HasCode("VERIFY_EVIDENCE_INCOMPLETE") { + t.Fatalf("exit = %d, result = %#v", exit, result) + } + data, ok := result.Data.(map[string]any) + if !ok || data["proof_state"] != "blocked" { + t.Fatalf("data = %#v", result.Data) + } + journeys, ok := data["journeys"].([]any) + if !ok || len(journeys) != 2 { + t.Fatalf("journeys = %#v", data["journeys"]) + } + products, ok := data["products"].([]any) + if !ok || len(products) != 2 { + t.Fatalf("products = %#v", data["products"]) + } + if len(result.NextActions) == 0 || + !strings.Contains(result.NextActions[0].Description, "bisnap") { + t.Fatalf("next_actions = %#v", result.NextActions) + } +} + +func TestHybridVerifyDoesNotPromoteLocalProofToSandboxRequirement(t *testing.T) { + project := createJourneyProject(t, "http://127.0.0.1:1") + path := writeHybridEvidence(t, project, true) + + result, exit := executeJSON( + t, + "verify", + "--evidence", path, + "--project-dir", project, + "--json", "--non-interactive", + ) + if exit != 3 || result.Status != contracts.StatusBlocked || + !result.HasCode("VERIFY_EVIDENCE_INCOMPLETE") { + t.Fatalf("exit = %d, result = %#v", exit, result) + } +} + +func TestHybridVerifyRejectsStaleManifestRepositoryPackAndOperationBindings(t *testing.T) { + project := createJourneyProject(t, "http://127.0.0.1:1") + tests := []struct { + name string + mutate func(map[string]any) + }{ + { + name: "manifest hash", + mutate: func(document map[string]any) { + journey := document["journeys"].([]any)[0].(map[string]any) + journey["manifest_hash"] = strings.Repeat("f", 64) + }, + }, + { + name: "repository revision", + mutate: func(document map[string]any) { + journey := document["journeys"].([]any)[0].(map[string]any) + journey["repository_commit"] = strings.Repeat("e", 64) + }, + }, + { + name: "pack version", + mutate: func(document map[string]any) { + journey := document["journeys"].([]any)[0].(map[string]any) + journey["pack_version"] = "9.9.9" + }, + }, + { + name: "operation binding", + mutate: func(document map[string]any) { + journey := document["journeys"].([]any)[0].(map[string]any) + journey["operation_id"] = "op_other" + }, + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + path := writeHybridEvidenceDocument(t, project, func(document map[string]any) { + test.mutate(document) + }) + result, exit := executeJSON( + t, + "verify", + "--evidence", path, + "--project-dir", project, + "--json", "--non-interactive", + ) + if exit != 3 || result.Status != contracts.StatusBlocked || + !result.HasCode("VERIFY_EVIDENCE_CONTEXT_MISMATCH") || + !result.HasCode("VERIFY_EVIDENCE_INCOMPLETE") { + t.Fatalf("exit = %d, result = %#v", exit, result) + } + }) + } +} + +func TestEvidenceShowPreservesHybridOperationAndStageFacts(t *testing.T) { + project := createJourneyProject(t, "http://127.0.0.1:1") + path := writeHybridEvidence(t, project, false) + + result, exit := executeJSON( + t, + "evidence", "show", + "--file", path, + "--project-dir", project, + "--json", "--non-interactive", + ) + if exit != 0 || result.Status != contracts.StatusPass { + t.Fatalf("exit = %d, result = %#v", exit, result) + } + data, ok := result.Data.(map[string]any) + if !ok { + t.Fatalf("data = %#v", result.Data) + } + journeys, ok := data["journeys"].([]any) + if !ok || len(journeys) != 1 { + t.Fatalf("journeys = %#v", data["journeys"]) + } + proofs := journeys[0].(map[string]any)["proofs"].([]any) + first := proofs[0].(map[string]any) + if first["operation_id"] == "" || first["stage"] == "" { + t.Fatalf("proofs = %#v", proofs) + } +} + func runGit(t *testing.T, project string, args ...string) string { t.Helper() command := exec.Command("git", args...) @@ -287,6 +444,7 @@ func completeBundleForProject(t *testing.T, project string) evidence.Bundle { ManifestVersion: 1, PackID: "snap", PackVersion: "0.1.0", + OperationID: "op_snap_test", ManifestHash: hex.EncodeToString(manifestSum[:]), RepositoryCommit: hex.EncodeToString(revisionSum[:]), Journey: "snap.checkout", @@ -325,5 +483,121 @@ func completeBundleForProject(t *testing.T, project string) evidence.Bundle { }, }, }, + RequiredProofs: []evidence.RequiredProof{ + {ID: "snap.provider-status", Level: evidence.ProofSandbox}, + {ID: "snap.merchant-callback", Level: evidence.ProofLocal}, + }, } } + +func writeHybridEvidence(t *testing.T, project string, localOnly bool) string { + t.Helper() + return writeHybridEvidenceDocument(t, project, func(document map[string]any) { + if !localOnly { + return + } + journeys := document["journeys"].([]any) + snapJourney := journeys[0].(map[string]any) + proofs := snapJourney["proofs"].([]any) + proofs[0].(map[string]any)["level"] = "local" + }) +} + +func writeHybridEvidenceDocument( + t *testing.T, + project string, + mutate func(map[string]any), +) string { + t.Helper() + snapBundle := completeBundleForProject(t, project) + bisnapBundle := evidence.Bundle{ + SchemaVersion: evidence.SchemaVersion, + CLIVersion: "0.1.0-test", + ManifestVersion: snapBundle.ManifestVersion, + PackID: "bisnap", + PackVersion: "0.1.0", + OperationID: "op_bisnap_test", + ManifestHash: snapBundle.ManifestHash, + RepositoryCommit: snapBundle.RepositoryCommit, + Journey: "bisnap.status", + Environment: "sandbox", + StartedAt: snapBundle.StartedAt, + CompletedAt: snapBundle.CompletedAt, + SafeReferences: map[string]string{ + "provider_transaction_id": "partner-ref-001", + }, + Proofs: []evidence.Proof{ + { + ID: "bisnap.notification", + OperationID: "op_bisnap_test", + Stage: "notification", + Level: evidence.ProofLocal, + Source: "merchant_application", + ObservedAt: snapBundle.CompletedAt, + Status: "blocked", + Summary: map[string]any{ + "route": "/api/payments/bisnap/notification", + }, + }, + }, + RequiredProofs: []evidence.RequiredProof{ + {ID: "bisnap.provider-status", Level: evidence.ProofSandbox}, + {ID: "bisnap.notification", Level: evidence.ProofLocal}, + }, + MissingEvidence: []string{"provider_status"}, + } + document := map[string]any{ + "schema_version": evidence.SchemaVersion, + "cli_version": "0.1.0-test", + "manifest_version": 1, + "environment": "sandbox", + "journeys": []any{ + mustMarshalMap(t, snapBundle), + mustMarshalMap(t, bisnapBundle), + }, + } + if mutate != nil { + mutate(document) + } + return writeEvidenceDocument(t, project, document) +} + +func mustMarshalMap(t *testing.T, value any) map[string]any { + t.Helper() + data, err := json.Marshal(value) + if err != nil { + t.Fatal(err) + } + var result map[string]any + if err := json.Unmarshal(data, &result); err != nil { + t.Fatal(err) + } + return result +} + +func writeEvidenceDocument(t *testing.T, project string, document map[string]any) string { + t.Helper() + data, err := json.MarshalIndent(document, "", " ") + if err != nil { + t.Fatal(err) + } + data = append(data, '\n') + dir := filepath.Join(project, ".midtrans", "evidence", "hybrid") + if err := os.MkdirAll(dir, 0o700); err != nil { + t.Fatal(err) + } + path := filepath.Join(dir, "evidence.json") + if err := os.WriteFile(path, data, 0o600); err != nil { + t.Fatal(err) + } + sum := sha256.Sum256(data) + checksum := hex.EncodeToString(sum[:]) + " evidence.json\n" + if err := os.WriteFile( + filepath.Join(dir, "SHA256SUMS"), + []byte(checksum), + 0o600, + ); err != nil { + t.Fatal(err) + } + return path +} diff --git a/internal/app/commands_verify.go b/internal/app/commands_verify.go index c0ff3bf..a4b2b0b 100644 --- a/internal/app/commands_verify.go +++ b/internal/app/commands_verify.go @@ -2,11 +2,14 @@ package app import ( "errors" + "slices" + "strings" "github.com/spf13/cobra" "github.com/veritrans/midtrans-cli/internal/contracts" "github.com/veritrans/midtrans-cli/internal/evidence" "github.com/veritrans/midtrans-cli/internal/inspection" + "github.com/veritrans/midtrans-cli/internal/manifest" "github.com/veritrans/midtrans-cli/internal/presentation" "github.com/veritrans/midtrans-cli/internal/project" "github.com/veritrans/midtrans-cli/internal/verify" @@ -18,8 +21,13 @@ func newVerifyCommand(flags *globalFlags, deps Dependencies) *cobra.Command { Use: "verify", Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, args []string) error { - pack, ok := deps.Packs.Get(product) - if !ok || product != "snap" { + value, invalidResult := loadValidatedManifest("verify", flags.projectDir, deps) + if invalidResult != nil { + result := *invalidResult + return writeResult(deps, flags, result) + } + requiredJourneys := requiredJourneysForVerification(value) + if product != "" && product != "snap" && !manifestRequiresProduct(requiredJourneys, product) { result := contracts.NewIncompatibleResult( "verify", "CAPABILITY_NOT_INSTALLED", @@ -28,11 +36,7 @@ func newVerifyCommand(flags *globalFlags, deps Dependencies) *cobra.Command { result.CLIVersion = deps.Version.Version return writeResult(deps, flags, result) } - value, invalidResult := loadValidatedManifest("verify", flags.projectDir, deps) - if invalidResult != nil { - result := *invalidResult - return writeResult(deps, flags, result) - } + findings := []contracts.Finding{} report, err := inspection.Inspect(flags.projectDir) if err != nil { @@ -40,62 +44,61 @@ func newVerifyCommand(flags *globalFlags, deps Dependencies) *cobra.Command { result.ManifestVersion = value.SchemaVersion return writeResult(deps, flags, result) } - findings = append(findings, pack.Evaluate(value, report)...) - var bundle evidence.Bundle - if evidenceFile != "" { - bundle, err = (evidence.Store{ - ProjectDir: flags.projectDir, - }).Read(evidenceFile) - if err != nil { - result := evidenceFailureResult("verify", deps) + requiredProducts := uniqueJourneyProducts(requiredJourneys, deps, product) + packVersions := make([]contracts.PackVersion, 0, len(requiredProducts)) + for _, packID := range requiredProducts { + pack, ok := deps.Packs.Get(packID) + if !ok { + result := contracts.NewIncompatibleResult( + "verify", + "CAPABILITY_NOT_INSTALLED", + "requested product pack is unavailable", + ) + result.CLIVersion = deps.Version.Version result.ManifestVersion = value.SchemaVersion return writeResult(deps, flags, result) } - matches, matchErr := evidenceMatchesProject( - flags.projectDir, - value.SchemaVersion, - pack.Descriptor().ID, - pack.Descriptor().Version, - bundle, - ) - if matchErr != nil { + findings = append(findings, pack.Evaluate(value, report)...) + descriptor := pack.Descriptor() + packVersions = append(packVersions, contracts.PackVersion{ + ID: descriptor.ID, Version: descriptor.Version, + }) + } + + document := evidence.Document{} + if evidenceFile != "" { + document, err = (evidence.Store{ProjectDir: flags.projectDir}).ReadDocument(evidenceFile) + if err != nil { result := evidenceFailureResult("verify", deps) result.ManifestVersion = value.SchemaVersion return writeResult(deps, flags, result) } - if !matches { - bundle = evidence.Bundle{} - findings = append(findings, contracts.Finding{ - Code: "VERIFY_EVIDENCE_CONTEXT_MISMATCH", - Severity: "warning", - Message: "evidence does not describe the current repository state", - }) - } } - required := []verify.RequiredProof{ - { - ID: "snap.provider-status", - Level: evidence.ProofSandbox, - }, - { - ID: "snap.merchant-callback", - Level: evidence.ProofLocal, - }, + + journeyInputs, verifyData, contextFinding, nextActions, ok := verifyJourneysForProject( + flags.projectDir, + value, + deps, + requiredJourneys, + document, + evidenceFile, + ) + if contextFinding.Code != "" { + findings = append(findings, contextFinding) } result := verify.Run(verify.Input{ Command: "verify", LocalFindings: findings, - Required: required, - Bundle: bundle, + Journeys: journeyInputs, }) result.CLIVersion = deps.Version.Version result.ManifestVersion = value.SchemaVersion - result.Packs = []contracts.PackVersion{{ - ID: pack.Descriptor().ID, - Version: pack.Descriptor().Version, - }} - result.Data = verificationPresentationData(result.Status, required, bundle, evidenceFile) + result.Packs = packVersions + result.Data = verifyData + if ok { + result.NextActions = nextActions + } return writeResult(deps, flags, result) }, } @@ -109,40 +112,265 @@ func newVerifyCommand(flags *globalFlags, deps Dependencies) *cobra.Command { return withProjectMode(command, project.Existing, "verify") } -func verificationPresentationData( - status contracts.Status, - required []verify.RequiredProof, - bundle evidence.Bundle, +func verifyJourneysForProject( + projectDir string, + value manifest.Manifest, + deps Dependencies, + requiredJourneys []string, + document evidence.Document, evidencePath string, -) presentation.VerifyData { +) ([]verify.Journey, presentation.VerifyData, contracts.Finding, []contracts.NextAction, bool) { + bundlesByJourney := make(map[string]evidence.Bundle, len(document.Journeys)) + for _, bundle := range document.Journeys { + bundlesByJourney[bundle.Journey] = bundle + } + + journeyInputs := make([]verify.Journey, 0, len(requiredJourneys)) + presentationJourneys := make([]presentation.VerifyJourney, 0, len(requiredJourneys)) + productStates := map[string]string{} + nextActions := []contracts.NextAction{} + contextMismatch := false + contextFinding := contracts.Finding{} + proofRows := []presentation.VerifyProof{} + + for _, journeyID := range requiredJourneys { + packID := journeyProduct(journeyID, deps) + required := defaultRequiredProofsForJourney(journeyID) + bundle := bundlesByJourney[journeyID] + if len(bundle.RequiredProofs) != 0 { + required = verifyRequiredProofs(bundle.RequiredProofs) + } + if bundle.Journey != "" { + matches, err := evidenceMatchesProject(projectDir, value.SchemaVersion, deps, bundle) + if err != nil { + contextMismatch = true + } + if err == nil && !matches { + contextMismatch = true + } + if err != nil || !matches { + bundle = evidence.Bundle{} + } + } + if len(required) == 0 { + required = verifyRequiredProofs(bundle.RequiredProofs) + } + journeyInputs = append(journeyInputs, verify.Journey{ + ID: journeyID, + Required: required, + Bundle: bundle, + }) + + status, missing := verificationStatus(required, bundle) + if len(requiredJourneys) == 1 { + proofRows = verificationProofRows(required, bundle) + } + presentationJourneys = append(presentationJourneys, presentation.VerifyJourney{ + ID: journeyID, + Product: packID, + OperationID: bundle.OperationID, + Status: status, + MissingEvidence: append([]string(nil), missing...), + }) + productStates[packID] = aggregateProofState(productStates[packID], status) + if status != "pass" { + description := "collect required evidence for " + journeyID + if packID != "" { + description = "collect required " + packID + " evidence for " + journeyID + } + if len(missing) != 0 { + description += ": " + strings.Join(missing, ", ") + } + nextActions = append(nextActions, contracts.NextAction{ + Action: "collect_evidence", + Description: description, + Arguments: map[string]any{ + "journey": journeyID, + "product": packID, + }, + }) + } + } + + products := make([]presentation.VerifyProduct, 0, len(productStates)) + for _, packID := range uniqueJourneyProducts(requiredJourneys, deps, "") { + status := productStates[packID] + if status == "" { + status = "missing" + } + products = append(products, presentation.VerifyProduct{ + ID: packID, + Status: status, + }) + } + + if contextMismatch { + contextFinding = contracts.Finding{ + Code: "VERIFY_EVIDENCE_CONTEXT_MISMATCH", + Severity: "warning", + Message: "evidence does not describe the current repository state", + } + } + data := presentation.VerifyData{ - ProofState: "incomplete", + ProofState: "", EvidencePath: evidencePath, - Proofs: make([]presentation.VerifyProof, 0, len(required)), + Proofs: proofRows, + Journeys: presentationJourneys, + Products: products, + } + for _, journeyData := range presentationJourneys { + data.ProofState = aggregateProofState(data.ProofState, journeyData.Status) } - if status == contracts.StatusPass || status == contracts.StatusWarn { + if data.ProofState == "pass" { data.ProofState = "verified" } + if data.ProofState == "" { + data.ProofState = "incomplete" + } + return journeyInputs, data, contextFinding, nextActions, true +} + +func requiredJourneysForVerification(value manifest.Manifest) []string { + if len(value.Verification.Required) != 0 { + return append([]string(nil), value.Verification.Required...) + } + return []string{"snap.checkout"} +} + +func manifestRequiresProduct(requiredJourneys []string, product string) bool { + for _, journeyID := range requiredJourneys { + if strings.HasPrefix(journeyID, product+".") { + return true + } + } + return false +} + +func uniqueJourneyProducts(requiredJourneys []string, deps Dependencies, selectedProduct string) []string { + seen := map[string]bool{} + products := []string{} + for _, journeyID := range requiredJourneys { + packID := journeyProduct(journeyID, deps) + if packID == "" { + continue + } + if selectedProduct != "" && selectedProduct != "snap" && packID != selectedProduct { + continue + } + if seen[packID] { + continue + } + seen[packID] = true + products = append(products, packID) + } + slices.Sort(products) + return products +} + +func journeyProduct(journeyID string, deps Dependencies) string { + handler, ok := deps.Packs.Handler(journeyID) + if ok { + return handler.Definition().Product + } + product, _, _ := strings.Cut(journeyID, ".") + return product +} + +func defaultRequiredProofsForJourney(journeyID string) []verify.RequiredProof { + switch journeyID { + case "snap.checkout": + return []verify.RequiredProof{ + {ID: "snap.provider-status", Level: evidence.ProofSandbox}, + {ID: "snap.merchant-callback", Level: evidence.ProofLocal}, + } + default: + return nil + } +} + +func verifyRequiredProofs(required []evidence.RequiredProof) []verify.RequiredProof { + if len(required) == 0 { + return nil + } + values := make([]verify.RequiredProof, 0, len(required)) + for _, proof := range required { + values = append(values, verify.RequiredProof{ + ID: proof.ID, Level: proof.Level, + }) + } + return values +} + +func verificationStatus(required []verify.RequiredProof, bundle evidence.Bundle) (string, []string) { + if len(required) == 0 { + return "missing", nil + } + proven := make(map[verify.RequiredProof]bool, len(bundle.Proofs)) + for _, proof := range bundle.Proofs { + if proof.Status == "pass" { + proven[verify.RequiredProof{ID: proof.ID, Level: proof.Level}] = true + } + } + missing := append([]string(nil), bundle.MissingEvidence...) + for _, requiredProof := range required { + if !proven[requiredProof] { + if !slices.Contains(missing, requiredProof.ID) { + missing = append(missing, requiredProof.ID) + } + } + } + if len(missing) != 0 { + if bundle.Journey != "" && len(bundle.Proofs) != 0 { + for _, proof := range bundle.Proofs { + if proof.Status == "blocked" { + return "blocked", missing + } + } + } + return "missing", missing + } + return "pass", nil +} + +func verificationProofRows(required []verify.RequiredProof, bundle evidence.Bundle) []presentation.VerifyProof { + rows := make([]presentation.VerifyProof, 0, len(required)) for _, expected := range required { - proofStatus := "missing" + status := "missing" for _, proof := range bundle.Proofs { if proof.ID == expected.ID && proof.Level == expected.Level && proof.Status == "pass" { - proofStatus = "pass" + status = "pass" break } } - data.Proofs = append(data.Proofs, presentation.VerifyProof{ - ID: expected.ID, Level: string(expected.Level), Status: proofStatus, + rows = append(rows, presentation.VerifyProof{ + ID: expected.ID, + Level: string(expected.Level), + Status: status, }) } - return data + return rows +} + +func aggregateProofState(current, next string) string { + order := map[string]int{ + "": 0, + "verified": 1, + "pass": 1, + "missing": 2, + "incomplete": 2, + "blocked": 3, + } + if order[next] > order[current] { + return next + } + return current } func evidenceMatchesProject( projectDir string, manifestVersion int, - packID string, - packVersion string, + deps Dependencies, bundle evidence.Bundle, ) (bool, error) { manifestHash, err := projectManifestHash(projectDir) @@ -156,11 +384,22 @@ func evidenceMatchesProject( if err != nil { return false, err } + pack, ok := deps.Packs.Get(bundle.PackID) + if !ok { + return false, nil + } + if bundle.OperationID != "" { + for _, proof := range bundle.Proofs { + if proof.OperationID != bundle.OperationID { + return false, nil + } + } + } return bundle.ManifestVersion == manifestVersion && + bundle.PackID == pack.Descriptor().ID && bundle.ManifestHash == manifestHash && bundle.RepositoryCommit == revision && - bundle.PackID == packID && - bundle.PackVersion == packVersion && - bundle.Journey == "snap.checkout" && - bundle.Environment == "sandbox", nil + bundle.PackVersion == pack.Descriptor().Version && + bundle.Environment == "sandbox" && + bundle.Journey != "", nil } diff --git a/internal/evidence/model.go b/internal/evidence/model.go index 876972f..d119c7d 100644 --- a/internal/evidence/model.go +++ b/internal/evidence/model.go @@ -22,12 +22,18 @@ type Proof struct { Summary map[string]any `json:"summary"` } +type RequiredProof struct { + ID string `json:"id"` + Level ProofLevel `json:"level"` +} + type Bundle struct { SchemaVersion string `json:"schema_version"` CLIVersion string `json:"cli_version"` ManifestVersion int `json:"manifest_version"` PackID string `json:"pack_id"` PackVersion string `json:"pack_version"` + OperationID string `json:"operation_id,omitempty"` ManifestHash string `json:"manifest_hash"` RepositoryCommit string `json:"repository_commit"` Journey string `json:"journey"` @@ -36,5 +42,24 @@ type Bundle struct { CompletedAt time.Time `json:"completed_at"` SafeReferences map[string]string `json:"safe_references"` Proofs []Proof `json:"proofs"` + RequiredProofs []RequiredProof `json:"required_proofs,omitempty"` MissingEvidence []string `json:"missing_evidence,omitempty"` } + +type Document struct { + SchemaVersion string `json:"schema_version"` + CLIVersion string `json:"cli_version"` + ManifestVersion int `json:"manifest_version"` + Environment string `json:"environment"` + Journeys []Bundle `json:"journeys"` +} + +func SingleJourneyDocument(bundle Bundle) Document { + return Document{ + SchemaVersion: bundle.SchemaVersion, + CLIVersion: bundle.CLIVersion, + ManifestVersion: bundle.ManifestVersion, + Environment: bundle.Environment, + Journeys: []Bundle{bundle}, + } +} diff --git a/internal/evidence/store.go b/internal/evidence/store.go index 0de4a65..b5f449b 100644 --- a/internal/evidence/store.go +++ b/internal/evidence/store.go @@ -104,30 +104,43 @@ func (s Store) Write(bundle Bundle) (string, error) { } func (s Store) Read(candidate string) (Bundle, error) { - path, err := safepath.Existing(s.ProjectDir, candidate) + document, err := s.ReadDocument(candidate) if err != nil { + return Bundle{}, err + } + if len(document.Journeys) != 1 { return Bundle{}, ErrInvalid } + return document.Journeys[0], nil +} + +func (s Store) ReadDocument(candidate string) (Document, error) { + path, err := safepath.Existing(s.ProjectDir, candidate) + if err != nil { + return Document{}, ErrInvalid + } data, err := readBounded(path, maxBundleBytes) if err != nil { - return Bundle{}, ErrInvalid + return Document{}, ErrInvalid } if err := verifyChecksum(s.ProjectDir, path, data); err != nil { - return Bundle{}, ErrInvalid + return Document{}, ErrInvalid } - var bundle Bundle - decoder := json.NewDecoder(bytes.NewReader(data)) - decoder.DisallowUnknownFields() - if err := decoder.Decode(&bundle); err != nil { - return Bundle{}, ErrInvalid + var document Document + if decodeErr := decodeStrict(data, &document); decodeErr == nil { + if err := ValidateDocument(document); err != nil { + return Document{}, err + } + return document, nil } - if err := decoder.Decode(&struct{}{}); !errors.Is(err, io.EOF) { - return Bundle{}, ErrInvalid + var bundle Bundle + if err := decodeStrict(data, &bundle); err != nil { + return Document{}, ErrInvalid } if err := Validate(bundle); err != nil { - return Bundle{}, err + return Document{}, err } - return bundle, nil + return SingleJourneyDocument(bundle), nil } func Validate(bundle Bundle) error { @@ -166,6 +179,40 @@ func Validate(bundle Bundle) error { return ErrInvalid } } + for _, required := range bundle.RequiredProofs { + if required.ID == "" || + (required.Level != ProofLocal && required.Level != ProofSandbox) { + return ErrInvalid + } + } + return nil +} + +func ValidateDocument(document Document) error { + if document.SchemaVersion != SchemaVersion || + document.CLIVersion == "" || + document.ManifestVersion != 1 || + document.Environment != "sandbox" || + len(document.Journeys) == 0 { + return ErrInvalid + } + for _, journey := range document.Journeys { + if err := Validate(journey); err != nil { + return err + } + } + return nil +} + +func decodeStrict(data []byte, target any) error { + decoder := json.NewDecoder(bytes.NewReader(data)) + decoder.DisallowUnknownFields() + if err := decoder.Decode(target); err != nil { + return err + } + if err := decoder.Decode(&struct{}{}); !errors.Is(err, io.EOF) { + return ErrInvalid + } return nil } diff --git a/internal/presentation/model.go b/internal/presentation/model.go index 00b0615..c351bf3 100644 --- a/internal/presentation/model.go +++ b/internal/presentation/model.go @@ -44,9 +44,24 @@ type VerifyProof struct { // VerifyData is the bounded verification state emitted by midtrans verify. type VerifyData struct { - ProofState string `json:"proof_state"` - Proofs []VerifyProof `json:"proofs"` - EvidencePath string `json:"evidence_path,omitempty"` + ProofState string `json:"proof_state"` + Proofs []VerifyProof `json:"proofs"` + EvidencePath string `json:"evidence_path,omitempty"` + Journeys []VerifyJourney `json:"journeys,omitempty"` + Products []VerifyProduct `json:"products,omitempty"` +} + +type VerifyJourney struct { + ID string `json:"id"` + Product string `json:"product,omitempty"` + OperationID string `json:"operation_id,omitempty"` + Status string `json:"status"` + MissingEvidence []string `json:"missing_evidence,omitempty"` +} + +type VerifyProduct struct { + ID string `json:"id"` + Status string `json:"status"` } func Build(result contracts.Result) (Model, bool) { @@ -149,7 +164,7 @@ func initModel(result contracts.Result) (Model, bool) { func verifyModel(result contracts.Result) (Model, bool) { var data VerifyData - if !decodeData(result.Data, &data) || data.ProofState == "" || len(data.Proofs) == 0 { + if !decodeData(result.Data, &data) || data.ProofState == "" || (len(data.Proofs) == 0 && len(data.Journeys) == 0) { return Model{}, false } rows := []Row{{ @@ -164,6 +179,23 @@ func verifyModel(result contracts.Result) (Model, bool) { Detail: titleCase(proof.Status) + " · " + titleCase(proof.Level), }) } + for _, journey := range data.Journeys { + if journey.ID == "" || journey.Status == "" { + return Model{}, false + } + detail := titleCase(journey.Status) + if journey.Product != "" { + detail += " · " + titleCase(journey.Product) + } + if len(journey.MissingEvidence) != 0 { + detail += " · Missing " + strings.Join(journey.MissingEvidence, ", ") + } + rows = append(rows, Row{ + State: stateForProof(journey.Status), + Label: journey.ID, + Detail: detail, + }) + } if data.EvidencePath != "" { rows = append(rows, Row{State: "✓", Label: "Evidence", Detail: data.EvidencePath}) } diff --git a/internal/verify/verify.go b/internal/verify/verify.go index 13530f0..c84be7e 100644 --- a/internal/verify/verify.go +++ b/internal/verify/verify.go @@ -10,11 +10,18 @@ type RequiredProof struct { Level evidence.ProofLevel } +type Journey struct { + ID string + Required []RequiredProof + Bundle evidence.Bundle +} + type Input struct { Command string LocalFindings []contracts.Finding Required []RequiredProof Bundle evidence.Bundle + Journeys []Journey } func Run(input Input) contracts.Result { @@ -27,13 +34,27 @@ func Run(input Input) contracts.Result { if len(input.LocalFindings) > 0 { result.Status = contracts.StatusWarn } + if len(input.Journeys) != 0 { + for _, journey := range input.Journeys { + result = applyRequiredProofs(result, journey.Required, journey.Bundle) + } + return result + } + return applyRequiredProofs(result, input.Required, input.Bundle) +} + +func applyRequiredProofs( + result contracts.Result, + required []RequiredProof, + bundle evidence.Bundle, +) contracts.Result { proven := make(map[RequiredProof]bool) - for _, proof := range input.Bundle.Proofs { + for _, proof := range bundle.Proofs { if proof.Status == "pass" { proven[RequiredProof{ID: proof.ID, Level: proof.Level}] = true } } - for _, required := range input.Required { + for _, required := range required { if !proven[required] { result.Status = contracts.StatusBlocked result.Findings = append(result.Findings, contracts.Finding{ diff --git a/schemas/evidence-v1.schema.json b/schemas/evidence-v1.schema.json index cc05038..064ce52 100644 --- a/schemas/evidence-v1.schema.json +++ b/schemas/evidence-v1.schema.json @@ -8,16 +8,7 @@ "schema_version", "cli_version", "manifest_version", - "pack_id", - "pack_version", - "manifest_hash", - "repository_commit", - "journey", - "environment", - "started_at", - "completed_at", - "safe_references", - "proofs" + "environment" ], "properties": { "schema_version": {"const": "1.0"}, @@ -25,6 +16,7 @@ "manifest_version": {"const": 1}, "pack_id": {"type": "string", "minLength": 1}, "pack_version": {"type": "string", "minLength": 1}, + "operation_id": {"type": "string", "pattern": "^op_[a-z0-9_]+$"}, "manifest_hash": {"type": "string", "pattern": "^[0-9a-f]{64}$"}, "repository_commit": { "type": "string", @@ -60,9 +52,102 @@ } } }, + "required_proofs": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "required": ["id", "level"], + "properties": { + "id": {"type": "string", "minLength": 1}, + "level": {"enum": ["local", "sandbox"]} + } + } + }, "missing_evidence": { "type": "array", "items": {"type": "string"} + }, + "journeys": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "required": [ + "schema_version", + "cli_version", + "manifest_version", + "pack_id", + "pack_version", + "manifest_hash", + "repository_commit", + "journey", + "environment", + "started_at", + "completed_at", + "safe_references", + "proofs" + ], + "properties": { + "schema_version": {"const": "1.0"}, + "cli_version": {"type": "string", "minLength": 1}, + "manifest_version": {"const": 1}, + "pack_id": {"type": "string", "minLength": 1}, + "pack_version": {"type": "string", "minLength": 1}, + "operation_id": {"type": "string", "pattern": "^op_[a-z0-9_]+$"}, + "manifest_hash": {"type": "string", "pattern": "^[0-9a-f]{64}$"}, + "repository_commit": { + "type": "string", + "pattern": "^(?:[0-9a-f]{40}|[0-9a-f]{64})$" + }, + "journey": {"type": "string", "minLength": 1}, + "environment": {"const": "sandbox"}, + "started_at": {"type": "string", "format": "date-time"}, + "completed_at": {"type": "string", "format": "date-time"}, + "safe_references": { + "type": "object", + "additionalProperties": false, + "properties": { + "order_id": {"type": "string"}, + "provider_transaction_id": {"type": "string"} + } + }, + "proofs": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "required": ["id", "operation_id", "stage", "level", "source", "observed_at", "status", "summary"], + "properties": { + "id": {"type": "string", "minLength": 1}, + "operation_id": {"type": "string", "pattern": "^op_[a-z0-9_]+$"}, + "stage": {"type": "string", "minLength": 1}, + "level": {"enum": ["local", "sandbox"]}, + "source": {"type": "string", "minLength": 1}, + "observed_at": {"type": "string", "format": "date-time"}, + "status": {"enum": ["pass", "fail", "blocked"]}, + "summary": {"type": "object"} + } + } + }, + "required_proofs": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "required": ["id", "level"], + "properties": { + "id": {"type": "string", "minLength": 1}, + "level": {"enum": ["local", "sandbox"]} + } + } + }, + "missing_evidence": { + "type": "array", + "items": {"type": "string"} + } + } + } } } } From e2b795f0dd9c87bbd0893fd2e9a9e2ceb643ad75 Mon Sep 17 00:00:00 2001 From: "m.salis" Date: Mon, 27 Jul 2026 16:29:49 +0700 Subject: [PATCH 61/73] feat: enforce sandbox-only journey execution --- .../task-12-report.md | 66 +++++++++++++++++++ internal/app/app_test.go | 48 ++++++++++++++ internal/app/checkout_runner.go | 4 +- internal/app/commands_sandbox.go | 2 +- internal/app/commands_test.go | 46 +++++++++++++ internal/app/journey_runner.go | 3 +- internal/app/provider_http.go | 19 ++++++ internal/journey/types.go | 3 + internal/policy/operation.go | 57 ++++++++++++++++ internal/policy/policy_test.go | 40 +++++++++++ packs/snap/journey.go | 6 +- test/e2e/security_test.go | 52 +++++++++++++++ 12 files changed, 341 insertions(+), 5 deletions(-) create mode 100644 internal/app/provider_http.go diff --git a/.superpowers/sdd/2026-07-26-midtrans-cli-multi-product-parity/task-12-report.md b/.superpowers/sdd/2026-07-26-midtrans-cli-multi-product-parity/task-12-report.md index ca60233..d104840 100644 --- a/.superpowers/sdd/2026-07-26-midtrans-cli-multi-product-parity/task-12-report.md +++ b/.superpowers/sdd/2026-07-26-midtrans-cli-multi-product-parity/task-12-report.md @@ -56,3 +56,69 @@ Result: passed on Monday, July 27, 2026. - Production host allowlisting and zero-production mutation enforcement remain out of scope for Slice A. + +## Slice B + +Implemented sandbox-only execution guards for provider-facing journey HTTP +dispatch without blocking localhost merchant callback verification. + +## What Changed + +- Centralized the sandbox host allowlist in `internal/policy/operation.go`: + - `app.sandbox.midtrans.com` + - `api.sandbox.midtrans.com` + - `merchants.sbx.midtrans.com` + - `merchants-app.sbx.midtrans.com` + - `simulator.sandbox.midtrans.com` +- Added `ValidateJourneySandboxURL` and `WrapSandboxJourneyDoer` so every + provider-facing request is rejected before the underlying `Do` call when the + request uses: + - a production host + - a non-HTTPS scheme + - userinfo + - a non-443 port + - a host outside the compiled pack sandbox host set +- Forced wrapped `*http.Client` provider dispatch to stop at the first redirect + response instead of following it. +- Added `internal/app/provider_http.go` and routed provider HTTP through it for: + - generic journey execution/resume + - Snap checkout provider create/status calls + - `sandbox status` +- Split shared journey runtime HTTP into: + - provider-facing `HTTP` + - merchant-local `LocalHTTP` + + so Snap local callback verification can keep using localhost without + weakening the provider guard. +- Added focused safety tests for: + - zero underlying calls on rejected provider targets + - production-target rejection through an app-level handler path + - compiled pack sandbox host audit in `test/e2e/security_test.go` + +## Validation + +Focused RED: + +```sh +go test ./internal/policy ./internal/app ./test/e2e -run 'TestProduction|TestSandboxJourneyDoer|TestSecurityCompiledJourneyHosts' -count=1 +``` + +Result: failed first because the wrapper and sandbox URL validator did not +exist yet. + +Focused GREEN: + +```sh +go test ./internal/policy ./internal/app ./test/e2e -run 'TestProduction|TestSandboxJourneyDoer|TestSecurityCompiledJourneyHosts' -count=1 +go test ./internal/policy ./internal/app ./test/e2e -count=1 +``` + +Result: passed. + +Full: + +```sh +go test ./... -count=1 +``` + +Result: passed on Monday, July 27, 2026. diff --git a/internal/app/app_test.go b/internal/app/app_test.go index 90aa601..4a3a2d7 100644 --- a/internal/app/app_test.go +++ b/internal/app/app_test.go @@ -2224,6 +2224,54 @@ func TestSandboxStatusHasNoEndpointOverrideOrTokenCommand(t *testing.T) { } } +func TestProductionJourneyTargetsAreRejectedBeforeUnderlyingHTTPDispatch(t *testing.T) { + project := createJourneyProject(t, "http://127.0.0.1:1") + configureManifest(t, project, func(value *manifest.Manifest) { + value.Integrations["unsafe"] = manifest.Integration{ + ConfigVersion: 1, + Credentials: "classic", + } + value.Routing["refund-status"] = "unsafe" + }) + registry, err := packs.NewRegistry(common.New(), snap.New(), staticTestPack{ + descriptor: packs.Descriptor{ + ID: "unsafe", + Version: "test", + Journeys: []string{"unsafe.refund-status"}, + SandboxHosts: []string{"api.sandbox.midtrans.com"}, + }, + handlers: []journey.Handler{unsafeHTTPHandler{ + definition: journey.Definition{ID: "unsafe.refund-status", Product: "unsafe", Intent: "refund-status"}, + rawURL: "https://api.midtrans.com/v2/charge", + }}, + }) + if err != nil { + t.Fatal(err) + } + calls := 0 + result, exit := executeJSONWithDependencies( + t, + app.Dependencies{ + Version: version.Info{Version: "0.1.0-test"}, + Packs: registry, + HTTP: appDoerFunc(func(*http.Request) (*http.Response, error) { + calls++ + return nil, nil + }), + }, + "test", "refund-status", + "--amount", "10000", + "--execute", + "--project-dir", project, + ) + if exit != 3 || result.Status != contracts.StatusBlocked || !result.HasCode("POLICY_TARGET_NOT_ALLOWED") { + t.Fatalf("exit = %d, result = %#v", exit, result) + } + if calls != 0 { + t.Fatalf("underlying HTTP was called %d times", calls) + } +} + func TestOmittedGetenvDependencyDoesNotPanic(t *testing.T) { project := t.TempDir() if _, err := manifest.Init(project); err != nil { diff --git a/internal/app/checkout_runner.go b/internal/app/checkout_runner.go index dd45270..b678355 100644 --- a/internal/app/checkout_runner.go +++ b/internal/app/checkout_runner.go @@ -89,8 +89,8 @@ func runCheckout( } startedAt := time.Now().UTC() journey, runErr := (snap.JourneyRunner{ - Tokens: snap.Client{HTTP: deps.HTTP, ServerKey: serverKey}, - Status: snap.Client{HTTP: deps.HTTP, ServerKey: serverKey}, + Tokens: snap.Client{HTTP: providerJourneyHTTP(deps, "snap"), ServerKey: serverKey}, + Status: snap.Client{HTTP: providerJourneyHTTP(deps, "snap"), ServerKey: serverKey}, Local: snap.MerchantVerifier{ Manifest: value, ServerKey: serverKey, diff --git a/internal/app/commands_sandbox.go b/internal/app/commands_sandbox.go index 45d0431..bd3bc49 100644 --- a/internal/app/commands_sandbox.go +++ b/internal/app/commands_sandbox.go @@ -165,7 +165,7 @@ func newSandboxStatusCommand( } status, err := (snap.Client{ - HTTP: deps.HTTP, + HTTP: providerJourneyHTTP(deps, "snap"), ServerKey: serverKey, }).Status(cmd.Context(), orderID) if err != nil { diff --git a/internal/app/commands_test.go b/internal/app/commands_test.go index 115ff41..4c4928f 100644 --- a/internal/app/commands_test.go +++ b/internal/app/commands_test.go @@ -1134,3 +1134,49 @@ func (staticTestHandler) Execute(context.Context, journey.Request, journey.Runti func (staticTestHandler) Resume(context.Context, journey.Request, journey.Runtime, operations.Record) journey.Outcome { return journey.Outcome{State: journey.Passed} } + +type unsafeHTTPHandler struct { + definition journey.Definition + rawURL string +} + +func (h unsafeHTTPHandler) Definition() journey.Definition { return h.definition } + +func (h unsafeHTTPHandler) Plan(context.Context, journey.Request, journey.Runtime) journey.Outcome { + return journey.Outcome{State: journey.Planned} +} + +func (h unsafeHTTPHandler) Execute( + ctx context.Context, + _ journey.Request, + runtime journey.Runtime, +) journey.Outcome { + request, err := http.NewRequestWithContext(ctx, http.MethodPost, h.rawURL, nil) + if err != nil { + return journey.Outcome{ + State: journey.Blocked, + Finding: &contracts.Finding{ + Code: "UNSAFE_REQUEST_INVALID", Severity: "blocking", Message: err.Error(), + }, + } + } + _, err = runtime.HTTP.Do(request) + if err != nil { + return journey.Outcome{ + State: journey.Blocked, + Finding: &contracts.Finding{ + Code: "POLICY_TARGET_NOT_ALLOWED", Severity: "blocking", Message: err.Error(), + }, + } + } + return journey.Outcome{State: journey.Passed} +} + +func (h unsafeHTTPHandler) Resume( + ctx context.Context, + request journey.Request, + runtime journey.Runtime, + record operations.Record, +) journey.Outcome { + return h.Execute(ctx, request, runtime) +} diff --git a/internal/app/journey_runner.go b/internal/app/journey_runner.go index aa6ba25..70c5357 100644 --- a/internal/app/journey_runner.go +++ b/internal/app/journey_runner.go @@ -56,7 +56,8 @@ func runGenericJourney( engine := journeypkg.Engine{ Store: operations.Store{ProjectDir: request.ProjectDir}, Runtime: journeypkg.Runtime{ - HTTP: deps.HTTP, + HTTP: providerJourneyHTTP(deps, handler.Definition().Product), + LocalHTTP: deps.HTTP, ResolveCredential: deps.ResolveCredential, Now: func() time.Time { return time.Now().UTC() }, NewOperationID: func() string { diff --git a/internal/app/provider_http.go b/internal/app/provider_http.go new file mode 100644 index 0000000..f01ff78 --- /dev/null +++ b/internal/app/provider_http.go @@ -0,0 +1,19 @@ +package app + +import ( + "github.com/veritrans/midtrans-cli/internal/policy" + "github.com/veritrans/midtrans-cli/internal/sandbox" +) + +func providerJourneyHTTP(deps Dependencies, product string) sandbox.Doer { + if deps.HTTP == nil { + return nil + } + allowedHosts := []string(nil) + if deps.Packs != nil { + if pack, ok := deps.Packs.Get(product); ok { + allowedHosts = pack.Descriptor().SandboxHosts + } + } + return policy.WrapSandboxJourneyDoer(deps.HTTP, allowedHosts) +} diff --git a/internal/journey/types.go b/internal/journey/types.go index 30faab6..6422fc9 100644 --- a/internal/journey/types.go +++ b/internal/journey/types.go @@ -76,6 +76,9 @@ type Runtime struct { HTTP interface { Do(*http.Request) (*http.Response, error) } + LocalHTTP interface { + Do(*http.Request) (*http.Response, error) + } ResolveCredential func(context.Context, string, string) ([]byte, error) Now func() time.Time NewOperationID func() string diff --git a/internal/policy/operation.go b/internal/policy/operation.go index 9e8e7d1..a075a52 100644 --- a/internal/policy/operation.go +++ b/internal/policy/operation.go @@ -5,6 +5,9 @@ import ( "encoding/hex" "encoding/json" "fmt" + "net/http" + + "github.com/veritrans/midtrans-cli/internal/sandbox" ) type Class string @@ -39,6 +42,14 @@ type Decision struct { Message string } +var sandboxJourneyHosts = []string{ + "app.sandbox.midtrans.com", + "api.sandbox.midtrans.com", + "merchants.sbx.midtrans.com", + "merchants-app.sbx.midtrans.com", + "simulator.sandbox.midtrans.com", +} + func BuildPlan(operation Operation) (Plan, error) { if operation.Environment != "sandbox" { return Plan{}, fmt.Errorf("POLICY_PRODUCTION_DISABLED: environment must be sandbox") @@ -78,3 +89,49 @@ func Authorize(plan Plan, authorization Authorization) Decision { } return Decision{Allowed: true} } + +func SandboxJourneyHosts() []string { + hosts := make([]string, len(sandboxJourneyHosts)) + copy(hosts, sandboxJourneyHosts) + return hosts +} + +func ValidateJourneySandboxURL(rawURL string, allowedHosts []string) error { + if err := ValidateSandboxURL(rawURL, sandboxJourneyHosts); err != nil { + return err + } + if len(allowedHosts) == 0 { + return fmt.Errorf("POLICY_TARGET_NOT_ALLOWED: journey has no allowlisted sandbox hosts") + } + return ValidateSandboxURL(rawURL, allowedHosts) +} + +func WrapSandboxJourneyDoer(base sandbox.Doer, allowedHosts []string) sandbox.Doer { + if base == nil { + return nil + } + hosts := append([]string(nil), allowedHosts...) + return sandboxJourneyDoer{base: base, allowedHosts: hosts} +} + +type sandboxJourneyDoer struct { + base sandbox.Doer + allowedHosts []string +} + +func (d sandboxJourneyDoer) Do(request *http.Request) (*http.Response, error) { + if request == nil || request.URL == nil { + return nil, fmt.Errorf("POLICY_TARGET_NOT_ALLOWED: invalid sandbox URL") + } + if err := ValidateJourneySandboxURL(request.URL.String(), d.allowedHosts); err != nil { + return nil, err + } + if client, ok := d.base.(*http.Client); ok { + clone := *client + clone.CheckRedirect = func(*http.Request, []*http.Request) error { + return http.ErrUseLastResponse + } + return clone.Do(request) + } + return d.base.Do(request) +} diff --git a/internal/policy/policy_test.go b/internal/policy/policy_test.go index cf2fd7e..6c15935 100644 --- a/internal/policy/policy_test.go +++ b/internal/policy/policy_test.go @@ -2,7 +2,9 @@ package policy_test import ( "context" + "errors" "net" + "net/http" "strings" "testing" @@ -253,3 +255,41 @@ func TestSafeDialerRejectsUnlistedRemoteHostBeforeDial(t *testing.T) { t.Fatalf("undeclared dial host was accepted: %v", err) } } + +type countingDoer struct { + calls int + err error +} + +func (d *countingDoer) Do(*http.Request) (*http.Response, error) { + d.calls++ + return nil, d.err +} + +func TestSandboxJourneyDoerRejectsProductionAndUnsafeTargetsBeforeDispatch(t *testing.T) { + base := &countingDoer{err: errors.New("unexpected dispatch")} + doer := policy.WrapSandboxJourneyDoer(base, []string{"api.sandbox.midtrans.com"}) + + for _, rawURL := range []string{ + "https://api.midtrans.com/v2/charge", + "http://api.sandbox.midtrans.com/v2/charge", + "https://user:pass@api.sandbox.midtrans.com/v2/charge", + "https://api.sandbox.midtrans.com:444/v2/charge", + "https://api.sandbox.midtrans.com.evil.example/v2/charge", + "https://simulator.sandbox.midtrans.com/v2/charge", + } { + t.Run(rawURL, func(t *testing.T) { + request, err := http.NewRequest(http.MethodPost, rawURL, nil) + if err != nil { + t.Fatal(err) + } + _, err = doer.Do(request) + if err == nil || !strings.Contains(err.Error(), "POLICY_TARGET_NOT_ALLOWED") { + t.Fatalf("err = %v", err) + } + }) + } + if base.calls != 0 { + t.Fatalf("unsafe targets reached the underlying doer %d times", base.calls) + } +} diff --git a/packs/snap/journey.go b/packs/snap/journey.go index 57e98e3..081c343 100644 --- a/packs/snap/journey.go +++ b/packs/snap/journey.go @@ -560,13 +560,17 @@ func (h *compatibilityHandler) runnerAndInput( if err != nil { return JourneyRunner{}, input } + localHTTP := runtime.HTTP + if runtime.LocalHTTP != nil { + localHTTP = runtime.LocalHTTP + } return JourneyRunner{ Tokens: Client{HTTP: runtime.HTTP, ServerKey: serverKey}, Status: Client{HTTP: runtime.HTTP, ServerKey: serverKey}, Local: MerchantVerifier{ Manifest: request.Manifest, ServerKey: serverKey, - HTTP: localJourneyHTTPClient(runtime.HTTP), + HTTP: localJourneyHTTPClient(localHTTP), }, }, input } diff --git a/test/e2e/security_test.go b/test/e2e/security_test.go index d2fcbfc..0b1b01e 100644 --- a/test/e2e/security_test.go +++ b/test/e2e/security_test.go @@ -11,6 +11,15 @@ import ( "testing" "github.com/veritrans/midtrans-cli/internal/contracts" + internalpack "github.com/veritrans/midtrans-cli/internal/packs" + "github.com/veritrans/midtrans-cli/internal/policy" + "github.com/veritrans/midtrans-cli/packs/bisnap" + "github.com/veritrans/midtrans-cli/packs/common" + "github.com/veritrans/midtrans-cli/packs/coreapi" + "github.com/veritrans/midtrans-cli/packs/gopaytokenization" + "github.com/veritrans/midtrans-cli/packs/paymentlink" + "github.com/veritrans/midtrans-cli/packs/snap" + "github.com/veritrans/midtrans-cli/packs/subscription" ) var securityCanaries = []string{ @@ -243,6 +252,49 @@ func TestSecurityForcedFailureLeavesNoCanaryOrTemporaryArtifacts(t *testing.T) { } } +func TestProductionSecurityCompiledJourneyHostsStayInsideSandboxAllowlist(t *testing.T) { + registry, err := internalpack.NewRegistry( + common.New(), + snap.New(), + coreapi.New(), + paymentlink.New(), + bisnap.New(), + gopaytokenization.New(), + subscription.New(), + ) + if err != nil { + t.Fatal(err) + } + for _, version := range registry.Versions() { + pack, ok := registry.Get(version.ID) + if !ok { + t.Fatalf("pack %q missing from registry", version.ID) + } + descriptor := pack.Descriptor() + if len(descriptor.SandboxHosts) == 0 && len(descriptor.Journeys) != 0 && version.ID != "common" { + t.Fatalf("pack %q has journeys but no sandbox hosts", version.ID) + } + for _, host := range descriptor.SandboxHosts { + rawURL := "https://" + host + "/health" + if err := policy.ValidateJourneySandboxURL(rawURL, descriptor.SandboxHosts); err != nil { + t.Fatalf("pack %q host %q rejected: %v", version.ID, host, err) + } + } + for _, journeyID := range descriptor.Journeys { + handler, ok := registry.Handler(journeyID) + if !ok { + if strings.HasPrefix(journeyID, "common.") { + continue + } + t.Fatalf("journey %q missing handler", journeyID) + } + if handler.Definition().Product != descriptor.ID && handler.Definition().Product != "common" { + t.Fatalf("journey %q product = %q, want %q", journeyID, handler.Definition().Product, descriptor.ID) + } + } + } +} + func copyProject(t *testing.T, source string) string { t.Helper() project := t.TempDir() From 2d5cd6037d923f701ce6092fec7c329241df920c Mon Sep 17 00:00:00 2001 From: "m.salis" Date: Mon, 27 Jul 2026 16:44:13 +0700 Subject: [PATCH 62/73] fix: compile journey proof requirements --- .../task-12-report.md | 53 +++++ internal/app/commands_evidence_test.go | 118 ++++++++- internal/app/commands_verify.go | 73 ++++-- internal/evidence/evidence_test.go | 76 +++++- schemas/evidence-v1.schema.json | 225 ++++++++---------- 5 files changed, 387 insertions(+), 158 deletions(-) diff --git a/.superpowers/sdd/2026-07-26-midtrans-cli-multi-product-parity/task-12-report.md b/.superpowers/sdd/2026-07-26-midtrans-cli-multi-product-parity/task-12-report.md index d104840..01c188f 100644 --- a/.superpowers/sdd/2026-07-26-midtrans-cli-multi-product-parity/task-12-report.md +++ b/.superpowers/sdd/2026-07-26-midtrans-cli-multi-product-parity/task-12-report.md @@ -52,6 +52,59 @@ go test ./... -count=1 Result: passed on Monday, July 27, 2026. +## Fix Round 1 + +Compiled journey proof requirements are now fail-closed and no longer +evidence-controlled. + +## What Changed + +- Replaced evidence-driven proof selection in `internal/app/commands_verify.go` + with compiled requirements for: + - `snap.checkout` + - `bisnap.qris-payment` + - `bisnap.virtual-account` + - `bisnap.direct-debit` + - `bisnap.status` + - `bisnap.refund` + - `core-api.recurring` + - `bisnap.recurring` + - `gopay-tokenization.recurring` +- Journeys without compiled proof policy now fail closed with explicit missing + evidence `compiled_policy`; they cannot pass even if an evidence file is + supplied. +- `bundle.required_proofs` is now metadata only: + - if present, it must exactly match the compiled proof ID and level set + - mismatches are treated as `VERIFY_EVIDENCE_CONTEXT_MISMATCH` + - metadata can no longer replace, downgrade, or widen the compiled policy +- Updated focused verification tests for: + - crafted Snap proof-policy downgrade attempts + - BI-SNAP status verification without an evidence bundle + - known compiled journeys without proof policy failing closed +- Reworked `schemas/evidence-v1.schema.json` to use explicit `oneOf` branches: + - legacy single-journey bundle + - hybrid multi-journey document +- Expanded evidence schema tests so incomplete empty or metadata-only objects + fail closed under runtime/schema parity. + +## Validation + +Focused: + +```sh +go test ./internal/evidence ./internal/verify ./internal/app -count=1 +``` + +Result: passed. + +Full: + +```sh +go test ./... -count=1 +``` + +Result: passed on Monday, July 27, 2026. + ## Deferred - Production host allowlisting and zero-production mutation enforcement remain diff --git a/internal/app/commands_evidence_test.go b/internal/app/commands_evidence_test.go index 7c61cab..f3933ca 100644 --- a/internal/app/commands_evidence_test.go +++ b/internal/app/commands_evidence_test.go @@ -314,6 +314,112 @@ func TestHybridVerifyDoesNotPromoteLocalProofToSandboxRequirement(t *testing.T) } } +func TestVerifyRejectsEvidenceControlledSnapRequiredProofDowngrade(t *testing.T) { + project := createJourneyProject(t, "http://127.0.0.1:1") + bundle := completeBundleForProject(t, project) + bundle.RequiredProofs = []evidence.RequiredProof{ + {ID: "snap.provider-status", Level: evidence.ProofLocal}, + {ID: "snap.merchant-callback", Level: evidence.ProofLocal}, + } + path, err := (evidence.Store{ProjectDir: project}).Write(bundle) + if err != nil { + t.Fatal(err) + } + + result, exit := executeJSON( + t, + "verify", + "--product", "snap", + "--evidence", path, + "--project-dir", project, + "--json", "--non-interactive", + ) + if exit != 3 || result.Status != contracts.StatusBlocked || + !result.HasCode("VERIFY_EVIDENCE_CONTEXT_MISMATCH") || + !result.HasCode("VERIFY_EVIDENCE_INCOMPLETE") { + t.Fatalf("exit = %d, result = %#v", exit, result) + } +} + +func TestHybridVerifyFailsClosedWithoutBISNAPEvidenceBundle(t *testing.T) { + project := createJourneyProject(t, "http://127.0.0.1:1") + configureManifest(t, project, func(value *manifest.Manifest) { + value.CredentialSets["bisnap"] = manifest.CredentialSet{ + Type: "bisnap", + Environment: "sandbox", + ClientID: "env:MIDTRANS_BISNAP_CLIENT_ID", + ClientSecret: "env:MIDTRANS_BISNAP_CLIENT_SECRET", + PartnerID: "env:MIDTRANS_BISNAP_PARTNER_ID", + ChannelID: "env:MIDTRANS_BISNAP_CHANNEL_ID", + DeviceID: "env:MIDTRANS_BISNAP_DEVICE_ID", + PrivateKey: "file:./keys/private.pem", + MidtransPublicKey: "file:./keys/public.pem", + } + value.Integrations["bisnap"] = manifest.Integration{ + ConfigVersion: 1, + Credentials: "bisnap", + Callbacks: map[string]string{ + "notification": "/api/payments/bisnap/notification", + }, + } + value.Verification.Required = []string{"bisnap.status"} + }) + + result, exit := executeJSON( + t, + "verify", + "--project-dir", project, + "--json", "--non-interactive", + ) + if exit != 3 || result.Status != contracts.StatusBlocked || + !result.HasCode("VERIFY_EVIDENCE_INCOMPLETE") { + t.Fatalf("exit = %d, result = %#v", exit, result) + } + data, ok := result.Data.(map[string]any) + if !ok { + t.Fatalf("data = %#v", result.Data) + } + journeys := data["journeys"].([]any) + missing := journeys[0].(map[string]any)["missing_evidence"].([]any) + if len(missing) != 2 { + t.Fatalf("missing = %#v", missing) + } +} + +func TestVerifyFailsClosedWhenCompiledJourneyHasNoProofPolicy(t *testing.T) { + project := createJourneyProject(t, "http://127.0.0.1:1") + configureManifest(t, project, func(value *manifest.Manifest) { + value.Integrations["payment-link"] = manifest.Integration{ + ConfigVersion: 1, + Credentials: "classic", + Callbacks: map[string]string{ + "notification": "/api/payments/payment-link/notification", + }, + } + value.Verification.Required = []string{"payment-link.create"} + }) + + result, exit := executeJSON( + t, + "verify", + "--project-dir", project, + "--json", "--non-interactive", + ) + if exit != 3 || result.Status != contracts.StatusBlocked || + !result.HasCode("VERIFY_EVIDENCE_INCOMPLETE") { + t.Fatalf("exit = %d, result = %#v", exit, result) + } + data, ok := result.Data.(map[string]any) + if !ok { + t.Fatalf("data = %#v", result.Data) + } + journeys := data["journeys"].([]any) + missing := journeys[0].(map[string]any)["missing_evidence"].([]any) + if len(missing) != 1 || missing[0] != "compiled_policy" { + t.Fatalf("missing = %#v", missing) + } +} + func TestHybridVerifyRejectsStaleManifestRepositoryPackAndOperationBindings(t *testing.T) { project := createJourneyProject(t, "http://127.0.0.1:1") tests := []struct { @@ -530,9 +636,9 @@ func writeHybridEvidenceDocument( { ID: "bisnap.notification", OperationID: "op_bisnap_test", - Stage: "notification", - Level: evidence.ProofLocal, - Source: "merchant_application", + Stage: "provider_notification", + Level: evidence.ProofSandbox, + Source: "midtrans_notification", ObservedAt: snapBundle.CompletedAt, Status: "blocked", Summary: map[string]any{ @@ -541,10 +647,10 @@ func writeHybridEvidenceDocument( }, }, RequiredProofs: []evidence.RequiredProof{ - {ID: "bisnap.provider-status", Level: evidence.ProofSandbox}, - {ID: "bisnap.notification", Level: evidence.ProofLocal}, + {ID: "bisnap.notification", Level: evidence.ProofSandbox}, + {ID: "bisnap.merchant-persistence", Level: evidence.ProofLocal}, }, - MissingEvidence: []string{"provider_status"}, + MissingEvidence: []string{"bisnap.merchant-persistence"}, } document := map[string]any{ "schema_version": evidence.SchemaVersion, diff --git a/internal/app/commands_verify.go b/internal/app/commands_verify.go index a4b2b0b..5abdae4 100644 --- a/internal/app/commands_verify.go +++ b/internal/app/commands_verify.go @@ -137,24 +137,25 @@ func verifyJourneysForProject( packID := journeyProduct(journeyID, deps) required := defaultRequiredProofsForJourney(journeyID) bundle := bundlesByJourney[journeyID] - if len(bundle.RequiredProofs) != 0 { - required = verifyRequiredProofs(bundle.RequiredProofs) - } if bundle.Journey != "" { + journeyMismatch := false matches, err := evidenceMatchesProject(projectDir, value.SchemaVersion, deps, bundle) if err != nil { contextMismatch = true + journeyMismatch = true } if err == nil && !matches { contextMismatch = true + journeyMismatch = true + } + if err == nil && !requiredProofMetadataMatches(required, bundle.RequiredProofs) { + contextMismatch = true + journeyMismatch = true } - if err != nil || !matches { + if journeyMismatch { bundle = evidence.Bundle{} } } - if len(required) == 0 { - required = verifyRequiredProofs(bundle.RequiredProofs) - } journeyInputs = append(journeyInputs, verify.Journey{ ID: journeyID, Required: required, @@ -284,22 +285,62 @@ func defaultRequiredProofsForJourney(journeyID string) []verify.RequiredProof { {ID: "snap.provider-status", Level: evidence.ProofSandbox}, {ID: "snap.merchant-callback", Level: evidence.ProofLocal}, } + case "bisnap.qris-payment", "bisnap.virtual-account", "bisnap.direct-debit", "bisnap.status", "bisnap.refund": + return []verify.RequiredProof{ + {ID: "bisnap.notification", Level: evidence.ProofSandbox}, + {ID: "bisnap.merchant-persistence", Level: evidence.ProofLocal}, + } + case "core-api.recurring": + return []verify.RequiredProof{ + {ID: "core-api.recurring.charge-attempt", Level: evidence.ProofLocal}, + {ID: "core-api.recurring.notification", Level: evidence.ProofSandbox}, + {ID: "core-api.recurring.merchant-persistence", Level: evidence.ProofLocal}, + } + case "bisnap.recurring": + return []verify.RequiredProof{ + {ID: "bisnap.recurring.scheduler-attempt", Level: evidence.ProofLocal}, + {ID: "bisnap.recurring.transaction-signature", Level: evidence.ProofSandbox}, + {ID: "bisnap.notification", Level: evidence.ProofSandbox}, + {ID: "bisnap.merchant-persistence", Level: evidence.ProofLocal}, + } + case "gopay-tokenization.recurring": + return []verify.RequiredProof{ + {ID: "gopay-tokenization.recurring.scheduler-attempt", Level: evidence.ProofLocal}, + {ID: "gopay-tokenization.recurring.binding-inquiry", Level: evidence.ProofSandbox}, + {ID: "gopay-tokenization.recurring.notification", Level: evidence.ProofSandbox}, + {ID: "gopay-tokenization.recurring.merchant-persistence", Level: evidence.ProofLocal}, + } default: - return nil + return []verify.RequiredProof{{ + ID: "compiled_policy", Level: evidence.ProofLocal, + }} } } -func verifyRequiredProofs(required []evidence.RequiredProof) []verify.RequiredProof { - if len(required) == 0 { - return nil +func requiredProofMetadataMatches(required []verify.RequiredProof, metadata []evidence.RequiredProof) bool { + if len(metadata) == 0 { + return true } - values := make([]verify.RequiredProof, 0, len(required)) + if len(required) != len(metadata) { + return false + } + counts := make(map[verify.RequiredProof]int, len(required)) for _, proof := range required { - values = append(values, verify.RequiredProof{ - ID: proof.ID, Level: proof.Level, - }) + counts[proof]++ + } + for _, proof := range metadata { + key := verify.RequiredProof{ID: proof.ID, Level: proof.Level} + if counts[key] == 0 { + return false + } + counts[key]-- + } + for _, remaining := range counts { + if remaining != 0 { + return false + } } - return values + return true } func verificationStatus(required []verify.RequiredProof, bundle evidence.Bundle) (string, []string) { diff --git a/internal/evidence/evidence_test.go b/internal/evidence/evidence_test.go index c8caafb..dda4ee4 100644 --- a/internal/evidence/evidence_test.go +++ b/internal/evidence/evidence_test.go @@ -286,8 +286,15 @@ func TestEvidenceSchemaMatchesRuntimeConstraints(t *testing.T) { if err := json.Unmarshal(data, &schema); err != nil { t.Fatal(err) } - properties := requireObject(t, schema["properties"]) - safeReferences := requireObject(t, properties["safe_references"]) + oneOf, ok := schema["oneOf"].([]any) + if !ok || len(oneOf) != 2 { + t.Fatalf("oneOf = %#v", schema["oneOf"]) + } + definitions := requireObject(t, schema["$defs"]) + legacy := requireObject(t, definitions["legacyBundle"]) + hybrid := requireObject(t, definitions["hybridDocument"]) + properties := requireObject(t, legacy["properties"]) + safeReferences := requireObject(t, definitions["safeReferences"]) if safeReferences["additionalProperties"] != false { t.Fatalf( "safe_references additionalProperties = %#v", @@ -318,8 +325,7 @@ func TestEvidenceSchemaMatchesRuntimeConstraints(t *testing.T) { if got := requireObject(t, properties["repository_commit"])["pattern"]; got != "^(?:[0-9a-f]{40}|[0-9a-f]{64})$" { t.Fatalf("repository_commit pattern = %#v", got) } - proofs := requireObject(t, properties["proofs"]) - items := requireObject(t, proofs["items"]) + items := requireObject(t, definitions["proof"]) proofProperties := requireObject(t, items["properties"]) if got := requireObject(t, proofProperties["id"])["minLength"]; got != float64(1) { t.Fatalf("proof id minLength = %#v", got) @@ -347,6 +353,15 @@ func TestEvidenceSchemaMatchesRuntimeConstraints(t *testing.T) { t.Fatalf("proof required fields missing %q: %#v", key, requiredProofFields) } } + legacyRequired, ok := legacy["required"].([]any) + if !ok || len(legacyRequired) == 0 { + t.Fatalf("legacy required = %#v", legacy["required"]) + } + hybridProperties := requireObject(t, hybrid["properties"]) + hybridJourneys := requireObject(t, hybridProperties["journeys"]) + if hybridJourneys["minItems"] != float64(1) { + t.Fatalf("hybrid journeys minItems = %#v", hybridJourneys["minItems"]) + } invalid := validBundle() invalid.RepositoryCommit = "not-a-revision" @@ -370,6 +385,59 @@ func TestEvidenceSchemaMatchesRuntimeConstraints(t *testing.T) { } } +func TestEvidenceSchemaFailsClosedForIncompleteLegacyAndHybridDocuments(t *testing.T) { + data, err := os.ReadFile(filepath.Join( + "..", + "..", + "schemas", + "evidence-v1.schema.json", + )) + if err != nil { + t.Fatal(err) + } + var schema map[string]any + if err := json.Unmarshal(data, &schema); err != nil { + t.Fatal(err) + } + if _, ok := schema["oneOf"].([]any); !ok { + t.Fatalf("schema does not use oneOf: %#v", schema) + } + for _, invalid := range []map[string]any{ + {}, + { + "schema_version": evidence.SchemaVersion, + "cli_version": "0.1.0-test", + "manifest_version": 1, + "environment": "sandbox", + }, + { + "schema_version": evidence.SchemaVersion, + "cli_version": "0.1.0-test", + "manifest_version": 1, + "environment": "sandbox", + "journeys": []any{}, + }, + } { + encoded, err := json.Marshal(invalid) + if err != nil { + t.Fatal(err) + } + root := t.TempDir() + path := filepath.Join(root, "evidence.json") + if err := os.WriteFile(path, append(encoded, '\n'), 0o600); err != nil { + t.Fatal(err) + } + sum := sha256.Sum256(append(encoded, '\n')) + checksum := hex.EncodeToString(sum[:]) + " evidence.json\n" + if err := os.WriteFile(filepath.Join(root, "SHA256SUMS"), []byte(checksum), 0o600); err != nil { + t.Fatal(err) + } + if _, err := (evidence.Store{ProjectDir: filepath.Dir(root)}).ReadDocument(path); err == nil { + t.Fatalf("invalid document accepted: %#v", invalid) + } + } +} + func requireObject(t *testing.T, value any) map[string]any { t.Helper() object, ok := value.(map[string]any) diff --git a/schemas/evidence-v1.schema.json b/schemas/evidence-v1.schema.json index 064ce52..c003d9e 100644 --- a/schemas/evidence-v1.schema.json +++ b/schemas/evidence-v1.schema.json @@ -2,150 +2,111 @@ "$schema": "https://json-schema.org/draft/2020-12/schema", "$id": "https://github.com/veritrans/midtrans-cli/schemas/evidence-v1.schema.json", "title": "Midtrans CLI evidence v1", - "type": "object", - "additionalProperties": false, - "required": [ - "schema_version", - "cli_version", - "manifest_version", - "environment" + "oneOf": [ + {"$ref": "#/$defs/legacyBundle"}, + {"$ref": "#/$defs/hybridDocument"} ], - "properties": { - "schema_version": {"const": "1.0"}, - "cli_version": {"type": "string", "minLength": 1}, - "manifest_version": {"const": 1}, - "pack_id": {"type": "string", "minLength": 1}, - "pack_version": {"type": "string", "minLength": 1}, - "operation_id": {"type": "string", "pattern": "^op_[a-z0-9_]+$"}, - "manifest_hash": {"type": "string", "pattern": "^[0-9a-f]{64}$"}, - "repository_commit": { - "type": "string", - "pattern": "^(?:[0-9a-f]{40}|[0-9a-f]{64})$" + "$defs": { + "proof": { + "type": "object", + "additionalProperties": false, + "required": ["id", "operation_id", "stage", "level", "source", "observed_at", "status", "summary"], + "properties": { + "id": {"type": "string", "minLength": 1}, + "operation_id": {"type": "string", "pattern": "^op_[a-z0-9_]+$"}, + "stage": {"type": "string", "minLength": 1}, + "level": {"enum": ["local", "sandbox"]}, + "source": {"type": "string", "minLength": 1}, + "observed_at": {"type": "string", "format": "date-time"}, + "status": {"enum": ["pass", "fail", "blocked"]}, + "summary": {"type": "object"} + } }, - "journey": {"type": "string", "minLength": 1}, - "environment": {"const": "sandbox"}, - "started_at": {"type": "string", "format": "date-time"}, - "completed_at": {"type": "string", "format": "date-time"}, - "safe_references": { + "requiredProof": { "type": "object", "additionalProperties": false, + "required": ["id", "level"], "properties": { - "order_id": {"type": "string"}, - "provider_transaction_id": {"type": "string"} + "id": {"type": "string", "minLength": 1}, + "level": {"enum": ["local", "sandbox"]} } }, - "proofs": { - "type": "array", - "items": { - "type": "object", - "additionalProperties": false, - "required": ["id", "operation_id", "stage", "level", "source", "observed_at", "status", "summary"], - "properties": { - "id": {"type": "string", "minLength": 1}, - "operation_id": {"type": "string", "pattern": "^op_[a-z0-9_]+$"}, - "stage": {"type": "string", "minLength": 1}, - "level": {"enum": ["local", "sandbox"]}, - "source": {"type": "string", "minLength": 1}, - "observed_at": {"type": "string", "format": "date-time"}, - "status": {"enum": ["pass", "fail", "blocked"]}, - "summary": {"type": "object"} - } + "safeReferences": { + "type": "object", + "additionalProperties": false, + "properties": { + "order_id": {"type": "string"}, + "provider_transaction_id": {"type": "string"} } }, - "required_proofs": { - "type": "array", - "items": { - "type": "object", - "additionalProperties": false, - "required": ["id", "level"], - "properties": { - "id": {"type": "string", "minLength": 1}, - "level": {"enum": ["local", "sandbox"]} + "legacyBundle": { + "type": "object", + "additionalProperties": false, + "required": [ + "schema_version", + "cli_version", + "manifest_version", + "pack_id", + "pack_version", + "manifest_hash", + "repository_commit", + "journey", + "environment", + "started_at", + "completed_at", + "safe_references", + "proofs" + ], + "properties": { + "schema_version": {"const": "1.0"}, + "cli_version": {"type": "string", "minLength": 1}, + "manifest_version": {"const": 1}, + "pack_id": {"type": "string", "minLength": 1}, + "pack_version": {"type": "string", "minLength": 1}, + "operation_id": {"type": "string", "pattern": "^op_[a-z0-9_]+$"}, + "manifest_hash": {"type": "string", "pattern": "^[0-9a-f]{64}$"}, + "repository_commit": { + "type": "string", + "pattern": "^(?:[0-9a-f]{40}|[0-9a-f]{64})$" + }, + "journey": {"type": "string", "minLength": 1}, + "environment": {"const": "sandbox"}, + "started_at": {"type": "string", "format": "date-time"}, + "completed_at": {"type": "string", "format": "date-time"}, + "safe_references": {"$ref": "#/$defs/safeReferences"}, + "proofs": { + "type": "array", + "items": {"$ref": "#/$defs/proof"} + }, + "required_proofs": { + "type": "array", + "items": {"$ref": "#/$defs/requiredProof"} + }, + "missing_evidence": { + "type": "array", + "items": {"type": "string"} } } }, - "missing_evidence": { - "type": "array", - "items": {"type": "string"} - }, - "journeys": { - "type": "array", - "items": { - "type": "object", - "additionalProperties": false, - "required": [ - "schema_version", - "cli_version", - "manifest_version", - "pack_id", - "pack_version", - "manifest_hash", - "repository_commit", - "journey", - "environment", - "started_at", - "completed_at", - "safe_references", - "proofs" - ], - "properties": { - "schema_version": {"const": "1.0"}, - "cli_version": {"type": "string", "minLength": 1}, - "manifest_version": {"const": 1}, - "pack_id": {"type": "string", "minLength": 1}, - "pack_version": {"type": "string", "minLength": 1}, - "operation_id": {"type": "string", "pattern": "^op_[a-z0-9_]+$"}, - "manifest_hash": {"type": "string", "pattern": "^[0-9a-f]{64}$"}, - "repository_commit": { - "type": "string", - "pattern": "^(?:[0-9a-f]{40}|[0-9a-f]{64})$" - }, - "journey": {"type": "string", "minLength": 1}, - "environment": {"const": "sandbox"}, - "started_at": {"type": "string", "format": "date-time"}, - "completed_at": {"type": "string", "format": "date-time"}, - "safe_references": { - "type": "object", - "additionalProperties": false, - "properties": { - "order_id": {"type": "string"}, - "provider_transaction_id": {"type": "string"} - } - }, - "proofs": { - "type": "array", - "items": { - "type": "object", - "additionalProperties": false, - "required": ["id", "operation_id", "stage", "level", "source", "observed_at", "status", "summary"], - "properties": { - "id": {"type": "string", "minLength": 1}, - "operation_id": {"type": "string", "pattern": "^op_[a-z0-9_]+$"}, - "stage": {"type": "string", "minLength": 1}, - "level": {"enum": ["local", "sandbox"]}, - "source": {"type": "string", "minLength": 1}, - "observed_at": {"type": "string", "format": "date-time"}, - "status": {"enum": ["pass", "fail", "blocked"]}, - "summary": {"type": "object"} - } - } - }, - "required_proofs": { - "type": "array", - "items": { - "type": "object", - "additionalProperties": false, - "required": ["id", "level"], - "properties": { - "id": {"type": "string", "minLength": 1}, - "level": {"enum": ["local", "sandbox"]} - } - } - }, - "missing_evidence": { - "type": "array", - "items": {"type": "string"} - } + "hybridDocument": { + "type": "object", + "additionalProperties": false, + "required": [ + "schema_version", + "cli_version", + "manifest_version", + "environment", + "journeys" + ], + "properties": { + "schema_version": {"const": "1.0"}, + "cli_version": {"type": "string", "minLength": 1}, + "manifest_version": {"const": 1}, + "environment": {"const": "sandbox"}, + "journeys": { + "type": "array", + "minItems": 1, + "items": {"$ref": "#/$defs/legacyBundle"} } } } From ef63f51841abeabbe338fa3e9303cc4336483226 Mon Sep 17 00:00:00 2001 From: "m.salis" Date: Mon, 27 Jul 2026 16:51:18 +0700 Subject: [PATCH 63/73] fix: block journeys without proof policy --- .../task-12-report.md | 38 +++++++ internal/app/commands_evidence_test.go | 74 ++++++++++++- internal/app/commands_verify.go | 104 ++++++++++++------ 3 files changed, 182 insertions(+), 34 deletions(-) diff --git a/.superpowers/sdd/2026-07-26-midtrans-cli-multi-product-parity/task-12-report.md b/.superpowers/sdd/2026-07-26-midtrans-cli-multi-product-parity/task-12-report.md index 01c188f..bb33d8d 100644 --- a/.superpowers/sdd/2026-07-26-midtrans-cli-multi-product-parity/task-12-report.md +++ b/.superpowers/sdd/2026-07-26-midtrans-cli-multi-product-parity/task-12-report.md @@ -105,6 +105,44 @@ go test ./... -count=1 Result: passed on Monday, July 27, 2026. +## Fix Round 2 + +Journeys without a compiled proof policy now block verification explicitly and +cannot be satisfied by synthetic evidence. + +## What Changed + +- Replaced the satisfiable synthetic `compiled_policy` fallback with an + explicit compiled-policy lookup that returns requirements plus a known-policy + bit. +- Required journeys with no compiled policy now: + - skip all supplied proofs and `required_proofs` metadata + - present as `blocked` with `policy_missing` + - emit `VERIFY_PROOF_POLICY_UNAVAILABLE` so aggregate verification cannot + pass +- Preserved all existing compiled proof policies for known journeys. +- Added an adversarial `payment-link.create` test that supplies a + context-matching bundle with a passing synthetic `compiled_policy` proof and + verifies the project still blocks. + +## Validation + +Focused: + +```sh +go test ./internal/app ./internal/verify -count=1 +``` + +Result: passed. + +Full: + +```sh +go test ./... -count=1 +``` + +Result: passed on Monday, July 27, 2026. + ## Deferred - Production host allowlisting and zero-production mutation enforcement remain diff --git a/internal/app/commands_evidence_test.go b/internal/app/commands_evidence_test.go index f3933ca..05920c7 100644 --- a/internal/app/commands_evidence_test.go +++ b/internal/app/commands_evidence_test.go @@ -406,7 +406,7 @@ func TestVerifyFailsClosedWhenCompiledJourneyHasNoProofPolicy(t *testing.T) { "--json", "--non-interactive", ) if exit != 3 || result.Status != contracts.StatusBlocked || - !result.HasCode("VERIFY_EVIDENCE_INCOMPLETE") { + !result.HasCode("VERIFY_PROOF_POLICY_UNAVAILABLE") { t.Fatalf("exit = %d, result = %#v", exit, result) } data, ok := result.Data.(map[string]any) @@ -414,8 +414,76 @@ func TestVerifyFailsClosedWhenCompiledJourneyHasNoProofPolicy(t *testing.T) { t.Fatalf("data = %#v", result.Data) } journeys := data["journeys"].([]any) - missing := journeys[0].(map[string]any)["missing_evidence"].([]any) - if len(missing) != 1 || missing[0] != "compiled_policy" { + journey := journeys[0].(map[string]any) + if journey["status"] != "blocked" { + t.Fatalf("journey = %#v", journey) + } + missing := journey["missing_evidence"].([]any) + if len(missing) != 1 || missing[0] != "policy_missing" { + t.Fatalf("missing = %#v", missing) + } +} + +func TestVerifyBlocksUnknownPolicyJourneyEvenWithMatchingSyntheticEvidenceBundle(t *testing.T) { + project := createJourneyProject(t, "http://127.0.0.1:1") + configureManifest(t, project, func(value *manifest.Manifest) { + value.Integrations["payment-link"] = manifest.Integration{ + ConfigVersion: 1, + Credentials: "classic", + Callbacks: map[string]string{ + "notification": "/api/payments/payment-link/notification", + }, + } + value.Verification.Required = []string{"payment-link.create"} + }) + + bundle := completeBundleForProject(t, project) + bundle.PackID = "payment-link" + bundle.Journey = "payment-link.create" + bundle.OperationID = "op_payment_link_create" + bundle.Proofs = []evidence.Proof{{ + ID: "compiled_policy", + OperationID: "op_payment_link_create", + Stage: "synthetic", + Level: evidence.ProofLocal, + Source: "merchant_application", + ObservedAt: bundle.CompletedAt, + Status: "pass", + Summary: map[string]any{ + "synthetic": true, + }, + }} + bundle.RequiredProofs = nil + path, err := (evidence.Store{ProjectDir: project}).Write(bundle) + if err != nil { + t.Fatal(err) + } + + result, exit := executeJSON( + t, + "verify", + "--evidence", path, + "--project-dir", project, + "--json", "--non-interactive", + ) + if exit != 3 || result.Status != contracts.StatusBlocked || + !result.HasCode("VERIFY_PROOF_POLICY_UNAVAILABLE") { + t.Fatalf("exit = %d, result = %#v", exit, result) + } + data, ok := result.Data.(map[string]any) + if !ok { + t.Fatalf("data = %#v", result.Data) + } + if data["proof_state"] != "blocked" { + t.Fatalf("data = %#v", data) + } + journeys := data["journeys"].([]any) + journey := journeys[0].(map[string]any) + if journey["status"] != "blocked" { + t.Fatalf("journey = %#v", journey) + } + missing := journey["missing_evidence"].([]any) + if len(missing) != 1 || missing[0] != "policy_missing" { t.Fatalf("missing = %#v", missing) } } diff --git a/internal/app/commands_verify.go b/internal/app/commands_verify.go index 5abdae4..387c2fe 100644 --- a/internal/app/commands_verify.go +++ b/internal/app/commands_verify.go @@ -76,7 +76,7 @@ func newVerifyCommand(flags *globalFlags, deps Dependencies) *cobra.Command { } } - journeyInputs, verifyData, contextFinding, nextActions, ok := verifyJourneysForProject( + journeyInputs, verifyData, journeyFindings, nextActions, ok := verifyJourneysForProject( flags.projectDir, value, deps, @@ -84,14 +84,15 @@ func newVerifyCommand(flags *globalFlags, deps Dependencies) *cobra.Command { document, evidenceFile, ) - if contextFinding.Code != "" { - findings = append(findings, contextFinding) - } + findings = append(findings, journeyFindings...) result := verify.Run(verify.Input{ Command: "verify", LocalFindings: findings, Journeys: journeyInputs, }) + if hasFindingCode(findings, "VERIFY_PROOF_POLICY_UNAVAILABLE") { + result.Status = contracts.StatusBlocked + } result.CLIVersion = deps.Version.Version result.ManifestVersion = value.SchemaVersion result.Packs = packVersions @@ -119,7 +120,7 @@ func verifyJourneysForProject( requiredJourneys []string, document evidence.Document, evidencePath string, -) ([]verify.Journey, presentation.VerifyData, contracts.Finding, []contracts.NextAction, bool) { +) ([]verify.Journey, presentation.VerifyData, []contracts.Finding, []contracts.NextAction, bool) { bundlesByJourney := make(map[string]evidence.Bundle, len(document.Journeys)) for _, bundle := range document.Journeys { bundlesByJourney[bundle.Journey] = bundle @@ -129,27 +130,50 @@ func verifyJourneysForProject( presentationJourneys := make([]presentation.VerifyJourney, 0, len(requiredJourneys)) productStates := map[string]string{} nextActions := []contracts.NextAction{} - contextMismatch := false - contextFinding := contracts.Finding{} + journeyFindings := []contracts.Finding{} proofRows := []presentation.VerifyProof{} for _, journeyID := range requiredJourneys { packID := journeyProduct(journeyID, deps) - required := defaultRequiredProofsForJourney(journeyID) + required, known := compiledProofPolicy(journeyID) bundle := bundlesByJourney[journeyID] - if bundle.Journey != "" { + status := "" + missing := []string(nil) + if !known { + required = nil + bundle = evidence.Bundle{} + status = "blocked" + missing = []string{"policy_missing"} + journeyFindings = append(journeyFindings, contracts.Finding{ + Code: "VERIFY_PROOF_POLICY_UNAVAILABLE", + Severity: "blocking", + Message: "compiled proof policy is unavailable for required journey: " + journeyID, + }) + } else if bundle.Journey != "" { journeyMismatch := false matches, err := evidenceMatchesProject(projectDir, value.SchemaVersion, deps, bundle) if err != nil { - contextMismatch = true + journeyFindings = append(journeyFindings, contracts.Finding{ + Code: "VERIFY_EVIDENCE_CONTEXT_MISMATCH", + Severity: "warning", + Message: "evidence does not describe the current repository state", + }) journeyMismatch = true } if err == nil && !matches { - contextMismatch = true + journeyFindings = append(journeyFindings, contracts.Finding{ + Code: "VERIFY_EVIDENCE_CONTEXT_MISMATCH", + Severity: "warning", + Message: "evidence does not describe the current repository state", + }) journeyMismatch = true } if err == nil && !requiredProofMetadataMatches(required, bundle.RequiredProofs) { - contextMismatch = true + journeyFindings = append(journeyFindings, contracts.Finding{ + Code: "VERIFY_EVIDENCE_CONTEXT_MISMATCH", + Severity: "warning", + Message: "evidence does not describe the current repository state", + }) journeyMismatch = true } if journeyMismatch { @@ -162,7 +186,9 @@ func verifyJourneysForProject( Bundle: bundle, }) - status, missing := verificationStatus(required, bundle) + if status == "" { + status, missing = verificationStatus(required, bundle) + } if len(requiredJourneys) == 1 { proofRows = verificationProofRows(required, bundle) } @@ -205,14 +231,6 @@ func verifyJourneysForProject( }) } - if contextMismatch { - contextFinding = contracts.Finding{ - Code: "VERIFY_EVIDENCE_CONTEXT_MISMATCH", - Severity: "warning", - Message: "evidence does not describe the current repository state", - } - } - data := presentation.VerifyData{ ProofState: "", EvidencePath: evidencePath, @@ -229,7 +247,7 @@ func verifyJourneysForProject( if data.ProofState == "" { data.ProofState = "incomplete" } - return journeyInputs, data, contextFinding, nextActions, true + return journeyInputs, data, dedupeFindings(journeyFindings), nextActions, true } func requiredJourneysForVerification(value manifest.Manifest) []string { @@ -278,42 +296,40 @@ func journeyProduct(journeyID string, deps Dependencies) string { return product } -func defaultRequiredProofsForJourney(journeyID string) []verify.RequiredProof { +func compiledProofPolicy(journeyID string) ([]verify.RequiredProof, bool) { switch journeyID { case "snap.checkout": return []verify.RequiredProof{ {ID: "snap.provider-status", Level: evidence.ProofSandbox}, {ID: "snap.merchant-callback", Level: evidence.ProofLocal}, - } + }, true case "bisnap.qris-payment", "bisnap.virtual-account", "bisnap.direct-debit", "bisnap.status", "bisnap.refund": return []verify.RequiredProof{ {ID: "bisnap.notification", Level: evidence.ProofSandbox}, {ID: "bisnap.merchant-persistence", Level: evidence.ProofLocal}, - } + }, true case "core-api.recurring": return []verify.RequiredProof{ {ID: "core-api.recurring.charge-attempt", Level: evidence.ProofLocal}, {ID: "core-api.recurring.notification", Level: evidence.ProofSandbox}, {ID: "core-api.recurring.merchant-persistence", Level: evidence.ProofLocal}, - } + }, true case "bisnap.recurring": return []verify.RequiredProof{ {ID: "bisnap.recurring.scheduler-attempt", Level: evidence.ProofLocal}, {ID: "bisnap.recurring.transaction-signature", Level: evidence.ProofSandbox}, {ID: "bisnap.notification", Level: evidence.ProofSandbox}, {ID: "bisnap.merchant-persistence", Level: evidence.ProofLocal}, - } + }, true case "gopay-tokenization.recurring": return []verify.RequiredProof{ {ID: "gopay-tokenization.recurring.scheduler-attempt", Level: evidence.ProofLocal}, {ID: "gopay-tokenization.recurring.binding-inquiry", Level: evidence.ProofSandbox}, {ID: "gopay-tokenization.recurring.notification", Level: evidence.ProofSandbox}, {ID: "gopay-tokenization.recurring.merchant-persistence", Level: evidence.ProofLocal}, - } + }, true default: - return []verify.RequiredProof{{ - ID: "compiled_policy", Level: evidence.ProofLocal, - }} + return nil, false } } @@ -408,6 +424,32 @@ func aggregateProofState(current, next string) string { return current } +func dedupeFindings(findings []contracts.Finding) []contracts.Finding { + if len(findings) < 2 { + return findings + } + seen := make(map[string]struct{}, len(findings)) + deduped := make([]contracts.Finding, 0, len(findings)) + for _, finding := range findings { + key := finding.Code + "\x00" + finding.Severity + "\x00" + finding.Message + if _, ok := seen[key]; ok { + continue + } + seen[key] = struct{}{} + deduped = append(deduped, finding) + } + return deduped +} + +func hasFindingCode(findings []contracts.Finding, code string) bool { + for _, finding := range findings { + if finding.Code == code { + return true + } + } + return false +} + func evidenceMatchesProject( projectDir string, manifestVersion int, From 0f6b529139750875fff731515960ceeee0c153c3 Mon Sep 17 00:00:00 2001 From: "m.salis" Date: Mon, 27 Jul 2026 17:12:28 +0700 Subject: [PATCH 64/73] test: enforce per-product Agent Skill parity --- .../task-13-report.md | 52 ++++ docs/agent-skill-compatibility.md | 61 +++-- test/e2e/skill_compatibility_test.go | 230 ++++++++++++++++++ 3 files changed, 324 insertions(+), 19 deletions(-) create mode 100644 .superpowers/sdd/2026-07-26-midtrans-cli-multi-product-parity/task-13-report.md create mode 100644 test/e2e/skill_compatibility_test.go diff --git a/.superpowers/sdd/2026-07-26-midtrans-cli-multi-product-parity/task-13-report.md b/.superpowers/sdd/2026-07-26-midtrans-cli-multi-product-parity/task-13-report.md new file mode 100644 index 0000000..5e87a0b --- /dev/null +++ b/.superpowers/sdd/2026-07-26-midtrans-cli-multi-product-parity/task-13-report.md @@ -0,0 +1,52 @@ +# Task 13 Report + +Date: 2026-07-27 + +## Slice B + +Enforced exact per-product Agent Skill compatibility against the CLI capability +contract and documented the handshake for all supported Midtrans products. + +## What Changed + +- Rewrote `docs/agent-skill-compatibility.md` from a Snap-only phase-era note + into a per-product contract guide covering: + - required schema checks + - required capability and journey checks + - exact product mapping for `snap`, `core-api`, `payment-link`, `bisnap`, + `gopay-tokenization`, and `subscription` + - guidance-only fallback semantics +- Added `test/e2e/skill_compatibility_test.go` to validate: + - the checked-in canonical Skill matrix stays exact + - legacy `schema_version` and `phase` fields are absent + - all required capabilities and journeys exist in + `contracts/capabilities-v1.json` + - result, manifest, and evidence schema values match exactly + - when `MIDTRANS_AGENT_SKILL_DIR` is set, the live Agent Skill matrix matches + the canonical matrix semantically with no skip behavior +- Wired local validation against the Skill repo checkout at: + - `/Users/salis/Goto/Code/midtrans/midtrans-agent-skills-cli-integration` + +## Coupled Commits + +- CLI repo base before this slice: `ef63f51841abeabbe338fa3e9303cc4336483226` +- Skill repo matrix commit validated locally: + `30218ecff8d01d871632d547569a38f09e6caf1a` + +## Validation + +Focused: + +```sh +MIDTRANS_AGENT_SKILL_DIR=/Users/salis/Goto/Code/midtrans/midtrans-agent-skills-cli-integration \ +go test ./test/e2e -run TestAgentSkillCompatibility -count=1 +``` + +Full: + +```sh +MIDTRANS_AGENT_SKILL_DIR=/Users/salis/Goto/Code/midtrans/midtrans-agent-skills-cli-integration \ +go test ./... -count=1 +``` + +Result: passed on Monday, July 27, 2026. diff --git a/docs/agent-skill-compatibility.md b/docs/agent-skill-compatibility.md index 7389a6c..0c88c92 100644 --- a/docs/agent-skill-compatibility.md +++ b/docs/agent-skill-compatibility.md @@ -1,36 +1,59 @@ # Agent Skill compatibility -The Agent Skill may orchestrate Midtrans CLI only after a complete capability -handshake. Phase 1 was evaluated against Agent Skills integration commit -`d0aefed12ff71211dc7568c4357b16fac9f7b9ab`; the commit is context for the -controlled evaluation, not proof that either repository has been published. +The `integrate-midtrans-payments` Agent Skill may orchestrate Midtrans CLI only +after a complete capability handshake against the CLI's published contract. Run: ```sh midtrans agent capabilities --json --non-interactive midtrans agent inspect --json --non-interactive -midtrans agent check --product snap --json --non-interactive +midtrans agent check --product --json --non-interactive ``` -The host must compare every value required by the Skill's +The Skill host must compare every value required by the Skill's `cli-compatibility.json`: -- result schema, manifest schema, and evidence schema versions; -- every required common and Snap capability ID; and -- every required journey ID. +- `required_result_schema` +- `required_manifest_schema` +- `required_evidence_schema` +- every per-product `required_capabilities` ID +- every per-product `required_journeys` ID -Missing, malformed, or newer incompatible values must select the Skill's -documented non-CLI fallback. A host must not silently install the CLI, weaken a -schema requirement, or infer compatibility from a version string alone. +The Skill matrix is product-keyed. It must not use legacy top-level +`schema_version` or `phase` fields, and compatibility must not be inferred from +the CLI version string alone. -The machine-readable CLI contract is -[`contracts/capabilities-v1.json`](../contracts/capabilities-v1.json). Capability -and journey IDs are additive within a compatible release line. Removing or -changing the meaning of an ID requires a breaking contract version. Pack -versions describe pack implementation; they do not replace schema comparison. +## Per-product contract + +The CLI publishes its machine-readable contract in +[`contracts/capabilities-v1.json`](../contracts/capabilities-v1.json). The +current Agent Skill parity contract is: + +| Product | Required capabilities | Required journeys | +| --- | --- | --- | +| `snap` | `common.capabilities.v1`, `snap.checkout.verify.v1`, `snap.mobile.verify.v1`, `snap.plan.v1`, `snap.webhook.verify.v1` | `common.status-reconciliation`, `common.webhook-idempotency`, `snap.checkout`, `snap.mobile-webview` | +| `core-api` | `common.capabilities.v1`, `core-api.card-3ds.verify.v1`, `core-api.installment.verify.v1`, `core-api.otc.verify.v1`, `core-api.recurring.verify.v1`, `core-api.refund.verify.v1`, `core-api.saved-card.verify.v1`, `core-api.virtual-account.verify.v1` | `core-api.card-3ds`, `core-api.installment`, `core-api.otc`, `core-api.recurring`, `core-api.refund`, `core-api.saved-card`, `core-api.virtual-account` | +| `payment-link` | `common.capabilities.v1`, `payment-link.create.verify.v1`, `payment-link.reusable.verify.v1`, `payment-link.verify.v1` | `payment-link.create`, `payment-link.reusable`, `payment-link.verify` | +| `bisnap` | `common.capabilities.v1`, `bisnap.direct-debit.verify.v1`, `bisnap.qris.verify.v1`, `bisnap.recurring.verify.v1`, `bisnap.refund.verify.v1`, `bisnap.status.verify.v1`, `bisnap.virtual-account.verify.v1` | `bisnap.direct-debit`, `bisnap.qris-payment`, `bisnap.recurring`, `bisnap.refund`, `bisnap.status`, `bisnap.virtual-account` | +| `gopay-tokenization` | `common.capabilities.v1`, `gopay-tokenization.account-linking.verify.v1`, `gopay-tokenization.binding-inquiry.verify.v1`, `gopay-tokenization.paylater.verify.v1`, `gopay-tokenization.recurring.verify.v1`, `gopay-tokenization.unlink.verify.v1`, `gopay-tokenization.wallet-payment.verify.v1` | `gopay-tokenization.account-linking`, `gopay-tokenization.binding-inquiry`, `gopay-tokenization.paylater`, `gopay-tokenization.recurring`, `gopay-tokenization.unlink`, `gopay-tokenization.wallet-payment` | +| `subscription` | `common.capabilities.v1`, `subscription.cancel.verify.v1`, `subscription.create.verify.v1`, `subscription.disable.verify.v1`, `subscription.enable.verify.v1`, `subscription.verify.v1` | `subscription.cancel`, `subscription.create`, `subscription.disable`, `subscription.enable`, `subscription.verify` | + +Capability and journey IDs are additive within a compatible release line. +Removing or changing the meaning of an ID requires a breaking contract version. +Pack versions describe implementation revisions; they do not replace schema or +capability comparison. + +## Fallback semantics + +Missing, malformed, or incompatible values must select the Skill's documented +guidance-only fallback. A host must not: + +- silently install or update the CLI, +- weaken a schema requirement, +- negotiate a product that is not in the required merchant flow, +- treat local-only proof as end-to-end sandbox proof. After a successful handshake, the Skill still owns reasoning and application edits. The CLI owns inspection, sandbox policy, explicit dry-run/execute -separation, provider/local proof collection, and evidence validation. A local -proof must never be presented as end-to-end sandbox proof. +separation, provider or local proof collection, and evidence validation. diff --git a/test/e2e/skill_compatibility_test.go b/test/e2e/skill_compatibility_test.go new file mode 100644 index 0000000..e741477 --- /dev/null +++ b/test/e2e/skill_compatibility_test.go @@ -0,0 +1,230 @@ +package e2e_test + +import ( + "bytes" + "encoding/json" + "os" + "path/filepath" + "slices" + "strings" + "testing" +) + +type capabilityContract struct { + SchemaVersion int `json:"schema_version"` + ResultSchema string `json:"result_schema"` + ManifestSchema int `json:"manifest_schema"` + EvidenceSchema string `json:"evidence_schema"` + Packs []contractPack `json:"packs"` +} + +type contractPack struct { + ID string `json:"id"` + Version string `json:"version"` + Capabilities []string `json:"capabilities"` + Journeys []string `json:"journeys"` +} + +type skillCompatibilityMatrix struct { + SchemaVersion int `json:"schema_version,omitempty"` + Phase string `json:"phase,omitempty"` + ContractVersion int `json:"contract_version"` + RequiredResultSchema string `json:"required_result_schema"` + RequiredManifestSchema int `json:"required_manifest_schema"` + RequiredEvidenceSchema string `json:"required_evidence_schema"` + Products map[string]skillProductContract `json:"products"` +} + +type skillProductContract struct { + RequiredCapabilities []string `json:"required_capabilities"` + RequiredJourneys []string `json:"required_journeys"` +} + +func TestAgentSkillCompatibility(t *testing.T) { + root := repositoryRoot(t) + contract := loadCapabilityContract(t, root) + expected := expectedSkillCompatibility(contract) + expectedJSON := marshalCanonicalJSON(t, expected) + + t.Run("checked in contract stays exact", func(t *testing.T) { + if !bytes.Equal(expectedJSON, []byte(expectedSkillCompatibilityJSON)) { + t.Fatalf( + "checked-in compatibility matrix drifted\nwant: %s\n got: %s", + expectedJSON, + expectedSkillCompatibilityJSON, + ) + } + }) + + t.Run("checked in matrix remains product keyed", func(t *testing.T) { + var checkedIn skillCompatibilityMatrix + decodeJSON(t, []byte(expectedSkillCompatibilityJSON), &checkedIn) + assertCompatibilityMatrix(t, contract, checkedIn) + }) + + if skillPath, ok := resolveSkillMatrixPath(); ok { + t.Run("explicit skill checkout matches expected matrix", func(t *testing.T) { + actual := loadSkillCompatibilityMatrix(t, skillPath) + assertCompatibilityMatrix(t, contract, actual) + actualJSON := marshalCanonicalJSON(t, actual) + if !bytes.Equal(actualJSON, expectedJSON) { + t.Fatalf( + "skill matrix mismatch for %s\nwant: %s\n got: %s", + skillPath, + expectedJSON, + actualJSON, + ) + } + }) + } +} + +func loadCapabilityContract(t *testing.T, root string) capabilityContract { + t.Helper() + path := filepath.Join(root, "contracts", "capabilities-v1.json") + data, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + var value capabilityContract + decodeJSON(t, data, &value) + return value +} + +func loadSkillCompatibilityMatrix(t *testing.T, path string) skillCompatibilityMatrix { + t.Helper() + data, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + var value skillCompatibilityMatrix + decodeJSON(t, data, &value) + return value +} + +func resolveSkillMatrixPath() (string, bool) { + if dir := strings.TrimSpace(os.Getenv("MIDTRANS_AGENT_SKILL_DIR")); dir != "" { + return filepath.Join(dir, "integrate-midtrans-payments", "cli-compatibility.json"), true + } + return "", false +} + +func expectedSkillCompatibility(contract capabilityContract) skillCompatibilityMatrix { + products := make(map[string]skillProductContract, len(contract.Packs)) + var commonCapabilities []string + for _, pack := range contract.Packs { + if pack.ID == "common" { + commonCapabilities = append(commonCapabilities, pack.Capabilities...) + break + } + } + for _, pack := range contract.Packs { + if pack.ID == "common" { + continue + } + requiredCapabilities := append([]string{}, commonCapabilities...) + requiredCapabilities = append(requiredCapabilities, pack.Capabilities...) + slices.Sort(requiredCapabilities) + products[pack.ID] = skillProductContract{ + RequiredCapabilities: slices.Compact(requiredCapabilities), + RequiredJourneys: sortedCompact(pack.Journeys), + } + } + return skillCompatibilityMatrix{ + ContractVersion: 1, + RequiredResultSchema: contract.ResultSchema, + RequiredManifestSchema: contract.ManifestSchema, + RequiredEvidenceSchema: contract.EvidenceSchema, + Products: products, + } +} + +func assertCompatibilityMatrix( + t *testing.T, + contract capabilityContract, + matrix skillCompatibilityMatrix, +) { + t.Helper() + if matrix.ContractVersion != 1 { + t.Fatalf("contract_version = %d, want 1", matrix.ContractVersion) + } + if matrix.SchemaVersion != 0 { + t.Fatalf("legacy schema_version must be removed, got %d", matrix.SchemaVersion) + } + if matrix.Phase != "" { + t.Fatalf("legacy phase must be removed, got %q", matrix.Phase) + } + if matrix.RequiredResultSchema != contract.ResultSchema { + t.Fatalf( + "required_result_schema = %q, want %q", + matrix.RequiredResultSchema, + contract.ResultSchema, + ) + } + if matrix.RequiredManifestSchema != contract.ManifestSchema { + t.Fatalf( + "required_manifest_schema = %d, want %d", + matrix.RequiredManifestSchema, + contract.ManifestSchema, + ) + } + if matrix.RequiredEvidenceSchema != contract.EvidenceSchema { + t.Fatalf( + "required_evidence_schema = %q, want %q", + matrix.RequiredEvidenceSchema, + contract.EvidenceSchema, + ) + } + if len(matrix.Products) != len(contract.Packs)-1 { + t.Fatalf("products count = %d, want %d", len(matrix.Products), len(contract.Packs)-1) + } + expected := expectedSkillCompatibility(contract) + for product, want := range expected.Products { + got, ok := matrix.Products[product] + if !ok { + t.Fatalf("missing product %q", product) + } + if !slices.Equal(got.RequiredCapabilities, want.RequiredCapabilities) { + t.Fatalf( + "%s required_capabilities = %v, want %v", + product, + got.RequiredCapabilities, + want.RequiredCapabilities, + ) + } + if !slices.Equal(got.RequiredJourneys, want.RequiredJourneys) { + t.Fatalf( + "%s required_journeys = %v, want %v", + product, + got.RequiredJourneys, + want.RequiredJourneys, + ) + } + } +} + +func decodeJSON(t *testing.T, data []byte, destination any) { + t.Helper() + decoder := json.NewDecoder(bytes.NewReader(data)) + decoder.DisallowUnknownFields() + if err := decoder.Decode(destination); err != nil { + t.Fatalf("decode json: %v\n%s", err, data) + } +} + +func marshalCanonicalJSON(t *testing.T, value any) []byte { + t.Helper() + data, err := json.Marshal(value) + if err != nil { + t.Fatal(err) + } + return data +} + +func sortedCompact(values []string) []string { + items := append([]string{}, values...) + slices.Sort(items) + return slices.Compact(items) +} + +const expectedSkillCompatibilityJSON = `{"contract_version":1,"required_result_schema":"1.0","required_manifest_schema":1,"required_evidence_schema":"1.0","products":{"bisnap":{"required_capabilities":["bisnap.direct-debit.verify.v1","bisnap.qris.verify.v1","bisnap.recurring.verify.v1","bisnap.refund.verify.v1","bisnap.status.verify.v1","bisnap.virtual-account.verify.v1","common.capabilities.v1"],"required_journeys":["bisnap.direct-debit","bisnap.qris-payment","bisnap.recurring","bisnap.refund","bisnap.status","bisnap.virtual-account"]},"core-api":{"required_capabilities":["common.capabilities.v1","core-api.card-3ds.verify.v1","core-api.installment.verify.v1","core-api.otc.verify.v1","core-api.recurring.verify.v1","core-api.refund.verify.v1","core-api.saved-card.verify.v1","core-api.virtual-account.verify.v1"],"required_journeys":["core-api.card-3ds","core-api.installment","core-api.otc","core-api.recurring","core-api.refund","core-api.saved-card","core-api.virtual-account"]},"gopay-tokenization":{"required_capabilities":["common.capabilities.v1","gopay-tokenization.account-linking.verify.v1","gopay-tokenization.binding-inquiry.verify.v1","gopay-tokenization.paylater.verify.v1","gopay-tokenization.recurring.verify.v1","gopay-tokenization.unlink.verify.v1","gopay-tokenization.wallet-payment.verify.v1"],"required_journeys":["gopay-tokenization.account-linking","gopay-tokenization.binding-inquiry","gopay-tokenization.paylater","gopay-tokenization.recurring","gopay-tokenization.unlink","gopay-tokenization.wallet-payment"]},"payment-link":{"required_capabilities":["common.capabilities.v1","payment-link.create.verify.v1","payment-link.reusable.verify.v1","payment-link.verify.v1"],"required_journeys":["payment-link.create","payment-link.reusable","payment-link.verify"]},"snap":{"required_capabilities":["common.capabilities.v1","snap.checkout.verify.v1","snap.mobile.verify.v1","snap.plan.v1","snap.webhook.verify.v1"],"required_journeys":["common.status-reconciliation","common.webhook-idempotency","snap.checkout","snap.mobile-webview"]},"subscription":{"required_capabilities":["common.capabilities.v1","subscription.cancel.verify.v1","subscription.create.verify.v1","subscription.disable.verify.v1","subscription.enable.verify.v1","subscription.verify.v1"],"required_journeys":["subscription.cancel","subscription.create","subscription.disable","subscription.enable","subscription.verify"]}}}` From ee71b545c9cf5e55386da7fb05421f9dd6a7c5e9 Mon Sep 17 00:00:00 2001 From: "m.salis" Date: Mon, 27 Jul 2026 17:20:45 +0700 Subject: [PATCH 65/73] docs: record Agent Skill parity review --- .../task-13-report.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.superpowers/sdd/2026-07-26-midtrans-cli-multi-product-parity/task-13-report.md b/.superpowers/sdd/2026-07-26-midtrans-cli-multi-product-parity/task-13-report.md index 5e87a0b..0f1a9f4 100644 --- a/.superpowers/sdd/2026-07-26-midtrans-cli-multi-product-parity/task-13-report.md +++ b/.superpowers/sdd/2026-07-26-midtrans-cli-multi-product-parity/task-13-report.md @@ -32,6 +32,8 @@ contract and documented the handshake for all supported Midtrans products. - CLI repo base before this slice: `ef63f51841abeabbe338fa3e9303cc4336483226` - Skill repo matrix commit validated locally: `30218ecff8d01d871632d547569a38f09e6caf1a` +- Skill repo parity-guidance review fix: + `f293153665a9d97b2cb1ab45179b879359370dc2` ## Validation From 61acc533f97f6e68e8f851cbabe2ce811bf059a2 Mon Sep 17 00:00:00 2001 From: "m.salis" Date: Mon, 27 Jul 2026 17:31:05 +0700 Subject: [PATCH 66/73] test: add multi-product merchant evaluations --- .../task-14-report.md | 49 +++++++ README.md | 23 ++- docs/sandbox-evidence.md | 22 +++ evaluations/README.md | 58 ++++++-- .../fixtures/bisnap-qris-va/.env.example | 5 + .../bisnap-qris-va/.midtrans/manifest.yaml | 50 +++++++ evaluations/fixtures/bisnap-qris-va/README.md | 30 ++++ evaluations/fixtures/bisnap-qris-va/reset.sh | 6 + evaluations/fixtures/bisnap-qris-va/server.py | 37 +++++ evaluations/fixtures/bisnap-qris-va/start.sh | 6 + evaluations/fixtures/bisnap-qris-va/test.sh | 15 ++ .../fixtures/coreapi-paymentlink/.env.example | 2 + .../.midtrans/manifest.yaml | 50 +++++++ .../fixtures/coreapi-paymentlink/README.md | 31 ++++ .../fixtures/coreapi-paymentlink/reset.sh | 6 + .../fixtures/coreapi-paymentlink/server.py | 40 ++++++ .../fixtures/coreapi-paymentlink/start.sh | 6 + .../fixtures/coreapi-paymentlink/test.sh | 15 ++ .../fixtures/hybrid-snap-gopay/.env.example | 3 + .../hybrid-snap-gopay/.midtrans/manifest.yaml | 51 +++++++ .../fixtures/hybrid-snap-gopay/README.md | 33 +++++ .../fixtures/hybrid-snap-gopay/reset.sh | 6 + .../fixtures/hybrid-snap-gopay/server.py | 40 ++++++ .../fixtures/hybrid-snap-gopay/start.sh | 6 + .../fixtures/hybrid-snap-gopay/test.sh | 15 ++ evaluations/multi-product-autonomous.json | 120 ++++++++++++++++ test/e2e/cli_test.go | 134 ++++++++++++++++++ 27 files changed, 841 insertions(+), 18 deletions(-) create mode 100644 .superpowers/sdd/2026-07-26-midtrans-cli-multi-product-parity/task-14-report.md create mode 100644 evaluations/fixtures/bisnap-qris-va/.env.example create mode 100644 evaluations/fixtures/bisnap-qris-va/.midtrans/manifest.yaml create mode 100644 evaluations/fixtures/bisnap-qris-va/README.md create mode 100755 evaluations/fixtures/bisnap-qris-va/reset.sh create mode 100755 evaluations/fixtures/bisnap-qris-va/server.py create mode 100755 evaluations/fixtures/bisnap-qris-va/start.sh create mode 100755 evaluations/fixtures/bisnap-qris-va/test.sh create mode 100644 evaluations/fixtures/coreapi-paymentlink/.env.example create mode 100644 evaluations/fixtures/coreapi-paymentlink/.midtrans/manifest.yaml create mode 100644 evaluations/fixtures/coreapi-paymentlink/README.md create mode 100755 evaluations/fixtures/coreapi-paymentlink/reset.sh create mode 100755 evaluations/fixtures/coreapi-paymentlink/server.py create mode 100755 evaluations/fixtures/coreapi-paymentlink/start.sh create mode 100755 evaluations/fixtures/coreapi-paymentlink/test.sh create mode 100644 evaluations/fixtures/hybrid-snap-gopay/.env.example create mode 100644 evaluations/fixtures/hybrid-snap-gopay/.midtrans/manifest.yaml create mode 100644 evaluations/fixtures/hybrid-snap-gopay/README.md create mode 100755 evaluations/fixtures/hybrid-snap-gopay/reset.sh create mode 100755 evaluations/fixtures/hybrid-snap-gopay/server.py create mode 100755 evaluations/fixtures/hybrid-snap-gopay/start.sh create mode 100755 evaluations/fixtures/hybrid-snap-gopay/test.sh create mode 100644 evaluations/multi-product-autonomous.json diff --git a/.superpowers/sdd/2026-07-26-midtrans-cli-multi-product-parity/task-14-report.md b/.superpowers/sdd/2026-07-26-midtrans-cli-multi-product-parity/task-14-report.md new file mode 100644 index 0000000..85d1d54 --- /dev/null +++ b/.superpowers/sdd/2026-07-26-midtrans-cli-multi-product-parity/task-14-report.md @@ -0,0 +1,49 @@ +# Task 14 Report + +Date: 2026-07-27 + +## Slice A + +Added representative multi-product evaluation artifacts, loopback-only fixture +repos, and e2e/docs coverage for synthetic journey rehearsal without real +Sandbox claims. + +## What Changed + +- Added `evaluations/multi-product-autonomous.json` covering six compiled packs + and three synthetic multi-pack merchant fixtures. +- Added fixture repositories: + - `evaluations/fixtures/hybrid-snap-gopay/` + - `evaluations/fixtures/coreapi-paymentlink/` + - `evaluations/fixtures/bisnap-qris-va/` +- Each fixture now includes: + - a clean hybrid `.midtrans/manifest.yaml` + - synthetic `.env.example` + - loopback `start.sh`, `reset.sh`, and `test.sh` + - a local JSON stub server + - blocked real Sandbox prerequisites called out in `README.md` +- Expanded `test/e2e/cli_test.go` to validate: + - the multi-product evaluation matrix exists + - every synthetic fixture enables at least two packs + - fixture scripts describe `pack list`, `agent plan`, `agent run`, + `agent resume`, and `evidence export` + - synthetic loopback runs still mark real Sandbox prerequisites blocked +- Updated `evaluations/README.md`, `README.md`, and `docs/sandbox-evidence.md` + to reflect multi-product synthetic rehearsal and blocked prerequisite + semantics. + +## Validation + +Focused: + +```sh +go test ./test/e2e -count=1 +``` + +Full: + +```sh +go test ./... -count=1 +``` + +Result: passed on Monday, July 27, 2026. diff --git a/README.md b/README.md index fbd2648..d133c77 100644 --- a/README.md +++ b/README.md @@ -94,6 +94,22 @@ loopback-safe application state defaults, and empty `credential_sets`, entry point that adds the first Snap-oriented credential set, integration, checkout routing, and verification requirements. +## Representative multi-product fixtures + +The repository also ships synthetic loopback fixtures for representative +multi-product merchant flows under `evaluations/fixtures/`: + +- `hybrid-snap-gopay` +- `coreapi-paymentlink` +- `bisnap-qris-va` + +These fixtures contain clean hybrid manifests, synthetic placeholder +credentials, loopback-only stubs, and checked-in rehearsal steps for `pack +list`, `agent plan`, `agent run`, `agent resume`, reconciliation, and evidence +export. They do **not** claim live Sandbox success. Real Sandbox prerequisites +such as activation, buyer interaction, callback delivery, or device proof stay +blocked and must remain explicit in any evaluation report. + ## Contracts and compatibility - [Capability contract](contracts/capabilities-v1.json) @@ -102,9 +118,10 @@ checkout routing, and verification requirements. - [Evidence schema](schemas/evidence-v1.schema.json) - [Agent Skill compatibility](docs/agent-skill-compatibility.md) -These public contracts implement the approved Phase 1 design boundary. Core -API, BI-SNAP, GoPay, subscriptions, refunds, production execution, framework -code generation, telemetry, and remote MCP operation are outside Phase 1. +These public contracts cover the compiled multi-product CLI surface: Snap, Core +API, Payment Link, BI-SNAP, GoPay tokenization, and Subscription. Production +execution, framework code generation, telemetry, and remote MCP operation stay +outside the current boundary. ## Development gates diff --git a/docs/sandbox-evidence.md b/docs/sandbox-evidence.md index 3c0d98c..2cc17e6 100644 --- a/docs/sandbox-evidence.md +++ b/docs/sandbox-evidence.md @@ -3,6 +3,11 @@ Evidence records what the CLI actually observed; it is not a production certification and must not be upgraded by interpretation. +Synthetic loopback fixtures may rehearse journey state, pause/resume behavior, +callback handling, reconciliation, and export format. They do not become +Sandbox proof unless the required provider-side observations were actually +captured at the declared proof level. + ## Proof levels - `local` proves behavior observed from the merchant application on loopback, @@ -11,11 +16,28 @@ certification and must not be upgraded by interpretation. - A complete Snap verification requires the declared provider-status proof and merchant-callback proof at their required levels. Missing, blocked, or failed proofs remain explicit. +- Hybrid or multi-product verification stays per journey. A local synthetic + pause or callback for one journey does not satisfy blocked real Sandbox + prerequisites for another journey. The verifier rejects evidence from another manifest, repository revision, pack, journey, or environment. It also rejects claims that label a local observation as sandbox proof. +## Blocked prerequisites + +When a fixture or operator run lacks real Sandbox prerequisites, the missing +requirement must stay explicit. Common blocked cases include: + +- sandbox credentials or key material not supplied, +- dashboard payment-method activation not confirmed, +- hosted checkout or wallet buyer interaction not completed, +- notification callback delivery from Midtrans not observed, +- real-device or app-switch proof not captured. + +Blocked prerequisites are valid evidence outcomes. They must never be rewritten +as pass because a local stub, replay, or synthetic payload exists. + ## Storage and export The CLI writes evidence and checksum files with mode `0600` inside owner-only diff --git a/evaluations/README.md b/evaluations/README.md index f15493f..a4451b9 100644 --- a/evaluations/README.md +++ b/evaluations/README.md @@ -1,13 +1,24 @@ -# Snap autonomous evaluation +# Multi-product autonomous evaluation -This matrix is a controlled release gate, not a unit-test substitute. It runs -two agent hosts across three merchant fixtures three times each: **18 runs**. -At least **17** must pass to exceed the 90% threshold, and any configured hard -failure blocks release regardless of completion rate. +The controlled release gate still targets **18 runs** and at least **17** +passing results across two agent hosts and three merchant fixtures. For Task 14 +slice A, this repository now also includes a synthetic multi-product matrix for +loopback-only rehearsal across six compiled packs: + +- `snap` +- `core-api` +- `payment-link` +- `bisnap` +- `gopay-tokenization` +- `subscription` + +The new file is `evaluations/multi-product-autonomous.json`. It is a +checked-in product and fixture contract, not a claim that live cross-agent +evaluation has completed. ## Reproducibility contract -Every run must use: +Every controlled run must use: - the same candidate Midtrans CLI commit and locally built binary; - Agent Skills integration commit @@ -32,12 +43,12 @@ For each matrix entry: additional hints. The host may edit, start, and test the merchant app. 4. Require the full capability handshake before CLI orchestration. 5. Require dry-run review before an explicit `--execute`. -6. Follow the one-time Snap checkout URL only with the controlled sandbox - browser runner. Do not substitute a local HTTP test or fabricated provider - result. -7. Require `snap.provider-status` and `snap.merchant-callback` evidence, - validate the evidence schema and `SHA256SUMS`, and scan all transcripts, - patches, logs, and artifacts for every canary. +6. Use loopback-only stubs for synthetic plan, pause, resume, callback, + reconciliation, and evidence-export rehearsal, while keeping real Sandbox + prerequisites explicitly blocked. +7. Require the declared proof set for the negotiated journeys, validate the + evidence schema and `SHA256SUMS`, and scan all transcripts, patches, logs, + and artifacts for every canary. 8. Record pass/fail, duration, edit loops, block reason, CLI commit, Skill commit, evidence checksum, and any hard-failure category. @@ -54,6 +65,22 @@ failure. - `broken-webhook-state-machine` contains a Ruby standard-library integration with duplicate fulfillment and paid-to-pending regression bugs. +The synthetic Task 14 slice A fixtures are: + +- `hybrid-snap-gopay` for hosted Snap plus tokenized GoPay routing. +- `coreapi-paymentlink` for Core API card flows plus Payment Link invoices. +- `bisnap-qris-va` for BI-SNAP QRIS and VA with shared tokenization inquiry. + +Each synthetic fixture: + +- uses a clean hybrid manifest with at least two enabled packs, +- binds only `127.0.0.1`, +- uses synthetic placeholder credentials and data only, +- scripts `pack list`, `agent plan`, `agent run`, `agent resume`, and + `evidence export` semantics without claiming live Sandbox success, +- records blocked real Sandbox prerequisites such as activation, buyer + interaction, callback delivery, or real-device proof. + The languages measure merchant-stack portability. Node, Python, and Ruby are not CLI runtime dependencies. Each fixture binds `127.0.0.1` on a configurable port; port `0` asks the OS for an ephemeral port. @@ -63,8 +90,9 @@ port; port `0` asks the OS for an ephemeral port. The actual 18-run cross-agent campaign has **not been run** in this repository implementation session because it requires live controlled Claude Code/Codex hosts, sandbox credentials, and a controlled sandbox browser. Local unit tests -cannot honestly replace those observations. This is a precise release blocker: -do not create a tag or invoke the release workflow until an authorized -evaluation operator records at least 17 passing runs and zero hard failures. +and loopback fixtures cannot honestly replace those observations. This is a +precise release blocker: do not create a tag or invoke the release workflow +until an authorized evaluation operator records at least 17 passing runs and +zero hard failures. No campaign result or pass rate is claimed by this infrastructure commit. diff --git a/evaluations/fixtures/bisnap-qris-va/.env.example b/evaluations/fixtures/bisnap-qris-va/.env.example new file mode 100644 index 0000000..a40e5f8 --- /dev/null +++ b/evaluations/fixtures/bisnap-qris-va/.env.example @@ -0,0 +1,5 @@ +MIDTRANS_BISNAP_CLIENT_ID=synthetic-client-id +MIDTRANS_BISNAP_CLIENT_SECRET=synthetic-client-secret +MIDTRANS_BISNAP_PARTNER_ID=synthetic-partner-id +MIDTRANS_BISNAP_CHANNEL_ID=synthetic-channel-id +MIDTRANS_BISNAP_DEVICE_ID=synthetic-device-id diff --git a/evaluations/fixtures/bisnap-qris-va/.midtrans/manifest.yaml b/evaluations/fixtures/bisnap-qris-va/.midtrans/manifest.yaml new file mode 100644 index 0000000..29d6e53 --- /dev/null +++ b/evaluations/fixtures/bisnap-qris-va/.midtrans/manifest.yaml @@ -0,0 +1,50 @@ +schema_version: 1 +policy: + environments: + - sandbox + production: deny +application: + base_url: http://127.0.0.1:18103 + payment_state: + paid: + - paid + - settled + terminal: + - paid + - settled + - failed + - expired + monotonic: true +credential_sets: + bisnap: + type: bisnap + environment: sandbox + client_id: env:MIDTRANS_BISNAP_CLIENT_ID + client_secret: env:MIDTRANS_BISNAP_CLIENT_SECRET + partner_id: env:MIDTRANS_BISNAP_PARTNER_ID + channel_id: env:MIDTRANS_BISNAP_CHANNEL_ID + device_id: env:MIDTRANS_BISNAP_DEVICE_ID + private_key: file:./keys/private.pem + midtrans_public_key: file:./keys/public.pem +integrations: + bisnap: + config_version: 1 + credentials: bisnap + payment_methods: + - qris + - virtual_account + callbacks: + notification: /api/payments/bisnap/notification + gopay-tokenization: + config_version: 1 + credentials: bisnap + capabilities: + - binding-inquiry +routing: + checkout: bisnap + inquiry: gopay-tokenization +verification: + required: + - bisnap.qris-payment + - bisnap.virtual-account + - gopay-tokenization.binding-inquiry diff --git a/evaluations/fixtures/bisnap-qris-va/README.md b/evaluations/fixtures/bisnap-qris-va/README.md new file mode 100644 index 0000000..68afb88 --- /dev/null +++ b/evaluations/fixtures/bisnap-qris-va/README.md @@ -0,0 +1,30 @@ +# BI-SNAP QRIS And VA Fixture + +This fixture is a loopback-only synthetic merchant repository that combines: + +- BI-SNAP QRIS and Virtual Account flows. +- GoPay tokenization inquiry-only support for shared account lookup paths. + +It contains no real credentials, payer data, or Midtrans payloads. Real +Sandbox prerequisites remain blocked until an operator provides sandbox BI-SNAP +credentials, callback delivery, and actual payer completion. + +## Intended synthetic loop + +1. `midtrans pack list` to confirm `bisnap` and `gopay-tokenization`. +2. `midtrans agent plan` for `bisnap.qris-payment` and + `bisnap.virtual-account` with no mutation. +3. `midtrans agent run --execute` against loopback stubs until the CLI pauses + for QRIS or VA payment completion. +4. `midtrans agent resume` after synthetic notification or reconciliation data. +5. `midtrans evidence export` after synthetic notification and merchant + persistence evidence exists. + +## Sandbox prerequisites + +- sandbox BI-SNAP credentials and key material, +- dashboard QRIS and VA activation, +- real callback delivery from Midtrans, +- real payer completion of QRIS or VA payment. + +These prerequisites must remain blocked in local synthetic runs. diff --git a/evaluations/fixtures/bisnap-qris-va/reset.sh b/evaluations/fixtures/bisnap-qris-va/reset.sh new file mode 100755 index 0000000..5c44814 --- /dev/null +++ b/evaluations/fixtures/bisnap-qris-va/reset.sh @@ -0,0 +1,6 @@ +#!/usr/bin/env sh +set -eu + +fixture_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) +rm -rf "$fixture_dir/.state" "$fixture_dir/exported" +mkdir -p "$fixture_dir/.state" "$fixture_dir/exported" diff --git a/evaluations/fixtures/bisnap-qris-va/server.py b/evaluations/fixtures/bisnap-qris-va/server.py new file mode 100755 index 0000000..4e2ef29 --- /dev/null +++ b/evaluations/fixtures/bisnap-qris-va/server.py @@ -0,0 +1,37 @@ +#!/usr/bin/env python3 +import json +import os +from http.server import BaseHTTPRequestHandler, HTTPServer + + +class Handler(BaseHTTPRequestHandler): + def _write(self, code, body): + encoded = json.dumps(body).encode("utf-8") + self.send_response(code) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(encoded))) + self.end_headers() + self.wfile.write(encoded) + + def do_GET(self): + if self.path == "/health": + self._write(200, {"ok": True, "fixture": "bisnap-qris-va", "loopback": True}) + return + self._write(404, {"error": "not_found"}) + + def do_POST(self): + if self.path == "/api/payments/bisnap/notification": + self._write(200, {"accepted": True, "synthetic": True}) + return + self._write(404, {"error": "not_found"}) + + +def main(): + port = int(os.environ.get("PORT", "18103")) + server = HTTPServer(("127.0.0.1", port), Handler) + print(json.dumps({"url": f"http://127.0.0.1:{port}", "fixture": "bisnap-qris-va"}), flush=True) + server.serve_forever() + + +if __name__ == "__main__": + main() diff --git a/evaluations/fixtures/bisnap-qris-va/start.sh b/evaluations/fixtures/bisnap-qris-va/start.sh new file mode 100755 index 0000000..3cdcbf9 --- /dev/null +++ b/evaluations/fixtures/bisnap-qris-va/start.sh @@ -0,0 +1,6 @@ +#!/usr/bin/env sh +set -eu + +fixture_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) +cd "$fixture_dir" +exec python3 server.py diff --git a/evaluations/fixtures/bisnap-qris-va/test.sh b/evaluations/fixtures/bisnap-qris-va/test.sh new file mode 100755 index 0000000..0cac0b0 --- /dev/null +++ b/evaluations/fixtures/bisnap-qris-va/test.sh @@ -0,0 +1,15 @@ +#!/usr/bin/env sh +set -eu + +fixture_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) +midtrans_bin=${MIDTRANS_BIN:-midtrans} + +cat < Date: Mon, 27 Jul 2026 17:46:35 +0700 Subject: [PATCH 67/73] test: prove multi-product merchant CLI parity --- .../task-14-report.md | 35 +++++ README.md | 10 +- contracts/public-sources-v1.json | 140 +++++++++--------- internal/sourceprovenance/baseline.go | 38 ++++- internal/sourceprovenance/baseline_test.go | 2 +- test/release/infrastructure_test.go | 36 +++++ tools/check_release.sh | 1 + tools/install-local.sh | 40 ++++- tools/test-install-local.sh | 6 +- 9 files changed, 221 insertions(+), 87 deletions(-) diff --git a/.superpowers/sdd/2026-07-26-midtrans-cli-multi-product-parity/task-14-report.md b/.superpowers/sdd/2026-07-26-midtrans-cli-multi-product-parity/task-14-report.md index 85d1d54..7a036dc 100644 --- a/.superpowers/sdd/2026-07-26-midtrans-cli-multi-product-parity/task-14-report.md +++ b/.superpowers/sdd/2026-07-26-midtrans-cli-multi-product-parity/task-14-report.md @@ -47,3 +47,38 @@ go test ./... -count=1 ``` Result: passed on Monday, July 27, 2026. + +## Slice B + +Completed installer and release-gate parity for the multi-product CLI without +expanding the evaluation fixture scope. + +## What Changed + +- Tightened `tools/install-local.sh` so install verification now parses + machine-readable `version` and `agent capabilities` JSON, requires the six + product packs, and confirms the evidence schema before replacing the target + binary. +- Tightened `tools/test-install-local.sh` to verify rollback via stable + checksums and to feed valid fake JSON through the installer verification path. +- Added `go run ./tools/source-drift --baseline contracts/public-sources-v1.json` + to `tools/check_release.sh`. +- Canonicalized Cloudflare email-protection `href` tokens in the source + provenance normalizer so release baselines stay stable across live docs fetches. +- Expanded `test/release/infrastructure_test.go` to enforce the stronger + installer and release-gate contract. + +## Validation + +Release gates: + +```sh +gofmt -w test/release/infrastructure_test.go +go vet ./... +go test ./... -count=1 +./tools/check_release.sh +./tools/test-install-local.sh +git diff --check +``` + +Result: passed on Monday, July 27, 2026. diff --git a/README.md b/README.md index d133c77..e4cffb4 100644 --- a/README.md +++ b/README.md @@ -133,11 +133,11 @@ go run github.com/goreleaser/goreleaser/v2@v2.17.0 build --snapshot --clean The public-source gate fetches only the URLs compiled into the Snap pack. It uses HTTPS on `docs.midtrans.com`, refuses redirects, times out after 10 seconds, and caps each response at 2 MiB. Before hashing, it normalizes CRLF and -canonicalizes only the randomized Cloudflare email-protection attribute by -decoding its value; this preserves email-content changes without treating -Cloudflare's per-response random key as documentation drift. A mismatch reports -source IDs without response bodies and requires deliberate human review before -baseline regeneration. +canonicalizes the randomized Cloudflare email-protection attribute and `href` +token by decoding their values; this preserves email-content changes without +treating Cloudflare's per-response random key as documentation drift. A +mismatch reports source IDs without response bodies and requires deliberate +human review before baseline regeneration. No tag or release should be created until the controlled 18-run evaluation in [evaluations/README.md](evaluations/README.md) passes its release gate. diff --git a/contracts/public-sources-v1.json b/contracts/public-sources-v1.json index 7389c38..d3e912d 100644 --- a/contracts/public-sources-v1.json +++ b/contracts/public-sources-v1.json @@ -8,8 +8,8 @@ "snap.token.create", "snap.basic-auth" ], - "sha256": "aed4cbc37d29b4f8f5f6d9c543a9a01c54f8defaf7a2a9226a3c6b26f6d363d8", - "retrieved_at": "2026-07-27T06:26:47.408497Z" + "sha256": "0c7b61f8d446f048f84209f2f87c49be6ca76712ce9b4875aa07f215b8de2a8a", + "retrieved_at": "2026-07-27T10:45:47.988248Z" }, { "id": "snap-js", @@ -18,8 +18,8 @@ "snap.checkout.popup", "snap.checkout.embed" ], - "sha256": "a801f05cc371c116e8c17d0569eadbe4a1f0bac2cc2df016b5f9b555dbe6de5e", - "retrieved_at": "2026-07-27T06:26:47.408497Z" + "sha256": "4bab74d43770fb66da10452bd1730213b22a7a3dc1064add26a419deacd7550b", + "retrieved_at": "2026-07-27T10:45:47.988248Z" }, { "id": "snap-integration", @@ -28,8 +28,8 @@ "snap.checkout.redirect", "snap.mobile.webview" ], - "sha256": "f869c0b53fb15ebbc9698dbb82d27f81e1c16f9f9717e2d8bbe9dd8103ef3639", - "retrieved_at": "2026-07-27T06:26:47.408497Z" + "sha256": "206d8f1e8a83dceec6f997a27cb9fa002c4bdfbfd41e090e585464ac1ea0c5ff", + "retrieved_at": "2026-07-27T10:45:47.988248Z" }, { "id": "technical-faq", @@ -38,8 +38,8 @@ "snap.mobile.deeplink-return", "snap.mobile.real-device-proof" ], - "sha256": "f865bf332992b374daad931cf071aa138a54d21a459037b82a3241847ce00857", - "retrieved_at": "2026-07-27T06:26:47.408497Z" + "sha256": "be0c04652e03289a09531bbdfc6de6750378489511ff28bb657d903fc52c6adb", + "retrieved_at": "2026-07-27T10:45:47.988248Z" }, { "id": "http-notifications", @@ -48,8 +48,8 @@ "snap.notification.signature", "common.webhook-idempotency" ], - "sha256": "f8565e0da1ffd4d38bfefb5a89b08a9cd731d00bd804efcb0b59b8f84e8a7889", - "retrieved_at": "2026-07-27T06:26:47.408497Z" + "sha256": "5f354ee579876e219f0e7365543944bac6e49d61f72bcedb9bd1c55cbbbd0fbd", + "retrieved_at": "2026-07-27T10:45:47.988248Z" }, { "id": "get-transaction-status", @@ -58,8 +58,8 @@ "snap.status.reconcile", "snap.mobile.status.reconcile" ], - "sha256": "5690ce474550c96a76593dfcc2487e75259418d0b2f62b1edfdae7a36ebde8c1", - "retrieved_at": "2026-07-27T06:26:47.408497Z" + "sha256": "cd82d0b4d10ceff81abfd26feed39bcc9261849a389229fec9db73c426319890", + "retrieved_at": "2026-07-27T10:45:47.988248Z" }, { "id": "coreapi-card-charge", @@ -68,8 +68,8 @@ "coreapi.card.charge", "coreapi.basic-auth" ], - "sha256": "e7d190b2163f58f3c807f21a3512c1628ebe74847381c87b4fad912a70469471", - "retrieved_at": "2026-07-27T06:26:47.408497Z" + "sha256": "1bf27d32309c2442b87c565cd1911e8b04969d6a19c014211fbb49caae6cd3e8", + "retrieved_at": "2026-07-27T10:45:47.988248Z" }, { "id": "coreapi-card-3ds", @@ -78,8 +78,8 @@ "coreapi.card.3ds", "coreapi.card.redirect" ], - "sha256": "d4c8ed1683ce00715079515eada4d475f821b4b6a809e23afba1ccd37dd3e8bb", - "retrieved_at": "2026-07-27T06:26:47.408497Z" + "sha256": "3292a8890118db7b50fd99762a31c7a63ffee14d7c396c9fda7c4cdbac890182", + "retrieved_at": "2026-07-27T10:45:47.988248Z" }, { "id": "coreapi-one-click", @@ -88,8 +88,8 @@ "coreapi.saved-card.token-only", "coreapi.recurring.saved-card-token" ], - "sha256": "291ccb1ad64fef8b49efca7a94e7012bd105cb85a09ee354b9d58d22e77f89d6", - "retrieved_at": "2026-07-27T06:26:47.408497Z" + "sha256": "e03d7b9dc47a3f65e119823ed27478d92e9127ac69b7261fba5c2690ecf8d404", + "retrieved_at": "2026-07-27T10:45:47.988248Z" }, { "id": "coreapi-alfamart", @@ -98,8 +98,8 @@ "coreapi.otc.charge", "coreapi.otc.payment-code" ], - "sha256": "233b55825dc6af757b0f335edb38e462ef17f093e40d5a535271e8dbe4f6ddf1", - "retrieved_at": "2026-07-27T06:26:47.408497Z" + "sha256": "85a259db9d987f5d391e744bfdb35120e11c76d80f1020d0c5150195a2d6b618", + "retrieved_at": "2026-07-27T10:45:47.988248Z" }, { "id": "coreapi-bni-va", @@ -108,8 +108,8 @@ "coreapi.va.charge", "coreapi.va.instructions" ], - "sha256": "f092e0b178ced9fb5e0a9eea77a3ca66654be1a8e48cbbf3246b083d4e7ab376", - "retrieved_at": "2026-07-27T06:26:47.408497Z" + "sha256": "b3db203c3f220f03c0f1175b1e8aa36953434ddce06cc8804872b447b6601626", + "retrieved_at": "2026-07-27T10:45:47.988248Z" }, { "id": "coreapi-status", @@ -119,8 +119,8 @@ "coreapi.recurring.status", "coreapi.refund.status" ], - "sha256": "5690ce474550c96a76593dfcc2487e75259418d0b2f62b1edfdae7a36ebde8c1", - "retrieved_at": "2026-07-27T06:26:47.408497Z" + "sha256": "cd82d0b4d10ceff81abfd26feed39bcc9261849a389229fec9db73c426319890", + "retrieved_at": "2026-07-27T10:45:47.988248Z" }, { "id": "coreapi-refund", @@ -129,8 +129,8 @@ "coreapi.refund.async", "coreapi.refund.idempotency" ], - "sha256": "a631255fcc1f3bb071c32cde6a0ab927634fb97affa263829601c2276c4d8c14", - "retrieved_at": "2026-07-27T06:26:47.408497Z" + "sha256": "facf272d33e2e407699992c3b8271ed7755f02e703275f16c3675640368011b0", + "retrieved_at": "2026-07-27T10:45:47.988248Z" }, { "id": "coreapi-direct-refund", @@ -138,8 +138,8 @@ "rules": [ "coreapi.refund.direct" ], - "sha256": "5d8efa9e94b4bf5e810bb3ba0757a2521824f665ba0939c865732034a35b9c8a", - "retrieved_at": "2026-07-27T06:26:47.408497Z" + "sha256": "e578d1033469eda45ff1a44a9e47cef07fc6fec0c9fb00c822789a7c0299b7db", + "retrieved_at": "2026-07-27T10:45:47.988248Z" }, { "id": "coreapi-notifications", @@ -149,8 +149,8 @@ "coreapi.recurring.notification", "common.webhook-idempotency" ], - "sha256": "f8565e0da1ffd4d38bfefb5a89b08a9cd731d00bd804efcb0b59b8f84e8a7889", - "retrieved_at": "2026-07-27T06:26:47.408497Z" + "sha256": "5f354ee579876e219f0e7365543944bac6e49d61f72bcedb9bd1c55cbbbd0fbd", + "retrieved_at": "2026-07-27T10:45:47.988248Z" }, { "id": "payment-link-overview", @@ -159,8 +159,8 @@ "paymentlink.create", "paymentlink.reusable" ], - "sha256": "1809d01e850c5caf7eed101a26e1d5530e01e52372c1909406223601744a2976", - "retrieved_at": "2026-07-27T06:26:47.408497Z" + "sha256": "f4522e173ecb69f70f692c3249d8dc3ae15bd6fd08ab73e4154fac1225fb09d5", + "retrieved_at": "2026-07-27T10:45:47.988248Z" }, { "id": "payment-link-status", @@ -168,8 +168,8 @@ "rules": [ "paymentlink.status.reconcile" ], - "sha256": "5690ce474550c96a76593dfcc2487e75259418d0b2f62b1edfdae7a36ebde8c1", - "retrieved_at": "2026-07-27T06:26:47.408497Z" + "sha256": "cd82d0b4d10ceff81abfd26feed39bcc9261849a389229fec9db73c426319890", + "retrieved_at": "2026-07-27T10:45:47.988248Z" }, { "id": "payment-link-notifications", @@ -178,8 +178,8 @@ "paymentlink.notification.signature", "common.webhook-idempotency" ], - "sha256": "f8565e0da1ffd4d38bfefb5a89b08a9cd731d00bd804efcb0b59b8f84e8a7889", - "retrieved_at": "2026-07-27T06:26:47.408497Z" + "sha256": "5f354ee579876e219f0e7365543944bac6e49d61f72bcedb9bd1c55cbbbd0fbd", + "retrieved_at": "2026-07-27T10:45:47.988248Z" }, { "id": "bisnap-overview", @@ -188,8 +188,8 @@ "bisnap.signing.verify.v1", "bisnap.recurring.transaction-signature" ], - "sha256": "fc348733022acfb2e29d77708dc74d33041b4053ee92e3dc80d1033e2162f903", - "retrieved_at": "2026-07-27T06:26:47.408497Z" + "sha256": "ef5d47829c92c362121b12d5cafb9848e5daafdad727153b04fd95b69730dbc5", + "retrieved_at": "2026-07-27T10:45:47.988248Z" }, { "id": "bisnap-qris", @@ -198,8 +198,8 @@ "bisnap.qris.create", "bisnap.qris.status" ], - "sha256": "ed2e6a71971549412dca481e463f058f8697dbe7a86c9a59757794d6531ac383", - "retrieved_at": "2026-07-27T06:26:47.408497Z" + "sha256": "570d194e36da969acb6d6ea6e3e3b90c51b12f51de8bb50c843c0e864791f59c", + "retrieved_at": "2026-07-27T10:45:47.988248Z" }, { "id": "bisnap-virtual-account", @@ -208,8 +208,8 @@ "bisnap.virtual-account.create", "bisnap.virtual-account.status" ], - "sha256": "a36a0c9060b067d751251972c4f321dbd957c3dc1f239feb2520467ff2492667", - "retrieved_at": "2026-07-27T06:26:47.408497Z" + "sha256": "fab9d59067f2968a85153078a017a075361d5f51f3cd693c951e1a5dba79a3e1", + "retrieved_at": "2026-07-27T10:45:47.988248Z" }, { "id": "bisnap-direct-debit", @@ -220,8 +220,8 @@ "bisnap.recurring.status", "bisnap.refund" ], - "sha256": "921a9ded97c836096d37f8faf1539678eeb51df8544c2615453830760c1a7f97", - "retrieved_at": "2026-07-27T06:26:47.408497Z" + "sha256": "927e3278b2ad97366f9930f271b7f2193879139c776c987ce1d066ff70c5d422", + "retrieved_at": "2026-07-27T10:45:47.988248Z" }, { "id": "bisnap-notifications", @@ -231,8 +231,8 @@ "bisnap.recurring.notification", "common.webhook-idempotency" ], - "sha256": "c96e1eb49a532a9ab44325a89d4609adc2a1bfd2bd80926b69b3dbb14b6e1b09", - "retrieved_at": "2026-07-27T06:26:47.408497Z" + "sha256": "c05d18b4ce5e67d7259580a10050f4ec780d23d7056e21a41e5807c713a3248c", + "retrieved_at": "2026-07-27T10:45:47.988248Z" }, { "id": "gopay-tokenization-get-auth-code", @@ -240,8 +240,8 @@ "rules": [ "gopaytokenization.linking.get-auth-code" ], - "sha256": "55329f74c98a5977f6dbd00a439d3d6db6b5e92bbeb02b3c3fe1cae3a2e913e9", - "retrieved_at": "2026-07-27T06:26:47.408497Z" + "sha256": "fad307376fbab42cadfa87108de50fb4b65ca580e9af02bad96a1436b8e1567b", + "retrieved_at": "2026-07-27T10:45:47.988248Z" }, { "id": "gopay-tokenization-binding-api", @@ -249,8 +249,8 @@ "rules": [ "gopaytokenization.linking.bind" ], - "sha256": "0d4e8b6d9fb4baf535ca69ababd7159b17d2ebfd268c0a776cf90028cd67e9dd", - "retrieved_at": "2026-07-27T06:26:47.408497Z" + "sha256": "30ac068d2e896dfe6f0765b3007d93769f6b312e6ad42b464ef90559161bac26", + "retrieved_at": "2026-07-27T10:45:47.988248Z" }, { "id": "gopay-tokenization-binding-inquiry-api", @@ -259,8 +259,8 @@ "gopaytokenization.linking.inquiry", "gopaytokenization.recurring.inquiry" ], - "sha256": "3cc21ba35f29a7db861c47a32443bec76ec4cf6295139a5d9fd72954470e3b14", - "retrieved_at": "2026-07-27T06:26:47.408497Z" + "sha256": "c8fbf2b8486644b270be77637ec1ce7ed3b26f05e5c6d82dc708970741e4c105", + "retrieved_at": "2026-07-27T10:45:47.988248Z" }, { "id": "gopay-tokenization-direct-debit", @@ -270,8 +270,8 @@ "gopaytokenization.paylater.charge", "gopaytokenization.recurring.option-selection" ], - "sha256": "1fbbd43a427439131a99190533fa4a8efc393b75530b8dfb5dbc4f696836ab89", - "retrieved_at": "2026-07-27T06:26:47.408497Z" + "sha256": "434c892e8c8aa075723e66e9ad0849cf3e4f9e1e4779e41ecb4d4e5f596d2312", + "retrieved_at": "2026-07-27T10:45:47.988248Z" }, { "id": "gopay-tokenization-unbind", @@ -279,8 +279,8 @@ "rules": [ "gopaytokenization.unlink" ], - "sha256": "d6098fccb6453a5c844c8d8ea58a2f4d8eeab0391e25a3bec0976f37fba0ef87", - "retrieved_at": "2026-07-27T06:26:47.408497Z" + "sha256": "4139a21b53919428075d94ff5865aa5436451f9e105cc13f9f86e075d4797541", + "retrieved_at": "2026-07-27T10:45:47.988248Z" }, { "id": "gopay-tokenization-account-linking-unlinking-notification", @@ -290,8 +290,8 @@ "gopaytokenization.recurring.notification", "common.webhook-idempotency" ], - "sha256": "c7040f48e6c9a31543b4b1ff38db6169dde9f384a651fd6558d527eea7d10365", - "retrieved_at": "2026-07-27T06:26:47.408497Z" + "sha256": "55df8f16cfd6d49973374b992b364dedb8928d801abce80a446382ddefcc4341", + "retrieved_at": "2026-07-27T10:45:47.988248Z" }, { "id": "subscription-create", @@ -300,8 +300,8 @@ "subscription.create", "subscription.basic-auth" ], - "sha256": "ff27aaeb3a0c240f3d6e0de2f394312acb38603a6335309236d7d5a18a7c489d", - "retrieved_at": "2026-07-27T06:26:47.408497Z" + "sha256": "cdb2e55b2ff4648314598902033e4bbf5a7b63bd9394231b03346c67f0c346fc", + "retrieved_at": "2026-07-27T10:45:47.988248Z" }, { "id": "subscription-update", @@ -310,8 +310,8 @@ "subscription.update", "subscription.safe-schedule" ], - "sha256": "dd9f1d9bbc1b580a4c6449d7a577e15e0ed358755620d0c2eafb88ea26d3da0d", - "retrieved_at": "2026-07-27T06:26:47.408497Z" + "sha256": "6660ab17233cba1e9241fc339062da6f7d25fe933260e3e7997950812edc7781", + "retrieved_at": "2026-07-27T10:45:47.988248Z" }, { "id": "subscription-get", @@ -320,8 +320,8 @@ "subscription.status", "subscription.status-before-mutation" ], - "sha256": "13b8f0dbe41afe776083cfff8beb97d164931c0d6a953e9d1b4a1c4cd96eda63", - "retrieved_at": "2026-07-27T06:26:47.408497Z" + "sha256": "c12b0d92506de62700bd3074474cd246f968275ca942af2a92e23a91d1e2a574", + "retrieved_at": "2026-07-27T10:45:47.988248Z" }, { "id": "subscription-disable", @@ -330,8 +330,8 @@ "subscription.disable", "subscription.no-blind-retry" ], - "sha256": "e55306449ca0129a620365a33b738d4501672b2cd02d9a4175eb39541e5a08c8", - "retrieved_at": "2026-07-27T06:26:47.408497Z" + "sha256": "b5330b19ff1860c54ee6d032be8f73a2b7ed6efcca4898018b0d5cf680dbbc32", + "retrieved_at": "2026-07-27T10:45:47.988248Z" }, { "id": "subscription-enable", @@ -340,8 +340,8 @@ "subscription.enable", "subscription.no-blind-retry" ], - "sha256": "124ada6c11a6e3a1e48de6159edcff2fd9d133d79ef47aef647189c7fceb66c5", - "retrieved_at": "2026-07-27T06:26:47.408497Z" + "sha256": "701f894530fb6c4813b3dea28789113a26f2c55f58f69818890ba7a250f15c6c", + "retrieved_at": "2026-07-27T10:45:47.988248Z" }, { "id": "subscription-cancel", @@ -350,8 +350,8 @@ "subscription.cancel", "subscription.no-blind-retry" ], - "sha256": "089421d43ee32f7fc3bfe3bf789cd8fdab27b5abdc8b8f34a61c32a520694e21", - "retrieved_at": "2026-07-27T06:26:47.408497Z" + "sha256": "56776bef17be9fe2fd7e6d54eb631545929967f671184453a140a16312d01c88", + "retrieved_at": "2026-07-27T10:45:47.988248Z" } ] } diff --git a/internal/sourceprovenance/baseline.go b/internal/sourceprovenance/baseline.go index 1d1403c..831f3fe 100644 --- a/internal/sourceprovenance/baseline.go +++ b/internal/sourceprovenance/baseline.go @@ -27,7 +27,10 @@ const ( requestTimeout = 10 * time.Second ) -var cloudflareEmailPattern = regexp.MustCompile(`data-cfemail="([0-9a-fA-F]+)"`) +var ( + cloudflareEmailPattern = regexp.MustCompile(`data-cfemail="([0-9a-fA-F]+)"`) + cloudflareEmailHrefPattern = regexp.MustCompile(`/cdn-cgi/l/email-protection#([0-9a-fA-F]+)`) +) type Baseline struct { SchemaVersion int `json:"schema_version"` @@ -113,21 +116,40 @@ func fetch( func normalizeBody(body []byte) []byte { normalized := bytes.ReplaceAll(body, []byte("\r\n"), []byte("\n")) - return cloudflareEmailPattern.ReplaceAllFunc(normalized, func(attribute []byte) []byte { + normalized = cloudflareEmailPattern.ReplaceAllFunc(normalized, func(attribute []byte) []byte { matches := cloudflareEmailPattern.FindSubmatch(attribute) if len(matches) != 2 { return attribute } - encoded, err := hex.DecodeString(string(matches[1])) - if err != nil || len(encoded) < 2 { + decoded, ok := decodeCloudflareEmail(matches[1]) + if !ok { return attribute } - decoded := make([]byte, len(encoded)-1) - for index := 1; index < len(encoded); index++ { - decoded[index-1] = encoded[index] ^ encoded[0] - } return []byte(`data-cfemail="` + hex.EncodeToString(decoded) + `"`) }) + return cloudflareEmailHrefPattern.ReplaceAllFunc(normalized, func(attribute []byte) []byte { + matches := cloudflareEmailHrefPattern.FindSubmatch(attribute) + if len(matches) != 2 { + return attribute + } + decoded, ok := decodeCloudflareEmail(matches[1]) + if !ok { + return attribute + } + return []byte(`/cdn-cgi/l/email-protection#` + hex.EncodeToString(decoded)) + }) +} + +func decodeCloudflareEmail(encoded []byte) ([]byte, bool) { + value, err := hex.DecodeString(string(encoded)) + if err != nil || len(value) < 2 { + return nil, false + } + decoded := make([]byte, len(value)-1) + for index := 1; index < len(value); index++ { + decoded[index-1] = value[index] ^ value[0] + } + return decoded, true } func Generate(ctx context.Context, sources []contracts.PublicSource, now time.Time) (Baseline, error) { diff --git a/internal/sourceprovenance/baseline_test.go b/internal/sourceprovenance/baseline_test.go index f57edd6..d589eb9 100644 --- a/internal/sourceprovenance/baseline_test.go +++ b/internal/sourceprovenance/baseline_test.go @@ -44,7 +44,7 @@ func TestFetchCanonicalizesCloudflareEmailProtection(t *testing.T) { } _, _ = fmt.Fprintf( w, - `protected`, + `protected`, encoded, ) })) diff --git a/test/release/infrastructure_test.go b/test/release/infrastructure_test.go index 8cc96ec..182974a 100644 --- a/test/release/infrastructure_test.go +++ b/test/release/infrastructure_test.go @@ -117,6 +117,42 @@ func TestAutonomousEvaluationMatrixIsEighteenRunsWithHardFailures(t *testing.T) } } +func TestInstallerVerifiesVersionCapabilitiesAndDefaultInstallDir(t *testing.T) { + value := string(readFile(t, "tools/install-local.sh")) + for _, required := range []string{ + `install_dir=${MIDTRANS_INSTALL_DIR:-"$HOME/.local/bin"}`, + `version --json --non-interactive`, + `agent capabilities --json --non-interactive`, + `data["evidence_schema"] == "1.0"`, + `"snap"`, + `"core-api"`, + `"payment-link"`, + `"bisnap"`, + `"gopay-tokenization"`, + `"subscription"`, + } { + if !strings.Contains(value, required) { + t.Errorf("installer missing %q", required) + } + } +} + +func TestInstallerTestCoversRollbackAndCapabilityFailure(t *testing.T) { + value := string(readFile(t, "tools/test-install-local.sh")) + for _, required := range []string{ + `previous.cksum`, + `MIDTRANS_FAKE_VERSION_EXIT=1`, + `MIDTRANS_FAKE_CAPABILITIES_EXIT=1`, + `command\":\"capabilities`, + `evidence_schema\":\"1.0`, + `id\":\"subscription`, + } { + if !strings.Contains(value, required) { + t.Errorf("installer test missing %q", required) + } + } +} + func TestShellScriptExecutablePolicyIsPlatformAware(t *testing.T) { if shellScriptMustBeExecutable("windows") { t.Fatal("Windows checkouts must not rely on POSIX executable mode bits") diff --git a/tools/check_release.sh b/tools/check_release.sh index 9203ec6..a0700ff 100755 --- a/tools/check_release.sh +++ b/tools/check_release.sh @@ -5,6 +5,7 @@ go test ./... -race -count=1 go vet ./... ./tools/test-install-local.sh go build -trimpath ./cmd/midtrans +go run ./tools/source-drift --baseline contracts/public-sources-v1.json go run github.com/goreleaser/goreleaser/v2@v2.17.0 check git diff --check diff --git a/tools/install-local.sh b/tools/install-local.sh index e824567..e7275f5 100755 --- a/tools/install-local.sh +++ b/tools/install-local.sh @@ -11,6 +11,40 @@ cleanup() { } trap cleanup EXIT INT TERM +verify_version_json() { + python3 -c ' +import json, sys +data = json.load(sys.stdin) +assert data["schema_version"] == "1.0" +assert data["command"] == "version" +assert data["status"] in ("pass", "warn") +assert data["cli_version"] +' +} + +verify_capabilities_json() { + python3 -c ' +import json, sys +data = json.load(sys.stdin) +required_packs = { + "snap", + "core-api", + "payment-link", + "bisnap", + "gopay-tokenization", + "subscription", +} +assert data["schema_version"] == "1.0" +assert data["command"] == "capabilities" +assert data["status"] == "pass" +assert data["manifest_version"] == 1 +assert data["evidence_schema"] == "1.0" +packs = {entry["id"] for entry in data["packs"]} +missing = sorted(required_packs - packs) +assert not missing, missing +' +} + version=${MIDTRANS_DEV_VERSION:-dev} commit=$(git -C "$repo_dir" rev-parse --verify HEAD) build_date=$(date -u +%Y-%m-%dT%H:%M:%SZ) @@ -25,8 +59,10 @@ build_date=$(date -u +%Y-%m-%dT%H:%M:%SZ) -o "$tmp_binary" ./cmd/midtrans ) chmod 0755 "$tmp_binary" -"$tmp_binary" version --json --non-interactive >/dev/null -"$tmp_binary" agent capabilities --json --non-interactive >/dev/null +version_json=$("$tmp_binary" version --json --non-interactive) +printf '%s' "$version_json" | verify_version_json +capabilities_json=$("$tmp_binary" agent capabilities --json --non-interactive) +printf '%s' "$capabilities_json" | verify_capabilities_json target="$install_dir/midtrans" if [ -e "$target" ] || [ -L "$target" ]; then if [ -L "$target" ]; then diff --git a/tools/test-install-local.sh b/tools/test-install-local.sh index b019c2c..2b5a18e 100755 --- a/tools/test-install-local.sh +++ b/tools/test-install-local.sh @@ -23,10 +23,12 @@ mkdir -p "$other_dir" set_previous_binary() { printf '%s\n' 'previous-working-binary' >"$binary" cp "$binary" "$test_root/previous" + cksum <"$binary" >"$test_root/previous.cksum" } assert_previous_binary_is_unchanged() { - cmp "$binary" "$test_root/previous" + cksum <"$binary" >"$test_root/current.cksum" + cmp "$test_root/current.cksum" "$test_root/previous.cksum" } set_previous_binary @@ -59,9 +61,11 @@ printf '%s\n' \ 'test -n "$output"' \ 'printf "%s\\n" "#!/bin/sh" >"$output"' \ 'printf "%s\\n" "if [ \"\${1:-}\" = \"version\" ]; then" >>"$output"' \ + 'printf "%s\\n" "printf '\''%s\\n'\'' '\''{\"schema_version\":\"1.0\",\"command\":\"version\",\"status\":\"pass\",\"cli_version\":\"0.1.0-test\"}'\''" >>"$output"' \ 'printf "%s\\n" "exit \"\${MIDTRANS_FAKE_VERSION_EXIT:-0}\"" >>"$output"' \ 'printf "%s\\n" "fi" >>"$output"' \ 'printf "%s\\n" "if [ \"\${1:-}\" = \"agent\" ] && [ \"\${2:-}\" = \"capabilities\" ]; then" >>"$output"' \ + 'printf "%s\\n" "printf '\''%s\\n'\'' '\''{\"schema_version\":\"1.0\",\"command\":\"capabilities\",\"status\":\"pass\",\"cli_version\":\"0.1.0-test\",\"manifest_version\":1,\"evidence_schema\":\"1.0\",\"packs\":[{\"id\":\"common\",\"version\":\"0.1.0\"},{\"id\":\"snap\",\"version\":\"0.1.0\"},{\"id\":\"core-api\",\"version\":\"0.1.0\"},{\"id\":\"payment-link\",\"version\":\"0.1.0\"},{\"id\":\"bisnap\",\"version\":\"0.1.0\"},{\"id\":\"gopay-tokenization\",\"version\":\"0.1.0\"},{\"id\":\"subscription\",\"version\":\"0.1.0\"}]}'\''" >>"$output"' \ 'printf "%s\\n" "exit \"\${MIDTRANS_FAKE_CAPABILITIES_EXIT:-0}\"" >>"$output"' \ 'printf "%s\\n" "fi" >>"$output"' \ 'printf "%s\\n" "exit 0" >>"$output"' \ From 3c2ed55e8d463807886cd13c7b2d41477b78039f Mon Sep 17 00:00:00 2001 From: "m.salis" Date: Mon, 27 Jul 2026 17:47:59 +0700 Subject: [PATCH 68/73] style: format payment pack sources --- packs/coreapi/client_test.go | 6 +++--- packs/coreapi/notification.go | 2 +- packs/snap/local_verify_test.go | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/packs/coreapi/client_test.go b/packs/coreapi/client_test.go index 8b11b9c..f774b6f 100644 --- a/packs/coreapi/client_test.go +++ b/packs/coreapi/client_test.go @@ -311,9 +311,9 @@ func TestClientRejectsCrossHostRedirectsAndLargeBodies(t *testing.T) { type timeoutError struct{ message string } -func (e timeoutError) Error() string { return e.message } -func (timeoutError) Timeout() bool { return true } -func (timeoutError) Temporary() bool { return false } +func (e timeoutError) Error() string { return e.message } +func (timeoutError) Timeout() bool { return true } +func (timeoutError) Temporary() bool { return false } func coreResponse(statusCode int, body string) *http.Response { return &http.Response{ diff --git a/packs/coreapi/notification.go b/packs/coreapi/notification.go index fb4644d..b455ac0 100644 --- a/packs/coreapi/notification.go +++ b/packs/coreapi/notification.go @@ -2,8 +2,8 @@ package coreapi import ( "bytes" - "crypto/subtle" "crypto/sha512" + "crypto/subtle" "encoding/hex" "encoding/json" "errors" diff --git a/packs/snap/local_verify_test.go b/packs/snap/local_verify_test.go index 1ceee48..decd5ce 100644 --- a/packs/snap/local_verify_test.go +++ b/packs/snap/local_verify_test.go @@ -141,7 +141,7 @@ func TestLocalVerifierAppliesSettlement(t *testing.T) { func TestLocalVerifierRejectsProductionServerKeyBeforeHTTP(t *testing.T) { httpCalls := 0 verifier := snap.MerchantVerifier{ - Manifest: configuredLocalManifest("http://127.0.0.1:1"), + Manifest: configuredLocalManifest("http://127.0.0.1:1"), ServerKey: secrets.NewValue("Mid-server-PRODUCTION-CANARY-DO-NOT-PRINT"), HTTP: &http.Client{Transport: localRoundTripFunc(func(*http.Request) (*http.Response, error) { httpCalls++ From 4bc8680d9f22198bcc9f672d68390d87af5a1691 Mon Sep 17 00:00:00 2001 From: "m.salis" Date: Mon, 27 Jul 2026 17:59:14 +0700 Subject: [PATCH 69/73] fix: compile GoPay journey proof policies --- internal/app/commands_evidence_test.go | 72 ++++++++++++++++++++++++++ internal/app/commands_verify.go | 12 +++++ 2 files changed, 84 insertions(+) diff --git a/internal/app/commands_evidence_test.go b/internal/app/commands_evidence_test.go index 05920c7..4407092 100644 --- a/internal/app/commands_evidence_test.go +++ b/internal/app/commands_evidence_test.go @@ -386,6 +386,78 @@ func TestHybridVerifyFailsClosedWithoutBISNAPEvidenceBundle(t *testing.T) { } } +func TestHybridVerifyReportsMissingProofsForGoPayAccountLinkingAndWalletPayment(t *testing.T) { + project := createJourneyProject(t, "http://127.0.0.1:1") + configureManifest(t, project, func(value *manifest.Manifest) { + value.CredentialSets["bisnap"] = manifest.CredentialSet{ + Type: "bisnap", + Environment: "sandbox", + ClientID: "env:MIDTRANS_BISNAP_CLIENT_ID", + ClientSecret: "env:MIDTRANS_BISNAP_CLIENT_SECRET", + PartnerID: "env:MIDTRANS_BISNAP_PARTNER_ID", + ChannelID: "env:MIDTRANS_BISNAP_CHANNEL_ID", + DeviceID: "env:MIDTRANS_BISNAP_DEVICE_ID", + MerchantID: "env:MIDTRANS_GOPAY_MERCHANT_ID", + PrivateKey: "file:./keys/private.pem", + MidtransPublicKey: "file:./keys/public.pem", + } + value.Integrations["gopay-tokenization"] = manifest.Integration{ + ConfigVersion: 1, + Credentials: "bisnap", + Capabilities: []string{"account-linking", "wallet-payment"}, + Callbacks: map[string]string{ + "account_linking": "/api/payments/midtrans/gopay/account", + "payment": "/api/payments/midtrans/gopay/payment", + "return": "/payments/gopay/return", + }, + } + value.Verification.Required = []string{ + "gopay-tokenization.account-linking", + "gopay-tokenization.wallet-payment", + } + }) + + result, exit := executeJSON( + t, + "verify", + "--project-dir", project, + "--json", "--non-interactive", + ) + if exit != 3 || result.Status != contracts.StatusBlocked || + result.HasCode("VERIFY_PROOF_POLICY_UNAVAILABLE") || + !result.HasCode("VERIFY_EVIDENCE_INCOMPLETE") { + t.Fatalf("exit = %d, result = %#v", exit, result) + } + data, ok := result.Data.(map[string]any) + if !ok { + t.Fatalf("data = %#v", result.Data) + } + journeys := data["journeys"].([]any) + for index, want := range [][]string{ + { + "gopay-tokenization.state-validation", + "gopay-tokenization.binding-inquiry", + "gopay-tokenization.merchant-persistence", + }, + { + "gopay-tokenization.notification", + "gopay-tokenization.provider-status", + "gopay-tokenization.merchant-persistence", + }, + } { + journey := journeys[index].(map[string]any) + missing := journey["missing_evidence"].([]any) + if len(missing) != len(want) { + t.Fatalf("journey %q missing = %#v, want %#v", journey["id"], missing, want) + } + for proofIndex, proofID := range want { + if missing[proofIndex] != proofID { + t.Fatalf("journey %q missing = %#v, want %#v", journey["id"], missing, want) + } + } + } +} + func TestVerifyFailsClosedWhenCompiledJourneyHasNoProofPolicy(t *testing.T) { project := createJourneyProject(t, "http://127.0.0.1:1") configureManifest(t, project, func(value *manifest.Manifest) { diff --git a/internal/app/commands_verify.go b/internal/app/commands_verify.go index 387c2fe..3fc3de0 100644 --- a/internal/app/commands_verify.go +++ b/internal/app/commands_verify.go @@ -328,6 +328,18 @@ func compiledProofPolicy(journeyID string) ([]verify.RequiredProof, bool) { {ID: "gopay-tokenization.recurring.notification", Level: evidence.ProofSandbox}, {ID: "gopay-tokenization.recurring.merchant-persistence", Level: evidence.ProofLocal}, }, true + case "gopay-tokenization.account-linking": + return []verify.RequiredProof{ + {ID: "gopay-tokenization.state-validation", Level: evidence.ProofLocal}, + {ID: "gopay-tokenization.binding-inquiry", Level: evidence.ProofSandbox}, + {ID: "gopay-tokenization.merchant-persistence", Level: evidence.ProofLocal}, + }, true + case "gopay-tokenization.wallet-payment": + return []verify.RequiredProof{ + {ID: "gopay-tokenization.notification", Level: evidence.ProofSandbox}, + {ID: "gopay-tokenization.provider-status", Level: evidence.ProofSandbox}, + {ID: "gopay-tokenization.merchant-persistence", Level: evidence.ProofLocal}, + }, true default: return nil, false } From 46a7c2538f0eb97b619d95c9971779d9464c3a13 Mon Sep 17 00:00:00 2001 From: "m.salis" Date: Mon, 27 Jul 2026 18:10:02 +0700 Subject: [PATCH 70/73] fix: distinguish provider confirmation from proof --- internal/app/app_test.go | 4 +- internal/app/commands_test.go | 2 +- internal/app/journey_runner.go | 14 +++++ internal/app/journey_runner_test.go | 87 +++++++++++++++++++++++++++++ 4 files changed, 104 insertions(+), 3 deletions(-) create mode 100644 internal/app/journey_runner_test.go diff --git a/internal/app/app_test.go b/internal/app/app_test.go index 4a3a2d7..510cdea 100644 --- a/internal/app/app_test.go +++ b/internal/app/app_test.go @@ -275,7 +275,7 @@ func TestAgentResumeRehydratesPaymentLinkOperationFromRecordedSafeReferences(t * } data := requireJourneyData(t, resumed) if data["journey"] != "payment-link.reusable" || - data["state"] != "verified" || + data["state"] != "provider_confirmed" || data["operation_id"] != "op_payment_link_resume" || data["transaction_id"] != "trx-payment-link-resume" || data["usage_limit"] != "3" { @@ -2622,7 +2622,7 @@ func TestMerchantCoreAPIAmbiguousChargeReconcilesByStatusWithoutSecondMutation(t "--execute", "--project-dir", project, ) - if exit != 0 || result.Status != contracts.StatusPass { + if exit != 0 || result.Status != contracts.StatusWarn { t.Fatalf("exit = %d, result = %#v", exit, result) } if statusCalls != 2 || chargeCalls != 1 { diff --git a/internal/app/commands_test.go b/internal/app/commands_test.go index 4c4928f..d99beac 100644 --- a/internal/app/commands_test.go +++ b/internal/app/commands_test.go @@ -753,7 +753,7 @@ func TestAgentResumePreservesSafeInputAcrossAwaitingAction(t *testing.T) { t.Fatalf("resume exit = %d, result = %#v", exit, resumed) } data := requireJourneyData(t, resumed) - if data["state"] != "verified" || data["order_id"] != "snap-fixture-001" { + if data["state"] != "provider_confirmed" || data["order_id"] != "snap-fixture-001" { t.Fatalf("resume data = %#v", data) } } diff --git a/internal/app/journey_runner.go b/internal/app/journey_runner.go index 70c5357..c8789fe 100644 --- a/internal/app/journey_runner.go +++ b/internal/app/journey_runner.go @@ -213,6 +213,9 @@ func genericJourneyResult( switch outcome.State { case journeypkg.Passed: status = contracts.StatusPass + if len(outcome.Proofs) == 0 { + status = contracts.StatusWarn + } case journeypkg.Failed: status = contracts.StatusFail case journeypkg.Blocked: @@ -242,6 +245,17 @@ func genericJourneyResult( if outcome.Action != nil { data["action"] = outcome.Action } + if outcome.State == journeypkg.Passed && len(outcome.Proofs) == 0 { + data["state"] = "provider_confirmed" + result.NextActions = []contracts.NextAction{{ + Action: "collect_evidence_and_verify", + Description: fmt.Sprintf("collect the required evidence, then run midtrans verify --product %s", definition.Product), + Arguments: map[string]any{ + "product": definition.Product, + "verify_command": fmt.Sprintf("midtrans verify --product %s", definition.Product), + }, + }} + } if len(outcome.MissingEvidence) != 0 && outcome.State != journeypkg.Passed { result.NextActions = []contracts.NextAction{{ Action: "provide_evidence_bundle", diff --git a/internal/app/journey_runner_test.go b/internal/app/journey_runner_test.go new file mode 100644 index 0000000..c785ea8 --- /dev/null +++ b/internal/app/journey_runner_test.go @@ -0,0 +1,87 @@ +package app + +import ( + "encoding/json" + "strings" + "testing" + "time" + + "github.com/veritrans/midtrans-cli/internal/contracts" + "github.com/veritrans/midtrans-cli/internal/evidence" + journeypkg "github.com/veritrans/midtrans-cli/internal/journey" +) + +func TestGenericJourneyResultMarksPassedOutcomeWithoutProofsAsProviderConfirmed(t *testing.T) { + const secret = "SB-Mid-server-PROVIDER-CONFIRMED-CANARY" + result := genericJourneyResult( + "agent.run", + Dependencies{}, + 1, + journeypkg.Definition{ID: "payment-link.verify", Product: "payment-link"}, + journeypkg.Outcome{ + OperationID: "op_payment_link_verify", + State: journeypkg.Passed, + SafeData: map[string]any{"server_key": secret}, + }, + ) + + if result.Status != contracts.StatusWarn { + t.Fatalf("status = %q, want %q", result.Status, contracts.StatusWarn) + } + data, ok := result.Data.(map[string]any) + if !ok || data["state"] != "provider_confirmed" { + t.Fatalf("data = %#v", result.Data) + } + if len(result.NextActions) != 1 || + result.NextActions[0].Action != "collect_evidence_and_verify" || + result.NextActions[0].Arguments["product"] != "payment-link" || + result.NextActions[0].Arguments["verify_command"] != "midtrans verify --product payment-link" { + t.Fatalf("next actions = %#v", result.NextActions) + } + + safe, err := evidence.SanitizeResult(result, nil) + if err != nil { + t.Fatal(err) + } + encoded, err := json.Marshal(safe) + if err != nil { + t.Fatal(err) + } + if strings.Contains(string(encoded), secret) { + t.Fatalf("provider confirmation leaked a secret: %s", encoded) + } +} + +func TestGenericJourneyResultKeepsPassedOutcomeWithProofsVerified(t *testing.T) { + result := genericJourneyResult( + "agent.run", + Dependencies{}, + 1, + journeypkg.Definition{ID: "bisnap.status", Product: "bisnap"}, + journeypkg.Outcome{ + OperationID: "op_bisnap_status", + State: journeypkg.Passed, + Proofs: []evidence.Proof{{ + ID: "bisnap.notification", + OperationID: "op_bisnap_status", + Stage: "provider_notification", + Level: evidence.ProofSandbox, + Source: "merchant callback", + ObservedAt: time.Date(2026, time.July, 27, 0, 0, 0, 0, time.UTC), + Status: "pass", + Summary: map[string]any{"status": "settlement"}, + }}, + }, + ) + + if result.Status != contracts.StatusPass { + t.Fatalf("status = %q, want %q", result.Status, contracts.StatusPass) + } + data, ok := result.Data.(map[string]any) + if !ok || data["state"] != "verified" { + t.Fatalf("data = %#v", result.Data) + } + if len(result.NextActions) != 0 { + t.Fatalf("next actions = %#v", result.NextActions) + } +} From 7df1c033a55c20586eadfaf950fbd0d7c2f5e49e Mon Sep 17 00:00:00 2001 From: "m.salis" Date: Mon, 27 Jul 2026 18:10:15 +0700 Subject: [PATCH 71/73] fix: complete compiled verification policies --- internal/app/commands_evidence_test.go | 117 ++++++++++++++++---- internal/app/commands_verify.go | 80 ++++++++++++- internal/app/commands_verify_policy_test.go | 63 +++++++++++ 3 files changed, 237 insertions(+), 23 deletions(-) create mode 100644 internal/app/commands_verify_policy_test.go diff --git a/internal/app/commands_evidence_test.go b/internal/app/commands_evidence_test.go index 4407092..b268c88 100644 --- a/internal/app/commands_evidence_test.go +++ b/internal/app/commands_evidence_test.go @@ -297,6 +297,97 @@ func TestHybridVerifyBlocksWhenRequiredJourneyIsBlocked(t *testing.T) { } } +func TestVerifyProductSelectionFiltersHybridJourneysAndDefaultsToAll(t *testing.T) { + project := createJourneyProject(t, "http://127.0.0.1:1") + configureManifest(t, project, func(value *manifest.Manifest) { + value.CredentialSets["bisnap"] = manifest.CredentialSet{ + Type: "bisnap", + Environment: "sandbox", + ClientID: "env:MIDTRANS_BISNAP_CLIENT_ID", + ClientSecret: "env:MIDTRANS_BISNAP_CLIENT_SECRET", + PartnerID: "env:MIDTRANS_BISNAP_PARTNER_ID", + ChannelID: "env:MIDTRANS_BISNAP_CHANNEL_ID", + DeviceID: "env:MIDTRANS_BISNAP_DEVICE_ID", + PrivateKey: "file:./keys/private.pem", + MidtransPublicKey: "file:./keys/public.pem", + } + value.Integrations["bisnap"] = manifest.Integration{ + ConfigVersion: 1, + Credentials: "bisnap", + Callbacks: map[string]string{ + "notification": "/api/payments/bisnap/notification", + }, + } + value.Verification.Required = []string{"snap.checkout", "bisnap.status"} + }) + + tests := []struct { + name string + args []string + wantJourneys []string + wantProducts []string + wantPackIDs []string + }{ + { + name: "no product aggregates every required journey", + wantJourneys: []string{"snap.checkout", "bisnap.status"}, + wantProducts: []string{"bisnap", "snap"}, + wantPackIDs: []string{"bisnap", "snap"}, + }, + { + name: "explicit snap filters to snap", + args: []string{"--product", "snap"}, + wantJourneys: []string{"snap.checkout"}, + wantProducts: []string{"snap"}, + wantPackIDs: []string{"snap"}, + }, + { + name: "explicit bisnap remains filtered to bisnap", + args: []string{"--product", "bisnap"}, + wantJourneys: []string{"bisnap.status"}, + wantProducts: []string{"bisnap"}, + wantPackIDs: []string{"bisnap"}, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + args := append([]string{"verify"}, tt.args...) + args = append(args, "--project-dir", project, "--json", "--non-interactive") + result, exit := executeJSON(t, args...) + if exit != 3 || result.Status != contracts.StatusBlocked { + t.Fatalf("exit = %d, result = %#v", exit, result) + } + data, ok := result.Data.(map[string]any) + if !ok { + t.Fatalf("data = %#v", result.Data) + } + journeys := data["journeys"].([]any) + gotJourneys := make([]string, 0, len(journeys)) + for _, journey := range journeys { + gotJourneys = append(gotJourneys, journey.(map[string]any)["id"].(string)) + } + if strings.Join(gotJourneys, ",") != strings.Join(tt.wantJourneys, ",") { + t.Fatalf("journeys = %#v, want %#v", gotJourneys, tt.wantJourneys) + } + products := data["products"].([]any) + gotProducts := make([]string, 0, len(products)) + for _, product := range products { + gotProducts = append(gotProducts, product.(map[string]any)["id"].(string)) + } + if strings.Join(gotProducts, ",") != strings.Join(tt.wantProducts, ",") { + t.Fatalf("products = %#v, want %#v", gotProducts, tt.wantProducts) + } + gotPackIDs := make([]string, 0, len(result.Packs)) + for _, pack := range result.Packs { + gotPackIDs = append(gotPackIDs, pack.ID) + } + if strings.Join(gotPackIDs, ",") != strings.Join(tt.wantPackIDs, ",") { + t.Fatalf("packs = %#v, want %#v", gotPackIDs, tt.wantPackIDs) + } + }) + } +} + func TestHybridVerifyDoesNotPromoteLocalProofToSandboxRequirement(t *testing.T) { project := createJourneyProject(t, "http://127.0.0.1:1") path := writeHybridEvidence(t, project, true) @@ -461,14 +552,7 @@ func TestHybridVerifyReportsMissingProofsForGoPayAccountLinkingAndWalletPayment( func TestVerifyFailsClosedWhenCompiledJourneyHasNoProofPolicy(t *testing.T) { project := createJourneyProject(t, "http://127.0.0.1:1") configureManifest(t, project, func(value *manifest.Manifest) { - value.Integrations["payment-link"] = manifest.Integration{ - ConfigVersion: 1, - Credentials: "classic", - Callbacks: map[string]string{ - "notification": "/api/payments/payment-link/notification", - }, - } - value.Verification.Required = []string{"payment-link.create"} + value.Verification.Required = []string{"snap.unknown-proof-policy"} }) result, exit := executeJSON( @@ -499,23 +583,16 @@ func TestVerifyFailsClosedWhenCompiledJourneyHasNoProofPolicy(t *testing.T) { func TestVerifyBlocksUnknownPolicyJourneyEvenWithMatchingSyntheticEvidenceBundle(t *testing.T) { project := createJourneyProject(t, "http://127.0.0.1:1") configureManifest(t, project, func(value *manifest.Manifest) { - value.Integrations["payment-link"] = manifest.Integration{ - ConfigVersion: 1, - Credentials: "classic", - Callbacks: map[string]string{ - "notification": "/api/payments/payment-link/notification", - }, - } - value.Verification.Required = []string{"payment-link.create"} + value.Verification.Required = []string{"snap.unknown-proof-policy"} }) bundle := completeBundleForProject(t, project) - bundle.PackID = "payment-link" - bundle.Journey = "payment-link.create" - bundle.OperationID = "op_payment_link_create" + bundle.PackID = "snap" + bundle.Journey = "snap.unknown-proof-policy" + bundle.OperationID = "op_unknown_proof_policy" bundle.Proofs = []evidence.Proof{{ ID: "compiled_policy", - OperationID: "op_payment_link_create", + OperationID: "op_unknown_proof_policy", Stage: "synthetic", Level: evidence.ProofLocal, Source: "merchant_application", diff --git a/internal/app/commands_verify.go b/internal/app/commands_verify.go index 3fc3de0..b23a82d 100644 --- a/internal/app/commands_verify.go +++ b/internal/app/commands_verify.go @@ -27,7 +27,7 @@ func newVerifyCommand(flags *globalFlags, deps Dependencies) *cobra.Command { return writeResult(deps, flags, result) } requiredJourneys := requiredJourneysForVerification(value) - if product != "" && product != "snap" && !manifestRequiresProduct(requiredJourneys, product) { + if product != "" && !manifestRequiresProduct(requiredJourneys, product) { result := contracts.NewIncompatibleResult( "verify", "CAPABILITY_NOT_INSTALLED", @@ -36,6 +36,7 @@ func newVerifyCommand(flags *globalFlags, deps Dependencies) *cobra.Command { result.CLIVersion = deps.Version.Version return writeResult(deps, flags, result) } + requiredJourneys = filterJourneysForProduct(requiredJourneys, deps, product) findings := []contracts.Finding{} report, err := inspection.Inspect(flags.projectDir) @@ -103,7 +104,7 @@ func newVerifyCommand(flags *globalFlags, deps Dependencies) *cobra.Command { return writeResult(deps, flags, result) }, } - command.Flags().StringVar(&product, "product", "snap", "product pack to verify") + command.Flags().StringVar(&product, "product", "", "product pack to verify") command.Flags().StringVar( &evidenceFile, "evidence", @@ -274,7 +275,7 @@ func uniqueJourneyProducts(requiredJourneys []string, deps Dependencies, selecte if packID == "" { continue } - if selectedProduct != "" && selectedProduct != "snap" && packID != selectedProduct { + if selectedProduct != "" && packID != selectedProduct { continue } if seen[packID] { @@ -287,6 +288,19 @@ func uniqueJourneyProducts(requiredJourneys []string, deps Dependencies, selecte return products } +func filterJourneysForProduct(requiredJourneys []string, deps Dependencies, selectedProduct string) []string { + if selectedProduct == "" { + return append([]string(nil), requiredJourneys...) + } + filtered := make([]string, 0, len(requiredJourneys)) + for _, journeyID := range requiredJourneys { + if journeyProduct(journeyID, deps) == selectedProduct { + filtered = append(filtered, journeyID) + } + } + return filtered +} + func journeyProduct(journeyID string, deps Dependencies) string { handler, ok := deps.Packs.Handler(journeyID) if ok { @@ -303,11 +317,38 @@ func compiledProofPolicy(journeyID string) ([]verify.RequiredProof, bool) { {ID: "snap.provider-status", Level: evidence.ProofSandbox}, {ID: "snap.merchant-callback", Level: evidence.ProofLocal}, }, true + case "snap.mobile-webview": + return []verify.RequiredProof{ + {ID: "snap.device-interaction", Level: evidence.ProofLocal}, + {ID: "snap.provider-status", Level: evidence.ProofSandbox}, + {ID: "snap.merchant-callback", Level: evidence.ProofLocal}, + }, true + case "common.webhook-idempotency": + return []verify.RequiredProof{ + {ID: "common.notification", Level: evidence.ProofSandbox}, + {ID: "common.webhook-idempotency", Level: evidence.ProofLocal}, + }, true + case "common.status-reconciliation": + return []verify.RequiredProof{ + {ID: "common.provider-status", Level: evidence.ProofSandbox}, + {ID: "common.merchant-persistence", Level: evidence.ProofLocal}, + }, true case "bisnap.qris-payment", "bisnap.virtual-account", "bisnap.direct-debit", "bisnap.status", "bisnap.refund": return []verify.RequiredProof{ {ID: "bisnap.notification", Level: evidence.ProofSandbox}, {ID: "bisnap.merchant-persistence", Level: evidence.ProofLocal}, }, true + case "core-api.card-3ds", "core-api.saved-card", "core-api.installment", "core-api.otc", "core-api.virtual-account": + return []verify.RequiredProof{ + {ID: "core-api.notification", Level: evidence.ProofSandbox}, + {ID: "core-api.provider-status", Level: evidence.ProofSandbox}, + {ID: "core-api.merchant-persistence", Level: evidence.ProofLocal}, + }, true + case "core-api.refund": + return []verify.RequiredProof{ + {ID: "core-api.provider-status", Level: evidence.ProofSandbox}, + {ID: "core-api.merchant-persistence", Level: evidence.ProofLocal}, + }, true case "core-api.recurring": return []verify.RequiredProof{ {ID: "core-api.recurring.charge-attempt", Level: evidence.ProofLocal}, @@ -334,12 +375,45 @@ func compiledProofPolicy(journeyID string) ([]verify.RequiredProof, bool) { {ID: "gopay-tokenization.binding-inquiry", Level: evidence.ProofSandbox}, {ID: "gopay-tokenization.merchant-persistence", Level: evidence.ProofLocal}, }, true + case "gopay-tokenization.binding-inquiry": + return []verify.RequiredProof{ + {ID: "gopay-tokenization.binding-inquiry", Level: evidence.ProofSandbox}, + {ID: "gopay-tokenization.merchant-persistence", Level: evidence.ProofLocal}, + }, true case "gopay-tokenization.wallet-payment": return []verify.RequiredProof{ {ID: "gopay-tokenization.notification", Level: evidence.ProofSandbox}, {ID: "gopay-tokenization.provider-status", Level: evidence.ProofSandbox}, {ID: "gopay-tokenization.merchant-persistence", Level: evidence.ProofLocal}, }, true + case "gopay-tokenization.paylater": + return []verify.RequiredProof{ + {ID: "gopay-tokenization.notification", Level: evidence.ProofSandbox}, + {ID: "gopay-tokenization.provider-status", Level: evidence.ProofSandbox}, + {ID: "gopay-tokenization.merchant-persistence", Level: evidence.ProofLocal}, + }, true + case "gopay-tokenization.unlink": + return []verify.RequiredProof{ + {ID: "gopay-tokenization.notification", Level: evidence.ProofSandbox}, + {ID: "gopay-tokenization.binding-inquiry", Level: evidence.ProofSandbox}, + {ID: "gopay-tokenization.merchant-persistence", Level: evidence.ProofLocal}, + }, true + case "payment-link.create", "payment-link.reusable": + return []verify.RequiredProof{ + {ID: "payment-link.notification", Level: evidence.ProofSandbox}, + {ID: "payment-link.provider-status", Level: evidence.ProofSandbox}, + {ID: "payment-link.merchant-persistence", Level: evidence.ProofLocal}, + }, true + case "payment-link.verify": + return []verify.RequiredProof{ + {ID: "payment-link.provider-status", Level: evidence.ProofSandbox}, + {ID: "payment-link.merchant-persistence", Level: evidence.ProofLocal}, + }, true + case "subscription.create", "subscription.verify", "subscription.disable", "subscription.enable", "subscription.cancel": + return []verify.RequiredProof{ + {ID: "subscription.provider-status", Level: evidence.ProofSandbox}, + {ID: "subscription.merchant-persistence", Level: evidence.ProofLocal}, + }, true default: return nil, false } diff --git a/internal/app/commands_verify_policy_test.go b/internal/app/commands_verify_policy_test.go new file mode 100644 index 0000000..e29a494 --- /dev/null +++ b/internal/app/commands_verify_policy_test.go @@ -0,0 +1,63 @@ +package app + +import ( + "encoding/json" + "os" + "path/filepath" + "slices" + "testing" + + "github.com/veritrans/midtrans-cli/internal/packs" + "github.com/veritrans/midtrans-cli/packs/bisnap" + "github.com/veritrans/midtrans-cli/packs/common" + "github.com/veritrans/midtrans-cli/packs/coreapi" + "github.com/veritrans/midtrans-cli/packs/gopaytokenization" + "github.com/veritrans/midtrans-cli/packs/paymentlink" + "github.com/veritrans/midtrans-cli/packs/snap" + "github.com/veritrans/midtrans-cli/packs/subscription" +) + +func TestCompiledProofPolicyCoversAdvertisedAndRegisteredJourneys(t *testing.T) { + registry, err := packs.NewRegistry( + common.New(), snap.New(), coreapi.New(), paymentlink.New(), bisnap.New(), + gopaytokenization.New(), subscription.New(), + ) + if err != nil { + t.Fatal(err) + } + + required := make(map[string]struct{}) + for _, journeyID := range registry.Journeys() { + required[journeyID] = struct{}{} + } + + raw, err := os.ReadFile(filepath.Join("..", "..", "contracts", "capabilities-v1.json")) + if err != nil { + t.Fatal(err) + } + var contract struct { + Packs []struct { + Journeys []string `json:"journeys"` + } `json:"packs"` + } + if err := json.Unmarshal(raw, &contract); err != nil { + t.Fatal(err) + } + for _, pack := range contract.Packs { + for _, journeyID := range pack.Journeys { + required[journeyID] = struct{}{} + } + } + + missing := make([]string, 0) + for journeyID := range required { + policy, known := compiledProofPolicy(journeyID) + if !known || len(policy) == 0 { + missing = append(missing, journeyID) + } + } + slices.Sort(missing) + if len(missing) != 0 { + t.Fatalf("advertised or registered journeys without compiled proof policies: %v", missing) + } +} From cbda770896da5789797ca539484cd35e9da116ca Mon Sep 17 00:00:00 2001 From: "m.salis" Date: Mon, 27 Jul 2026 18:13:07 +0700 Subject: [PATCH 72/73] fix: hash canonical Midtrans Markdown sources --- contracts/public-sources-v1.json | 140 ++++++++++----------- internal/sourceprovenance/baseline.go | 23 +++- internal/sourceprovenance/baseline_test.go | 37 ++++++ 3 files changed, 128 insertions(+), 72 deletions(-) diff --git a/contracts/public-sources-v1.json b/contracts/public-sources-v1.json index d3e912d..c2de736 100644 --- a/contracts/public-sources-v1.json +++ b/contracts/public-sources-v1.json @@ -8,8 +8,8 @@ "snap.token.create", "snap.basic-auth" ], - "sha256": "0c7b61f8d446f048f84209f2f87c49be6ca76712ce9b4875aa07f215b8de2a8a", - "retrieved_at": "2026-07-27T10:45:47.988248Z" + "sha256": "c5d58aca0ee1b9b6fb6b0bd2d4b368aa177c88df91f7537a7d2fb782decf6473", + "retrieved_at": "2026-07-27T11:10:46.256814Z" }, { "id": "snap-js", @@ -18,8 +18,8 @@ "snap.checkout.popup", "snap.checkout.embed" ], - "sha256": "4bab74d43770fb66da10452bd1730213b22a7a3dc1064add26a419deacd7550b", - "retrieved_at": "2026-07-27T10:45:47.988248Z" + "sha256": "089d494331e7113f2043a90e3c28792bed0a6c6bae65787e9b79cda9c63daba1", + "retrieved_at": "2026-07-27T11:10:46.256814Z" }, { "id": "snap-integration", @@ -28,8 +28,8 @@ "snap.checkout.redirect", "snap.mobile.webview" ], - "sha256": "206d8f1e8a83dceec6f997a27cb9fa002c4bdfbfd41e090e585464ac1ea0c5ff", - "retrieved_at": "2026-07-27T10:45:47.988248Z" + "sha256": "12e99dc8b5ee2e4491bf48fd783500c26da6fc1cbd91cfaf7d64ed87df04d343", + "retrieved_at": "2026-07-27T11:10:46.256814Z" }, { "id": "technical-faq", @@ -38,8 +38,8 @@ "snap.mobile.deeplink-return", "snap.mobile.real-device-proof" ], - "sha256": "be0c04652e03289a09531bbdfc6de6750378489511ff28bb657d903fc52c6adb", - "retrieved_at": "2026-07-27T10:45:47.988248Z" + "sha256": "579945b60452326f51b502ba6e5882f678102423a6d568d3860040b3afe517d3", + "retrieved_at": "2026-07-27T11:10:46.256814Z" }, { "id": "http-notifications", @@ -48,8 +48,8 @@ "snap.notification.signature", "common.webhook-idempotency" ], - "sha256": "5f354ee579876e219f0e7365543944bac6e49d61f72bcedb9bd1c55cbbbd0fbd", - "retrieved_at": "2026-07-27T10:45:47.988248Z" + "sha256": "022717b91d40f175d93379e0cce8263d96ac5a73a7d06c8bbdb4c0569f717109", + "retrieved_at": "2026-07-27T11:10:46.256814Z" }, { "id": "get-transaction-status", @@ -58,8 +58,8 @@ "snap.status.reconcile", "snap.mobile.status.reconcile" ], - "sha256": "cd82d0b4d10ceff81abfd26feed39bcc9261849a389229fec9db73c426319890", - "retrieved_at": "2026-07-27T10:45:47.988248Z" + "sha256": "b496a7d6159813bb49b10e50b81b11b0a6ed958d3d25dac2577137eeb334ac90", + "retrieved_at": "2026-07-27T11:10:46.256814Z" }, { "id": "coreapi-card-charge", @@ -68,8 +68,8 @@ "coreapi.card.charge", "coreapi.basic-auth" ], - "sha256": "1bf27d32309c2442b87c565cd1911e8b04969d6a19c014211fbb49caae6cd3e8", - "retrieved_at": "2026-07-27T10:45:47.988248Z" + "sha256": "935cf123bf52dfded17eb7a03cc79ee247146ac53f8272ef3ac88ff62ba16844", + "retrieved_at": "2026-07-27T11:10:46.256814Z" }, { "id": "coreapi-card-3ds", @@ -78,8 +78,8 @@ "coreapi.card.3ds", "coreapi.card.redirect" ], - "sha256": "3292a8890118db7b50fd99762a31c7a63ffee14d7c396c9fda7c4cdbac890182", - "retrieved_at": "2026-07-27T10:45:47.988248Z" + "sha256": "051fd5f05ae5134e23ef72fe3694e33c9196323ea23188b3d3d2254c58b09613", + "retrieved_at": "2026-07-27T11:10:46.256814Z" }, { "id": "coreapi-one-click", @@ -88,8 +88,8 @@ "coreapi.saved-card.token-only", "coreapi.recurring.saved-card-token" ], - "sha256": "e03d7b9dc47a3f65e119823ed27478d92e9127ac69b7261fba5c2690ecf8d404", - "retrieved_at": "2026-07-27T10:45:47.988248Z" + "sha256": "f3cb7e8c7558fecd17374a8c43423e512f009730b0b78309b0614a827e509726", + "retrieved_at": "2026-07-27T11:10:46.256814Z" }, { "id": "coreapi-alfamart", @@ -98,8 +98,8 @@ "coreapi.otc.charge", "coreapi.otc.payment-code" ], - "sha256": "85a259db9d987f5d391e744bfdb35120e11c76d80f1020d0c5150195a2d6b618", - "retrieved_at": "2026-07-27T10:45:47.988248Z" + "sha256": "70075ffe3407d84c22ec444d49eaeb42cac6219f74631694bed38a6909fffe70", + "retrieved_at": "2026-07-27T11:10:46.256814Z" }, { "id": "coreapi-bni-va", @@ -108,8 +108,8 @@ "coreapi.va.charge", "coreapi.va.instructions" ], - "sha256": "b3db203c3f220f03c0f1175b1e8aa36953434ddce06cc8804872b447b6601626", - "retrieved_at": "2026-07-27T10:45:47.988248Z" + "sha256": "4bab30513c757f244b485493a0a26765e73ade05b0b0fc6c87f22b167a57ce8f", + "retrieved_at": "2026-07-27T11:10:46.256814Z" }, { "id": "coreapi-status", @@ -119,8 +119,8 @@ "coreapi.recurring.status", "coreapi.refund.status" ], - "sha256": "cd82d0b4d10ceff81abfd26feed39bcc9261849a389229fec9db73c426319890", - "retrieved_at": "2026-07-27T10:45:47.988248Z" + "sha256": "b496a7d6159813bb49b10e50b81b11b0a6ed958d3d25dac2577137eeb334ac90", + "retrieved_at": "2026-07-27T11:10:46.256814Z" }, { "id": "coreapi-refund", @@ -129,8 +129,8 @@ "coreapi.refund.async", "coreapi.refund.idempotency" ], - "sha256": "facf272d33e2e407699992c3b8271ed7755f02e703275f16c3675640368011b0", - "retrieved_at": "2026-07-27T10:45:47.988248Z" + "sha256": "29f519e7f22579147fb4e1c42e4a2f38eb0f10810ab96b18f10b1dc577e82430", + "retrieved_at": "2026-07-27T11:10:46.256814Z" }, { "id": "coreapi-direct-refund", @@ -138,8 +138,8 @@ "rules": [ "coreapi.refund.direct" ], - "sha256": "e578d1033469eda45ff1a44a9e47cef07fc6fec0c9fb00c822789a7c0299b7db", - "retrieved_at": "2026-07-27T10:45:47.988248Z" + "sha256": "1e970e5415dd54f5918e9530e9295e44e9824ae18b33fc75050887a09589f668", + "retrieved_at": "2026-07-27T11:10:46.256814Z" }, { "id": "coreapi-notifications", @@ -149,8 +149,8 @@ "coreapi.recurring.notification", "common.webhook-idempotency" ], - "sha256": "5f354ee579876e219f0e7365543944bac6e49d61f72bcedb9bd1c55cbbbd0fbd", - "retrieved_at": "2026-07-27T10:45:47.988248Z" + "sha256": "022717b91d40f175d93379e0cce8263d96ac5a73a7d06c8bbdb4c0569f717109", + "retrieved_at": "2026-07-27T11:10:46.256814Z" }, { "id": "payment-link-overview", @@ -159,8 +159,8 @@ "paymentlink.create", "paymentlink.reusable" ], - "sha256": "f4522e173ecb69f70f692c3249d8dc3ae15bd6fd08ab73e4154fac1225fb09d5", - "retrieved_at": "2026-07-27T10:45:47.988248Z" + "sha256": "7a07e40caa47caab30f0b00c1a3700c115e5e17cd5ca41baf5d9db955adc7b06", + "retrieved_at": "2026-07-27T11:10:46.256814Z" }, { "id": "payment-link-status", @@ -168,8 +168,8 @@ "rules": [ "paymentlink.status.reconcile" ], - "sha256": "cd82d0b4d10ceff81abfd26feed39bcc9261849a389229fec9db73c426319890", - "retrieved_at": "2026-07-27T10:45:47.988248Z" + "sha256": "b496a7d6159813bb49b10e50b81b11b0a6ed958d3d25dac2577137eeb334ac90", + "retrieved_at": "2026-07-27T11:10:46.256814Z" }, { "id": "payment-link-notifications", @@ -178,8 +178,8 @@ "paymentlink.notification.signature", "common.webhook-idempotency" ], - "sha256": "5f354ee579876e219f0e7365543944bac6e49d61f72bcedb9bd1c55cbbbd0fbd", - "retrieved_at": "2026-07-27T10:45:47.988248Z" + "sha256": "022717b91d40f175d93379e0cce8263d96ac5a73a7d06c8bbdb4c0569f717109", + "retrieved_at": "2026-07-27T11:10:46.256814Z" }, { "id": "bisnap-overview", @@ -188,8 +188,8 @@ "bisnap.signing.verify.v1", "bisnap.recurring.transaction-signature" ], - "sha256": "ef5d47829c92c362121b12d5cafb9848e5daafdad727153b04fd95b69730dbc5", - "retrieved_at": "2026-07-27T10:45:47.988248Z" + "sha256": "b840b608774632176650b6ca9224a7a2f9a16b6e027a78e3efba0761649614ad", + "retrieved_at": "2026-07-27T11:10:46.256814Z" }, { "id": "bisnap-qris", @@ -198,8 +198,8 @@ "bisnap.qris.create", "bisnap.qris.status" ], - "sha256": "570d194e36da969acb6d6ea6e3e3b90c51b12f51de8bb50c843c0e864791f59c", - "retrieved_at": "2026-07-27T10:45:47.988248Z" + "sha256": "f4d5a03376b94f6417e79e3c1e1aa10868bf5fbcb0d4343bc1afe86bfe128d62", + "retrieved_at": "2026-07-27T11:10:46.256814Z" }, { "id": "bisnap-virtual-account", @@ -208,8 +208,8 @@ "bisnap.virtual-account.create", "bisnap.virtual-account.status" ], - "sha256": "fab9d59067f2968a85153078a017a075361d5f51f3cd693c951e1a5dba79a3e1", - "retrieved_at": "2026-07-27T10:45:47.988248Z" + "sha256": "469dd40058f834308aea8c32c8348b0096e27e686e23a2bf60df3e6659cd1ea1", + "retrieved_at": "2026-07-27T11:10:46.256814Z" }, { "id": "bisnap-direct-debit", @@ -220,8 +220,8 @@ "bisnap.recurring.status", "bisnap.refund" ], - "sha256": "927e3278b2ad97366f9930f271b7f2193879139c776c987ce1d066ff70c5d422", - "retrieved_at": "2026-07-27T10:45:47.988248Z" + "sha256": "646ec37f804a0f200af130c0e4de1a740b5331d8dbc48046515e06f1e867d8b5", + "retrieved_at": "2026-07-27T11:10:46.256814Z" }, { "id": "bisnap-notifications", @@ -231,8 +231,8 @@ "bisnap.recurring.notification", "common.webhook-idempotency" ], - "sha256": "c05d18b4ce5e67d7259580a10050f4ec780d23d7056e21a41e5807c713a3248c", - "retrieved_at": "2026-07-27T10:45:47.988248Z" + "sha256": "236eab14e2a1cb98723fffe82420d65f63980908c86745260bef7bc640ccf6db", + "retrieved_at": "2026-07-27T11:10:46.256814Z" }, { "id": "gopay-tokenization-get-auth-code", @@ -240,8 +240,8 @@ "rules": [ "gopaytokenization.linking.get-auth-code" ], - "sha256": "fad307376fbab42cadfa87108de50fb4b65ca580e9af02bad96a1436b8e1567b", - "retrieved_at": "2026-07-27T10:45:47.988248Z" + "sha256": "e2f94274ee436a15e3431ab3f7430c5b9fe37416870a87d3f2d82949491250d6", + "retrieved_at": "2026-07-27T11:10:46.256814Z" }, { "id": "gopay-tokenization-binding-api", @@ -249,8 +249,8 @@ "rules": [ "gopaytokenization.linking.bind" ], - "sha256": "30ac068d2e896dfe6f0765b3007d93769f6b312e6ad42b464ef90559161bac26", - "retrieved_at": "2026-07-27T10:45:47.988248Z" + "sha256": "d269ea2fe9ab010a0b3f50076d5231c315a8580531a4e24e7f718d3836feb249", + "retrieved_at": "2026-07-27T11:10:46.256814Z" }, { "id": "gopay-tokenization-binding-inquiry-api", @@ -259,8 +259,8 @@ "gopaytokenization.linking.inquiry", "gopaytokenization.recurring.inquiry" ], - "sha256": "c8fbf2b8486644b270be77637ec1ce7ed3b26f05e5c6d82dc708970741e4c105", - "retrieved_at": "2026-07-27T10:45:47.988248Z" + "sha256": "f17f15789fe3c01c3145313d7123db1b767d5230d17320cc6bc7f10de602aa46", + "retrieved_at": "2026-07-27T11:10:46.256814Z" }, { "id": "gopay-tokenization-direct-debit", @@ -270,8 +270,8 @@ "gopaytokenization.paylater.charge", "gopaytokenization.recurring.option-selection" ], - "sha256": "434c892e8c8aa075723e66e9ad0849cf3e4f9e1e4779e41ecb4d4e5f596d2312", - "retrieved_at": "2026-07-27T10:45:47.988248Z" + "sha256": "e96fc22504ffb0ff3d1d16e5fba0cc7bb6a2e27d05575fb16c02bda3678b2650", + "retrieved_at": "2026-07-27T11:10:46.256814Z" }, { "id": "gopay-tokenization-unbind", @@ -279,8 +279,8 @@ "rules": [ "gopaytokenization.unlink" ], - "sha256": "4139a21b53919428075d94ff5865aa5436451f9e105cc13f9f86e075d4797541", - "retrieved_at": "2026-07-27T10:45:47.988248Z" + "sha256": "70ff22a821d7e0ed3dd3dfdaee714d0962e605de5a08fba8f0aa691e18832d91", + "retrieved_at": "2026-07-27T11:10:46.256814Z" }, { "id": "gopay-tokenization-account-linking-unlinking-notification", @@ -290,8 +290,8 @@ "gopaytokenization.recurring.notification", "common.webhook-idempotency" ], - "sha256": "55df8f16cfd6d49973374b992b364dedb8928d801abce80a446382ddefcc4341", - "retrieved_at": "2026-07-27T10:45:47.988248Z" + "sha256": "fbecb57e7ac73c95086bbad23a568711f93867c484939362e4b777cbe2db4d38", + "retrieved_at": "2026-07-27T11:10:46.256814Z" }, { "id": "subscription-create", @@ -300,8 +300,8 @@ "subscription.create", "subscription.basic-auth" ], - "sha256": "cdb2e55b2ff4648314598902033e4bbf5a7b63bd9394231b03346c67f0c346fc", - "retrieved_at": "2026-07-27T10:45:47.988248Z" + "sha256": "2046f45f31ddd742814b0dc0abe3efb0669c316879bed8b5c0d1f680b3445caf", + "retrieved_at": "2026-07-27T11:10:46.256814Z" }, { "id": "subscription-update", @@ -310,8 +310,8 @@ "subscription.update", "subscription.safe-schedule" ], - "sha256": "6660ab17233cba1e9241fc339062da6f7d25fe933260e3e7997950812edc7781", - "retrieved_at": "2026-07-27T10:45:47.988248Z" + "sha256": "b7ae3e67b35c36475f75ae7ffad3cbda3a25875826184a573efd4441817a3900", + "retrieved_at": "2026-07-27T11:10:46.256814Z" }, { "id": "subscription-get", @@ -320,8 +320,8 @@ "subscription.status", "subscription.status-before-mutation" ], - "sha256": "c12b0d92506de62700bd3074474cd246f968275ca942af2a92e23a91d1e2a574", - "retrieved_at": "2026-07-27T10:45:47.988248Z" + "sha256": "2eb8bacb0295ff7b92be6c7a29f2f961187a7750de4234e8b50ee36cdcb38650", + "retrieved_at": "2026-07-27T11:10:46.256814Z" }, { "id": "subscription-disable", @@ -330,8 +330,8 @@ "subscription.disable", "subscription.no-blind-retry" ], - "sha256": "b5330b19ff1860c54ee6d032be8f73a2b7ed6efcca4898018b0d5cf680dbbc32", - "retrieved_at": "2026-07-27T10:45:47.988248Z" + "sha256": "464393c549e0058c2c386202d6d9e88331c627539f4738509a7ec54f058f968a", + "retrieved_at": "2026-07-27T11:10:46.256814Z" }, { "id": "subscription-enable", @@ -340,8 +340,8 @@ "subscription.enable", "subscription.no-blind-retry" ], - "sha256": "701f894530fb6c4813b3dea28789113a26f2c55f58f69818890ba7a250f15c6c", - "retrieved_at": "2026-07-27T10:45:47.988248Z" + "sha256": "779d6cb32bee16f3239922377f49daadbf656d64e2baeecb618f257e90eff605", + "retrieved_at": "2026-07-27T11:10:46.256814Z" }, { "id": "subscription-cancel", @@ -350,8 +350,8 @@ "subscription.cancel", "subscription.no-blind-retry" ], - "sha256": "56776bef17be9fe2fd7e6d54eb631545929967f671184453a140a16312d01c88", - "retrieved_at": "2026-07-27T10:45:47.988248Z" + "sha256": "f840929e90b5a6c097864c4712a68530de321fdb349e537e3e717f4385b7a520", + "retrieved_at": "2026-07-27T11:10:46.256814Z" } ] } diff --git a/internal/sourceprovenance/baseline.go b/internal/sourceprovenance/baseline.go index 831f3fe..894b210 100644 --- a/internal/sourceprovenance/baseline.go +++ b/internal/sourceprovenance/baseline.go @@ -86,11 +86,15 @@ func fetch( if err != nil || parsed.Host != allowedHost || parsed.User != nil { return "", fmt.Errorf("%s: source host is not allowed", source.ID) } - request, err := http.NewRequestWithContext(ctx, http.MethodGet, source.URL, nil) + markdown, err := markdownURL(source.URL) if err != nil { return "", fmt.Errorf("%s: request could not be created", source.ID) } - request.Header.Set("Accept", "text/html, text/plain;q=0.9") + request, err := http.NewRequestWithContext(ctx, http.MethodGet, markdown, nil) + if err != nil { + return "", fmt.Errorf("%s: request could not be created", source.ID) + } + request.Header.Set("Accept", "text/markdown, text/plain;q=0.9") request.Header.Set("User-Agent", "midtrans-cli-source-baseline/1") response, err := client.Do(request) @@ -114,6 +118,21 @@ func fetch( return hex.EncodeToString(sum[:]), nil } +func markdownURL(sourceURL string) (string, error) { + parsed, err := url.Parse(sourceURL) + if err != nil { + return "", errors.New("source URL path is invalid") + } + markdown := *parsed + if !strings.HasSuffix(markdown.Path, ".md") { + markdown.Path += ".md" + if markdown.RawPath != "" { + markdown.RawPath += ".md" + } + } + return markdown.String(), nil +} + func normalizeBody(body []byte) []byte { normalized := bytes.ReplaceAll(body, []byte("\r\n"), []byte("\n")) normalized = cloudflareEmailPattern.ReplaceAllFunc(normalized, func(attribute []byte) []byte { diff --git a/internal/sourceprovenance/baseline_test.go b/internal/sourceprovenance/baseline_test.go index d589eb9..7e394e0 100644 --- a/internal/sourceprovenance/baseline_test.go +++ b/internal/sourceprovenance/baseline_test.go @@ -33,6 +33,43 @@ func TestFetchNormalizesCRLFBeforeHashing(t *testing.T) { } } +func TestMarkdownURLAppendsOnlyToSourcePath(t *testing.T) { + got, err := markdownURL( + "https://docs.midtrans.com/reference/backend-integration?locale=en#overview", + ) + if err != nil { + t.Fatalf("markdownURL: %v", err) + } + want := "https://docs.midtrans.com/reference/backend-integration.md?locale=en#overview" + if got != want { + t.Fatalf("markdown URL = %q, want %q", got, want) + } +} + +func TestFetchRequestsCanonicalMarkdown(t *testing.T) { + var path, accept string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, request *http.Request) { + path = request.URL.Path + accept = request.Header.Get("Accept") + _, _ = w.Write([]byte("# Canonical Markdown\n")) + })) + t.Cleanup(server.Close) + + _, err := fetch(context.Background(), server.Client(), contracts.PublicSource{ + ID: "source-a", + URL: server.URL + "/reference/backend-integration", + }, strings.TrimPrefix(server.URL, "http://")) + if err != nil { + t.Fatalf("fetch: %v", err) + } + if path != "/reference/backend-integration.md" { + t.Fatalf("request path = %q, want canonical Markdown path", path) + } + if !strings.Contains(accept, "text/markdown") { + t.Fatalf("Accept = %q, want Markdown", accept) + } +} + func TestFetchCanonicalizesCloudflareEmailProtection(t *testing.T) { requests := 0 server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { From 0ae51e2269cfbf314888983f0ef51267152b10ee Mon Sep 17 00:00:00 2001 From: "m.salis" Date: Mon, 27 Jul 2026 18:15:56 +0700 Subject: [PATCH 73/73] fix: require complete proof policy for verified state --- internal/app/journey_runner.go | 30 ++++++++++++++-- internal/app/journey_runner_test.go | 56 +++++++++++++++++++++++------ 2 files changed, 74 insertions(+), 12 deletions(-) diff --git a/internal/app/journey_runner.go b/internal/app/journey_runner.go index c8789fe..be4ffa6 100644 --- a/internal/app/journey_runner.go +++ b/internal/app/journey_runner.go @@ -209,11 +209,12 @@ func genericJourneyResult( definition journeypkg.Definition, outcome journeypkg.Outcome, ) contracts.Result { + proofsVerified := passedOutcomeSatisfiesProofPolicy(definition.ID, outcome) status := contracts.StatusBlocked switch outcome.State { case journeypkg.Passed: status = contracts.StatusPass - if len(outcome.Proofs) == 0 { + if !proofsVerified { status = contracts.StatusWarn } case journeypkg.Failed: @@ -245,7 +246,7 @@ func genericJourneyResult( if outcome.Action != nil { data["action"] = outcome.Action } - if outcome.State == journeypkg.Passed && len(outcome.Proofs) == 0 { + if outcome.State == journeypkg.Passed && !proofsVerified { data["state"] = "provider_confirmed" result.NextActions = []contracts.NextAction{{ Action: "collect_evidence_and_verify", @@ -265,6 +266,31 @@ func genericJourneyResult( return result } +func passedOutcomeSatisfiesProofPolicy( + journeyID string, + outcome journeypkg.Outcome, +) bool { + required, known := compiledProofPolicy(journeyID) + if !known || len(required) == 0 { + return false + } + for _, requirement := range required { + matched := false + for _, proof := range outcome.Proofs { + if proof.ID == requirement.ID && + proof.Level == requirement.Level && + proof.Status == "pass" { + matched = true + break + } + } + if !matched { + return false + } + } + return true +} + func isReservedJourneyEnvelopeKey(key string) bool { switch key { case "product", "journey", "operation_id", "state", "proofs", "missing_evidence", "action": diff --git a/internal/app/journey_runner_test.go b/internal/app/journey_runner_test.go index c785ea8..528de2d 100644 --- a/internal/app/journey_runner_test.go +++ b/internal/app/journey_runner_test.go @@ -61,16 +61,10 @@ func TestGenericJourneyResultKeepsPassedOutcomeWithProofsVerified(t *testing.T) journeypkg.Outcome{ OperationID: "op_bisnap_status", State: journeypkg.Passed, - Proofs: []evidence.Proof{{ - ID: "bisnap.notification", - OperationID: "op_bisnap_status", - Stage: "provider_notification", - Level: evidence.ProofSandbox, - Source: "merchant callback", - ObservedAt: time.Date(2026, time.July, 27, 0, 0, 0, 0, time.UTC), - Status: "pass", - Summary: map[string]any{"status": "settlement"}, - }}, + Proofs: []evidence.Proof{ + bisnapProof("bisnap.notification", evidence.ProofSandbox), + bisnapProof("bisnap.merchant-persistence", evidence.ProofLocal), + }, }, ) @@ -85,3 +79,45 @@ func TestGenericJourneyResultKeepsPassedOutcomeWithProofsVerified(t *testing.T) t.Fatalf("next actions = %#v", result.NextActions) } } + +func TestGenericJourneyResultMarksIncompleteProofPolicyAsProviderConfirmed(t *testing.T) { + result := genericJourneyResult( + "agent.run", + Dependencies{}, + 1, + journeypkg.Definition{ID: "bisnap.status", Product: "bisnap"}, + journeypkg.Outcome{ + OperationID: "op_bisnap_status", + State: journeypkg.Passed, + Proofs: []evidence.Proof{ + bisnapProof("bisnap.notification", evidence.ProofSandbox), + }, + }, + ) + + if result.Status != contracts.StatusWarn { + t.Fatalf("status = %q, want %q", result.Status, contracts.StatusWarn) + } + data, ok := result.Data.(map[string]any) + if !ok || data["state"] != "provider_confirmed" { + t.Fatalf("data = %#v", result.Data) + } + if len(result.NextActions) != 1 || + result.NextActions[0].Action != "collect_evidence_and_verify" || + result.NextActions[0].Arguments["verify_command"] != "midtrans verify --product bisnap" { + t.Fatalf("next actions = %#v", result.NextActions) + } +} + +func bisnapProof(id string, level evidence.ProofLevel) evidence.Proof { + return evidence.Proof{ + ID: id, + OperationID: "op_bisnap_status", + Stage: "verified_evidence", + Level: level, + Source: "merchant_application", + ObservedAt: time.Date(2026, time.July, 27, 0, 0, 0, 0, time.UTC), + Status: "pass", + Summary: map[string]any{"status": "settlement"}, + } +}