初版功能完成
This commit is contained in:
@@ -0,0 +1,183 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user