651 lines
22 KiB
Go
651 lines
22 KiB
Go
package httpapi_test
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"io"
|
|
"net/http"
|
|
"net/http/cookiejar"
|
|
"net/http/httptest"
|
|
"net/url"
|
|
"os"
|
|
"path/filepath"
|
|
"strconv"
|
|
"strings"
|
|
"testing"
|
|
|
|
"rmm-openwrt/server/internal/httpapi"
|
|
"rmm-openwrt/server/internal/model"
|
|
"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"`
|
|
Args json.RawMessage `json:"args"`
|
|
}
|
|
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"`
|
|
LuCIPort int `json:"luci_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,
|
|
"luci_scheme": "https",
|
|
"duration_seconds": 900,
|
|
}, http.StatusCreated, &remoteSession)
|
|
if remoteSession.ID == "" || remoteSession.Status != "queued" || remoteSession.CommandID == "" || remoteSession.RemotePort != 22022 || remoteSession.LuCIPort < 22100 || remoteSession.LuCIPort > 22199 {
|
|
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)
|
|
}
|
|
if !strings.Contains(string(uciCommand.Args), `"luci_local_port":"443"`) || !strings.Contains(string(uciCommand.Args), `"luci_port":"`) {
|
|
t.Fatalf("remote command does not include LuCI HTTPS forward: %s", uciCommand.Args)
|
|
}
|
|
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 commandsAfterClose struct {
|
|
Commands []struct {
|
|
Type string `json:"type"`
|
|
Args json.RawMessage `json:"args"`
|
|
} `json:"commands"`
|
|
}
|
|
requestJSON(t, http.MethodGet, srv.URL+"/api/devices/"+enrolled.DeviceID+"/commands", "operator-test", nil, http.StatusOK, &commandsAfterClose)
|
|
foundRemoteClose := false
|
|
for _, command := range commandsAfterClose.Commands {
|
|
if command.Type == "remote_ssh_close" && strings.Contains(string(command.Args), remoteSession.ID) {
|
|
foundRemoteClose = true
|
|
}
|
|
}
|
|
if !foundRemoteClose {
|
|
t.Fatalf("expected remote_ssh_close command, got %#v", commandsAfterClose.Commands)
|
|
}
|
|
|
|
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 TestLuCIProxyRequiresActiveSessionAndRewritesPaths(t *testing.T) {
|
|
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if strings.Contains(r.Header.Get("Cookie"), "rmm_operator_session=") {
|
|
t.Fatal("operator session cookie leaked to LuCI upstream")
|
|
}
|
|
w.Header().Set("Content-Type", "text/html")
|
|
_, _ = io.WriteString(w, `<a href="/cgi-bin/luci/admin">LuCI</a><link href="/luci-static/test.css">`)
|
|
}))
|
|
defer upstream.Close()
|
|
upstreamURL, err := url.Parse(upstream.URL)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
_, portText, _ := strings.Cut(upstreamURL.Host, ":")
|
|
port, err := strconv.Atoi(portText)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
st, err := store.OpenSQLite(context.Background(), filepath.Join(t.TempDir(), "luci.db"))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer st.Close()
|
|
enrolled, err := st.EnrollDevice(context.Background(), "router", "OpenWrt")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
session, _, err := st.CreateRemoteSession(context.Background(), model.RemoteSession{
|
|
DeviceID: enrolled.DeviceID,
|
|
Target: "ssh",
|
|
Status: "active",
|
|
ServerHost: "tunnel",
|
|
ServerPort: 2222,
|
|
RemotePort: 22000,
|
|
LuCIPort: port,
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
srv := httptest.NewServer(httpapi.NewHandler(st, httpapi.Config{
|
|
OperatorToken: "operator-test",
|
|
TunnelHTTPHost: "127.0.0.1",
|
|
}))
|
|
defer srv.Close()
|
|
|
|
body := requestText(t, http.MethodGet, srv.URL+"/luci/"+enrolled.DeviceID+"/"+session.ID+"/", "operator-test", nil, http.StatusOK)
|
|
prefix := "/luci/" + enrolled.DeviceID + "/" + session.ID
|
|
if !strings.Contains(body, `href="`+prefix+`/cgi-bin/luci/admin"`) || !strings.Contains(body, `href="`+prefix+`/luci-static/test.css"`) {
|
|
t.Fatalf("LuCI paths were not rewritten: %s", body)
|
|
}
|
|
}
|
|
|
|
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)
|
|
}
|