diff options
| author | James Elliott <james-d-elliott@users.noreply.github.com> | 2025-03-02 21:04:22 +1100 | 
|---|---|---|
| committer | GitHub <noreply@github.com> | 2025-03-02 10:04:22 +0000 | 
| commit | 3520b84e78c2e7e47ab2149816bd79422ceece76 (patch) | |
| tree | 724ea0cdcbf50e6a844301c42eb7ae9b56f851aa | |
| parent | c3fc3f94f7bb916faa8baebf313e259bcdd76ffb (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.json | 3 | ||||
| -rw-r--r-- | internal/configuration/test_resources/i18n/example2/locales/en/portal.json | 3 | ||||
| -rw-r--r-- | internal/configuration/validator/const.go | 4 | ||||
| -rw-r--r-- | internal/configuration/validator/server.go | 118 | ||||
| -rw-r--r-- | internal/configuration/validator/server_test.go | 153 | ||||
| -rw-r--r-- | internal/server/asset.go | 69 | ||||
| -rw-r--r-- | internal/server/server.go | 4 | ||||
| -rw-r--r-- | internal/suites/Caddy/configuration.yml | 1 | ||||
| -rw-r--r-- | internal/suites/Envoy/configuration.yml | 1 | 
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'  | 
