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 Apr 19, 2022
1 parent f0dd9a8 commit 84ac53d
Show file tree
Hide file tree
Showing 3 changed files with 137 additions and 34 deletions.
70 changes: 49 additions & 21 deletions securedrop/i18n.py
Original file line number Diff line number Diff line change
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 @@ -128,30 +128,36 @@ def configure_babel(config: SDConfig, app: Flask) -> Babel:

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
available = set(babel.list_translations()) # available in the filesystem
available.add(FALLBACK_LOCALE)
configured = set(config.SUPPORTED_LOCALES) # configured by the administrator
usable = available & configured # usable by Babel

# This loop could be replaced with a set operation, but we'd have to iterate over the result
# anyway in order to log it.
for locale in configured:
parsed = Locale.parse(locale)
if parsed not in usable:
babel.app.logger.error(
f'Configured locale "{parsed}" is not in the set of usable locales {usable}'
)

defaults = set([config.DEFAULT_LOCALE, FALLBACK_LOCALE]) & usable
if len(defaults) == 0:
raise ValueError(
f'None of the default locales {defaults} is in the set of usable locales {usable}'
)

translations = babel.list_translations()
for locale in config.SUPPORTED_LOCALES:
if locale == "en_US":
continue

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


LOCALES = collections.OrderedDict() # type: collections.OrderedDict[str, RequestLocaleInfo]
USABLE_LOCALES = set() # type: Set[str]


def map_locale_display_names(config: SDConfig) -> None:
Expand Down Expand Up @@ -185,6 +191,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 config.DEFAULT_LOCALE in USABLE_LOCALES:
return config.DEFAULT_LOCALE

elif 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 +236,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
Original file line number Diff line number Diff line change
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
96 changes: 84 additions & 12 deletions securedrop/tests/test_i18n.py
Original file line number Diff line number Diff line change
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 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,100 @@ def test_i18n(journalist_app, config):
verify_i18n(app)


def test_supported_locales(config):
def test_resolve_fallback_locale(config):
"""
Only a usable default or fallback locale is returned.
"""
i18n.USABLE_LOCALES = [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 f'Configured locale "{NEVER_LOCALE}" is 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 'Configured locale "wae_CH" is 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 84ac53d

Please sign in to comment.