From 524ec3fad116076d314d505c8afa1f2704e08017 Mon Sep 17 00:00:00 2001 From: IK Date: Wed, 17 Jun 2026 20:40:45 +0900 Subject: [PATCH] =?UTF-8?q?fix(#163):=20=E8=B3=BC=E5=85=A5=E3=83=AC?= =?UTF-8?q?=E3=82=B7=E3=83=BC=E3=83=88=E3=81=AE=E3=82=B5=E3=83=BC=E3=83=90?= =?UTF-8?q?=E3=83=BC=E5=81=B4=E6=A4=9C=E8=A8=BC=E3=82=92=E8=BF=BD=E5=8A=A0?= =?UTF-8?q?=EF=BC=88Android=E5=85=88=E8=A1=8C=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit クライアント改竄によるプレミアム不正取得を防ぐため、購入をサーバー側 (Cloud Functions)で検証する仕組みを追加。プレミアム判定の信頼できる 唯一のソースをサーバー検証済みフラグに一本化する(対応案#1/#3)。 サーバー: - functions/purchase/android_verifier.js: Play Developer API での検証 ロジック(APIクライアントを注入可能にしてユニットテスト済み・8件) - functions/index.js: verifyPurchase(onCall) を追加。認証+レート制限+ Android検証→検証成功時のみ premium_entitlement(サーバー専用doc)へ書込。 iOSは未対応(unimplemented)としTODO化 - firestore.rules: premium_entitlement をクライアント書込禁止(サーバー専用) クライアント: - purchase_verifier.dart: verifyPurchase を呼ぶ注入可能ラッパー (Firebase初期化前の構築でも落ちないよう instance アクセスを遅延) - one_time_purchase_service.dart: 購入成功時にAndroidはサーバー検証を必須化。 読込元を premium_entitlement に一本化。レガシーフラグのみの既存ユーザーは 起動時に自動再検証(restorePurchases)でシームレス移行。iOSは既存挙動を維持。 InAppPurchase.instance アクセスを遅延化(構築時の課金接続を回避) テスト: - server_verification_test / premium_migration_test を追加(実機なしで 「サーバー未検証ならプレミアムを付与しない」「移行判定」を検証) - firestore_rules/purchases.test.js を追加(JDK21+環境/CIで実行) 残作業(IK手動・docs/purchase-server-verification-setup.md 参照): サービスアカウント発行・Play Console権限付与・Secret Manager登録・deploy Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/purchase-server-verification-setup.md | 89 ++++++++ firestore.rules | 10 +- functions/index.js | 120 +++++++++++ functions/package-lock.json | 178 ++++++++++++++++ functions/package.json | 1 + .../purchase/android_publisher_client.js | 39 ++++ functions/purchase/android_verifier.js | 65 ++++++ .../test/purchase/android_verifier.test.js | 125 ++++++++++++ lib/services/one_time_purchase_service.dart | 136 +++++++++++-- .../purchase/purchase_persistence.dart | 37 ++++ lib/services/purchase/purchase_verifier.dart | 78 +++++++ test/firestore_rules/purchases.test.js | 86 ++++++++ test/providers/auth_provider_test.dart | 13 ++ .../services/feature_access_control_test.dart | 17 ++ .../one_time_purchase_service_test.dart | 17 ++ .../purchase/premium_migration_test.dart | 122 +++++++++++ .../purchase/server_verification_test.dart | 191 ++++++++++++++++++ 17 files changed, 1308 insertions(+), 16 deletions(-) create mode 100644 docs/purchase-server-verification-setup.md create mode 100644 functions/purchase/android_publisher_client.js create mode 100644 functions/purchase/android_verifier.js create mode 100644 functions/test/purchase/android_verifier.test.js create mode 100644 lib/services/purchase/purchase_verifier.dart create mode 100644 test/firestore_rules/purchases.test.js create mode 100644 test/services/purchase/premium_migration_test.dart create mode 100644 test/services/purchase/server_verification_test.dart diff --git a/docs/purchase-server-verification-setup.md b/docs/purchase-server-verification-setup.md new file mode 100644 index 00000000..f56b208c --- /dev/null +++ b/docs/purchase-server-verification-setup.md @@ -0,0 +1,89 @@ +# 購入サーバー検証のセットアップ手順(Issue #163) + +クライアント改竄によるプレミアム不正取得を防ぐため、購入レシートを +Cloud Functions(`verifyPurchase`)でサーバー検証する仕組みを追加した。 +コードは実装・テスト済みだが、**本番で動かすには以下の手動作業が必要**。 + +## 全体像 + +``` +アプリ(購入成功) + → verifyPurchase(platform, productId, purchaseToken) ← Cloud Function + → Google Play Developer API で購入トークンを検証 + → 検証OKなら users/{uid}/purchases/premium_entitlement に isPremium:true を書込 + (このドキュメントは Firestore ルールでクライアント書込禁止 = サーバー専用) +アプリ(起動時) + → premium_entitlement を読んでプレミアム判定(信頼できる唯一のソース) +``` + +## 手動作業(IK が実施) + +### 1. Google Cloud サービスアカウントの用意 + +1. Firebase プロジェクトの GCP コンソール → IAM と管理 → サービスアカウント。 +2. サービスアカウントを新規作成(例: `play-purchase-verifier`)。 + - 既存の Functions 実行用 SA を使い回さず、専用 SA を推奨。 +3. そのサービスアカウントの **JSON 鍵** を作成・ダウンロード。 + +### 2. Google Play Console 側で権限付与 + +1. [Google Play Console](https://play.google.com/console) → 「ユーザーと権限」。 +2. 上記サービスアカウントのメールアドレスを招待。 +3. アプリ権限で以下を付与(購入状態の参照に必要): + - 「財務データ、注文、キャンセル調査レポートを表示」 + - (または「注文と定期購入を管理」) +4. Google Cloud コンソールで **Google Play Android Developer API** を有効化。 + +> 反映に最大24〜48時間かかることがある。 + +### 3. Secret Manager に鍵を登録 + +ダウンロードした JSON 鍵の中身を、シークレット名 `GOOGLE_PLAY_SERVICE_ACCOUNT_JSON` で登録する。 + +```bash +# 例: JSONファイルからシークレットを作成 +firebase functions:secrets:set GOOGLE_PLAY_SERVICE_ACCOUNT_JSON +# プロンプトに JSON 全文を貼り付け(または < でファイルを流し込む) +``` + +### 4. デプロイ + +```bash +# Cloud Functions(verifyPurchase を含む) +cd functions +npm install +firebase deploy --only functions + +# Firestore ルール(premium_entitlement をサーバー専用書込にする) +cd .. +firebase deploy --only firestore:rules +``` + +## 動作確認 + +- 実機(Android)で購入 → プレミアムが有効になること。 +- Functions ログに「購入検証成功・エンタイトルメント付与」が出ること。 +- Firestore で `users/{uid}/purchases/premium_entitlement` が作成され、 + クライアントからは書き込めない(ルールで拒否される)こと。 +- 既存のプレミアム購入者: アプリ起動時に自動で `restorePurchases` が走り、 + サーバー再検証 → `premium_entitlement` 付与でシームレスに移行する。 + +## 既知の制約・TODO + +- **iOS は未対応**。`verifyPurchase` は iOS に対して `unimplemented` を返す。 + iOS リリース時に App Store Server API(JWT署名)で同等の検証を実装し、 + `_handleSuccessfulPurchase` の iOS 分岐をサーバー検証必須に切り替える。 + 現状 iOS はクライアント一次検証(`PurchaseValidator`)のみで付与している。 +- **Firestore ルールの emulator テスト**(`test/firestore_rules/purchases.test.js`)は + **JDK 21 以上が必要**。ローカルが JDK 17 の場合は実行できない(CI / JDK21+ 環境で実行)。 + +## 関連ファイル + +| 役割 | ファイル | +|---|---| +| Android 検証ロジック(純粋・テスト済み) | `functions/purchase/android_verifier.js` | +| Play API グルー(googleapis) | `functions/purchase/android_publisher_client.js` | +| Cloud Function 本体 | `functions/index.js`(`exports.verifyPurchase`) | +| Firestore ルール | `firestore.rules`(`premium_entitlement` 保護) | +| クライアント検証ラッパー | `lib/services/purchase/purchase_verifier.dart` | +| 購入処理・移行 | `lib/services/one_time_purchase_service.dart` | diff --git a/firestore.rules b/firestore.rules index 29fbb54e..0b3b5393 100644 --- a/firestore.rules +++ b/firestore.rules @@ -31,8 +31,14 @@ service cloud.firestore { // ユーザーの購入データのルール match /purchases/{purchaseId} { - // 認証されたユーザーは自分の購入データのみ読み書き可能 - allow read, write: if request.auth != null && request.auth.uid == userId; + // 読み取りは本人のみ + allow read: if request.auth != null && request.auth.uid == userId; + // 書き込みは本人のみ。ただし premium_entitlement(サーバー検証済みの + // プレミアムフラグ)は Cloud Functions(admin SDK)専用とし、クライアントの + // 書き込みを禁止する。改竄によるプレミアム不正取得を防ぐ(Issue #163)。 + allow write: if request.auth != null + && request.auth.uid == userId + && purchaseId != 'premium_entitlement'; } } diff --git a/functions/index.js b/functions/index.js index c145bf27..6bbd51d4 100644 --- a/functions/index.js +++ b/functions/index.js @@ -5,6 +5,10 @@ const logger = require('firebase-functions/logger'); const admin = require('firebase-admin'); const vision = require('@google-cloud/vision'); const openai = require('openai'); +const { verifyAndroidPurchase } = require('./purchase/android_verifier'); +const { + createAndroidPublisherDeps, +} = require('./purchase/android_publisher_client'); admin.initializeApp(); @@ -20,6 +24,23 @@ function getVisionClient() { // Secret Manager でAPIキーを管理(2nd Gen関数用) const openaiApiKey = defineSecret('OPENAI_API_KEY'); +// Google Play Developer API 用サービスアカウント鍵(JSON文字列)。 +// 購入レシート検証(Issue #163)で使用。Secret Manager で管理。 +const googlePlayServiceAccount = defineSecret('GOOGLE_PLAY_SERVICE_ACCOUNT_JSON'); + +// Android 購入検証用クライアントを遅延初期化 +let _androidPurchaseDeps = null; +function getAndroidPurchaseDeps() { + if (!_androidPurchaseDeps) { + // 環境変数(.env)を優先し、Secret Manager にフォールバック + const json = + process.env.GOOGLE_PLAY_SERVICE_ACCOUNT_JSON || + googlePlayServiceAccount.value(); + _androidPurchaseDeps = createAndroidPublisherDeps(json); + } + return _androidPurchaseDeps; +} + // OpenAI APIクライアントを遅延初期化 let _openaiClient = null; function getOpenAIClient() { @@ -486,3 +507,102 @@ exports.checkIngredientSimilarity = onCall( } } ); + +// Cloud Function: 購入レシートをサーバー側で検証する(Issue #163 対応案#1/#3) +// +// クライアントの PurchaseDetails を信頼せず、Google Play Developer API で +// 購入トークンを検証する。検証成功時のみ、サーバー専用ドキュメント +// users/{uid}/purchases/premium_entitlement に isPremium:true を書き込む +// (admin SDK は Firestore ルールを迂回するため、クライアントは書き込めない)。 +exports.verifyPurchase = onCall( + { memory: '256MiB', timeoutSeconds: 30, secrets: [googlePlayServiceAccount] }, + async (request) => { + // 認証チェック + if (!request.auth) { + throw new HttpsError('unauthenticated', '認証が必要です'); + } + + // レート制限チェック(既存のOCR等と共有) + await checkRateLimit(request.auth.uid); + + const { platform, productId, purchaseToken } = request.data || {}; + if (!platform || !productId || !purchaseToken) { + throw new HttpsError( + 'invalid-argument', + 'platform / productId / purchaseToken が必要です' + ); + } + + if (platform === 'ios') { + // TODO(#163): App Store Server API による iOS 検証は未対応。 + // iOS リリース時に android_verifier.js と同様の ios_verifier.js を追加する。 + throw new HttpsError('unimplemented', 'iOSのサーバー検証は未対応です'); + } + if (platform !== 'android') { + throw new HttpsError( + 'invalid-argument', + `未対応のプラットフォーム: ${platform}` + ); + } + + const uid = request.auth.uid; + + let result; + try { + result = await verifyAndroidPurchase( + { productId, purchaseToken }, + getAndroidPurchaseDeps() + ); + } catch (error) { + // 検証ロジックは例外を握って reason に変換するため、ここに来るのは + // クライアント構築失敗(鍵不正)等の想定外エラーのみ。 + logger.error('購入検証で予期せぬエラー', { + uid, + error: error.message, + }); + throw new HttpsError('internal', '購入検証に失敗しました'); + } + + if (!result.valid) { + logger.warn('購入検証に失敗(プレミアム付与せず)', { + uid, + productId, + reason: result.reason, + }); + // API一時障害は再試行可能なエラーとして返す + if (result.reason === 'api_error') { + throw new HttpsError( + 'unavailable', + '検証サーバーに接続できませんでした。時間をおいて再試行してください' + ); + } + throw new HttpsError('permission-denied', '購入を検証できませんでした'); + } + + // サーバー専用ドキュメントにエンタイトルメントを書き込む + await admin + .firestore() + .collection('users') + .doc(uid) + .collection('purchases') + .doc('premium_entitlement') + .set( + { + isPremium: true, + productId, + platform: 'android', + orderId: result.orderId || null, + verifiedAt: admin.firestore.FieldValue.serverTimestamp(), + }, + { merge: true } + ); + + logger.info('購入検証成功・エンタイトルメント付与', { + uid, + productId, + orderId: result.orderId, + }); + + return { verified: true }; + } +); diff --git a/functions/package-lock.json b/functions/package-lock.json index 2dc71124..5bb7628d 100644 --- a/functions/package-lock.json +++ b/functions/package-lock.json @@ -11,6 +11,7 @@ "@google-cloud/vision": "^5.3.7", "firebase-admin": "^13.6.1", "firebase-functions": "^7.2.5", + "googleapis": "^173.0.0", "openai": "^6.42.0" }, "engines": { @@ -1872,6 +1873,177 @@ "node": ">=14" } }, + "node_modules/googleapis": { + "version": "173.0.0", + "resolved": "https://registry.npmjs.org/googleapis/-/googleapis-173.0.0.tgz", + "integrity": "sha512-xEJJYLZ4qeenVyfzispNfRjCe9bsv7CzBv5zYFLvScOze9snJ8S9W6hjQ729CWPQt5mvn/JrcRaCHzQiukt0ng==", + "license": "Apache-2.0", + "dependencies": { + "google-auth-library": "^10.2.0", + "googleapis-common": "^8.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/googleapis-common": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/googleapis-common/-/googleapis-common-8.0.2.tgz", + "integrity": "sha512-5MXeQzIZaqCH7B+HJWqhQm946VARpZep6acbWSr/fcgF2cQANq7allgX+i/G0EqF0WyUxB277gtWMzRYHMl9tg==", + "license": "Apache-2.0", + "dependencies": { + "extend": "^3.0.2", + "gaxios": "7.1.3", + "google-auth-library": "10.5.0", + "google-logging-utils": "1.1.3", + "qs": "^6.7.0", + "url-template": "^2.0.8" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/googleapis-common/node_modules/gaxios": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/gaxios/-/gaxios-7.1.3.tgz", + "integrity": "sha512-YGGyuEdVIjqxkxVH1pUTMY/XtmmsApXrCVv5EU25iX6inEPbV+VakJfLealkBtJN69AQmh1eGOdCl9Sm1UP6XQ==", + "license": "Apache-2.0", + "dependencies": { + "extend": "^3.0.2", + "https-proxy-agent": "^7.0.1", + "node-fetch": "^3.3.2", + "rimraf": "^5.0.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/googleapis-common/node_modules/gcp-metadata": { + "version": "8.1.3", + "resolved": "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-8.1.3.tgz", + "integrity": "sha512-ziTrzUhhpL9Zk5k0HHzgP/KIpWDJT0VMBC/ynt/QIBvTW+UUcSivQRl6VlwTf/EilDxtSWklHoRsKy1c4k+59w==", + "license": "Apache-2.0", + "dependencies": { + "gaxios": "7.1.3", + "google-logging-utils": "1.1.3", + "json-bigint": "^1.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/googleapis-common/node_modules/google-auth-library": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-10.5.0.tgz", + "integrity": "sha512-7ABviyMOlX5hIVD60YOfHw4/CxOfBhyduaYB+wbFWCWoni4N7SLcV46hrVRktuBbZjFC9ONyqamZITN7q3n32w==", + "license": "Apache-2.0", + "dependencies": { + "base64-js": "^1.3.0", + "ecdsa-sig-formatter": "^1.0.11", + "gaxios": "^7.0.0", + "gcp-metadata": "^8.0.0", + "google-logging-utils": "^1.0.0", + "gtoken": "^8.0.0", + "jws": "^4.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/googleapis-common/node_modules/gtoken": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/gtoken/-/gtoken-8.0.0.tgz", + "integrity": "sha512-+CqsMbHPiSTdtSO14O51eMNlrp9N79gmeqmXeouJOhfucAedHw9noVe/n5uJk3tbKE6a+6ZCQg3RPhVhHByAIw==", + "license": "MIT", + "dependencies": { + "gaxios": "^7.0.0", + "jws": "^4.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/googleapis-common/node_modules/node-fetch": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.3.2.tgz", + "integrity": "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==", + "license": "MIT", + "dependencies": { + "data-uri-to-buffer": "^4.0.0", + "fetch-blob": "^3.1.4", + "formdata-polyfill": "^4.0.10" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/node-fetch" + } + }, + "node_modules/googleapis/node_modules/gaxios": { + "version": "7.1.5", + "resolved": "https://registry.npmjs.org/gaxios/-/gaxios-7.1.5.tgz", + "integrity": "sha512-5FZy72Rh8LhtjmvDrKkI+lVhrsQrVKVsItxMoDm5mNQE+xR0WVIIs+jzPSJgBvKVsLi24fZhXJIsNI0bihDzFg==", + "license": "Apache-2.0", + "dependencies": { + "extend": "^3.0.2", + "https-proxy-agent": "^7.0.1", + "node-fetch": "^3.3.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/googleapis/node_modules/gcp-metadata": { + "version": "8.1.2", + "resolved": "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-8.1.2.tgz", + "integrity": "sha512-zV/5HKTfCeKWnxG0Dmrw51hEWFGfcF2xiXqcA3+J90WDuP0SvoiSO5ORvcBsifmx/FoIjgQN3oNOGaQ5PhLFkg==", + "license": "Apache-2.0", + "dependencies": { + "gaxios": "^7.0.0", + "google-logging-utils": "^1.0.0", + "json-bigint": "^1.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/googleapis/node_modules/google-auth-library": { + "version": "10.7.0", + "resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-10.7.0.tgz", + "integrity": "sha512-QpTAbNJ36TliZLx3TTtahR8HG0hN9RllL1e3FymOvQSIKK8JmgV58H924ub2wa2DsS3ANjjP1Aw1N+Ramc8hqQ==", + "license": "Apache-2.0", + "dependencies": { + "base64-js": "^1.3.0", + "ecdsa-sig-formatter": "^1.0.11", + "gaxios": "^7.1.4", + "gcp-metadata": "8.1.2", + "google-logging-utils": "1.1.3", + "jws": "^4.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/googleapis/node_modules/node-fetch": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.3.2.tgz", + "integrity": "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==", + "license": "MIT", + "dependencies": { + "data-uri-to-buffer": "^4.0.0", + "fetch-blob": "^3.1.4", + "formdata-polyfill": "^4.0.10" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/node-fetch" + } + }, "node_modules/gopd": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", @@ -3224,6 +3396,12 @@ "node": ">= 0.8" } }, + "node_modules/url-template": { + "version": "2.0.8", + "resolved": "https://registry.npmjs.org/url-template/-/url-template-2.0.8.tgz", + "integrity": "sha512-XdVKMF4SJ0nP/O7XIPB0JwAEuT9lDIYnNsK8yGVe43y0AWoKeJNdv3ZNWh7ksJ6KqQFjOO6ox/VEitLnaVNufw==", + "license": "BSD" + }, "node_modules/util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", diff --git a/functions/package.json b/functions/package.json index 0ecda371..e87726f8 100644 --- a/functions/package.json +++ b/functions/package.json @@ -12,6 +12,7 @@ "@google-cloud/vision": "^5.3.7", "firebase-admin": "^13.6.1", "firebase-functions": "^7.2.5", + "googleapis": "^173.0.0", "openai": "^6.42.0" } } diff --git a/functions/purchase/android_publisher_client.js b/functions/purchase/android_publisher_client.js new file mode 100644 index 00000000..9f041a0c --- /dev/null +++ b/functions/purchase/android_publisher_client.js @@ -0,0 +1,39 @@ +'use strict'; + +const { google } = require('googleapis'); + +/** + * サービスアカウントJSON文字列から、Google Play Developer API を呼ぶ + * `getProductPurchase` 関数を構築する。 + * + * android_verifier.js に注入する依存(deps)として使う。検証ロジック本体は + * android_verifier.js 側にあり、本モジュールは googleapis への薄いグルーのみ。 + * + * @param {string} serviceAccountJson - GOOGLE_PLAY_SERVICE_ACCOUNT_JSON の値 + * (サービスアカウント鍵のJSON文字列) + * @returns {{getProductPurchase: (args: {packageName: string, + * productId: string, token: string}) => Promise}} + */ +function createAndroidPublisherDeps(serviceAccountJson) { + const credentials = JSON.parse(serviceAccountJson); + + const auth = new google.auth.GoogleAuth({ + credentials, + scopes: ['https://www.googleapis.com/auth/androidpublisher'], + }); + + const androidpublisher = google.androidpublisher({ version: 'v3', auth }); + + return { + getProductPurchase: async ({ packageName, productId, token }) => { + const res = await androidpublisher.purchases.products.get({ + packageName, + productId, + token, + }); + return res.data; + }, + }; +} + +module.exports = { createAndroidPublisherDeps }; diff --git a/functions/purchase/android_verifier.js b/functions/purchase/android_verifier.js new file mode 100644 index 00000000..f9305714 --- /dev/null +++ b/functions/purchase/android_verifier.js @@ -0,0 +1,65 @@ +'use strict'; + +/** + * Android(Google Play)の購入レシート検証ロジック。 + * + * Google Play Developer API の `purchases.products.get` を用いて購入トークンを + * 検証する純粋ロジック。API クライアントは [deps.getProductPurchase] として + * 外から注入する(テスト時はモックを渡し、本番は googleapis を使う)。 + * + * Issue #163 対応案#1: 改竄に対する真の防御をサーバー側で行う。 + */ + +/** アプリの Android パッケージ名(android/app/build.gradle の applicationId と一致)。 */ +const PACKAGE_NAME = 'com.ikcoding.maikago'; + +/** プレミアム有効化に対応する商品ID。 */ +const VALID_PRODUCT_IDS = new Set(['maikago_premium_unlock']); + +/** + * Android の購入を検証する。 + * + * @param {Object} params + * @param {string} params.productId - 購入された商品ID + * @param {string} params.purchaseToken - Google Play が発行した購入トークン + * @param {Object} deps + * @param {(args: {packageName: string, productId: string, token: string}) => + * Promise} deps.getProductPurchase + * - Play Developer API の ProductPurchase を返す関数(注入) + * @returns {Promise<{valid: boolean, reason?: string, orderId?: string}>} + */ +async function verifyAndroidPurchase({ productId, purchaseToken }, deps) { + if (!productId || !purchaseToken) { + return { valid: false, reason: 'missing_params' }; + } + + if (!VALID_PRODUCT_IDS.has(productId)) { + return { valid: false, reason: 'unknown_product' }; + } + + let purchase; + try { + purchase = await deps.getProductPurchase({ + packageName: PACKAGE_NAME, + productId, + token: purchaseToken, + }); + } catch (_e) { + // API 失敗は「検証できなかった」として扱い、例外は握って判定に変換する。 + // (プレミアムは付与しない。呼び出し側で再試行可能にする) + return { valid: false, reason: 'api_error' }; + } + + if (!purchase) { + return { valid: false, reason: 'not_found' }; + } + + // purchaseState: 0=Purchased / 1=Canceled / 2=Pending + if (purchase.purchaseState !== 0) { + return { valid: false, reason: 'not_purchased' }; + } + + return { valid: true, orderId: purchase.orderId }; +} + +module.exports = { verifyAndroidPurchase, PACKAGE_NAME, VALID_PRODUCT_IDS }; diff --git a/functions/test/purchase/android_verifier.test.js b/functions/test/purchase/android_verifier.test.js new file mode 100644 index 00000000..1f9969f6 --- /dev/null +++ b/functions/test/purchase/android_verifier.test.js @@ -0,0 +1,125 @@ +'use strict'; + +const { test } = require('node:test'); +const assert = require('node:assert'); + +const { + verifyAndroidPurchase, + PACKAGE_NAME, +} = require('../../purchase/android_verifier'); + +// 正規の ProductPurchase(Google Play Developer API のレスポンス相当)を返す偽API。 +function fakeApi(productPurchase) { + return { + getProductPurchase: async () => productPurchase, + }; +} + +test('正規購入(purchaseState=0)は valid:true と orderId を返す', async () => { + const deps = fakeApi({ purchaseState: 0, orderId: 'GPA.TEST-0001' }); + + const result = await verifyAndroidPurchase( + { productId: 'maikago_premium_unlock', purchaseToken: 'valid-token' }, + deps, + ); + + assert.strictEqual(result.valid, true); + assert.strictEqual(result.orderId, 'GPA.TEST-0001'); +}); + +test('正規購入では正しい packageName/productId/token で API を呼ぶ', async () => { + let calledWith = null; + const deps = { + getProductPurchase: async (args) => { + calledWith = args; + return { purchaseState: 0, orderId: 'GPA.TEST-0002' }; + }, + }; + + await verifyAndroidPurchase( + { productId: 'maikago_premium_unlock', purchaseToken: 'tok-123' }, + deps, + ); + + assert.deepStrictEqual(calledWith, { + packageName: PACKAGE_NAME, + productId: 'maikago_premium_unlock', + token: 'tok-123', + }); +}); + +test('未知の productId は valid:false(unknown_product)', async () => { + const deps = fakeApi({ purchaseState: 0, orderId: 'x' }); + + const result = await verifyAndroidPurchase( + { productId: 'not_a_real_product', purchaseToken: 'valid-token' }, + deps, + ); + + assert.strictEqual(result.valid, false); + assert.strictEqual(result.reason, 'unknown_product'); +}); + +test('purchaseToken 欠落は valid:false(missing_params)', async () => { + const deps = fakeApi({ purchaseState: 0 }); + + const result = await verifyAndroidPurchase( + { productId: 'maikago_premium_unlock', purchaseToken: '' }, + deps, + ); + + assert.strictEqual(result.valid, false); + assert.strictEqual(result.reason, 'missing_params'); +}); + +test('キャンセル済み(purchaseState=1)は valid:false(not_purchased)', async () => { + const deps = fakeApi({ purchaseState: 1, orderId: 'GPA.CANCELED' }); + + const result = await verifyAndroidPurchase( + { productId: 'maikago_premium_unlock', purchaseToken: 'valid-token' }, + deps, + ); + + assert.strictEqual(result.valid, false); + assert.strictEqual(result.reason, 'not_purchased'); +}); + +test('保留中(purchaseState=2)は valid:false(not_purchased)', async () => { + const deps = fakeApi({ purchaseState: 2 }); + + const result = await verifyAndroidPurchase( + { productId: 'maikago_premium_unlock', purchaseToken: 'valid-token' }, + deps, + ); + + assert.strictEqual(result.valid, false); + assert.strictEqual(result.reason, 'not_purchased'); +}); + +test('API がエラーを投げたら valid:false(api_error)— 例外を握らず判定に変換', async () => { + const deps = { + getProductPurchase: async () => { + throw new Error('network down'); + }, + }; + + const result = await verifyAndroidPurchase( + { productId: 'maikago_premium_unlock', purchaseToken: 'valid-token' }, + deps, + ); + + assert.strictEqual(result.valid, false); + assert.strictEqual(result.reason, 'api_error'); +}); + +test('購入が見つからない(null)は valid:false(not_found)', async () => { + const deps = fakeApi(null); + + const result = await verifyAndroidPurchase( + { productId: 'maikago_premium_unlock', purchaseToken: 'valid-token' }, + deps, + ); + + assert.strictEqual(result.valid, false); + assert.strictEqual(result.reason, 'not_found'); +}); diff --git a/lib/services/one_time_purchase_service.dart b/lib/services/one_time_purchase_service.dart index 07fe445f..dd533e77 100644 --- a/lib/services/one_time_purchase_service.dart +++ b/lib/services/one_time_purchase_service.dart @@ -1,5 +1,10 @@ import 'package:flutter/foundation.dart' - show kIsWeb, ChangeNotifier, defaultTargetPlatform, TargetPlatform; + show + kIsWeb, + ChangeNotifier, + defaultTargetPlatform, + TargetPlatform, + visibleForTesting; import 'package:firebase_core/firebase_core.dart'; import 'package:in_app_purchase/in_app_purchase.dart'; import 'package:shared_preferences/shared_preferences.dart'; @@ -12,20 +17,35 @@ import 'package:maikago/services/debug_service.dart'; import 'package:maikago/services/purchase/trial_manager.dart'; import 'package:maikago/services/purchase/purchase_persistence.dart'; import 'package:maikago/services/purchase/purchase_validator.dart'; +import 'package:maikago/services/purchase/purchase_verifier.dart'; import 'package:maikago/services/purchase/restore_coordinator.dart'; /// 非消耗型アプリ内課金管理サービス class OneTimePurchaseService extends ChangeNotifier { - OneTimePurchaseService() { + OneTimePurchaseService({ + PurchaseVerifier? purchaseVerifier, + PurchasePersistence? persistence, + }) : _persistence = persistence ?? PurchasePersistence(), + _purchaseVerifier = + purchaseVerifier ?? CloudFunctionsPurchaseVerifier() { _trialManager = TrialManager( onStateChanged: _onTrialStateChanged, ); } - final PurchasePersistence _persistence = PurchasePersistence(); + final PurchasePersistence _persistence; + final PurchaseVerifier _purchaseVerifier; late final TrialManager _trialManager; - final InAppPurchase _inAppPurchase = InAppPurchase.instance; + /// サーバー未検証だがレガシーのプレミアムフラグが残っているユーザー向けに、 + /// 起動時に自動再検証(restorePurchases)が必要かどうか(Issue #163 移行)。 + bool _needsServerReverification = false; + + // 遅延初期化: 実際にストアが必要になるまで InAppPurchase.instance に触れない + // (構築しただけで課金クライアント接続を開始させない。テスト容易性も向上)。 + InAppPurchase? _inAppPurchaseInstance; + InAppPurchase get _inAppPurchase => + _inAppPurchaseInstance ??= InAppPurchase.instance; StreamSubscription>? _purchaseSubscription; bool _isStoreAvailable = false; @@ -150,6 +170,7 @@ class OneTimePurchaseService extends ChangeNotifier { if (userId != null) { _currentUserId = userId; await _loadFromFirestore(); + _maybeTriggerServerReverification(); notifyListeners(); // ユーザーID変更時の通知を追加 } return; @@ -164,6 +185,7 @@ class OneTimePurchaseService extends ChangeNotifier { if (Firebase.apps.isNotEmpty) { await _loadFromFirestore(); + _maybeTriggerServerReverification(); } DebugService().logInfo('非消耗型アプリ内課金初期化完了'); // 初期化時に体験期間タイマーをセット @@ -269,14 +291,41 @@ class OneTimePurchaseService extends ChangeNotifier { Future _handleSuccessfulPurchase( PurchaseDetails purchaseDetails) async { // クライアント側の一次検証: 不正・未完了の購入情報ではプレミアムを有効化しない。 - // (真の改竄対策はサーバー側レシート検証で別途対応する。Issue #163 対応案#1) if (!PurchaseValidator.isValidPremiumPurchase(purchaseDetails)) { DebugService().logWarning( '不正または未完了の購入情報を拒否: ${purchaseDetails.productID} / ${purchaseDetails.status}'); return; } - // 購入済み機能を更新 + // サーバー側レシート検証(Issue #163 対応案#1/#3)。 + // Android はサーバー検証を必須とし、検証成功時のみプレミアムを付与する。 + // サーバーは検証成功時に premium_entitlement(サーバー専用ドキュメント)へ + // 書き込むため、クライアント改竄では付与できない。 + // iOS はサーバー検証が未実装のため、当面はクライアント一次検証のみで付与する + // (TODO #163: App Store Server API 対応時に Android と同様の必須化を行う)。 + final isIos = defaultTargetPlatform == TargetPlatform.iOS; + if (!isIos) { + try { + final result = await _purchaseVerifier.verify( + platform: 'android', + productId: purchaseDetails.productID, + purchaseToken: + purchaseDetails.verificationData.serverVerificationData, + ); + if (!result.verified) { + DebugService().logWarning( + 'サーバー購入検証に失敗(プレミアム付与せず): ${purchaseDetails.productID}'); + _setError('購入を検証できませんでした。時間をおいて再試行してください。'); + return; + } + } on PurchaseVerificationException catch (e) { + DebugService().logError('サーバー購入検証エラー: ${e.code}'); + _setError('購入の検証に失敗しました。時間をおいて再試行してください。'); + return; + } + } + + // 検証通過 → 購入済み機能を更新 if (PurchaseValidator.premiumProductIds .contains(purchaseDetails.productID)) { _userPremiumStatus[_currentUserId] = true; @@ -287,14 +336,38 @@ class OneTimePurchaseService extends ChangeNotifier { _restoreCoordinator.signalRestored(); } - // ローカルストレージとFirestoreに保存 + // ローカルストレージにキャッシュ(オフライン表示用)。プレミアムの最終ソースは + // サーバーの premium_entitlement であり、次回ロード時に上書きされる。 await _saveToLocalStorage(); - await _saveToFirestore(); notifyListeners(); - DebugService().logInfo('購入完了: ${purchaseDetails.productID}'); + DebugService().logInfo('購入検証成功・プレミアム付与: ${purchaseDetails.productID}'); + } + + /// テスト用: 現在のユーザーIDを直接設定する。 + @visibleForTesting + void setCurrentUserIdForTest(String userId) { + _currentUserId = userId; + } + + /// テスト用: 購入成功時の内部処理を直接呼び出す。 + @visibleForTesting + Future handleSuccessfulPurchaseForTest(PurchaseDetails details) { + return _handleSuccessfulPurchase(details); } + /// テスト用: デバイスフィンガープリントを直接設定する。 + @visibleForTesting + set deviceFingerprintForTest(String value) => _deviceFingerprint = value; + + /// テスト用: Firestore読み込み(移行判定含む)を直接呼び出す。 + @visibleForTesting + Future loadFromFirestoreForTest() => _loadFromFirestore(); + + /// テスト用: 自動再検証が必要と判定されたか。 + @visibleForTesting + bool get needsServerReverificationForTest => _needsServerReverification; + /// 商品を購入 Future purchaseProduct(OneTimePurchase purchase) async { try { @@ -400,19 +473,54 @@ class OneTimePurchaseService extends ChangeNotifier { } /// Firestoreから読み込み(PurchasePersistenceに委譲) + /// + /// プレミアム判定の信頼できる唯一のソースは、サーバー検証済みの + /// premium_entitlement(Issue #163 対応案#3)。レガシーのクライアント + /// フラグ(premium_status_map)は移行判定にのみ使う。 Future _loadFromFirestore() async { if (_currentUserId.isEmpty || _deviceFingerprint == null) return; + // サーバー検証済みエンタイトルメント(信頼できる唯一のソース) + final isPremiumVerified = + await _persistence.loadServerEntitlement(userId: _currentUserId); + + // 体験期間履歴 + レガシーフラグ(移行判定用) final data = await _persistence.loadFromFirestore( userId: _currentUserId, deviceFingerprint: _deviceFingerprint!, ); - if (data != null) { - _userPremiumStatus[_currentUserId] = data.isPremium; - if (data.isTrialEverStarted) { - _trialManager.markAsEverStarted(); - } + final hasLegacyPremium = data?.isPremium ?? false; + if (data != null && data.isTrialEverStarted) { + _trialManager.markAsEverStarted(); + } + + final isIos = defaultTargetPlatform == TargetPlatform.iOS; + if (isIos) { + // iOSはサーバー検証が未実装。既存挙動を壊さないよう、サーバー検証済み + // またはレガシーフラグのいずれかでプレミアムとする(自動再検証はしない)。 + _userPremiumStatus[_currentUserId] = + isPremiumVerified || hasLegacyPremium; + _needsServerReverification = false; + } else { + // Androidはサーバー検証済みフラグのみを信頼。レガシーフラグだけが立つ + // 既存ユーザーは、起動時の自動再検証でシームレスに移行する。 + _userPremiumStatus[_currentUserId] = isPremiumVerified; + _needsServerReverification = !isPremiumVerified && hasLegacyPremium; + } + } + + /// レガシープレミアムの自動再検証を必要なら起動する(Issue #163 移行)。 + /// + /// サーバー検証済みフラグがなく、レガシーのクライアントフラグだけが残る + /// 既存ユーザーに対し、restorePurchases を自動実行してサーバー再検証へ導く。 + void _maybeTriggerServerReverification() { + if (_needsServerReverification && _isStoreAvailable) { + _needsServerReverification = false; + DebugService().logInfo('レガシープレミアムの自動再検証を開始(Issue #163移行)'); + // 結果は purchaseStream 経由で _handleSuccessfulPurchase が受け、 + // サーバー検証→entitlement付与まで進む。失敗してもUIは継続。 + unawaited(restorePurchases()); } } diff --git a/lib/services/purchase/purchase_persistence.dart b/lib/services/purchase/purchase_persistence.dart index 62555187..acda4972 100644 --- a/lib/services/purchase/purchase_persistence.dart +++ b/lib/services/purchase/purchase_persistence.dart @@ -158,6 +158,43 @@ class PurchasePersistence { } } + /// サーバー検証済みのプレミアムエンタイトルメントを読み込む(Issue #163)。 + /// + /// Cloud Functions(admin SDK)のみが書き込む `premium_entitlement` + /// ドキュメントの `isPremium` を返す。これがプレミアム判定の信頼できる + /// 唯一のソース。存在しない・読み込み失敗時は false。 + Future loadServerEntitlement({required String userId}) async { + try { + if (userId.isEmpty) return false; + + if (kIsWeb) { + try { + if (Firebase.apps.isEmpty) return false; + } catch (e) { + return false; + } + } + + final firestore = _firestore; + if (firestore == null) return false; + + final doc = await firestore + .collection('users') + .doc(userId) + .collection('purchases') + .doc('premium_entitlement') + .get(); + + if (doc.exists) { + return doc.data()?['isPremium'] == true; + } + return false; + } catch (e) { + DebugService().logError('サーバーエンタイトルメント読み込みエラー: $e'); + return false; + } + } + /// Firestoreから購入データを読み込む /// /// 戻り値がnullの場合、データが存在しないか読み込みに失敗したことを示す。 diff --git a/lib/services/purchase/purchase_verifier.dart b/lib/services/purchase/purchase_verifier.dart new file mode 100644 index 00000000..b162d4a2 --- /dev/null +++ b/lib/services/purchase/purchase_verifier.dart @@ -0,0 +1,78 @@ +import 'package:cloud_functions/cloud_functions.dart'; + +/// 購入のサーバー検証結果。 +class PurchaseVerificationResult { + const PurchaseVerificationResult({required this.verified}); + + /// サーバーが購入を検証し、プレミアムを有効化してよいと判断した場合 true。 + final bool verified; +} + +/// サーバー検証が失敗・通信不能だったことを表す例外。 +/// +/// [code] は Cloud Functions のエラーコード(例: 'permission-denied', +/// 'unavailable', 'unimplemented')。呼び出し側は再試行可否の判断に使う。 +class PurchaseVerificationException implements Exception { + PurchaseVerificationException(this.code, this.message); + + final String code; + final String message; + + @override + String toString() => 'PurchaseVerificationException($code): $message'; +} + +/// 購入をサーバー(Cloud Functions)で検証する責務を抽象化する。 +/// +/// クライアント内の [PurchaseDetails] は信頼せず、サーバー側で +/// Google Play / App Store のレシートを検証する(Issue #163 対応案#1/#3)。 +/// テスト時は本インターフェースを実装したFakeに差し替える。 +abstract class PurchaseVerifier { + /// サーバー検証を実行する。 + /// + /// 検証に成功すれば [PurchaseVerificationResult.verified] が true。 + /// 検証拒否・通信失敗時は [PurchaseVerificationException] を投げる。 + Future verify({ + required String platform, + required String productId, + required String purchaseToken, + }); +} + +/// Cloud Functions の `verifyPurchase` を呼ぶ本番実装。 +class CloudFunctionsPurchaseVerifier implements PurchaseVerifier { + CloudFunctionsPurchaseVerifier({FirebaseFunctions? functions}) + : _injectedFunctions = functions; + + final FirebaseFunctions? _injectedFunctions; + + // Firebase 初期化前にインスタンスを構築しても落ちないよう、 + // FirebaseFunctions.instance へのアクセスは verify() 実行時まで遅延する + // (main.dart はサービスを Firebase.initializeApp より前に生成するため)。 + FirebaseFunctions get _functions => + _injectedFunctions ?? FirebaseFunctions.instance; + + @override + Future verify({ + required String platform, + required String productId, + required String purchaseToken, + }) async { + try { + final callable = _functions.httpsCallable('verifyPurchase'); + final result = await callable.call({ + 'platform': platform, + 'productId': productId, + 'purchaseToken': purchaseToken, + }); + + final data = (result.data as Map?) ?? const {}; + return PurchaseVerificationResult(verified: data['verified'] == true); + } on FirebaseFunctionsException catch (e) { + throw PurchaseVerificationException( + e.code, + e.message ?? '購入検証に失敗しました', + ); + } + } +} diff --git a/test/firestore_rules/purchases.test.js b/test/firestore_rules/purchases.test.js new file mode 100644 index 00000000..97ffdc9b --- /dev/null +++ b/test/firestore_rules/purchases.test.js @@ -0,0 +1,86 @@ +// Firestore セキュリティルール: 購入データ(Issue #163)の emulator テスト +// +// premium_entitlement(サーバー検証済みのプレミアムフラグ)は Cloud Functions +// (admin SDK) 専用書き込みで、クライアントからは書き込めないことを検証する。 +// 体験期間データ(one_time_purchases)は従来通りクライアント書き込み可能。 + +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { dirname, join } from 'node:path'; + +import { + initializeTestEnvironment, + assertFails, + assertSucceeds, +} from '@firebase/rules-unit-testing'; +import { doc, setDoc, getDoc } from 'firebase/firestore'; +import { before, after, beforeEach, describe, it } from 'mocha'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const rulesPath = join(__dirname, '..', '..', 'firestore.rules'); + +let testEnv; + +before(async () => { + testEnv = await initializeTestEnvironment({ + projectId: 'demo-maikago', + firestore: { + rules: readFileSync(rulesPath, 'utf8'), + host: '127.0.0.1', + port: 8080, + }, + }); +}); + +after(async () => { + await testEnv.cleanup(); +}); + +beforeEach(async () => { + await testEnv.clearFirestore(); +}); + +describe('purchases ルール(Issue #163: サーバー検証済みフラグの保護)', () => { + const uid = 'user1'; + const entitlementPath = `users/${uid}/purchases/premium_entitlement`; + const trialPath = `users/${uid}/purchases/one_time_purchases`; + + it('本人でも premium_entitlement は書き込めない(サーバー専用)', async () => { + const db = testEnv.authenticatedContext(uid).firestore(); + await assertFails( + setDoc(doc(db, entitlementPath), { isPremium: true }), + ); + }); + + it('本人は premium_entitlement を読み取れる', async () => { + // サーバー(ルール無効)でエンタイトルメントを先に書いておく + await testEnv.withSecurityRulesDisabled(async (ctx) => { + await setDoc(doc(ctx.firestore(), entitlementPath), { isPremium: true }); + }); + + const db = testEnv.authenticatedContext(uid).firestore(); + await assertSucceeds(getDoc(doc(db, entitlementPath))); + }); + + it('他人の premium_entitlement は読み取れない', async () => { + await testEnv.withSecurityRulesDisabled(async (ctx) => { + await setDoc(doc(ctx.firestore(), entitlementPath), { isPremium: true }); + }); + + const db = testEnv.authenticatedContext('attacker').firestore(); + await assertFails(getDoc(doc(db, entitlementPath))); + }); + + it('本人は one_time_purchases(体験期間データ)を書き込める', async () => { + const db = testEnv.authenticatedContext(uid).firestore(); + await assertSucceeds( + setDoc(doc(db, trialPath), { trial_history: {} }), + ); + }); + + it('未認証ユーザーは premium_entitlement を読み書きできない', async () => { + const db = testEnv.unauthenticatedContext().firestore(); + await assertFails(getDoc(doc(db, entitlementPath))); + await assertFails(setDoc(doc(db, entitlementPath), { isPremium: true })); + }); +}); diff --git a/test/providers/auth_provider_test.dart b/test/providers/auth_provider_test.dart index 0b85965b..8612a9e1 100644 --- a/test/providers/auth_provider_test.dart +++ b/test/providers/auth_provider_test.dart @@ -1,5 +1,6 @@ import 'package:flutter/foundation.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:in_app_purchase/in_app_purchase.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'package:maikago/providers/auth_provider.dart'; import 'package:maikago/models/migration_result.dart'; @@ -68,6 +69,18 @@ class FakeOneTimePurchaseService extends ChangeNotifier void resetForLogout() { resetForLogoutCalled = true; } + + // テストシーム(本体の @visibleForTesting メソッド)。このFakeでは未使用。 + @override + void setCurrentUserIdForTest(String userId) {} + @override + Future handleSuccessfulPurchaseForTest(PurchaseDetails details) async {} + @override + set deviceFingerprintForTest(String value) {} + @override + Future loadFromFirestoreForTest() async {} + @override + bool get needsServerReverificationForTest => false; } void main() { diff --git a/test/services/feature_access_control_test.dart b/test/services/feature_access_control_test.dart index 83ea0457..09c47b6f 100644 --- a/test/services/feature_access_control_test.dart +++ b/test/services/feature_access_control_test.dart @@ -1,5 +1,6 @@ import 'package:flutter/foundation.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:in_app_purchase/in_app_purchase.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'package:maikago/services/feature_access_control.dart'; import 'package:maikago/services/one_time_purchase_service.dart'; @@ -95,6 +96,22 @@ class FakeOneTimePurchaseService extends ChangeNotifier @override void resetForLogout() {} + + // テストシーム(本体の @visibleForTesting メソッド)。このFakeでは未使用。 + @override + void setCurrentUserIdForTest(String userId) {} + + @override + Future handleSuccessfulPurchaseForTest(PurchaseDetails details) async {} + + @override + set deviceFingerprintForTest(String value) {} + + @override + Future loadFromFirestoreForTest() async {} + + @override + bool get needsServerReverificationForTest => false; } void main() { diff --git a/test/services/one_time_purchase_service_test.dart b/test/services/one_time_purchase_service_test.dart index fa8d088e..9b989665 100644 --- a/test/services/one_time_purchase_service_test.dart +++ b/test/services/one_time_purchase_service_test.dart @@ -1,5 +1,6 @@ import 'package:flutter/foundation.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:in_app_purchase/in_app_purchase.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'package:maikago/models/one_time_purchase.dart'; import 'package:maikago/services/one_time_purchase_service.dart'; @@ -137,6 +138,22 @@ class FakeOneTimePurchaseService extends ChangeNotifier @override Future restorePurchases() async => false; + + // テストシーム(本体の @visibleForTesting メソッド)。このFakeでは未使用。 + @override + void setCurrentUserIdForTest(String userId) {} + + @override + Future handleSuccessfulPurchaseForTest(PurchaseDetails details) async {} + + @override + set deviceFingerprintForTest(String value) {} + + @override + Future loadFromFirestoreForTest() async {} + + @override + bool get needsServerReverificationForTest => false; } void main() { diff --git a/test/services/purchase/premium_migration_test.dart b/test/services/purchase/premium_migration_test.dart new file mode 100644 index 00000000..4aaa02b9 --- /dev/null +++ b/test/services/purchase/premium_migration_test.dart @@ -0,0 +1,122 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import 'package:maikago/services/one_time_purchase_service.dart'; +import 'package:maikago/services/purchase/purchase_persistence.dart'; +import 'package:maikago/services/purchase/purchase_verifier.dart'; + +class _NoopVerifier implements PurchaseVerifier { + @override + Future verify({ + required String platform, + required String productId, + required String purchaseToken, + }) async => + const PurchaseVerificationResult(verified: true); +} + +/// サーバーエンタイトルメントとレガシーフラグを固定で返すFake永続化。 +class FakeMigrationPersistence extends PurchasePersistence { + FakeMigrationPersistence({ + required this.serverEntitlement, + required this.legacyPremium, + }); + + final bool serverEntitlement; + final bool legacyPremium; + + @override + Future loadServerEntitlement({required String userId}) async => + serverEntitlement; + + @override + Future loadFromFirestore({ + required String userId, + required String deviceFingerprint, + }) async => + FirestorePurchaseData( + isPremium: legacyPremium, + isTrialEverStarted: false, + ); +} + +OneTimePurchaseService _buildService(FakeMigrationPersistence persistence) { + final service = OneTimePurchaseService( + purchaseVerifier: _NoopVerifier(), + persistence: persistence, + ); + service.setCurrentUserIdForTest('user1'); + service.deviceFingerprintForTest = 'fp-1'; + return service; +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + setUp(() { + SharedPreferences.setMockInitialValues({}); + }); + + tearDown(() { + debugDefaultTargetPlatformOverride = null; + }); + + group('プレミアム読込元の一本化と移行(Issue #163 対応案#3)', () { + test('Android: サーバー検証済みtrue → プレミアムtrue・再検証不要', () async { + debugDefaultTargetPlatformOverride = TargetPlatform.android; + final service = _buildService( + FakeMigrationPersistence(serverEntitlement: true, legacyPremium: false), + ); + + await service.loadFromFirestoreForTest(); + + expect(service.isPremiumPurchased, true); + expect(service.needsServerReverificationForTest, false); + service.dispose(); + }); + + test('Android: サーバー未検証・レガシーtrue → プレミアムfalse・再検証が必要', () async { + debugDefaultTargetPlatformOverride = TargetPlatform.android; + final service = _buildService( + FakeMigrationPersistence(serverEntitlement: false, legacyPremium: true), + ); + + await service.loadFromFirestoreForTest(); + + // レガシーのクライアントフラグだけでは付与しない(改竄対策) + expect(service.isPremiumPurchased, false); + // 既存購入者をロックアウトしないため、自動再検証フラグを立てる + expect(service.needsServerReverificationForTest, true); + service.dispose(); + }); + + test('Android: サーバー未検証・レガシーもfalse → プレミアムfalse・再検証不要', () async { + debugDefaultTargetPlatformOverride = TargetPlatform.android; + final service = _buildService( + FakeMigrationPersistence( + serverEntitlement: false, legacyPremium: false), + ); + + await service.loadFromFirestoreForTest(); + + expect(service.isPremiumPurchased, false); + expect(service.needsServerReverificationForTest, false); + service.dispose(); + }); + + test('iOS: サーバー未検証・レガシーtrue → フォールバックでプレミアムtrue・再検証しない', () async { + debugDefaultTargetPlatformOverride = TargetPlatform.iOS; + final service = _buildService( + FakeMigrationPersistence(serverEntitlement: false, legacyPremium: true), + ); + + await service.loadFromFirestoreForTest(); + + // iOSはサーバー検証未実装のため、既存挙動を壊さずレガシーフラグを尊重 + expect(service.isPremiumPurchased, true); + expect(service.needsServerReverificationForTest, false); + service.dispose(); + }); + }); +} diff --git a/test/services/purchase/server_verification_test.dart b/test/services/purchase/server_verification_test.dart new file mode 100644 index 00000000..2a94803e --- /dev/null +++ b/test/services/purchase/server_verification_test.dart @@ -0,0 +1,191 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:in_app_purchase/in_app_purchase.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import 'package:maikago/services/one_time_purchase_service.dart'; +import 'package:maikago/services/purchase/purchase_persistence.dart'; +import 'package:maikago/services/purchase/purchase_verifier.dart'; + +/// 設定可能なFake検証器。 +class FakePurchaseVerifier implements PurchaseVerifier { + FakePurchaseVerifier({ + this.result = const PurchaseVerificationResult(verified: true), + this.throwError, + }); + + PurchaseVerificationResult result; + PurchaseVerificationException? throwError; + + int callCount = 0; + String? lastPlatform; + String? lastProductId; + String? lastPurchaseToken; + + @override + Future verify({ + required String platform, + required String productId, + required String purchaseToken, + }) async { + callCount++; + lastPlatform = platform; + lastProductId = productId; + lastPurchaseToken = purchaseToken; + if (throwError != null) throw throwError!; + return result; + } +} + +/// Firestore/SharedPreferences に触れないFake永続化。 +class FakePurchasePersistence extends PurchasePersistence { + @override + Future saveToLocalStorage({ + required Map userPremiumStatus, + required bool isTrialActive, + required bool isTrialEverStarted, + DateTime? trialStartDate, + DateTime? trialEndDate, + }) async {} + + @override + Future saveToFirestore({ + required String userId, + required String deviceFingerprint, + required bool isPremium, + required bool isTrialEverStarted, + DateTime? trialStartDate, + DateTime? trialEndDate, + }) async {} +} + +PurchaseDetails _premiumPurchase({ + PurchaseStatus status = PurchaseStatus.purchased, + String productID = 'maikago_premium_unlock', + String serverVerificationData = 'token-abc', +}) { + return PurchaseDetails( + purchaseID: 'p1', + productID: productID, + verificationData: PurchaseVerificationData( + localVerificationData: 'local', + serverVerificationData: serverVerificationData, + source: 'google_play', + ), + transactionDate: null, + status: status, + ); +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + setUp(() { + SharedPreferences.setMockInitialValues({}); + debugDefaultTargetPlatformOverride = TargetPlatform.android; + }); + + tearDown(() { + debugDefaultTargetPlatformOverride = null; + }); + + group('Android: サーバー検証を通したときのみプレミアム付与(Issue #163)', () { + test('サーバー検証成功 → プレミアム付与・検証器に正しい引数を渡す', () async { + final verifier = FakePurchaseVerifier( + result: const PurchaseVerificationResult(verified: true), + ); + final service = OneTimePurchaseService( + purchaseVerifier: verifier, + persistence: FakePurchasePersistence(), + ); + service.setCurrentUserIdForTest('user1'); + + await service.handleSuccessfulPurchaseForTest(_premiumPurchase()); + + expect(service.isPremiumPurchased, true); + expect(verifier.callCount, 1); + expect(verifier.lastPlatform, 'android'); + expect(verifier.lastProductId, 'maikago_premium_unlock'); + expect(verifier.lastPurchaseToken, 'token-abc'); + + service.dispose(); + }); + + test('サーバー検証拒否(verified:false) → プレミアム付与しない', () async { + final verifier = FakePurchaseVerifier( + result: const PurchaseVerificationResult(verified: false), + ); + final service = OneTimePurchaseService( + purchaseVerifier: verifier, + persistence: FakePurchasePersistence(), + ); + service.setCurrentUserIdForTest('user1'); + + await service.handleSuccessfulPurchaseForTest(_premiumPurchase()); + + expect(service.isPremiumPurchased, false); + expect(verifier.callCount, 1); + + service.dispose(); + }); + + test('サーバー検証が例外 → プレミアム付与しない(改竄・通信失敗で得しない)', () async { + final verifier = FakePurchaseVerifier( + throwError: PurchaseVerificationException( + 'permission-denied', + '購入を検証できませんでした', + ), + ); + final service = OneTimePurchaseService( + purchaseVerifier: verifier, + persistence: FakePurchasePersistence(), + ); + service.setCurrentUserIdForTest('user1'); + + await service.handleSuccessfulPurchaseForTest(_premiumPurchase()); + + expect(service.isPremiumPurchased, false); + + service.dispose(); + }); + + test('クライアント一次検証で不正(検証データ空) → 検証器を呼ばず付与しない', () async { + final verifier = FakePurchaseVerifier(); + final service = OneTimePurchaseService( + purchaseVerifier: verifier, + persistence: FakePurchasePersistence(), + ); + service.setCurrentUserIdForTest('user1'); + + await service.handleSuccessfulPurchaseForTest( + _premiumPurchase(serverVerificationData: ' '), + ); + + expect(service.isPremiumPurchased, false); + expect(verifier.callCount, 0); + + service.dispose(); + }); + }); + + group('iOS: サーバー検証は未実装のためクライアント一次検証のみで付与', () { + test('iOS購入 → 検証器を呼ばずにクライアント検証のみで付与', () async { + debugDefaultTargetPlatformOverride = TargetPlatform.iOS; + final verifier = FakePurchaseVerifier(); + final service = OneTimePurchaseService( + purchaseVerifier: verifier, + persistence: FakePurchasePersistence(), + ); + service.setCurrentUserIdForTest('user1'); + + await service.handleSuccessfulPurchaseForTest( + _premiumPurchase(serverVerificationData: 'apple-receipt'), + ); + + expect(service.isPremiumPurchased, true); + expect(verifier.callCount, 0); // iOSはサーバー検証を呼ばない(未実装) + + service.dispose(); + }); + }); +}