nodexpay 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/LICENSE.txt +22 -0
- data/README.md +125 -0
- data/docs/README.md +49 -0
- data/docs/api-contract.md +203 -0
- data/docs/architecture.md +226 -0
- data/docs/http-and-errors.md +157 -0
- data/docs/implementation-plan.md +130 -0
- data/docs/payment-results.md +109 -0
- data/docs/testing.md +175 -0
- data/lib/nodex_pay/amount_serializer.rb +78 -0
- data/lib/nodex_pay/client.rb +240 -0
- data/lib/nodex_pay/errors.rb +56 -0
- data/lib/nodex_pay/models.rb +40 -0
- data/lib/nodex_pay/signer.rb +22 -0
- data/lib/nodex_pay/transport.rb +94 -0
- data/lib/nodex_pay/validator.rb +106 -0
- data/lib/nodex_pay/version.rb +5 -0
- data/lib/nodex_pay.rb +11 -0
- data/rbi/nodex_pay.rbi +243 -0
- data/sig/nodex_pay.rbs +165 -0
- metadata +162 -0
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
# HTTP とエラー
|
|
2
|
+
|
|
3
|
+
## 既定 HTTP transport
|
|
4
|
+
|
|
5
|
+
既定実装は Ruby 標準ライブラリの `Net::HTTP` を使用し、追加の runtime dependency を持ちません。
|
|
6
|
+
|
|
7
|
+
- HTTPS の証明書検証を有効にする
|
|
8
|
+
- request ごとに接続を作成し、可変な connection を Client 間で共有しない
|
|
9
|
+
- `POST` だけを使用する
|
|
10
|
+
- redirect を追従しない
|
|
11
|
+
- transport / 503 の自動再試行を行わない
|
|
12
|
+
- body は上位層で生成された form-urlencoded String をそのまま送る
|
|
13
|
+
|
|
14
|
+
## Timeout
|
|
15
|
+
|
|
16
|
+
既定値:
|
|
17
|
+
|
|
18
|
+
| 設定 | 既定 | 対象 |
|
|
19
|
+
| --- | --- | --- |
|
|
20
|
+
| `open_timeout` | 5秒 | TCP/TLS 接続確立 |
|
|
21
|
+
| `read_timeout` | 30秒 | response 読み取り |
|
|
22
|
+
| `write_timeout` | 30秒 | request body 書き込み |
|
|
23
|
+
|
|
24
|
+
client initializer で正の Numeric を指定できます。0、負数、NaN、Infinity は `ConfigurationError` とします。
|
|
25
|
+
|
|
26
|
+
公式 FAQ の「API timeout はない」という記載は server 側上限の説明であり、client の無制限待機を意味するものとして扱いません。
|
|
27
|
+
|
|
28
|
+
## Retry と結果不明状態
|
|
29
|
+
|
|
30
|
+
Payment Request は、server が作成を完了した後に response を受け取れなかった場合があります。この状態で同じ `order_code` を再送すると、2021 duplicate が返り、元の payment token を回収できない可能性があります。
|
|
31
|
+
|
|
32
|
+
そのため次を禁止します。
|
|
33
|
+
|
|
34
|
+
- timeout、接続切断、503 に対する自動 retry
|
|
35
|
+
- 新しい `order_code` を SDK が自動生成して retry
|
|
36
|
+
- duplicate error を成功として扱うこと
|
|
37
|
+
|
|
38
|
+
`TransportError` または `TimeoutError` を受けた利用側は、注文を「結果不明」として保持し、管理画面や別の業務手順で確認します。公式の照会 endpoint がないため、SDK は fallback を提供しません。
|
|
39
|
+
|
|
40
|
+
## 例外階層
|
|
41
|
+
|
|
42
|
+
```text
|
|
43
|
+
NodexPay::Error
|
|
44
|
+
├── ConfigurationError
|
|
45
|
+
├── ValidationError
|
|
46
|
+
├── SignatureVerificationError
|
|
47
|
+
├── TransportError
|
|
48
|
+
│ └── TimeoutError
|
|
49
|
+
├── APIError
|
|
50
|
+
│ ├── BadRequestError
|
|
51
|
+
│ ├── ServiceUnavailableError
|
|
52
|
+
│ └── UnexpectedHTTPStatusError
|
|
53
|
+
└── InvalidResponseError
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
### ローカルエラー
|
|
57
|
+
|
|
58
|
+
| 例外 | 条件 |
|
|
59
|
+
| --- | --- |
|
|
60
|
+
| `ConfigurationError` | credential、environment、timeout、transport 設定が不正 |
|
|
61
|
+
| `ValidationError` | API 呼び出し引数が公開契約に違反 |
|
|
62
|
+
| `SignatureVerificationError` | Payment Result の署名が一致しない |
|
|
63
|
+
| `InvalidResponseError` | 成功 response または Payment Result の JSON/schema が不正 |
|
|
64
|
+
|
|
65
|
+
### 通信エラー
|
|
66
|
+
|
|
67
|
+
| 例外 | 条件 |
|
|
68
|
+
| --- | --- |
|
|
69
|
+
| `TimeoutError` | open/read/write timeout |
|
|
70
|
+
| `TransportError` | DNS、TLS、connection reset、その他 response 未取得の通信障害 |
|
|
71
|
+
|
|
72
|
+
`TimeoutError` は `TransportError` の subclass です。元例外は `cause` で参照可能にしますが、message に request body や credential を含めません。
|
|
73
|
+
|
|
74
|
+
### API エラー
|
|
75
|
+
|
|
76
|
+
`APIError` は次の読み取り専用属性を持ちます。
|
|
77
|
+
|
|
78
|
+
```ruby
|
|
79
|
+
error.operation # :create_payment or :cancel_payment
|
|
80
|
+
error.status # Integer
|
|
81
|
+
error.error_codes # Array[Integer]
|
|
82
|
+
error.response_headers # Hash[String, String]
|
|
83
|
+
error.response_body # String
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
`message` には operation、HTTP status、既知の error code 名だけを含めます。`identification_token`、`hash_token`、`verify_token`、request body は含めません。`response_body` は自動的に message や `inspect` へ展開せず、利用者にも機密情報として扱うよう文書化します。
|
|
87
|
+
|
|
88
|
+
## HTTP status mapping
|
|
89
|
+
|
|
90
|
+
| Status | 例外 |
|
|
91
|
+
| --- | --- |
|
|
92
|
+
| 200 | endpoint ごとの成功処理 |
|
|
93
|
+
| 400 | `BadRequestError` |
|
|
94
|
+
| 503 | `ServiceUnavailableError` |
|
|
95
|
+
| その他 | `UnexpectedHTTPStatusError` |
|
|
96
|
+
|
|
97
|
+
301/302/307/308 も `UnexpectedHTTPStatusError` です。別 host へ credential を転送しないため redirect は追従しません。
|
|
98
|
+
|
|
99
|
+
## Error body の解析
|
|
100
|
+
|
|
101
|
+
公式の400例は endpoint 間で形が異なり、一部は JSON として壊れています。SDK は成功扱いを広げる fallback ではなく、診断情報を失わないため次の限定的な抽出を行います。
|
|
102
|
+
|
|
103
|
+
有効な JSON の場合、次の位置だけから error code を抽出します。
|
|
104
|
+
|
|
105
|
+
```json
|
|
106
|
+
{ "errors": [2021] }
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
```json
|
|
110
|
+
{ "errors": 2024 }
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
```json
|
|
114
|
+
{ "data": { "errors": [2021] } }
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
- scalar または Array の整数と、10進整数だけの String を Integer 化する
|
|
118
|
+
- 上記以外の key を error code として走査しない
|
|
119
|
+
- JSON が壊れている、または code を抽出できない場合は空配列にする
|
|
120
|
+
- unknown code も数値のまま保持する
|
|
121
|
+
- status と元 body は常に `APIError` に保持する
|
|
122
|
+
|
|
123
|
+
503 の maintenance body は valid JSON schema が規定されていないため、status のみで `ServiceUnavailableError` にします。
|
|
124
|
+
|
|
125
|
+
## 既知 error code
|
|
126
|
+
|
|
127
|
+
少なくとも公開 endpoint と直接関係する次の code 名を定義します。
|
|
128
|
+
|
|
129
|
+
| Code | Name |
|
|
130
|
+
| --- | --- |
|
|
131
|
+
| 2010 | `RECEIVED_IDENTIFICATION_TOKEN_EMPTY` |
|
|
132
|
+
| 2011 | `RECEIVED_IDENTIFICATION_TOKEN_INVALID` |
|
|
133
|
+
| 2020 | `RECEIVED_ORDER_CODE_EMPTY` |
|
|
134
|
+
| 2021 | `RECEIVED_ORDER_CODE_DUPLICATE` |
|
|
135
|
+
| 2022 | `RECEIVED_ORDER_CODE_ALREADY_CANCELLED` |
|
|
136
|
+
| 2023 | `PAYMENT_NOT_FOUND` |
|
|
137
|
+
| 2024 | `PAYMENT_NOT_CANCELABLE` |
|
|
138
|
+
| 2030 | `RECEIVED_AMOUNT_INVALID_FORMAT` |
|
|
139
|
+
| 2040 | `RECEIVED_INVALID_CALLBACK_URL` |
|
|
140
|
+
| 2050 | `RECEIVED_COLOR_THEME_INVALID_FORMAT` |
|
|
141
|
+
| 2060 | `RECEIVED_VERIFY_TOKEN_EMPTY` |
|
|
142
|
+
| 2061 | `RECEIVED_VERIFY_TOKEN_INVALID` |
|
|
143
|
+
| 2101 | `RECEIVED_EXT_RESERVED_INVALID` |
|
|
144
|
+
| 2102 | `RECEIVED_EXT_DESCRIPTION_INVALID` |
|
|
145
|
+
| 2370 | `RECEIVE_CONTRACT_UNPUBLISHED` |
|
|
146
|
+
| 2460 | `RECEIVED_INVALID_DEADLINE` |
|
|
147
|
+
|
|
148
|
+
公式 Payment Request ページには内部 API 用に見える多数の code も列挙されています。SDK は未知 code を破棄しないため、すべてを定数化しなくても診断可能です。
|
|
149
|
+
|
|
150
|
+
## 秘密情報の取り扱い
|
|
151
|
+
|
|
152
|
+
- Client の `inspect` は credential を表示しない
|
|
153
|
+
- request body と signing string をログ出力しない
|
|
154
|
+
- transport exception に URI path は含めても query/body は含めない
|
|
155
|
+
- test fixture、recording、CI artifact に実 credential を保存しない
|
|
156
|
+
- Hash Token は server-side でのみ使用し、browser/mobile bundle へ渡さない
|
|
157
|
+
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
# 実装計画
|
|
2
|
+
|
|
3
|
+
## リリース方針
|
|
4
|
+
|
|
5
|
+
最初の公開版は `0.1.0` とします。Nodex Pay の正式 SDK を名乗らず、README と gem metadata に非公式 SDK であることを記載します。
|
|
6
|
+
|
|
7
|
+
`1.0.0` は次を満たしてから公開します。
|
|
8
|
+
|
|
9
|
+
- Testnet で Payment Request → Payment Cancel の contract を確認済み
|
|
10
|
+
- 実運用相当の endpoint で Payment Result payload と署名を確認済み
|
|
11
|
+
- 公式文書の未確認事項が、解決済みまたは明示された制限として整理済み
|
|
12
|
+
- Ruby 3.4 / 4.0 の CI、RBS、package 検査が安定している
|
|
13
|
+
|
|
14
|
+
公開ライセンスは MIT とします。公開 API の修正時に旧 keyword、alias、domain fallback を安易に追加しません。1.0 以降の破壊的変更は SemVer の major version で表現します。
|
|
15
|
+
|
|
16
|
+
## Phase 1: Gem scaffold
|
|
17
|
+
|
|
18
|
+
### 変更
|
|
19
|
+
|
|
20
|
+
- gem 名 `nodexpay`、require path `nodex_pay`、namespace `NodexPay` で scaffold を作る
|
|
21
|
+
- `required_ruby_version` を `>= 3.4` にする
|
|
22
|
+
- runtime dependency を追加しない
|
|
23
|
+
- Minitest、RBS、Sorbet RBI、CI、package build の最小構成を追加する
|
|
24
|
+
- Sorbet RBI は型エラーなしとmetrics上のsignature/strict-file coverage 100%をCIで必須にする
|
|
25
|
+
- MIT license、非公式 SDK の表示、version `0.1.0` を追加する
|
|
26
|
+
|
|
27
|
+
### 完了条件
|
|
28
|
+
|
|
29
|
+
- `require "nodex_pay"` が成功する
|
|
30
|
+
- Ruby 3.4 / 4.0 で空の test suite と RBS 検査が成功する
|
|
31
|
+
- build した gem に意図した lib、sig、docs、license だけが含まれる
|
|
32
|
+
|
|
33
|
+
## Phase 2: Value、validation、署名
|
|
34
|
+
|
|
35
|
+
### 変更
|
|
36
|
+
|
|
37
|
+
- immutable な設定、`Payment`、`PaymentResult`、transport value を `Data` で定義する
|
|
38
|
+
- `order_code`、enum、description、deadline、timeout の validator を実装する
|
|
39
|
+
- String / Integer / BigDecimal を単一の decimal serializer へ集約する
|
|
40
|
+
- SHA-256 signer と constant-time verifier を pure object/function として実装する
|
|
41
|
+
- [testing.md](testing.md) の固定署名ベクトルを追加する
|
|
42
|
+
|
|
43
|
+
### 完了条件
|
|
44
|
+
|
|
45
|
+
- 署名と form field が同じ normalized amount を使うことを unit test で証明する
|
|
46
|
+
- Float、指数表記、範囲外を送信前に拒否する
|
|
47
|
+
- 秘密情報が各 object の `inspect` に含まれない
|
|
48
|
+
|
|
49
|
+
## Phase 3: HTTP transport と例外
|
|
50
|
+
|
|
51
|
+
### 変更
|
|
52
|
+
|
|
53
|
+
- `Request` / `Response` contract と `Net::HTTP` transport を実装する
|
|
54
|
+
- TLS、open/read/write timeout、header、form body を設定する
|
|
55
|
+
- 例外階層、HTTP status mapping、限定的な error code decoder を実装する
|
|
56
|
+
- redirect と retry を実装しないことを test で固定する
|
|
57
|
+
|
|
58
|
+
### 完了条件
|
|
59
|
+
|
|
60
|
+
- local server integration test で exact method/header/body を確認する
|
|
61
|
+
- timeout / DNS / TLS / connection failure が契約どおりの例外になる
|
|
62
|
+
- 400 の既知形状、503、unknown status、malformed body で診断情報を保持する
|
|
63
|
+
|
|
64
|
+
## Phase 4: Payment Request と Cancel
|
|
65
|
+
|
|
66
|
+
### 変更
|
|
67
|
+
|
|
68
|
+
- 環境から固定 base URL を選択する
|
|
69
|
+
- `create_payment` を実装し、form request と `Payment` decoder を接続する
|
|
70
|
+
- `cancel_payment` を amount なしの署名で実装する
|
|
71
|
+
- 成功 response と不正 response の endpoint-specific test を追加する
|
|
72
|
+
|
|
73
|
+
### 完了条件
|
|
74
|
+
|
|
75
|
+
- [api-contract.md](api-contract.md) の全 keyword と wire field が一致する
|
|
76
|
+
- `ext_reserved` / `callback_url` を送信できる非公開経路が存在しない
|
|
77
|
+
- Cancel の200だけが `true` を返し、2022を含む400は例外になる
|
|
78
|
+
|
|
79
|
+
## Phase 5: Payment Result
|
|
80
|
+
|
|
81
|
+
### 変更
|
|
82
|
+
|
|
83
|
+
- raw JSON parser と field/type validator を実装する
|
|
84
|
+
- 受信した amount を変更せず署名検証する
|
|
85
|
+
- 検証成功後にのみ `PaymentResult` を構築する
|
|
86
|
+
- 署名対象外 field とリプレイ制約を利用ガイドへ記載する
|
|
87
|
+
|
|
88
|
+
### 完了条件
|
|
89
|
+
|
|
90
|
+
- 正常/不正署名、欠落/型違い、`result: false` の全 test が成功する
|
|
91
|
+
- 未検証 payload がモデルや例外から外部へ漏れない
|
|
92
|
+
- Rails/Rack、DB、ACK response の責務が SDK 内へ混入していない
|
|
93
|
+
|
|
94
|
+
## Phase 6: RBS/Sorbet RBI、文書、Testnet
|
|
95
|
+
|
|
96
|
+
### 変更
|
|
97
|
+
|
|
98
|
+
- 公開 API、モデル、transport、例外を RBS へ反映する
|
|
99
|
+
- RBS と同じ公開契約を `rbi/nodex_pay.rbi` へ反映し、gem に同梱する
|
|
100
|
+
- root README にインストール、設定、3 API の最小例、非公式表示を追加する
|
|
101
|
+
- この設計文書と実装の名称・引数・戻り値を照合する
|
|
102
|
+
- 資格情報を必要とする手動 Testnet smoke job を追加する
|
|
103
|
+
|
|
104
|
+
### 完了条件
|
|
105
|
+
|
|
106
|
+
- Ruby 3.4 / 4.0 の CI が成功する
|
|
107
|
+
- RBS/Sorbet RBI と Ruby の公開 API が一致する
|
|
108
|
+
- Testnet で create → cancel を実行し、Content-Type、Cancel 署名、成功 body を確認する
|
|
109
|
+
- 観測結果が設計と異なる場合は、実装の fallback ではなく設計判断を更新する
|
|
110
|
+
|
|
111
|
+
## 0.1.0 Definition of Done
|
|
112
|
+
|
|
113
|
+
- `gem install nodexpay` と `require "nodex_pay"` が成立する
|
|
114
|
+
- 3つの公開メソッド以外に推測 API を公開していない
|
|
115
|
+
- outbound request は form-urlencoded、retry/redirect は無効である
|
|
116
|
+
- 全 credential と署名材料がログ、例外 message、test artifact から除外されている
|
|
117
|
+
- API/model/error/transport の RBS と Sorbet RBI が同梱されている
|
|
118
|
+
- docs、tests、実装が同じ契約を参照している
|
|
119
|
+
- MIT license と非公式 SDK 表示が配布物に含まれる
|
|
120
|
+
|
|
121
|
+
## 1.0 前に Nodex 側へ確認する項目
|
|
122
|
+
|
|
123
|
+
1. outbound request の正式な Content-Type
|
|
124
|
+
2. amount の server-side lexical normalization
|
|
125
|
+
3. Cancel の署名原文と success response
|
|
126
|
+
4. `ext_reserved` の現行 request field としての可否
|
|
127
|
+
5. Payment Result の必須 field、failure payload、ACK、retry
|
|
128
|
+
6. rate limit、API timeout、secret rotation、deprecation policy
|
|
129
|
+
|
|
130
|
+
回答が得られない項目は、Testnet/実環境で観測できた範囲と未保証範囲を明記します。観測できない機能を推測して実装しません。
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
# Payment Result
|
|
2
|
+
|
|
3
|
+
Payment Result API は、Nodex Pay が blockchain 上の支払い結果を確認した後、加盟店が管理画面で指定した URL へ送る JSON POST です。SDK は Web サーバーを提供せず、raw request body の解析と署名検証だけを担当します。
|
|
4
|
+
|
|
5
|
+
## 公開 API
|
|
6
|
+
|
|
7
|
+
```ruby
|
|
8
|
+
result = client.verify_payment_result(raw_request_body)
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
成功時は `NodexPay::PaymentResult`、JSON/field が不正な場合は `InvalidResponseError`、署名不一致の場合は `SignatureVerificationError` を返します。
|
|
12
|
+
|
|
13
|
+
署名不一致を Boolean で返す低水準 API は公開しません。未検証データを業務処理へ渡さないためです。
|
|
14
|
+
|
|
15
|
+
## 受信形式
|
|
16
|
+
|
|
17
|
+
Content-Type は公式仕様どおり `application/json` です。利用側は HTTP body を文字コード変換、parameter merge、symbolize する前に String として SDK へ渡します。
|
|
18
|
+
|
|
19
|
+
```json
|
|
20
|
+
{
|
|
21
|
+
"order_code": "order-123",
|
|
22
|
+
"transaction_code": "A1B2-3CD4-5E6F",
|
|
23
|
+
"amount": "100",
|
|
24
|
+
"symbol": "USDT",
|
|
25
|
+
"result": true,
|
|
26
|
+
"verify_token": "lowercase-sha256-hex",
|
|
27
|
+
"chain_id": "1",
|
|
28
|
+
"transaction_hash": "0x..."
|
|
29
|
+
}
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
公式ページに完全な payload 例はないため、上記は field の組み合わせを示す SDK 文書上の例です。
|
|
33
|
+
|
|
34
|
+
## field contract
|
|
35
|
+
|
|
36
|
+
| Field | 型 | SDK の検査 |
|
|
37
|
+
| --- | --- | --- |
|
|
38
|
+
| `order_code` | String | 空でないこと |
|
|
39
|
+
| `transaction_code` | String | `\A[A-Za-z0-9]{4}(?:-[A-Za-z0-9]{4}){2}\z` に一致する14 characters |
|
|
40
|
+
| `amount` | String | 空でないこと。署名用にそのまま保持 |
|
|
41
|
+
| `symbol` | String | 空でないこと。固定 enum にはしない |
|
|
42
|
+
| `result` | Boolean | `true` または `false` だけを許可 |
|
|
43
|
+
| `verify_token` | String | 64 characters の16進 SHA-256 |
|
|
44
|
+
| `chain_id` | String | 空でないこと。固定 enum にはしない |
|
|
45
|
+
| `transaction_hash` | String | 空でないこと |
|
|
46
|
+
|
|
47
|
+
すべての field を必須として扱います。公式 table に required marker はありませんが、各 field の省略時動作も定義されていないため、欠落を許容して不完全なモデルを生成することはしません。
|
|
48
|
+
|
|
49
|
+
`symbol` と `chain_id` は、対応 token/network の追加を妨げないよう SDK の enum validation を行いません。
|
|
50
|
+
|
|
51
|
+
## 署名検証
|
|
52
|
+
|
|
53
|
+
受信した文字列を次の順番で連結し、client の `hash_token` を使って SHA-256 を計算します。
|
|
54
|
+
|
|
55
|
+
```text
|
|
56
|
+
<order_code>::<amount>::<hash_token>
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
重要な点:
|
|
60
|
+
|
|
61
|
+
- `amount` は数値化、丸め、末尾ゼロ削除を行わない
|
|
62
|
+
- 受信した `verify_token` は小文字へ変換せず、正規の64桁小文字 hex として検査する
|
|
63
|
+
- 長さを検査した後、`OpenSSL.fixed_length_secure_compare` 相当の constant-time comparison を行う
|
|
64
|
+
- `hash_token` や生成した raw signing string を例外、ログ、inspect へ含めない
|
|
65
|
+
|
|
66
|
+
## 署名のセキュリティ境界
|
|
67
|
+
|
|
68
|
+
> [!WARNING]
|
|
69
|
+
> 公式署名が保護するのは `order_code` と `amount` だけです。`result`、`transaction_code`、`symbol`、`chain_id`、`transaction_hash` は署名対象ではありません。署名検証の成功だけで payload 全体の完全性や blockchain finality が保証されたと扱ってはいけません。
|
|
70
|
+
|
|
71
|
+
利用アプリケーションは、検証後に少なくとも次を行います。
|
|
72
|
+
|
|
73
|
+
1. `order_code` に対応する注文が存在することを確認する。
|
|
74
|
+
2. 保存済みの注文金額・受取 token と `amount` / `symbol` を照合する。
|
|
75
|
+
3. `transaction_code` を一意 key として、同じ通知を重複処理しない。
|
|
76
|
+
4. 既に確定した注文を古い通知や矛盾する通知で上書きしない。
|
|
77
|
+
5. 高い finality が必要な場合は、`transaction_hash` を explorer / JSON-RPC で別途確認する。
|
|
78
|
+
|
|
79
|
+
これらは加盟店のデータストアと業務規則を必要とするため SDK の責務には含めません。
|
|
80
|
+
|
|
81
|
+
## HTTP endpoint 側の責務
|
|
82
|
+
|
|
83
|
+
SDK は Rack/Rails response を生成しません。利用側 endpoint は次を担当します。
|
|
84
|
+
|
|
85
|
+
- `Content-Type` と request size を確認する
|
|
86
|
+
- raw body を一度だけ読み取って `verify_payment_result` へ渡す
|
|
87
|
+
- 検証成功後、トランザクション内で重複排除と状態更新を行う
|
|
88
|
+
- 保存成功後にのみ 2xx を返す
|
|
89
|
+
- 例外時の response、監視、再処理方針を決める
|
|
90
|
+
|
|
91
|
+
公式文書は ACK body、必要な status、timeout、再送回数、再送間隔、順序保証を規定していません。SDK は特定の ACK や retry protocol を仮定しません。
|
|
92
|
+
|
|
93
|
+
## リプレイと重複
|
|
94
|
+
|
|
95
|
+
Payment Result には timestamp、nonce、delivery ID がありません。署名検証だけでは同一 payload の再送を検出できません。
|
|
96
|
+
|
|
97
|
+
`transaction_code` を一意制約付きで保存し、同じ値の再受信は同じ結果を返す業務処理を推奨します。ただし、この一意性管理は SDK がメモリ内で代替してはいけません。複数プロセス・再起動をまたぐ永続性が必要だからです。
|
|
98
|
+
|
|
99
|
+
## Finality
|
|
100
|
+
|
|
101
|
+
公式 FAQ には blockchain reorganization を完全には考慮しない旨の記載があります。`result: true` は Nodex Pay が成功として通知した事実を表しますが、不可逆な blockchain finality の保証ではありません。
|
|
102
|
+
|
|
103
|
+
## Nodex 側への確認事項
|
|
104
|
+
|
|
105
|
+
1. 必須 field と failure notification 時の nullability
|
|
106
|
+
2. ACK に必要な HTTP status / body
|
|
107
|
+
3. timeout、再送、重複、順序の保証
|
|
108
|
+
4. source IP、追加認証、secret rotation の仕様
|
|
109
|
+
5. `result` と transaction field を含む署名方式への拡張予定
|
data/docs/testing.md
ADDED
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
# テスト戦略
|
|
2
|
+
|
|
3
|
+
テストは、外部 API を推測した柔軟な挙動ではなく、この設計文書で固定した公開契約を検証します。unit test は Minitest、型検査は RBS と Sorbet を使用します。
|
|
4
|
+
|
|
5
|
+
## テスト階層
|
|
6
|
+
|
|
7
|
+
| 階層 | 目的 | 外部通信 |
|
|
8
|
+
| --- | --- | --- |
|
|
9
|
+
| Unit | validation、金額正規化、署名、decoder、モデル | なし |
|
|
10
|
+
| Client contract | Client から transport へ渡る request と戻り値/例外 | fake transport |
|
|
11
|
+
| Net::HTTP integration | form body、header、timeout、TLS/connection error mapping | local test server |
|
|
12
|
+
| Testnet smoke | Nodex Pay の実際の wire contract | 手動・資格情報必須 |
|
|
13
|
+
|
|
14
|
+
Production API は自動テストや CI から呼び出しません。
|
|
15
|
+
|
|
16
|
+
## 固定署名ベクトル
|
|
17
|
+
|
|
18
|
+
署名テストは最低限、次を固定 fixture として使用します。
|
|
19
|
+
|
|
20
|
+
### 金額あり
|
|
21
|
+
|
|
22
|
+
```text
|
|
23
|
+
raw:
|
|
24
|
+
ORDER-123::10::hash-secret
|
|
25
|
+
|
|
26
|
+
sha256:
|
|
27
|
+
b9087ce2f0af360db0a0172df536f755e25700b13601306831224ae69d22631a
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
### 金額なし
|
|
31
|
+
|
|
32
|
+
```text
|
|
33
|
+
raw:
|
|
34
|
+
ORDER-123::::hash-secret
|
|
35
|
+
|
|
36
|
+
sha256:
|
|
37
|
+
3816f1a2297c926880d0859439a7da193959936d4816dd28000e18428599c0a3
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
`10` と `10.0` は異なる digest になるため、Client contract test では正規化後の文字列が署名入力と form field の両方で同一であることを直接検証します。
|
|
41
|
+
|
|
42
|
+
## Configuration
|
|
43
|
+
|
|
44
|
+
- `:production` と `:testnet` を受け付ける
|
|
45
|
+
- environment 未指定、未知 Symbol/String を拒否する
|
|
46
|
+
- 空の `identification_token` / `hash_token` を拒否する
|
|
47
|
+
- timeout の既定値が 5/30/30 秒である
|
|
48
|
+
- 正の Integer/Float timeout を受け付け、0、負数、NaN、Infinity を拒否する
|
|
49
|
+
- custom transport が `call` contract を満たさない場合を明確な `ConfigurationError` にする
|
|
50
|
+
- Client の `inspect` に credential が含まれない
|
|
51
|
+
|
|
52
|
+
## 金額と入力 validation
|
|
53
|
+
|
|
54
|
+
### 正常系
|
|
55
|
+
|
|
56
|
+
- String、Integer、BigDecimal を受け付ける
|
|
57
|
+
- `"10.00"`、`10`、`BigDecimal("10.0")` を `"10"` にする
|
|
58
|
+
- `"10.50"` を `"10.5"` にする
|
|
59
|
+
- 最小値 `0.000001` と最大値 `100000000` を受け付ける
|
|
60
|
+
- amount nil 時に form から `amount` を完全に省略する
|
|
61
|
+
- Time deadline を Unix time 秒へ変換する
|
|
62
|
+
- enum の Symbol/String と大文字・小文字を規定どおり正規化する
|
|
63
|
+
|
|
64
|
+
### 異常系
|
|
65
|
+
|
|
66
|
+
- Float、指数表記、先頭ゼロ、負数、ゼロ、範囲外、空文字列を拒否する
|
|
67
|
+
- NaN、Infinity を拒否する
|
|
68
|
+
- 非 ASCII、空、256 bytes 以上の `order_code` を拒否する
|
|
69
|
+
- 未知の `amount_type`、`color_theme`、`ui_mode` を拒否する
|
|
70
|
+
- 101 characters 以上または不正 UTF-8 の description 系 field を拒否する
|
|
71
|
+
- 負の deadline、Time/Integer 以外の deadline を拒否する
|
|
72
|
+
|
|
73
|
+
## Payment Request
|
|
74
|
+
|
|
75
|
+
- environment ごとに正しい `/payment/receive` URI を組み立てる
|
|
76
|
+
- method、Accept、Content-Type、User-Agent が契約どおりである
|
|
77
|
+
- form body に credential、order code、digest、全 optional field が正しい wire name で入る
|
|
78
|
+
- `ui_mode` が `uimode` になる
|
|
79
|
+
- nil の optional field、`ext_reserved`、`callback_url`、`hash_token` が body に入らない
|
|
80
|
+
- 200 JSON を `Payment` へ変換する
|
|
81
|
+
- `ext_reserved` / `ext_description` の欠落と null を nil として扱う
|
|
82
|
+
- `url` / `token` 欠落、型違い、invalid JSON を `InvalidResponseError` にする
|
|
83
|
+
|
|
84
|
+
## Payment Cancel
|
|
85
|
+
|
|
86
|
+
- environment ごとに正しい `/payment/cancel` URI を組み立てる
|
|
87
|
+
- 署名が必ず `order_code::::hash_token` である
|
|
88
|
+
- body に `amount` が入らない
|
|
89
|
+
- 200 の空 body、`{}`、その他の body で一律 `true` を返す
|
|
90
|
+
- 2022、2023、2024 を含む400を `BadRequestError` にする
|
|
91
|
+
- 2022 を成功へ読み替えない
|
|
92
|
+
|
|
93
|
+
## HTTP と API error
|
|
94
|
+
|
|
95
|
+
- top-level `errors` の Array/scalar を抽出する
|
|
96
|
+
- `data.errors` の Array/scalar を抽出する
|
|
97
|
+
- 数値 String は code 化し、非数値値は無視する
|
|
98
|
+
- unknown code を保持する
|
|
99
|
+
- malformed JSON と plain text body でも status/body を失わない
|
|
100
|
+
- 400、503、未知 status、redirect status を正しい例外へ変換する
|
|
101
|
+
- open/read/write timeout を `TimeoutError` にする
|
|
102
|
+
- DNS、TLS、connection reset を `TransportError` にする
|
|
103
|
+
- どの通信エラーにも自動 retry しない
|
|
104
|
+
- redirect の Location へ2回目の request を送らない
|
|
105
|
+
- 例外の message / inspect に credential、verify token、request body が含まれない
|
|
106
|
+
|
|
107
|
+
## Payment Result
|
|
108
|
+
|
|
109
|
+
### 正常系
|
|
110
|
+
|
|
111
|
+
- 正しい JSON と署名を `PaymentResult` へ変換する
|
|
112
|
+
- `amount` の末尾ゼロを変更せず署名に使い、モデルにも保持する
|
|
113
|
+
- `result: true` と `result: false` を受け付ける
|
|
114
|
+
- `success?` が `result` と一致する
|
|
115
|
+
- 未知の `symbol` / `chain_id` を String として保持する
|
|
116
|
+
|
|
117
|
+
### 異常系
|
|
118
|
+
|
|
119
|
+
- invalid JSON、配列 top-level、欠落 field、null、型違いを拒否する
|
|
120
|
+
- transaction code の形式違反を拒否する
|
|
121
|
+
- verify token の長さ、文字種、大文字 hex を拒否する
|
|
122
|
+
- order code、amount、hash token のいずれかが異なる署名を拒否する
|
|
123
|
+
- `result` や transaction field の変更だけでは公式署名が変化しないという制約を、期待されるセキュリティ境界として明示的にテストする
|
|
124
|
+
- 署名不一致時に未検証の payload/model を例外へ含めない
|
|
125
|
+
|
|
126
|
+
## Transport contract
|
|
127
|
+
|
|
128
|
+
fake/custom transport 共通の contract test を用意します。
|
|
129
|
+
|
|
130
|
+
- `call(Request)` を1回だけ呼ぶ
|
|
131
|
+
- `Response` の status が Integer、headers が Hash、body が String であることを要求する
|
|
132
|
+
- response 型違反を `InvalidResponseError` にする
|
|
133
|
+
- transport が body を変更しないことを、local server が受け取った exact body で確認する
|
|
134
|
+
|
|
135
|
+
## CI matrix
|
|
136
|
+
|
|
137
|
+
| 対象 | 内容 |
|
|
138
|
+
| --- | --- |
|
|
139
|
+
| Ruby | 3.4、4.0 の最新 patch |
|
|
140
|
+
| Tests | 全 Minitest |
|
|
141
|
+
| Types | RBS validation、同梱 RBI の `srb tc`、Sorbet signature/strict-file coverage 100%検査 |
|
|
142
|
+
| Package | `gem build` と gem contents 検査 |
|
|
143
|
+
| Docs | Markdown link、相対リンク、コードブロック、表の検査 |
|
|
144
|
+
|
|
145
|
+
CI は credential を要求せず、Testnet smoke test とは分離します。
|
|
146
|
+
|
|
147
|
+
## Testnet smoke test
|
|
148
|
+
|
|
149
|
+
資格情報を環境変数から受け取る手動ジョブとして実行します。
|
|
150
|
+
|
|
151
|
+
1. timestamp と random suffix を含む一意な `order_code` を生成する。
|
|
152
|
+
2. 最小限の固定額で Payment Request を1回実行する。
|
|
153
|
+
3. `url` と `token` の型を確認する。値そのものはログへ出さない。
|
|
154
|
+
4. 同じ `order_code` を Payment Cancel へ渡す。
|
|
155
|
+
5. HTTP status、Content-Type、response schema の観測結果を秘密情報なしで記録する。
|
|
156
|
+
|
|
157
|
+
通信結果が不明な場合に自動で再実行しません。失敗時の order code は、管理画面で確認できるよう秘密でない artifact として保持します。
|
|
158
|
+
|
|
159
|
+
Payment Result の実環境確認は、専用の加盟店 endpoint と testnet payment が必要なため別の手動受入試験とします。
|
|
160
|
+
|
|
161
|
+
## 完了条件
|
|
162
|
+
|
|
163
|
+
- Ruby 3.4 / 4.0 の全テストが成功する
|
|
164
|
+
- 固定ベクトルと exact form body test が成功する
|
|
165
|
+
- すべての例外 path で秘密情報が露出しない
|
|
166
|
+
- Production への自動通信が存在しない
|
|
167
|
+
- このディレクトリの公開 API、引数、モデル、例外名が実装・RBS・Sorbet RBI・テストと一致する
|
|
168
|
+
- Sorbet RBI に型エラーがなく、metrics上のsignature coverageとstrict-file coverageがともに100%である
|
|
169
|
+
|
|
170
|
+
Sorbetのcoverageは、同梱RBIについて次のように定義します。
|
|
171
|
+
|
|
172
|
+
- signature coverage: `types.sig.count / types.input.foundmethods.total`
|
|
173
|
+
- strict-file coverage: `types.input.files.sigil.strict / rbi/**/*.rbi のファイル数`
|
|
174
|
+
|
|
175
|
+
`script/check_sorbet.rb` が両方を計算し、100%未満なら終了statusを非0にします。SorbetではRubyの `Data.define` が生成するinitializerとRBIのkeyword initializerを重ねると誤った再定義判定が起きるため、この検査は配布物であるRBIを単独で実行します。
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module NodexPay
|
|
4
|
+
module AmountSerializer
|
|
5
|
+
MINIMUM = "0.000001"
|
|
6
|
+
MAXIMUM = "100000000"
|
|
7
|
+
DECIMAL_PATTERN = /\A(?:0|[1-9][0-9]*)(?:\.[0-9]+)?\z/
|
|
8
|
+
|
|
9
|
+
module_function
|
|
10
|
+
|
|
11
|
+
def serialize(amount)
|
|
12
|
+
raw = case amount
|
|
13
|
+
when String
|
|
14
|
+
validate_string!(amount)
|
|
15
|
+
amount
|
|
16
|
+
when Integer
|
|
17
|
+
amount.to_s
|
|
18
|
+
else
|
|
19
|
+
bigdecimal_value(amount)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
normalized = normalize(raw)
|
|
23
|
+
validate_string!(normalized)
|
|
24
|
+
unless compare_decimals(normalized, MINIMUM) >= 0 && compare_decimals(normalized, MAXIMUM) <= 0
|
|
25
|
+
raise ValidationError, "amount must be between 0.000001 and 100000000"
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
normalized
|
|
29
|
+
rescue ArgumentError
|
|
30
|
+
raise ValidationError, "amount must be a plain decimal value"
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def validate_string!(amount)
|
|
34
|
+
return if DECIMAL_PATTERN.match?(amount)
|
|
35
|
+
|
|
36
|
+
raise ValidationError, "amount must use plain decimal notation without leading zeroes"
|
|
37
|
+
end
|
|
38
|
+
private_class_method :validate_string!
|
|
39
|
+
|
|
40
|
+
def normalize(value)
|
|
41
|
+
integer, fraction = value.split(".", 2)
|
|
42
|
+
return integer unless fraction
|
|
43
|
+
|
|
44
|
+
fraction = fraction.sub(/0+\z/, "")
|
|
45
|
+
fraction.empty? ? integer : "#{integer}.#{fraction}"
|
|
46
|
+
end
|
|
47
|
+
private_class_method :normalize
|
|
48
|
+
|
|
49
|
+
def bigdecimal_value(amount)
|
|
50
|
+
unless defined?(::BigDecimal) && amount.is_a?(::BigDecimal)
|
|
51
|
+
raise ValidationError, "amount must be a String, Integer, or BigDecimal"
|
|
52
|
+
end
|
|
53
|
+
unless amount.finite?
|
|
54
|
+
raise ValidationError, "amount must be between 0.000001 and 100000000"
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
amount.to_s("F")
|
|
58
|
+
end
|
|
59
|
+
private_class_method :bigdecimal_value
|
|
60
|
+
|
|
61
|
+
def compare_decimals(left, right)
|
|
62
|
+
left_integer, left_fraction = left.split(".", 2)
|
|
63
|
+
right_integer, right_fraction = right.split(".", 2)
|
|
64
|
+
left_fraction ||= ""
|
|
65
|
+
right_fraction ||= ""
|
|
66
|
+
|
|
67
|
+
integer_comparison = left_integer.length <=> right_integer.length
|
|
68
|
+
return integer_comparison unless integer_comparison.zero?
|
|
69
|
+
|
|
70
|
+
integer_comparison = left_integer <=> right_integer
|
|
71
|
+
return integer_comparison unless integer_comparison.zero?
|
|
72
|
+
|
|
73
|
+
width = [left_fraction.length, right_fraction.length].max
|
|
74
|
+
left_fraction.ljust(width, "0") <=> right_fraction.ljust(width, "0")
|
|
75
|
+
end
|
|
76
|
+
private_class_method :compare_decimals
|
|
77
|
+
end
|
|
78
|
+
end
|