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) } }