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.
542 lines
14 KiB
Go
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)
|
|
}
|
|
}
|