Files
1Panel/agent/app/service/nginx_module_runtime_test.go
Kadxy 37170ae81a fix(openresty): report brotli as pending when the http.d include can be added (#14285)
Installs that enabled ngx_brotli before v2.3.0 have no http.d include in
nginx.conf, because the include is only inserted when a module is toggled
or built. The settings page reported this as "cannot write the managed
configuration" and skipped the brotli save, so the save path that inserts
the include was never reached, even on a stock nginx.conf.

The settings response now tells the two cases apart with an in-memory
insertion check that writes nothing:

- include missing but insertable, or managed file missing: managedPending,
  the page explains the values take effect after saving and saves normally
- no safe insertion point: managedUnavailable, unchanged

The save path now inserts the include through ensureNginxHTTPIncludeActive,
the same function module activation uses, so both produce identical
results. A sentinel error keeps "no insertion point" mapped to
ErrBrotliUnsupported while I/O failures are still returned as-is.
2026-11-03 18:58:24 +08:00

314 lines
10 KiB
Go

package service
import (
"os"
"path"
"strings"
"testing"
"github.com/1Panel-dev/1Panel/agent/app/model"
"github.com/1Panel-dev/1Panel/agent/constant"
"github.com/1Panel-dev/1Panel/agent/global"
)
const userBrotliConf = `user root;
worker_processes auto;
include /usr/local/openresty/nginx/conf/modules-enabled/*.conf;
events { use epoll; }
http {
include mime.types;
default_type application/octet-stream;
gzip on;
gzip_comp_level 5;
# enabled by hand, long before the panel managed it
brotli on;
brotli_comp_level 6;
brotli_types text/plain text/css application/json;
include /usr/local/openresty/nginx/conf/http.d/*.conf;
include /usr/local/openresty/nginx/conf/conf.d/*.conf;
}
`
func TestNginxConfigDefinesDirective(t *testing.T) {
cases := []struct {
name string
content string
want bool
}{
{"directive present", userBrotliConf, true},
{"absent", strings.Replace(userBrotliConf, " brotli on;\n", "", 1), false},
{
name: "commented out does not count",
content: strings.Replace(userBrotliConf, " brotli on;", " # brotli on;", 1),
want: false,
},
{
name: "a longer directive name is not a match",
content: "http {\n brotli_comp_level 6;\n}\n",
want: false,
},
{
name: "indentation does not matter",
content: "http {\n\t\tbrotli on;\n}\n",
want: true,
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := nginxConfigDefinesDirective(tc.content, "brotli"); got != tc.want {
t.Fatalf("expected %v, got %v", tc.want, got)
}
})
}
}
// The detection must catch any brotli* directive, in any file nginx loads it
// from, not only the primary directive in nginx.conf.
func TestNginxModuleUserDirectiveRe(t *testing.T) {
cases := []struct {
name string
content string
want bool
}{
{"primary directive", "http {\n brotli on;\n}", true},
{"a tuning directive alone", "server {\n brotli_comp_level 11;\n}", true},
{"another variant", "http {\n brotli_types text/plain;\n}", true},
{"server scope in a site file", "server {\n listen 80;\n brotli on;\n}", true},
{"commented out does not count", "http {\n # brotli on;\n}", false},
{"indented comment does not count", "http {\n # brotli_comp_level 6;\n}", false},
{"no brotli at all", "http {\n gzip on;\n}", false},
{"a similarly named directive is not a match", "http {\n gzip on;\n}", false},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := nginxModuleUserDirectiveRe.MatchString(tc.content); got != tc.want {
t.Fatalf("expected %v, got %v", tc.want, got)
}
})
}
}
// The panel must not emit a managed file for a module the user already
// configured: nginx rejects the same directive defined twice.
func TestUserConfiguredBrotliSuppressesManagedFile(t *testing.T) {
if !nginxModuleUserDirectiveRe.MatchString(userBrotliConf) {
t.Fatal("a hand-written brotli config must be detected")
}
clean := strings.Replace(userBrotliConf, " brotli on;\n", "", 1)
clean = strings.Replace(clean, " brotli_comp_level 6;\n", "", 1)
clean = strings.Replace(clean, " brotli_types text/plain text/css application/json;\n", "", 1)
if nginxModuleUserDirectiveRe.MatchString(clean) {
t.Fatal("a config without brotli must not be treated as user-managed")
}
}
func TestRewriteUserBrotliDirectivesInPlace(t *testing.T) {
// Mirrors updateUserNginxBrotliParams without touching the filesystem.
rewrite := func(content string, values map[string][]string) string {
updated := content
for _, name := range []string{"brotli", "brotli_comp_level", "brotli_min_length", "brotli_types"} {
params, ok := values[name]
if !ok || len(params) == 0 {
continue
}
pattern := nginxUserDirectivePattern(name)
if !pattern.MatchString(updated) {
continue
}
updated = pattern.ReplaceAllString(updated, "${1}"+name+" "+strings.Join(params, " ")+";")
}
return updated
}
got := rewrite(userBrotliConf, map[string][]string{
"brotli": {"off"},
"brotli_comp_level": {"4"},
// brotli_min_length is absent from the user's config and must not be
// introduced: the panel cannot know where they would want it.
"brotli_min_length": {"2k"},
})
if !strings.Contains(got, " brotli off;") {
t.Errorf("value was not updated:\n%s", got)
}
if !strings.Contains(got, " brotli_comp_level 4;") {
t.Errorf("comp level was not updated:\n%s", got)
}
if strings.Contains(got, "brotli_min_length") {
t.Error("a directive the user never wrote must not be added")
}
// Everything else survives, including the comment the parser would drop.
for _, keep := range []string{
"# enabled by hand, long before the panel managed it",
" gzip on;",
" gzip_comp_level 5;",
" brotli_types text/plain text/css application/json;",
"include /usr/local/openresty/nginx/conf/conf.d/*.conf;",
"worker_processes auto;",
} {
if !strings.Contains(got, keep) {
t.Errorf("unrelated line was altered or lost: %s", keep)
}
}
if strings.Count(got, "brotli on;")+strings.Count(got, "brotli off;") != 1 {
t.Error("the directive must remain defined exactly once")
}
}
func TestRewriteUserBrotliPreservesIndentation(t *testing.T) {
content := "http {\n\t\tbrotli on;\n}\n"
pattern := nginxUserDirectivePattern("brotli")
got := pattern.ReplaceAllString(content, "${1}brotli off;")
if !strings.Contains(got, "\t\tbrotli off;") {
t.Errorf("original indentation was not preserved: %q", got)
}
}
// Detection must cover the default/ directory, which is included at http
// scope like conf.d but lives under the install directory, not the site root.
func TestNginxModuleUserConfigPathsCoversDefaultDir(t *testing.T) {
siteDir := t.TempDir()
installRoot := t.TempDir()
installDir := path.Join(installRoot, "openresty", "openresty")
defaultDir := path.Join(installDir, "conf", "default")
if err := os.MkdirAll(defaultDir, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(path.Join(defaultDir, "00.default.conf"), []byte("server {}\n"), 0o644); err != nil {
t.Fatal(err)
}
global.Dir.AppInstallDir = installRoot
install := model.AppInstall{Name: "openresty"}
install.App.Key = constant.AppOpenresty
paths := nginxModuleUserConfigPathsWithSiteDir(install, siteDir)
foundMain, foundDefault := false, false
for _, p := range paths {
if strings.HasSuffix(p, path.Join("conf", "nginx.conf")) {
foundMain = true
}
if strings.HasSuffix(p, path.Join("conf", "default", "00.default.conf")) {
foundDefault = true
}
}
if !foundMain {
t.Error("nginx.conf must be scanned")
}
if !foundDefault {
t.Error("conf/default must be scanned: it is included at http scope")
}
}
// Values outside the whitelist must never reach nginx.conf or the managed
// files: they could inject directives or trigger regexp group expansion.
func TestValidateNginxBrotliValues(t *testing.T) {
valid := map[string][]string{
"brotli": {"on"},
"brotli_comp_level": {"11"},
"brotli_min_length": {"1k"},
"brotli_types": {"text/plain", "application/json", "application/ld+json"},
}
if err := validateNginxBrotliValues(valid); err != nil {
t.Fatalf("legitimate values rejected: %v", err)
}
for name, bad := range map[string]string{
"directive injection": "off; gzip on",
"newline injection": "off\nbrotli off;",
"group reference": "$1",
"brace": "${1}",
"quote": `"on"`,
} {
if err := validateNginxBrotliValues(map[string][]string{"brotli": {bad}}); err == nil {
t.Errorf("%s: %q must be rejected", name, bad)
}
}
}
// newBrotliStateInstall lays out an OpenResty install whose nginx.conf holds
// conf, or that has no nginx.conf at all when conf is empty.
func newBrotliStateInstall(t *testing.T, conf string) model.AppInstall {
t.Helper()
installRoot := t.TempDir()
confDir := path.Join(installRoot, "openresty", "openresty", "conf")
if err := os.MkdirAll(confDir, 0o755); err != nil {
t.Fatal(err)
}
if conf != "" {
if err := os.WriteFile(path.Join(confDir, "nginx.conf"), []byte(conf), 0o644); err != nil {
t.Fatal(err)
}
}
previous := global.Dir.AppInstallDir
global.Dir.AppInstallDir = installRoot
t.Cleanup(func() { global.Dir.AppInstallDir = previous })
install := model.AppInstall{Name: "openresty"}
install.App.Key = constant.AppOpenresty
return install
}
// Installs that enabled brotli before the panel managed its directives have
// no http.d include. Saving inserts it, so the settings page must report the
// values as pending rather than as something the panel can never apply.
func TestNginxBrotliManagedState(t *testing.T) {
withInclude, err := insertNginxHTTPInclude(plainNginxConf)
if err != nil {
t.Fatal(err)
}
managedFile := nginxHTTPConfigFileName(nginxModuleRuntimeOrder(nginxBrotliModuleName), nginxBrotliModuleName)
cases := []struct {
name string
conf string
managedFile bool
wantPending bool
wantUnavailable bool
}{
{"include missing but insertable", plainNginxConf, false, true, false},
{"no insertion point", "events {}\n", false, false, true},
{"nginx.conf unreadable", "", false, false, true},
{"include present, managed file missing", withInclude, false, true, false},
{"include present and managed file written", withInclude, true, false, false},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
install := newBrotliStateInstall(t, tc.conf)
if tc.managedFile {
configDir := nginxHTTPConfigDir(install)
if err := os.MkdirAll(configDir, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(path.Join(configDir, managedFile), []byte("brotli on;\n"), 0o644); err != nil {
t.Fatal(err)
}
}
pending, unavailable := nginxBrotliManagedState(install)
if pending != tc.wantPending || unavailable != tc.wantUnavailable {
t.Fatalf("expected pending=%v unavailable=%v, got pending=%v unavailable=%v",
tc.wantPending, tc.wantUnavailable, pending, unavailable)
}
})
}
}
// The state is computed on every visit to the settings page; only an explicit
// save may change the configuration.
func TestNginxBrotliManagedStateDoesNotWrite(t *testing.T) {
install := newBrotliStateInstall(t, plainNginxConf)
nginxBrotliManagedState(install)
content, err := os.ReadFile(nginxMainConfigPath(install))
if err != nil {
t.Fatal(err)
}
if string(content) != plainNginxConf {
t.Fatal("checking the state must not edit nginx.conf")
}
if _, err := os.Stat(nginxHTTPConfigDir(install)); !os.IsNotExist(err) {
t.Fatal("checking the state must not create the http.d directory")
}
}