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, ``) { 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) } } }