nginx-cache-purge/service_cmd_test.go
James Coleman 8d9b1c9302 Add service management, harden the purge server, and modernize the build.
Server:
- Add a --cache-path allowlist so a server can be limited to the caches it
  is meant to purge, defaulting to any path as before.
- Set socket permissions explicitly (--socket-mode, default 0660) instead of
  inheriting the service manager's umask, which left the socket unreachable.
- Refuse to remove a socket another instance is still serving.
- Read keys both raw and decoded, so keys nginx stored with escapes and keys
  a caller escaped by hand both purge.
- Add an exact= parameter for literal keys containing glob punctuation.
- Report purge failures as 500 rather than 502, and send error bodies through
  http.Error so a failure is not reported as a successful purge.
- Graceful shutdown with systemd readiness notification.

Purge:
- Group purge arguments into PurgeRequest and report the number of entries
  removed.
- Compile exclude globs once, and fail the purge when one is invalid rather
  than purging the keys it was meant to keep.
- Cap header scanning and tolerate entries nginx evicts mid-walk.
- Switch to filepath.WalkDir to avoid an Lstat per cache file.

New:
- service command to install, start, stop, and remove the system service.
- service install takes --cache-path, writing the allowlist into the unit it
  installs, so an installed service is restricted from its first start.
- Makefile, VERSION, and build identifiers stamped via ldflags.
- Tests for the server handler and the service command.

Build:
- Update to Go 1.25, kong v1, GoReleaser v2, and current GitHub Actions.
- Add vet and test steps to CI.
- Rename purgeCmd.go/serverCmd.go to Go's file naming convention.

Bump version to 0.2.0.
2026-08-12 14:58:12 -05:00

121 lines
3.8 KiB
Go

package main
import (
"os"
"path/filepath"
"testing"
"github.com/alecthomas/kong"
"github.com/stretchr/testify/require"
)
// The allowlist has to reach the installed unit's ExecStart, as that is the
// only place the server it starts reads it from.
func TestServiceArguments(t *testing.T) {
tests := []struct {
name string
cachePaths []string
want []string
}{
{
// What the command has always installed, so a service installed
// without the flag runs exactly as it did before.
name: "no allowlist",
want: []string{"server"},
},
{
name: "one cache path",
cachePaths: []string{"/var/nginx/proxy_temp/cache"},
want: []string{"server", "--cache-path", "/var/nginx/proxy_temp/cache"},
},
{
// The flag repeats, and each one has to arrive as its own argument
// rather than joined into a value the server reads as one path.
name: "several cache paths",
cachePaths: []string{"/var/cache/one", "/var/cache/two"},
want: []string{
"server",
"--cache-path", "/var/cache/one",
"--cache-path", "/var/cache/two",
},
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
cmd := &ServiceCmd{Action: "install", CachePaths: test.cachePaths}
require.Equal(t, test.want, cmd.arguments())
// The same arguments have to be what the service definition is
// built with, or the unit is written without them.
svc, err := cmd.service()
require.NoError(t, err)
require.NotNil(t, svc)
})
}
}
// The unit runs from a working directory of the service manager's choosing, so
// a relative path would name a different directory there than the one the
// install was typed against.
func TestServiceArgumentsAbsolutePaths(t *testing.T) {
cmd := &ServiceCmd{Action: "install", CachePaths: []string{"cache"}}
arguments := cmd.arguments()
require.Len(t, arguments, 3)
require.True(t, filepath.IsAbs(arguments[2]), "%s is not absolute", arguments[2])
require.Equal(t, "cache", filepath.Base(arguments[2]))
}
// A symlinked cache is resolved per request by the server, so the install must
// leave the link alone rather than pin the allowlist to today's target.
func TestServiceArgumentsKeepsSymlinks(t *testing.T) {
base := t.TempDir()
target := filepath.Join(base, "cache")
link := filepath.Join(base, "link")
require.NoError(t, os.MkdirAll(target, 0o755))
require.NoError(t, os.Symlink(target, link))
cmd := &ServiceCmd{Action: "install", CachePaths: []string{link}}
require.Equal(t, []string{"server", "--cache-path", link}, cmd.arguments())
}
// The allowlist is written into the unit at install, so accepting it on an
// action that cannot act on it would read as having changed the allowlist of a
// service that carries on with the one it was installed with.
func TestServiceRejectsCachePathsOutsideInstall(t *testing.T) {
for _, action := range ServiceAction {
if action == "install" {
continue
}
t.Run(action, func(t *testing.T) {
cmd := &ServiceCmd{Action: action, CachePaths: []string{t.TempDir()}}
err := cmd.Run()
require.Error(t, err)
require.Contains(t, err.Error(), "--cache-path")
})
}
}
// The flag has to be reachable from the command line it is documented on, and
// kong's path type has to expand each value rather than only the first.
func TestServiceCachePathFlagParses(t *testing.T) {
flags := &Flags{}
parser, err := kong.New(flags, kong.Name(Name), kongVars())
require.NoError(t, err)
_, err = parser.Parse([]string{
"service", "install",
"--cache-path", "/var/cache/one",
"--cache-path", "relative/cache",
})
require.NoError(t, err)
require.Equal(t, "install", flags.Service.Action)
require.Len(t, flags.Service.CachePaths, 2)
require.Equal(t, "/var/cache/one", flags.Service.CachePaths[0])
require.True(t, filepath.IsAbs(flags.Service.CachePaths[1]), "relative path was not expanded")
}