Files
job-search/internal/server/server_test.go
T
bwilliams 70d62f280e Added the opportunities REST API, read-only apart from their marks, described in OpenAPI 3.1 with swag
GET /api/opportunities lists the stored opportunities, newest release first,
a page at a time (offset, and limit up to 100, default 50); has_more says
whether another page follows. GET /api/opportunities/{uuid} returns one with
its releases. PUT and DELETE /api/opportunities/{uuid}/marks/{mark} set and
clear one mark (hidden, skipped, interested, bid_submitted, won, lost) and
return them all; won and lost exclude each other (409). The API uses the
types in internal/types, and changing a mark is made atomic, as the store
replaces all of an opportunity's marks at once. The marks are found by
reflection: every bool field of types.OpportunityMarks, by its JSON name, so
a new mark needs no API change.

The API has one entry point, api.New, with its dependencies as options
(api.WithStore), and the server only mounts it under /api, keeping the
middleware. New resources are added inside internal/api. job-search serve
passes server.WithStore when database.file is set, opening the store before
the server; without one there are no opportunity routes.

The API is described by swag v2 annotations on the handlers. go generate
writes the OpenAPI 3.1 description (git-ignored, like the OCDS types), which
the server embeds and serves at /api/openapi.json, with Swagger UI at
/api/docs. A test checks the description and the routes match. swag is in
the README's dev tools, pinned to v2.0.0-rc6, and the build now needs it.
2026-10-16 13:13:44 +01:00

396 lines
10 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"
"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/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)
}
opts = append([]Option{WithLogger(slog.New(slog.DiscardHandler))}, 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",
}
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,
)
}
}
}
// 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()
}
// With WithOpportunityStore the opportunities are served, and changing a mark
// needs the CSRF token; without it there are no opportunity routes.
func TestOpportunityRoutes(t *testing.T) {
opportunityStore, err := store.Open(
context.Background(),
filepath.Join(t.TempDir(), "server.db"),
store.WithAutoMigrate(),
)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() {
opportunityStore.Close()
})
_, err = opportunityStore.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)
}
ts := newTestServer(t, nil, WithStore(opportunityStore))
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)
}
}