Add Go module, fail-fast config loader and /health endpoint
Independent review Not signed off — 2 high finding(s) (#2)
Independent review Not signed off — 2 high finding(s) (#2)
- go.mod: module code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest, go 1.25,
sole dependency github.com/go-chi/chi/v5.
- internal/config: Load() reads MEALQUEST_* env vars and *_FILE secrets,
collects every missing/invalid value into one error naming each variable,
rejects inline secrets, and redacts secrets when formatted.
- internal/server: chi router serving GET /health -> {"status":"ok","version"}.
- cmd/server: wiring only; slog JSON logging, version via -ldflags
"-X main.version=...", graceful shutdown on SIGINT/SIGTERM.
- .gitignore: cover .env*, deploy/secrets/, node_modules, dist, *.db.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
+11
@@ -1,2 +1,13 @@
|
||||
.DS_Store
|
||||
design/.DS_Store
|
||||
|
||||
# Secrets and local environment
|
||||
.env*
|
||||
deploy/secrets/
|
||||
|
||||
# Build artifacts and dependencies
|
||||
node_modules
|
||||
dist
|
||||
|
||||
# Local databases
|
||||
*.db
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
// Command server runs the MealQuest HTTP service.
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/config"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/server"
|
||||
)
|
||||
|
||||
// version is set at build time with -ldflags "-X main.version=<version>".
|
||||
var version = "dev"
|
||||
|
||||
func main() {
|
||||
cfg, err := config.Load()
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))
|
||||
if err := run(cfg, logger); err != nil {
|
||||
logger.Error("server stopped", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func run(cfg config.Config, logger *slog.Logger) error {
|
||||
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
||||
defer stop()
|
||||
|
||||
srv := &http.Server{
|
||||
Addr: cfg.HTTPAddr,
|
||||
Handler: server.NewRouter(logger, version),
|
||||
ReadHeaderTimeout: 10 * time.Second,
|
||||
ErrorLog: slog.NewLogLogger(logger.Handler(), slog.LevelError),
|
||||
}
|
||||
|
||||
errc := make(chan error, 1)
|
||||
go func() {
|
||||
logger.Info("listening",
|
||||
"addr", cfg.HTTPAddr,
|
||||
"public_url", cfg.PublicURL.String(),
|
||||
"data_dir", cfg.DataDir,
|
||||
"version", version,
|
||||
)
|
||||
errc <- srv.ListenAndServe()
|
||||
}()
|
||||
|
||||
select {
|
||||
case err := <-errc:
|
||||
return err
|
||||
case <-ctx.Done():
|
||||
}
|
||||
|
||||
logger.Info("shutting down", "timeout", cfg.ShutdownTimeout.String())
|
||||
shutdownCtx, cancel := context.WithTimeout(context.Background(), cfg.ShutdownTimeout)
|
||||
defer cancel()
|
||||
if err := srv.Shutdown(shutdownCtx); err != nil {
|
||||
return fmt.Errorf("shutdown: %w", err)
|
||||
}
|
||||
if err := <-errc; !errors.Is(err, http.ErrServerClosed) {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
module code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest
|
||||
|
||||
go 1.25
|
||||
|
||||
require github.com/go-chi/chi/v5 v5.3.1
|
||||
@@ -0,0 +1,2 @@
|
||||
github.com/go-chi/chi/v5 v5.3.1 h1:3j4HZLGZQ3JpMCrPJF/Jl3mYJfWLKBfNJ6quurUGCf8=
|
||||
github.com/go-chi/chi/v5 v5.3.1/go.mod h1:R+tYY2hNuVUUjxoPtqUdgBqevM9s9njzkTLutVsOCto=
|
||||
@@ -0,0 +1,200 @@
|
||||
// Package config loads MealQuest configuration from MEALQUEST_* environment
|
||||
// variables. Secrets are read only from files named by *_FILE variables.
|
||||
package config
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Environment variable names.
|
||||
const (
|
||||
EnvHTTPAddr = "MEALQUEST_HTTP_ADDR"
|
||||
EnvPublicURL = "MEALQUEST_PUBLIC_URL"
|
||||
EnvDataDir = "MEALQUEST_DATA_DIR"
|
||||
EnvShutdownTimeout = "MEALQUEST_SHUTDOWN_TIMEOUT"
|
||||
EnvSessionSecretFile = "MEALQUEST_SESSION_SECRET_FILE"
|
||||
)
|
||||
|
||||
// Config is the validated service configuration.
|
||||
type Config struct {
|
||||
HTTPAddr string
|
||||
PublicURL *url.URL
|
||||
DataDir string
|
||||
ShutdownTimeout time.Duration
|
||||
SessionSecret Secret
|
||||
}
|
||||
|
||||
// Load reads and validates configuration from the process environment.
|
||||
// It reports every missing or invalid variable in a single *Error.
|
||||
func Load() (Config, error) {
|
||||
return load(os.LookupEnv)
|
||||
}
|
||||
|
||||
func load(lookup func(string) (string, bool)) (Config, error) {
|
||||
r := reader{lookup: lookup}
|
||||
cfg := Config{
|
||||
HTTPAddr: r.listenAddr(EnvHTTPAddr),
|
||||
PublicURL: r.httpURL(EnvPublicURL),
|
||||
DataDir: r.dir(EnvDataDir),
|
||||
ShutdownTimeout: r.duration(EnvShutdownTimeout),
|
||||
SessionSecret: r.secretFile(EnvSessionSecretFile),
|
||||
}
|
||||
if len(r.problems) > 0 {
|
||||
return Config{}, &Error{Problems: r.problems}
|
||||
}
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
// Problem describes one missing or invalid variable.
|
||||
type Problem struct {
|
||||
Var string
|
||||
Reason string
|
||||
}
|
||||
|
||||
// Error lists every configuration problem found by Load.
|
||||
type Error struct {
|
||||
Problems []Problem
|
||||
}
|
||||
|
||||
func (e *Error) Error() string {
|
||||
var b strings.Builder
|
||||
b.WriteString("invalid configuration:")
|
||||
for _, p := range e.Problems {
|
||||
fmt.Fprintf(&b, "\n %s: %s", p.Var, p.Reason)
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// Secret holds a sensitive value. It never prints its contents.
|
||||
type Secret struct {
|
||||
value string
|
||||
}
|
||||
|
||||
// Reveal returns the secret value. Call it only where the value is used.
|
||||
func (s Secret) Reveal() string { return s.value }
|
||||
|
||||
// String redacts the secret so it cannot leak through fmt or logs.
|
||||
func (s Secret) String() string { return "[REDACTED]" }
|
||||
|
||||
// GoString redacts the secret for %#v.
|
||||
func (s Secret) GoString() string { return "[REDACTED]" }
|
||||
|
||||
// reader reads variables and collects every problem instead of stopping at
|
||||
// the first one.
|
||||
type reader struct {
|
||||
lookup func(string) (string, bool)
|
||||
problems []Problem
|
||||
}
|
||||
|
||||
func (r *reader) fail(name, format string, args ...any) {
|
||||
r.problems = append(r.problems, Problem{Var: name, Reason: fmt.Sprintf(format, args...)})
|
||||
}
|
||||
|
||||
// required returns the variable's value, recording a problem when it is
|
||||
// unset or empty.
|
||||
func (r *reader) required(name string) (string, bool) {
|
||||
v, ok := r.lookup(name)
|
||||
if !ok || strings.TrimSpace(v) == "" {
|
||||
r.fail(name, "required but not set")
|
||||
return "", false
|
||||
}
|
||||
return v, true
|
||||
}
|
||||
|
||||
func (r *reader) listenAddr(name string) string {
|
||||
v, ok := r.required(name)
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
if _, _, err := net.SplitHostPort(v); err != nil {
|
||||
r.fail(name, "invalid listen address %q (want host:port or :port)", v)
|
||||
return ""
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
func (r *reader) httpURL(name string) *url.URL {
|
||||
v, ok := r.required(name)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
u, err := url.Parse(v)
|
||||
if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" {
|
||||
r.fail(name, "invalid URL %q (want absolute http:// or https:// URL)", v)
|
||||
return nil
|
||||
}
|
||||
return u
|
||||
}
|
||||
|
||||
func (r *reader) dir(name string) string {
|
||||
v, ok := r.required(name)
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
info, err := os.Stat(v)
|
||||
if err != nil {
|
||||
r.fail(name, "cannot access directory %q: %v", v, err)
|
||||
return ""
|
||||
}
|
||||
if !info.IsDir() {
|
||||
r.fail(name, "%q is not a directory", v)
|
||||
return ""
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
func (r *reader) duration(name string) time.Duration {
|
||||
v, ok := r.required(name)
|
||||
if !ok {
|
||||
return 0
|
||||
}
|
||||
d, err := time.ParseDuration(v)
|
||||
if err != nil {
|
||||
r.fail(name, "invalid duration %q (want e.g. 10s, 1m)", v)
|
||||
return 0
|
||||
}
|
||||
if d <= 0 {
|
||||
r.fail(name, "duration must be positive, got %q", v)
|
||||
return 0
|
||||
}
|
||||
return d
|
||||
}
|
||||
|
||||
// secretFile reads a secret from the file named by name, which must end in
|
||||
// _FILE. Setting the secret inline (name without _FILE) is rejected.
|
||||
func (r *reader) secretFile(name string) Secret {
|
||||
inline := strings.TrimSuffix(name, "_FILE")
|
||||
if _, set := r.lookup(inline); set {
|
||||
r.fail(inline, "secrets must not be set inline; use %s", name)
|
||||
}
|
||||
path, ok := r.required(name)
|
||||
if !ok {
|
||||
return Secret{}
|
||||
}
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
// Report the cause without echoing any file contents.
|
||||
r.fail(name, "cannot read secret file %q: %v", path, unwrapPathError(err))
|
||||
return Secret{}
|
||||
}
|
||||
value := strings.TrimRight(string(data), "\r\n")
|
||||
if strings.TrimSpace(value) == "" {
|
||||
r.fail(name, "secret file %q is empty", path)
|
||||
return Secret{}
|
||||
}
|
||||
return Secret{value: value}
|
||||
}
|
||||
|
||||
func unwrapPathError(err error) error {
|
||||
var pe *os.PathError
|
||||
if errors.As(err, &pe) {
|
||||
return pe.Err
|
||||
}
|
||||
return err
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestLoad(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
dir := t.TempDir()
|
||||
secretPath := filepath.Join(dir, "session_secret")
|
||||
if err := os.WriteFile(secretPath, []byte("s3cret-value\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
unreadablePath := filepath.Join(dir, "missing_secret")
|
||||
emptyPath := filepath.Join(dir, "empty_secret")
|
||||
if err := os.WriteFile(emptyPath, []byte("\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
valid := func() map[string]string {
|
||||
return map[string]string{
|
||||
EnvHTTPAddr: ":8080",
|
||||
EnvPublicURL: "https://meals.example.com",
|
||||
EnvDataDir: dir,
|
||||
EnvShutdownTimeout: "15s",
|
||||
EnvSessionSecretFile: secretPath,
|
||||
}
|
||||
}
|
||||
with := func(changes map[string]string, unset ...string) map[string]string {
|
||||
env := valid()
|
||||
for k, v := range changes {
|
||||
env[k] = v
|
||||
}
|
||||
for _, k := range unset {
|
||||
delete(env, k)
|
||||
}
|
||||
return env
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
env map[string]string
|
||||
badVars []string // variables the error must name; nil means success
|
||||
}{
|
||||
{
|
||||
name: "all required vars present",
|
||||
env: valid(),
|
||||
},
|
||||
{
|
||||
name: "one missing",
|
||||
env: with(nil, EnvHTTPAddr),
|
||||
badVars: []string{EnvHTTPAddr},
|
||||
},
|
||||
{
|
||||
name: "empty value counts as missing",
|
||||
env: with(map[string]string{EnvDataDir: ""}),
|
||||
badVars: []string{EnvDataDir},
|
||||
},
|
||||
{
|
||||
name: "all missing are collected",
|
||||
env: map[string]string{},
|
||||
badVars: []string{
|
||||
EnvHTTPAddr, EnvPublicURL, EnvDataDir, EnvShutdownTimeout, EnvSessionSecretFile,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "invalid duration",
|
||||
env: with(map[string]string{EnvShutdownTimeout: "ten seconds"}),
|
||||
badVars: []string{EnvShutdownTimeout},
|
||||
},
|
||||
{
|
||||
name: "non-positive duration",
|
||||
env: with(map[string]string{EnvShutdownTimeout: "0s"}),
|
||||
badVars: []string{EnvShutdownTimeout},
|
||||
},
|
||||
{
|
||||
name: "invalid listen address",
|
||||
env: with(map[string]string{EnvHTTPAddr: "8080"}),
|
||||
badVars: []string{EnvHTTPAddr},
|
||||
},
|
||||
{
|
||||
name: "relative public URL",
|
||||
env: with(map[string]string{EnvPublicURL: "meals.example.com"}),
|
||||
badVars: []string{EnvPublicURL},
|
||||
},
|
||||
{
|
||||
name: "data dir does not exist",
|
||||
env: with(map[string]string{EnvDataDir: filepath.Join(dir, "nope")}),
|
||||
badVars: []string{EnvDataDir},
|
||||
},
|
||||
{
|
||||
name: "unreadable secret file",
|
||||
env: with(map[string]string{EnvSessionSecretFile: unreadablePath}),
|
||||
badVars: []string{EnvSessionSecretFile},
|
||||
},
|
||||
{
|
||||
name: "secret file path is a directory",
|
||||
env: with(map[string]string{EnvSessionSecretFile: dir}),
|
||||
badVars: []string{EnvSessionSecretFile},
|
||||
},
|
||||
{
|
||||
name: "empty secret file",
|
||||
env: with(map[string]string{EnvSessionSecretFile: emptyPath}),
|
||||
badVars: []string{EnvSessionSecretFile},
|
||||
},
|
||||
{
|
||||
name: "inline secret rejected",
|
||||
env: with(map[string]string{"MEALQUEST_SESSION_SECRET": "inline"}),
|
||||
badVars: []string{"MEALQUEST_SESSION_SECRET"},
|
||||
},
|
||||
{
|
||||
name: "several invalid values are all named",
|
||||
env: with(map[string]string{
|
||||
EnvShutdownTimeout: "soon",
|
||||
EnvSessionSecretFile: unreadablePath,
|
||||
}, EnvPublicURL),
|
||||
badVars: []string{EnvPublicURL, EnvShutdownTimeout, EnvSessionSecretFile},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
lookup := func(k string) (string, bool) {
|
||||
v, ok := tc.env[k]
|
||||
return v, ok
|
||||
}
|
||||
cfg, err := load(lookup)
|
||||
|
||||
if tc.badVars == nil {
|
||||
if err != nil {
|
||||
t.Fatalf("load() error = %v, want nil", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
var cerr *Error
|
||||
if !errors.As(err, &cerr) {
|
||||
t.Fatalf("load() error = %v, want *Error", err)
|
||||
}
|
||||
var got []string
|
||||
for _, p := range cerr.Problems {
|
||||
got = append(got, p.Var)
|
||||
}
|
||||
if !slices.Equal(got, tc.badVars) {
|
||||
t.Errorf("problem vars = %v, want %v", got, tc.badVars)
|
||||
}
|
||||
for _, v := range tc.badVars {
|
||||
if !strings.Contains(err.Error(), v) {
|
||||
t.Errorf("error message %q does not name %s", err.Error(), v)
|
||||
}
|
||||
}
|
||||
if cfg != (Config{}) {
|
||||
t.Errorf("load() returned non-zero Config alongside error: %+v", cfg)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadValues(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
dir := t.TempDir()
|
||||
secretPath := filepath.Join(dir, "session_secret")
|
||||
if err := os.WriteFile(secretPath, []byte("s3cret-value\r\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
env := map[string]string{
|
||||
EnvHTTPAddr: "127.0.0.1:9000",
|
||||
EnvPublicURL: "http://localhost:9000",
|
||||
EnvDataDir: dir,
|
||||
EnvShutdownTimeout: "1m30s",
|
||||
EnvSessionSecretFile: secretPath,
|
||||
}
|
||||
cfg, err := load(func(k string) (string, bool) { v, ok := env[k]; return v, ok })
|
||||
if err != nil {
|
||||
t.Fatalf("load() error = %v", err)
|
||||
}
|
||||
|
||||
if cfg.HTTPAddr != "127.0.0.1:9000" {
|
||||
t.Errorf("HTTPAddr = %q", cfg.HTTPAddr)
|
||||
}
|
||||
if cfg.PublicURL.String() != "http://localhost:9000" {
|
||||
t.Errorf("PublicURL = %q", cfg.PublicURL)
|
||||
}
|
||||
if cfg.DataDir != dir {
|
||||
t.Errorf("DataDir = %q, want %q", cfg.DataDir, dir)
|
||||
}
|
||||
if cfg.ShutdownTimeout != 90*time.Second {
|
||||
t.Errorf("ShutdownTimeout = %v, want 1m30s", cfg.ShutdownTimeout)
|
||||
}
|
||||
if cfg.SessionSecret.Reveal() != "s3cret-value" {
|
||||
t.Errorf("SessionSecret.Reveal() = %q, want trailing newline trimmed", cfg.SessionSecret.Reveal())
|
||||
}
|
||||
}
|
||||
|
||||
func TestSecretRedacted(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
s := Secret{value: "s3cret-value"}
|
||||
for _, format := range []string{"%v", "%s", "%+v", "%#v"} {
|
||||
cfg := Config{SessionSecret: s}
|
||||
for _, out := range []string{fmt.Sprintf(format, s), fmt.Sprintf(format, cfg)} {
|
||||
if strings.Contains(out, "s3cret-value") {
|
||||
t.Errorf("format %s leaked secret: %s", format, out)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
// Package server builds the MealQuest HTTP router.
|
||||
package server
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
|
||||
// NewRouter returns the HTTP handler for the service. version is reported by
|
||||
// GET /health.
|
||||
func NewRouter(logger *slog.Logger, version string) http.Handler {
|
||||
r := chi.NewRouter()
|
||||
r.Get("/health", health(logger, version))
|
||||
return r
|
||||
}
|
||||
|
||||
type healthResponse struct {
|
||||
Status string `json:"status"`
|
||||
Version string `json:"version"`
|
||||
}
|
||||
|
||||
func health(logger *slog.Logger, version string) http.HandlerFunc {
|
||||
body, err := json.Marshal(healthResponse{Status: "ok", Version: version})
|
||||
if err != nil {
|
||||
panic(err) // two string fields cannot fail to marshal
|
||||
}
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
if _, err := w.Write(body); err != nil {
|
||||
logger.WarnContext(r.Context(), "write health response", "error", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestHealth(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
srv := httptest.NewServer(NewRouter(logger, "1.2.3"))
|
||||
t.Cleanup(srv.Close)
|
||||
|
||||
resp, err := http.Get(srv.URL + "/health")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Errorf("status = %d, want 200", resp.StatusCode)
|
||||
}
|
||||
if ct := resp.Header.Get("Content-Type"); ct != "application/json" {
|
||||
t.Errorf("Content-Type = %q, want application/json", ct)
|
||||
}
|
||||
var got map[string]string
|
||||
if err := json.NewDecoder(resp.Body).Decode(&got); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := map[string]string{"status": "ok", "version": "1.2.3"}
|
||||
if len(got) != len(want) || got["status"] != want["status"] || got["version"] != want["version"] {
|
||||
t.Errorf("body = %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHealthRejectsOtherMethods(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
rec := httptest.NewRecorder()
|
||||
NewRouter(logger, "dev").ServeHTTP(rec, httptest.NewRequest(http.MethodPost, "/health", nil))
|
||||
|
||||
if rec.Code != http.StatusMethodNotAllowed {
|
||||
t.Errorf("status = %d, want 405", rec.Code)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user