151 lines
7.7 KiB
Go
151 lines
7.7 KiB
Go
package controlplane
|
|
|
|
import (
|
|
"encoding/json"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"path/filepath"
|
|
"testing"
|
|
)
|
|
|
|
func TestUserManagementPersistsAndScopesNodeAccess(t *testing.T) {
|
|
dataFile := filepath.Join(t.TempDir(), "control-plane.json")
|
|
nodeTokens := map[string]string{"node-a": "node-secret-a", "node-b": "node-secret-b"}
|
|
server, err := NewServer(ServerConfig{DataFile: dataFile, NodeTokens: nodeTokens, WebUsers: map[string]string{"root": "legacy-admin-password"}})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
httpServer := httptest.NewServer(server.Handler())
|
|
defer httpServer.Close()
|
|
defer server.Close()
|
|
client := httpServer.Client()
|
|
adminToken := testLogin(t, client, httpServer.URL, "root", "legacy-admin-password")
|
|
|
|
for _, item := range []struct{ nodeID, accountID, token string }{
|
|
{"node-a", "account-a", nodeTokens["node-a"]},
|
|
{"node-b", "account-b", nodeTokens["node-b"]},
|
|
} {
|
|
registration := NodeRegistration{NodeID: item.nodeID, AgentVersion: "test", ProtocolVersion: ProtocolVersion,
|
|
Capabilities: []string{"heartbeat", "poll-tasks", "send-text"}, ReportingConfigVersion: 1,
|
|
Accounts: []AccountSummary{{AccountID: item.accountID, Active: true, Verified: true}}}
|
|
if response := doJSON(t, client, http.MethodPost, httpServer.URL+"/v1/nodes/register", "Bearer "+item.token, registration); response.Code != http.StatusOK {
|
|
t.Fatalf("register %s: status=%d body=%s", item.nodeID, response.Code, response.Body.String())
|
|
}
|
|
heartbeat := Heartbeat{NodeID: item.nodeID, AgentVersion: "test", ProtocolVersion: ProtocolVersion, NodeStatus: NodeOnline,
|
|
WechatRunning: true, WechatLoggedIn: true, ActiveAccountID: item.accountID, ReportingConfigVersion: 1}
|
|
if response := doJSON(t, client, http.MethodPost, httpServer.URL+"/v1/nodes/"+item.nodeID+"/heartbeat", "Bearer "+item.token, heartbeat); response.Code != http.StatusOK {
|
|
t.Fatalf("heartbeat %s: status=%d body=%s", item.nodeID, response.Code, response.Body.String())
|
|
}
|
|
}
|
|
|
|
createUser := doJSON(t, client, http.MethodPost, httpServer.URL+"/v1/users", "Bearer "+adminToken, map[string]any{
|
|
"username": "operator-a", "display_name": "Operator A", "password": "member-password-1", "role": RoleMember, "node_ids": []string{"node-a"},
|
|
})
|
|
if createUser.Code != http.StatusCreated {
|
|
t.Fatalf("create member: status=%d body=%s", createUser.Code, createUser.Body.String())
|
|
}
|
|
memberToken := testLogin(t, client, httpServer.URL, "operator-a", "member-password-1")
|
|
|
|
nodes := doJSON(t, client, http.MethodGet, httpServer.URL+"/v1/nodes", "Bearer "+memberToken, nil)
|
|
var nodeList struct {
|
|
Nodes []Node `json:"nodes"`
|
|
}
|
|
decodeBody(t, nodes, &nodeList)
|
|
if nodes.Code != http.StatusOK || len(nodeList.Nodes) != 1 || nodeList.Nodes[0].NodeID != "node-a" {
|
|
t.Fatalf("member Node list was not scoped: status=%d nodes=%+v", nodes.Code, nodeList.Nodes)
|
|
}
|
|
if response := doJSON(t, client, http.MethodGet, httpServer.URL+"/v1/users", "Bearer "+memberToken, nil); response.Code != http.StatusForbidden {
|
|
t.Fatalf("member user-list status=%d body=%s", response.Code, response.Body.String())
|
|
}
|
|
if response := doJSON(t, client, http.MethodGet, httpServer.URL+"/v1/data/accounts/account-b/conversations", "Bearer "+memberToken, nil); response.Code != http.StatusNotFound {
|
|
t.Fatalf("unassigned account data status=%d body=%s", response.Code, response.Body.String())
|
|
}
|
|
if response := doJSON(t, client, http.MethodGet, httpServer.URL+"/v1/audit", "Bearer "+memberToken, nil); response.Code != http.StatusForbidden {
|
|
t.Fatalf("member audit status=%d body=%s", response.Code, response.Body.String())
|
|
}
|
|
if response := doJSON(t, client, http.MethodGet, httpServer.URL+"/v1/ai/flows", "Bearer "+memberToken, nil); response.Code != http.StatusForbidden {
|
|
t.Fatalf("member AI-flow status=%d body=%s", response.Code, response.Body.String())
|
|
}
|
|
deniedTask := doJSON(t, client, http.MethodPost, httpServer.URL+"/v1/tasks", "Bearer "+memberToken, TaskSubmission{
|
|
NodeID: "node-b", AccountID: "account-b", Kind: "send-text", IdempotencyKey: "denied-node-b",
|
|
Payload: json.RawMessage(`{"target_id":"chat-b","text":"not sent","confirmed":true}`),
|
|
})
|
|
if deniedTask.Code != http.StatusNotFound {
|
|
t.Fatalf("task for unassigned Node status=%d body=%s", deniedTask.Code, deniedTask.Body.String())
|
|
}
|
|
|
|
adminTask := doJSON(t, client, http.MethodPost, httpServer.URL+"/v1/tasks", "Bearer "+adminToken, TaskSubmission{
|
|
NodeID: "node-b", AccountID: "account-b", Kind: "send-text", IdempotencyKey: "admin-node-b",
|
|
Payload: json.RawMessage(`{"target_id":"chat-b","text":"test only","confirmed":true}`),
|
|
})
|
|
if adminTask.Code != http.StatusAccepted {
|
|
t.Fatalf("admin task create status=%d body=%s", adminTask.Code, adminTask.Body.String())
|
|
}
|
|
var adminTaskBody TaskSubmissionResponse
|
|
decodeBody(t, adminTask, &adminTaskBody)
|
|
if response := doJSON(t, client, http.MethodGet, httpServer.URL+"/v1/tasks/"+adminTaskBody.TaskID, "Bearer "+memberToken, nil); response.Code != http.StatusNotFound {
|
|
t.Fatalf("unassigned task detail status=%d body=%s", response.Code, response.Body.String())
|
|
}
|
|
adminTaskA := doJSON(t, client, http.MethodPost, httpServer.URL+"/v1/tasks", "Bearer "+adminToken, TaskSubmission{
|
|
NodeID: "node-a", AccountID: "account-a", Kind: "send-text", IdempotencyKey: "admin-node-a",
|
|
Payload: json.RawMessage(`{"target_id":"chat-a","text":"test only","confirmed":true}`),
|
|
})
|
|
if adminTaskA.Code != http.StatusAccepted {
|
|
t.Fatalf("admin Node A task create status=%d body=%s", adminTaskA.Code, adminTaskA.Body.String())
|
|
}
|
|
memberTasks := doJSON(t, client, http.MethodGet, httpServer.URL+"/v1/tasks?limit=20", "Bearer "+memberToken, nil)
|
|
var taskList struct {
|
|
Tasks []Task `json:"tasks"`
|
|
}
|
|
decodeBody(t, memberTasks, &taskList)
|
|
if memberTasks.Code != http.StatusOK || len(taskList.Tasks) != 1 || taskList.Tasks[0].NodeID != "node-a" {
|
|
t.Fatalf("member task list was not scoped: status=%d tasks=%+v", memberTasks.Code, taskList.Tasks)
|
|
}
|
|
|
|
logout := doJSON(t, client, http.MethodPost, httpServer.URL+"/v1/auth/logout", "Bearer "+memberToken, nil)
|
|
if logout.Code != http.StatusOK {
|
|
t.Fatalf("logout status=%d", logout.Code)
|
|
}
|
|
if response := doJSON(t, client, http.MethodGet, httpServer.URL+"/v1/auth/me", "Bearer "+memberToken, nil); response.Code != http.StatusUnauthorized {
|
|
t.Fatalf("logged-out token status=%d", response.Code)
|
|
}
|
|
|
|
httpServer.Close()
|
|
if err := server.Close(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
restarted, err := NewServer(ServerConfig{DataFile: dataFile, NodeTokens: nodeTokens})
|
|
if err != nil {
|
|
t.Fatalf("restart from persistent user store: %v", err)
|
|
}
|
|
restartedHTTP := httptest.NewServer(restarted.Handler())
|
|
defer restartedHTTP.Close()
|
|
defer restarted.Close()
|
|
restartedClient := restartedHTTP.Client()
|
|
persistedToken := testLogin(t, restartedClient, restartedHTTP.URL, "operator-a", "member-password-1")
|
|
persistedNodes := doJSON(t, restartedClient, http.MethodGet, restartedHTTP.URL+"/v1/nodes", "Bearer "+persistedToken, nil)
|
|
var persistedList struct {
|
|
Nodes []Node `json:"nodes"`
|
|
}
|
|
decodeBody(t, persistedNodes, &persistedList)
|
|
if persistedNodes.Code != http.StatusOK || len(persistedList.Nodes) != 1 || persistedList.Nodes[0].NodeID != "node-a" {
|
|
t.Fatalf("persisted assignment was lost: status=%d nodes=%+v", persistedNodes.Code, persistedList.Nodes)
|
|
}
|
|
}
|
|
|
|
func testLogin(t *testing.T, client *http.Client, baseURL, username, password string) string {
|
|
t.Helper()
|
|
response := doJSON(t, client, http.MethodPost, baseURL+"/v1/auth/login", "", map[string]string{"username": username, "password": password})
|
|
if response.Code != http.StatusOK {
|
|
t.Fatalf("login %s: status=%d body=%s", username, response.Code, response.Body.String())
|
|
}
|
|
var body struct {
|
|
AccessToken string `json:"access_token"`
|
|
}
|
|
decodeBody(t, response, &body)
|
|
if body.AccessToken == "" {
|
|
t.Fatalf("login %s returned no token", username)
|
|
}
|
|
return body.AccessToken
|
|
}
|