Repository: freedomofpress/securedrop

Issue:
centralize list of supported languages
## Description

The current state of play:

1. Users can only use SecureDrop in a language configured by the instance's administrator.  (#2378, #2561)
2. Administrators can only configure languages present in their working copy's `securedrop/translations` directory.  (#2516)

But:

3. [x] To keep translations in sync even if they fall (or have not yet reached) 100% translation coverage, we need to have them present in `securedrop/translations`.  (#6156)

Therefore:

4. [x] `i18n_tool.py` and `securedrop-admin sdconfig` need to share a centralized list of *supported* languages.

This can be thought of as `securedrop`'s companion to freedomofpress/securedrop-client#1438—and as another incremental step away from the `securedrop` repository itself as the Server's packaging.

## User Research Evidence

In #6156, I tried to sync up a language not yet supported and needed a [sequence diagram](https://gist.github.com/cfm/30ef449a7adaee0f1437d39822eb7dae) to do it.  :-)

## User Stories

As a translator, I want to be able to request (volunteer!) a new language to translate without placing a large operational or maintenance burden on the SecureDrop project *until and unless* the translation can be considered supported and ready to be deployed.
* Currently, a translator can add a new language directly in Weblate, whereupon it will receive the _current_ string catalog but no future updates.  This is what's happened with the languages reported in #6156.)

As a Localization Manager, I want to be able to add languages and facilitate their translation when they've not yet reached, or may have fallen below, the 100% coverage threshold to be considered supported.
* Formalize language-support lifecycle: 80% or 90% threshold for _losing_ support?

As a SecureDrop administrator, I (still) want to be able to configure only supported languages, no matter what else may be present in my `securedrop` working copy.


Discussion:

Resolving patch:
diff --git a/admin/securedrop_admin/__init__.py b/admin/securedrop_admin/__init__.py
--- a/admin/securedrop_admin/__init__.py
+++ b/admin/securedrop_admin/__init__.py
@@ -63,6 +63,9 @@
 MAX_NAMESERVERS = 3
 LIST_SPLIT_RE = re.compile(r"\s*,\s*|\s+")
 
+I18N_CONF = "securedrop/i18n.json"
+I18N_DEFAULT_LOCALES = {"en_US"}
+
 
 class FingerprintException(Exception):
     pass
@@ -204,24 +207,27 @@ def __init__(self, appdir: str) -> None:
             self.translation_dir = os.path.realpath(os.path.join(appdir, "translations"))
 
         def get_translations(self) -> Set[str]:
-            translations = set(["en_US"])
+            translations = I18N_DEFAULT_LOCALES
             for dirname in os.listdir(self.translation_dir):
                 if dirname != "messages.pot":
                     translations.add(dirname)
             return translations
 
     class ValidateLocales(Validator):
-        def __init__(self, basedir: str) -> None:
-            self.basedir = basedir
+        def __init__(self, basedir: str, supported: Set[str]) -> None:
+            present = SiteConfig.Locales(basedir).get_translations()
+            self.available = present & supported
+
             super(SiteConfig.ValidateLocales, self).__init__()
 
         def validate(self, document: Document) -> bool:
             desired = document.text.split()
-            existing = SiteConfig.Locales(self.basedir).get_translations()
-            missing = set(desired) - set(existing)
+            missing = set(desired) - self.available
             if not missing:
                 return True
-            raise ValidationError(message="The following locales do not exist " + " ".join(missing))
+            raise ValidationError(
+                message="The following locales are not available " + " ".join(missing)
+            )
 
     class ValidateOSSECUsername(Validator):
         def validate(self, document: Document) -> bool:
@@ -268,8 +274,14 @@ def __init__(self, args: argparse.Namespace) -> None:
         # Hold runtime configuration before save, to support
         # referencing other responses during validation
         self._config_in_progress = {}  # type: Dict
-        translations = SiteConfig.Locales(self.args.app_path).get_translations()
-        translations_as_str = " ".join(translations)
+
+        supported_locales = I18N_DEFAULT_LOCALES.copy()
+        i18n_conf_path = os.path.join(args.root, I18N_CONF)
+        if os.path.exists(i18n_conf_path):
+            with open(i18n_conf_path) as i18n_conf_file:
+                i18n_conf = json.load(i18n_conf_file)
+            supported_locales.update(set(i18n_conf["supported_locales"].keys()))
+        locale_validator = SiteConfig.ValidateLocales(self.args.app_path, supported_locales)
 
         self.desc = [
             (
@@ -503,8 +515,8 @@ def __init__(self, args: argparse.Namespace) -> None:
                 [],
                 list,
                 "Space separated list of additional locales to support "
-                "(" + translations_as_str + ")",
-                SiteConfig.ValidateLocales(self.args.app_path),
+                "(" + " ".join(sorted(list(locale_validator.available))) + ")",
+                locale_validator,
                 str.split,
                 lambda config: True,
             ),
diff --git a/securedrop/i18n_tool.py b/securedrop/i18n_tool.py
--- a/securedrop/i18n_tool.py
+++ b/securedrop/i18n_tool.py
@@ -4,6 +4,7 @@
 import argparse
 import glob
 import io
+import json
 import logging
 import os
 import re
@@ -13,7 +14,8 @@
 import textwrap
 from argparse import _SubParsersAction
 from os.path import abspath, dirname, join, realpath
-from typing import List, Optional, Set
+from pathlib import Path
+from typing import Dict, List, Optional, Set
 
 import version
 from sh import git, msgfmt, msgmerge, pybabel, sed, xgettext
@@ -21,6 +23,15 @@
 logging.basicConfig(format="%(asctime)s %(levelname)s %(message)s")
 log = logging.getLogger(__name__)
 
+I18N_CONF = os.path.join(os.path.dirname(__file__), "i18n.json")
+
+# Map components of the "securedrop" Weblate project (keys) to their filesystem
+# paths (values) relative to the repository root.
+LOCALE_DIR = {
+    "securedrop": "securedrop/translations",
+    "desktop": "install_files/ansible-base/roles/tails-config/templates",
+}
+
 
 class I18NTool:
     #
@@ -33,92 +44,9 @@ class I18NTool:
     #       display in the interface.
     # desktop: The language code used for dekstop icons.
     #
-    supported_languages = {
-        "ar": {
-            "name": "Arabic",
-            "desktop": "ar",
-        },
-        "ca": {
-            "name": "Catalan",
-            "desktop": "ca",
-        },
-        "cs": {
-            "name": "Czech",
-            "desktop": "cs",
-        },
-        "de_DE": {
-            "name": "German",
-            "desktop": "de_DE",
-        },
-        "el": {
-            "name": "Greek",
-            "desktop": "el",
-        },
-        "es_ES": {
-            "name": "Spanish",
-            "desktop": "es_ES",
-        },
-        "fr_FR": {
-            "name": "French",
-            "desktop": "fr",
-        },
-        "hi": {
-            "name": "Hindi",
-            "desktop": "hi",
-        },
-        "is": {
-            "name": "Icelandic",
-            "desktop": "is",
-        },
-        "it_IT": {
-            "name": "Italian",
-            "desktop": "it",
-        },
-        "nb_NO": {
-            "name": "Norwegian",
-            "desktop": "nb_NO",
-        },
-        "nl": {
-            "name": "Dutch",
-            "desktop": "nl",
-        },
-        "pt_BR": {
-            "name": "Portuguese, Brasil",
-            "desktop": "pt_BR",
-        },
-        "pt_PT": {
-            "name": "Portuguese, Portugal",
-            "desktop": "pt_PT",
-        },
-        "ro": {
-            "name": "Romanian",
-            "desktop": "ro",
-        },
-        "ru": {
-            "name": "Russian",
-            "desktop": "ru",
-        },
-        "sk": {
-            "name": "Slovak",
-            "desktop": "sk",
-        },
-        "sv": {
-            "name": "Swedish",
-            "desktop": "sv",
-        },
-        "tr": {
-            "name": "Turkish",
-            "desktop": "tr",
-        },
-        "zh_Hans": {
-            "name": "Chinese, Simplified",
-            "desktop": "zh_Hans",
-        },
-        "zh_Hant": {
-            "name": "Chinese, Traditional",
-            "desktop": "zh_Hant",
-        },
-    }
+    with open(I18N_CONF) as i18n_conf:
+        conf = json.load(i18n_conf)
+    supported_languages = conf["supported_locales"]
     release_tag_re = re.compile(r"^\d+\.\d+\.\d+$")
     translated_commit_re = re.compile("Translated using Weblate")
     updated_commit_re = re.compile(r"(?:updated from|  (?:revision|commit):) (\w+)")
@@ -158,7 +86,7 @@ def translate_messages(self, args: argparse.Namespace) -> None:
                 "--strip-comments",
                 "--add-location=never",
                 "--no-wrap",
-                *sources
+                *sources,
             )
 
             sed("-i", "-e", '/^"POT-Creation-Date/d', messages_file)
@@ -190,7 +118,7 @@ def translate_desktop(self, args: argparse.Namespace) -> None:
                 "--msgid-bugs-address=securedrop@freedom.press",
                 "--copyright-holder=Freedom of the Press Foundation",
                 *sources,
-                **k
+                **k,
             )
             sed("-i", "-e", '/^"POT-Creation-Date/d', messages_file, **k)
 
