commit f6a7c7d4495112bcc48dcec407e60428273d2cfc equwal <truex@equwal.com> 2026-09-26 14:55:32 -0700 Add hours for Google Play purchases once for each token The Play build of Subrep sends each Google Play purchase to the new endpoint POST /api/captions/play-purchase, with its device cookie. The server asks the Google Play Developer API for the purchase (purchases.products.get). It adds the hours of the pack once. Then it consumes the purchase. The app never consumes. The table play_purchases keeps the SHA-256 of each token, with a unique index. The row and the hours go in one transaction. So a second send of the same token adds nothing and answers 200. A purchase with an account id must belong to the caller, or to an account that merged into the caller. A purchase without an account id (for example, a promo code) goes to the first caller. A consumed token without a row gets 409, because only this server consumes, and it consumes after its commit. New dependency: google-auth[requests]. It signs the service-account token of the Play Developer API. The standard library has no OAuth 2 signer. requests and cryptography are installed already. Tests: pytest 236 passed (38 new), with property tests for the credit and for the address of a token. The new tests failed before the change. Each of five changes to play.py makes a test fail: no state check, no owner check, no unique token, no consumed check, no consume by the server. test_vendor_files_may_be_kept fails before and after the change, because a new worktree has no frontend/vendor.
backend/main.py | 3 +- backend/play.py | 236 +++++++++++++++++++++++++++ backend/settings.py | 3 +- requirements.txt | 5 + tests/test_play.py | 450 +++++++++++++++++++++++++++++++++++++++++++++++++++- 5 files changed, 694 insertions(+), 3 deletions(-)
diff --git a/backend/main.py b/backend/main.py index 11d98c7..859a742 100644 --- a/backend/main.py +++ b/backend/main.py @@ -19,7 +19,7 @@ from fastapi import FastAPI from fastapi.responses import FileResponse from fastapi.staticfiles import StaticFiles -from . import billing, captions, subrep +from . import billing, captions, play, subrep from .api import router from .db import Job, JobStatus, SessionLocal, init_db from .languages import all_languages @@ -133,6 +133,7 @@ app = FastAPI( ) app.include_router(router) app.include_router(captions.router) +app.include_router(play.router) app.include_router(subrep.router) diff --git a/backend/play.py b/backend/play.py new file mode 100644 index 0000000..a439395 --- /dev/null +++ b/backend/play.py @@ -0,0 +1,236 @@ +"""Google Play purchases of cloud caption hours: the Play build of Subrep. + +Google Play requires its own billing for digital goods that an app from Google +Play sells. So the Play build of Subrep buys the hour packs of captions.PACKS +with Google Play Billing. The Play product ids are the pack ids. + +The app sends each purchase token here with its device cookie. This module asks +Google for the purchase, adds the hours of the pack to the account once, and +consumes the purchase, so that the user can buy the pack again. The app never +consumes. If this server does not answer, the app sends the purchase again at +its next start. +""" +from __future__ import annotations + +import functools +import hashlib +import logging +from datetime import datetime +from typing import Annotated +from urllib.parse import quote + +from fastapi import APIRouter, Depends, HTTPException +from pydantic import BaseModel, Field +from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, String +from sqlalchemy.exc import IntegrityError +from sqlalchemy.orm import Mapped, Session, mapped_column + +from . import accounts, captions +from .api import get_account, get_session +from .db import Account, Base, new_id, utcnow +from .settings import settings + +log = logging.getLogger(__name__) + +ANDROIDPUBLISHER = "https://www.googleapis.com/auth/androidpublisher" +API = "https://androidpublisher.googleapis.com/androidpublisher/v3/applications" +PACKAGE = "com.honjimaku.subrep" +NOT_SET_UP = "Google Play purchases are not set up on this server." +NO_ANSWER = "Google Play did not answer. Try again later." +# purchases.products.get numbers the states of a purchase in another way than +# the Android library, where PURCHASED is 1. Do not mix the two. +PURCHASED = 0 # purchaseState +CONSUMED = 1 # consumptionState +TEST = 0 # purchaseType: a license tester bought it, and paid nothing + + +class PlayError(Exception): + """A purchase that gives no hours now. `status` is the HTTP answer for the app.""" + + def __init__(self, status: int, detail: str): + super().__init__(detail) + self.status = status + self.detail = detail + + +class PlayPurchase(Base): + """Ledger of Google Play purchases. + + The unique token hash makes the credit idempotent. The app sends a purchase + again after a crash or a lost answer, and the hours must come only once. + Only the hash is kept: no code needs the token after the credit. + """ + + __tablename__ = "play_purchases" + + id: Mapped[str] = mapped_column( + String(64), primary_key=True, default=lambda: new_id("play") + ) + token_hash: Mapped[str] = mapped_column(String(64), unique=True, index=True) + account_id: Mapped[str] = mapped_column(ForeignKey("accounts.id"), index=True) + product_id: Mapped[str] = mapped_column(String(32)) + quantity: Mapped[int] = mapped_column(Integer, default=1) + seconds: Mapped[int] = mapped_column(Integer) + test: Mapped[bool] = mapped_column(Boolean, default=False) + # The Google order number on the receipt of the buyer (GPA....). Support + # finds the account with it. A purchase with a promo code has none. + order_id: Mapped[str | None] = mapped_column(String(64), nullable=True) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), default=utcnow + ) + + +def _hash(token: str) -> str: + return hashlib.sha256(token.encode()).hexdigest() + + +# --------------------------------------------------------------------------- +# the Google Play Developer API +# --------------------------------------------------------------------------- + +@functools.lru_cache(maxsize=1) +def _credentials(path: str): + """Read the key file once. A missing or bad file is PlayError 503.""" + from google.auth import exceptions + from google.oauth2 import service_account + + try: + return service_account.Credentials.from_service_account_file( + path, scopes=[ANDROIDPUBLISHER] + ) + except (OSError, ValueError, exceptions.DefaultCredentialsError) as exc: + # Log only the type: the message can hold parts of the file. + log.error("cannot read the Google Play key file %s: %s", path, type(exc).__name__) + raise PlayError(503, NOT_SET_UP) from exc + + +def _session(): + """An HTTP session that signs each request. Tests replace this function.""" + from google.auth.transport.requests import AuthorizedSession + + return AuthorizedSession(_credentials(settings.play_service_account_file)) + + +def _url(product_id: str, token: str) -> str: + return (f"{API}/{PACKAGE}/purchases/products/{quote(product_id, safe='')}" + f"/tokens/{quote(token, safe='')}") + + +def _call(method: str, url: str): + """Send one request to Google. Never log `url`: it can hold a purchase token.""" + import requests + from google.auth import exceptions + + try: + with _session() as http: + response = http.request(method, url, timeout=15) + except exceptions.RefreshError as exc: + log.error("Google refused the Play key: %s", type(exc).__name__) + raise PlayError(503, NOT_SET_UP) from exc + except (requests.RequestException, exceptions.TransportError) as exc: + raise PlayError(502, NO_ANSWER) from exc + if response.status_code in (200, 204): + return response + if response.status_code in (400, 404, 410): + raise PlayError(400, "Google Play does not know this purchase.") + if response.status_code in (401, 403): + log.error("the Play key has no access to the orders of %s (%d)", + PACKAGE, response.status_code) + raise PlayError(503, NOT_SET_UP) + raise PlayError(502, NO_ANSWER) + + +def get(product_id: str, token: str) -> dict: + """The purchase as Google sees it (purchases.products.get).""" + return _call("GET", _url(product_id, token)).json() + + +def consume(product_id: str, token: str) -> None: + """Close the purchase, so that the user can buy the pack again.""" + _call("POST", _url(product_id, token) + ":consume") + + +# --------------------------------------------------------------------------- +# the credit +# --------------------------------------------------------------------------- + +def credit(session: Session, account: Account, product_id: str, token: str, + purchase: dict) -> bool: + """Add the hours of one purchase that Google returned, once for each token. + + Return True when this call added them, and False when an earlier call did. + Raise PlayError when the purchase gives no hours. + """ + if purchase.get("purchaseState") != PURCHASED: + raise PlayError(409, "The purchase is not complete yet.") + if purchase.get("productId", product_id) != product_id: + raise PlayError(400, "Google Play does not know this purchase.") + owner_id = purchase.get("obfuscatedExternalAccountId") + # A purchase made outside the app, for example with a promo code, has no + # account id. Google says that the app can give it to the user who sends it. + if owner_id: + owner = session.get(Account, owner_id) + if owner is None or accounts.resolve(session, owner).id != account.id: + raise PlayError(403, "This purchase belongs to another account.") + quantity = int(purchase.get("quantity") or 1) + seconds = captions.HOURS[product_id] * 3600 * quantity + test = purchase.get("purchaseType") == TEST + session.add(PlayPurchase( + token_hash=_hash(token), account_id=account.id, product_id=product_id, + quantity=quantity, seconds=seconds, test=test, order_id=purchase.get("orderId"), + )) + try: + session.flush() + except IntegrityError: + session.rollback() # An earlier call added this token. + return False + if purchase.get("consumptionState") == CONSUMED: + # Only this server consumes, and it commits before it consumes. So a + # consumed token without a row means that the ledger lost the row. + session.rollback() + log.warning("consumed Play purchase %s has no ledger row", + purchase.get("orderId") or "(no order id)") + raise PlayError(409, "This purchase was used already.") + captions.add(session, account.id, seconds) + session.commit() + log.info("play purchase %s: %s x%d for %s%s", + purchase.get("orderId") or "(no order id)", product_id, quantity, + account.id, " (test)" if test else "") + return True + + +# --------------------------------------------------------------------------- +# routes +# --------------------------------------------------------------------------- + +router = APIRouter(prefix="/api/captions") + + +class PlayIn(BaseModel): + product_id: str = Field(max_length=64) + purchase_token: str = Field(min_length=1, max_length=4096) + + +@router.post("/play-purchase") +def play_purchase( + body: PlayIn, + account: Annotated[Account, Depends(get_account)], + session: Annotated[Session, Depends(get_session)], +): + """The Play build sends each purchase here, once or more. It gives hours once.""" + if not settings.play_configured: + raise HTTPException(503, NOT_SET_UP) + if body.product_id not in captions.HOURS: + raise HTTPException(404, "No such pack.") + try: + purchase = get(body.product_id, body.purchase_token) + credit(session, account, body.product_id, body.purchase_token, purchase) + except PlayError as exc: + raise HTTPException(exc.status, exc.detail) from exc + if purchase.get("consumptionState") != CONSUMED: + try: + consume(body.product_id, body.purchase_token) + except PlayError as exc: + # The hours are in the account. The next send of the app consumes it. + log.warning("could not consume a Play purchase of %s: %s", account.id, exc.detail) + return {"seconds_left": captions.seconds_left(session, account.id)} diff --git a/backend/settings.py b/backend/settings.py index 201dd42..04badcf 100644 --- a/backend/settings.py +++ b/backend/settings.py @@ -145,7 +145,8 @@ class Settings(BaseSettings): # The path of the JSON key of a service account that can read and consume # the orders of Subrep in Play Console. Keep the file outside the checkout, # owned by the service user, with mode 600. Without the file, the server - # tells the Play build that it cannot sell hours (play_available). + # tells the Play build that it cannot sell hours (play_available), and + # POST /api/captions/play-purchase answers 503. See play.py. play_service_account_file: str = "" # --- Subrep Pro ------------------------------------------------------- diff --git a/requirements.txt b/requirements.txt index 9957924..a6e1711 100644 --- a/requirements.txt +++ b/requirements.txt @@ -17,6 +17,11 @@ stripe>=13 # desktop app checks. The standard library has no Ed25519. cryptography>=42 +# Google Play purchases of Subrep hours (backend/play.py). google-auth signs the +# service-account token of the Play Developer API. The standard library has no +# OAuth 2 signer. +google-auth[requests]>=2.58 + # --- public deployment extras (not needed on localhost) --- # redis>=5.0 # SUBPLZ_WEB_QUEUE_BACKEND=redis # rq>=1.16 # external worker processes diff --git a/tests/test_play.py b/tests/test_play.py index 162594b..0001c47 100644 --- a/tests/test_play.py +++ b/tests/test_play.py @@ -1,9 +1,119 @@ -"""Google Play purchases of cloud caption hours: the Play build of Subrep.""" +"""Google Play purchases of cloud caption hours: the Play build of Subrep. + +No test reaches Google. FakeGooglePlay replaces play.get and play.consume, and +the tests of the HTTP layer give play._session a fake session. +""" from __future__ import annotations +import itertools +import json +import logging +import secrets +from urllib.parse import unquote + +import pytest +import requests +from google.auth import exceptions as google_errors +from hypothesis import given +from hypothesis import settings as hsettings +from hypothesis import strategies as st + +from backend import accounts, captions, play +from backend.db import Account, SessionLocal from backend.settings import settings +from .conftest import account_id + +HOUR = 3600 +UNKNOWN = "Google Play does not know this purchase." +_orders = itertools.count(1) + + +class FakeGooglePlay: + """Google Play as this server sees it: the purchases of the app, by token.""" + + def __init__(self): + self.purchases: dict[str, dict] = {} + self.consumed: list[str] = [] + self.gets: list[tuple[str, str]] = [] + self.down = False + self.fail_consume = 0 + + def buy(self, token: str, account: str | None, product: str = "captions20", + state: int = 0, quantity: int | None = None, test: bool = False) -> dict: + """Add a purchase, as purchases.products.get returns it. + + `account` None is a purchase without an account id, for example with + a promo code in the Play Store app. + """ + purchase = {"kind": "androidpublisher#productPurchase", "productId": product, + "purchaseState": state, "consumptionState": 0, + "orderId": f"GPA.3300-{next(_orders):04d}"} + if account is not None: + purchase["obfuscatedExternalAccountId"] = account + if quantity is not None: + purchase["quantity"] = quantity + if test: + purchase["purchaseType"] = 0 + self.purchases[token] = purchase + return purchase + + def get(self, product_id: str, token: str) -> dict: + self.gets.append((product_id, token)) + if self.down: + raise play.PlayError(502, play.NO_ANSWER) + if token not in self.purchases: + raise play.PlayError(400, UNKNOWN) + return dict(self.purchases[token]) + + def consume(self, product_id: str, token: str) -> None: + if self.fail_consume: + self.fail_consume -= 1 + raise play.PlayError(502, play.NO_ANSWER) + self.consumed.append(token) + self.purchases[token]["consumptionState"] = 1 + + +@pytest.fixture(autouse=True) +def _no_google(monkeypatch): + """No test may reach Google. A test of the HTTP layer gives its own fake session.""" + def refuse(): + raise AssertionError("a test tried to reach Google Play") + + monkeypatch.setattr(play, "_session", refuse) + + +@pytest.fixture +def google(monkeypatch, tmp_path): + """A server with a Play key file, and Google Play faked.""" + fake = FakeGooglePlay() + monkeypatch.setattr(play, "get", fake.get) + monkeypatch.setattr(play, "consume", fake.consume) + key = tmp_path / "play-key.json" + key.write_text("{}") # play.get is fake, so nothing reads the key. + monkeypatch.setattr(settings, "play_service_account_file", str(key)) + return fake + + +def new_token() -> str: + """A purchase token that no other test uses: the test database lives for the whole run.""" + return f"tok.{secrets.token_hex(8)}-_" + + +def post_play(client, token: str, product: str = "captions20"): + return client.post("/api/captions/play-purchase", + json={"product_id": product, "purchase_token": token}) + + +def left(client) -> int: + return client.get("/api/captions").json()["seconds_left"] + + +def ledger(token: str) -> list[play.PlayPurchase]: + with SessionLocal() as s: + return s.query(play.PlayPurchase).filter_by(token_hash=play._hash(token)).all() + def test_the_play_build_can_sell_only_when_the_server_can_check_purchases( client, monkeypatch, tmp_path): @@ -24,3 +134,341 @@ def test_the_play_build_can_sell_only_when_the_server_can_check_purchases( monkeypatch.setattr(settings, "caption_api_key", "") assert client.get("/api/captions").json()["play_available"] is False + + +# --- POST /api/captions/play-purchase ----------------------------------------- + +def test_no_play_key_is_503_and_google_is_not_asked(client, google, monkeypatch, tmp_path): + token = new_token() + google.buy(token, account_id(client)) + monkeypatch.setattr(settings, "play_service_account_file", "") + r = post_play(client, token) + assert r.status_code == 503 and r.json()["detail"] == play.NOT_SET_UP + monkeypatch.setattr(settings, "play_service_account_file", str(tmp_path / "missing.json")) + assert post_play(client, token).status_code == 503 + assert google.gets == [] and left(client) == 0 + + +def test_an_unknown_pack_is_404(client, google): + token = new_token() + google.buy(token, account_id(client)) + assert post_play(client, token, product="pack10").status_code == 404 + assert google.gets == [] + + +def test_a_paid_purchase_gives_its_hours_and_is_consumed(client, google): + token = new_token() + purchase = google.buy(token, account_id(client)) + r = post_play(client, token) + assert r.status_code == 200 and r.json() == {"seconds_left": 20 * HOUR} + assert google.consumed == [token] + assert left(client) == 20 * HOUR + [row] = ledger(token) + assert (row.account_id, row.product_id, row.quantity, row.seconds, row.order_id) == ( + account_id(client), "captions20", 1, 20 * HOUR, purchase["orderId"]) + + +def test_the_same_purchase_twice_gives_its_hours_once(client, google): + token = new_token() + google.buy(token, account_id(client)) + assert post_play(client, token).status_code == 200 + r = post_play(client, token) # The app sends it again after a lost answer. + assert r.status_code == 200 and r.json() == {"seconds_left": 20 * HOUR} + assert google.consumed == [token] + assert len(ledger(token)) == 1 + + +def test_a_pending_purchase_gives_nothing_until_it_is_paid(client, google): + token = new_token() + google.buy(token, account_id(client), state=2) + r = post_play(client, token) + assert r.status_code == 409 and r.json()["detail"] == "The purchase is not complete yet." + assert left(client) == 0 and google.consumed == [] and ledger(token) == [] + + google.purchases[token]["purchaseState"] = 0 + assert post_play(client, token).status_code == 200 + assert left(client) == 20 * HOUR and google.consumed == [token] + + +def test_a_cancelled_purchase_gives_nothing(client, google): + token = new_token() + google.buy(token, account_id(client), state=1) + assert post_play(client, token).status_code == 409 + assert left(client) == 0 and google.consumed == [] and ledger(token) == [] + + +def test_a_purchase_for_another_account_gives_nothing(client, second_client, google): + token, stranger = new_token(), new_token() + google.buy(token, account_id(client)) + google.buy(stranger, "acct_0000000000000000") # No such account on this server. + for sender, sent in ((second_client, token), (client, stranger)): + r = post_play(sender, sent) + assert r.status_code == 403 + assert r.json()["detail"] == "This purchase belongs to another account." + assert left(client) == 0 and left(second_client) == 0 and google.consumed == [] + + assert post_play(client, token).status_code == 200 + assert left(client) == 20 * HOUR and left(second_client) == 0 + + +def test_a_purchase_without_an_account_id_goes_to_the_caller_once(client, second_client, google): + token = new_token() + google.buy(token, None) + assert post_play(client, token).status_code == 200 + r = post_play(second_client, token) + assert r.status_code == 200 and r.json() == {"seconds_left": 0} + assert left(client) == 20 * HOUR and left(second_client) == 0 + assert google.consumed == [token] + + +def test_a_purchase_from_before_a_merge_goes_to_the_merged_account(client, second_client, google): + token = new_token() + old, survivor = account_id(client), account_id(second_client) + google.buy(token, old) + with SessionLocal() as s: + accounts.merge(s, s.get(Account, old), s.get(Account, survivor)) + s.commit() + assert post_play(second_client, token).status_code == 200 + assert left(second_client) == 20 * HOUR + + +def test_a_failed_consume_keeps_the_hours_and_the_next_call_consumes(client, google): + token = new_token() + google.buy(token, account_id(client)) + google.fail_consume = 1 + r = post_play(client, token) + assert r.status_code == 200 and r.json() == {"seconds_left": 20 * HOUR} + assert google.consumed == [] + + assert post_play(client, token).status_code == 200 + assert google.consumed == [token] and left(client) == 20 * HOUR + + +def test_no_answer_from_google_gives_nothing(client, google): + token = new_token() + google.buy(token, account_id(client)) + google.down = True + r = post_play(client, token) + assert r.status_code == 502 and r.json()["detail"] == play.NO_ANSWER + assert left(client) == 0 and ledger(token) == [] + + +def test_a_token_that_google_does_not_know_is_400(client, google): + r = post_play(client, new_token()) + assert r.status_code == 400 and r.json()["detail"] == UNKNOWN + assert left(client) == 0 + + +def test_a_purchase_for_another_pack_is_400(client, google): + token = new_token() + google.buy(token, account_id(client), product="captions80") + r = post_play(client, token, product="captions20") + assert r.status_code == 400 and r.json()["detail"] == UNKNOWN + assert left(client) == 0 and google.consumed == [] and ledger(token) == [] + + +def test_the_quantity_multiplies_the_hours(client, google): + token = new_token() + google.buy(token, account_id(client), quantity=3) + assert post_play(client, token).json() == {"seconds_left": 60 * HOUR} + + +def test_the_log_never_holds_the_token(client, google, caplog): + caplog.set_level(logging.DEBUG) + token, lost = new_token(), new_token() + google.buy(token, account_id(client)) + google.buy(lost, account_id(client))["consumptionState"] = 1 + google.fail_consume = 1 + assert post_play(client, token).status_code == 200 # The hours, and a failed consume. + assert post_play(client, token).status_code == 200 # The consume. + assert post_play(client, lost).status_code == 409 # Consumed, but not in the ledger. + assert "play purchase" in caplog.text and "could not consume" in caplog.text + assert token not in caplog.text and lost not in caplog.text + + +def test_a_consumed_purchase_without_a_row_gives_nothing(client, google): + token = new_token() + google.buy(token, account_id(client))["consumptionState"] = 1 + r = post_play(client, token) + assert r.status_code == 409 and r.json()["detail"] == "This purchase was used already." + assert left(client) == 0 and google.consumed == [] and ledger(token) == [] + + +def test_a_test_purchase_is_marked_in_the_ledger(client, google): + tested, paid = new_token(), new_token() + google.buy(tested, account_id(client), test=True) + google.buy(paid, account_id(client)) + assert post_play(client, tested).status_code == 200 + assert post_play(client, paid).status_code == 200 + assert [row.test for row in ledger(tested)] == [True] + assert [row.test for row in ledger(paid)] == [False] + + +def test_a_bad_body_is_422(client, google): + token = new_token() + google.buy(token, account_id(client)) + assert post_play(client, "").status_code == 422 + assert post_play(client, "x" * 4097).status_code == 422 + assert google.gets == [] + + +# --- the HTTP layer ----------------------------------------------------------- + +class FakeResponse: + def __init__(self, status_code: int, body: dict | None = None): + self.status_code = status_code + self._body = body or {} + + def json(self) -> dict: + return self._body + + +class FakeHttp: + """Stands in for the signed session: records each request and gives the next answer.""" + + def __init__(self): + self.answers: list = [] + self.seen: list[tuple[str, str]] = [] + + def __enter__(self): + return self + + def __exit__(self, *exc): + return False + + def request(self, method: str, url: str, timeout: float | None = None): + assert timeout # A request without a timeout can hang a worker. + self.seen.append((method, url)) + answer = self.answers.pop(0) + if isinstance(answer, Exception): + raise answer + return answer + + +@pytest.fixture +def http(monkeypatch): + fake = FakeHttp() + monkeypatch.setattr(play, "_session", lambda: fake) + return fake + + +def test_get_asks_google_for_the_pack_and_the_token(http): + http.answers = [FakeResponse(200, {"purchaseState": 0})] + assert play.get("captions20", "abc.DEF-_1") == {"purchaseState": 0} + url = ("https://androidpublisher.googleapis.com/androidpublisher/v3/applications/" + "com.honjimaku.subrep/purchases/products/captions20/tokens/abc.DEF-_1") + assert http.seen == [("GET", url)] + + +def test_consume_posts_to_the_consume_address(http): + http.answers = [FakeResponse(204)] + play.consume("captions80", "abc") + [(method, url)] = http.seen + assert method == "POST" + assert url.endswith("/com.honjimaku.subrep/purchases/products/captions80/tokens/abc:consume") + + +@pytest.mark.parametrize("google_status, status", [ + (400, 400), (404, 400), (410, 400), (401, 503), (403, 503), (429, 502), (500, 502), (503, 502), +]) +def test_google_errors_become_clear_answers(http, google_status, status): + http.answers = [FakeResponse(google_status)] + with pytest.raises(play.PlayError) as caught: + play.get("captions20", "abc") + assert caught.value.status == status + + +@pytest.mark.parametrize("error", [ + requests.ConnectionError("no route to host"), + requests.Timeout("read timed out"), + google_errors.TransportError("no route to the token server"), +]) +def test_no_connection_to_google_is_502(http, error): + http.answers = [error] + with pytest.raises(play.PlayError) as caught: + play.get("captions20", "abc") + assert caught.value.status == 502 + + +def test_a_key_that_google_refuses_is_503(http): + http.answers = [google_errors.RefreshError("invalid_grant: account not found")] + with pytest.raises(play.PlayError) as caught: + play.get("captions20", "abc") + assert (caught.value.status, caught.value.detail) == (503, play.NOT_SET_UP) + + +def test_the_key_file_gives_credentials_for_the_play_api(tmp_path): + from cryptography.hazmat.primitives import serialization + from cryptography.hazmat.primitives.asymmetric import rsa + + pem = rsa.generate_private_key(public_exponent=65537, key_size=2048).private_bytes( + serialization.Encoding.PEM, serialization.PrivateFormat.PKCS8, + serialization.NoEncryption()).decode() + key = tmp_path / "play-key.json" + key.write_text(json.dumps({ + "type": "service_account", "project_id": "subrep-test", "private_key_id": "k1", + "private_key": pem, "client_email": "play@subrep-test.iam.gserviceaccount.com", + "client_id": "1", "token_uri": "https://oauth2.googleapis.com/token", + })) + credentials = play._credentials(str(key)) + assert credentials.scopes == ["https://www.googleapis.com/auth/androidpublisher"] + assert credentials.service_account_email == "play@subrep-test.iam.gserviceaccount.com" + + +@pytest.mark.parametrize("content", ["{}", "not json", None]) +def test_a_bad_key_file_is_503(tmp_path, content): + key = tmp_path / "play-key.json" + if content is not None: + key.write_text(content) + with pytest.raises(play.PlayError) as caught: + play._credentials(str(key)) + assert caught.value.status == 503 + + +# --- properties --------------------------------------------------------------- + +@hsettings(max_examples=50, deadline=None) +@given(buys=st.lists(st.tuples(st.sampled_from(sorted(captions.HOURS)), st.integers(1, 3), + st.sampled_from(["me", "other", "none"])), + min_size=1, max_size=4), + calls=st.lists(st.tuples(st.integers(0, 3), st.sampled_from([0, 1, 2]), + st.sampled_from([0, 1])), + max_size=25)) +def test_a_token_gives_its_hours_once_and_only_when_paid_for_this_account(buys, calls): + run = secrets.token_hex(6) # The database lives across examples. + with SessionLocal() as s: + me, other = accounts.new_account(s), accounts.new_account(s) + s.commit() + ids = {"me": me.id, "other": other.id, "none": None} + expected, added = {}, {} + for i, state, consumed in calls: + if i >= len(buys): + continue + product, quantity, owner = buys[i] + token = f"{run}.{i}" + google = {"purchaseState": state, "consumptionState": consumed, + "productId": product, "quantity": quantity} + if ids[owner]: + google["obfuscatedExternalAccountId"] = ids[owner] + try: + if play.credit(s, me, product, token, google): + added[token] = added.get(token, 0) + 1 + except play.PlayError: + pass + # Hours come only from a call that saw the purchase paid and not consumed. + if state == 0 and consumed == 0 and owner != "other": + expected[token] = captions.HOURS[product] * HOUR * quantity + assert captions.seconds_left(s, ids["me"]) == sum(expected.values()) + assert captions.seconds_left(s, ids["other"]) == 0 + assert added == {token: 1 for token in expected} + assert s.query(play.PlayPurchase).filter_by(account_id=ids["me"]).count() == len(expected) + + +@given(st.text(min_size=1)) +def test_any_token_is_one_path_segment_of_the_address(token): + url = play._url("captions20", token) + assert "?" not in url and "#" not in url + head, tail = url.split("/tokens/", 1) + assert head.endswith("/com.honjimaku.subrep/purchases/products/captions20") + assert "/" not in tail + assert unquote(tail) == token