feat: Go port of the panel in panel-go/ — drop-in replacement for server.js
Pure-stdlib Go implementation of the whole control plane: same nexusgate.json schema (unknown fields preserved verbatim, explicit null/false tri-states modeled), same HTTP API and error strings, embedded frontend (NG_PUBLIC_DIR override), self-implemented scrypt auth, X25519 Reality keys, panel-hosted keyStore and heartbeat reconciliation included. Version stays 0.6.8. Verified: Go tests 33/33 (scrypt + Reality vectors generated from the Node implementation, subscription goldens byte-identical), Node suite 52/52 untouched, differential E2E against the Node panel 50/50 including two-way DB swap. See panel-go/PARITY.md and panel-go/INSTALL-GO.md.
This commit is contained in:
@@ -0,0 +1,174 @@
|
||||
package main
|
||||
|
||||
// Minimal smoke tests for the HTTP layer: healthz, the login gate, session
|
||||
// round-trip, server creation shape, static + SPA fallback, and the 404
|
||||
// shape. Uses a throwaway Store in t.TempDir().
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http/httptest"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func newSmokeApp(t *testing.T) *App {
|
||||
t.Helper()
|
||||
cookieSecure = false
|
||||
store := NewStore(filepath.Join(t.TempDir(), "nexusgate.json"))
|
||||
if err := store.Init(); err != nil {
|
||||
t.Fatalf("store init: %v", err)
|
||||
}
|
||||
_, err := store.Transaction(func(d *Data) (any, error) {
|
||||
d.Users = append(d.Users, &User{
|
||||
ID: NewID("usr"),
|
||||
Username: "admin",
|
||||
PasswordHash: HashSecret("password12345"),
|
||||
Role: "owner",
|
||||
Status: "active",
|
||||
CreatedAt: NowISO(),
|
||||
})
|
||||
return nil, nil
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("seed user: %v", err)
|
||||
}
|
||||
app := NewApp(store, NewSessionManager(12), NewOrchestrator(store))
|
||||
return app
|
||||
}
|
||||
|
||||
func doRequest(app *App, method, path, body string, headers map[string]string) *httptest.ResponseRecorder {
|
||||
var reader *strings.Reader
|
||||
if body != "" {
|
||||
reader = strings.NewReader(body)
|
||||
} else {
|
||||
reader = strings.NewReader("")
|
||||
}
|
||||
req := httptest.NewRequest(method, path, reader)
|
||||
for k, v := range headers {
|
||||
req.Header.Set(k, v)
|
||||
}
|
||||
rec := httptest.NewRecorder()
|
||||
app.ServeHTTP(rec, req)
|
||||
return rec
|
||||
}
|
||||
|
||||
func decodeBody(t *testing.T, rec *httptest.ResponseRecorder) map[string]any {
|
||||
t.Helper()
|
||||
var out map[string]any
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil {
|
||||
t.Fatalf("decode %q: %v", rec.Body.String(), err)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func TestSmokeHealthz(t *testing.T) {
|
||||
app := newSmokeApp(t)
|
||||
rec := doRequest(app, "GET", "/healthz", "", nil)
|
||||
if rec.Code != 200 {
|
||||
t.Fatalf("healthz status %d", rec.Code)
|
||||
}
|
||||
body := decodeBody(t, rec)
|
||||
if body["ok"] != true || body["version"] != VERSION {
|
||||
t.Fatalf("healthz body %v", body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSmokeLoginFlowAndServerCreate(t *testing.T) {
|
||||
app := newSmokeApp(t)
|
||||
|
||||
// Unauthenticated admin API -> 401 unauthorized shape.
|
||||
rec := doRequest(app, "GET", "/api/servers", "", nil)
|
||||
if rec.Code != 401 {
|
||||
t.Fatalf("unauth status %d", rec.Code)
|
||||
}
|
||||
body := decodeBody(t, rec)
|
||||
if body["error"] != "unauthorized" || body["message"] != "请先登录" {
|
||||
t.Fatalf("unauth body %v", body)
|
||||
}
|
||||
|
||||
// Wrong password -> 401 invalid_credentials.
|
||||
rec = doRequest(app, "POST", "/api/auth/login", `{"username":"admin","password":"wrong"}`, nil)
|
||||
if rec.Code != 401 || decodeBody(t, rec)["error"] != "invalid_credentials" {
|
||||
t.Fatalf("bad login %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
// Correct login -> 200, csrf + session cookie.
|
||||
rec = doRequest(app, "POST", "/api/auth/login", `{"username":"admin","password":"password12345"}`, nil)
|
||||
if rec.Code != 200 {
|
||||
t.Fatalf("login status %d body %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
body = decodeBody(t, rec)
|
||||
csrf, _ := body["csrf"].(string)
|
||||
if csrf == "" {
|
||||
t.Fatalf("login csrf missing: %v", body)
|
||||
}
|
||||
cookie := rec.Header().Get("Set-Cookie")
|
||||
if !strings.HasPrefix(cookie, "ng_session=") || !strings.Contains(cookie, "HttpOnly") {
|
||||
t.Fatalf("login cookie %q", cookie)
|
||||
}
|
||||
sessionCookie := strings.Split(cookie, ";")[0]
|
||||
authHeaders := map[string]string{"Cookie": sessionCookie}
|
||||
|
||||
// /api/session reflects the login.
|
||||
rec = doRequest(app, "GET", "/api/session", "", authHeaders)
|
||||
body = decodeBody(t, rec)
|
||||
if body["authenticated"] != true || body["csrf"] != csrf {
|
||||
t.Fatalf("session body %v", body)
|
||||
}
|
||||
|
||||
// Changing request without CSRF -> 403 csrf_failed.
|
||||
rec = doRequest(app, "POST", "/api/servers", `{"name":"s1","publicAddress":"192.0.2.1"}`, authHeaders)
|
||||
if rec.Code != 403 || decodeBody(t, rec)["error"] != "csrf_failed" {
|
||||
t.Fatalf("csrf gate %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
// Create server -> 201 shape.
|
||||
createHeaders := map[string]string{"Cookie": sessionCookie, "X-CSRF-Token": csrf}
|
||||
rec = doRequest(app, "POST", "/api/servers", `{"name":"s1","publicAddress":"192.0.2.1"}`, createHeaders)
|
||||
if rec.Code != 201 {
|
||||
t.Fatalf("create server %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
body = decodeBody(t, rec)
|
||||
server, _ := body["server"].(map[string]any)
|
||||
if server == nil || server["name"] != "s1" || server["status"] != "pending" ||
|
||||
!strings.HasPrefix(server["id"].(string), "srv_") || server["portRangeStart"] != float64(20000) {
|
||||
t.Fatalf("server shape %v", body)
|
||||
}
|
||||
|
||||
// Authenticated unknown API -> 404 not_found shape.
|
||||
rec = doRequest(app, "GET", "/api/does-not-exist", "", authHeaders)
|
||||
if rec.Code != 404 {
|
||||
t.Fatalf("404 status %d", rec.Code)
|
||||
}
|
||||
body = decodeBody(t, rec)
|
||||
if body["error"] != "not_found" || body["message"] != "接口不存在" {
|
||||
t.Fatalf("404 body %v", body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSmokeStaticAndSPAFallback(t *testing.T) {
|
||||
app := newSmokeApp(t)
|
||||
rec := doRequest(app, "GET", "/", "", nil)
|
||||
if rec.Code != 200 {
|
||||
t.Fatalf("index status %d", rec.Code)
|
||||
}
|
||||
if !strings.Contains(rec.Body.String(), "<") {
|
||||
t.Fatalf("index body too small: %q", rec.Body.String())
|
||||
}
|
||||
if rec.Header().Get("Cache-Control") != "no-store" {
|
||||
t.Fatalf("index cache-control %q", rec.Header().Get("Cache-Control"))
|
||||
}
|
||||
indexBody := rec.Body.String()
|
||||
|
||||
rec = doRequest(app, "GET", "/some/client/route", "", nil)
|
||||
if rec.Code != 200 || rec.Body.String() != indexBody {
|
||||
t.Fatalf("spa fallback %d", rec.Code)
|
||||
}
|
||||
|
||||
// Unknown /api path is not swallowed by the SPA fallback.
|
||||
rec = doRequest(app, "GET", "/api/nope", "", nil)
|
||||
if rec.Code != 401 { // admin gate fires before the 404 for /api/*
|
||||
t.Fatalf("api unknown status %d", rec.Code)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user