@@ -291,7 +219,8 @@ def set_translate_desktop_parser(self, subps: _SubParsersAction) -> None:
         )
         translations_dir = join(
             dirname(realpath(__file__)),
-            "../install_files/ansible-base/roles/tails-config/templates",
+            "..",
+            LOCALE_DIR["desktop"],
         )
         sources = "desktop-journalist-icon.j2.in,desktop-source-icon.j2.in"
         self.set_translate_parser(parser, translations_dir, sources)
@@ -347,56 +276,62 @@ def update_from_weblate(self, args: argparse.Namespace) -> None:
         Pull in updated translations from the i18n repo.
         """
         self.ensure_i18n_remote(args)
-        codes = list(self.supported_languages.keys())
-        if args.supported_languages:
-            codes = args.supported_languages.split(",")
-        for code in sorted(codes):
-            info = self.supported_languages[code]
 
-            def need_update(path: str) -> bool:
-                """
-                Check if the file is different in the i18n repo.
-                """
+        # Check out *all* remote changes to the LOCALE_DIRs.
+        git(
+            "-C",
+            args.root,
+            "checkout",
+            args.target,
+            "--",
+            *LOCALE_DIR.values(),
+        )
 
-                exists = os.path.exists(join(args.root, path))
-                k = {"_cwd": args.root}
-                git.checkout(args.target, "--", path, **k)
-                git.reset("HEAD", "--", path, **k)
-                if not exists:
-                    return True
+        # Use the LOCALE_DIR corresponding to the "securedrop" component to
+        # determine which locales are present.
+        locale_dir = os.path.join(args.root, LOCALE_DIR["securedrop"])
+        locales = self.translated_locales(locale_dir)
+        if args.supported_languages:
+            codes = args.supported_languages.split(",")
+            locales = {code: locales[code] for code in locales if code in codes}
 
-                return self.file_is_modified(join(args.root, path))
+        # Then iterate over all locales present and decide which to stage and commit.
+        for code, path in locales.items():
+            paths = []
 
             def add(path: str) -> None:
                 """
                 Add the file to the git index.
                 """
                 git("-C", args.root, "add", path)
+                paths.append(path)
 
-            updated = False
-            #
-            # Add changes to web .po files
-            #
-            path = "securedrop/translations/{l}/LC_MESSAGES/messages.po".format(
-                l=code  # noqa: E741
-            )
-            if need_update(path):
-                add(path)
-                updated = True
-            #
-            # Add changes to desktop .po files
-            #
-            desktop_code = info["desktop"]
-            path = join(
-                "install_files/ansible-base/roles",
-                "tails-config/templates/{l}.po".format(l=desktop_code),  # noqa: E741
-            )
-            if need_update(path):
+            # Any translated locale may have changes that need to be staged from the
+            # securedrop/securedrop component.
+            add(path)
+
+            # Only supported locales may have changes that need to be staged from the
+            # securedrop/desktop component, because the link between the two components
+            # is defined in I18N_CONF when a language is marked supported.
+            try:
+                info = self.supported_languages[code]
+                name = info["name"]
+                desktop_code = info["desktop"]
+                path = join(
+                    LOCALE_DIR["desktop"],
+                    "{l}.po".format(l=desktop_code),  # noqa: E741
+                )
                 add(path)
-                updated = True
+            except KeyError:
+                log.info(
+                    f"{code} has translations but is not marked as supported; "
+                    f"skipping desktop translation"
+                )
+                name = code
 
-            if updated:
-                self.commit_changes(args, code)
+            # Try to commit changes for this locale no matter what, even if it turns out to be a
+            # no-op.
+            self.commit_changes(args, code, name, paths)
 
     def translators(
         self, args: argparse.Namespace, path: str, since_commit: Optional[str]
@@ -438,17 +373,32 @@ def get_path_commits(self, root: str, path: str) -> List[str]:
             "--no-pager", "-C", root, "log", "--format=%H", path, _encoding="utf-8"
         ).splitlines()
 
-    def commit_changes(self, args: argparse.Namespace, code: str) -> None:
+    def translated_locales(self, path: str) -> Dict[str, str]:
+        """Return a dictionary of all locale directories present in `path`, where the keys
+        are the base (directory) names and the values are the full paths."""
+        p = Path(path)
+        return {x.name: str(x) for x in p.iterdir() if x.is_dir()}
+
+    def commit_changes(
+        self, args: argparse.Namespace, code: str, name: str, paths: List[str]
+    ) -> None:
+        """Check if any of the given paths have had changed staged.  If so, commit them."""
         self.require_git_email_name(args.root)
         authors = set()  # type: Set[str]
-        diffs = "{}".format(git("--no-pager", "-C", args.root, "diff", "--name-only", "--cached"))
+        diffs = "{}".format(
+            git("--no-pager", "-C", args.root, "diff", "--name-only", "--cached", *paths)
+        )
+
+        # If nothing was changed, "git commit" will return nonzero as a no-op.
+        if len(diffs) == 0:
+            return
 
         # for each modified file, find the last commit made by this
         # function, then collect all the contributors since that
         # commit, so they can be credited in this one. if no commit
         # with the right message is found, just use the most recent
         # commit that touched the file.
-        for path in sorted(diffs.strip().split("\n")):
+        for path in paths:
             path_commits = self.get_path_commits(args.root, path)
             since_commit = None
             for path_commit in path_commits:
@@ -465,7 +415,6 @@ def commit_changes(self, args: argparse.Namespace, code: str) -> None:
         authors_as_str = "\n  ".join(sorted(authors))
 
         current = git("-C", args.root, "rev-parse", args.target)
-        info = self.supported_languages[code]
         message = textwrap.dedent(
             """
         l10n: updated {name} ({code})
