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("t"), }, "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) } }