Files
explorer-monorepo/backend/api/track4/operator_scripts_test.go
Devin 66f35fa2aa fix(auth): typed context keys and real sentinel errors
backend/api/middleware/context.go (new):
- Introduces an unexported ctxKey type and three constants
  (ctxKeyUserAddress, ctxKeyUserTrack, ctxKeyAuthenticated) that
  replace the bare string keys 'user_address', 'user_track', and
  'authenticated'. Bare strings trigger go vet's SA1029 and collide
  with keys from any other package that happens to share the name.
- Helpers: ContextWithAuth, UserAddress, UserTrack, IsAuthenticated.
- Sentinel: ErrMissingAuthorization replaces the misuse of
  http.ErrMissingFile as an auth-missing signal. (http.ErrMissingFile
  belongs to multipart form parsing and was semantically wrong.)

backend/api/middleware/auth.go:
- RequireAuth, OptionalAuth, RequireTrack now all read/write via the
  helpers; no more string literals for context keys in this file.
- extractAuth returns ErrMissingAuthorization instead of
  http.ErrMissingFile.
- Dropped now-unused 'context' import.

backend/api/track4/operator_scripts.go, backend/api/track4/endpoints.go,
backend/api/rest/features.go:
- Read user address / track via middleware.UserAddress() and
  middleware.UserTrack() instead of a raw context lookup with a bare
  string key.
- Import 'github.com/explorer/backend/api/middleware'.

backend/api/track4/operator_scripts_test.go:
- Four test fixtures updated to seed the request context through
  middleware.ContextWithAuth (track 4, authenticated) instead of
  context.WithValue with a bare 'user_address' string. This is the
  load-bearing change that proves typed keys are required: a bare
  string key no longer wakes up the middleware helpers.

backend/api/middleware/context_test.go (new):
- Round-trip test for ContextWithAuth + UserAddress + UserTrack +
  IsAuthenticated.
- Defaults: UserTrack=1, UserAddress="", IsAuthenticated=false on a
  bare context.
- TestContextKeyIsolation: an outside caller that inserts
  'user_address' as a bare string key must NOT be visible to
  UserAddress; proves the type discipline.
- ErrMissingAuthorization sentinel smoke test.

Verification:
- go build ./... clean.
- go vet ./... clean (removes SA1029 on the old bare keys).
- go test ./api/middleware/... ./api/track4/... ./api/rest/... PASS.

Advances completion criterion 3 (Auth correctness).
2026-04-18 19:05:24 +00:00

147 lines
5.4 KiB
Go