@@ -477,10 +426,8 @@ def commit_changes(self, args: argparse.Namespace, code: str) -> None:
           repo: {remote}
           commit: {current}
         """
-        ).format(
-            remote=args.url, name=info["name"], authors=authors_as_str, code=code, current=current
-        )
-        git("-C", args.root, "commit", "-m", message)
+        ).format(remote=args.url, name=name, authors=authors_as_str, code=code, current=current)
+        git("-C", args.root, "commit", "-m", message, *paths)
 
     def set_update_from_weblate_parser(self, subps: _SubParsersAction) -> None:
         parser = subps.add_parser("update-from-weblate", help=("Import translations from weblate"))
@@ -590,8 +537,8 @@ def get_commit_timestamp(self, root: str, commit: Optional[str]) -> str:
 
     def list_translators(self, args: argparse.Namespace) -> None:
         self.ensure_i18n_remote(args)
-        app_template = "securedrop/translations/{}/LC_MESSAGES/messages.po"
-        desktop_template = "install_files/ansible-base/roles/tails-config/templates/{}.po"
+        app_template = "{}/{}/LC_MESSAGES/messages.po"
+        desktop_template = LOCALE_DIR["desktop"] + "/{}.po"
         since = None
         if args.all:
             print("Listing all translators who have ever helped")
@@ -601,7 +548,7 @@ def list_translators(self, args: argparse.Namespace) -> None:
         for code, info in sorted(self.supported_languages.items()):
             translators = set([])
             paths = [
-                app_template.format(code),
+                app_template.format(LOCALE_DIR["securedrop"], code),
                 desktop_template.format(info["desktop"]),
             ]
             for path in paths:


Test patch:
diff --git a/admin/tests/files/securedrop/i18n.json b/admin/tests/files/securedrop/i18n.json
new file mode 100644
--- /dev/null
+++ b/admin/tests/files/securedrop/i18n.json
@@ -0,0 +1,16 @@
+{
+  "supported_locales": {
+    "de_DE": {
+      "name": "German",
+      "desktop": "de_DE"
+    },
+    "es_ES": {
+      "name": "Spanish",
+      "desktop": "es_ES"
+    },
+    "fr_FR": {
+      "name": "French",
+      "desktop": "fr"
+    }
+  }
+}
diff --git a/admin/tests/test_integration.py b/admin/tests/test_integration.py
--- a/admin/tests/test_integration.py
+++ b/admin/tests/test_integration.py
@@ -140,6 +140,9 @@ def setup_function(function):
     for name in ["de_DE", "es_ES", "fr_FR", "pt_BR"]:
         dircmd = "mkdir -p {0}/securedrop/translations/{1}".format(SD_DIR, name)
         subprocess.check_call(dircmd.split())
+    subprocess.check_call(
+        "cp {0}/files/securedrop/i18n.json {1}/securedrop".format(CURRENT_DIR, SD_DIR).split()
+    )
 
 
 def teardown_function(function):
diff --git a/admin/tests/test_securedrop-admin.py b/admin/tests/test_securedrop-admin.py
--- a/admin/tests/test_securedrop-admin.py
+++ b/admin/tests/test_securedrop-admin.py
@@ -480,13 +480,16 @@ def test_exit_codes(self, tmpdir):
 
 
 class TestSiteConfig(object):
-    def test_exists(self):
+    def test_exists(self, tmpdir):
         args = argparse.Namespace(
-            site_config="DOES_NOT_EXIST", ansible_path=".", app_path=dirname(__file__)
+            site_config="DOES_NOT_EXIST",
+            ansible_path=".",
+            app_path=dirname(__file__),
+            root=tmpdir,
         )
         assert not securedrop_admin.SiteConfig(args).exists()
         args = argparse.Namespace(
-            site_config=__file__, ansible_path=".", app_path=dirname(__file__)
+            site_config=__file__, ansible_path=".", app_path=dirname(__file__), root=tmpdir
         )
         assert securedrop_admin.SiteConfig(args).exists()
 
@@ -623,9 +626,12 @@ def test_validate_optional_fingerprint(self):
         assert validator.validate(Document("012345678901234567890123456789ABCDEFABCD"))
         assert validator.validate(Document(""))
 
-    def test_sanitize_fingerprint(self):
+    def test_sanitize_fingerprint(self, tmpdir):
         args = argparse.Namespace(
-            site_config="DOES_NOT_EXIST", ansible_path=".", app_path=dirname(__file__)
+            site_config="DOES_NOT_EXIST",
+            ansible_path=".",
+            app_path=dirname(__file__),
+            root=tmpdir,
         )
         site_config = securedrop_admin.SiteConfig(args)
         assert "ABC" == site_config.sanitize_fingerprint("    A bc")
@@ -643,7 +649,9 @@ def test_locales(self):
         assert "fr_FR" in translations
 
     def test_validate_locales(self):
-        validator = securedrop_admin.SiteConfig.ValidateLocales(dirname(__file__))
+        validator = securedrop_admin.SiteConfig.ValidateLocales(
+            dirname(__file__), {"en_US", "fr_FR"}
+        )
         assert validator.validate(Document("en_US  fr_FR "))
         with pytest.raises(ValidationError) as e:
             validator.validate(Document("BAD"))
@@ -652,7 +660,10 @@ def test_validate_locales(self):
     def test_save(self, tmpdir):
         site_config_path = join(str(tmpdir), "site_config")
         args = argparse.Namespace(
-            site_config=site_config_path, ansible_path=".", app_path=dirname(__file__)
+            site_config=site_config_path,
+            ansible_path=".",
+            app_path=dirname(__file__),
+            root=tmpdir,
         )
         site_config = securedrop_admin.SiteConfig(args)
         site_config.config = {"var1": "val1", "var2": "val2"}
@@ -665,9 +676,12 @@ def test_save(self, tmpdir):
         )
         assert expected == io.open(site_config_path).read()
 
-    def test_validate_gpg_key(self, caplog):
+    def test_validate_gpg_key(self, tmpdir, caplog):
         args = argparse.Namespace(
-            site_config="INVALID", ansible_path="tests/files", app_path=dirname(__file__)
+            site_config="INVALID",
+            ansible_path="tests/files",
+            app_path=dirname(__file__),
+            root=tmpdir,
         )
         good_config = {
             "securedrop_app_gpg_public_key": "test_journalist_key.pub",
@@ -689,9 +703,12 @@ def test_validate_gpg_key(self, caplog):
                 site_config.validate_gpg_keys()
             assert "FAIL does not match" in str(e)
 
-    def test_journalist_alert_email(self):
+    def test_journalist_alert_email(self, tmpdir):
         args = argparse.Namespace(
-            site_config="INVALID", ansible_path="tests/files", app_path=dirname(__file__)
+            site_config="INVALID",
+            ansible_path="tests/files",
+            app_path=dirname(__file__),
+            root=tmpdir,
         )
         site_config = securedrop_admin.SiteConfig(args)
         site_config.config = {
@@ -723,6 +740,7 @@ def test_update_config(self, mock_save, mock_validate_input):
             site_config="tests/files/site-specific",
             ansible_path="tests/files",
             app_path=dirname(__file__),
+            root="tests/files",
         )
         site_config = securedrop_admin.SiteConfig(args)
 
@@ -736,7 +754,10 @@ def test_update_config(self, mock_save, mock_validate_input):
     def test_update_config_no_site_specific(self, validate_gpg_keys, mock_validate_input, tmpdir):
         site_config_path = join(str(tmpdir), "site_config")
         args = argparse.Namespace(
-            site_config=site_config_path, ansible_path=".", app_path=dirname(__file__)
+            site_config=site_config_path,
+            ansible_path=".",
+            app_path=dirname(__file__),
+            root=tmpdir,
         )
         site_config = securedrop_admin.SiteConfig(args)
         assert site_config.load_and_update_config()
@@ -744,11 +765,12 @@ def test_update_config_no_site_specific(self, validate_gpg_keys, mock_validate_i
         validate_gpg_keys.assert_called_once()
         assert exists(site_config_path)
 
-    def test_load_and_update_config(self):
+    def test_load_and_update_config(self, tmpdir):
         args = argparse.Namespace(
             site_config="tests/files/site-specific",
             ansible_path="tests/files",
             app_path=dirname(__file__),
+            root=tmpdir,
         )
         site_config = securedrop_admin.SiteConfig(args)
         with mock.patch("securedrop_admin.SiteConfig.update_config"):
@@ -759,6 +781,7 @@ def test_load_and_update_config(self):
             site_config="tests/files/site-specific-missing-entries",
             ansible_path="tests/files",
             app_path=dirname(__file__),
+            root=tmpdir,
         )
         site_config = securedrop_admin.SiteConfig(args)
         with mock.patch("securedrop_admin.SiteConfig.update_config"):
@@ -766,7 +789,10 @@ def test_load_and_update_config(self):
             assert site_config.config != {}
 
         args = argparse.Namespace(
-            site_config="UNKNOWN", ansible_path="tests/files", app_path=dirname(__file__)
+            site_config="UNKNOWN",
+            ansible_path="tests/files",
+            app_path=dirname(__file__),
+            root=tmpdir,
         )
         site_config = securedrop_admin.SiteConfig(args)
         with mock.patch("securedrop_admin.SiteConfig.update_config"):
@@ -797,7 +823,7 @@ def verify_prompt_boolean(self, site_config, desc):
         assert site_config.user_prompt_config_one(desc, "YES") is True
         assert site_config.user_prompt_config_one(desc, "NO") is False
 
-    def test_desc_conditional(self):
+    def test_desc_conditional(self, tmpdir):
         """Ensure that conditional prompts behave correctly.
 
         Prompts which depend on another question should only be
