package http import ( "bytes" "context" "encoding/json" "io" stdhttp "net/http" "net/http/httptest" "path/filepath" "testing" "gitea.dcglab.co.uk/steve/restic-manager/internal/crypto" "gitea.dcglab.co.uk/steve/restic-manager/internal/server/config" "gitea.dcglab.co.uk/steve/restic-manager/internal/store" ) // newTestServer wires up a Server with file-backed sqlite + a fresh // AEAD key + an httptest harness. Returns the Server, its base URL, // and the bootstrap token (caller decides whether to consume it). func newTestServer(t *testing.T, withBootstrapToken bool) (*Server, string) { t.Helper() dir := t.TempDir() st, err := store.Open(context.Background(), filepath.Join(dir, "rm.db")) if err != nil { t.Fatalf("store: %v", err) } t.Cleanup(func() { _ = st.Close() }) keyPath := filepath.Join(dir, "secret.key") if err := crypto.GenerateKeyFile(keyPath); err != nil { t.Fatalf("genkey: %v", err) } key, _ := crypto.LoadKeyFromFile(keyPath) aead, _ := crypto.NewAEAD(key) deps := Deps{ Cfg: config.Config{Listen: ":0", DataDir: dir, SecretKeyFile: keyPath}, Store: st, AEAD: aead, } if withBootstrapToken { deps.BootstrapToken = "test-token" } s := New(deps) ts := httptest.NewServer(s.srv.Handler) t.Cleanup(ts.Close) return s, ts.URL } func TestHealthz(t *testing.T) { t.Parallel() _, url := newTestServer(t, false) res, err := stdhttp.Get(url + "/healthz") if err != nil { t.Fatalf("GET: %v", err) } defer res.Body.Close() if res.StatusCode != stdhttp.StatusNoContent { t.Errorf("status: %d", res.StatusCode) } } func TestBootstrapHappyPath(t *testing.T) { t.Parallel() _, url := newTestServer(t, true) body, _ := json.Marshal(bootstrapRequest{ Token: "test-token", Username: "alice", Password: "averylongpassword", }) res, err := stdhttp.Post(url+"/api/bootstrap", "application/json", bytes.NewReader(body)) if err != nil { t.Fatalf("POST: %v", err) } defer res.Body.Close() if res.StatusCode != stdhttp.StatusCreated { got, _ := io.ReadAll(res.Body) t.Fatalf("status %d: %s", res.StatusCode, got) } // Re-running bootstrap must fail now that a user exists. res2, _ := stdhttp.Post(url+"/api/bootstrap", "application/json", bytes.NewReader(body)) defer res2.Body.Close() if res2.StatusCode != stdhttp.StatusConflict { t.Errorf("second bootstrap: want 409, got %d", res2.StatusCode) } } func TestBootstrapBadToken(t *testing.T) { t.Parallel() _, url := newTestServer(t, true) body, _ := json.Marshal(bootstrapRequest{ Token: "wrong", Username: "alice", Password: "averylongpassword", }) res, _ := stdhttp.Post(url+"/api/bootstrap", "application/json", bytes.NewReader(body)) defer res.Body.Close() if res.StatusCode != stdhttp.StatusUnauthorized { t.Errorf("status: %d", res.StatusCode) } } func TestBootstrapWeakPassword(t *testing.T) { t.Parallel() _, url := newTestServer(t, true) body, _ := json.Marshal(bootstrapRequest{ Token: "test-token", Username: "alice", Password: "short", }) res, _ := stdhttp.Post(url+"/api/bootstrap", "application/json", bytes.NewReader(body)) defer res.Body.Close() if res.StatusCode != stdhttp.StatusBadRequest { t.Errorf("status: %d", res.StatusCode) } } func TestLoginAndLogout(t *testing.T) { t.Parallel() _, url := newTestServer(t, true) // Bootstrap the admin first. bs, _ := json.Marshal(bootstrapRequest{ Token: "test-token", Username: "alice", Password: "averylongpassword", }) stdhttp.Post(url+"/api/bootstrap", "application/json", bytes.NewReader(bs)) //nolint:errcheck // Login. body, _ := json.Marshal(loginRequest{Username: "alice", Password: "averylongpassword"}) res, err := stdhttp.Post(url+"/api/auth/login", "application/json", bytes.NewReader(body)) if err != nil { t.Fatalf("login: %v", err) } defer res.Body.Close() if res.StatusCode != stdhttp.StatusOK { got, _ := io.ReadAll(res.Body) t.Fatalf("login status %d: %s", res.StatusCode, got) } cookies := res.Cookies() var sess *stdhttp.Cookie for _, c := range cookies { if c.Name == sessionCookieName { sess = c break } } if sess == nil { t.Fatal("no session cookie set") } if !sess.HttpOnly { t.Error("session cookie should be HttpOnly") } // Bad password → 401. bad, _ := json.Marshal(loginRequest{Username: "alice", Password: "wrong"}) res2, _ := stdhttp.Post(url+"/api/auth/login", "application/json", bytes.NewReader(bad)) defer res2.Body.Close() if res2.StatusCode != stdhttp.StatusUnauthorized { t.Errorf("bad pass: want 401, got %d", res2.StatusCode) } // Logout deletes the cookie + the server-side session. logoutReq, _ := stdhttp.NewRequest(stdhttp.MethodPost, url+"/api/auth/logout", nil) logoutReq.AddCookie(sess) res3, err := stdhttp.DefaultClient.Do(logoutReq) if err != nil { t.Fatalf("logout: %v", err) } defer res3.Body.Close() if res3.StatusCode != stdhttp.StatusNoContent { t.Errorf("logout status: %d", res3.StatusCode) } } func TestLoginUnknownUserSameAsBadPassword(t *testing.T) { t.Parallel() _, url := newTestServer(t, false) body, _ := json.Marshal(loginRequest{Username: "ghost", Password: "anything12345"}) res, _ := stdhttp.Post(url+"/api/auth/login", "application/json", bytes.NewReader(body)) defer res.Body.Close() // Both unknown-user and bad-password must return 401 with the same // code so a probe can't enumerate usernames. if res.StatusCode != stdhttp.StatusUnauthorized { t.Errorf("unknown user: want 401, got %d", res.StatusCode) } }