package track4
import (
"bytes"
"context"
"encoding/json"
"os"
"path/filepath"
"testing"
"net/http"
"net/http/httptest"
"github.com/explorer/backend/api/middleware"
"github.com/stretchr/testify/require"
)
type stubRoleManager struct {
allowed bool
gotIP string
logs int
}
func (s *stubRoleManager) IsIPWhitelisted(_ context.Context, _ string, ipAddress string) (bool, error) {
s.gotIP = ipAddress
return s.allowed, nil
}
func (s *stubRoleManager) LogOperatorEvent(_ context.Context, _ string, _ *int, _ string, _ string, _ string, _ map[string]interface{}, _ string, _ string) error {
s.logs++
return nil
}
func TestHandleRunScriptUsesForwardedClientIPAndRunsAllowlistedScript(t *testing.T) {
root := t.TempDir()
scriptPath := filepath.Join(root, "echo.sh")
require.NoError(t, os.WriteFile(scriptPath, []byte("#!/usr/bin/env bash\necho hello \"$1\"\n"), 0o644))
t.Setenv("OPERATOR_SCRIPTS_ROOT", root)
t.Setenv("OPERATOR_SCRIPT_ALLOWLIST", "echo.sh")
t.Setenv("OPERATOR_SCRIPT_TIMEOUT_SEC", "30")
t.Setenv("TRUST_PROXY_CIDRS", "10.0.0.0/8")
roleMgr := &stubRoleManager{allowed: true}
s := &Server{roleMgr: roleMgr, chainID: 138}
reqBody := []byte(`{"script":"echo.sh","args":["world"]}`)
req := httptest.NewRequest(http.MethodPost, "/api/v1/track4/operator/run-script", bytes.NewReader(reqBody))
req = req.WithContext(middleware.ContextWithAuth(req.Context(), "0x4A666F96fC8764181194447A7dFdb7d471b301C8", 4, true))
req.RemoteAddr = "10.0.0.10:8080"
req.Header.Set("X-Forwarded-For", "203.0.113.9, 10.0.0.10")
w := httptest.NewRecorder()
s.HandleRunScript(w, req)
require.Equal(t, http.StatusOK, w.Code)
require.Equal(t, "203.0.113.9", roleMgr.gotIP)
require.Equal(t, 2, roleMgr.logs)
var out struct {
Data map[string]any `json:"data"`
}
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &out))
require.Equal(t, "echo.sh", out.Data["script"])
require.Equal(t, float64(0), out.Data["exit_code"])
require.Equal(t, "hello world", out.Data["stdout"])
require.Equal(t, false, out.Data["timed_out"])
}
func TestHandleRunScriptRejectsNonAllowlistedScript(t *testing.T) {
root := t.TempDir()
require.NoError(t, os.WriteFile(filepath.Join(root, "allowed.sh"), []byte("#!/usr/bin/env bash\necho ok\n"), 0o644))
require.NoError(t, os.WriteFile(filepath.Join(root, "blocked.sh"), []byte("#!/usr/bin/env bash\necho blocked\n"), 0o644))
t.Setenv("OPERATOR_SCRIPTS_ROOT", root)
t.Setenv("OPERATOR_SCRIPT_ALLOWLIST", "allowed.sh")
s := &Server{roleMgr: &stubRoleManager{allowed: true}, chainID: 138}
req := httptest.NewRequest(http.MethodPost, "/api/v1/track4/operator/run-script", bytes.NewReader([]byte(`{"script":"blocked.sh"}`)))
req = req.WithContext(middleware.ContextWithAuth(req.Context(), "0x4A666F96fC8764181194447A7dFdb7d471b301C8", 4, true))
req.RemoteAddr = "127.0.0.1:9999"
w := httptest.NewRecorder()
s.HandleRunScript(w, req)
require.Equal(t, http.StatusForbidden, w.Code)
require.Contains(t, w.Body.String(), "script not in OPERATOR_SCRIPT_ALLOWLIST")
}
func TestHandleRunScriptRejectsFilenameCollisionOutsideAllowlistedPath(t *testing.T) {
root := t.TempDir()
require.NoError(t, os.MkdirAll(filepath.Join(root, "safe"), 0o755))
require.NoError(t, os.MkdirAll(filepath.Join(root, "unsafe"), 0o755))
require.NoError(t, os.WriteFile(filepath.Join(root, "safe", "backup.sh"), []byte("#!/usr/bin/env bash\necho safe\n"), 0o644))
require.NoError(t, os.WriteFile(filepath.Join(root, "unsafe", "backup.sh"), []byte("#!/usr/bin/env bash\necho unsafe\n"), 0o644))
t.Setenv("OPERATOR_SCRIPTS_ROOT", root)
t.Setenv("OPERATOR_SCRIPT_ALLOWLIST", "safe/backup.sh")
s := &Server{roleMgr: &stubRoleManager{allowed: true}, chainID: 138}
req := httptest.NewRequest(http.MethodPost, "/api/v1/track4/operator/run-script", bytes.NewReader([]byte(`{"script":"unsafe/backup.sh"}`)))
req = req.WithContext(middleware.ContextWithAuth(req.Context(), "0x4A666F96fC8764181194447A7dFdb7d471b301C8", 4, true))
req.RemoteAddr = "127.0.0.1:9999"
w := httptest.NewRecorder()
s.HandleRunScript(w, req)
require.Equal(t, http.StatusForbidden, w.Code)
require.Contains(t, w.Body.String(), "script not in OPERATOR_SCRIPT_ALLOWLIST")
}
func TestHandleRunScriptTruncatesLargeOutput(t *testing.T) {
root := t.TempDir()
scriptPath := filepath.Join(root, "large.sh")
require.NoError(t, os.WriteFile(scriptPath, []byte("#!/usr/bin/env bash\npython3 - <<'PY'\nprint('x' * 70000)\nPY\n"), 0o644))
t.Setenv("OPERATOR_SCRIPTS_ROOT", root)
t.Setenv("OPERATOR_SCRIPT_ALLOWLIST", "large.sh")
t.Setenv("OPERATOR_SCRIPT_TIMEOUT_SEC", "30")
s := &Server{roleMgr: &stubRoleManager{allowed: true}, chainID: 138}
req := httptest.NewRequest(http.MethodPost, "/api/v1/track4/operator/run-script", bytes.NewReader([]byte(`{"script":"large.sh"}`)))
req = req.WithContext(middleware.ContextWithAuth(req.Context(), "0x4A666F96fC8764181194447A7dFdb7d471b301C8", 4, true))
req.RemoteAddr = "127.0.0.1:9999"
w := httptest.NewRecorder()
s.HandleRunScript(w, req)
require.Equal(t, http.StatusOK, w.Code)
var out struct {
Data struct {
ExitCode float64 `json:"exit_code"`
Stdout string `json:"stdout"`
StdoutTruncated bool `json:"stdout_truncated"`
} `json:"data"`
}
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &out))
require.Equal(t, float64(0), out.Data.ExitCode)
require.True(t, out.Data.StdoutTruncated)
require.Contains(t, out.Data.Stdout, "[truncated after")
require.LessOrEqual(t, len(out.Data.Stdout), maxOperatorScriptOutputBytes+64)
}