|
|
|
|
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/startup":
|
|
|
|
|
_ = json.NewEncoder(w).Encode(map[string]any{"ready": true, "state": "ready"})
|
|
|
|
|
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, `<div class="user-pill">field-operator</div>`) {
|
|
|
|
|
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{"네트워크 설정", "관리자 운영", "포트 디버그", "포트 로그", "/logs/ports", "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">고정값</span>`) {
|
|
|
|
|
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, `<div class="port-link-state">UP</div>`) {
|
|
|
|
|
t.Fatalf("port parameter page renders a guessed UP state before API synchronization: %d %s", parameterResponse.Code, parameterBody)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
for _, section := range []string{"operation-1", "operation-2"} {
|
|
|
|
|
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 basic, IPv4, and MAC pages only: %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)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestHTTPLoginAfterHTTPSKeepsIndependentCookiesAndSessions(t *testing.T) {
|
|
|
|
|
var calls atomic.Int32
|
|
|
|
|
backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
|
|
|
if r.URL.Path == "/api/v1/auth/login" {
|
|
|
|
|
_ = json.NewEncoder(w).Encode(map[string]any{"session_token": "backend-" + strconv.Itoa(int(calls.Add(1))), "actor": "terauser", "role": "administrator"})
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
w.WriteHeader(http.StatusNoContent)
|
|
|
|
|
}))
|
|
|
|
|
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)
|
|
|
|
|
}
|
|
|
|
|
login := func(scheme string) *http.Cookie {
|
|
|
|
|
request := httptest.NewRequest(http.MethodPost, scheme+"://192.168.0.223/login", strings.NewReader(url.Values{"username": {"terauser"}, "password": {"test"}}.Encode()))
|
|
|
|
|
request.RemoteAddr = "192.168.0.100:40000"
|
|
|
|
|
request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
|
|
|
|
response := httptest.NewRecorder()
|
|
|
|
|
handler.handleLogin(response, request)
|
|
|
|
|
if response.Code != http.StatusSeeOther || len(response.Result().Cookies()) != 1 {
|
|
|
|
|
t.Fatalf("login failed: %d", response.Code)
|
|
|
|
|
}
|
|
|
|
|
return response.Result().Cookies()[0]
|
|
|
|
|
}
|
|
|
|
|
secure := login("https")
|
|
|
|
|
plain := login("http")
|
|
|
|
|
if secure.Name == plain.Name || !secure.Secure || plain.Secure {
|
|
|
|
|
t.Fatal("HTTP can collide with secure cookie")
|
|
|
|
|
}
|
|
|
|
|
if secure.Name != "__Host-teraclone_session" || plain.Name == "teraclone_session" {
|
|
|
|
|
t.Fatal("legacy cookie collision")
|
|
|
|
|
}
|
|
|
|
|
for _, scheme := range []string{"http", "https"} {
|
|
|
|
|
request := httptest.NewRequest(http.MethodGet, scheme+"://192.168.0.223/", nil)
|
|
|
|
|
request.AddCookie(secure)
|
|
|
|
|
request.AddCookie(plain)
|
|
|
|
|
session, ok := handler.authenticatedSession(request)
|
|
|
|
|
if !ok || session.Username != "terauser" {
|
|
|
|
|
t.Fatalf("%s session lost", scheme)
|
|
|
|
|
}
|
|
|
|
|
if expiredSessionCookie(scheme == "https").Name != browserSessionCookieName(scheme == "https") {
|
|
|
|
|
t.Fatal("wrong cookie expired")
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if len(handler.sessions) != 2 {
|
|
|
|
|
t.Fatal("HTTP login revoked HTTPS recovery session")
|
|
|
|
|
}
|
|
|
|
|
}
|