@@ -827,6 +853,7 @@ def test_desc_conditional(self):
             site_config="tests/files/site-specific",
             ansible_path="tests/files",
             app_path=dirname(__file__),
+            root=tmpdir,
         )
         site_config = securedrop_admin.SiteConfig(args)
         site_config.desc = questions
@@ -904,9 +931,12 @@ def verify_prompt_securedrop_supported_locales(self, site_config, desc):
         with pytest.raises(ValidationError):
             site_config.user_prompt_config_one(desc, "wrong")
 
-    def test_user_prompt_config_one(self):
+    def test_user_prompt_config_one(self, tmpdir):
         args = argparse.Namespace(
-            site_config="UNKNOWN", ansible_path="tests/files", app_path=dirname(__file__)
+            site_config="UNKNOWN",
+            ansible_path="tests/files",
+            app_path=dirname(__file__),
+            root=tmpdir,
         )
         site_config = securedrop_admin.SiteConfig(args)
 
@@ -922,9 +952,12 @@ def auto_prompt(prompt, default, **kwargs):
                 print("checking " + method)
                 getattr(self, method)(site_config, desc)
 
-    def test_validated_input(self):
+    def test_validated_input(self, tmpdir):
         args = argparse.Namespace(
-            site_config="UNKNOWN", ansible_path="tests/files", app_path=dirname(__file__)
+            site_config="UNKNOWN",
+            ansible_path="tests/files",
+            app_path=dirname(__file__),
+            root=tmpdir,
         )
         site_config = securedrop_admin.SiteConfig(args)
 
