clone

a desktop tv and program guide for over-the-air broadcasts: what's on air, what your tuner gets, live tv, recordings

main
airwaves / internal / api / auth_test.go
auth_test.go 5.5K
1package api23import (4 "context"5 "errors"6 "fmt"7 "io"8 "net/http"9 "net/http/httptest"10 "strings"11 "testing"1213 "airwaves/internal/service"14)1516// TestAdminPassword: with a password, the admin page, its API and the MCP17// endpoint ask for it (Basic auth with any user name, or a bearer token);18// the app's API, logos and the health check don't.19func TestAdminPassword(t *testing.T) {20 admin := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { fmt.Fprint(w, "admin "+r.URL.Path) })21 logos := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { fmt.Fprint(w, "logo") })22 open := httptest.NewServer((&Server{Backend: &fake{}, Admin: admin}).Handler())23 defer open.Close()24 locked := httptest.NewServer((&Server{Backend: &fake{}, Admin: admin, Logos: logos, AdminPassword: "correct horse"}).Handler())25 defer locked.Close()2627 do := func(method, url string, auth func(*http.Request)) (int, string, http.Header) {28 t.Helper()29 req, _ := http.NewRequest(method, url, nil)30 if auth != nil {31 auth(req)32 }33 resp, err := http.DefaultClient.Do(req)34 if err != nil {35 t.Fatal(err)36 }37 defer resp.Body.Close()38 body, _ := io.ReadAll(resp.Body)39 return resp.StatusCode, string(body), resp.Header40 }41 basic := func(user, pass string) func(*http.Request) {42 return func(r *http.Request) { r.SetBasicAuth(user, pass) }43 }44 bearer := func(tok string) func(*http.Request) {45 return func(r *http.Request) { r.Header.Set("Authorization", "Bearer "+tok) }46 }4748 if code, body, _ := do("GET", open.URL+"/admin/", nil); code != 200 || body != "admin /admin/" {49 t.Errorf("no password: %d %q", code, body)50 }51 for _, path := range []string{"/admin/", "/admin/api/state", "/admin/mcp"} {52 code, body, h := do("GET", locked.URL+path, nil)53 if code != http.StatusUnauthorized || !strings.HasPrefix(h.Get("WWW-Authenticate"), "Basic ") || strings.Contains(body, "admin /") {54 t.Errorf("%s without the password: %d %q %v", path, code, body, h)55 }56 if code, _, _ := do("GET", locked.URL+path, basic("anyone", "wrong")); code != http.StatusUnauthorized {57 t.Errorf("%s with a wrong password: %d", path, code)58 }59 if code, _, _ := do("GET", locked.URL+path, basic("anyone", "correct hors")); code != http.StatusUnauthorized {60 t.Errorf("%s with a prefix of the password: %d", path, code)61 }62 if code, body, _ := do("GET", locked.URL+path, basic("anyone", "correct horse")); code != 200 || body != "admin "+path {63 t.Errorf("%s with the password: %d %q", path, code, body)64 }65 }66 if code, _, _ := do("POST", locked.URL+"/admin/mcp", bearer("correct horse")); code != 200 {67 t.Errorf("MCP with the password as a bearer token: %d", code)68 }69 if code, _, _ := do("POST", locked.URL+"/admin/mcp", bearer("nope")); code != http.StatusUnauthorized {70 t.Errorf("MCP with a wrong bearer token: %d", code)71 }72 for _, path := range []string{"/api/info", "/channel-logos/1.4", "/healthz"} {73 if code, _, _ := do("GET", locked.URL+path, nil); code != 200 {74 t.Errorf("%s asked for the admin password: %d", path, code)75 }76 }77}7879// TestHealthNeedsNoToken: container health checks know no token.80func TestHealthNeedsNoToken(t *testing.T) {81 srv := httptest.NewServer((&Server{Backend: &fake{}, Token: "s3cret"}).Handler())82 defer srv.Close()83 resp, err := http.Get(srv.URL + "/healthz")84 if err != nil {85 t.Fatal(err)86 }87 defer resp.Body.Close()88 body, _ := io.ReadAll(resp.Body)89 if resp.StatusCode != 200 || string(body) != "ok\n" {90 t.Errorf("healthz: %d %q", resp.StatusCode, body)91 }92}9394// noAntenna is a server with custom channels only.95type noAntenna struct{ fake }9697func (noAntenna) Signal(context.Context) (*service.SignalReport, error) {98 return nil, service.ErrNoAntenna99}100101// TestNotAvailable: what a server doesn't have is a 501 with the reason,102// which the client passes on.103func TestNotAvailable(t *testing.T) {104 srv := httptest.NewServer((&Server{Backend: &noAntenna{}}).Handler())105 defer srv.Close()106 resp, err := http.Get(srv.URL + "/api/signal")107 if err != nil {108 t.Fatal(err)109 }110 resp.Body.Close()111 if resp.StatusCode != http.StatusNotImplemented {112 t.Errorf("status %d", resp.StatusCode)113 }114 if _, err := NewClient(srv.URL, "").Signal(t.Context()); err == nil || err.Error() != service.ErrNoAntenna.Error() {115 t.Errorf("client error: %v", err)116 }117 if errStatus(errors.New("boom")) != http.StatusInternalServerError {118 t.Error("other errors aren't 500")119 }120}121122// TestCrossSite: a browser on another site can't change anything, while123// the app's own page and clients that aren't browsers can.124func TestCrossSite(t *testing.T) {125 admin := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { fmt.Fprint(w, "admin "+r.URL.Path) })126 srv := httptest.NewServer((&Server{Backend: &fake{}, Admin: admin}).Handler())127 defer srv.Close()128 for _, tc := range []struct {129 name, fetchSite string130 want int131 }{132 {"another site", "cross-site", http.StatusForbidden},133 {"the app's page", "same-origin", http.StatusOK},134 {"not a browser", "", http.StatusOK},135 } {136 for _, path := range []string{"/api/signal/measure", "/admin/api/backups/x/restore"} {137 req, _ := http.NewRequest("POST", srv.URL+path, strings.NewReader("{}"))138 req.Header.Set("Content-Type", "text/plain")139 if tc.fetchSite != "" {140 req.Header.Set("Sec-Fetch-Site", tc.fetchSite)141 }142 resp, err := http.DefaultClient.Do(req)143 if err != nil {144 t.Fatal(err)145 }146 resp.Body.Close()147 if refused := resp.StatusCode == http.StatusForbidden; refused != (tc.want == http.StatusForbidden) {148 t.Errorf("%s, POST %s: %d", tc.name, path, resp.StatusCode)149 }150 }151 }152}