Files
job-search/internal/server/server_test.go
T
bwilliams 9905ffb4cb Served the frontend's build output from server.frontend.dir
internal/frontend serves a Vite build from an fs.FS: hashed files under
/assets/ are cached for a year, HTML and other files get no-cache, ETags are
computed (and cached for a build that can't change), precompressed variants
are served when the client accepts them, and a path without an extension
falls back to index.html for client-side routes. Only GET and HEAD are
allowed.

server.WithFrontend serves it for every path outside /api and /ws, behind
compression and the frontend group's hooks. job-search serve passes the
directory in server.frontend.dir, checked for an index.html at start; empty
means no frontend. Its option building moved into serverOptions, with the
access log.
2026-10-16 16:00:34 +01:00

542 lines
14 KiB
Go

package server
import (
"bytes"
"context"
"encoding/json"
"log"
"log/slog"
"net/http"
"net/http/cookiejar"
"net/http/httptest"
"path/filepath"
"strings"
"sync"
"testing"
"testing/fstest"
"time"
"github.com/coder/websocket"
"github.com/coder/websocket/wsjson"
"github.com/spf13/viper"
"git.avsdev.uk/Go/job-search/internal/config"
"git.avsdev.uk/Go/job-search/internal/events"
"git.avsdev.uk/Go/job-search/internal/middleware"
"git.avsdev.uk/Go/job-search/internal/store"
"git.avsdev.uk/Go/job-search/internal/types"
ws "git.avsdev.uk/Go/job-search/internal/websocket"
)
// defaultConf returns the server configuration's defaults, as Init would
// load them with no config file or environment.
func defaultConf(t *testing.T) *config.JSCServer {
t.Helper()
viper.Reset()
t.Cleanup(viper.Reset)
config.LoadDefaultConfig()
var conf config.JobSearchConfig
config.LoadFromViper(&conf)
return &conf.Server
}
func newTestServer(
t *testing.T, mutate func(*config.JSCServer), opts ...Option,
) *httptest.Server {
t.Helper()
cfg := defaultConf(t)
if mutate != nil {
mutate(cfg)
}
frontendFiles := fstest.MapFS{
"index.html": {
Data: []byte("<!doctype html><title>t</title>"),
},
"assets/app-1.js": {
Data: []byte(strings.Repeat("console.log(1);", 200)),
},
}
opts = append(
[]Option{
WithLogger(slog.New(slog.DiscardHandler)),
WithFrontend(frontendFiles),
},
opts...,
)
s, err := New(cfg, opts...)
if err != nil {
t.Fatal(err)
}
ts := httptest.NewServer(s.Handler())
t.Cleanup(func() {
ts.Close()
s.backgroundCancel()
})
return ts
}
type csrfToken struct {
Token string `json:"token"`
Header string `json:"header"`
}
func getToken(t *testing.T, c *http.Client, base string) csrfToken {
t.Helper()
res, err := c.Get(base + "/api/csrf")
if err != nil {
t.Fatal(err)
}
defer res.Body.Close()
var tok csrfToken
if err := json.NewDecoder(res.Body).Decode(&tok); err != nil {
t.Fatal(err)
}
return tok
}
func clientWithJar(t *testing.T) *http.Client {
t.Helper()
jar, err := cookiejar.New(nil)
if err != nil {
t.Fatal(err)
}
return &http.Client{
Jar: jar,
Timeout: 5 * time.Second,
}
}
// apiClient returns a function making requests to the test server ts with
// client c, sending body as JSON and, if withToken, the CSRF token tok.
func apiClient(
t *testing.T, ts *httptest.Server, c *http.Client, tok csrfToken,
) func(method, path, body string, withToken bool) *http.Response {
t.Helper()
return func(method, path, body string, withToken bool) *http.Response {
req, _ := http.NewRequest(method, ts.URL+path, strings.NewReader(body))
if body != "" {
req.Header.Set("Content-Type", "application/json")
}
if withToken {
req.Header.Set(tok.Header, tok.Token)
}
res, err := c.Do(req)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() {
res.Body.Close()
})
return res
}
}
// Unsafe API requests need the CSRF token: without it they are rejected
// before routing; with it they reach the router (here, 405 from a GET-only
// route).
func TestAPICSRF(t *testing.T) {
ts := newTestServer(t, nil)
c := clientWithJar(t)
tok := getToken(t, c, ts.URL)
post := func(withToken bool) int {
req, _ := http.NewRequest(
"POST", ts.URL+"/api/health", strings.NewReader("{}"),
)
req.Header.Set("Content-Type", "application/json")
if withToken {
req.Header.Set(tok.Header, tok.Token)
}
res, err := c.Do(req)
if err != nil {
t.Fatal(err)
}
res.Body.Close()
return res.StatusCode
}
if code := post(false); code != http.StatusForbidden {
t.Errorf("POST without token: got %d, want 403", code)
}
if code := post(true); code != http.StatusMethodNotAllowed {
t.Errorf("POST with token: got %d, want 405", code)
}
}
// Every route group sees the ClientInfo built by RealIP, not the fallback
// from middleware.Client: ViaProxy is only set when RealIP honoured a header.
func TestRealIPCoversAllGroups(t *testing.T) {
groups := map[Group]string{
GroupGlobal: "/",
GroupAPI: "/api/health",
GroupWebSocket: "/ws",
GroupFrontend: "/index.html",
}
for g, path := range groups {
var ran, viaProxy bool
hook := func(http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ran, viaProxy = true, middleware.Client(r).ViaProxy
w.WriteHeader(http.StatusNoContent)
})
}
// 127.0.0.1 is trusted by default.
ts := newTestServer(t, nil, WithMiddleware(g, hook))
req, _ := http.NewRequest("GET", ts.URL+path, nil)
req.Header.Set("X-Forwarded-For", "203.0.113.9")
res, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatal(err)
}
res.Body.Close()
if !ran || !viaProxy {
t.Errorf(
"group %d (%s): hook ran=%v, ViaProxy=%v", g, path, ran, viaProxy,
)
}
}
}
// The frontend: a client-side route gets index.html, uncached; an asset is
// compressed, cached as immutable and revalidated by its ETag; a missing
// asset is 404.
func TestFrontend(t *testing.T) {
ts := newTestServer(t, func(conf *config.JSCServer) {
conf.Headers.ContentSecurityPolicy = "default-src 'self'"
})
res, err := http.Get(ts.URL + "/some/client/route")
if err != nil {
t.Fatal(err)
}
res.Body.Close()
cacheControl := res.Header.Get("Cache-Control")
if res.StatusCode != 200 || cacheControl != "no-cache" {
t.Fatalf("spa fallback: %d %q", res.StatusCode, cacheControl)
}
if res.Header.Get("Content-Security-Policy") == "" ||
res.Header.Get("X-Request-Id") == "" {
t.Fatal("security headers missing")
}
req, _ := http.NewRequest("GET", ts.URL+"/assets/app-1.js", nil)
req.Header.Set("Accept-Encoding", "gzip")
res, err = http.DefaultTransport.RoundTrip(req)
if err != nil {
t.Fatal(err)
}
res.Body.Close()
encoding := res.Header.Get("Content-Encoding")
cacheControl = res.Header.Get("Cache-Control")
if encoding != "gzip" || !strings.Contains(cacheControl, "immutable") {
t.Fatalf("asset: encoding=%q cache=%q", encoding, cacheControl)
}
req.Header.Set("If-None-Match", res.Header.Get("ETag"))
res, _ = http.DefaultTransport.RoundTrip(req)
res.Body.Close()
if res.StatusCode != http.StatusNotModified {
t.Fatalf("etag revalidation: %d", res.StatusCode)
}
res, _ = http.Get(ts.URL + "/assets/missing.js")
res.Body.Close()
if res.StatusCode != 404 {
t.Fatalf("missing asset: %d", res.StatusCode)
}
}
// Without WithFrontend, paths outside /api and /ws are 404.
func TestNoFrontend(t *testing.T) {
s, err := New(defaultConf(t), WithLogger(slog.New(slog.DiscardHandler)))
if err != nil {
t.Fatal(err)
}
ts := httptest.NewServer(s.Handler())
t.Cleanup(func() {
ts.Close()
s.backgroundCancel()
})
res, err := http.Get(ts.URL + "/index.html")
if err != nil {
t.Fatal(err)
}
res.Body.Close()
if res.StatusCode != http.StatusNotFound {
t.Fatalf("no frontend: %d; want 404", res.StatusCode)
}
}
// A WebSocket upgrade is refused from a foreign origin, accepted from an
// origin in csrf.cross_origin_protection.trusted_origins, and the client then
// gets replies.
func TestWebSocket(t *testing.T) {
ts := newTestServer(t, func(conf *config.JSCServer) {
conf.CSRF.CrossOriginProtection.TrustedOrigins = []string{
"https://admin.example.com",
}
})
c := clientWithJar(t)
wsURL := "ws" + strings.TrimPrefix(ts.URL, "http") + "/ws"
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
foreign := &websocket.DialOptions{
HTTPClient: c,
HTTPHeader: http.Header{"Origin": []string{"https://evil.example"}},
}
_, res, err := websocket.Dial(ctx, wsURL, foreign)
if err == nil || res.StatusCode != http.StatusForbidden {
t.Fatalf("foreign origin: expected 403, err=%v", err)
}
trusted := &websocket.DialOptions{
HTTPClient: c,
HTTPHeader: http.Header{"Origin": []string{"https://admin.example.com"}},
}
conn, _, err := websocket.Dial(ctx, wsURL, trusted)
if err != nil {
t.Fatal(err)
}
defer conn.CloseNow()
read := func() ws.Envelope {
var e ws.Envelope
if err := wsjson.Read(ctx, conn, &e); err != nil {
t.Fatal(err)
}
return e
}
if e := read(); e.Type != ws.TypeWelcome {
t.Fatalf("want welcome, got %+v", e)
}
wsjson.Write(ctx, conn, ws.Envelope{
Type: ws.TypePing,
ID: "1",
})
if e := read(); e.Type != ws.TypePong || e.ID != "1" {
t.Fatalf("want pong, got %+v", e)
}
conn.Close(websocket.StatusNormalClosure, "")
}
// A timeout of 0 or less is passed on as -1, which http.Server and the PROXY
// listener read as no limit rather than as another timeout.
func TestNoLimitIfUnset(t *testing.T) {
tests := []struct {
in, want time.Duration
}{
{0, -1},
{-time.Second, -1},
{time.Second, time.Second},
}
for _, tt := range tests {
if got := noLimitIfUnset(tt.in); got != tt.want {
t.Errorf("noLimitIfUnset(%v) = %v; want %v", tt.in, got, tt.want)
}
}
}
// With WithAccessLog, each request, including one refused by middleware, is
// written to the access log in the Combined Log Format.
func TestAccessLog(t *testing.T) {
var buffer bytes.Buffer
ts := newTestServer(t, func(conf *config.JSCServer) {
conf.Headers.AllowedHosts = []string{"example.com"}
}, WithAccessLog(log.New(&buffer, "", 0)))
req, _ := http.NewRequest("GET", ts.URL+"/api/health", nil)
req.Host = "example.com"
req.Header.Set("User-Agent", "test-agent")
res, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatal(err)
}
res.Body.Close()
// A Host that isn't allowed is refused by RequestChecks.
res, err = http.Get(ts.URL + "/api/health")
if err != nil {
t.Fatal(err)
}
res.Body.Close()
lines := strings.Split(strings.TrimSpace(buffer.String()), "\n")
if len(lines) != 2 {
t.Fatalf("access log has %d lines; want 2:\n%s", len(lines), &buffer)
}
if !strings.Contains(lines[0], `"GET /api/health HTTP/1.1" 200`) ||
!strings.Contains(lines[0], `"test-agent"`) {
t.Errorf("first line = %q; want a 200 with the user agent", lines[0])
}
if !strings.Contains(lines[1], `"GET /api/health HTTP/1.1" 421`) {
t.Errorf("second line = %q; want the refused request's 421", lines[1])
}
}
// With WithAccessLog, a WebSocket upgrade still hijacks the connection: the
// client gets its welcome, and the upgrade is logged with 101.
func TestAccessLogWebSocket(t *testing.T) {
var buffer syncBuffer
ts := newTestServer(t, nil, WithAccessLog(log.New(&buffer, "", 0)))
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
wsURL := "ws" + strings.TrimPrefix(ts.URL, "http") + "/ws"
conn, _, err := websocket.Dial(ctx, wsURL, nil)
if err != nil {
t.Fatal(err)
}
var welcome ws.Envelope
err = wsjson.Read(ctx, conn, &welcome)
if err != nil || welcome.Type != ws.TypeWelcome {
t.Fatalf("want welcome, got %+v, %v", welcome, err)
}
conn.Close(websocket.StatusNormalClosure, "")
// The line is written when the connection's handler returns.
deadline := time.Now().Add(time.Second)
for !strings.Contains(buffer.String(), `"GET /ws HTTP/1.1" 101`) {
if time.Now().After(deadline) {
t.Fatalf("upgrade not logged with 101: %q", buffer.String())
}
time.Sleep(5 * time.Millisecond)
}
}
// syncBuffer is a bytes.Buffer safe to write from one goroutine while
// another reads it.
type syncBuffer struct {
mu sync.Mutex
buffer bytes.Buffer
}
func (b *syncBuffer) Write(data []byte) (int, error) {
b.mu.Lock()
defer b.mu.Unlock()
return b.buffer.Write(data)
}
func (b *syncBuffer) String() string {
b.mu.Lock()
defer b.mu.Unlock()
return b.buffer.String()
}
// opportunityStore returns a store, in a temporary file, holding one
// opportunity, ocds-1.
func opportunityStore(t *testing.T) *store.Store {
t.Helper()
st, err := store.Open(
context.Background(),
filepath.Join(t.TempDir(), "server.db"),
store.WithAutoMigrate(),
)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() {
st.Close()
})
_, err = st.SaveOpportunity(
context.Background(),
&types.Opportunity{
UUID: "ocds-1",
UUIDType: "ocid",
Releases: []types.OpportunityRelease{
{
ReleaseID: "r1",
ReleaseDate: time.Now(),
JSON: "{}",
},
},
},
)
if err != nil {
t.Fatal(err)
}
return st
}
// With WithStore the opportunities are served, and changing a mark needs
// the CSRF token; without it there are no opportunity routes.
func TestOpportunityRoutes(t *testing.T) {
ts := newTestServer(t, nil, WithStore(opportunityStore(t)))
c := clientWithJar(t)
do := apiClient(t, ts, c, getToken(t, c, ts.URL))
if res := do("GET", "/api/opportunities", "", false); res.StatusCode != 200 {
t.Errorf("list: %d; want 200", res.StatusCode)
}
mark := "/api/opportunities/ocds-1/marks/interested"
if res := do("PUT", mark, "", false); res.StatusCode != http.StatusForbidden {
t.Errorf("mark without token: %d; want 403", res.StatusCode)
}
if res := do("PUT", mark, "", true); res.StatusCode != 200 {
t.Errorf("mark with token: %d; want 200", res.StatusCode)
}
without := newTestServer(t, nil)
res, err := http.Get(without.URL + "/api/opportunities")
if err != nil {
t.Fatal(err)
}
res.Body.Close()
if res.StatusCode != http.StatusNotFound {
t.Errorf("no store: %d; want 404", res.StatusCode)
}
}
// With WithEvents, events published to the bus, and the API's own, such as
// a changed mark, are pushed to WebSocket clients.
func TestEventsPushed(t *testing.T) {
var bus events.Bus
ts := newTestServer(
t, nil, WithStore(opportunityStore(t)), WithEvents(&bus),
)
c := clientWithJar(t)
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
conn, _, err := websocket.Dial(
ctx,
"ws"+strings.TrimPrefix(ts.URL, "http")+"/ws",
&websocket.DialOptions{
HTTPClient: c,
},
)
if err != nil {
t.Fatal(err)
}
defer conn.CloseNow()
read := func() ws.Envelope {
var e ws.Envelope
if err := wsjson.Read(ctx, conn, &e); err != nil {
t.Fatal(err)
}
return e
}
read() // welcome
bus.Publish(events.Event{
Type: events.OpportunitiesUpdated,
Payload: map[string]int{"n": 1},
})
e := read()
if e.Type != ws.TypeEvent || e.EventType != events.OpportunitiesUpdated {
t.Fatalf("want the published event, got %+v", e)
}
do := apiClient(t, ts, c, getToken(t, c, ts.URL))
mark := "/api/opportunities/ocds-1/marks/interested"
if res := do("PUT", mark, "", true); res.StatusCode != 200 {
t.Fatalf("mark: %d", res.StatusCode)
}
e = read()
if e.EventType != events.OpportunityMarksUpdated ||
!strings.Contains(string(e.Payload), `"interested":true`) {
t.Fatalf("want the marks event, got %+v", e)
}
}