@@ -941,17 +974,21 @@ def auto_prompt(prompt, default, **kwargs):
             assert "a b" == site_config.validated_input("", ["a", "b"], lambda: True, None)
             assert "{}" == site_config.validated_input("", {}, lambda: True, None)
 
-    def test_load(self, caplog):
+    def test_load(self, tmpdir, caplog):
         args = argparse.Namespace(
             site_config="tests/files/site-specific",
             ansible_path="tests/files",
             app_path=dirname(__file__),
+            root=tmpdir,
         )
         site_config = securedrop_admin.SiteConfig(args)
         assert "app_hostname" in site_config.load()
 
         args = argparse.Namespace(
-            site_config="UNKNOWN", ansible_path="tests/files", app_path=dirname(__file__)
+            site_config="UNKNOWN",
+            ansible_path="tests/files",
+            app_path=dirname(__file__),
+            root=tmpdir,
         )
         site_config = securedrop_admin.SiteConfig(args)
         with pytest.raises(IOError) as e:
@@ -963,6 +1000,7 @@ def test_load(self, caplog):
             site_config="tests/files/corrupted",
             ansible_path="tests/files",
             app_path=dirname(__file__),
+            root=tmpdir,
         )
         site_config = securedrop_admin.SiteConfig(args)
         with pytest.raises(yaml.YAMLError) as e: