Skip to content

Commit

Permalink
feat: validate SDConfig.SUPPORTED_LANGUAGES for *usable* locales
Browse files Browse the repository at this point in the history
A locale is considered usable if it is both (a) available in the
filesystem and (b) configured by the administrator in
SDConfig.SUPPORTED_LANGUAGES.  Once we've determined which configured
locales are actually usable, we:

1. warn if a configured locale is not available;

2. fall back to the hard-coded FALLBACK_LOCALE ("en_US") if
   SDConfig.DEFAULT_LOCALE is not usable; and

3. error out if neither the default nor the fallback locale is usable.
  • Loading branch information
cfm committed May 2, 2022
1 parent f0dd9a8 commit 9c11950
Show file tree
Hide file tree
Showing 3 changed files with 150 additions and 34 deletions.
78 changes: 57 additions & 21 deletions securedrop/i18n.py
Expand Up @@ -17,7 +17,7 @@
#
import collections

from typing import Dict, List
from typing import Dict, List, Set

from babel.core import (
Locale,
Expand All @@ -29,7 +29,7 @@
from flask import Flask, g, request, session
from flask_babel import Babel

from sdconfig import SDConfig
from sdconfig import SDConfig, FALLBACK_LOCALE


class RequestLocaleInfo:
Expand Down Expand Up @@ -126,32 +126,46 @@ def configure_babel(config: SDConfig, app: Flask) -> Babel:
return babel


def parse_locale_set(codes: List[str]) -> Set[Locale]:
return {Locale.parse(code) for code in codes}


def validate_locale_configuration(config: SDConfig, babel: Babel) -> None:
"""
Ensure that the configured locales are valid and translated.
Check that configured locales are available in the filesystem and therefore usable by
Babel. Warn about configured locales that are not usable, unless we're left with
no usable default or fallback locale, in which case raise an exception.
"""
if config.DEFAULT_LOCALE not in config.SUPPORTED_LOCALES:
raise ValueError(
'The default locale "{}" is not included in the set of supported locales "{}"'.format(
config.DEFAULT_LOCALE, config.SUPPORTED_LOCALES
)
# These locales are available and loadable from the filesystem.
available = set(babel.list_translations())
available.add(Locale.parse(FALLBACK_LOCALE))

# These locales were configured via "securedrop-admin sdconfig", meaning
# they were present on the Admin Workstation at "securedrop-admin" runtime.
configured = parse_locale_set(config.SUPPORTED_LOCALES)

# The intersection of these sets is the set of locales usable by Babel.
usable = available & configured

missing = configured - usable
if missing:
babel.app.logger.error(
f'Configured locales {missing} are not in the set of usable locales {usable}'
)

translations = babel.list_translations()
for locale in config.SUPPORTED_LOCALES:
if locale == "en_US":
continue
defaults = parse_locale_set([config.DEFAULT_LOCALE, FALLBACK_LOCALE])
if not defaults & usable:
raise ValueError(
f'None of the default locales {defaults} are in the set of usable locales {usable}'
)

parsed = Locale.parse(locale)
if parsed not in translations:
raise ValueError(
'Configured locale "{}" is not in the set of translated locales "{}"'.format(
parsed, translations
)
)
global USABLE_LOCALES
USABLE_LOCALES = usable


# TODO(#6420): avoid relying on and manipulating on this global state
LOCALES = collections.OrderedDict() # type: collections.OrderedDict[str, RequestLocaleInfo]
USABLE_LOCALES = set() # type: Set[Locale]


def map_locale_display_names(config: SDConfig) -> None:
Expand Down Expand Up @@ -185,6 +199,28 @@ def configure(config: SDConfig, app: Flask) -> None:
map_locale_display_names(config)


def resolve_fallback_locale(config: SDConfig) -> str:
"""
Return a *usable* fallback locale. Namely:
1. Don't fall back to the configured `DEFAULT_LOCALE` if it isn't available.
2. Don't fall back to the hard-coded `FALLBACK_LOCALE` (`en_US`) if it isn't configured.
NB. If neither the default nor the fallback locale is usable, then we should have crashed
already in `validate_locale_configuration()`.
"""

if Locale.parse(config.DEFAULT_LOCALE) in USABLE_LOCALES:
return config.DEFAULT_LOCALE

elif Locale.parse(FALLBACK_LOCALE) in USABLE_LOCALES:
return FALLBACK_LOCALE

else:
raise ValueError('No usable fallback locale')


def get_locale(config: SDConfig) -> str:
"""
Return the best supported locale for a request.
Expand All @@ -208,8 +244,8 @@ def get_locale(config: SDConfig) -> str:
if not locale:
locale = negotiate_locale(get_accepted_languages(), LOCALES.keys())

# Finally, fall back to the default locale if necessary.
return locale or config.DEFAULT_LOCALE
# Finally, if we can't negotiate a requested locale, resolve a fallback.
return locale or resolve_fallback_locale(config)


def get_accepted_languages() -> List[str]:
Expand Down
5 changes: 4 additions & 1 deletion securedrop/sdconfig.py
Expand Up @@ -8,6 +8,9 @@
from typing import Set


FALLBACK_LOCALE = "en_US"


class SDConfig:
def __init__(self) -> None:
try:
Expand Down Expand Up @@ -120,7 +123,7 @@ def __init__(self) -> None:
# Config entries used by i18n.py
# Use en_US as the default locale if the key is not defined in _config
self.DEFAULT_LOCALE = getattr(
_config, "DEFAULT_LOCALE", "en_US"
_config, "DEFAULT_LOCALE", FALLBACK_LOCALE,
) # type: str
supported_locales = set(getattr(
_config, "SUPPORTED_LOCALES", [self.DEFAULT_LOCALE]
Expand Down
101 changes: 89 additions & 12 deletions securedrop/tests/test_i18n.py
Expand Up @@ -32,13 +32,17 @@
from flask import request
from flask import session
from flask_babel import gettext
from sdconfig import SDConfig
from i18n import parse_locale_set, resolve_fallback_locale
from sdconfig import FALLBACK_LOCALE, SDConfig
from sh import pybabel
from sh import sed
from .utils.env import TESTS_DIR
from werkzeug.datastructures import Headers


NEVER_LOCALE = 'eo' # Esperanto


def verify_i18n(app):
not_translated = 'code hello i18n'
translated_fr = 'code bonjour'
Expand Down Expand Up @@ -225,32 +229,105 @@ def test_i18n(journalist_app, config):
verify_i18n(app)


def test_supported_locales(config):
def test_parse_locale_set():
assert parse_locale_set([FALLBACK_LOCALE]) == set([Locale.parse(FALLBACK_LOCALE)])


def test_resolve_fallback_locale(config):
"""
Only a usable default or fallback locale is returned.
"""
i18n.USABLE_LOCALES = parse_locale_set([FALLBACK_LOCALE, 'es_ES'])
fake_config = SDConfig()

# Check that an invalid locale raises an error during app
# configuration.
fake_config.SUPPORTED_LOCALES = ['en_US', 'yy_ZZ']
# The default locale is neither configured nor available.
fake_config.DEFAULT_LOCALE = NEVER_LOCALE
assert resolve_fallback_locale(fake_config) == FALLBACK_LOCALE

# The default locale is configured but not available.
fake_config.SUPPORTED_LOCALES = [FALLBACK_LOCALE, NEVER_LOCALE]
assert resolve_fallback_locale(fake_config) == FALLBACK_LOCALE

# The default locale is available but not configured.
fake_config.SUPPORTED_LOCALES = [FALLBACK_LOCALE]
fake_config.DEFAULT_LOCALE = NEVER_LOCALE
assert resolve_fallback_locale(fake_config) == FALLBACK_LOCALE

# Happy path: a non-fallback default locale is both available and configured.
fake_config.SUPPORTED_LOCALES = [FALLBACK_LOCALE, 'es_ES']
fake_config.DEFAULT_LOCALE = 'es_ES'
assert resolve_fallback_locale(fake_config) == 'es_ES'


def test_no_usable_fallback_locale(journalist_app, config):
"""
The apps fail if neither the default nor the fallback locale is usable.
"""
fake_config = SDConfig()
fake_config.DEFAULT_LOCALE = NEVER_LOCALE
fake_config.SUPPORTED_LOCALES = [NEVER_LOCALE]
fake_config.TRANSLATION_DIRS = Path(config.TEMP_DIR)

with pytest.raises(UnknownLocaleError):
i18n.USABLE_LOCALES = set()
with pytest.raises(ValueError, match='No usable fallback locale'):
resolve_fallback_locale(fake_config)

with pytest.raises(ValueError, match='in the set of usable locales'):
journalist_app_module.create_app(fake_config)

with pytest.raises(UnknownLocaleError):
with pytest.raises(ValueError, match='in the set of usable locales'):
source_app.create_app(fake_config)

# Check that a valid but unsupported locale raises an error during
# app configuration.
fake_config.SUPPORTED_LOCALES = ['en_US', 'wae_CH']

def test_unusable_default_but_usable_fallback_locale(config, caplog):
"""
The apps start even if the default locale is unusable, as along as the fallback locale is
usable, but log an error for OSSEC to pick up.
"""
fake_config = SDConfig()
fake_config.DEFAULT_LOCALE = NEVER_LOCALE
fake_config.SUPPORTED_LOCALES = [NEVER_LOCALE, FALLBACK_LOCALE]
fake_config.TRANSLATION_DIRS = Path(config.TEMP_DIR)

for app in (journalist_app_module.create_app(fake_config),
source_app.create_app(fake_config)):
with app.app_context():
assert NEVER_LOCALE in caplog.text
assert 'not in the set of usable locales' in caplog.text


def test_invalid_locales(config):
"""
An invalid locale raises an error during app configuration.
"""
fake_config = SDConfig()
fake_config.SUPPORTED_LOCALES = [FALLBACK_LOCALE, 'yy_ZZ']
fake_config.TRANSLATION_DIRS = Path(config.TEMP_DIR)

with pytest.raises(ValueError, match="not in the set of translated locales"):
with pytest.raises(UnknownLocaleError):
journalist_app_module.create_app(fake_config)

with pytest.raises(ValueError, match="not in the set of translated locales"):
with pytest.raises(UnknownLocaleError):
source_app.create_app(fake_config)


def test_valid_but_unusable_locales(config, caplog):
"""
The apps start with one or more unusable, but still valid, locales, but log an error for
OSSEC to pick up.
"""
fake_config = SDConfig()

fake_config.SUPPORTED_LOCALES = [FALLBACK_LOCALE, 'wae_CH']
fake_config.TRANSLATION_DIRS = Path(config.TEMP_DIR)

for app in (journalist_app_module.create_app(fake_config),
source_app.create_app(fake_config)):
with app.app_context():
assert 'wae' in caplog.text
assert 'not in the set of usable locales' in caplog.text


def test_language_tags():
assert i18n.RequestLocaleInfo(Locale.parse('en')).language_tag == 'en'
assert i18n.RequestLocaleInfo(Locale.parse('en-US', sep="-")).language_tag == 'en-US'
Expand Down

0 comments on commit 9c11950

Please sign in to comment.