Files
wx-win-agent/control-plane/user_routes_test.go
T
rogee 8760fa49f0
Build web service image / build (push) Successful in 2m7s
feat: add control-plane identity and management console
2026-09-27 20:27:31 +08:00

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
}