Files
RemLink/internal/admin/handler_test.go
T
qsc20001102 142e5dc7d6
ci / Go checks (ubuntu-latest) (push) Has been cancelled
ci / Go checks (windows-latest) (push) Has been cancelled
初版功能完成
2026-08-29 13:12:17 +08:00

184 lines
7.6 KiB
Go

package admin
import (
"bytes"
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"net/netip"
"path/filepath"
"testing"
"time"
"remlink/internal/bootstrap"
"remlink/internal/database"
"remlink/internal/ipam"
"remlink/internal/model"
)
type fakeJoinTokenRotator struct{ count int }
func (f *fakeJoinTokenRotator) Rotate(context.Context) (string, error) {
f.count++
return "rotated-token", nil
}
func TestAdminHandlerAuthNodeUpdateLogsAndRotateOnly(t *testing.T) {
ctx := context.Background()
db, err := database.Open(ctx, filepath.Join(t.TempDir(), "admin.db"))
if err != nil {
t.Fatal(err)
}
defer db.Close()
store := database.NewStore(db)
if err := store.CreateNode(ctx, model.Node{
ID: "engineer", Type: model.NodeTypeEngineer, Name: "Engineer",
OverlayIP: netip.MustParseAddr("10.88.0.2"), WGPublicKey: "wg-key", NodeTokenHash: []byte("hash"),
}); err != nil {
t.Fatal(err)
}
ipamManager, err := ipam.New(store, netip.MustParsePrefix("10.88.0.0/24"), netip.MustParseAddr("10.88.0.1"))
if err != nil {
t.Fatal(err)
}
peers := &fakeAdminPeers{}
bootstrapNetwork := &fakeBootstrapNetwork{config: bootstrap.ServiceConfig{
WGEndpoint: "203.0.113.4:51820", ControlURL: "ws://10.88.0.1:7001/control",
OverlayCIDR: netip.MustParsePrefix("10.88.0.0/24"), ServerOverlayIP: netip.MustParseAddr("10.88.0.1"),
SessionUDPPort: 6200, MTU: 1280, ConfigVersion: 1,
}}
controlNetwork := &fakeAdminControl{}
sessions := &fakeSessionControl{}
network, err := NewNetworkManager(store, ipamManager, peers, bootstrapNetwork, controlNetwork, sessions, Network{
OverlayCIDR: "10.88.0.0/24", ServerOverlayIP: "10.88.0.1", WireGuardPort: 51820,
SessionUDPPort: 6200, MTU: 1280, ConfigVersion: 1,
}, nil)
if err != nil {
t.Fatal(err)
}
rotator := &fakeJoinTokenRotator{}
handler, err := Handler(HandlerConfig{
Store: store, IPAM: ipamManager, Peers: peers, Control: controlNetwork,
Sessions: sessions, Network: network, JoinTokens: rotator, AdminToken: "secret",
})
if err != nil {
t.Fatal(err)
}
unauthorized := httptest.NewRecorder()
handler.ServeHTTP(unauthorized, httptest.NewRequest(http.MethodGet, "/api/v1/admin/nodes", nil))
if unauthorized.Code != http.StatusUnauthorized {
t.Fatalf("unauthorized status = %d", unauthorized.Code)
}
nodesResponse := adminRequest(t, handler, http.MethodGet, "/api/v1/admin/nodes", nil)
if nodesResponse.Code != http.StatusOK || !bytes.Contains(nodesResponse.Body.Bytes(), []byte(`"wg_handshake"`)) {
t.Fatalf("nodes response status=%d body=%s", nodesResponse.Code, nodesResponse.Body.String())
}
patch := adminRequest(t, handler, http.MethodPatch, "/api/v1/admin/nodes/engineer", map[string]any{"name": "Field Engineer"})
if patch.Code != http.StatusOK || bytes.Contains(patch.Body.Bytes(), []byte("node_token")) {
t.Fatalf("PATCH response status=%d body=%s", patch.Code, patch.Body.String())
}
updated, err := store.GetNode(ctx, "engineer")
if err != nil || updated.Name != "Field Engineer" {
t.Fatalf("updated node = %+v, %v", updated, err)
}
invalidCombined := adminRequest(t, handler, http.MethodPatch, "/api/v1/admin/nodes/engineer", map[string]any{
"name": "Must Not Persist", "overlay_ip": "not-an-ip",
})
unchanged, _ := store.GetNode(ctx, "engineer")
if invalidCombined.Code != http.StatusBadRequest || unchanged.Name != "Field Engineer" {
t.Fatalf("invalid combined PATCH status=%d node=%+v", invalidCombined.Code, unchanged)
}
steps := make([]string, 0, 2)
peers.steps = &steps
controlNetwork.steps = &steps
addressPatch := adminRequest(t, handler, http.MethodPatch, "/api/v1/admin/nodes/engineer", map[string]any{
"name": "Moved Engineer", "overlay_ip": "10.88.0.9",
})
moved, moveErr := store.GetNode(ctx, "engineer")
if addressPatch.Code != http.StatusOK || moveErr != nil || moved.Name != "Moved Engineer" || moved.OverlayIP.String() != "10.88.0.9" {
t.Fatalf("address PATCH status=%d node=%+v error=%v body=%s", addressPatch.Code, moved, moveErr, addressPatch.Body.String())
}
wantSteps := []string{"notify", "peers"}
if len(steps) != len(wantSteps) || steps[0] != wantSteps[0] || steps[1] != wantSteps[1] {
t.Fatalf("Node address change steps=%v, want=%v", steps, wantSteps)
}
if peers.ensured != moved.OverlayIP || !controlNetwork.resetNode || sessions.nodeDisconnects != 1 || sessions.nodeReason != "NODE_OVERLAY_IP_CHANGED" {
t.Fatalf("Node address orchestration peer=%s reset=%v disconnects=%d reason=%s", peers.ensured, controlNetwork.resetNode, sessions.nodeDisconnects, sessions.nodeReason)
}
peers.steps = nil
controlNetwork.steps = nil
bad := adminRequest(t, handler, http.MethodPatch, "/api/v1/admin/nodes/engineer", map[string]any{"unknown": true})
if bad.Code != http.StatusBadRequest {
t.Fatalf("unknown JSON field status = %d, want 400", bad.Code)
}
rotate := adminRequest(t, handler, http.MethodPut, "/api/v1/admin/network", map[string]any{
"overlay_cidr": "10.88.0.0/24", "server_overlay_ip": "10.88.0.1",
"wireguard_port": 51820, "session_udp_port": 6200, "mtu": 1280, "rotate_join_token": true,
})
if rotate.Code != http.StatusOK || rotator.count != 1 || sessions.all || controlNetwork.resetAll {
t.Fatalf("rotate-only response=%s count=%d migrated=%v reset=%v", rotate.Body.String(), rotator.count, sessions.all, controlNetwork.resetAll)
}
var rotateBody map[string]any
_ = json.Unmarshal(rotate.Body.Bytes(), &rotateBody)
if rotateBody["join_token"] != "rotated-token" || rotateBody["config_version"] != float64(1) {
t.Fatalf("rotate response = %v", rotateBody)
}
from := time.Now().Add(-time.Hour).UTC().Format(time.RFC3339)
to := time.Now().Add(time.Hour).UTC().Format(time.RFC3339)
logs := adminRequest(t, handler, http.MethodGet, "/api/v1/admin/logs?module=CORE&limit=10&from="+from+"&to="+to, nil)
if logs.Code != http.StatusOK {
t.Fatalf("logs status=%d body=%s", logs.Code, logs.Body.String())
}
var events []model.EventLog
if err := json.Unmarshal(logs.Body.Bytes(), &events); err != nil || len(events) < 2 {
t.Fatalf("Admin events = %+v, %v", events, err)
}
badTime := adminRequest(t, handler, http.MethodGet, "/api/v1/admin/logs?from=not-a-time", nil)
if badTime.Code != http.StatusBadRequest {
t.Fatalf("invalid log time status = %d", badTime.Code)
}
view := adminRequest(t, handler, http.MethodGet, "/api/v1/admin/network", nil)
var networkView map[string]any
_ = json.Unmarshal(view.Body.Bytes(), &networkView)
if _, exists := networkView["uptime_seconds"]; !exists {
t.Fatalf("network response has no uptime_seconds: %s", view.Body.String())
}
deleted := adminRequest(t, handler, http.MethodDelete, "/api/v1/admin/nodes/engineer", nil)
if deleted.Code != http.StatusNoContent || peers.removed != 1 || sessions.nodeDisconnects != 2 || controlNetwork.resetNodeCount != 2 || controlNetwork.resetReason != "Node revoked" {
t.Fatalf("DELETE status=%d peer removals=%d disconnects=%d resets=%d reason=%s",
deleted.Code, peers.removed, sessions.nodeDisconnects, controlNetwork.resetNodeCount, controlNetwork.resetReason)
}
if _, err := store.GetNode(ctx, "engineer"); err == nil {
t.Fatal("deleted Node remains in the registry")
}
}
func adminRequest(t *testing.T, handler http.Handler, method, path string, body any) *httptest.ResponseRecorder {
t.Helper()
var raw []byte
if body != nil {
var err error
raw, err = json.Marshal(body)
if err != nil {
t.Fatal(err)
}
}
request := httptest.NewRequest(method, path, bytes.NewReader(raw))
request.Header.Set("Authorization", "Bearer secret")
if body != nil {
request.Header.Set("Content-Type", "application/json")
}
recorder := httptest.NewRecorder()
handler.ServeHTTP(recorder, request)
return recorder
}