package handlers
import (
"encoding/json"
"net/http"
"net/http/httptest"
"net/url"
"path/filepath"
"strconv"
"strings"
"sync/atomic"
"testing"
"github.com/gorilla/websocket"
"teraclone/internal/service"
)
func TestBackendLoginAndAPIProxy(t *testing.T) {
var logoutCalls atomic.Int32
var streamCalls atomic.Int32
var readinessCalls atomic.Int32
backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/api/v1/auth/login-policy":
if got := r.Header.Get("Authorization"); got != "" {
t.Fatalf("readiness probe leaked authorization = %q", got)
}
_ = json.NewEncoder(w).Encode(map[string]any{"minimum_password_length": 8})
case "/api/v1/auth/login":
if got := r.Header.Get("X-Tera-Client-IP"); got != "192.168.0.100" {
t.Fatalf("login forwarded client IP = %q, want 192.168.0.100", got)
}
if got := r.Header.Get("X-Tera-Client-Protocol"); got != "http" {
t.Fatalf("login forwarded protocol = %q, want http", got)
}
if got := r.Header.Get("X-Tera-Client-Port"); got != "8083" {
t.Fatalf("login forwarded port = %q, want 8083", got)
}
var credentials map[string]string
if err := json.NewDecoder(r.Body).Decode(&credentials); err != nil {
t.Fatalf("decode login: %v", err)
}
if credentials["username"] != "admin" || credentials["password"] != "secret" {
t.Fatalf("unexpected credentials: %#v", credentials)
}
_ = json.NewEncoder(w).Encode(map[string]any{
"session_token": "backend-session-token",
"actor": "mock-admin",
"role": "administrator",
})
case "/api/v1/system/status":
if got := r.Header.Get("Authorization"); got != "Bearer backend-session-token" {
t.Fatalf("authorization = %q", got)
}
if got := r.Header.Get("Cookie"); got != "" {
t.Fatalf("browser cookie leaked to backend: %q", got)
}
if got := r.Header.Get("X-Tera-Client-IP"); got != "192.168.0.100" {
t.Fatalf("forwarded client IP = %q, want 192.168.0.100", got)
}
if got := r.Header.Get("X-Tera-Client-Protocol"); got != "http" {
t.Fatalf("forwarded protocol = %q, want http", got)
}
if got := r.Header.Get("X-Tera-Client-Port"); got != "8083" {
t.Fatalf("forwarded port = %q, want 8083", got)
}
_ = json.NewEncoder(w).Encode(map[string]string{"model": "TERA Core-X PRO"})
case "/api/v1/auth/logout":
logoutCalls.Add(1)
w.WriteHeader(http.StatusOK)
default:
http.NotFound(w, r)
}
}))
defer backend.Close()
streamBackend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/readyz":
readinessCalls.Add(1)
_ = json.NewEncoder(w).Encode(map[string]any{"status": "ok"})
case "/api/v1/direct/CH01/sessions/direct-1/stream":
streamCalls.Add(1)
if got := r.Header.Get("Cookie"); got != "" {
t.Fatalf("browser cookie leaked to stream backend: %q", got)
}
w.Header().Set("X-Stream-Upstream", "tera-sessiond")
w.WriteHeader(http.StatusNoContent)
default:
t.Fatalf("unexpected stream path %q", r.URL.Path)
}
}))
defer streamBackend.Close()
app, err := service.NewAppService(filepath.Join(t.TempDir(), "frontend.db"))
if err != nil {
t.Fatal(err)
}
defer app.Close()
handler, err := NewDeviceHandlerWithSessionUpstream(app, backend.URL, streamBackend.URL)
if err != nil {
t.Fatal(err)
}
mux := http.NewServeMux()
handler.RegisterRoutes(mux)
statusRequest := httptest.NewRequest(http.MethodGet, "/login/backend-status", nil)
statusResponse := httptest.NewRecorder()
mux.ServeHTTP(statusResponse, statusRequest)
if statusResponse.Code != http.StatusOK || !strings.Contains(statusResponse.Body.String(), `"ready":true`) {
t.Fatalf("backend readiness status = %d %s", statusResponse.Code, statusResponse.Body.String())
}
loginBody := url.Values{"username": {"admin"}, "password": {"secret"}}.Encode()
loginRequest := httptest.NewRequest(http.MethodPost, "/login", strings.NewReader(loginBody))
loginRequest.RemoteAddr = "192.168.0.100:48000"
loginRequest.Host = "192.168.0.223:8083"
loginRequest.Header.Set("Content-Type", "application/x-www-form-urlencoded")
loginResponse := httptest.NewRecorder()
mux.ServeHTTP(loginResponse, loginRequest)
if loginResponse.Code != http.StatusSeeOther {
t.Fatalf("login status = %d, body = %s", loginResponse.Code, loginResponse.Body.String())
}
if len(loginResponse.Result().Cookies()) != 1 {
t.Fatalf("login cookies = %d", len(loginResponse.Result().Cookies()))
}
cookie := loginResponse.Result().Cookies()[0]
readinessRequest := httptest.NewRequest(http.MethodGet, "/port-debug/readiness", nil)
readinessRequest.AddCookie(cookie)
readinessResponse := httptest.NewRecorder()
mux.ServeHTTP(readinessResponse, readinessRequest)
if readinessResponse.Code != http.StatusOK || !strings.Contains(readinessResponse.Body.String(), `"ready":true`) || readinessCalls.Load() != 1 {
t.Fatalf("port debug readiness = %d %s calls=%d", readinessResponse.Code, readinessResponse.Body.String(), readinessCalls.Load())
}
apiRequest := httptest.NewRequest(http.MethodGet, "/api/v1/system/status", nil)
apiRequest.RemoteAddr = "192.168.0.100:49000"
apiRequest.Host = "192.168.0.223:8083"
apiRequest.AddCookie(cookie)
apiResponse := httptest.NewRecorder()
mux.ServeHTTP(apiResponse, apiRequest)
if apiResponse.Code != http.StatusOK || !strings.Contains(apiResponse.Body.String(), "TERA Core-X PRO") {
t.Fatalf("proxy response = %d %s", apiResponse.Code, apiResponse.Body.String())
}
streamRequest := httptest.NewRequest(http.MethodGet, "/api/v1/direct/CH01/sessions/direct-1/stream", nil)
streamRequest.AddCookie(cookie)
streamResponse := httptest.NewRecorder()
mux.ServeHTTP(streamResponse, streamRequest)
if streamResponse.Code != http.StatusNoContent || streamResponse.Header().Get("X-Stream-Upstream") != "tera-sessiond" || streamCalls.Load() != 1 {
t.Fatalf("stream proxy response = %d header=%q calls=%d", streamResponse.Code, streamResponse.Header().Get("X-Stream-Upstream"), streamCalls.Load())
}
for _, legacy := range []struct {
method string
path string
}{
{http.MethodGet, "/api/status"},
{http.MethodGet, "/api/network"},
{http.MethodPost, "/api/network/apply"},
{http.MethodGet, "/api/system"},
{http.MethodPost, "/api/system/reboot"},
{http.MethodGet, "/api/logs/system"},
{http.MethodGet, "/api/logs/ports"},
{http.MethodPost, "/api/mock/action"},
} {
request := httptest.NewRequest(legacy.method, legacy.path, nil)
request.AddCookie(cookie)
response := httptest.NewRecorder()
mux.ServeHTTP(response, request)
if response.Code != http.StatusNotFound {
t.Errorf("legacy route %s %s returned %d", legacy.method, legacy.path, response.Code)
}
}
logoutRequest := httptest.NewRequest(http.MethodPost, "/logout", nil)
logoutRequest.AddCookie(cookie)
logoutResponse := httptest.NewRecorder()
mux.ServeHTTP(logoutResponse, logoutRequest)
if logoutCalls.Load() != 1 {
t.Fatalf("logout calls = %d", logoutCalls.Load())
}
}
func TestLoginReplacesExistingSessionFromSameDesktop(t *testing.T) {
var loginCalls atomic.Int32
var logoutCalls atomic.Int32
backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/api/v1/auth/login":
call := loginCalls.Add(1)
_ = json.NewEncoder(w).Encode(map[string]any{
"session_token": "backend-token-" + strconv.Itoa(int(call)),
"actor": "mock-admin",
"role": "administrator",
})
case "/api/v1/auth/logout":
logoutCalls.Add(1)
w.WriteHeader(http.StatusNoContent)
case "/api/v1/system/status":
_ = json.NewEncoder(w).Encode(map[string]string{"authorization": r.Header.Get("Authorization")})
default:
http.NotFound(w, r)
}
}))
defer backend.Close()
app, err := service.NewAppService(filepath.Join(t.TempDir(), "frontend.db"))
if err != nil {
t.Fatal(err)
}
defer app.Close()
handler, err := NewDeviceHandler(app, backend.URL)
if err != nil {
t.Fatal(err)
}
mux := http.NewServeMux()
handler.RegisterRoutes(mux)
login := func(remoteAddr string) *http.Cookie {
t.Helper()
body := url.Values{"username": {"admin"}, "password": {"secret"}}.Encode()
request := httptest.NewRequest(http.MethodPost, "/login", strings.NewReader(body))
request.RemoteAddr = remoteAddr
request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
response := httptest.NewRecorder()
mux.ServeHTTP(response, request)
if response.Code != http.StatusSeeOther || len(response.Result().Cookies()) != 1 {
t.Fatalf("login = %d cookies=%d body=%s", response.Code, len(response.Result().Cookies()), response.Body.String())
}
return response.Result().Cookies()[0]
}
firstCookie := login("192.168.0.100:41000")
secondCookie := login("192.168.0.100:42000")
if logoutCalls.Load() != 1 {
t.Fatalf("replaced backend logout calls = %d, want 1", logoutCalls.Load())
}
handler.mu.RLock()
activeSessions := len(handler.sessions)
handler.mu.RUnlock()
if activeSessions != 1 {
t.Fatalf("active browser sessions = %d, want 1", activeSessions)
}
oldRequest := httptest.NewRequest(http.MethodGet, "/api/v1/system/status", nil)
oldRequest.AddCookie(firstCookie)
oldResponse := httptest.NewRecorder()
mux.ServeHTTP(oldResponse, oldRequest)
if oldResponse.Code != http.StatusUnauthorized {
t.Fatalf("replaced session status = %d, want 401", oldResponse.Code)
}
newRequest := httptest.NewRequest(http.MethodGet, "/api/v1/system/status", nil)
newRequest.AddCookie(secondCookie)
newResponse := httptest.NewRecorder()
mux.ServeHTTP(newResponse, newRequest)
if newResponse.Code != http.StatusOK || !strings.Contains(newResponse.Body.String(), "Bearer backend-token-2") {
t.Fatalf("replacement session = %d %s", newResponse.Code, newResponse.Body.String())
}
}
func TestBackendUnauthorizedClearsBrowserSessionAndAllowsLoginPage(t *testing.T) {
backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/api/v1/auth/login":
_ = json.NewEncoder(w).Encode(map[string]any{
"session_token": "revoked-backend-token",
"actor": "mock-admin",
"role": "administrator",
})
case "/api/v1/system/status":
writeJSON(w, http.StatusUnauthorized, apiResponse{OK: false, Message: "revoked"})
default:
http.NotFound(w, r)
}
}))
defer backend.Close()
app, err := service.NewAppService(filepath.Join(t.TempDir(), "frontend.db"))
if err != nil {
t.Fatal(err)
}
defer app.Close()
handler, err := NewDeviceHandler(app, backend.URL)
if err != nil {
t.Fatal(err)
}
mux := http.NewServeMux()
handler.RegisterRoutes(mux)
body := url.Values{"username": {"admin"}, "password": {"secret"}}.Encode()
loginRequest := httptest.NewRequest(http.MethodPost, "/login", strings.NewReader(body))
loginRequest.Header.Set("Content-Type", "application/x-www-form-urlencoded")
loginResponse := httptest.NewRecorder()
mux.ServeHTTP(loginResponse, loginRequest)
cookie := loginResponse.Result().Cookies()[0]
apiRequest := httptest.NewRequest(http.MethodGet, "/api/v1/system/status", nil)
apiRequest.AddCookie(cookie)
apiResponse := httptest.NewRecorder()
mux.ServeHTTP(apiResponse, apiRequest)
if apiResponse.Code != http.StatusUnauthorized {
t.Fatalf("upstream unauthorized status = %d", apiResponse.Code)
}
cleared := false
for _, responseCookie := range apiResponse.Result().Cookies() {
if responseCookie.Name == sessionCookieName && responseCookie.MaxAge < 0 {
cleared = true
}
}
if !cleared {
t.Fatalf("401 response did not clear the browser session cookie")
}
loginPageRequest := httptest.NewRequest(http.MethodGet, "/login", nil)
loginPageRequest.AddCookie(cookie)
loginPageResponse := httptest.NewRecorder()
mux.ServeHTTP(loginPageResponse, loginPageRequest)
if loginPageResponse.Code != http.StatusOK || !strings.Contains(loginPageResponse.Body.String(), "로그인") {
t.Fatalf("login page after invalidation = %d %s", loginPageResponse.Code, loginPageResponse.Body.String())
}
if !strings.Contains(loginPageResponse.Body.String(), "data-login-progress") || !strings.Contains(loginPageResponse.Body.String(), "/static/js/login.js?") {
t.Fatalf("login page does not expose backend authentication progress: %s", loginPageResponse.Body.String())
}
}
func TestPortOperatorWithInitialPasswordRedirectsToPasswordPage(t *testing.T) {
backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/api/v1/auth/login" {
http.NotFound(w, r)
return
}
_ = json.NewEncoder(w).Encode(map[string]any{
"session_token": "operator-session-token",
"actor": "local-field-operator",
"role": "port_operator",
"permissions": []string{"port.read", "system.read", "network.read"},
"must_change_password": true,
})
}))
defer backend.Close()
app, err := service.NewAppService(filepath.Join(t.TempDir(), "frontend.db"))
if err != nil {
t.Fatal(err)
}
defer app.Close()
handler, err := NewDeviceHandler(app, backend.URL)
if err != nil {
t.Fatal(err)
}
mux := http.NewServeMux()
handler.RegisterRoutes(mux)
loginBody := url.Values{"username": {"field-operator"}, "password": {"initial-secret"}}.Encode()
loginRequest := httptest.NewRequest(http.MethodPost, "/login", strings.NewReader(loginBody))
loginRequest.Header.Set("Content-Type", "application/x-www-form-urlencoded")
loginResponse := httptest.NewRecorder()
mux.ServeHTTP(loginResponse, loginRequest)
if loginResponse.Code != http.StatusSeeOther || loginResponse.Header().Get("Location") != "/password" {
t.Fatalf("initial login = %d location=%q, want password redirect", loginResponse.Code, loginResponse.Header().Get("Location"))
}
cookies := loginResponse.Result().Cookies()
if len(cookies) != 1 {
t.Fatalf("login cookies = %d, want 1", len(cookies))
}
passwordRequest := httptest.NewRequest(http.MethodGet, "/password", nil)
passwordRequest.AddCookie(cookies[0])
passwordResponse := httptest.NewRecorder()
mux.ServeHTTP(passwordResponse, passwordRequest)
body := passwordResponse.Body.String()
if passwordResponse.Code != http.StatusOK || !strings.Contains(body, "비밀번호 변경") {
t.Fatalf("password page = %d %s", passwordResponse.Code, body)
}
if !strings.Contains(body, `
field-operator
`) {
t.Fatalf("password page does not show login username: %s", body)
}
}
func TestAPIProxyRequiresBrowserSession(t *testing.T) {
app, err := service.NewAppService(filepath.Join(t.TempDir(), "frontend.db"))
if err != nil {
t.Fatal(err)
}
defer app.Close()
handler, err := NewDeviceHandler(app, "http://127.0.0.1:1")
if err != nil {
t.Fatal(err)
}
mux := http.NewServeMux()
handler.RegisterRoutes(mux)
response := httptest.NewRecorder()
mux.ServeHTTP(response, httptest.NewRequest(http.MethodGet, "/api/v1/system/status", nil))
if response.Code != http.StatusUnauthorized {
t.Fatalf("status = %d, body = %s", response.Code, response.Body.String())
}
}
func TestDirectStreamWebSocketProxy(t *testing.T) {
streamCookies := make(chan string, 1)
upgrader := websocket.Upgrader{
Subprotocols: []string{"tera.direct.v1"},
CheckOrigin: func(request *http.Request) bool {
origin, err := url.Parse(request.Header.Get("Origin"))
return err == nil && origin.Scheme == "http" && origin.Host == request.Host
},
}
streamBackend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
streamCookies <- r.Header.Get("Cookie")
connection, err := upgrader.Upgrade(w, r, nil)
if err != nil {
return
}
defer connection.Close()
messageType, payload, err := connection.ReadMessage()
if err == nil {
_ = connection.WriteMessage(messageType, payload)
}
}))
defer streamBackend.Close()
apiBackend := httptest.NewServer(http.NotFoundHandler())
defer apiBackend.Close()
app, err := service.NewAppService(filepath.Join(t.TempDir(), "frontend.db"))
if err != nil {
t.Fatal(err)
}
defer app.Close()
handler, err := NewDeviceHandlerWithSessionUpstream(app, apiBackend.URL, streamBackend.URL)
if err != nil {
t.Fatal(err)
}
handler.sessions["browser-session"] = browserSession{Username: "operator", BackendToken: "backend-token", Role: "administrator"}
mux := http.NewServeMux()
handler.RegisterRoutes(mux)
webServer := httptest.NewServer(mux)
defer webServer.Close()
dialer := websocket.Dialer{Subprotocols: []string{"tera.direct.v1"}}
header := http.Header{}
header.Set("Cookie", sessionCookieName+"=browser-session")
header.Set("Origin", webServer.URL)
connection, response, err := dialer.Dial("ws"+strings.TrimPrefix(webServer.URL, "http")+"/api/v1/direct/CH48/sessions/direct-48/stream", header)
if err != nil {
if response != nil {
t.Fatalf("dial websocket: %v status=%d", err, response.StatusCode)
}
t.Fatal(err)
}
defer connection.Close()
if got := connection.Subprotocol(); got != "tera.direct.v1" {
t.Fatalf("subprotocol = %q", got)
}
if err := connection.WriteMessage(websocket.BinaryMessage, []byte("show version\r")); err != nil {
t.Fatal(err)
}
messageType, payload, err := connection.ReadMessage()
if err != nil || messageType != websocket.BinaryMessage || string(payload) != "show version\r" {
t.Fatalf("proxied websocket message type=%d payload=%q err=%v", messageType, payload, err)
}
if got := <-streamCookies; got != "" {
t.Fatalf("browser cookie leaked to tera-sessiond: %q", got)
}
}
func TestPortOperatorSeesOnlyDashboardPortStatusAndSerialPortMenus(t *testing.T) {
app, err := service.NewAppService(filepath.Join(t.TempDir(), "frontend.db"))
if err != nil {
t.Fatal(err)
}
defer app.Close()
handler, err := NewDeviceHandler(app, "http://127.0.0.1:1")
if err != nil {
t.Fatal(err)
}
handler.sessions["operator-session"] = browserSession{Username: "local-operator", BackendToken: "backend-token", Role: "port_operator"}
mux := http.NewServeMux()
handler.RegisterRoutes(mux)
cookie := &http.Cookie{Name: sessionCookieName, Value: "operator-session"}
dashboardRequest := httptest.NewRequest(http.MethodGet, "/", nil)
dashboardRequest.AddCookie(cookie)
dashboardResponse := httptest.NewRecorder()
mux.ServeHTTP(dashboardResponse, dashboardRequest)
body := dashboardResponse.Body.String()
if dashboardResponse.Code != http.StatusOK || !strings.Contains(body, "data-current-role=\"port_operator\"") {
t.Fatalf("dashboard = %d %s", dashboardResponse.Code, body)
}
for _, hidden := range []string{"네트워크 설정", "관리자 운영", "포트 디버그", "data-global-reboot"} {
if strings.Contains(body, hidden) {
t.Fatalf("port operator dashboard exposed %q", hidden)
}
}
deniedRequest := httptest.NewRequest(http.MethodGet, "/network/interface-1", nil)
deniedRequest.AddCookie(cookie)
deniedResponse := httptest.NewRecorder()
mux.ServeHTTP(deniedResponse, deniedRequest)
if deniedResponse.Code != http.StatusForbidden {
t.Fatalf("network page status = %d, want 403", deniedResponse.Code)
}
portRequest := httptest.NewRequest(http.MethodGet, "/ports/1/operation-1", nil)
portRequest.AddCookie(cookie)
portResponse := httptest.NewRecorder()
mux.ServeHTTP(portResponse, portRequest)
portBody := portResponse.Body.String()
if portResponse.Code != http.StatusOK || strings.Contains(portBody, "데이터 패킷") {
t.Fatalf("serial port page = %d %s", portResponse.Code, portResponse.Body.String())
}
if !strings.Contains(portBody, `class="form-row form-type-text form-fixed"`) || !strings.Contains(portBody, `class="fixed-value-badge">고정값`) {
t.Fatalf("serial port operation page does not identify fixed timeout values: %d %s", portResponse.Code, portBody)
}
if !strings.Contains(portBody, `class="panel port-selector-panel"`) {
t.Fatalf("serial port operation page does not render the port selector: %d %s", portResponse.Code, portBody)
}
for _, shortcut := range []string{"controlShortcut", "switchShortcut", "quitShortcut", "breakShortcut", "interruptShortcut"} {
marker := `name="` + shortcut + `"`
index := strings.Index(portBody, marker)
if index < 0 {
t.Fatalf("serial port operation page does not retain internal shortcut %q", shortcut)
}
start := max(0, index-300)
if !strings.Contains(portBody[start:index], " hidden") {
t.Fatalf("serial port operation shortcut %q is still visible", shortcut)
}
}
parameterRequest := httptest.NewRequest(http.MethodGet, "/ports/1/parameters", nil)
parameterRequest.AddCookie(cookie)
parameterResponse := httptest.NewRecorder()
mux.ServeHTTP(parameterResponse, parameterRequest)
parameterBody := parameterResponse.Body.String()
if parameterResponse.Code != http.StatusOK || !strings.Contains(parameterBody, `class="panel port-selector-panel"`) || !strings.Contains(parameterBody, `class="panel form-panel"`) {
t.Fatalf("port parameter page does not render split-layout panels: %d %s", parameterResponse.Code, parameterBody)
}
if strings.Contains(parameterBody, `UP
`) {
t.Fatalf("port parameter page renders a guessed UP state before API synchronization: %d %s", parameterResponse.Code, parameterBody)
}
for _, section := range []string{"operation-2", "data-log", "advanced"} {
request := httptest.NewRequest(http.MethodGet, "/ports/1/"+section, nil)
request.AddCookie(cookie)
response := httptest.NewRecorder()
mux.ServeHTTP(response, request)
if response.Code != http.StatusOK || !strings.Contains(response.Body.String(), `class="panel port-selector-panel"`) {
t.Fatalf("serial port %s page does not render the port selector: %d %s", section, response.Code, response.Body.String())
}
}
}
func TestNetworkPagesRenderOptionalIPv6AndModeSpecificOOBFields(t *testing.T) {
app, err := service.NewAppService(filepath.Join(t.TempDir(), "frontend.db"))
if err != nil {
t.Fatal(err)
}
defer app.Close()
handler, err := NewDeviceHandler(app, "http://127.0.0.1:1")
if err != nil {
t.Fatal(err)
}
handler.sessions["admin-session"] = browserSession{Username: "local-admin", BackendToken: "backend-token", Role: "administrator"}
mux := http.NewServeMux()
handler.RegisterRoutes(mux)
cookie := &http.Cookie{Name: sessionCookieName, Value: "admin-session"}
interfaceRequest := httptest.NewRequest(http.MethodGet, "/network/interface-1", nil)
interfaceRequest.AddCookie(cookie)
interfaceResponse := httptest.NewRecorder()
mux.ServeHTTP(interfaceResponse, interfaceRequest)
interfaceBody := interfaceResponse.Body.String()
if interfaceResponse.Code != http.StatusOK || !strings.Contains(interfaceBody, "인터페이스 1 IPv4 설정") || !strings.Contains(interfaceBody, `data-field-group="ipv6" hidden`) {
t.Fatalf("interface page does not default IPv6 fields to hidden: %d %s", interfaceResponse.Code, interfaceBody)
}
for _, removedCopy := range []string{"API action:", "적용하면 백엔드 운용 구성에 저장되고"} {
if strings.Contains(interfaceBody, removedCopy) {
t.Fatalf("interface page exposed internal implementation copy %q", removedCopy)
}
}
advancedRequest := httptest.NewRequest(http.MethodGet, "/network/advanced", nil)
advancedRequest.AddCookie(cookie)
advancedResponse := httptest.NewRecorder()
mux.ServeHTTP(advancedResponse, advancedRequest)
advancedBody := advancedResponse.Body.String()
if advancedResponse.Code != http.StatusOK || !strings.Contains(advancedBody, `value="single"`) || !strings.Contains(advancedBody, `value="oob"`) || !strings.Contains(advancedBody, `data-field-group="oob-settings" hidden`) {
t.Fatalf("advanced page does not render mode-specific OOB fields: %d %s", advancedResponse.Code, advancedBody)
}
addressFilterRequest := httptest.NewRequest(http.MethodGet, "/admin/address-filter", nil)
addressFilterRequest.AddCookie(cookie)
addressFilterResponse := httptest.NewRecorder()
mux.ServeHTTP(addressFilterResponse, addressFilterRequest)
addressFilterBody := addressFilterResponse.Body.String()
if addressFilterResponse.Code != http.StatusOK ||
!strings.Contains(addressFilterBody, `/admin/address-filter/ipv4`) ||
!strings.Contains(addressFilterBody, `/admin/address-filter/mac`) ||
strings.Contains(addressFilterBody, `/admin/address-filter/ipv6`) {
t.Fatalf("address filter tabs must expose only basic, IPv4, and MAC pages: %d %s", addressFilterResponse.Code, addressFilterBody)
}
assetResponse := httptest.NewRecorder()
mux.ServeHTTP(assetResponse, httptest.NewRequest(http.MethodGet, "/static/js/integration.js", nil))
assetBody := assetResponse.Body.String()
if assetResponse.Code != http.StatusOK ||
!strings.Contains(assetBody, `toast-" + normalizedTone`) ||
!strings.Contains(assetBody, `label: "Session_" + sessionCount`) ||
!strings.Contains(assetBody, `persistentTerminalNavigation.terminalContent`) ||
!strings.Contains(assetBody, `loadedUIVersion !== nextUIVersion`) ||
!strings.Contains(assetBody, `persistentTerminalNavigation.active && isPortDebugPath`) ||
!strings.Contains(assetBody, `currentContent.replaceWith(replacement)`) ||
!strings.Contains(assetBody, `window.teraNavigate = requestConsoleNavigation`) ||
!strings.Contains(assetBody, `if (bytes.byteLength === 0)`) ||
!strings.Contains(assetBody, `전송할 값 또는 끝 문자를 선택하세요.`) ||
!strings.Contains(assetBody, `input.addEventListener("beforeinput"`) ||
!strings.Contains(assetBody, `input.addEventListener("compositionend", discardComposedInput)`) ||
!strings.Contains(assetBody, `let localCursor = 0`) ||
!strings.Contains(assetBody, `localInput.slice(0, localCursor) + value + localInput.slice(localCursor)`) ||
!strings.Contains(assetBody, `moveCommandCursor(localCursor)`) ||
!strings.Contains(assetBody, `nextCursor = Math.max(0, previousCursor - 1)`) ||
!strings.Contains(assetBody, `cursor.className = "terminal-block-cursor"`) ||
!strings.Contains(assetBody, `remoteEditEchoState = 2`) ||
!strings.Contains(assetBody, `suppressRemoteEraseUntil = Date.now() + 1500`) ||
!strings.Contains(assetBody, `let localSelectionAnchor = 0`) ||
!strings.Contains(assetBody, `selection.setBaseAndExtent`) ||
!strings.Contains(assetBody, `queueExpectedCursorEcho(localInput.slice(from, to))`) ||
!strings.Contains(assetBody, `screen.addEventListener("mouseup", finishPointerSelection)`) ||
!strings.Contains(assetBody, `event.code === "KeyV"`) ||
!strings.Contains(assetBody, `const after = localInput.slice(localCursor)`) ||
strings.Contains(assetBody, `localInput.startsWith(character)) { localInput = localInput.slice(1); }`) ||
!strings.Contains(assetBody, `input.dataset.terminalValue !== localInput`) ||
!strings.Contains(assetBody, `OOB 저장 전 인터페이스 1과 인터페이스 2에 Static IPv4와 게이트웨이를 설정하세요.`) ||
!strings.Contains(assetBody, `async function waitForNetworkReconnect()`) ||
!strings.Contains(assetBody, `keepalive: true`) ||
strings.Contains(assetBody, "network-transaction") {
t.Fatalf("integration asset does not use compact status toasts: %d", assetResponse.Code)
}
debugRequest := httptest.NewRequest(http.MethodGet, "/port-debug/48", nil)
debugRequest.AddCookie(cookie)
debugResponse := httptest.NewRecorder()
mux.ServeHTTP(debugResponse, debugRequest)
debugBody := debugResponse.Body.String()
if debugResponse.Code != http.StatusOK ||
!strings.Contains(debugBody, `data-serial-terminal`) ||
!strings.Contains(debugBody, `data-default-port="48"`) ||
!strings.Contains(debugBody, `data-terminal-screen`) ||
!strings.Contains(debugBody, `data-terminal-command-line`) ||
!strings.Contains(debugBody, `class="terminal-command-input"`) ||
!strings.Contains(debugBody, `data-terminal-toggle`) ||
!strings.Contains(debugBody, `data-terminal-advanced-toggle`) ||
!strings.Contains(debugBody, `data-terminal-advanced-panel hidden`) ||
!strings.Contains(debugBody, `터미널 화면을 클릭하고 바로 입력하세요.`) ||
strings.Contains(debugBody, `data-terminal-disconnect`) ||
!strings.Contains(debugBody, `contenteditable="false"`) ||
!strings.Contains(debugBody, `UP 채널 확인 중...`) ||
!strings.Contains(debugBody, "TERA Serial Console") ||
strings.Contains(debugBody, `data-base-label=`) ||
strings.Contains(debugBody, `data-terminal-prompt`) ||
strings.Contains(debugBody, "terminal-input-row") ||
strings.Contains(debugBody, "전송 데이터") {
t.Fatalf("port debug page does not render the Direct Mode terminal: %d %s", debugResponse.Code, debugBody)
}
}
func TestDirectStreamPathValidation(t *testing.T) {
for _, path := range []string{
"/api/v1/direct/CH01/sessions/direct-1/stream",
"/api/v1/direct/CH48/sessions/session:operator/stream",
} {
if !isDirectStreamPath(path) {
t.Errorf("isDirectStreamPath(%q) = false", path)
}
}
for _, path := range []string{
"/api/v1/direct/CH00/sessions/direct-1/stream",
"/api/v1/direct/CH49/sessions/direct-1/stream",
"/api/v1/direct/CH01/sessions//stream",
"/api/v1/direct/CH01/sessions/direct-1",
} {
if isDirectStreamPath(path) {
t.Errorf("isDirectStreamPath(%q) = true", path)
}
}
}