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..7bb96a4 --- /dev/null +++ b/.superpowers/sdd/2026-07-26-midtrans-cli-multi-product-parity/task-1-report.md @@ -0,0 +1,63 @@ +## 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. + +## 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/.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..de03363 --- /dev/null +++ b/.superpowers/sdd/2026-07-26-midtrans-cli-multi-product-parity/task-10-report.md @@ -0,0 +1,133 @@ +# 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. + +## 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 +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` + +## 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/.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..491cf5f --- /dev/null +++ b/.superpowers/sdd/2026-07-26-midtrans-cli-multi-product-parity/task-11-report.md @@ -0,0 +1,417 @@ +# 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 +``` + +## 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 +``` + +## 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 +``` + +## 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 +``` + +## 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 +``` + +## 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/.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..bb33d8d --- /dev/null +++ b/.superpowers/sdd/2026-07-26-midtrans-cli-multi-product-parity/task-12-report.md @@ -0,0 +1,215 @@ +# 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. + +## 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. + +## 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 + 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/.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..0f1a9f4 --- /dev/null +++ b/.superpowers/sdd/2026-07-26-midtrans-cli-multi-product-parity/task-13-report.md @@ -0,0 +1,54 @@ +# 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` +- Skill repo parity-guidance review fix: + `f293153665a9d97b2cb1ab45179b879359370dc2` + +## 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/.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..7a036dc --- /dev/null +++ b/.superpowers/sdd/2026-07-26-midtrans-cli-multi-product-parity/task-14-report.md @@ -0,0 +1,84 @@ +# 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. + +## 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/.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..ad65012 --- /dev/null +++ b/.superpowers/sdd/2026-07-26-midtrans-cli-multi-product-parity/task-2-report.md @@ -0,0 +1,65 @@ +## 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. + +## 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/.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..d049e21 --- /dev/null +++ b/.superpowers/sdd/2026-07-26-midtrans-cli-multi-product-parity/task-3-report.md @@ -0,0 +1,104 @@ +## 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. + +## 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. + +## 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/.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..3374b85 --- /dev/null +++ b/.superpowers/sdd/2026-07-26-midtrans-cli-multi-product-parity/task-4-report.md @@ -0,0 +1,105 @@ +# 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. + +## 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. + +## 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. + +## 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/.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..bc5aedb --- /dev/null +++ b/.superpowers/sdd/2026-07-26-midtrans-cli-multi-product-parity/task-5-report.md @@ -0,0 +1,79 @@ +# 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 + +- `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. + +## 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/.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..17960db --- /dev/null +++ b/.superpowers/sdd/2026-07-26-midtrans-cli-multi-product-parity/task-6-report.md @@ -0,0 +1,57 @@ +# 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` + +## 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/.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..6574945 --- /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: `ca55bde` (`fix: restore payment link resume and routing`) + +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/.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..8f4aa0a --- /dev/null +++ b/.superpowers/sdd/2026-07-26-midtrans-cli-multi-product-parity/task-8-report.md @@ -0,0 +1,55 @@ +# 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 +``` + +## 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. +- Product journeys, access-token response handling, and webhook response serialization remain for later tasks by design. 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..8429ce1 --- /dev/null +++ b/.superpowers/sdd/2026-07-26-midtrans-cli-multi-product-parity/task-9-report.md @@ -0,0 +1,90 @@ +# 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 +- 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 +- 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 + +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 +``` + +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 +``` + +Requested fix round 2 validation: + +```sh +go test ./packs/bisnap ./internal/app -count=1 +go test ./... -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 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 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/README.md b/README.md index 801081d..e4cffb4 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,39 @@ 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: +variable references, never secret values. Phase 1 remains Sandbox-only: the +CLI rejects production Midtrans hosts and production credentials. -```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: +`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. -```sh -midtrans sandbox run snap.checkout \ - --order-id cli-sandbox-001 --gross-amount 10000 \ - --project-dir /path/to/merchant --json --non-interactive -``` +## Representative multi-product fixtures -Only after reviewing that exact plan, opt in to the sandbox mutation: +The repository also ships synthetic loopback fixtures for representative +multi-product merchant flows under `evaluations/fixtures/`: -```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 -``` +- `hybrid-snap-gopay` +- `coreapi-paymentlink` +- `bisnap-qris-va` -See [sandbox evidence](docs/sandbox-evidence.md) before sharing an export. +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 @@ -108,9 +118,10 @@ See [sandbox evidence](docs/sandbox-evidence.md) before sharing an export. - [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 @@ -122,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/cmd/midtrans/main.go b/cmd/midtrans/main.go index 23f78f9..1ec3b95 100644 --- a/cmd/midtrans/main.go +++ b/cmd/midtrans/main.go @@ -8,12 +8,17 @@ 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/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()) + 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 e4a3f42..3e13abf 100644 --- a/contracts/capabilities-v1.json +++ b/contracts/capabilities-v1.json @@ -12,19 +12,115 @@ ], "journeys": [] }, + { + "id": "bisnap", + "version": "0.1.0", + "capabilities": [ + "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" + ], + "journeys": [ + "bisnap.qris-payment", + "bisnap.virtual-account", + "bisnap.direct-debit", + "bisnap.recurring", + "bisnap.status", + "bisnap.refund" + ] + }, + { + "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.recurring.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.recurring", + "core-api.virtual-account", + "core-api.refund" + ] + }, { "id": "snap", "version": "0.1.0", "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" ] + }, + { + "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" + ] + }, + { + "id": "gopay-tokenization", + "version": "0.1.0", + "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" + ], + "journeys": [ + "gopay-tokenization.account-linking", + "gopay-tokenization.binding-inquiry", + "gopay-tokenization.recurring", + "gopay-tokenization.wallet-payment", + "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 59dcff9..c2de736 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": "c5d58aca0ee1b9b6fb6b0bd2d4b368aa177c88df91f7537a7d2fb782decf6473", + "retrieved_at": "2026-07-27T11:10:46.256814Z" + }, + { + "id": "snap-js", + "url": "https://docs.midtrans.com/reference/snap-js", + "rules": [ + "snap.checkout.popup", + "snap.checkout.embed" + ], + "sha256": "089d494331e7113f2043a90e3c28792bed0a6c6bae65787e9b79cda9c63daba1", + "retrieved_at": "2026-07-27T11:10:46.256814Z" + }, { "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": "12e99dc8b5ee2e4491bf48fd783500c26da6fc1cbd91cfaf7d64ed87df04d343", + "retrieved_at": "2026-07-27T11:10:46.256814Z" + }, + { + "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": "579945b60452326f51b502ba6e5882f678102423a6d568d3860040b3afe517d3", + "retrieved_at": "2026-07-27T11:10:46.256814Z" }, { "id": "http-notifications", @@ -18,18 +48,310 @@ "snap.notification.signature", "common.webhook-idempotency" ], - "sha256": "00c2adc7cf4db97bf574cdd0664fad32849cc7151ed4d6b937b96d215a956f6d", - "retrieved_at": "2026-07-24T16:11:32.099663Z" + "sha256": "022717b91d40f175d93379e0cce8263d96ac5a73a7d06c8bbdb4c0569f717109", + "retrieved_at": "2026-07-27T11:10:46.256814Z" + }, + { + "id": "get-transaction-status", + "url": "https://docs.midtrans.com/reference/get-transaction-status", + "rules": [ + "snap.status.reconcile", + "snap.mobile.status.reconcile" + ], + "sha256": "b496a7d6159813bb49b10e50b81b11b0a6ed958d3d25dac2577137eeb334ac90", + "retrieved_at": "2026-07-27T11:10:46.256814Z" + }, + { + "id": "coreapi-card-charge", + "url": "https://docs.midtrans.com/reference/charge-transactions-on-card", + "rules": [ + "coreapi.card.charge", + "coreapi.basic-auth" + ], + "sha256": "935cf123bf52dfded17eb7a03cc79ee247146ac53f8272ef3ac88ff62ba16844", + "retrieved_at": "2026-07-27T11:10:46.256814Z" + }, + { + "id": "coreapi-card-3ds", + "url": "https://docs.midtrans.com/reference/card-feature-3d-secure-3ds", + "rules": [ + "coreapi.card.3ds", + "coreapi.card.redirect" + ], + "sha256": "051fd5f05ae5134e23ef72fe3694e33c9196323ea23188b3d3d2254c58b09613", + "retrieved_at": "2026-07-27T11:10:46.256814Z" + }, + { + "id": "coreapi-one-click", + "url": "https://docs.midtrans.com/reference/card-feature-one-click", + "rules": [ + "coreapi.saved-card.token-only", + "coreapi.recurring.saved-card-token" + ], + "sha256": "f3cb7e8c7558fecd17374a8c43423e512f009730b0b78309b0614a827e509726", + "retrieved_at": "2026-07-27T11:10:46.256814Z" + }, + { + "id": "coreapi-alfamart", + "url": "https://docs.midtrans.com/reference/alfamart-1", + "rules": [ + "coreapi.otc.charge", + "coreapi.otc.payment-code" + ], + "sha256": "70075ffe3407d84c22ec444d49eaeb42cac6219f74631694bed38a6909fffe70", + "retrieved_at": "2026-07-27T11:10:46.256814Z" + }, + { + "id": "coreapi-bni-va", + "url": "https://docs.midtrans.com/reference/bni-virtual-account-1", + "rules": [ + "coreapi.va.charge", + "coreapi.va.instructions" + ], + "sha256": "4bab30513c757f244b485493a0a26765e73ade05b0b0fc6c87f22b167a57ce8f", + "retrieved_at": "2026-07-27T11:10:46.256814Z" + }, + { + "id": "coreapi-status", + "url": "https://docs.midtrans.com/reference/get-transaction-status", + "rules": [ + "coreapi.status.reconcile", + "coreapi.recurring.status", + "coreapi.refund.status" + ], + "sha256": "b496a7d6159813bb49b10e50b81b11b0a6ed958d3d25dac2577137eeb334ac90", + "retrieved_at": "2026-07-27T11:10:46.256814Z" + }, + { + "id": "coreapi-refund", + "url": "https://docs.midtrans.com/reference/refund-transaction", + "rules": [ + "coreapi.refund.async", + "coreapi.refund.idempotency" + ], + "sha256": "29f519e7f22579147fb4e1c42e4a2f38eb0f10810ab96b18f10b1dc577e82430", + "retrieved_at": "2026-07-27T11:10:46.256814Z" + }, + { + "id": "coreapi-direct-refund", + "url": "https://docs.midtrans.com/reference/direct-refund-transaction", + "rules": [ + "coreapi.refund.direct" + ], + "sha256": "1e970e5415dd54f5918e9530e9295e44e9824ae18b33fc75050887a09589f668", + "retrieved_at": "2026-07-27T11:10:46.256814Z" + }, + { + "id": "coreapi-notifications", + "url": "https://docs.midtrans.com/docs/https-notification-webhooks", + "rules": [ + "coreapi.notification.signature", + "coreapi.recurring.notification", + "common.webhook-idempotency" + ], + "sha256": "022717b91d40f175d93379e0cce8263d96ac5a73a7d06c8bbdb4c0569f717109", + "retrieved_at": "2026-07-27T11:10:46.256814Z" + }, + { + "id": "payment-link-overview", + "url": "https://docs.midtrans.com/docs/payment-link-via-api", + "rules": [ + "paymentlink.create", + "paymentlink.reusable" + ], + "sha256": "7a07e40caa47caab30f0b00c1a3700c115e5e17cd5ca41baf5d9db955adc7b06", + "retrieved_at": "2026-07-27T11:10:46.256814Z" + }, + { + "id": "payment-link-status", + "url": "https://docs.midtrans.com/reference/get-transaction-status", + "rules": [ + "paymentlink.status.reconcile" + ], + "sha256": "b496a7d6159813bb49b10e50b81b11b0a6ed958d3d25dac2577137eeb334ac90", + "retrieved_at": "2026-07-27T11:10:46.256814Z" + }, + { + "id": "payment-link-notifications", + "url": "https://docs.midtrans.com/docs/https-notification-webhooks", + "rules": [ + "paymentlink.notification.signature", + "common.webhook-idempotency" + ], + "sha256": "022717b91d40f175d93379e0cce8263d96ac5a73a7d06c8bbdb4c0569f717109", + "retrieved_at": "2026-07-27T11:10:46.256814Z" + }, + { + "id": "bisnap-overview", + "url": "https://docs.midtrans.com/reference/core-api-snap-open-api-overview", + "rules": [ + "bisnap.signing.verify.v1", + "bisnap.recurring.transaction-signature" + ], + "sha256": "b840b608774632176650b6ca9224a7a2f9a16b6e027a78e3efba0761649614ad", + "retrieved_at": "2026-07-27T11:10:46.256814Z" + }, + { + "id": "bisnap-qris", + "url": "https://docs.midtrans.com/reference/mpm-api-qris", + "rules": [ + "bisnap.qris.create", + "bisnap.qris.status" + ], + "sha256": "f4d5a03376b94f6417e79e3c1e1aa10868bf5fbcb0d4343bc1afe86bfe128d62", + "retrieved_at": "2026-07-27T11:10:46.256814Z" + }, + { + "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": "469dd40058f834308aea8c32c8348b0096e27e686e23a2bf60df3e6659cd1ea1", + "retrieved_at": "2026-07-27T11:10:46.256814Z" + }, + { + "id": "bisnap-direct-debit", + "url": "https://docs.midtrans.com/reference/direct-debit-api-gopay", + "rules": [ + "bisnap.direct-debit.create", + "bisnap.direct-debit.status", + "bisnap.recurring.status", + "bisnap.refund" + ], + "sha256": "646ec37f804a0f200af130c0e4de1a740b5331d8dbc48046515e06f1e867d8b5", + "retrieved_at": "2026-07-27T11:10:46.256814Z" + }, + { + "id": "bisnap-notifications", + "url": "https://docs.midtrans.com/reference/payment-notification-api", + "rules": [ + "bisnap.notification.signature", + "bisnap.recurring.notification", + "common.webhook-idempotency" + ], + "sha256": "236eab14e2a1cb98723fffe82420d65f63980908c86745260bef7bc640ccf6db", + "retrieved_at": "2026-07-27T11:10:46.256814Z" + }, + { + "id": "gopay-tokenization-get-auth-code", + "url": "https://docs.midtrans.com/reference/get-auth-code-api", + "rules": [ + "gopaytokenization.linking.get-auth-code" + ], + "sha256": "e2f94274ee436a15e3431ab3f7430c5b9fe37416870a87d3f2d82949491250d6", + "retrieved_at": "2026-07-27T11:10:46.256814Z" + }, + { + "id": "gopay-tokenization-binding-api", + "url": "https://docs.midtrans.com/reference/binding-api", + "rules": [ + "gopaytokenization.linking.bind" + ], + "sha256": "d269ea2fe9ab010a0b3f50076d5231c315a8580531a4e24e7f718d3836feb249", + "retrieved_at": "2026-07-27T11:10:46.256814Z" + }, + { + "id": "gopay-tokenization-binding-inquiry-api", + "url": "https://docs.midtrans.com/reference/binding-inquiry-api", + "rules": [ + "gopaytokenization.linking.inquiry", + "gopaytokenization.recurring.inquiry" + ], + "sha256": "f17f15789fe3c01c3145313d7123db1b767d5230d17320cc6bc7f10de602aa46", + "retrieved_at": "2026-07-27T11:10:46.256814Z" + }, + { + "id": "gopay-tokenization-direct-debit", + "url": "https://docs.midtrans.com/reference/direct-debit-api-gopay-tokenization", + "rules": [ + "gopaytokenization.wallet.charge", + "gopaytokenization.paylater.charge", + "gopaytokenization.recurring.option-selection" + ], + "sha256": "e96fc22504ffb0ff3d1d16e5fba0cc7bb6a2e27d05575fb16c02bda3678b2650", + "retrieved_at": "2026-07-27T11:10:46.256814Z" + }, + { + "id": "gopay-tokenization-unbind", + "url": "https://docs.midtrans.com/reference/unbind-api", + "rules": [ + "gopaytokenization.unlink" + ], + "sha256": "70ff22a821d7e0ed3dd3dfdaee714d0962e605de5a08fba8f0aa691e18832d91", + "retrieved_at": "2026-07-27T11:10:46.256814Z" + }, + { + "id": "gopay-tokenization-account-linking-unlinking-notification", + "url": "https://docs.midtrans.com/reference/account-linking-unlinking-notification", + "rules": [ + "gopaytokenization.notification.signature", + "gopaytokenization.recurring.notification", + "common.webhook-idempotency" + ], + "sha256": "fbecb57e7ac73c95086bbad23a568711f93867c484939362e4b777cbe2db4d38", + "retrieved_at": "2026-07-27T11:10:46.256814Z" + }, + { + "id": "subscription-create", + "url": "https://docs.midtrans.com/reference/create-subscription", + "rules": [ + "subscription.create", + "subscription.basic-auth" + ], + "sha256": "2046f45f31ddd742814b0dc0abe3efb0669c316879bed8b5c0d1f680b3445caf", + "retrieved_at": "2026-07-27T11:10:46.256814Z" + }, + { + "id": "subscription-update", + "url": "https://docs.midtrans.com/reference/update-subscription", + "rules": [ + "subscription.update", + "subscription.safe-schedule" + ], + "sha256": "b7ae3e67b35c36475f75ae7ffad3cbda3a25875826184a573efd4441817a3900", + "retrieved_at": "2026-07-27T11:10:46.256814Z" + }, + { + "id": "subscription-get", + "url": "https://docs.midtrans.com/reference/get-subscription", + "rules": [ + "subscription.status", + "subscription.status-before-mutation" + ], + "sha256": "2eb8bacb0295ff7b92be6c7a29f2f961187a7750de4234e8b50ee36cdcb38650", + "retrieved_at": "2026-07-27T11:10:46.256814Z" + }, + { + "id": "subscription-disable", + "url": "https://docs.midtrans.com/reference/disable-subscription", + "rules": [ + "subscription.disable", + "subscription.no-blind-retry" + ], + "sha256": "464393c549e0058c2c386202d6d9e88331c627539f4738509a7ec54f058f968a", + "retrieved_at": "2026-07-27T11:10:46.256814Z" + }, + { + "id": "subscription-enable", + "url": "https://docs.midtrans.com/reference/enable-subscription", + "rules": [ + "subscription.enable", + "subscription.no-blind-retry" + ], + "sha256": "779d6cb32bee16f3239922377f49daadbf656d64e2baeecb618f257e90eff605", + "retrieved_at": "2026-07-27T11:10:46.256814Z" }, { - "id": "api-authorization", - "url": "https://docs.midtrans.com/docs/api-authorization-headers", + "id": "subscription-cancel", + "url": "https://docs.midtrans.com/reference/cancel-subscription", "rules": [ - "snap.basic-auth", - "snap.status.reconcile" + "subscription.cancel", + "subscription.no-blind-retry" ], - "sha256": "b63e48e364a8aeee7623b4904eeab7dbfa1416443317e94fa31572e70b408ef1", - "retrieved_at": "2026-07-24T16:11:32.099663Z" + "sha256": "f840929e90b5a6c097864c4712a68530de321fdb349e537e3e717f4385b7a520", + "retrieved_at": "2026-07-27T11:10:46.256814Z" } ] } diff --git a/docs/agent-skill-compatibility.md b/docs/agent-skill-compatibility.md index 61ccc39..0c88c92 100644 --- a/docs/agent-skill-compatibility.md +++ b/docs/agent-skill-compatibility.md @@ -1,34 +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 capabilities --json --non-interactive +midtrans agent capabilities --json --non-interactive +midtrans agent inspect --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/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/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..36cc875 --- /dev/null +++ b/docs/superpowers/plans/2026-07-26-merchant-cli-experience.md @@ -0,0 +1,2430 @@ +# 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) + } +} +``` + +- [ ] **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) + } +} +``` + +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/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 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** + +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-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..dc8ce15 --- /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 +POST /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/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 new file mode 100644 index 0000000..87b027b --- /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:** Approved +- **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. 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..417f955 --- /dev/null +++ b/docs/superpowers/specs/2026-07-26-multi-product-parity-design.md @@ -0,0 +1,459 @@ +# Midtrans CLI Multi-Product Parity Design + +**Date:** 2026-07-26 +**Status:** Approved +**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 | +| `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, +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 +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. 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 < 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 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, + 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 + 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 TestSetupNonInteractivePreservesFailedReadinessAndNeverWritesManifest(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 != 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)) + } +} + +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) + 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) + } +} + +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( + 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 { @@ -90,6 +641,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]++ @@ -361,14 +930,10 @@ 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"}, - "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", "plan", "resume", "run"}, + "test": {"webhook"}, + "update": {"check"}, } for command, want := range expected { t.Run(strings.ReplaceAll(command, " ", "."), func(t *testing.T) { @@ -438,6 +1003,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") @@ -505,13 +1129,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) @@ -537,7 +1161,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 { @@ -580,7 +1204,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) } @@ -615,14 +1243,17 @@ 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) } } 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"), @@ -673,9 +1304,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()) } @@ -872,10 +1506,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 +1527,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 +1561,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 +1577,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 +1589,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) } } @@ -962,6 +1600,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", @@ -1005,6 +1644,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 @@ -1018,8 +1658,64 @@ func TestCredentialsStatusReturnsOnlyPresenceBooleans(t *testing.T) { if err != nil { t.Fatal(err) } - if string(encoded) != `{"provider":"environment","references":{"client_key":false,"server_key":true}}` { - t.Fatalf("credential status data = %s", encoded) + if string(encoded) != `{"provider":"environment","references":{"client_key":false,"server_key":true}}` { + t.Fatalf("credential status data = %s", encoded) + } +} + +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) + } + 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) } } @@ -1028,6 +1724,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 { @@ -1035,7 +1732,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, ) @@ -1056,12 +1753,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) @@ -1117,6 +1814,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", ) @@ -1179,6 +1877,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", @@ -1254,6 +1962,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 { @@ -1358,6 +2067,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 @@ -1514,11 +2224,61 @@ 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 { 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", @@ -1528,9 +2288,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) { @@ -1538,6 +2308,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) @@ -1561,11 +2332,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) @@ -1601,6 +2373,291 @@ 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.StatusWarn { + 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( + 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 @@ -1635,6 +2692,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) @@ -1949,6 +3007,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 @@ -1968,6 +3052,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, @@ -2015,6 +3105,35 @@ 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 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 @@ -2096,7 +3215,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(), paymentlink.New(), bisnap.New(), gopaytokenization.New(), subscription.New()) if err != nil { t.Fatal(err) } @@ -2129,10 +3248,78 @@ func (inspectionAwarePack) Evaluate( }} } +func (inspectionAwarePack) Handlers() []journey.Handler { return nil } + func merchantFixture(name string) string { return filepath.Join("..", "..", "testdata", "merchant-repos", name) } +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 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) + if err != nil { + t.Fatal(err) + } + mutate(&value) + 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 new file mode 100644 index 0000000..b678355 --- /dev/null +++ b/internal/app/checkout_runner.go @@ -0,0 +1,477 @@ +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 + OperationID 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, + request.ProjectDir, + checkoutServerKeyReference(value), + deps, + ) + 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: providerJourneyHTTP(deps, "snap"), ServerKey: serverKey}, + Status: snap.Client{HTTP: providerJourneyHTTP(deps, "snap"), ServerKey: serverKey}, + Local: snap.MerchantVerifier{ + Manifest: value, + ServerKey: serverKey, + HTTP: localJourneyHTTPClient(deps.HTTP), + }, + Ledger: operations.Store{ProjectDir: request.ProjectDir}, + }).Run(ctx, snap.JourneyInput{ + OperationID: checkoutOperationID(request, plan.Hash), + ManifestHash: manifestHash, + 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{ + "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) + } + 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{ + "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{ + "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) + } + 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" + } + 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, + OperationID: journey.OperationID, + ManifestHash: manifestHash, + RepositoryCommit: revision, + Journey: "snap.checkout", + Environment: "sandbox", + StartedAt: startedAt, + 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 + } + 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_agent.go b/internal/app/commands_agent.go new file mode 100644 index 0000000..288bbf1 --- /dev/null +++ b/internal/app/commands_agent.go @@ -0,0 +1,145 @@ +package app + +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{ + Use: "agent", + Short: "machine-readable integration and capability commands", + } + 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 + 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, 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") +} + +type genericJourneyFlags struct { + command string + journeyID string + product string + orderID string + operationID string + 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 +} + +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.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") + 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") + } + 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, + EvidencePath: f.evidencePath, + 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, + Reusable: f.reusable, + }, + Execute: execute, + } +} 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/app/commands_checkout.go b/internal/app/commands_checkout.go new file mode 100644 index 0000000..8f8851a --- /dev/null +++ b/internal/app/commands_checkout.go @@ -0,0 +1,447 @@ +package app + +import ( + "context" + "errors" + "fmt" + "io" + "strconv" + "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/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 [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)) + } + return runMerchantIntent(cmd, flags, deps, request, args[0]) + }, + } + request.bind(parent) + parent.AddCommand(newLegacyTestCheckoutCommand(flags, deps)) + parent.AddCommand(newTestWebhookCommand(flags, deps)) + return withProjectMode(parent, project.Existing, "test") +} + +type merchantJourneyFlags struct { + command string + product string + orderID string + 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 +} + +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.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") + 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") +} + +func newLegacyTestCheckoutCommand(flags *globalFlags, deps Dependencies) *cobra.Command { + request := bindMerchantJourneyFlags("test.checkout") + command := &cobra.Command{ + Use: "checkout", + Short: "plan and run a Sandbox checkout", + Args: cobra.NoArgs, + Hidden: true, + RunE: func(cmd *cobra.Command, _ []string) error { + return runMerchantIntent(cmd, flags, deps, request, "checkout") + }, + } + request.bind(command) + return withProjectMode(command, project.Existing, "test.checkout") +} + +func runMerchantIntent( + cmd *cobra.Command, + flags *globalFlags, + deps Dependencies, + 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 { + commandName := request.commandNameForIntent(intent) + if request.amount <= 0 && intent != "status" { + result := contracts.NewResult(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: commandName, + ProjectDir: flags.projectDir, + Intent: intent, + Product: request.product, + EvidencePath: request.evidencePath, + Input: journeyInput( + orderID, + request.subscriptionID, + request.amount, + request.method, + request.scheduleInterval, + request.scheduleUnit, + request.scheduleStart, + request.customerReference, + request.paymentTokenReference, + request.mobileNumberReference, + request.usageLimit, + 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(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" { + return runGenericJourney(ctx, request, deps) + } + 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)) + 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_primary_test_intent", + Description: description, + }} + return result +} + +func shouldConfirmMerchantJourney(flags *globalFlags, deps Dependencies, execute bool) bool { + return !execute && !flags.json && !flags.nonInteractive && deps.IsTerminal() +} + +func journeyInput( + orderID string, + subscriptionID string, + amount int64, + method string, + scheduleInterval int, + scheduleUnit string, + scheduleStart string, + customerReference string, + paymentTokenReference string, + mobileNumberReference string, + usageLimit int, + reusable bool, +) 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, + Reusable: reusable, + } +} + +func (f *merchantJourneyFlags) commandName() string { + if f.command == "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 { + 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" +} + +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_credentials.go b/internal/app/commands_credentials.go index 6a5c0c9..bf74798 100644 --- a/internal/app/commands_credentials.go +++ b/internal/app/commands_credentials.go @@ -2,70 +2,88 @@ 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/secrets" + "github.com/veritrans/midtrans-cli/internal/project" ) func newCredentialsCommand(flags *globalFlags, deps Dependencies) *cobra.Command { - parent := &cobra.Command{Use: "credentials"} - parent.AddCommand(&cobra.Command{ + 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) - } - - 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"], - ) + RunE: newCredentialsStatusRunner(flags, deps), + }, project.Existing, "credentials.status")) + return parent +} - 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, - }, - } +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) - }, - }) - return parent + } + + value, invalidResult := loadValidatedManifest( + "credentials.status", flags.projectDir, deps, + ) + if invalidResult != nil { + return writeResult(deps, flags, *invalidResult) + } + + _, serverKeyErr := resolveSandboxServerKey( + cmd.Context(), + "credentials.status", + value.SchemaVersion, + flags.projectDir, + checkoutServerKeyReference(value), + deps, + ) + serverKeyPresent := serverKeyErr == nil + if serverKeyErr != nil { + return writeResult(deps, flags, *serverKeyErr) + } + clientKeyPresent := secretPresent( + cmd.Context(), + flags.projectDir, + checkoutClientKeyReference(value), + deps, + ) + + 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( - 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 { + if reference == "" { + return false + } + _, err := deps.ResolveCredential(ctx, projectDir, reference) return err == nil } @@ -76,22 +94,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 62bbf7e..c0a59ef 100644 --- a/internal/app/commands_doctor.go +++ b/internal/app/commands_doctor.go @@ -4,19 +4,37 @@ 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" ) 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", ) @@ -24,22 +42,21 @@ func newDoctorCommand(flags *globalFlags, deps Dependencies) *cobra.Command { return writeResult(deps, flags, result) } - value, err := manifest.Load(flags.projectDir) - if err != nil { - result := manifestLoadFailureResult("doctor", 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 { - result := inspectionFailureResult("doctor", deps) + result := inspectionFailureResult(resultCommand, deps) result.ManifestVersion = value.SchemaVersion return writeResult(deps, flags, result) } - findings = append(findings, pack.Evaluate(value, report)...) + 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 @@ -47,5 +64,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, resultCommand) } diff --git a/internal/app/commands_evidence.go b/internal/app/commands_evidence.go index e9dd479..cdcd951 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" ) @@ -38,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, @@ -48,19 +54,20 @@ 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) }, } command.Flags().StringVar(&file, "file", "", "checksummed evidence JSON file") _ = command.MarkFlagRequired("file") - return command + return withProjectMode(command, project.Existing, "evidence.show") } func newEvidenceExportCommand( @@ -72,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, @@ -83,7 +90,7 @@ func newEvidenceExportCommand( ) } safe, err := evidence.StructuralRedact( - bundle, + document, deps.Packs.SensitiveKeys(), ) if err != nil { @@ -119,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) }, @@ -128,7 +135,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) { @@ -241,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 66585c2..b268c88 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,485 @@ 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 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) + + 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 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 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) { + value.Verification.Required = []string{"snap.unknown-proof-policy"} + }) + + result, exit := executeJSON( + t, + "verify", + "--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) + } + 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) + } +} + +func TestVerifyBlocksUnknownPolicyJourneyEvenWithMatchingSyntheticEvidenceBundle(t *testing.T) { + project := createJourneyProject(t, "http://127.0.0.1:1") + configureManifest(t, project, func(value *manifest.Manifest) { + value.Verification.Required = []string{"snap.unknown-proof-policy"} + }) + + bundle := completeBundleForProject(t, project) + bundle.PackID = "snap" + bundle.Journey = "snap.unknown-proof-policy" + bundle.OperationID = "op_unknown_proof_policy" + bundle.Proofs = []evidence.Proof{{ + ID: "compiled_policy", + OperationID: "op_unknown_proof_policy", + 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) + } +} + +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 +767,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", @@ -298,18 +779,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, @@ -317,5 +806,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: "provider_notification", + Level: evidence.ProofSandbox, + Source: "midtrans_notification", + ObservedAt: snapBundle.CompletedAt, + Status: "blocked", + Summary: map[string]any{ + "route": "/api/payments/bisnap/notification", + }, + }, + }, + RequiredProofs: []evidence.RequiredProof{ + {ID: "bisnap.notification", Level: evidence.ProofSandbox}, + {ID: "bisnap.merchant-persistence", Level: evidence.ProofLocal}, + }, + MissingEvidence: []string{"bisnap.merchant-persistence"}, + } + 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_inspect.go b/internal/app/commands_inspect.go index fc92955..544096d 100644 --- a/internal/app/commands_inspect.go +++ b/internal/app/commands_inspect.go @@ -4,24 +4,30 @@ 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{ - Use: "inspect", +func newInspectCommand( + flags *globalFlags, + deps Dependencies, + use string, + resultCommand string, +) *cobra.Command { + return withProjectMode(&cobra.Command{ + 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, resultCommand) } func inspectionFailureResult(command string, deps Dependencies) contracts.Result { @@ -34,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 9600393..f3c03a1 100644 --- a/internal/app/commands_manifest.go +++ b/internal/app/commands_manifest.go @@ -1,55 +1,83 @@ 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" ) 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) + 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(&cobra.Command{ + 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) }, - }) - 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) - 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( @@ -71,6 +99,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_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_plan.go b/internal/app/commands_plan.go index cd30c4d..0a9f6c0 100644 --- a/internal/app/commands_plan.go +++ b/internal/app/commands_plan.go @@ -4,11 +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/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 { @@ -20,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) @@ -39,5 +39,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..bd3bc49 100644 --- a/internal/app/commands_sandbox.go +++ b/internal/app/commands_sandbox.go @@ -1,35 +1,24 @@ 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/safepath" + "github.com/veritrans/midtrans-cli/internal/project" "github.com/veritrans/midtrans-cli/packs/snap" ) -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") + 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)) @@ -38,10 +27,7 @@ func newSandboxCommand(flags *globalFlags, deps Dependencies) *cobra.Command { return parent } -func newSandboxRunCommand( - flags *globalFlags, - deps Dependencies, -) *cobra.Command { +func newSandboxRunCommand(flags *globalFlags, deps Dependencies) *cobra.Command { var ( orderID string grossAmount int64 @@ -51,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", @@ -69,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( @@ -202,228 +82,14 @@ func newSandboxRunCommand( ) _ = command.MarkFlagRequired("order-id") _ = command.MarkFlagRequired("gross-amount") - return command -} - -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 + return withProjectMode(command, project.Existing, "sandbox.run") } 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 { @@ -438,7 +104,8 @@ func newSandboxPreflightCommand( cmd.Context(), "sandbox.preflight", value.SchemaVersion, - value.Credentials.References["server_key"], + flags.projectDir, + checkoutServerKeyReference(value), deps, ) if credentialResult != nil { @@ -454,7 +121,7 @@ func newSandboxPreflightCommand( } return writeResult(deps, flags, result) }, - } + }, project.Existing, "sandbox.preflight") } func newSandboxStatusCommand( @@ -489,7 +156,8 @@ func newSandboxStatusCommand( cmd.Context(), "sandbox.status", value.SchemaVersion, - value.Credentials.References["server_key"], + flags.projectDir, + checkoutServerKeyReference(value), deps, ) if credentialResult != nil { @@ -497,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 { @@ -529,5 +197,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_sandbox_run_test.go b/internal/app/commands_sandbox_run_test.go index 8fc90a0..7432b59 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"]) @@ -374,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 new file mode 100644 index 0000000..f3f4869 --- /dev/null +++ b/internal/app/commands_setup.go @@ -0,0 +1,215 @@ +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" + 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 + 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") + } + if _, err := fmt.Fprintln(output, "\nProposed .midtrans/manifest.yaml changes:"); err != nil { + return manifest.Manifest{}, err + } + for _, preview := range []struct { + field string + value string + }{ + {"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 + } + } + 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/app/commands_status.go b/internal/app/commands_status.go new file mode 100644 index 0000000..f57ded3 --- /dev/null +++ b/internal/app/commands_status.go @@ -0,0 +1,98 @@ +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" +) + +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)...) + serverPresent, serverInvalid := sandboxServerKeyReadiness( + ctx, + flags.projectDir, + checkoutServerKeyReference(value), + deps, + ) + clientPresent := secretPresent( + ctx, + flags.projectDir, + checkoutClientKeyReference(value), + deps, + ) + reachable := readiness.ReachabilityUnknown + if value.Application.BaseURL != "" { + if deps.LocalProbe(ctx, value.Application.BaseURL) { + 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, + ServerKeyInvalid: serverInvalid, + 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") + } +} diff --git a/internal/app/commands_test.go b/internal/app/commands_test.go new file mode 100644 index 0000000..d99beac --- /dev/null +++ b/internal/app/commands_test.go @@ -0,0 +1,1182 @@ +package app_test + +import ( + "bytes" + "context" + "crypto/sha256" + "encoding/hex" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "reflect" + "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" + "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) { + 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 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 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) + } + 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) + } +} + +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"), + "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 + } + }), + }, + "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 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) { + 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 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, method, providerReference, 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) + 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", + 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": route, + "order_id": "order-status", + "provider_reference": providerReference, + "latest_transaction_status": "00", + }, + }, + { + ID: "bisnap.merchant-persistence", + OperationID: operationID, + Stage: "merchant_persistence", + Level: evidence.ProofLocal, + Source: "merchant_application", + ObservedAt: now, + Status: persistenceStatus, + Summary: map[string]any{ + "order_id": "order-status", + "provider_reference": providerReference, + "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) { + 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", + 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"] != "provider_confirmed" || 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( + 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( + 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) + } +} + +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} +} + +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/commands_verify.go b/internal/app/commands_verify.go index c830cba..b23a82d 100644 --- a/internal/app/commands_verify.go +++ b/internal/app/commands_verify.go @@ -2,12 +2,16 @@ 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" ) @@ -17,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 != "" && !manifestRequiresProduct(requiredJourneys, product) { result := contracts.NewIncompatibleResult( "verify", "CAPABILITY_NOT_INSTALLED", @@ -27,90 +36,510 @@ 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) - return writeResult(deps, flags, result) - } - findings := manifest.Validate(value) + requiredJourneys = filterJourneysForProduct(requiredJourneys, deps, product) + + findings := []contracts.Finding{} report, err := inspection.Inspect(flags.projectDir) if err != nil { result := inspectionFailureResult("verify", deps) 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", - }) - } } + + journeyInputs, verifyData, journeyFindings, nextActions, ok := verifyJourneysForProject( + flags.projectDir, + value, + deps, + requiredJourneys, + document, + evidenceFile, + ) + findings = append(findings, journeyFindings...) 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, + Journeys: journeyInputs, }) + if hasFindingCode(findings, "VERIFY_PROOF_POLICY_UNAVAILABLE") { + result.Status = contracts.StatusBlocked + } result.CLIVersion = deps.Version.Version result.ManifestVersion = value.SchemaVersion - result.Packs = []contracts.PackVersion{{ - ID: pack.Descriptor().ID, - Version: pack.Descriptor().Version, - }} + result.Packs = packVersions + result.Data = verifyData + if ok { + result.NextActions = nextActions + } 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", "", "checksummed evidence JSON file", ) - return command + return withProjectMode(command, project.Existing, "verify") +} + +func verifyJourneysForProject( + projectDir string, + value manifest.Manifest, + deps Dependencies, + requiredJourneys []string, + document evidence.Document, + evidencePath string, +) ([]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{} + journeyFindings := []contracts.Finding{} + proofRows := []presentation.VerifyProof{} + + for _, journeyID := range requiredJourneys { + packID := journeyProduct(journeyID, deps) + required, known := compiledProofPolicy(journeyID) + bundle := bundlesByJourney[journeyID] + 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 { + 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 { + 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) { + 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 { + bundle = evidence.Bundle{} + } + } + journeyInputs = append(journeyInputs, verify.Journey{ + ID: journeyID, + Required: required, + Bundle: bundle, + }) + + if status == "" { + 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, + }) + } + + data := presentation.VerifyData{ + ProofState: "", + EvidencePath: evidencePath, + Proofs: proofRows, + Journeys: presentationJourneys, + Products: products, + } + for _, journeyData := range presentationJourneys { + data.ProofState = aggregateProofState(data.ProofState, journeyData.Status) + } + if data.ProofState == "pass" { + data.ProofState = "verified" + } + if data.ProofState == "" { + data.ProofState = "incomplete" + } + return journeyInputs, data, dedupeFindings(journeyFindings), 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 != "" && packID != selectedProduct { + continue + } + if seen[packID] { + continue + } + seen[packID] = true + products = append(products, packID) + } + slices.Sort(products) + 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 { + return handler.Definition().Product + } + product, _, _ := strings.Cut(journeyID, ".") + return product +} + +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 "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}, + {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 + 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.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 + } +} + +func requiredProofMetadataMatches(required []verify.RequiredProof, metadata []evidence.RequiredProof) bool { + if len(metadata) == 0 { + return true + } + if len(required) != len(metadata) { + return false + } + counts := make(map[verify.RequiredProof]int, len(required)) + for _, proof := range required { + 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 true +} + +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 { + status := "missing" + for _, proof := range bundle.Proofs { + if proof.ID == expected.ID && proof.Level == expected.Level && proof.Status == "pass" { + status = "pass" + break + } + } + rows = append(rows, presentation.VerifyProof{ + ID: expected.ID, + Level: string(expected.Level), + Status: status, + }) + } + 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 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, - packID string, - packVersion string, + deps Dependencies, bundle evidence.Bundle, ) (bool, error) { manifestHash, err := projectManifestHash(projectDir) @@ -124,11 +553,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/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) + } +} 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/app/commands_webhook.go b/internal/app/commands_webhook.go index d3d5253..5ee4428 100644 --- a/internal/app/commands_webhook.go +++ b/internal/app/commands_webhook.go @@ -12,8 +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" ) @@ -41,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, @@ -64,11 +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, - value.Credentials.References["server_key"], + flags.projectDir, + reference, deps, ) if credentialResult != nil { @@ -86,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" @@ -109,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, @@ -118,8 +132,100 @@ 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 command + 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 { @@ -153,7 +259,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, @@ -257,7 +363,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/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/journey_runner.go b/internal/app/journey_runner.go new file mode 100644 index 0000000..be4ffa6 --- /dev/null +++ b/internal/app/journey_runner.go @@ -0,0 +1,314 @@ +package app + +import ( + "context" + "fmt" + "strings" + "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 + EvidencePath 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: providerJourneyHTTP(deps, handler.Definition().Product), + LocalHTTP: 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(), + }, + } + 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 + 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, + evidencePath 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, + EvidencePath: evidencePath, + 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 != "" { + 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) + 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 { + proofsVerified := passedOutcomeSatisfiesProofPolicy(definition.ID, outcome) + status := contracts.StatusBlocked + switch outcome.State { + case journeypkg.Passed: + status = contracts.StatusPass + if !proofsVerified { + status = contracts.StatusWarn + } + 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} + } + 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, + } + result.Data = data + for key, value := range outcome.SafeData { + if isReservedJourneyEnvelopeKey(key) { + continue + } + data[key] = value + } + if outcome.Action != nil { + data["action"] = outcome.Action + } + if outcome.State == journeypkg.Passed && !proofsVerified { + 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", + Description: "rerun this journey with --evidence after collecting the missing proof", + }} + } + 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": + return true + default: + return false + } +} + +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/app/journey_runner_test.go b/internal/app/journey_runner_test.go new file mode 100644 index 0000000..528de2d --- /dev/null +++ b/internal/app/journey_runner_test.go @@ -0,0 +1,123 @@ +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{ + bisnapProof("bisnap.notification", evidence.ProofSandbox), + bisnapProof("bisnap.merchant-persistence", evidence.ProofLocal), + }, + }, + ) + + 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) + } +} + +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"}, + } +} 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/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/project_context.go b/internal/app/project_context.go new file mode 100644 index 0000000..620c2fa --- /dev/null +++ b/internal/app/project_context.go @@ -0,0 +1,93 @@ +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 + flags.projectInitialized = resolution.Initialized + 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) +} 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/app/server_key.go b/internal/app/server_key.go index 35b9413..d4df143 100644 --- a/internal/app/server_key.go +++ b/internal/app/server_key.go @@ -12,16 +12,30 @@ 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, - ) + if reference == "" { + result := sandboxServerKeyErrorResult( + command, + manifestVersion, + deps, + secrets.ErrCredentialNotFound, + ) + return secrets.Value{}, &result + } + rawValue, err := deps.ResolveCredential(ctx, projectDir, reference) if err == nil { - return value, nil + value := secrets.NewValue(string(rawValue)) + if _, err := value.SandboxServerKey(); err == nil { + return value, nil + } else { + rawValue = nil + } + } + if err == nil { + err = secrets.ErrSandboxServerKeyRequired } result := sandboxServerKeyErrorResult( command, @@ -32,6 +46,30 @@ func resolveSandboxServerKey( return secrets.Value{}, &result } +func sandboxServerKeyReadiness( + ctx context.Context, + projectDir string, + 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() + rawValue = nil + } + switch { + case err == nil: + return true, false + case errors.Is(err, secrets.ErrMissing), errors.Is(err, secrets.ErrCredentialNotFound): + return false, false + default: + return false, true + } +} + func sandboxServerKeyErrorResult( command string, manifestVersion int, @@ -40,7 +78,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 new file mode 100644 index 0000000..bc06660 --- /dev/null +++ b/internal/app/webhook_test_runner.go @@ -0,0 +1,153 @@ +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, request.ProjectDir, + checkoutServerKeyReference(value), 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.Application.BaseURL) + 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 + } + 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.Application.BaseURL, "/") + 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/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/internal/evidence/evidence_test.go b/internal/evidence/evidence_test.go index 9e2c807..dda4ee4 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 { @@ -264,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", @@ -296,12 +325,43 @@ 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) } + 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) + } + } + 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" @@ -313,6 +373,69 @@ 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 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 { diff --git a/internal/evidence/model.go b/internal/evidence/model.go index 1eb27c9..d119c7d 100644 --- a/internal/evidence/model.go +++ b/internal/evidence/model.go @@ -12,10 +12,19 @@ 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 RequiredProof struct { + ID string `json:"id"` + Level ProofLevel `json:"level"` } type Bundle struct { @@ -24,6 +33,7 @@ type Bundle struct { 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"` @@ -32,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/redact.go b/internal/evidence/redact.go index f60d5c0..c945501 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", @@ -28,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/evidence/store.go b/internal/evidence/store.go index e1279b5..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 { @@ -154,7 +167,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") || @@ -162,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/inspection/detectors.go b/internal/inspection/detectors.go index 282c260..73b029d 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 ClassifyProjectPath(path) == ProjectPathBackend && + 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", @@ -90,5 +99,22 @@ 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, + }) + } return facts } diff --git a/internal/inspection/inspection_test.go b/internal/inspection/inspection_test.go index 36f2233..2bf2025 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) @@ -203,6 +246,80 @@ 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 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/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)) || diff --git a/internal/journey/engine.go b/internal/journey/engine.go new file mode 100644 index 0000000..ac068e1 --- /dev/null +++ b/internal/journey/engine.go @@ -0,0 +1,268 @@ +package journey + +import ( + "context" + "strings" + "time" + "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 interface { + Load(context.Context, string) (operations.Record, bool, error) + Reserve(context.Context, operations.Record) (bool, error) + Save(context.Context, operations.Record) error + } + 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 planned.State != Planned { + return planned + } + if !execute { + return 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", + ) + } + + executed := normalizeOutcome(request.OperationID, handler.Execute(ctx, request, runtime)) + return e.persistWithSave(ctx, runtime, definition, request, initial, 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.persistWithSave(ctx, runtime, definition, request, record, resumed) +} + +func (e Engine) reserveInitial( + ctx context.Context, + runtime Runtime, + definition Definition, + request Request, + outcome Outcome, +) (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, + } + record.StartedAt = record.UpdatedAt + ok, err := e.Store.Reserve(ctx, record) + if err != nil { + return operations.Record{}, false, err + } + 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, + 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, + "JOURNEY_PERSIST_FAILED", + "the journey state could not be stored safely", + ) + } + 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: mergeSafeReferences( + previous.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() + } + 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 { + allSensitive := append(evidence.CoreSensitiveKeys(), sensitiveKeys...) + references := make(map[string]string) + for key, value := range safeData { + stringValue, ok := value.(string) + if !ok || stringValue == "" || isSensitiveKey(key, allSensitive) { + 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..a4ee370 --- /dev/null +++ b/internal/journey/engine_test.go @@ -0,0 +1,408 @@ +package journey_test + +import ( + "context" + "os" + "path/filepath" + "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}, + } + + 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) + } + 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 { + 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) { + 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", + "gross_amount": "10000", + }, + }, + resume: journey.Outcome{ + State: journey.Passed, + SafeData: map[string]any{"order_id": "order-001"}, + }, + } + + 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) + } + 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) + } + 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) { + 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) + } +} + +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 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) + } + if outcome.Finding != nil { + t.Fatalf("unexpected finding = %#v", outcome.Finding) + } + 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 + 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) + 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) + } +} + +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 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 { + 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..6422fc9 --- /dev/null +++ b/internal/journey/types.go @@ -0,0 +1,94 @@ +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"` + 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"` + Reusable bool `json:"reusable,omitempty"` +} + +type Request struct { + OperationID string + ProjectDir string + ManifestHash string + Manifest manifest.Manifest + Evidence evidence.Bundle + 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) + } + LocalHTTP 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/manifest/file.go b/internal/manifest/file.go index 956efd9..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 } @@ -101,3 +96,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..0d54f86 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" @@ -24,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")) @@ -57,6 +58,25 @@ 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 = configuredManifest(value) + 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 { @@ -85,6 +105,289 @@ 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 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", + }, + { + 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"+ @@ -239,22 +542,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", } { @@ -276,8 +590,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) @@ -291,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 { @@ -300,16 +740,65 @@ 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:\\./(?:[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, references, key) + property := schemaObjectAt(t, credentialSets, key) if property["pattern"] != wantPattern { t.Errorf("%s pattern = %#v, want %q", key, property["pattern"], wantPattern) } } } +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{ + 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() @@ -343,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/model.go b/internal/manifest/model.go index 7eb9cc3..0f8ca8f 100644 --- a/internal/manifest/model.go +++ b/internal/manifest/model.go @@ -1,68 +1,124 @@ 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"` + 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"` +} + +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..f712df8 100644 --- a/internal/manifest/validate.go +++ b/internal/manifest/validate.go @@ -10,7 +10,15 @@ 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:\./(?:[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", "client_secret", "partner_id", "channel_id", "device_id", "private_key", "midtrans_public_key"}, +} func Validate(value Manifest) []contracts.Finding { var findings []contracts.Finding @@ -21,68 +29,266 @@ 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", }) } + 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", + 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 + } + } + 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", + }) + } + } } - 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.DeviceID, + set.MerchantID, + set.PrivateKey, + set.MidtransPublicKey, + } +} + +func hasEmptyOrDuplicate(values []string) bool { + if len(values) == 0 { + return true + } + 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 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 "device_id": + return set.DeviceID + case "merchant_id": + return set.MerchantID + 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 && + 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/operations/store.go b/internal/operations/store.go index 8a01b99..8bcf256 100644 --- a/internal/operations/store.go +++ b/internal/operations/store.go @@ -10,19 +10,29 @@ import ( "io" "os" "path/filepath" + "regexp" + "strings" + "time" "github.com/veritrans/midtrans-cli/internal/safepath" ) -const maxRecordBytes = 4 << 10 +const maxRecordBytes = 8 << 10 var errRecordInvalid = errors.New("OPERATION_RECORD_INVALID") +var operationIDPattern = regexp.MustCompile(`^op_[a-z0-9_]+$`) + 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 +41,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 +82,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 +147,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 +207,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 +217,42 @@ func recordRelativePath(orderID string) string { } func validRecord(record Record) bool { - return record.OperationID != "" && - record.OrderID != "" && - record.GrossAmount > 0 && - record.State != "" + if record.SchemaVersion != 1 || + !ValidOperationID(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 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 + } + _, err := hex.DecodeString(value) + return err == nil } diff --git a/internal/operations/store_test.go b/internal/operations/store_test.go index e6e6741..ff1dee6 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,57 @@ 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 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{ + 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/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..4aa03b4 100644 --- a/internal/packs/registry_test.go +++ b/internal/packs/registry_test.go @@ -1,21 +1,29 @@ 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/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()) + registry, err := packs.NewRegistry(common.New(), snap.New(), coreapi.New(), paymentlink.New()) if err != nil { t.Fatal(err) } capabilities := registry.Capabilities() - if len(capabilities) != 4 { + if len(capabilities) != 15 { t.Fatalf("capabilities = %#v", capabilities) } if _, ok := registry.Get("snap"); !ok { @@ -28,7 +36,18 @@ 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.recurring.verify.v1", + "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", "snap.webhook.verify.v1", } @@ -44,8 +63,41 @@ 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(), coreapi.New(), paymentlink.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()) + registry, err := packs.NewRegistry(snap.New(), common.New(), coreapi.New(), paymentlink.New()) if err != nil { t.Fatal(err) } @@ -53,7 +105,18 @@ 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.recurring", + "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", } if got := registry.Journeys(); !reflect.DeepEqual(got, wantJourneys) { t.Fatalf("journeys = %#v, want %#v", got, wantJourneys) @@ -62,7 +125,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", "payment-link@0.1.0", "snap@0.1.0"} versions := registry.Versions() gotVersions := make([]string, 0, len(versions)) for _, version := range versions { @@ -72,3 +135,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/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/internal/presentation/model.go b/internal/presentation/model.go new file mode 100644 index 0000000..c351bf3 --- /dev/null +++ b/internal/presentation/model.go @@ -0,0 +1,473 @@ +// Package presentation builds bounded, command-specific human output models. +package presentation + +import ( + "encoding/json" + "fmt" + "net/url" + "strconv" + "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 +} + +// 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"` + 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) { + switch result.Command { + case "init": + return initModel(result) + 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 + case "capabilities": + 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": + return verifyModel(result) + default: + return Model{}, false + } +} + +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 == "" || + 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 && len(data.Journeys) == 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), + }) + } + 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}) + } + 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"` + 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"` + 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 + } + 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 + } + 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:] +} + +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 new file mode 100644 index 0000000..07443f2 --- /dev/null +++ b/internal/presentation/model_test.go @@ -0,0 +1,193 @@ +package presentation + +import ( + "strings" + "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) + } +} + +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) + } + } +} + +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{ + "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) + } + } +} 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 +} diff --git a/internal/readiness/report.go b/internal/readiness/report.go new file mode 100644 index 0000000..b7d4c07 --- /dev/null +++ b/internal/readiness/report.go @@ -0,0 +1,314 @@ +// Package readiness builds an IO-free, deterministic integration-readiness report. +package readiness + +import ( + "fmt" + "path/filepath" + "regexp" + "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 + ServerKeyInvalid bool + ClientKeyPresent bool + 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 := "" + 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.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.EnabledProducts()), + checkoutCheck(input.Manifest), + webhookCheck(input.Manifest), + localStatusCheck(input.Manifest, manifestFindings), + 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) { + report.Checks = append(report.Checks, Check{ + ID: fmt.Sprintf("pack-finding-%d", index+1), + Label: "Pack finding", + State: findingState(finding.Severity), + Detail: "an installed pack reported a finding", + }) + } + 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") { + 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 { + _, 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 { + _, 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"} +} + +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"} + } + _, 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"} +} + +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"} + } + return Check{ID: id, Label: label, State: Ready, Detail: reference + " is set"} +} + +func validEnvironmentReference(reference string) bool { + return readinessCredentialReferencePattern.MatchString(reference) +} + +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..ce2181c --- /dev/null +++ b/internal/readiness/report_test.go @@ -0,0 +1,166 @@ +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 := readySnapManifest() + + 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 := readySnapManifest() + credentials := value.CredentialSets["classic"] + credentials.ServerKey = "not-an-environment-reference" + value.CredentialSets["classic"] = credentials + + 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: "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"}, + }, + }) + + 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-1", "pack-finding-2", + } + 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()) + } + 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) + } + if bytes.Contains(encoded, []byte("SB-Mid")) { + t.Fatalf("report contains a credential: %s", encoded) + } +} + +func TestBuildPassesOnlyWhenEveryCheckIsReady(t *testing.T) { + value := readySnapManifest() + + 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) +} + +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/render/render.go b/internal/render/render.go index 26b4d9e..111f0f0 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,8 +29,14 @@ 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) +} - if _, err := fmt.Fprintf(w, "%s: %s\n", strings.ToUpper(string(result.Status)), result.Command); err != nil { +func writeGenericHuman(w io.Writer, result contracts.Result) error { + if _, err := fmt.Fprintf(w, "%s (%s)\n", result.Command, strings.ToUpper(string(result.Status))); err != nil { return err } for _, finding := range result.Findings { @@ -37,10 +44,54 @@ func Write(w io.Writer, result contracts.Result, format Format) 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 } } 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) 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/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) + } +} diff --git a/internal/sourceprovenance/baseline.go b/internal/sourceprovenance/baseline.go index 1d1403c..894b210 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"` @@ -83,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, 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/html, text/plain;q=0.9") + 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) @@ -111,23 +118,57 @@ 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")) - 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 13391ea..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) { @@ -44,7 +81,7 @@ func TestFetchCanonicalizesCloudflareEmailProtection(t *testing.T) { } _, _ = fmt.Fprintf( w, - `protected`, + `protected`, encoded, ) })) @@ -141,3 +178,34 @@ func TestChangedSourceIDsDoesNotExposeDigests(t *testing.T) { t.Fatalf("ChangedSourceIDs = %#v, want [source-a]", got) } } + +func TestAllPublicSourcesIncludesPackEntries(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) + } + } + 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 new file mode 100644 index 0000000..57aecfa --- /dev/null +++ b/internal/sourceprovenance/catalog.go @@ -0,0 +1,21 @@ +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/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 { + 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...) + sources = append(sources, gopaytokenization.New().Descriptor().Sources...) + sources = append(sources, subscription.New().Descriptor().Sources...) + return sources +} 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/packs/bisnap/client.go b/packs/bisnap/client.go new file mode 100644 index 0000000..7c8820b --- /dev/null +++ b/packs/bisnap/client.go @@ -0,0 +1,474 @@ +package bisnap + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "io" + "net/http" + "strconv" + "strings" + "time" + + "github.com/veritrans/midtrans-cli/internal/sandbox" +) + +type Request struct { + Method string + Path string + AccessToken string + CustomerToken string + DeviceID string + Body []byte + UseApplicationHost bool +} + +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) +} + +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 + QRArtifactKind 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) + 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 + } + deviceID := strings.TrimSpace(input.DeviceID) + if deviceID == "" { + deviceID = strings.TrimSpace(c.DeviceID) + } + if deviceID == "" { + 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) + request.Header.Set("X-DEVICE-ID", deviceID) + 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 + } + for _, r := range value { + if r < '0' || r > '9' { + return errRequestInvalid + } + } + 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"` + QRURL string `json:"qrUrl"` + QRImage string `json:"qrImage"` + QRContent string `json:"qrContent"` + 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, + QRArtifactKind: preferredQRArtifactKind(result.QRURL, result.QRImage, result.QRContent), + }, 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() +} + +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/client_test.go b/packs/bisnap/client_test.go new file mode 100644 index 0000000..03e0924 --- /dev/null +++ b/packs/bisnap/client_test.go @@ -0,0 +1,196 @@ +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", + DeviceID: deviceIDCanary, + 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", + DeviceID: deviceIDCanary, + 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("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) + } + 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", + DeviceID: deviceIDCanary, + 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) + } + 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 { + return time.Date(2026, 7, 27, 8, 9, 10, 0, time.FixedZone("WIB", 7*60*60)) +} 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 new file mode 100644 index 0000000..62a6b02 --- /dev/null +++ b/packs/bisnap/endpoints.go @@ -0,0 +1,15 @@ +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" + 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..b56271f --- /dev/null +++ b/packs/bisnap/journey.go @@ -0,0 +1,683 @@ +package bisnap + +import ( + "context" + "crypto/sha256" + "errors" + "fmt" + "strconv" + "strings" + "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" +) + +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 NewRecurringHandler() Handler { return newHandler("bisnap.recurring", "recurring") } +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 == "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") + } + runner, outcome := h.runtimeRunner(ctx, request, runtime) + if outcome != nil { + return *outcome + } + 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: + 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 evaluateVerifiedStatus(request, 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 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() } + } + 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": + if created.QRArtifactKind != "" { + safeData["qr_artifact_kind"] = created.QRArtifactKind + safeData["qr_artifact_reference"] = "provider_generated" + } + 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") + } + 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) + if !ok { + return base + } + return journey.Outcome{ + State: journey.Passed, + SafeData: base.SafeData, + Proofs: proofs, + } +} + +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 == "" { + 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"] != status.OrderID { + return nil, false + } + expectedRoute := expectedNotificationRoute(request.Input.Method) + if expectedRoute == "" { + return nil, false + } + var notificationProof *evidence.Proof + var persistenceProof *evidence.Proof + for _, proof := range bundle.Proofs { + if proof.OperationID != request.OperationID { + continue + } + switch proof.ID { + 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" { + proofCopy := proof + persistenceProof = &proofCopy + } else { + return nil, false + } + } + } + if notificationProof == nil || persistenceProof == nil { + return nil, false + } + 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": + 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 + } + 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) +} + +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 new file mode 100644 index 0000000..a4e9b77 --- /dev/null +++ b/packs/bisnap/journey_test.go @@ -0,0 +1,964 @@ +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" +) + +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 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 + + 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 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 + + 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]) + } +} + +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, "qris", "provider-status-001", "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, "qris", "provider-status-001", "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, "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", + }, + }, 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) } + +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 { + 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) { + 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), + } +} + +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, 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", + 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": route, + "order_id": "order-status", + "provider_reference": providerReference, + "latest_transaction_status": "00", + }, + }, + { + ID: "bisnap.merchant-persistence", + OperationID: "operation-status", + Stage: "merchant_persistence", + Level: evidence.ProofLocal, + Source: "merchant_application", + ObservedAt: now, + Status: persistenceStatus, + Summary: map[string]any{ + "order_id": "order-status", + "provider_reference": providerReference, + "payment_status": "paid", + }, + }, + }, + } +} + +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/notification.go b/packs/bisnap/notification.go new file mode 100644 index 0000000..3fdfdce --- /dev/null +++ b/packs/bisnap/notification.go @@ -0,0 +1,65 @@ +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", + SuccessCode: "2008800", + FailureCode: "4018800", + }, +} + +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..97e4b47 --- /dev/null +++ b/packs/bisnap/notification_test.go @@ -0,0 +1,130 @@ +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) + } + + 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) { + 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/pack.go b/packs/bisnap/pack.go new file mode 100644 index 0000000..8a55a9e --- /dev/null +++ b/packs/bisnap/pack.go @@ -0,0 +1,84 @@ +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.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"}, + }, + Journeys: []string{ + "bisnap.qris-payment", + "bisnap.virtual-account", + "bisnap.direct-debit", + "bisnap.recurring", + "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/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.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"}}, + }, + } +} + +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(), + NewRecurringHandler(), + NewStatusHandler(), + NewRefundHandler(), + } +} diff --git a/packs/bisnap/pack_test.go b/packs/bisnap/pack_test.go new file mode 100644 index 0000000..cf56cae --- /dev/null +++ b/packs/bisnap/pack_test.go @@ -0,0 +1,47 @@ +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.recurring.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.recurring", + "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/signature.go b/packs/bisnap/signature.go new file mode 100644 index 0000000..e9153c0 --- /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 base64.StdEncoding.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..c509f97 --- /dev/null +++ b/packs/bisnap/signature_test.go @@ -0,0 +1,154 @@ +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" + deviceIDCanary = "Mozilla/5.0 (X11; Linux x86_64) DEVICE-CANARY-DO-NOT-PRINT" + + wantAccessTokenSignature = "X8ozgLWxhdK8nP4YnkNcJHhXObRBeExU68M+JYyAHVaGN8Qcq2T5DeIYchvXmfNz88PWEWWZCusR5tH2jwkIexTn01UOpReEW4oJfHMDv8attospztZhq3HOjg6xGPcmN4+vaeJcRW5FkXe0tPIXg2UdC5Xzuh/Qx8NPLt6g2mH3UbR6jVhziE5oU8TpR3EIYZhmBm5CgFneCF+e9GR1xb48W7/4LgbdjAXl9XP/ViPLK8XBBYNbxPA1aV23KFgkwwVa3hFQBkbRExRQlwi2ykDV683LsLRhci9hS2ujrvyBrINGUW5gft9FdSNJTRMbpiNBL5rBIjRRz8RnkS09ng==" + wantTransactionSignature = "lZJRRB5ATygmqgtcevrAHWlX3pAGG5vwZrFQzW/cQfBf5tbABYqxkRLhF1wzwbcjgv46V73WglvuoePXbXMBpQ==" + 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/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/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/coreapi/client.go b/packs/coreapi/client.go new file mode 100644 index 0000000..9e5435d --- /dev/null +++ b/packs/coreapi/client.go @@ -0,0 +1,403 @@ +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"` + TransactionID string `json:"transaction_id,omitempty"` + 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"` + TransactionID string `json:"transaction_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, + TransactionID: result.TransactionID, + 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..f774b6f --- /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..68e1ef1 --- /dev/null +++ b/packs/coreapi/journey.go @@ -0,0 +1,539 @@ +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" + "github.com/veritrans/midtrans-cli/internal/secrets" +) + +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 + runnerOverride bool +} + +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 NewRecurringHandler() Handler { + return newHandler("core-api.recurring", "recurring") +} + +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"}, + }, + } +} + +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 { + 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, 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 == "" || request.Input.Amount <= 0 { + return inputRequired("order_id and amount are required") + } + 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") + } + case "otc": + if request.Input.Method == "" { + request.Input.Method = "alfamart" + } + case "virtual-account": + if request.Input.Method == "" { + request.Input.Method = "bni" + } + } + runner, tokenID, outcome := h.runtimeRunner(ctx, request, runtime) + 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") + } + status, err := runner.Status.Status(ctx, request.Input.OrderID) + if err == nil && !status.NotFound { + return h.evaluateStatus(status) + } + + response, err := runner.Charge.Charge(ctx, ChargeRequest{ + OperationID: request.OperationID, + OrderID: request.Input.OrderID, + GrossAmount: request.Input.Amount, + Method: h.definition.Intent, + TokenID: tokenID, + Bank: request.Input.Method, + Store: request.Input.Method, + }) + if err != nil { + var ambiguous sandbox.AmbiguousOperationError + if errors.As(err, &ambiguous) { + reconciled, statusErr := 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, 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") + } + runner, _, outcome := h.runtimeRunner(ctx, request, runtime) + if outcome != nil { + return *outcome + } + if runner.Refund == nil { + return blockedOutcome("refund execution is unavailable") + } + response, err := 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, + 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 != "" { + 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", + ExpiresAt: now().Add(15 * time.Minute), + 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 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, + 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" || 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 { + 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/coreapi/journey_test.go b/packs/coreapi/journey_test.go new file mode 100644 index 0000000..dd58f9a --- /dev/null +++ b/packs/coreapi/journey_test.go @@ -0,0 +1,485 @@ +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" + "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 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) { + 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) { + statusCalls := 0 + chargeCalls := 0 + handler := coreapi.NewCard3DSHandler().WithRunner(coreapi.JourneyRunner{ + Charge: stubCharge(func(context.Context, coreapi.ChargeRequest) (coreapi.ChargeResponse, error) { + 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", + 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) + } + if chargeCalls != 1 || statusCalls != 2 { + t.Fatalf("chargeCalls = %d, statusCalls = %d", chargeCalls, statusCalls) + } +} + +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) { + 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 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 +} + +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 { + 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..b455ac0 --- /dev/null +++ b/packs/coreapi/notification.go @@ -0,0 +1,57 @@ +package coreapi + +import ( + "bytes" + "crypto/sha512" + "crypto/subtle" + "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..7e00a80 --- /dev/null +++ b/packs/coreapi/pack.go @@ -0,0 +1,82 @@ +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.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"}, + }, + Journeys: []string{ + "core-api.card-3ds", + "core-api.saved-card", + "core-api.installment", + "core-api.otc", + "core-api.recurring", + "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", "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.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", "coreapi.recurring.notification", "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(), + NewRecurringHandler(), + NewVirtualAccountHandler(), + NewRefundHandler(), + } +} diff --git a/packs/coreapi/pack_test.go b/packs/coreapi/pack_test.go new file mode 100644 index 0000000..cf3574f --- /dev/null +++ b/packs/coreapi/pack_test.go @@ -0,0 +1,67 @@ +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.recurring.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.recurring", + "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/packs/gopaytokenization/client.go b/packs/gopaytokenization/client.go new file mode 100644 index 0000000..63332d4 --- /dev/null +++ b/packs/gopaytokenization/client.go @@ -0,0 +1,376 @@ +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 = "/v1.0/get-auth-code" + 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 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 + NewExternalID func() (string, error) +} + +type GetAuthCodeInput struct { + StateHash string + MerchantID string + RedirectURL string + MobileNumber string + Scopes []string + Lang string +} + +type BindingRequestInput struct { + AccessToken string + MerchantID string + AuthCode string +} + +type InquiryRequestInput struct { + AccessToken string + CustomerToken string +} + +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"` + AccessTokenInfo BindingAccessTokenInfo `json:"accessTokenInfo"` +} + +type BindingAccessTokenInfo struct { + AccessToken string `json:"accessToken"` +} + +type InquiryResponse struct { + ResponseCode string `json:"responseCode"` + ResponseMessage string `json:"responseMessage"` + AdditionalInfo InquiryAdditionalInfo `json:"additionalInfo"` +} + +type InquiryAdditionalInfo struct { + AccessToken string `json:"accessToken"` + PaymentOptions []PaymentOption `json:"paymentOptions"` +} + +type PaymentOption struct { + Name string `json:"name"` + Active bool `json:"active"` + Token string `json:"token"` +} + +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.MerchantID) == "" || strings.TrimSpace(input.RedirectURL) == "" || strings.TrimSpace(input.MobileNumber) == "" { + return nil, bisnapRequestInvalid() + } + if len(c.PrivateKeyPEM) == 0 { + return nil, bisnapRequestInvalid() + } + 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, ",")) + lang := strings.TrimSpace(input.Lang) + if lang == "" { + lang = "en" + } + values.Set("lang", lang) + values.Set("seamlessData", seamlessData) + values.Set("seamlessSign", seamlessSign) + 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{ + "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, false) +} + +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, false) +} + +func (c Client) NewUnbindRequest(ctx context.Context, input UnbindRequestInput) (*http.Request, error) { + 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, false) +} + +func (c Client) NewPaymentRequest(ctx context.Context, input PaymentRequestInput) (*http.Request, error) { + 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() + } + 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..96d1832 --- /dev/null +++ b/packs/gopaytokenization/client_test.go @@ -0,0 +1,228 @@ +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", + 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) + } + if authCodeRequest.Method != http.MethodGet { + t.Fatalf("auth code method = %q", authCodeRequest.Method) + } + 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) + } + query := authCodeRequest.URL.Query() + 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) + } + 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) + } + if got := query.Get("seamlessData"); got != "mobileNumber=08123456789&paymentType=gopay" { + t.Fatalf("seamlessData = %q", got) + } + 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.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", + CustomerToken: "CUSTOMER-TOKEN-CANARY-DO-NOT-PRINT", + }) + if err != nil { + t.Fatal(err) + } + 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" { + t.Fatalf("inquiry Authorization-Customer = %q", got) + } + + 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.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) + } + 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) + } + 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 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) { + 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) + } +} + +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..17b0309 --- /dev/null +++ b/packs/gopaytokenization/journey.go @@ -0,0 +1,809 @@ +package gopaytokenization + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "errors" + "strconv" + "strings" + "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" +) + +type JourneyRunner struct { + Client Client + Now func() time.Time + ResolveCredential func(context.Context, string, string) ([]byte, error) +} + +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 NewRecurringHandler() Handler { + return newHandler("gopay-tokenization.recurring", "recurring") +} +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 { + 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, + } + 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") + } + credentials, ok := request.Manifest.CredentialSetFor(integration.Credentials) + 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") + } + rawPrivateKey, err := runtime.ResolveCredential(ctx, request.ProjectDir, credentials.PrivateKey) + if err != nil { + 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{ + PrivateKeyPEM: append([]byte(nil), rawPrivateKey...), + }, + }, 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"], request.Input.PaymentTokenReference) { + 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.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") + } + 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 { + 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) + mobileNumber, outcome := resolveMobileNumber(ctx, request, runner) + if outcome != nil { + return *outcome + } + authRequest, err := runner.Client.NewGetAuthCodeRequest(ctx, GetAuthCodeInput{ + 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") + } + return journey.Outcome{ + State: journey.AwaitingUserAction, + SafeData: map[string]any{ + "order_id": request.Input.OrderID, + "state_hash": stateHash, + }, + 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"], 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") + } + 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, + MerchantID: merchantID, + 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"], + "response_code": response.ResponseCode, + "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 { + 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.AdditionalInfo.PaymentOptions)), + "rotated_token_present": strconv.FormatBool(strings.TrimSpace(inquiry.AdditionalInfo.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.AdditionalInfo.PaymentOptions, optionType) + if !ok { + return blockedOutcome("the requested payment option is not active") + } + customerTokenForPayment := strings.TrimSpace(inquiry.AdditionalInfo.AccessToken) + if customerTokenForPayment == "" { + 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.Token, + PaymentOptionType: optionType, + RedirectURL: redirectURL, + }) + 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) 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 { + 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 { + 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, + }, + 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.MerchantID == "" || 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 + } + 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") + 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)), + MerchantID: strings.TrimSpace(string(rawMerchantID)), + 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) { + 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, request.Input.PaymentTokenReference) + 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.Name, optionType) && option.Active { + return option, true + } + } + 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[:]) +} + +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, 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.Stage != "account_linking_return" || proof.Status != "pass" || proof.Source != "merchant_application" { + continue + } + if summaryString(proof.Summary, "state_hash") == expectedStateHash && + 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 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://") { + return route + } + if route == "" { + return base + } + return base + route +} + +func summaryString(summary map[string]any, key string) string { + if summary == nil { + return "" + } + value, _ := summary[key].(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, + 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..00fff1b --- /dev/null +++ b/packs/gopaytokenization/journey_test.go @@ -0,0 +1,653 @@ +package gopaytokenization_test + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "io" + "net/http" + "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/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, "") + request.Input.MobileNumberReference = "env:MIDTRANS_GOPAY_MOBILE_NUMBER" + + 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 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) + } + 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), + }, + 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 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), + 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() + 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) { + 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", + "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, `{ + "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) + } + 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", "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() + 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) { + 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} + ]} + }`), 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 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, "") + 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", + 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", + MerchantID: "env:MIDTRANS_GOPAY_MERCHANT_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 "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": + 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 + } + } +} + +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) { + 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) +} + +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 new file mode 100644 index 0000000..2883972 --- /dev/null +++ b/packs/gopaytokenization/pack.go @@ -0,0 +1,96 @@ +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.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"}, + }, + Journeys: []string{ + "gopay-tokenization.account-linking", + "gopay-tokenization.binding-inquiry", + "gopay-tokenization.recurring", + "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-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", "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", "gopaytokenization.recurring.notification", "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(), + NewRecurringHandler(), + NewWalletPaymentHandler(), + NewPayLaterHandler(), + NewUnlinkHandler(), + } +} diff --git a/packs/gopaytokenization/pack_test.go b/packs/gopaytokenization/pack_test.go new file mode 100644 index 0000000..84cd196 --- /dev/null +++ b/packs/gopaytokenization/pack_test.go @@ -0,0 +1,38 @@ +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.recurring.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.recurring", + "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..c7feab2 --- /dev/null +++ b/packs/gopaytokenization/seamless_test.go @@ -0,0 +1,69 @@ +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{ + ResolveCredential: gopayResolveCredential(t), + Now: fixedNow, + NewOperationID: func() string { return "op_gopay_persist" }, + SensitiveKeys: []string{ + "auth_code", + "customer_authorization_token", + "payment_option_token", + "authorization_reference", + }, + }, + } + + 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) + } + + 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", + "auth_code_ref", + "customer_token_ref", + "payment_token_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/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 + } +} 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/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..33a0622 --- /dev/null +++ b/packs/paymentlink/journey.go @@ -0,0 +1,323 @@ +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, 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") + } + 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") + } + + statusKey := statusLookupKey(request, record) + status, err := runner.Status.Status(ctx, statusKey) + if err == nil && !status.NotFound { + return h.evaluateStatus(request, record, 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, + 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) + 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 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 + } + 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..dce6eff --- /dev/null +++ b/packs/paymentlink/journey_test.go @@ -0,0 +1,349 @@ +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{ + 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) + } + if resumed.SafeData["transaction_id"] != "trx-payment-link-002" || + 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) { + 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/packs/snap/journey.go b/packs/snap/journey.go index 033dfd8..081c343 100644 --- a/packs/snap/journey.go +++ b/packs/snap/journey.go @@ -3,16 +3,19 @@ package snap import ( "context" "errors" + "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" ) -const createStartedState = "create_started" - var ( errJourneyInvalid = errors.New("SANDBOX_JOURNEY_INVALID") errJourneyStatus = errors.New("SANDBOX_STATUS_FAILED") @@ -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, @@ -134,6 +148,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 @@ -155,138 +173,276 @@ func (r JourneyRunner) Run( ctx context.Context, input JourneyInput, ) (JourneyResult, error) { - base := JourneyResult{ - 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 == "" || - 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.OrderID) - 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 { + input := h.inputForRequest(request) + h.lastErr = nil + h.lastResult = JourneyResult{ + OperationID: request.OperationID, + State: JourneyPlanned, + OrderID: input.OrderID, + } + h.lastOutcome = genericjourney.Outcome{ + State: genericjourney.Planned, + SafeData: map[string]any{ + "order_id": input.OrderID, + "gross_amount": strconv.FormatInt(input.GrossAmount, 10), + }, } + return h.lastOutcome +} + +func (h *compatibilityHandler) Execute( + ctx context.Context, + request genericjourney.Request, + runtime genericjourney.Runtime, +) genericjourney.Outcome { + return h.executeOrResume(ctx, request, runtime, operations.Record{}) +} - started := operations.Record{ - OperationID: input.OperationID, +func (h *compatibilityHandler) Resume( + ctx context.Context, + request genericjourney.Request, + runtime genericjourney.Runtime, + record operations.Record, +) genericjourney.Outcome { + 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.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 + input.GrossAmountString = record.SafeReferences["gross_amount"] + ".00" + input.Plan, _ = CheckoutPlan(input.OrderID, input.GrossAmount) + } + } + base := JourneyResult{ + OperationID: request.OperationID, OrderID: input.OrderID, - GrossAmount: input.GrossAmount, - State: createStartedState, } - reserved, err := r.Ledger.Reserve(ctx, started) + if request.OperationID == "" || + !operations.ValidOperationID(request.OperationID) || + 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(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 := runner.Status.Status(ctx, input.OrderID) if err != nil { - base.State = JourneyBlocked - return base, errJourneyLedger - } - if !reserved { - record, found, err = r.Ledger.Load(ctx, input.OrderID) - if err != nil || !found { - 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, 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": input.OrderID}, + } + } + h.lastResult = JourneyResult{ + OperationID: request.OperationID, + State: JourneyBlocked, + OrderID: input.OrderID, + NextActions: []contracts.NextAction{reusePreviousCheckoutAction()}, + } + return genericjourney.Outcome{ + State: genericjourney.Blocked, + SafeData: map[string]any{"order_id": input.OrderID}, } - return existingOperationResult(base, record), nil } - created, err := r.Tokens.CreateToken(ctx, CreateTokenRequest{ - OperationID: input.OperationID, + created, err := runner.Tokens.CreateToken(ctx, CreateTokenRequest{ + OperationID: request.OperationID, OrderID: input.OrderID, GrossAmount: 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 := runner.Status.Status(ctx, 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": input.OrderID}, + } } - return r.evaluateStatus(ctx, input, reconciled) - } - - accepted := started - accepted.State = string(JourneyCheckoutRequired) - if err := r.Ledger.Save(ctx, accepted); err != nil { - base.State = JourneyAmbiguous - return base, nil - } - 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 + return h.evaluateStatus(ctx, request.OperationID, input, runner, reconciled) + } + + h.lastResult = JourneyResult{ + OperationID: request.OperationID, + State: JourneyCheckoutRequired, + OrderID: 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": input.OrderID, + "gross_amount": strconv.FormatInt(input.GrossAmount, 10), + }, + Action: &genericjourney.Action{ + Type: "browser", + URL: created.RedirectURL, + Instructions: "complete the hosted Snap sandbox checkout and rerun this journey", + ResumeCommand: "midtrans test checkout --execute", + }, + } } -func (r JourneyRunner) evaluateStatus( +func (h *compatibilityHandler) evaluateStatus( ctx context.Context, + operationID string, input JourneyInput, + runner JourneyRunner, status StatusResponse, -) (JourneyResult, error) { +) genericjourney.Outcome { result := JourneyResult{ - OrderID: input.OrderID, - Provider: status, + OperationID: operationID, + OrderID: input.OrderID, + Provider: status, } if status.OrderID != input.OrderID { - result.State = JourneyBlocked - return result, errJourneyStatus + 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": 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": 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": 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": input.OrderID}, + } } - local, err := r.Local.VerifyLocal(ctx, LocalVerificationInput{ + local, err := runner.Local.VerifyLocal(ctx, LocalVerificationInput{ OrderID: input.OrderID, GrossAmount: input.GrossAmountString, }) @@ -297,10 +453,75 @@ 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": input.OrderID}, + } } result.State = JourneyVerified - return result, nil + h.lastResult = result + return genericjourney.Outcome{ + State: genericjourney.Passed, + SafeData: map[string]any{ + "order_id": input.OrderID, + "gross_amount": strconv.FormatInt(input.GrossAmount, 10), + }, + } +} + +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.inputForRequest(genericjourney.Request{Input: genericjourney.Input{OrderID: h.input.OrderID}}).OrderID, + } +} + +func (h *compatibilityHandler) resultError(outcome genericjourney.Outcome) error { + if h.lastErr != nil { + return h.lastErr + } + 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 reusePreviousCheckoutAction() contracts.NextAction { @@ -311,19 +532,66 @@ func reusePreviousCheckoutAction() contracts.NextAction { } } -func existingOperationResult( - result JourneyResult, - record operations.Record, -) JourneyResult { - if record.State == createStartedState { - result.State = JourneyAmbiguous - return result - } - result.State = JourneyBlocked - if record.State == string(JourneyCheckoutRequired) { - result.NextActions = []contracts.NextAction{ - reusePreviousCheckoutAction(), - } +func (h *compatibilityHandler) inputForRequest(request genericjourney.Request) JourneyInput { + if h.input.OrderID != "" || h.input.GrossAmount != 0 || h.input.ManifestHash != "" { + return h.input } - return result + 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 + } + 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(localHTTP), + }, + }, 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 } diff --git a/packs/snap/journey_test.go b/packs/snap/journey_test.go index 4e4b048..472edca 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 { @@ -72,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 } @@ -253,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) { @@ -294,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, @@ -316,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 @@ -343,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(), ) } @@ -355,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, @@ -377,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) } } @@ -408,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) @@ -673,6 +676,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", @@ -681,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/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..decd5ce 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/mobile.go b/packs/snap/mobile.go new file mode 100644 index 0000000..f82de06 --- /dev/null +++ b/packs/snap/mobile.go @@ -0,0 +1,212 @@ +package snap + +import ( + "context" + "net/http" + + "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{} +} + +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{ + 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 (h 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 + tokenCreateBackend := false + for _, fact := range report.Facts { + switch fact.Kind { + case "midtrans.server-key-reference": + switch inspection.ClassifyProjectPath(fact.Path) { + case inspection.ProjectPathBackend: + serverKeyBackend = true + default: + serverKeyMobile = true + } + case "midtrans.snap-token-create": + if inspection.ClassifyProjectPath(fact.Path) == inspection.ProjectPathBackend { + tokenCreateBackend = 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 !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") + } + 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 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") + } + 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", + }, + } + } + 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", + }, + } +} + +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..5ef3544 --- /dev/null +++ b/packs/snap/mobile_test.go @@ -0,0 +1,322 @@ +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() + 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", + 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) != 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_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 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{ + "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" + 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 579876f..3b5229f 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" ) @@ -21,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", }, @@ -36,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", @@ -47,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"}, }, }, } @@ -57,50 +75,63 @@ 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") && + !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: "checkout_modes must include redirect or popup", + Message: "integrations.snap.profiles must include web-redirect, web-popup, web-embed, or mobile-webview", }) } - if value.Integration.LocalStatusRoute == "" { + 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"] == "" { 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", @@ -108,3 +139,7 @@ func (Pack) Evaluate(value manifest.Manifest, report inspection.Report) []contra } return findings } + +func (Pack) Handlers() []journey.Handler { + return []journey.Handler{NewJourneyHandler(), NewMobileHandler()} +} diff --git a/packs/snap/pack_test.go b/packs/snap/pack_test.go index 6600aab..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 { @@ -63,17 +68,54 @@ 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) } } +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.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 +126,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 +162,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/packs/subscription/client.go b/packs/subscription/client.go new file mode 100644 index 0000000..b1dc5e7 --- /dev/null +++ b/packs/subscription/client.go @@ -0,0 +1,318 @@ +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 CreateRequest struct { + OperationID string + Name string + Amount string + Token string + Schedule Schedule +} + +type UpdateRequest struct { + OperationID string + SubscriptionID string + Name string + Amount string + Token string + Currency 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 AcknowledgementResponse struct { + StatusMessage string `json:"status_message"` +} + +type MutationRequest struct { + OperationID string + SubscriptionID string +} + +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 UpdateRequest) (AcknowledgementResponse, error) { + if c.HTTP == nil || strings.TrimSpace(input.OperationID) == "" || strings.TrimSpace(input.SubscriptionID) == "" { + return AcknowledgementResponse{}, errors.New("SANDBOX_REQUEST_INVALID") + } + 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, + "name": input.Name, + "amount": input.Amount, + } + if input.Schedule.Interval > 0 { + payload["schedule"] = map[string]any{"interval": input.Schedule.Interval} + } + 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.OperationID, + encoded, + ) +} + +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") + } + response, err := c.send(ctx, http.MethodGet, subscriptionSandboxURL+"/"+url.PathEscape(strings.TrimSpace(subscriptionID)), nil) + if err != nil { + return SubscriptionResponse{}, err + } + return decodeSubscriptionResponse(response, subscriptionID, "subscription.get") +} + +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) (AcknowledgementResponse, error) { + return c.mutate(ctx, "enable", "subscription.enable", input) +} + +func (c Client) Cancel(ctx context.Context, input MutationRequest) (AcknowledgementResponse, error) { + return c.mutate(ctx, "cancel", "subscription.cancel", input) +} + +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") + } + 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 + } + 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 AcknowledgementResponse{}, err + } + 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 nil, sandbox.AmbiguousOperationError{ + OperationID: operationID, + Cause: errors.New("sandbox request transport failed"), + } + } + return nil, errors.New("sandbox request transport failed") + } + return response, nil +} + +func (c Client) send(ctx context.Context, method, requestURL string, payload []byte) (*http.Response, error) { + serverKey, err := c.ServerKey.SandboxServerKey() + if err != nil { + return nil, err + } + var body io.Reader + if payload != nil { + body = bytes.NewReader(payload) + } + request, err := http.NewRequestWithContext(ctx, method, requestURL, body) + if err != nil { + return nil, errors.New("SANDBOX_REQUEST_INVALID") + } + if payload != nil { + request.Header.Set("Content-Type", "application/json") + } + request.SetBasicAuth(serverKey, "") + 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, + } + } + 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) { + 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..0abedf8 --- /dev/null +++ b/packs/subscription/client_test.go @@ -0,0 +1,319 @@ +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 TestClientCreateUsesFixedSandboxHostBasicAuthAndDocumentedPayload(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.CreateRequest{ + 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 string `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 TestClientUpdateUsesExactPatchBodyWithoutCreateOnlyFields(t *testing.T) { + doer := &subscriptionRecordingDoer{ + 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), + } + + got, err := client.Update(context.Background(), subscription.UpdateRequest{ + OperationID: "op_subscription_update", + 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) + } + 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 payload["name"] != "merchant-order-002" || payload["amount"] != "25000" || payload["currency"] != "IDR" || payload["token"] != "saved-token-123" { + t.Fatalf("payload = %#v", payload) + } + 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) + } + } + if got.StatusMessage != "Subscription is updated." { + t.Fatalf("ack = %#v", got) + } +} + +func TestClientUpdateRequiresAtLeastOneMutableField(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(), 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) + } + 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) { + 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) + } + }) + } +} + +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..d347382 --- /dev/null +++ b/packs/subscription/journey.go @@ -0,0 +1,497 @@ +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, CreateRequest) (SubscriptionResponse, error) +} + +type Updater interface { + Update(context.Context, UpdateRequest) (AcknowledgementResponse, error) +} + +type Getter interface { + Get(context.Context, string) (SubscriptionResponse, error) +} + +type Disabler interface { + Disable(context.Context, MutationRequest) (AcknowledgementResponse, error) +} + +type Enabler interface { + Enable(context.Context, MutationRequest) (AcknowledgementResponse, error) +} + +type Canceler interface { + Cancel(context.Context, MutationRequest) (AcknowledgementResponse, 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", "payment_token_reference"} + } + 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": + isUpdate := strings.TrimSpace(request.Input.SubscriptionID) != "" + if isUpdate { + if strings.TrimSpace(request.Input.PaymentTokenReference) == "" { + return inputRequired("subscription updates require payment_token_reference") + } + 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) == "" { + 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) == "" { + 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) == "" { + 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": + if strings.TrimSpace(request.Input.SubscriptionID) != "" { + return h.runUpdate(ctx, request, runner, token) + } + return h.runCreate(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) 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) + } + return evaluateStatus(response) +} + +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") + } + _, 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.reconcileAmbiguousUpdate(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 !updateMatchesRequestedFields(request, verified) { + return journey.Outcome{State: journey.Reconciling, SafeData: safeDataForRequest(request)} + } + 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 journey.Outcome{State: journey.Reconciling, SafeData: safeDataForRequest(request)} +} + +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") + } + 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 !updateMatchesRequestedFields(request, reconciled) { + 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 { + 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 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 ack AcknowledgementResponse + switch h.definition.ID { + case "subscription.disable": + if runner.Disable == nil { + return blockedOutcome("subscription disable is unavailable") + } + ack, err = runner.Disable.Disable(ctx, input) + case "subscription.enable": + if runner.Enable == nil { + return blockedOutcome("subscription enable is unavailable") + } + ack, err = runner.Enable.Enable(ctx, input) + case "subscription.cancel": + if runner.Cancel == nil { + return blockedOutcome("subscription cancel is unavailable") + } + ack, err = runner.Cancel.Cancel(ctx, input) + } + if err != nil { + return h.reconcileAmbiguousMutation(ctx, request, runner, err, target) + } + 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 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 { + 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 { + 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 amountString(value int64) string { + if value <= 0 { + return "" + } + 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, + 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..a38014d --- /dev/null +++ b/packs/subscription/journey_test.go @@ -0,0 +1,638 @@ +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 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++ + 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: 3, + 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", + ProjectDir: "/merchant", + ManifestHash: strings.Repeat("a", 64), + Manifest: validSubscriptionManifest(), + Input: journeypkg.Input{ + SubscriptionID: "sub-123", + OrderID: "merchant-order-002", + Amount: 25000, + ScheduleInterval: 3, + PaymentTokenReference: "env:MIDTRANS_SAVED_TOKEN", + }, + }, 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" || 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 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 + handler := subscription.NewDisableHandler().WithRunner(subscription.JourneyRunner{ + Get: stubSubscriptionGet(func(context.Context, string) (subscription.SubscriptionResponse, error) { + getCalls++ + 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{}, 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) + } + 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) { + 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.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.Passed || outcome.SafeData["provider_status"] != "inactive" { + t.Fatalf("outcome = %#v", outcome) + } +} + +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) { + 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 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.AcknowledgementResponse, error) { + 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 } +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/schemas/evidence-v1.schema.json b/schemas/evidence-v1.schema.json index 328443a..c003d9e 100644 --- a/schemas/evidence-v1.schema.json +++ b/schemas/evidence-v1.schema.json @@ -2,39 +2,36 @@ "$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", - "pack_id", - "pack_version", - "manifest_hash", - "repository_commit", - "journey", - "environment", - "started_at", - "completed_at", - "safe_references", - "proofs" + "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}, - "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"} + } + }, + "requiredProof": { + "type": "object", + "additionalProperties": false, + "required": ["id", "level"], + "properties": { + "id": {"type": "string", "minLength": 1}, + "level": {"enum": ["local", "sandbox"]} + } }, - "journey": {"type": "string", "minLength": 1}, - "environment": {"const": "sandbox"}, - "started_at": {"type": "string", "format": "date-time"}, - "completed_at": {"type": "string", "format": "date-time"}, - "safe_references": { + "safeReferences": { "type": "object", "additionalProperties": false, "properties": { @@ -42,23 +39,76 @@ "provider_transaction_id": {"type": "string"} } }, - "proofs": { - "type": "array", - "items": { - "type": "object", - "additionalProperties": false, - "required": ["id", "level", "status", "summary"], - "properties": { - "id": {"type": "string", "minLength": 1}, - "level": {"enum": ["local", "sandbox"]}, - "status": {"enum": ["pass", "fail", "blocked"]}, - "summary": {"type": "object"} + "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"} + "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"} + } + } } } } diff --git a/schemas/manifest-v1.schema.json b/schemas/manifest-v1.schema.json index bdd1465..eb4a4f6 100644 --- a/schemas/manifest-v1.schema.json +++ b/schemas/manifest-v1.schema.json @@ -6,76 +6,248 @@ "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", + "minItems": 1, + "items": { + "type": "string", + "minLength": 1 + }, + "uniqueItems": true + }, + "terminal": { + "type": "array", + "minItems": 1, + "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" + ], + "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", + "client_secret", + "partner_id", + "channel_id", + "device_id", + "private_key", + "midtrans_public_key" + ] + } + } + ], + "properties": { + "type": { + "enum": [ + "classic", + "bisnap" + ] + }, + "environment": { + "const": "sandbox" + }, + "server_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._-]*)*)$" + }, + "client_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._-]*)*)$" + }, + "client_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._-]*)*)$" + }, + "client_secret": { + "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._-]*)*)$" + }, + "partner_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._-]*)*)$" + }, + "channel_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._-]*)*)$" + }, + "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._-]*)*)$" + }, + "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._-]*)*)$" + }, + "midtrans_public_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._-]*)*)$" + } + } } }, - "credentials": { + "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" + } + }, + "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/schemas/operation-v1.schema.json b/schemas/operation-v1.schema.json new file mode 100644 index 0000000..f00ce61 --- /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", "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}$"}, + "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"} + } +} 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": { diff --git a/test/e2e/cli_test.go b/test/e2e/cli_test.go index e923cb1..4b78cd0 100644 --- a/test/e2e/cli_test.go +++ b/test/e2e/cli_test.go @@ -8,6 +8,7 @@ import ( "os" "os/exec" "path/filepath" + "reflect" "strings" "testing" @@ -115,6 +116,130 @@ func TestCLIJSONExitClassifications(t *testing.T) { } } +func TestMultiProductEvaluationMatrixAndFixtures(t *testing.T) { + root := repositoryRoot(t) + var matrix struct { + SchemaVersion int `json:"schema_version"` + Packs []string `json:"packs"` + Repositories []struct { + ID string `json:"id"` + EnabledProducts []string `json:"enabled_products"` + RequiredJourneys []string `json:"required_journeys"` + RequiredProofs []string `json:"required_proofs"` + ExpectedInteractions []string `json:"expected_interactions"` + SandboxPrerequisites []string `json:"sandbox_prerequisites"` + LoopbackOnly bool `json:"loopback_only"` + ContainsSyntheticData bool `json:"contains_synthetic_data"` + ContainsRealCredential bool `json:"contains_real_credentials"` + } `json:"repositories"` + } + data, err := os.ReadFile(filepath.Join(root, "evaluations", "multi-product-autonomous.json")) + if err != nil { + t.Fatal(err) + } + if err := json.Unmarshal(data, &matrix); err != nil { + t.Fatalf("parse matrix: %v", err) + } + if matrix.SchemaVersion != 1 { + t.Fatalf("schema_version = %d, want 1", matrix.SchemaVersion) + } + if !reflect.DeepEqual(matrix.Packs, []string{ + "snap", + "core-api", + "payment-link", + "bisnap", + "gopay-tokenization", + "subscription", + }) { + t.Fatalf("packs = %#v", matrix.Packs) + } + if len(matrix.Repositories) != 3 { + t.Fatalf("repositories = %#v", matrix.Repositories) + } + expectedFixtures := map[string][]string{ + "hybrid-snap-gopay": {"snap", "gopay-tokenization"}, + "coreapi-paymentlink": {"core-api", "payment-link"}, + "bisnap-qris-va": {"bisnap", "gopay-tokenization"}, + } + for fixture, products := range expectedFixtures { + path := filepath.Join(root, "evaluations", "fixtures", fixture) + info, err := os.Stat(path) + if err != nil || !info.IsDir() { + t.Fatalf("fixture %s missing: %v", fixture, err) + } + readme, err := os.ReadFile(filepath.Join(path, "README.md")) + if err != nil { + t.Fatal(err) + } + text := string(readme) + for _, required := range []string{ + "loopback", + "synthetic", + "blocked", + "Sandbox prerequisites", + } { + if !strings.Contains(text, required) { + t.Fatalf("%s README missing %q", fixture, required) + } + } + for _, name := range []string{".env.example", "reset.sh", "start.sh", "test.sh", ".midtrans/manifest.yaml"} { + target := filepath.Join(path, filepath.FromSlash(name)) + if _, err := os.Stat(target); err != nil { + t.Fatalf("%s missing %s: %v", fixture, name, err) + } + } + testScript, err := os.ReadFile(filepath.Join(path, "test.sh")) + if err != nil { + t.Fatal(err) + } + for _, required := range []string{ + "pack list", + "agent plan", + "agent run", + "agent resume", + "evidence export", + "blocked", + } { + if !strings.Contains(string(testScript), required) { + t.Fatalf("%s test.sh missing %q", fixture, required) + } + } + manifest, err := os.ReadFile(filepath.Join(path, ".midtrans", "manifest.yaml")) + if err != nil { + t.Fatal(err) + } + manifestText := string(manifest) + for _, product := range products { + if !strings.Contains(manifestText, product) { + t.Fatalf("%s manifest missing product %q", fixture, product) + } + } + } + for _, repository := range matrix.Repositories { + if len(repository.EnabledProducts) < 2 { + t.Fatalf("%s enabled_products = %#v", repository.ID, repository.EnabledProducts) + } + if !repository.LoopbackOnly || !repository.ContainsSyntheticData || repository.ContainsRealCredential { + t.Fatalf("%s metadata = %#v", repository.ID, repository) + } + for _, required := range []string{ + "pack.list", + "agent.plan.no-mutation", + "agent.run.execute.pause", + "agent.resume.reconcile", + "evidence.export.synthetic", + "sandbox.blocked-real-prereqs", + } { + if !contains(repository.ExpectedInteractions, required) { + t.Fatalf("%s missing interaction %q", repository.ID, required) + } + } + if len(repository.SandboxPrerequisites) == 0 { + t.Fatalf("%s sandbox_prerequisites empty", repository.ID) + } + } +} + func repositoryRoot(t *testing.T) string { t.Helper() workingDirectory, err := os.Getwd() @@ -209,3 +334,12 @@ func commandEnvironment(overrides map[string]string) []string { } return environment } + +func contains(values []string, want string) bool { + for _, value := range values { + if value == want { + return true + } + } + return false +} diff --git a/test/e2e/security_test.go b/test/e2e/security_test.go index daa252c..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{ @@ -96,10 +105,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 +135,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) @@ -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() 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"]}}}` 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/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----- 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/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. 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 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/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. diff --git a/tools/check_release.sh b/tools/check_release.sh index 744695c..a0700ff 100755 --- a/tools/check_release.sh +++ b/tools/check_release.sh @@ -3,7 +3,9 @@ set -euo pipefail 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 new file mode 100755 index 0000000..e7275f5 --- /dev/null +++ b/tools/install-local.sh @@ -0,0 +1,88 @@ +#!/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 + +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) + +( + 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" +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 + 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 + *":$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/source-baseline/main.go b/tools/source-baseline/main.go index f11d169..c7ac579 100644 --- a/tools/source-baseline/main.go +++ b/tools/source-baseline/main.go @@ -8,7 +8,6 @@ import ( "time" "github.com/veritrans/midtrans-cli/internal/sourceprovenance" - "github.com/veritrans/midtrans-cli/packs/snap" ) func main() { @@ -20,7 +19,7 @@ func main() { os.Exit(2) } - sources := snap.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 8764254..7ae4a9a 100644 --- a/tools/source-drift/main.go +++ b/tools/source-drift/main.go @@ -7,7 +7,6 @@ import ( "os" "github.com/veritrans/midtrans-cli/internal/sourceprovenance" - "github.com/veritrans/midtrans-cli/packs/snap" ) func main() { @@ -24,7 +23,7 @@ func main() { fmt.Fprintln(os.Stderr, "baseline could not be read") os.Exit(1) } - sources := snap.New().Descriptor().Sources + sources := sourceprovenance.AllPublicSources() if err := sourceprovenance.ValidateBaseline(baseline, sources); err != nil { fmt.Fprintln(os.Stderr, err) os.Exit(1) diff --git a/tools/test-install-local.sh b/tools/test-install-local.sh new file mode 100755 index 0000000..2b5a18e --- /dev/null +++ b/tools/test-install-local.sh @@ -0,0 +1,140 @@ +#!/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 +) + +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() { + cksum <"$binary" >"$test_root/current.cksum" + cmp "$test_root/current.cksum" "$test_root/previous.cksum" +} + +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 +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" "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"' \ + '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