summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorJames Elliott <james-d-elliott@users.noreply.github.com>2025-03-02 21:04:22 +1100
committerGitHub <noreply@github.com>2025-03-02 10:04:22 +0000
commit3520b84e78c2e7e47ab2149816bd79422ceece76 (patch)
tree724ea0cdcbf50e6a844301c42eb7ae9b56f851aa
parentc3fc3f94f7bb916faa8baebf313e259bcdd76ffb (diff)
test(i18n): validate i18n asset overrides (#8869)
This adjusts the assets validations to only validate the asset overrides. Signed-off-by: James Elliott <james-d-elliott@users.noreply.github.com>
-rw-r--r--internal/configuration/test_resources/i18n/example1/locales/en/portal.json3
-rw-r--r--internal/configuration/test_resources/i18n/example2/locales/en/portal.json3
-rw-r--r--internal/configuration/validator/const.go4
-rw-r--r--internal/configuration/validator/server.go118
-rw-r--r--internal/configuration/validator/server_test.go153
-rw-r--r--internal/server/asset.go69
-rw-r--r--internal/server/server.go4
-rw-r--r--internal/suites/Caddy/configuration.yml1
-rw-r--r--internal/suites/Envoy/configuration.yml1
9 files changed, 279 insertions, 77 deletions
diff --git a/internal/configuration/test_resources/i18n/example1/locales/en/portal.json b/internal/configuration/test_resources/i18n/example1/locales/en/portal.json
new file mode 100644
index 000000000..29a58b972
--- /dev/null
+++ b/internal/configuration/test_resources/i18n/example1/locales/en/portal.json
@@ -0,0 +1,3 @@
+{
+ "Powered by {{authelia}}": "Powered by Crayons"
+}
diff --git a/internal/configuration/test_resources/i18n/example2/locales/en/portal.json b/internal/configuration/test_resources/i18n/example2/locales/en/portal.json
new file mode 100644
index 000000000..961d3157d
--- /dev/null
+++ b/internal/configuration/test_resources/i18n/example2/locales/en/portal.json
@@ -0,0 +1,3 @@
+{
+ "Powered by {{authelia}}": true
+}
diff --git a/internal/configuration/validator/const.go b/internal/configuration/validator/const.go
index a465a3568..77332fb0e 100644
--- a/internal/configuration/validator/const.go
+++ b/internal/configuration/validator/const.go
@@ -25,6 +25,10 @@ const (
)
const (
+ i18nAuthelia = "{{authelia}}"
+)
+
+const (
durationZero = time.Duration(0)
)
diff --git a/internal/configuration/validator/server.go b/internal/configuration/validator/server.go
index 2b7dfb79a..6919edcc9 100644
--- a/internal/configuration/validator/server.go
+++ b/internal/configuration/validator/server.go
@@ -1,9 +1,12 @@
package validator
import (
+ "encoding/json"
"errors"
"fmt"
+ "io/fs"
"os"
+ "path/filepath"
"sort"
"strings"
"time"
@@ -59,6 +62,7 @@ func validateServerTLSFileExists(name, path string, validator *schema.StructVali
func ValidateServer(config *schema.Configuration, validator *schema.StructValidator) {
ValidateServerAddress(config, validator)
ValidateServerTLS(config, validator)
+ validateServerAssets(config, validator)
if config.Server.Buffers.Read <= 0 {
config.Server.Buffers.Read = schema.DefaultServerConfiguration.Buffers.Read
@@ -84,8 +88,6 @@ func ValidateServer(config *schema.Configuration, validator *schema.StructValida
}
// ValidateServerAddress checks the configured server address is correct.
-//
-
func ValidateServerAddress(config *schema.Configuration, validator *schema.StructValidator) {
if config.Server.Address == nil {
config.Server.Address = schema.DefaultServerConfiguration.Address
@@ -161,6 +163,118 @@ func ValidateServerEndpoints(config *schema.Configuration, validator *schema.Str
}
}
+func validateServerAssets(config *schema.Configuration, validator *schema.StructValidator) {
+ if config.Server.AssetPath == "" {
+ return
+ }
+
+ if _, err := os.Stat(config.Server.AssetPath); err != nil {
+ switch {
+ case os.IsNotExist(err):
+ validator.Push(fmt.Errorf("server: asset_path: error occurred reading the '%s' directory: the directory does not exist", config.Server.AssetPath))
+ case os.IsPermission(err):
+ validator.Push(fmt.Errorf("server: asset_path: error occurred reading the '%s' directory: a permission error occurred trying to read the directory", config.Server.AssetPath))
+ default:
+ validator.Push(fmt.Errorf("server: asset_path: error occurred reading the '%s' directory: %w", config.Server.AssetPath, err))
+ }
+
+ return
+ }
+
+ var (
+ entries []fs.DirEntry
+ err error
+ )
+
+ if entries, err = os.ReadDir(filepath.Join(config.Server.AssetPath, "locales")); err != nil {
+ if !os.IsNotExist(err) {
+ validator.Push(fmt.Errorf("server: asset_path: error occurred reading the '%s' directory: %w", filepath.Join(config.Server.AssetPath, "locales"), err))
+ }
+
+ return
+ }
+
+ for _, entry := range entries {
+ if !entry.IsDir() {
+ continue
+ }
+
+ locale := entry.Name()
+
+ var namespaceEntries []fs.DirEntry
+
+ if namespaceEntries, err = os.ReadDir(filepath.Join(config.Server.AssetPath, "locales", locale)); err != nil {
+ validator.Push(fmt.Errorf("server: asset_path: error occurred reading the '%s' directory: %w", filepath.Join(config.Server.AssetPath, "locales", locale), err))
+ }
+
+ for _, namespaceEntry := range namespaceEntries {
+ if namespaceEntry.IsDir() || !strings.HasSuffix(namespaceEntry.Name(), ".json") {
+ continue
+ }
+
+ path := filepath.Join(config.Server.AssetPath, "locales", locale, namespaceEntry.Name())
+
+ var (
+ data []byte
+ translations map[string]any
+ )
+
+ if data, err = os.ReadFile(path); err != nil {
+ validator.Push(fmt.Errorf("server: asset_path: error occurred reading the '%s' file: %w", path, err))
+
+ continue
+ }
+
+ if err = json.Unmarshal(data, &translations); err != nil {
+ validator.Push(fmt.Errorf("server: asset_path: error occurred decoding the '%s' file: %w", path, err))
+
+ continue
+ }
+
+ validateServerAssetsIterate("", path, translations, validator)
+ }
+ }
+}
+
+func validateServerAssetsIterate(keyRoot, path string, translations map[string]any, validator *schema.StructValidator) {
+ for key, raw := range translations {
+ var (
+ value string
+ fullkey string
+ sub map[string]any
+ ok bool
+ )
+
+ if keyRoot == "" {
+ fullkey = key
+ } else {
+ fullkey = strings.Join([]string{keyRoot, key}, ".")
+ }
+
+ if sub, ok = raw.(map[string]any); ok {
+ validateServerAssetsIterate(fullkey, path, sub, validator)
+
+ continue
+ }
+
+ if !strings.Contains(key, i18nAuthelia) {
+ continue
+ }
+
+ if value, ok = raw.(string); !ok {
+ validator.Push(fmt.Errorf("server: asset_path: error occurred decoding the '%s' file: translation key '%s' has a value which is not the required type", path, fullkey))
+
+ continue
+ }
+
+ if !strings.Contains(value, i18nAuthelia) {
+ validator.Push(fmt.Errorf("server: asset_path: error occurred decoding the '%s' file: translation key '%s' has a value which is missing a required placeholder", path, fullkey))
+
+ continue
+ }
+ }
+}
+
func validateServerEndpointsRateLimits(config *schema.Configuration, validator *schema.StructValidator) {
validateServerEndpointsRateLimitDefault("reset_password_start", &config.Server.Endpoints.RateLimits.ResetPasswordStart, schema.DefaultServerConfiguration.Endpoints.RateLimits.ResetPasswordStart, validator)
validateServerEndpointsRateLimitDefault("reset_password_finish", &config.Server.Endpoints.RateLimits.ResetPasswordFinish, schema.DefaultServerConfiguration.Endpoints.RateLimits.ResetPasswordFinish, validator)
diff --git a/internal/configuration/validator/server_test.go b/internal/configuration/validator/server_test.go
index 5e04ac802..13cdb0bb9 100644
--- a/internal/configuration/validator/server_test.go
+++ b/internal/configuration/validator/server_test.go
@@ -3,6 +3,8 @@ package validator
import (
"fmt"
"os"
+ "path/filepath"
+ "regexp"
"testing"
"time"
@@ -728,3 +730,154 @@ func TestValidateTLSPathIsDir(t *testing.T) {
assert.EqualError(t, validator.Errors()[0], fmt.Sprintf("server: tls: option 'key' with path '%s' refers to a directory but it should refer to a file", dir))
}
+
+func TestValidateServerAssets(t *testing.T) {
+ testCases := []struct {
+ name string
+ have string
+ setup func(t *testing.T, have string) string
+ errors []any
+ }{
+ {
+ name: "ShouldValidateEmbedded",
+ have: "../../server",
+ },
+ {
+ name: "ShouldValidateEmptyPath",
+ have: "",
+ },
+ {
+ name: "ShouldValidateNoPath",
+ have: "../../nopath",
+ errors: []any{
+ "server: asset_path: error occurred reading the '../../nopath' directory: the directory does not exist",
+ },
+ },
+ {
+ name: "ShouldValidateValueExcludePlaceholder",
+ have: "../test_resources/i18n/example1",
+ errors: []any{
+ "server: asset_path: error occurred decoding the '../test_resources/i18n/example1/locales/en/portal.json' file: translation key 'Powered by {{authelia}}' has a value which is missing a required placeholder",
+ },
+ },
+ {
+ name: "ShouldValidateValueExcludePlaceholder",
+ have: "../test_resources/i18n/example2",
+ errors: []any{
+ "server: asset_path: error occurred decoding the '../test_resources/i18n/example2/locales/en/portal.json' file: translation key 'Powered by {{authelia}}' has a value which is not the required type",
+ },
+ },
+ {
+ name: "ShouldErrorReadDirectory",
+ setup: func(t *testing.T, have string) (out string) {
+ out = t.TempDir()
+
+ require.NoError(t, os.Mkdir(filepath.Join(out, "locales"), 0000))
+
+ return out
+ },
+ errors: []any{
+ regexp.MustCompile(`server: asset_path: error occurred reading the '[\w#/\\]+/locales' directory: open [\w#/\\]+locales: permission denied`),
+ },
+ },
+ {
+ name: "ShouldErrorReadLocaleDirectory",
+ setup: func(t *testing.T, have string) (out string) {
+ out = t.TempDir()
+
+ require.NoError(t, os.Mkdir(filepath.Join(out, "locales"), 0777))
+ require.NoError(t, os.Mkdir(filepath.Join(out, "locales", "en"), 0000))
+
+ return out
+ },
+ errors: []any{
+ regexp.MustCompile(`server: asset_path: error occurred reading the '[\w#/\\]+/locales/en' directory: open [\w#/\\]+locales/en: permission denied`),
+ },
+ },
+ {
+ name: "ShouldErrorReadNamespaceFile",
+ setup: func(t *testing.T, have string) (out string) {
+ out = t.TempDir()
+
+ require.NoError(t, os.Mkdir(filepath.Join(out, "locales"), 0777))
+ require.NoError(t, os.Mkdir(filepath.Join(out, "locales", "en"), 0777))
+
+ f, err := os.Create(filepath.Join(out, "locales", "en", "portal.json"))
+ require.NoError(t, err)
+
+ require.NoError(t, f.Chmod(0000))
+
+ require.NoError(t, f.Close())
+
+ return out
+ },
+ errors: []any{
+ regexp.MustCompile(`server: asset_path: error occurred reading the '[\w#/\\]+/locales/en/portal.json' file: open [\w#/\\]+locales/en/portal.json: permission denied`),
+ },
+ },
+ {
+ name: "ShouldErrorDecodeJSON",
+ setup: func(t *testing.T, have string) (out string) {
+ out = t.TempDir()
+
+ require.NoError(t, os.Mkdir(filepath.Join(out, "locales"), 0777))
+ require.NoError(t, os.Mkdir(filepath.Join(out, "locales", "en"), 0777))
+ require.NoError(t, os.Mkdir(filepath.Join(out, "locales", "en", "notafile"), 0777))
+
+ f, err := os.Create(filepath.Join(out, "locales", "notadir"))
+ require.NoError(t, err)
+ require.NoError(t, f.Close())
+
+ f, err = os.Create(filepath.Join(out, "locales", "en", "x.notjson"))
+ require.NoError(t, err)
+ require.NoError(t, f.Close())
+
+ f, err = os.Create(filepath.Join(out, "locales", "en", "portal.json"))
+ require.NoError(t, err)
+
+ _, err = f.Write([]byte("not json"))
+ require.NoError(t, err)
+
+ require.NoError(t, f.Close())
+
+ return out
+ },
+ errors: []any{
+ regexp.MustCompile(`server: asset_path: error occurred decoding the '[\w#/\\]+/locales/en/portal.json' file: invalid character 'o' in literal null \(expecting 'u'\)`),
+ },
+ },
+ }
+
+ for _, tc := range testCases {
+ t.Run(tc.name, func(t *testing.T) {
+ have := tc.have
+
+ if tc.setup != nil {
+ have = tc.setup(t, have)
+ }
+
+ config := &schema.Configuration{Server: schema.Server{AssetPath: have}}
+
+ validator := schema.NewStructValidator()
+
+ validateServerAssets(config, validator)
+
+ warnings := validator.Warnings()
+ errors := validator.Errors()
+
+ assert.Len(t, warnings, 0)
+ require.Len(t, errors, len(tc.errors))
+
+ for i, expected := range tc.errors {
+ switch e := expected.(type) {
+ case *regexp.Regexp:
+ assert.Regexp(t, e, errors[i])
+ case string:
+ assert.EqualError(t, errors[i], e)
+ default:
+ t.Fatal("Expected regex or string for error type")
+ }
+ }
+ })
+ }
+}
diff --git a/internal/server/asset.go b/internal/server/asset.go
index eb12e6aae..42667e7ab 100644
--- a/internal/server/asset.go
+++ b/internal/server/asset.go
@@ -4,7 +4,6 @@ import (
"bytes"
"crypto/sha1" //nolint:gosec // Usage is for collision avoidance not security.
"embed"
- "encoding/json"
"errors"
"fmt"
"io/fs"
@@ -251,71 +250,3 @@ func hfsHandleErr(ctx *fasthttp.RequestCtx, err error) {
handlers.SetStatusCodeResponse(ctx, fasthttp.StatusInternalServerError)
}
}
-
-// ValidateTranslations checks that translations contain required placeholders.
-func ValidateTranslations() error {
- criticalTranslations := map[string]map[string][]string{
- "portal.json": {
- "You must view and accept the Privacy Policy before using {{authelia}}": {"authelia"},
- "Powered by {{authelia}}": {"authelia"},
- },
- }
-
- allLocales, err := locales.ReadDir("locales")
- if err != nil {
- return fmt.Errorf("failed to read locales directory: %w", err)
- }
-
- var validationErrors []string
-
- for _, currentLocaleDirectory := range allLocales {
- localeToCheck := currentLocaleDirectory.Name()
-
- for fileName, keysToCheck := range criticalTranslations {
- if len(keysToCheck) == 0 {
- continue
- }
-
- translationFile := fmt.Sprintf("locales/%s/%s", localeToCheck, fileName)
- data, err := locales.ReadFile(translationFile)
-
- if err != nil {
- return fmt.Errorf("failed to read required translation file %s: %w", translationFile, err)
- }
-
- var translations map[string]interface{}
- if err := json.Unmarshal(data, &translations); err != nil {
- return fmt.Errorf("failed to parse translation file %s: %w", translationFile, err)
- }
-
- for key, requiredPlaceholders := range keysToCheck {
- translationValue, exists := translations[key]
- if !exists {
- continue
- }
-
- translation, ok := translationValue.(string)
- if !ok {
- validationErrors = append(validationErrors,
- fmt.Sprintf("%s locale, file %s: key %s is not a string", localeToCheck, fileName, key))
- continue
- }
-
- for _, placeholder := range requiredPlaceholders {
- if !strings.Contains(translation, fmt.Sprintf("{{%s}}", placeholder)) {
- validationErrors = append(validationErrors,
- fmt.Sprintf("%s locale, file %s: missing placeholder {{%s}} in key %s",
- localeToCheck, fileName, placeholder, key))
- }
- }
- }
- }
- }
-
- if len(validationErrors) > 0 {
- return fmt.Errorf("translation validation failed with %d errors:\n%s",
- len(validationErrors), strings.Join(validationErrors, "\n"))
- }
-
- return nil
-}
diff --git a/internal/server/server.go b/internal/server/server.go
index acfef3b75..564460155 100644
--- a/internal/server/server.go
+++ b/internal/server/server.go
@@ -21,10 +21,6 @@ func CreateDefaultServer(config *schema.Configuration, providers middlewares.Pro
return nil, nil, nil, false, fmt.Errorf("failed to load templated assets: %w", err)
}
- if err = ValidateTranslations(); err != nil {
- return nil, nil, nil, false, fmt.Errorf("translation validation failed: %w", err)
- }
-
server = &fasthttp.Server{
ErrorHandler: handleError("server"),
Handler: handleRouter(config, providers),
diff --git a/internal/suites/Caddy/configuration.yml b/internal/suites/Caddy/configuration.yml
index f6b75af5c..5fa665911 100644
--- a/internal/suites/Caddy/configuration.yml
+++ b/internal/suites/Caddy/configuration.yml
@@ -7,7 +7,6 @@ identity_validation:
server:
address: 'tcp://:9091'
- asset_path: '/config/assets/'
tls:
certificate: '/pki/public.backend.crt'
key: '/pki/private.backend.pem'
diff --git a/internal/suites/Envoy/configuration.yml b/internal/suites/Envoy/configuration.yml
index b2f0cd4d9..c5f1174f3 100644
--- a/internal/suites/Envoy/configuration.yml
+++ b/internal/suites/Envoy/configuration.yml
@@ -3,7 +3,6 @@ certificates_directory: '/certs/'
server:
address: 'tcp://:9091'
- asset_path: '/config/assets/'
tls:
certificate: '/pki/public.backend.crt'
key: '/pki/private.backend.pem'