Initial OpenWrt RMM implementation

This commit is contained in:
benya
2026-06-04 15:26:43 +03:00
commit 3cc9385f14
44 changed files with 9892 additions and 0 deletions
+138
View File
@@ -0,0 +1,138 @@
package httpapi
import (
"crypto/hmac"
"crypto/rand"
"crypto/sha256"
"crypto/subtle"
"encoding/base64"
"net/http"
"strconv"
"strings"
"time"
)
const (
operatorSessionCookie = "rmm_operator_session"
operatorSessionTTL = 12 * time.Hour
)
func (a *App) handleLogin(w http.ResponseWriter, r *http.Request) {
var req loginRequest
if !decodeJSON(w, r, &req) {
return
}
if !constantTimeEqual(strings.TrimSpace(req.Username), a.operatorUsername) || !constantTimeEqual(req.Password, a.operatorPassword) {
time.Sleep(250 * time.Millisecond)
writeError(w, http.StatusUnauthorized, "invalid username or password")
return
}
token, expiresAt, err := a.newOperatorSession(req.Username)
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to create session")
return
}
http.SetCookie(w, &http.Cookie{
Name: operatorSessionCookie,
Value: token,
Path: "/",
HttpOnly: true,
Secure: a.cookieSecure,
SameSite: http.SameSiteLaxMode,
Expires: expiresAt,
MaxAge: int(operatorSessionTTL.Seconds()),
})
_, _ = a.store.AddAuditEvent(r.Context(), req.Username, "auth.login", "", "", mustJSON(map[string]string{
"request_id": requestID(r.Context()),
}))
writeJSON(w, http.StatusOK, map[string]any{"username": req.Username, "expires_at": expiresAt})
}
func (a *App) handleLogout(w http.ResponseWriter, r *http.Request) {
username, _ := a.operatorSessionUsername(r)
http.SetCookie(w, &http.Cookie{
Name: operatorSessionCookie,
Value: "",
Path: "/",
HttpOnly: true,
Secure: a.cookieSecure,
SameSite: http.SameSiteLaxMode,
MaxAge: -1,
Expires: time.Unix(0, 0),
})
if username != "" {
_, _ = a.store.AddAuditEvent(r.Context(), username, "auth.logout", "", "", mustJSON(map[string]string{
"request_id": requestID(r.Context()),
}))
}
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
}
func (a *App) handleAuthMe(w http.ResponseWriter, r *http.Request) {
username, ok := a.operatorSessionUsername(r)
if !ok {
username = a.operatorUsername
}
writeJSON(w, http.StatusOK, map[string]string{"username": username})
}
func (a *App) newOperatorSession(username string) (string, time.Time, error) {
var nonce [16]byte
if _, err := rand.Read(nonce[:]); err != nil {
return "", time.Time{}, err
}
expiresAt := time.Now().UTC().Add(operatorSessionTTL)
payload := strings.Join([]string{
username,
strconv.FormatInt(expiresAt.Unix(), 10),
base64.RawURLEncoding.EncodeToString(nonce[:]),
}, "|")
encodedPayload := base64.RawURLEncoding.EncodeToString([]byte(payload))
signature := a.signSessionPayload(encodedPayload)
return encodedPayload + "." + signature, expiresAt, nil
}
func (a *App) operatorSessionUsername(r *http.Request) (string, bool) {
cookie, err := r.Cookie(operatorSessionCookie)
if err != nil || cookie.Value == "" {
return "", false
}
encodedPayload, signature, ok := strings.Cut(cookie.Value, ".")
if !ok || !hmac.Equal([]byte(signature), []byte(a.signSessionPayload(encodedPayload))) {
return "", false
}
payloadBytes, err := base64.RawURLEncoding.DecodeString(encodedPayload)
if err != nil {
return "", false
}
parts := strings.Split(string(payloadBytes), "|")
if len(parts) != 3 || !constantTimeEqual(parts[0], a.operatorUsername) {
return "", false
}
expiresUnix, err := strconv.ParseInt(parts[1], 10, 64)
if err != nil || time.Now().UTC().Unix() >= expiresUnix {
return "", false
}
return parts[0], true
}
func (a *App) signSessionPayload(payload string) string {
mac := hmac.New(sha256.New, a.sessionSecret)
_, _ = mac.Write([]byte(payload))
return base64.RawURLEncoding.EncodeToString(mac.Sum(nil))
}
func constantTimeEqual(left, right string) bool {
if len(left) != len(right) {
return false
}
return subtle.ConstantTimeCompare([]byte(left), []byte(right)) == 1
}
func (a *App) operatorAuthorized(r *http.Request) bool {
if token, ok := bearerToken(r); ok && constantTimeEqual(token, a.operatorToken) {
return true
}
_, ok := a.operatorSessionUsername(r)
return ok
}
File diff suppressed because it is too large Load Diff
+571
View File
@@ -0,0 +1,571 @@
package httpapi_test
import (
"bytes"
"context"
"encoding/json"
"io"
"net/http"
"net/http/cookiejar"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"rmm-openwrt/server/internal/httpapi"
"rmm-openwrt/server/internal/store"
)
func TestAgentOperatorSmokeFlow(t *testing.T) {
st, err := store.OpenSQLite(context.Background(), filepath.Join(t.TempDir(), "test.db"))
if err != nil {
t.Fatal(err)
}
defer st.Close()
srv := httptest.NewServer(httpapi.NewHandler(st, httpapi.Config{
EnrollmentToken: "enroll-test",
OperatorToken: "operator-test",
}))
defer srv.Close()
var enrolled struct {
DeviceID string `json:"device_id"`
DeviceToken string `json:"device_token"`
}
requestJSON(t, http.MethodPost, srv.URL+"/api/agent/enroll", "", map[string]any{
"enrollment_token": "enroll-test",
"hostname": "test-openwrt",
"openwrt_version": "OpenWrt test",
}, http.StatusCreated, &enrolled)
if enrolled.DeviceID == "" || enrolled.DeviceToken == "" {
t.Fatalf("expected enrollment credentials, got %#v", enrolled)
}
var heartbeat struct {
Commands []any `json:"commands"`
}
requestJSON(t, http.MethodPost, srv.URL+"/api/agent/heartbeat", enrolled.DeviceToken, map[string]any{
"device_id": enrolled.DeviceID,
"inventory": map[string]any{
"hostname": "test-openwrt",
},
"metrics": map[string]any{
"loadavg": "0.00 0.01 0.02",
},
}, http.StatusOK, &heartbeat)
if len(heartbeat.Commands) != 0 {
t.Fatalf("expected no commands, got %d", len(heartbeat.Commands))
}
var history struct {
Samples []struct {
DeviceID string `json:"device_id"`
} `json:"samples"`
}
requestJSON(t, http.MethodGet, srv.URL+"/api/devices/"+enrolled.DeviceID+"/metrics-history", "operator-test", nil, http.StatusOK, &history)
if len(history.Samples) != 1 || history.Samples[0].DeviceID != enrolled.DeviceID {
t.Fatalf("unexpected metrics history: %#v", history)
}
var alerts struct {
Alerts []struct {
ID string `json:"id"`
Type string `json:"type"`
Severity string `json:"severity"`
Status string `json:"status"`
} `json:"alerts"`
}
requestJSON(t, http.MethodGet, srv.URL+"/api/devices/"+enrolled.DeviceID+"/alerts", "operator-test", nil, http.StatusOK, &alerts)
if len(alerts.Alerts) != 0 {
t.Fatalf("expected no active alerts, got %#v", alerts.Alerts)
}
var fleetDevice struct {
ID string `json:"id"`
Group string `json:"group"`
Tags []string `json:"tags"`
}
requestJSON(t, http.MethodPatch, srv.URL+"/api/devices/"+enrolled.DeviceID+"/fleet", "operator-test", map[string]any{
"group": "lab",
"tags": []string{"edge", "vpn", "edge"},
}, http.StatusOK, &fleetDevice)
if fleetDevice.ID != enrolled.DeviceID || fleetDevice.Group != "lab" || len(fleetDevice.Tags) != 2 {
t.Fatalf("unexpected fleet metadata: %#v", fleetDevice)
}
requestJSON(t, http.MethodGet, srv.URL+"/api/devices", "", nil, http.StatusUnauthorized, nil)
var created struct {
ID string `json:"id"`
Type string `json:"type"`
Args json.RawMessage `json:"args"`
Status string `json:"status"`
}
requestJSON(t, http.MethodPost, srv.URL+"/api/devices/"+enrolled.DeviceID+"/commands", "operator-test", map[string]any{
"type": "ping",
"args": map[string]any{"target": "1.1.1.1"},
}, http.StatusCreated, &created)
if created.ID == "" || created.Type != "ping" || created.Status != "queued" {
t.Fatalf("unexpected created command: %#v", created)
}
if string(created.Args) != `{"target":"1.1.1.1"}` {
t.Fatalf("expected compact args, got %s", created.Args)
}
nextReq := map[string]any{"device_id": enrolled.DeviceID}
nextLine := requestText(t, http.MethodPost, srv.URL+"/api/agent/commands/next", enrolled.DeviceToken, nextReq, http.StatusOK)
expectedPrefix := created.ID + "\tping\t"
if !strings.HasPrefix(nextLine, expectedPrefix) {
t.Fatalf("expected next command prefix %q, got %q", expectedPrefix, nextLine)
}
if !strings.Contains(nextLine, `{"target":"1.1.1.1"}`) {
t.Fatalf("expected compact next command args, got %q", nextLine)
}
if err := st.ExpireClaimedCommands(context.Background(), 0); err != nil {
t.Fatal(err)
}
nextLine = requestText(t, http.MethodPost, srv.URL+"/api/agent/commands/next", enrolled.DeviceToken, nextReq, http.StatusOK)
if !strings.HasPrefix(nextLine, expectedPrefix) {
t.Fatalf("expected retried command prefix %q, got %q", expectedPrefix, nextLine)
}
var result struct {
Status string `json:"status"`
}
requestJSON(t, http.MethodPost, srv.URL+"/api/agent/commands/"+created.ID+"/result", enrolled.DeviceToken, map[string]any{
"device_id": enrolled.DeviceID,
"status": "completed",
"exit_code": 0,
"output": "network.wg0.private_key='super-secret'\nnetwork.lan.ipaddr='10.0.0.1'",
"result": map[string]any{},
}, http.StatusOK, &result)
if result.Status != "ok" {
t.Fatalf("unexpected result status: %q", result.Status)
}
var commandDetail struct {
ID string `json:"id"`
Status string `json:"status"`
Output string `json:"output"`
AttemptCount int `json:"attempt_count"`
}
requestJSON(t, http.MethodGet, srv.URL+"/api/devices/"+enrolled.DeviceID+"/commands/"+created.ID, "operator-test", nil, http.StatusOK, &commandDetail)
if commandDetail.ID != created.ID || commandDetail.Status != "completed" || commandDetail.AttemptCount != 2 {
t.Fatalf("unexpected command detail: %#v", commandDetail)
}
if !strings.Contains(commandDetail.Output, "private_key='[redacted]'") || strings.Contains(commandDetail.Output, "super-secret") {
t.Fatalf("expected redacted command output, got %q", commandDetail.Output)
}
var expiring struct {
ID string `json:"id"`
Status string `json:"status"`
}
requestJSON(t, http.MethodPost, srv.URL+"/api/devices/"+enrolled.DeviceID+"/commands", "operator-test", map[string]any{
"type": "opkg_list_installed",
"args": map[string]any{},
}, http.StatusCreated, &expiring)
for i := 0; i < 3; i++ {
nextLine = requestText(t, http.MethodPost, srv.URL+"/api/agent/commands/next", enrolled.DeviceToken, nextReq, http.StatusOK)
if !strings.HasPrefix(nextLine, expiring.ID+"\topkg_list_installed\t") {
t.Fatalf("expected expiring command attempt %d, got %q", i+1, nextLine)
}
if err := st.ExpireClaimedCommands(context.Background(), 0); err != nil {
t.Fatal(err)
}
}
requestText(t, http.MethodPost, srv.URL+"/api/agent/commands/next", enrolled.DeviceToken, nextReq, http.StatusNoContent)
requestJSON(t, http.MethodGet, srv.URL+"/api/devices/"+enrolled.DeviceID+"/commands/"+expiring.ID, "operator-test", nil, http.StatusOK, &expiring)
if expiring.Status != "expired" {
t.Fatalf("expected expired command, got %#v", expiring)
}
requestJSON(t, http.MethodGet, srv.URL+"/api/devices/"+enrolled.DeviceID+"/alerts", "operator-test", nil, http.StatusOK, &alerts)
foundCommandAlert := false
commandAlertID := ""
for _, alert := range alerts.Alerts {
if alert.Type == "command_attention" && alert.Severity == "warning" {
foundCommandAlert = true
commandAlertID = alert.ID
}
}
if !foundCommandAlert {
t.Fatalf("expected command attention alert, got %#v", alerts.Alerts)
}
var acknowledgedAlert struct {
ID string `json:"id"`
Status string `json:"status"`
}
requestJSON(t, http.MethodPost, srv.URL+"/api/devices/"+enrolled.DeviceID+"/alerts/"+commandAlertID+"/acknowledge", "operator-test", nil, http.StatusOK, &acknowledgedAlert)
if acknowledgedAlert.ID != commandAlertID || acknowledgedAlert.Status != "acknowledged" {
t.Fatalf("unexpected acknowledged alert: %#v", acknowledgedAlert)
}
var commandHistory struct {
Commands []struct {
ID string `json:"id"`
Status string `json:"status"`
} `json:"commands"`
}
requestJSON(t, http.MethodGet, srv.URL+"/api/devices/"+enrolled.DeviceID+"/commands", "operator-test", nil, http.StatusOK, &commandHistory)
foundCreated := false
for _, c := range commandHistory.Commands {
if c.ID == created.ID && c.Status == "completed" {
foundCreated = true
}
}
if !foundCreated {
t.Fatalf("unexpected command history: %#v", commandHistory)
}
var cancellable struct {
ID string `json:"id"`
Status string `json:"status"`
}
requestJSON(t, http.MethodPost, srv.URL+"/api/devices/"+enrolled.DeviceID+"/commands", "operator-test", map[string]any{
"type": "traceroute",
"args": map[string]any{"target": "1.1.1.1"},
}, http.StatusCreated, &cancellable)
requestJSON(t, http.MethodPost, srv.URL+"/api/devices/"+enrolled.DeviceID+"/commands/"+cancellable.ID+"/cancel", "operator-test", nil, http.StatusOK, &cancellable)
if cancellable.Status != "cancelled" {
t.Fatalf("expected cancelled command, got %#v", cancellable)
}
var packageCommand struct {
ID string `json:"id"`
Type string `json:"type"`
Status string `json:"status"`
}
requestJSON(t, http.MethodPost, srv.URL+"/api/devices/"+enrolled.DeviceID+"/commands", "operator-test", map[string]any{
"type": "pkg_list_upgradable",
"args": map[string]any{},
}, http.StatusCreated, &packageCommand)
if packageCommand.Type != "pkg_list_upgradable" || packageCommand.Status != "queued" {
t.Fatalf("unexpected package command: %#v", packageCommand)
}
var bulk struct {
Commands []struct {
ID string `json:"id"`
Type string `json:"type"`
Status string `json:"status"`
} `json:"commands"`
}
requestJSON(t, http.MethodPost, srv.URL+"/api/devices/bulk-commands", "operator-test", map[string]any{
"device_ids": []string{enrolled.DeviceID, enrolled.DeviceID},
"type": "ping",
"args": map[string]any{"target": "1.1.1.1"},
}, http.StatusCreated, &bulk)
if len(bulk.Commands) != 1 || bulk.Commands[0].Type != "ping" || bulk.Commands[0].Status != "queued" {
t.Fatalf("unexpected bulk commands: %#v", bulk.Commands)
}
var uciCommand struct {
ID string `json:"id"`
Type string `json:"type"`
Status string `json:"status"`
}
requestJSON(t, http.MethodPost, srv.URL+"/api/devices/"+enrolled.DeviceID+"/commands", "operator-test", map[string]any{
"type": "uci_show",
"args": map[string]any{"config": "network"},
}, http.StatusCreated, &uciCommand)
if uciCommand.Type != "uci_show" || uciCommand.Status != "queued" {
t.Fatalf("unexpected uci command: %#v", uciCommand)
}
requestJSON(t, http.MethodPost, srv.URL+"/api/devices/"+enrolled.DeviceID+"/commands", "operator-test", map[string]any{
"type": "uci_backup",
"args": map[string]any{"config": "network"},
}, http.StatusCreated, &uciCommand)
if uciCommand.Type != "uci_backup" || uciCommand.Status != "queued" {
t.Fatalf("unexpected uci backup command: %#v", uciCommand)
}
requestJSON(t, http.MethodPost, srv.URL+"/api/devices/"+enrolled.DeviceID+"/commands", "operator-test", map[string]any{
"type": "uci_preview",
"args": map[string]any{"config": "network", "section": "lan", "option": "ipaddr", "value": "10.10.10.1/24"},
}, http.StatusCreated, &uciCommand)
if uciCommand.Type != "uci_preview" || uciCommand.Status != "queued" {
t.Fatalf("unexpected uci preview command: %#v", uciCommand)
}
for _, commandType := range []string{"uci_commit_confirmed", "uci_restore"} {
requestJSON(t, http.MethodPost, srv.URL+"/api/devices/"+enrolled.DeviceID+"/commands", "operator-test", map[string]any{
"type": commandType,
"args": map[string]any{"config": "network"},
}, http.StatusCreated, &uciCommand)
if uciCommand.Type != commandType || uciCommand.Status != "queued" {
t.Fatalf("unexpected %s command: %#v", commandType, uciCommand)
}
}
var remoteSession struct {
ID string `json:"id"`
Status string `json:"status"`
CommandID string `json:"command_id"`
ServerHost string `json:"server_host"`
ServerPort int `json:"server_port"`
RemotePort int `json:"remote_port"`
LocalPort int `json:"local_port"`
}
requestJSON(t, http.MethodPost, srv.URL+"/api/devices/"+enrolled.DeviceID+"/remote-sessions", "operator-test", map[string]any{
"target": "ssh",
"server_host": "10.10.10.2",
"server_port": 22,
"remote_port": 22022,
"local_port": 22,
"duration_seconds": 900,
}, http.StatusCreated, &remoteSession)
if remoteSession.ID == "" || remoteSession.Status != "queued" || remoteSession.CommandID == "" || remoteSession.RemotePort != 22022 {
t.Fatalf("unexpected remote session: %#v", remoteSession)
}
var remoteSessions struct {
Sessions []struct {
ID string `json:"id"`
CommandID string `json:"command_id"`
Status string `json:"status"`
} `json:"remote_sessions"`
}
requestJSON(t, http.MethodGet, srv.URL+"/api/devices/"+enrolled.DeviceID+"/remote-sessions", "operator-test", nil, http.StatusOK, &remoteSessions)
if len(remoteSessions.Sessions) != 1 || remoteSessions.Sessions[0].ID != remoteSession.ID || remoteSessions.Sessions[0].CommandID == "" {
t.Fatalf("unexpected remote sessions: %#v", remoteSessions.Sessions)
}
requestJSON(t, http.MethodGet, srv.URL+"/api/devices/"+enrolled.DeviceID+"/commands/"+remoteSession.CommandID, "operator-test", nil, http.StatusOK, &uciCommand)
if uciCommand.Type != "remote_ssh_reverse" || uciCommand.Status != "queued" {
t.Fatalf("unexpected remote command: %#v", uciCommand)
}
requestJSON(t, http.MethodPost, srv.URL+"/api/agent/commands/"+remoteSession.CommandID+"/result", enrolled.DeviceToken, map[string]any{
"device_id": enrolled.DeviceID,
"status": "completed",
"exit_code": 0,
"output": "remote ssh reverse started",
"result": map[string]any{},
}, http.StatusOK, &result)
requestJSON(t, http.MethodGet, srv.URL+"/api/devices/"+enrolled.DeviceID+"/remote-sessions", "operator-test", nil, http.StatusOK, &remoteSessions)
if len(remoteSessions.Sessions) != 1 || remoteSessions.Sessions[0].Status != "active" {
t.Fatalf("expected active remote session, got %#v", remoteSessions.Sessions)
}
requestJSON(t, http.MethodPost, srv.URL+"/api/devices/"+enrolled.DeviceID+"/remote-sessions/"+remoteSession.ID+"/close", "operator-test", nil, http.StatusOK, &remoteSession)
if remoteSession.Status != "closed" {
t.Fatalf("expected closed remote session, got %#v", remoteSession)
}
var devices struct {
Devices []struct {
ID string `json:"id"`
Online bool `json:"online"`
ActiveAlerts int `json:"active_alerts"`
} `json:"devices"`
}
requestJSON(t, http.MethodGet, srv.URL+"/api/devices", "operator-test", nil, http.StatusOK, &devices)
if len(devices.Devices) != 1 {
t.Fatalf("expected one device, got %d", len(devices.Devices))
}
if devices.Devices[0].ID != enrolled.DeviceID || !devices.Devices[0].Online {
t.Fatalf("unexpected device list: %#v", devices.Devices)
}
if devices.Devices[0].ActiveAlerts < 1 {
t.Fatalf("expected open alert count, got %#v", devices.Devices)
}
var audit struct {
Events []struct {
Action string `json:"action"`
DeviceID string `json:"device_id"`
CommandID string `json:"command_id"`
} `json:"audit_events"`
}
requestJSON(t, http.MethodGet, srv.URL+"/api/audit-events?device_id="+enrolled.DeviceID, "operator-test", nil, http.StatusOK, &audit)
if len(audit.Events) < 2 {
t.Fatalf("expected audit events, got %#v", audit.Events)
}
}
func TestServesStaticWebUI(t *testing.T) {
dir := t.TempDir()
if err := os.WriteFile(filepath.Join(dir, "index.html"), []byte("<!doctype html><title>RMM UI</title>"), 0644); err != nil {
t.Fatal(err)
}
st, err := store.OpenSQLite(context.Background(), filepath.Join(t.TempDir(), "test.db"))
if err != nil {
t.Fatal(err)
}
defer st.Close()
srv := httptest.NewServer(httpapi.NewHandler(st, httpapi.Config{
EnrollmentToken: "enroll-test",
OperatorToken: "operator-test",
StaticDir: dir,
}))
defer srv.Close()
resp, err := http.Get(srv.URL + "/")
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
data, err := io.ReadAll(resp.Body)
if err != nil {
t.Fatal(err)
}
if resp.StatusCode != http.StatusOK || !strings.Contains(string(data), "RMM UI") {
t.Fatalf("unexpected static response %d: %s", resp.StatusCode, data)
}
}
func TestOperatorCookieAuth(t *testing.T) {
st, err := store.OpenSQLite(context.Background(), filepath.Join(t.TempDir(), "test.db"))
if err != nil {
t.Fatal(err)
}
defer st.Close()
srv := httptest.NewServer(httpapi.NewHandler(st, httpapi.Config{
EnrollmentToken: "enroll-test",
OperatorToken: "operator-test",
OperatorUsername: "admin",
OperatorPassword: "correct-horse-battery-staple",
SessionSecret: "test-session-secret-with-enough-entropy",
}))
defer srv.Close()
jar, err := cookiejar.New(nil)
if err != nil {
t.Fatal(err)
}
client := &http.Client{Jar: jar}
authRequestJSON(t, client, http.MethodGet, srv.URL+"/api/devices", nil, http.StatusUnauthorized, nil)
authRequestJSON(t, client, http.MethodPost, srv.URL+"/api/auth/login", map[string]any{
"username": "admin",
"password": "wrong",
}, http.StatusUnauthorized, nil)
var me struct {
Username string `json:"username"`
}
authRequestJSON(t, client, http.MethodPost, srv.URL+"/api/auth/login", map[string]any{
"username": "admin",
"password": "correct-horse-battery-staple",
}, http.StatusOK, &me)
if me.Username != "admin" {
t.Fatalf("unexpected login response: %#v", me)
}
authRequestJSON(t, client, http.MethodGet, srv.URL+"/api/auth/me", nil, http.StatusOK, &me)
authRequestJSON(t, client, http.MethodGet, srv.URL+"/api/devices", nil, http.StatusOK, nil)
authRequestJSON(t, client, http.MethodPost, srv.URL+"/api/auth/logout", nil, http.StatusOK, nil)
authRequestJSON(t, client, http.MethodGet, srv.URL+"/api/devices", nil, http.StatusUnauthorized, nil)
}
func authRequestJSON(t *testing.T, client *http.Client, method, url string, body any, wantStatus int, out any) {
t.Helper()
var reader io.Reader
if body != nil {
buf, err := json.Marshal(body)
if err != nil {
t.Fatal(err)
}
reader = bytes.NewReader(buf)
}
req, err := http.NewRequest(method, url, reader)
if err != nil {
t.Fatal(err)
}
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
resp, err := client.Do(req)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
if resp.StatusCode != wantStatus {
data, _ := io.ReadAll(resp.Body)
t.Fatalf("expected status %d, got %d: %s", wantStatus, resp.StatusCode, data)
}
if out != nil {
if err := json.NewDecoder(resp.Body).Decode(out); err != nil {
t.Fatal(err)
}
}
}
func requestJSON(t *testing.T, method, url, token string, body any, wantStatus int, out any) {
t.Helper()
var reader io.Reader
if body != nil {
buf, err := json.Marshal(body)
if err != nil {
t.Fatal(err)
}
reader = bytes.NewReader(buf)
}
req, err := http.NewRequest(method, url, reader)
if err != nil {
t.Fatal(err)
}
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
if token != "" {
req.Header.Set("Authorization", "Bearer "+token)
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
if resp.StatusCode != wantStatus {
data, _ := io.ReadAll(resp.Body)
t.Fatalf("expected status %d, got %d: %s", wantStatus, resp.StatusCode, data)
}
if out == nil {
return
}
if err := json.NewDecoder(resp.Body).Decode(out); err != nil {
t.Fatal(err)
}
}
func requestText(t *testing.T, method, url, token string, body any, wantStatus int) string {
t.Helper()
var reader io.Reader
if body != nil {
buf, err := json.Marshal(body)
if err != nil {
t.Fatal(err)
}
reader = bytes.NewReader(buf)
}
req, err := http.NewRequest(method, url, reader)
if err != nil {
t.Fatal(err)
}
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
if token != "" {
req.Header.Set("Authorization", "Bearer "+token)
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
data, err := io.ReadAll(resp.Body)
if err != nil {
t.Fatal(err)
}
if resp.StatusCode != wantStatus {
t.Fatalf("expected status %d, got %d: %s", wantStatus, resp.StatusCode, data)
}
return string(data)
}