Recently Written · git

subplz-web

git clone https://github.com/equwal/subplz-web

Log | Files | Refs


commit 170760b25a559ed72b18f6285e3d4257491101f9
equwal <truex@equwal.com>
2026-09-26 18:31:37 -0700

Do not answer 500 when the server cannot read the Google Play key

On Python 3.11, Path.is_file raises PermissionError when the service user cannot open the folder of the key. Then settings.play_configured raised, and GET /api/captions answered 500 to each Subrep user. The GitHub build also lost its Stripe packs. POST /api/captions/play-purchase answered 500, and each hourly housekeeping pass failed.

Use os.path.isfile, which gives False for each error. Also make sure that the service user can read the key. An unreadable key now stops only the Play sales, and the server does not sell Play hours that it cannot check.

The README now tells the operator to let subplz open /etc/subplz-web, and to check play_available after the restart.

 README.md           | 10 +++++++---
 backend/settings.py | 10 ++++++++--
 tests/test_play.py  | 56 +++++++++++++++++++++++++++++++++++++++++++++++++++++
 3 files changed, 71 insertions(+), 5 deletions(-)
diff --git a/README.md b/README.md
index c040b0e..609df09 100644
--- a/README.md
+++ b/README.md
@@ -489,14 +489,18 @@ To turn it on:
 6. For Subrep, grant View financial data and Manage orders and subscriptions.
    The access can take some hours to start.
 7. On the server, put the key at `/etc/subplz-web/play-service-account.json`,
-   owned by `subplz`, with mode 600.
+   owned by `subplz`, with mode 600. The user `subplz` must also be able to
+   open the folder `/etc/subplz-web`, for example with owner `subplz` and
+   mode 700.
 8. Add `SUBPLZ_WEB_PLAY_SERVICE_ACCOUNT_FILE=/etc/subplz-web/play-service-account.json`
    to `.env`.
 9. Restart the server.
+10. Make sure that `GET /api/captions` shows `"play_available": true`.
 
 The server reads the key file once. After you replace the file, restart the
-server. Without the file, `GET /api/captions` shows `"play_available": false`,
-the Play build shows no packs, and the endpoint answers 503.
+server. If `subplz` cannot read the file, `GET /api/captions` shows
+`"play_available": false`, the Play build shows no packs, and the endpoint
+answers 503.
 
 ---
 
diff --git a/backend/settings.py b/backend/settings.py
index 04badcf..968841c 100644
--- a/backend/settings.py
+++ b/backend/settings.py
@@ -1,5 +1,6 @@
 """Configuration. Every scale-out seam is an env var with a localhost-friendly default."""
 
+import os
 import shutil
 from pathlib import Path
 from typing import Literal
@@ -144,7 +145,8 @@ class Settings(BaseSettings):
     # --- Google Play (Subrep Play build) ------------------------------------
     # 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
+    # owned by the service user, with mode 600, in a folder that the service
+    # user can open. If the service user cannot read the file, the server
     # 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 = ""
@@ -170,8 +172,12 @@ class Settings(BaseSettings):
 
     @property
     def play_configured(self) -> bool:
+        # Do not use Path.is_file here. On Python 3.11, it raises
+        # PermissionError when the service user cannot open the folder of the
+        # key, and then each GET /api/captions answers 500. os.path.isfile
+        # gives False for each error.
         path = self.play_service_account_file
-        return bool(path) and Path(path).is_file()
+        return bool(path) and os.path.isfile(path) and os.access(path, os.R_OK)
 
     @property
     def email_configured(self) -> bool:
diff --git a/tests/test_play.py b/tests/test_play.py
index 5c0e3e5..39a174c 100644
--- a/tests/test_play.py
+++ b/tests/test_play.py
@@ -6,9 +6,12 @@ play.voided, and the tests of the HTTP layer give play._session a fake session.
 
 from __future__ import annotations
 
+import errno
 import itertools
 import json
 import logging
+import os
+import pathlib
 import secrets
 from urllib.parse import unquote
 
@@ -142,6 +145,59 @@ def test_the_play_build_can_sell_only_when_the_server_can_check_purchases(
     assert client.get("/api/captions").json()["play_available"] is False
 
 
+def assert_only_the_play_sales_stop(client, google):
+    """The Play build sells nothing. The GitHub build still sells with Stripe."""
+    token = new_token()
+    google.buy(token, account_id(client))
+    state = client.get("/api/captions")
+    assert state.status_code == 200
+    assert (state.json()["play_available"], state.json()["available"]) == (False, True)
+    r = post_play(client, token)
+    assert r.status_code == 503 and r.json()["detail"] == play.NOT_SET_UP
+    assert google.gets == []
+
+
+def test_a_key_folder_that_the_server_cannot_open_stops_only_the_play_sales(
+        client, google, monkeypatch, tmp_path):
+    # The service user cannot open the folder of the key, so stat of the key
+    # fails with EACCES. The server runs Python 3.11. There, Path.is_file
+    # raises PermissionError for this error, and each GET /api/captions
+    # answered 500. The tests can run on a newer Python, so this test makes
+    # Path.is_file act as on Python 3.11. The test does not make the key file:
+    # os.path.isfile gives False for each stat error, EACCES or ENOENT.
+    key = str(tmp_path / "locked" / "play-key.json")
+    is_file = pathlib.Path.is_file
+
+    def is_file_on_python_3_11(self, *args, **kwargs):
+        if str(self) == key:
+            raise PermissionError(errno.EACCES, "Permission denied", key)
+        return is_file(self, *args, **kwargs)
+
+    monkeypatch.setattr(pathlib.Path, "is_file", is_file_on_python_3_11)
+    monkeypatch.setattr(settings, "play_service_account_file", key)
+    monkeypatch.setattr(settings, "caption_api_key", "dg_test")
+    assert_only_the_play_sales_stop(client, google)
+    main.housekeeping()  # The hourly pass does not fail.
+
+
+def test_a_key_file_that_the_server_cannot_read_stops_only_the_play_sales(
+        client, google, monkeypatch):
+    # The key is there, but the service user cannot read it. For example, root
+    # owns it with mode 600. Then the server cannot check a purchase, so the
+    # Play build must not sell.
+    key = settings.play_service_account_file
+    access = os.access
+
+    def access_of_the_service_user(path, mode, *args, **kwargs):
+        if os.fspath(path) == key and mode & os.R_OK:
+            return False
+        return access(path, mode, *args, **kwargs)
+
+    monkeypatch.setattr(os, "access", access_of_the_service_user)
+    monkeypatch.setattr(settings, "caption_api_key", "dg_test")
+    assert_only_the_play_sales_stop(client, google)
+
+
 # --- POST /api/captions/play-purchase -----------------------------------------
 
 def test_no_play_key_is_503_and_google_is_not_asked(client, google, monkeypatch, tmp_path):