Add minimal Web UI server

This commit is contained in:
Andy Oknen 2025-07-29 20:14:41 +00:00
parent 707e90b1b3
commit 345d5b9cbd
13 changed files with 2058 additions and 0 deletions

335
src/webui/config_test.go Normal file
View file

@ -0,0 +1,335 @@
package webui
import (
"fmt"
"testing"
"github.com/yggdrasil-network/yggdrasil-go/src/config"
)
func TestWebUIConfig_DefaultValues(t *testing.T) {
cfg := config.GenerateConfig()
// Check that WebUI config has reasonable defaults
if cfg.WebUI.Port == 0 {
t.Log("Note: WebUI Port is 0 (might be default unset value)")
}
// Host can be empty (meaning all interfaces)
if cfg.WebUI.Host == "" {
t.Log("Note: WebUI Host is empty (binds to all interfaces)")
}
// Enable should have a default value
if !cfg.WebUI.Enable && cfg.WebUI.Enable {
t.Log("WebUI Enable flag has a boolean value")
}
}
func TestWebUIConfig_Validation(t *testing.T) {
testCases := []struct {
name string
config config.WebUIConfig
valid bool
expected string
}{
{
name: "Valid config with default port",
config: config.WebUIConfig{
Enable: true,
Port: 9000,
Host: "",
},
valid: true,
expected: ":9000",
},
{
name: "Valid config with localhost",
config: config.WebUIConfig{
Enable: true,
Port: 8080,
Host: "localhost",
},
valid: true,
expected: "localhost:8080",
},
{
name: "Valid config with specific IP",
config: config.WebUIConfig{
Enable: true,
Port: 3000,
Host: "127.0.0.1",
},
valid: true,
expected: "127.0.0.1:3000",
},
{
name: "Valid config with IPv6",
config: config.WebUIConfig{
Enable: true,
Port: 9000,
Host: "::1",
},
valid: true,
expected: "[::1]:9000",
},
{
name: "Disabled config",
config: config.WebUIConfig{
Enable: false,
Port: 9000,
Host: "localhost",
},
valid: false,
expected: "",
},
{
name: "Zero port",
config: config.WebUIConfig{
Enable: true,
Port: 0,
Host: "localhost",
},
valid: true,
expected: "localhost:0",
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
// Test building listen address from config
var listenAddr string
if tc.config.Enable {
if tc.config.Host == "" {
listenAddr = fmt.Sprintf(":%d", tc.config.Port)
} else if tc.config.Host == "::1" || (len(tc.config.Host) > 0 && tc.config.Host[0] == ':') {
// IPv6 needs brackets
listenAddr = fmt.Sprintf("[%s]:%d", tc.config.Host, tc.config.Port)
} else {
listenAddr = fmt.Sprintf("%s:%d", tc.config.Host, tc.config.Port)
}
}
if tc.valid {
if listenAddr != tc.expected {
t.Errorf("Expected listen address %s, got %s", tc.expected, listenAddr)
}
// Try to create server with this config
logger := createTestLogger()
server := Server(listenAddr, logger)
if server == nil {
t.Error("Failed to create server with valid config")
}
} else {
if tc.config.Enable {
t.Error("Config should be considered invalid when WebUI is disabled")
}
}
})
}
}
func TestWebUIConfig_PortRanges(t *testing.T) {
logger := createTestLogger()
// Test various port ranges
portTests := []struct {
port uint16
shouldWork bool
description string
}{
{1, true, "Port 1 (lowest valid port)"},
{80, true, "Port 80 (HTTP)"},
{443, true, "Port 443 (HTTPS)"},
{8080, true, "Port 8080 (common alternative)"},
{9000, true, "Port 9000 (default WebUI)"},
{65535, true, "Port 65535 (highest valid port)"},
{0, true, "Port 0 (OS assigns port)"},
}
for _, test := range portTests {
t.Run(test.description, func(t *testing.T) {
listenAddr := fmt.Sprintf("127.0.0.1:%d", test.port)
server := Server(listenAddr, logger)
if server == nil {
t.Errorf("Failed to create server for %s", test.description)
return
}
// For port 0, the OS will assign an available port
// For other ports, we just check if server creation succeeds
if test.shouldWork {
// Try to start briefly to see if port is valid
go func() {
server.Start()
}()
// Quick cleanup
server.Stop()
}
})
}
}
func TestWebUIConfig_HostFormats(t *testing.T) {
logger := createTestLogger()
hostTests := []struct {
host string
port uint16
expected string
description string
}{
{"", 9000, ":9000", "Empty host (all interfaces)"},
{"localhost", 9000, "localhost:9000", "Localhost"},
{"127.0.0.1", 9000, "127.0.0.1:9000", "IPv4 loopback"},
{"0.0.0.0", 9000, "0.0.0.0:9000", "IPv4 all interfaces"},
{"::1", 9000, "[::1]:9000", "IPv6 loopback"},
{"::", 9000, "[::]:9000", "IPv6 all interfaces"},
}
for _, test := range hostTests {
t.Run(test.description, func(t *testing.T) {
var listenAddr string
if test.host == "" {
listenAddr = fmt.Sprintf(":%d", test.port)
} else if test.host == "::1" || test.host == "::" {
listenAddr = fmt.Sprintf("[%s]:%d", test.host, test.port)
} else {
listenAddr = fmt.Sprintf("%s:%d", test.host, test.port)
}
if listenAddr != test.expected {
t.Errorf("Expected %s, got %s", test.expected, listenAddr)
}
server := Server(listenAddr, logger)
if server == nil {
t.Errorf("Failed to create server with %s", test.description)
}
})
}
}
func TestWebUIConfig_Integration(t *testing.T) {
// Test integration with actual config generation
cfg := config.GenerateConfig()
// Modify WebUI config
cfg.WebUI.Enable = true
cfg.WebUI.Port = 9001
cfg.WebUI.Host = "127.0.0.1"
// Build listen address from config
listenAddr := fmt.Sprintf("%s:%d", cfg.WebUI.Host, cfg.WebUI.Port)
logger := createTestLogger()
server := Server(listenAddr, logger)
if server == nil {
t.Fatal("Failed to create server from generated config")
}
// Test that server can start with this config
go func() {
server.Start()
}()
defer server.Stop()
// Verify server properties match config
if server.listen != listenAddr {
t.Errorf("Server listen address %s doesn't match config %s", server.listen, listenAddr)
}
}
func TestWebUIConfig_JSONSerialization(t *testing.T) {
// Test that WebUIConfig can be serialized/deserialized
// This is important for config file handling
originalConfig := config.WebUIConfig{
Enable: true,
Port: 8080,
Host: "localhost",
}
// In a real scenario, this would go through JSON marshaling/unmarshaling
// For this test, we'll just verify the struct is properly defined
if originalConfig.Enable != true {
t.Error("Enable field not properly set")
}
if originalConfig.Port != 8080 {
t.Error("Port field not properly set")
}
if originalConfig.Host != "localhost" {
t.Error("Host field not properly set")
}
}
func TestWebUIConfig_EdgeCases(t *testing.T) {
logger := createTestLogger()
// Test edge cases for configuration
edgeCases := []struct {
name string
config config.WebUIConfig
test func(t *testing.T, cfg config.WebUIConfig)
}{
{
name: "All zeros",
config: config.WebUIConfig{
Enable: false,
Port: 0,
Host: "",
},
test: func(t *testing.T, cfg config.WebUIConfig) {
if cfg.Enable {
t.Error("Enable should be false")
}
},
},
{
name: "Maximum port",
config: config.WebUIConfig{
Enable: true,
Port: 65535,
Host: "127.0.0.1",
},
test: func(t *testing.T, cfg config.WebUIConfig) {
listenAddr := fmt.Sprintf("%s:%d", cfg.Host, cfg.Port)
server := Server(listenAddr, logger)
if server == nil {
t.Error("Should be able to create server with max port")
}
},
},
{
name: "Unicode host (should be handled gracefully)",
config: config.WebUIConfig{
Enable: true,
Port: 9000,
Host: "тест",
},
test: func(t *testing.T, cfg config.WebUIConfig) {
listenAddr := fmt.Sprintf("%s:%d", cfg.Host, cfg.Port)
server := Server(listenAddr, logger)
// Server creation should not panic, even with invalid host
if server == nil {
t.Error("Server creation should not fail due to host format")
}
},
},
}
for _, tc := range edgeCases {
t.Run(tc.name, func(t *testing.T) {
tc.test(t, tc.config)
})
}
}

282
src/webui/endpoints_test.go Normal file
View file

@ -0,0 +1,282 @@
package webui
import (
"fmt"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
)
func TestWebUIServer_RootEndpoint(t *testing.T) {
logger := createTestLogger()
// Use httptest.Server for more reliable testing
mux := http.NewServeMux()
setupStaticHandler(mux)
mux.HandleFunc("/", func(rw http.ResponseWriter, r *http.Request) {
serveFile(rw, r, logger)
})
server := httptest.NewServer(mux)
defer server.Close()
// Test root endpoint
resp, err := http.Get(server.URL + "/")
if err != nil {
t.Fatalf("Error requesting root endpoint: %v", err)
}
defer resp.Body.Close()
// Should return some content (index.html or 404, depending on build mode)
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusNotFound {
t.Errorf("Expected status 200 or 404, got %d", resp.StatusCode)
}
}
func TestWebUIServer_HealthEndpointDetails(t *testing.T) {
logger := createTestLogger()
// Use httptest.Server for more reliable testing
mux := http.NewServeMux()
setupStaticHandler(mux)
mux.HandleFunc("/", func(rw http.ResponseWriter, r *http.Request) {
serveFile(rw, r, logger)
})
mux.HandleFunc("/health", func(rw http.ResponseWriter, r *http.Request) {
rw.WriteHeader(http.StatusOK)
rw.Write([]byte("OK"))
})
server := httptest.NewServer(mux)
defer server.Close()
// Test health endpoint with different HTTP methods
testCases := []struct {
method string
expectedStatus int
}{
{"GET", http.StatusOK},
{"POST", http.StatusOK},
{"PUT", http.StatusOK},
{"DELETE", http.StatusOK},
{"HEAD", http.StatusOK},
}
client := &http.Client{Timeout: 5 * time.Second}
for _, tc := range testCases {
t.Run(fmt.Sprintf("Method_%s", tc.method), func(t *testing.T) {
req, err := http.NewRequest(tc.method, server.URL+"/health", nil)
if err != nil {
t.Fatalf("Error creating request: %v", err)
}
resp, err := client.Do(req)
if err != nil {
t.Fatalf("Error making request: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != tc.expectedStatus {
t.Errorf("Expected status %d for %s, got %d", tc.expectedStatus, tc.method, resp.StatusCode)
}
if tc.method != "HEAD" {
body, err := io.ReadAll(resp.Body)
if err != nil {
t.Fatalf("Error reading response body: %v", err)
}
if string(body) != "OK" {
t.Errorf("Expected body 'OK', got '%s'", string(body))
}
}
})
}
}
func TestWebUIServer_NonExistentEndpoint(t *testing.T) {
logger := createTestLogger()
// Use httptest.Server for more reliable testing
mux := http.NewServeMux()
setupStaticHandler(mux)
mux.HandleFunc("/", func(rw http.ResponseWriter, r *http.Request) {
serveFile(rw, r, logger)
})
mux.HandleFunc("/health", func(rw http.ResponseWriter, r *http.Request) {
rw.WriteHeader(http.StatusOK)
rw.Write([]byte("OK"))
})
server := httptest.NewServer(mux)
defer server.Close()
// Test non-existent endpoints
testPaths := []string{
"/nonexistent",
"/api/v1/test",
"/static/nonexistent.css",
"/admin",
"/config",
}
client := &http.Client{Timeout: 5 * time.Second}
for _, path := range testPaths {
t.Run(fmt.Sprintf("Path_%s", strings.ReplaceAll(path, "/", "_")), func(t *testing.T) {
resp, err := client.Get(server.URL + path)
if err != nil {
t.Fatalf("Error requesting %s: %v", path, err)
}
defer resp.Body.Close()
// Should return 404 for non-existent paths
if resp.StatusCode != http.StatusNotFound {
t.Errorf("Expected status 404 for %s, got %d", path, resp.StatusCode)
}
})
}
}
func TestWebUIServer_ContentTypes(t *testing.T) {
// This test checks if proper content types are set
// We'll use httptest.Server for more controlled testing
logger := createTestLogger()
// Create a test handler similar to what the webui server creates
mux := http.NewServeMux()
// Setup handlers like in the actual server
setupStaticHandler(mux)
mux.HandleFunc("/", func(rw http.ResponseWriter, r *http.Request) {
serveFile(rw, r, logger)
})
mux.HandleFunc("/health", func(rw http.ResponseWriter, r *http.Request) {
rw.WriteHeader(http.StatusOK)
rw.Write([]byte("OK"))
})
server := httptest.NewServer(mux)
defer server.Close()
// Test health endpoint content type
resp, err := http.Get(server.URL + "/health")
if err != nil {
t.Fatalf("Error requesting health endpoint: %v", err)
}
defer resp.Body.Close()
// Health endpoint might not set explicit content type, which is fine
if resp.StatusCode != http.StatusOK {
t.Errorf("Expected status 200 for health endpoint, got %d", resp.StatusCode)
}
}
func TestWebUIServer_HeaderSecurity(t *testing.T) {
logger := createTestLogger()
// Use httptest.Server for more reliable testing
mux := http.NewServeMux()
setupStaticHandler(mux)
mux.HandleFunc("/", func(rw http.ResponseWriter, r *http.Request) {
serveFile(rw, r, logger)
})
mux.HandleFunc("/health", func(rw http.ResponseWriter, r *http.Request) {
rw.WriteHeader(http.StatusOK)
rw.Write([]byte("OK"))
})
server := httptest.NewServer(mux)
defer server.Close()
// Test that server handles large headers properly
client := &http.Client{Timeout: 5 * time.Second}
req, err := http.NewRequest("GET", server.URL+"/health", nil)
if err != nil {
t.Fatalf("Error creating request: %v", err)
}
// Add a reasonably sized header
req.Header.Set("X-Test-Header", strings.Repeat("a", 1000))
resp, err := client.Do(req)
if err != nil {
t.Fatalf("Error making request with large header: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Errorf("Expected status 200 with normal header, got %d", resp.StatusCode)
}
}
func TestWebUIServer_ConcurrentRequests(t *testing.T) {
logger := createTestLogger()
// Use httptest.Server for more reliable testing
mux := http.NewServeMux()
setupStaticHandler(mux)
mux.HandleFunc("/", func(rw http.ResponseWriter, r *http.Request) {
serveFile(rw, r, logger)
})
mux.HandleFunc("/health", func(rw http.ResponseWriter, r *http.Request) {
rw.WriteHeader(http.StatusOK)
rw.Write([]byte("OK"))
})
server := httptest.NewServer(mux)
defer server.Close()
// Test concurrent requests to health endpoint
const numRequests = 20
errChan := make(chan error, numRequests)
client := &http.Client{Timeout: 5 * time.Second}
for i := 0; i < numRequests; i++ {
go func() {
resp, err := client.Get(server.URL + "/health")
if err != nil {
errChan <- err
return
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
errChan <- fmt.Errorf("unexpected status code: %d", resp.StatusCode)
return
}
body, err := io.ReadAll(resp.Body)
if err != nil {
errChan <- err
return
}
if string(body) != "OK" {
errChan <- fmt.Errorf("unexpected body: %s", string(body))
return
}
errChan <- nil
}()
}
// Wait for all requests to complete
for i := 0; i < numRequests; i++ {
select {
case err := <-errChan:
if err != nil {
t.Errorf("Concurrent request %d failed: %v", i+1, err)
}
case <-time.After(10 * time.Second):
t.Fatalf("Request %d timed out", i+1)
}
}
}

View file

@ -0,0 +1,360 @@
package webui
import (
"context"
"fmt"
"net"
"net/http"
"net/http/httptest"
"testing"
"time"
)
func TestWebUIServer_InvalidListenAddress(t *testing.T) {
logger := createTestLogger()
// Test various invalid listen addresses
invalidAddresses := []string{
"invalid:address",
"256.256.256.256:8080",
"localhost:-1",
"localhost:99999",
"not-a-valid-address",
"",
}
for _, addr := range invalidAddresses {
t.Run(fmt.Sprintf("Address_%s", addr), func(t *testing.T) {
server := Server(addr, logger)
// Start should fail for invalid addresses
err := server.Start()
if err == nil {
server.Stop() // Clean up if it somehow started
t.Errorf("Expected Start() to fail for invalid address %s", addr)
}
})
}
}
func TestWebUIServer_PortAlreadyInUse(t *testing.T) {
logger := createTestLogger()
// Start a server on a specific port
listener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("Failed to create listener: %v", err)
}
defer listener.Close()
// Get the port that's now in use
usedPort := listener.Addr().(*net.TCPAddr).Port
conflictAddress := fmt.Sprintf("127.0.0.1:%d", usedPort)
server := Server(conflictAddress, logger)
// This should fail because port is already in use
err = server.Start()
if err == nil {
server.Stop()
t.Error("Expected Start() to fail when port is already in use")
}
}
func TestWebUIServer_DoubleStart(t *testing.T) {
logger := createTestLogger()
// Create two separate servers to test behavior
server1 := Server("127.0.0.1:0", logger)
server2 := Server("127.0.0.1:0", logger)
// Start first server
startDone1 := make(chan error, 1)
go func() {
startDone1 <- server1.Start()
}()
// Wait for first server to start
time.Sleep(100 * time.Millisecond)
if server1.server == nil {
t.Fatal("First server should have started")
}
// Start second server (should work since different instance)
startDone2 := make(chan error, 1)
go func() {
startDone2 <- server2.Start()
}()
// Wait a bit then stop both servers
time.Sleep(100 * time.Millisecond)
err1 := server1.Stop()
if err1 != nil {
t.Errorf("Stop() failed for server1: %v", err1)
}
err2 := server2.Stop()
if err2 != nil {
t.Errorf("Stop() failed for server2: %v", err2)
}
// Wait for both Start() calls to complete
select {
case err := <-startDone1:
if err != nil {
t.Logf("First Start() returned error: %v", err)
}
case <-time.After(2 * time.Second):
t.Error("First Start() did not return after Stop()")
}
select {
case err := <-startDone2:
if err != nil {
t.Logf("Second Start() returned error: %v", err)
}
case <-time.After(2 * time.Second):
t.Error("Second Start() did not return after Stop()")
}
}
func TestWebUIServer_StopTwice(t *testing.T) {
logger := createTestLogger()
server := Server("127.0.0.1:0", logger)
// Start server
go func() {
server.Start()
}()
time.Sleep(100 * time.Millisecond)
// Stop server first time
err := server.Stop()
if err != nil {
t.Errorf("First Stop() failed: %v", err)
}
// Stop server second time - should not error
err = server.Stop()
if err != nil {
t.Errorf("Second Stop() failed: %v", err)
}
}
func TestWebUIServer_GracefulShutdown(t *testing.T) {
logger := createTestLogger()
// Create a listener to get a real address
listener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("Failed to create listener: %v", err)
}
addr := listener.Addr().String()
listener.Close() // Close so our server can use it
server := Server(addr, logger)
// Channel to track when Start() returns
startDone := make(chan error, 1)
// Start server
go func() {
startDone <- server.Start()
}()
time.Sleep(100 * time.Millisecond)
// Verify server is running
if server.server == nil {
t.Fatal("Server should be running")
}
// Make a request while server is running
client := &http.Client{Timeout: 1 * time.Second}
resp, err := client.Get(fmt.Sprintf("http://%s/health", addr))
if err != nil {
t.Fatalf("Request failed while server running: %v", err)
}
resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Errorf("Expected status 200, got %d", resp.StatusCode)
}
// Stop server
err = server.Stop()
if err != nil {
t.Errorf("Stop() failed: %v", err)
}
// Verify Start() returns
select {
case err := <-startDone:
if err != nil {
t.Errorf("Start() returned error after Stop(): %v", err)
}
case <-time.After(2 * time.Second):
t.Error("Start() did not return within timeout after Stop()")
}
// Verify server is no longer accessible
_, err = client.Get(fmt.Sprintf("http://%s/health", addr))
if err == nil {
t.Error("Expected request to fail after server stopped")
}
}
func TestWebUIServer_ContextCancellation(t *testing.T) {
logger := createTestLogger()
server := Server("127.0.0.1:0", logger)
// Create context with timeout
ctx, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond)
defer cancel()
// Start server
go func() {
server.Start()
}()
time.Sleep(100 * time.Millisecond)
// Wait for context to be cancelled
<-ctx.Done()
// Stop server after context cancellation
err := server.Stop()
if err != nil {
t.Errorf("Stop() failed after context cancellation: %v", err)
}
}
func TestWebUIServer_LoggerNil(t *testing.T) {
// Test server creation with nil logger
server := Server("127.0.0.1:0", nil)
if server == nil {
t.Fatal("Server should be created even with nil logger")
}
if server.log != nil {
t.Error("Server logger should be nil if nil was passed")
}
}
func TestWebUIServer_EmptyListenAddress(t *testing.T) {
logger := createTestLogger()
// Test with empty listen address
server := Server("", logger)
// This might fail when trying to start
err := server.Start()
if err == nil {
server.Stop()
t.Log("Note: Server started with empty listen address")
} else {
t.Logf("Expected behavior: Start() failed with empty address: %v", err)
}
}
func TestWebUIServer_RapidStartStop(t *testing.T) {
logger := createTestLogger()
// Test rapid start/stop cycles with fewer iterations
for i := 0; i < 5; i++ {
server := Server("127.0.0.1:0", logger)
// Start server
startDone := make(chan error, 1)
go func() {
startDone <- server.Start()
}()
// Wait a bit for server to start
time.Sleep(50 * time.Millisecond)
// Stop server
err := server.Stop()
if err != nil {
t.Errorf("Iteration %d: Stop() failed: %v", i, err)
}
// Wait for Start() to return
select {
case <-startDone:
// Start() returned, good
case <-time.After(1 * time.Second):
t.Errorf("Iteration %d: Start() did not return after Stop()", i)
}
// Pause between iterations to avoid port conflicts
time.Sleep(50 * time.Millisecond)
}
}
func TestWebUIServer_LargeNumberOfRequests(t *testing.T) {
logger := createTestLogger()
// Use httptest.Server for more reliable testing
mux := http.NewServeMux()
setupStaticHandler(mux)
mux.HandleFunc("/", func(rw http.ResponseWriter, r *http.Request) {
serveFile(rw, r, logger)
})
mux.HandleFunc("/health", func(rw http.ResponseWriter, r *http.Request) {
rw.WriteHeader(http.StatusOK)
rw.Write([]byte("OK"))
})
server := httptest.NewServer(mux)
defer server.Close()
// Send many requests quickly
const numRequests = 50 // Reduced number for more reliable testing
errorChan := make(chan error, numRequests)
client := &http.Client{Timeout: 2 * time.Second}
for i := 0; i < numRequests; i++ {
go func(requestID int) {
resp, err := client.Get(server.URL + "/health")
if err != nil {
errorChan <- fmt.Errorf("request %d failed: %v", requestID, err)
return
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
errorChan <- fmt.Errorf("request %d: expected status 200, got %d", requestID, resp.StatusCode)
return
}
errorChan <- nil
}(i)
}
// Check results
errorCount := 0
for i := 0; i < numRequests; i++ {
select {
case err := <-errorChan:
if err != nil {
errorCount++
if errorCount <= 5 { // Only log first few errors
t.Errorf("Request error: %v", err)
}
}
case <-time.After(10 * time.Second):
t.Fatalf("Request %d timed out", i)
}
}
if errorCount > 0 {
t.Errorf("Total failed requests: %d/%d", errorCount, numRequests)
}
}

63
src/webui/server.go Normal file
View file

@ -0,0 +1,63 @@
package webui
import (
"fmt"
"net/http"
"time"
"github.com/yggdrasil-network/yggdrasil-go/src/core"
)
type WebUIServer struct {
server *http.Server
log core.Logger
listen string
}
func Server(listen string, log core.Logger) *WebUIServer {
return &WebUIServer{
listen: listen,
log: log,
}
}
func (w *WebUIServer) Start() error {
mux := http.NewServeMux()
// Setup static files handler (implementation varies by build)
setupStaticHandler(mux)
// Serve any file by path (implementation varies by build)
mux.HandleFunc("/", func(rw http.ResponseWriter, r *http.Request) {
serveFile(rw, r, w.log)
})
// Health check endpoint
mux.HandleFunc("/health", func(rw http.ResponseWriter, r *http.Request) {
rw.WriteHeader(http.StatusOK)
rw.Write([]byte("OK"))
})
w.server = &http.Server{
Addr: w.listen,
Handler: mux,
ReadTimeout: 10 * time.Second,
WriteTimeout: 10 * time.Second,
MaxHeaderBytes: 1 << 20,
}
w.log.Infof("WebUI server starting on %s", w.listen)
if err := w.server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
return fmt.Errorf("WebUI server failed: %v", err)
}
return nil
}
func (w *WebUIServer) Stop() error {
if w.server != nil {
return w.server.Close()
}
return nil
}

51
src/webui/server_dev.go Normal file
View file

@ -0,0 +1,51 @@
//go:build debug
// +build debug
package webui
import (
"mime"
"net/http"
"os"
"path/filepath"
"strings"
"github.com/yggdrasil-network/yggdrasil-go/src/core"
)
// setupStaticHandler configures static file serving for development (files from disk)
func setupStaticHandler(mux *http.ServeMux) {
// Serve static files from disk for development
mux.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.Dir("src/webui/static/"))))
}
// serveFile serves any file from disk or returns 404 if not found
func serveFile(rw http.ResponseWriter, r *http.Request, log core.Logger) {
// Clean the path and remove leading slash
requestPath := strings.TrimPrefix(r.URL.Path, "/")
// If path is empty, serve index.html
if requestPath == "" {
requestPath = "index.html"
}
// Construct the full path on disk
filePath := filepath.Join("src/webui/static", requestPath)
// Check if file exists
if _, err := os.Stat(filePath); os.IsNotExist(err) {
log.Debugf("File not found: %s", filePath)
http.NotFound(rw, r)
return
}
// Determine content type based on file extension
contentType := mime.TypeByExtension(filepath.Ext(requestPath))
if contentType != "" {
rw.Header().Set("Content-Type", contentType)
}
// Serve the file
log.Debugf("Serving file from disk: %s", filePath)
http.ServeFile(rw, r, filePath)
}

64
src/webui/server_prod.go Normal file
View file

@ -0,0 +1,64 @@
//go:build !debug
// +build !debug
package webui
import (
"embed"
"io/fs"
"mime"
"net/http"
"path/filepath"
"strings"
"github.com/yggdrasil-network/yggdrasil-go/src/core"
)
//go:embed static/*
var staticFiles embed.FS
// setupStaticHandler configures static file serving for production (embedded files)
func setupStaticHandler(mux *http.ServeMux) {
// Get the embedded file system for static files
staticFS, err := fs.Sub(staticFiles, "static")
if err != nil {
panic("failed to get embedded static files: " + err.Error())
}
// Serve static files from embedded FS
mux.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.FS(staticFS))))
}
// serveFile serves any file from embedded files or returns 404 if not found
func serveFile(rw http.ResponseWriter, r *http.Request, log core.Logger) {
// Clean the path and remove leading slash
requestPath := strings.TrimPrefix(r.URL.Path, "/")
// If path is empty, serve index.html
if requestPath == "" {
requestPath = "index.html"
}
// Construct the full path within static directory
filePath := "static/" + requestPath
// Try to read the file from embedded FS
data, err := staticFiles.ReadFile(filePath)
if err != nil {
log.Debugf("File not found: %s", filePath)
http.NotFound(rw, r)
return
}
// Determine content type based on file extension
contentType := mime.TypeByExtension(filepath.Ext(requestPath))
if contentType == "" {
contentType = "application/octet-stream"
}
// Set headers and serve the file
rw.Header().Set("Content-Type", contentType)
rw.Write(data)
log.Debugf("Served file: %s (type: %s)", filePath, contentType)
}

219
src/webui/server_test.go Normal file
View file

@ -0,0 +1,219 @@
package webui
import (
"fmt"
"io"
"net/http"
"net/http/httptest"
"os"
"testing"
"time"
"github.com/gologme/log"
"github.com/yggdrasil-network/yggdrasil-go/src/core"
)
// Helper function to create a test logger
func createTestLogger() core.Logger {
return log.New(os.Stderr, "webui_test: ", log.Flags())
}
// Helper function to get available port for testing
func getTestAddress() string {
return "127.0.0.1:0" // Let OS assign available port
}
// Helper function to wait for server to be ready
func waitForServer(url string, timeout time.Duration) error {
client := &http.Client{Timeout: 1 * time.Second}
deadline := time.Now().Add(timeout)
for time.Now().Before(deadline) {
resp, err := client.Get(url)
if err == nil {
resp.Body.Close()
return nil
}
time.Sleep(50 * time.Millisecond)
}
return fmt.Errorf("server not ready within timeout")
}
func TestWebUIServer_Creation(t *testing.T) {
logger := createTestLogger()
listen := getTestAddress()
server := Server(listen, logger)
if server == nil {
t.Fatal("Server function returned nil")
}
if server.listen != listen {
t.Errorf("Expected listen address %s, got %s", listen, server.listen)
}
if server.log != logger {
t.Error("Logger not properly set")
}
if server.server != nil {
t.Error("HTTP server should be nil before Start()")
}
}
func TestWebUIServer_StartStop(t *testing.T) {
logger := createTestLogger()
listen := getTestAddress()
server := Server(listen, logger)
// Start server in goroutine
errChan := make(chan error, 1)
go func() {
errChan <- server.Start()
}()
// Give server time to start
time.Sleep(100 * time.Millisecond)
// Verify server is running
if server.server == nil {
t.Fatal("HTTP server not initialized after Start()")
}
// Stop server
err := server.Stop()
if err != nil {
t.Errorf("Error stopping server: %v", err)
}
// Check that Start() returns without error after Stop()
select {
case err := <-errChan:
if err != nil {
t.Errorf("Start() returned error: %v", err)
}
case <-time.After(2 * time.Second):
t.Error("Start() did not return after Stop()")
}
}
func TestWebUIServer_StopWithoutStart(t *testing.T) {
logger := createTestLogger()
listen := getTestAddress()
server := Server(listen, logger)
// Stop server that was never started should not error
err := server.Stop()
if err != nil {
t.Errorf("Stop() on unstarted server returned error: %v", err)
}
}
func TestWebUIServer_HealthEndpoint(t *testing.T) {
logger := createTestLogger()
// Create a test server using net/http/httptest for reliable testing
mux := http.NewServeMux()
setupStaticHandler(mux)
mux.HandleFunc("/", func(rw http.ResponseWriter, r *http.Request) {
serveFile(rw, r, logger)
})
mux.HandleFunc("/health", func(rw http.ResponseWriter, r *http.Request) {
rw.WriteHeader(http.StatusOK)
rw.Write([]byte("OK"))
})
server := httptest.NewServer(mux)
defer server.Close()
// Test health endpoint
resp, err := http.Get(server.URL + "/health")
if err != nil {
t.Fatalf("Error requesting health endpoint: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Errorf("Expected status 200, got %d", resp.StatusCode)
}
body, err := io.ReadAll(resp.Body)
if err != nil {
t.Fatalf("Error reading response body: %v", err)
}
if string(body) != "OK" {
t.Errorf("Expected body 'OK', got '%s'", string(body))
}
}
func TestWebUIServer_Timeouts(t *testing.T) {
logger := createTestLogger()
server := Server("127.0.0.1:0", logger)
// Start server
go func() {
server.Start()
}()
defer server.Stop()
// Wait for server to start
time.Sleep(200 * time.Millisecond)
if server.server == nil {
t.Fatal("Server not started")
}
// Check that timeouts are properly configured
expectedReadTimeout := 10 * time.Second
expectedWriteTimeout := 10 * time.Second
expectedMaxHeaderBytes := 1 << 20
if server.server.ReadTimeout != expectedReadTimeout {
t.Errorf("Expected ReadTimeout %v, got %v", expectedReadTimeout, server.server.ReadTimeout)
}
if server.server.WriteTimeout != expectedWriteTimeout {
t.Errorf("Expected WriteTimeout %v, got %v", expectedWriteTimeout, server.server.WriteTimeout)
}
if server.server.MaxHeaderBytes != expectedMaxHeaderBytes {
t.Errorf("Expected MaxHeaderBytes %d, got %d", expectedMaxHeaderBytes, server.server.MaxHeaderBytes)
}
}
func TestWebUIServer_ConcurrentStartStop(t *testing.T) {
logger := createTestLogger()
// Test concurrent start/stop operations with separate servers
for i := 0; i < 3; i++ {
server := Server("127.0.0.1:0", logger)
// Start server
startDone := make(chan error, 1)
go func() {
startDone <- server.Start()
}()
time.Sleep(100 * time.Millisecond)
// Stop server
err := server.Stop()
if err != nil {
t.Errorf("Iteration %d: Error stopping server: %v", i, err)
}
// Wait for Start() to return
select {
case <-startDone:
// Good, Start() returned
case <-time.After(2 * time.Second):
t.Errorf("Iteration %d: Start() did not return after Stop()", i)
}
time.Sleep(50 * time.Millisecond)
}
}

View file

@ -0,0 +1,55 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Yggdrasil Web Interface</title>
<link rel="stylesheet" href="static/style.css">
</head>
<body>
<div class="container">
<header>
<h1>🌳 Yggdrasil Web Interface</h1>
<p>Network mesh management dashboard</p>
</header>
<main>
<div class="status-card">
<h2>Node Status</h2>
<div class="status-indicator">
<span class="status-dot active"></span>
<span>Active</span>
</div>
<p>WebUI is running and accessible</p>
</div>
<div class="info-grid">
<div class="info-card">
<h3>Configuration</h3>
<p>Manage node settings and peers</p>
<small>Coming soon...</small>
</div>
<div class="info-card">
<h3>Peers</h3>
<p>View and manage peer connections</p>
<small>Coming soon...</small>
</div>
<div class="info-card">
<h3>Network</h3>
<p>Network topology and routing</p>
<small>Coming soon...</small>
</div>
</div>
</main>
<footer>
<p>Yggdrasil Network • Minimal WebUI v1.0</p>
</footer>
</div>
</body>
</html>

136
src/webui/static/style.css Normal file
View file

@ -0,0 +1,136 @@
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
min-height: 100vh;
color: #333;
}
.container {
max-width: 1200px;
margin: 0 auto;
padding: 20px;
}
header {
text-align: center;
margin-bottom: 40px;
color: white;
}
header h1 {
font-size: 2.5rem;
margin-bottom: 10px;
text-shadow: 2px 2px 4px rgba(0,0,0,0.3);
}
header p {
font-size: 1.2rem;
opacity: 0.9;
}
main {
background: white;
border-radius: 12px;
padding: 30px;
box-shadow: 0 8px 32px rgba(0,0,0,0.1);
margin-bottom: 20px;
}
.status-card {
background: #f8f9fa;
border-radius: 8px;
padding: 20px;
margin-bottom: 30px;
text-align: center;
}
.status-indicator {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
margin: 15px 0;
}
.status-dot {
width: 12px;
height: 12px;
border-radius: 50%;
background: #6c757d;
}
.status-dot.active {
background: #28a745;
animation: pulse 2s infinite;
}
@keyframes pulse {
0% { opacity: 1; }
50% { opacity: 0.5; }
100% { opacity: 1; }
}
.info-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
gap: 20px;
}
.info-card {
background: #ffffff;
border: 1px solid #e9ecef;
border-radius: 8px;
padding: 20px;
text-align: center;
transition: transform 0.2s ease, box-shadow 0.2s ease;
}
.info-card:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0,0,0,0.1);
}
.info-card h3 {
color: #495057;
margin-bottom: 10px;
}
.info-card p {
color: #6c757d;
margin-bottom: 10px;
}
.info-card small {
color: #adb5bd;
font-style: italic;
}
footer {
text-align: center;
color: white;
opacity: 0.8;
}
@media (max-width: 768px) {
.container {
padding: 10px;
}
header h1 {
font-size: 2rem;
}
main {
padding: 20px;
}
.info-grid {
grid-template-columns: 1fr;
}
}

View file

@ -0,0 +1,186 @@
//go:build !debug
// +build !debug
package webui
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
)
func TestStaticFiles_ProdMode_EmbeddedFiles(t *testing.T) {
logger := createTestLogger()
// Test that the embedded files system is working
// Note: In production mode, we can't easily create test files
// so we test the behavior with what's available
// Test serveFile function with various paths
testCases := []struct {
path string
expectedStatus int
description string
}{
{"/", http.StatusOK, "root path should serve index.html if available"},
{"/index.html", http.StatusOK, "index.html should be available if embedded"},
{"/style.css", http.StatusOK, "style.css should be available if embedded"},
{"/nonexistent.txt", http.StatusNotFound, "non-existent files should return 404"},
{"/subdir/nonexistent.html", http.StatusNotFound, "non-existent nested files should return 404"},
}
for _, tc := range testCases {
t.Run(strings.ReplaceAll(tc.path, "/", "_"), func(t *testing.T) {
req := httptest.NewRequest("GET", tc.path, nil)
rec := httptest.NewRecorder()
serveFile(rec, req, logger)
// For embedded files, we expect either 200 (if file exists) or 404 (if not)
if rec.Code != http.StatusOK && rec.Code != http.StatusNotFound {
t.Errorf("Expected status 200 or 404 for %s, got %d", tc.path, rec.Code)
}
// Check that known files return expected status if they exist
if (tc.path == "/" || tc.path == "/index.html") && rec.Code == http.StatusOK {
// Should have HTML content type
contentType := rec.Header().Get("Content-Type")
if !strings.Contains(contentType, "text/html") {
t.Logf("Note: Content-Type for %s is %s (might not contain text/html)", tc.path, contentType)
}
}
})
}
}
func TestStaticFiles_ProdMode_SetupStaticHandler(t *testing.T) {
// Test that setupStaticHandler works in production mode
mux := http.NewServeMux()
// This should not panic
setupStaticHandler(mux)
server := httptest.NewServer(mux)
defer server.Close()
// Test static handler route
resp, err := http.Get(server.URL + "/static/style.css")
if err != nil {
t.Fatalf("Error requesting static file: %v", err)
}
defer resp.Body.Close()
// Should return either 200 (if file exists) or 404 (if not embedded)
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusNotFound {
t.Errorf("Expected status 200 or 404 for static file, got %d", resp.StatusCode)
}
}
func TestStaticFiles_ProdMode_PathTraversal(t *testing.T) {
logger := createTestLogger()
// Test path traversal attempts in production mode
pathTraversalTests := []string{
"/../sensitive.txt",
"/../../etc/passwd",
"/..\\sensitive.txt",
"/static/../../../etc/passwd",
"/static/../../config.json",
}
for _, path := range pathTraversalTests {
t.Run(strings.ReplaceAll(path, "/", "_"), func(t *testing.T) {
req := httptest.NewRequest("GET", path, nil)
rec := httptest.NewRecorder()
serveFile(rec, req, logger)
// Should return 404 for path traversal attempts
if rec.Code != http.StatusNotFound {
t.Errorf("Expected status 404 for path traversal attempt %s, got %d", path, rec.Code)
}
// Should not contain any system file content
body := rec.Body.String()
if strings.Contains(body, "root:") || strings.Contains(body, "/bin/") {
t.Errorf("Path traversal might be successful for %s - system content detected", path)
}
})
}
}
func TestStaticFiles_ProdMode_ContentTypes(t *testing.T) {
logger := createTestLogger()
// Test that proper content types are set for different file types
testCases := []struct {
path string
expectedContentType string
}{
{"/index.html", "text/html"},
{"/style.css", "text/css"},
{"/script.js", "text/javascript"},
{"/data.json", "application/json"},
{"/image.png", "image/png"},
{"/favicon.ico", "image/x-icon"},
}
for _, tc := range testCases {
t.Run(strings.ReplaceAll(tc.path, "/", "_"), func(t *testing.T) {
req := httptest.NewRequest("GET", tc.path, nil)
rec := httptest.NewRecorder()
serveFile(rec, req, logger)
// Only check content type if file exists (status 200)
if rec.Code == http.StatusOK {
contentType := rec.Header().Get("Content-Type")
if !strings.Contains(contentType, tc.expectedContentType) {
t.Logf("Note: Expected content type %s for %s, got %s", tc.expectedContentType, tc.path, contentType)
}
}
})
}
}
func TestStaticFiles_ProdMode_EmptyPath(t *testing.T) {
logger := createTestLogger()
// Test that empty path serves index.html
req := httptest.NewRequest("GET", "/", nil)
rec := httptest.NewRecorder()
serveFile(rec, req, logger)
// Should return either 200 (if index.html exists) or 404 (if not embedded)
if rec.Code != http.StatusOK && rec.Code != http.StatusNotFound {
t.Errorf("Expected status 200 or 404 for root path, got %d", rec.Code)
}
// If successful, should have appropriate content type
if rec.Code == http.StatusOK {
contentType := rec.Header().Get("Content-Type")
if contentType == "" {
t.Logf("Note: No Content-Type header set for root path")
}
}
}
func TestStaticFiles_ProdMode_EmbeddedFileSystem(t *testing.T) {
// Test that the embedded file system can be accessed
// This is a basic test to ensure the embed directive works
// Try to read from embedded FS directly
_, err := staticFiles.ReadFile("static/index.html")
if err != nil {
// This is expected if the file doesn't exist in embedded FS
t.Logf("Note: index.html not found in embedded FS: %v", err)
}
// Test that we can at least access the embedded FS without panic
_, err = staticFiles.ReadFile("static/nonexistent.txt")
if err == nil {
t.Error("Expected error when reading non-existent file from embedded FS")
}
}

View file

@ -0,0 +1,272 @@
//go:build debug
// +build debug
package webui
import (
"fmt"
"io"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
)
func TestStaticFiles_DevMode_ServeFile(t *testing.T) {
logger := createTestLogger()
// Create temporary test files
tempDir := t.TempDir()
staticDir := filepath.Join(tempDir, "src", "webui", "static")
err := os.MkdirAll(staticDir, 0755)
if err != nil {
t.Fatalf("Failed to create temp static dir: %v", err)
}
// Create test files
testFiles := map[string]string{
"index.html": "<html><body>Test Index</body></html>",
"style.css": "body { background: white; }",
"script.js": "console.log('test');",
"image.png": "fake png data",
"data.json": `{"test": "data"}`,
"favicon.ico": "fake ico data",
}
for filename, content := range testFiles {
filePath := filepath.Join(staticDir, filename)
err := os.WriteFile(filePath, []byte(content), 0644)
if err != nil {
t.Fatalf("Failed to create test file %s: %v", filename, err)
}
}
// Change working directory temporarily
originalWd, err := os.Getwd()
if err != nil {
t.Fatalf("Failed to get working directory: %v", err)
}
defer os.Chdir(originalWd)
err = os.Chdir(tempDir)
if err != nil {
t.Fatalf("Failed to change working directory: %v", err)
}
// Test serveFile function
testCases := []struct {
path string
expectedStatus int
expectedContentType string
expectedContent string
}{
{"/", http.StatusOK, "text/html", testFiles["index.html"]},
{"/index.html", http.StatusOK, "text/html", testFiles["index.html"]},
{"/style.css", http.StatusOK, "text/css", testFiles["style.css"]},
{"/script.js", http.StatusOK, "text/javascript", testFiles["script.js"]},
{"/data.json", http.StatusOK, "application/json", testFiles["data.json"]},
{"/nonexistent.txt", http.StatusNotFound, "", ""},
{"/subdir/nonexistent.html", http.StatusNotFound, "", ""},
}
for _, tc := range testCases {
t.Run(fmt.Sprintf("Path_%s", strings.ReplaceAll(tc.path, "/", "_")), func(t *testing.T) {
req := httptest.NewRequest("GET", tc.path, nil)
rec := httptest.NewRecorder()
serveFile(rec, req, logger)
if rec.Code != tc.expectedStatus {
t.Errorf("Expected status %d, got %d", tc.expectedStatus, rec.Code)
}
if tc.expectedStatus == http.StatusOK {
contentType := rec.Header().Get("Content-Type")
if tc.expectedContentType != "" && !strings.Contains(contentType, tc.expectedContentType) {
t.Errorf("Expected content type to contain %s, got %s", tc.expectedContentType, contentType)
}
body := rec.Body.String()
if body != tc.expectedContent {
t.Errorf("Expected body %q, got %q", tc.expectedContent, body)
}
}
})
}
}
func TestStaticFiles_DevMode_SetupStaticHandler(t *testing.T) {
// Create temporary test files for static handler testing
tempDir := t.TempDir()
staticDir := filepath.Join(tempDir, "src", "webui", "static")
err := os.MkdirAll(staticDir, 0755)
if err != nil {
t.Fatalf("Failed to create temp static dir: %v", err)
}
// Create test CSS file
cssContent := "body { color: blue; }"
cssPath := filepath.Join(staticDir, "test.css")
err = os.WriteFile(cssPath, []byte(cssContent), 0644)
if err != nil {
t.Fatalf("Failed to create test CSS file: %v", err)
}
// Change working directory temporarily
originalWd, err := os.Getwd()
if err != nil {
t.Fatalf("Failed to get working directory: %v", err)
}
defer os.Chdir(originalWd)
err = os.Chdir(tempDir)
if err != nil {
t.Fatalf("Failed to change working directory: %v", err)
}
// Create HTTP server with static handler
mux := http.NewServeMux()
setupStaticHandler(mux)
server := httptest.NewServer(mux)
defer server.Close()
// Test static file serving
resp, err := http.Get(server.URL + "/static/test.css")
if err != nil {
t.Fatalf("Error requesting static file: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Errorf("Expected status 200, got %d", resp.StatusCode)
}
body, err := io.ReadAll(resp.Body)
if err != nil {
t.Fatalf("Error reading response body: %v", err)
}
if string(body) != cssContent {
t.Errorf("Expected CSS content %q, got %q", cssContent, string(body))
}
// Test Content-Type header
contentType := resp.Header.Get("Content-Type")
if !strings.Contains(contentType, "text/css") {
t.Errorf("Expected Content-Type to contain text/css, got %s", contentType)
}
}
func TestStaticFiles_DevMode_PathTraversal(t *testing.T) {
logger := createTestLogger()
// Create temporary test setup
tempDir := t.TempDir()
staticDir := filepath.Join(tempDir, "src", "webui", "static")
err := os.MkdirAll(staticDir, 0755)
if err != nil {
t.Fatalf("Failed to create temp static dir: %v", err)
}
// Create a sensitive file outside static directory
sensitiveFile := filepath.Join(tempDir, "sensitive.txt")
err = os.WriteFile(sensitiveFile, []byte("sensitive data"), 0644)
if err != nil {
t.Fatalf("Failed to create sensitive file: %v", err)
}
// Change working directory temporarily
originalWd, err := os.Getwd()
if err != nil {
t.Fatalf("Failed to get working directory: %v", err)
}
defer os.Chdir(originalWd)
err = os.Chdir(tempDir)
if err != nil {
t.Fatalf("Failed to change working directory: %v", err)
}
// Test path traversal attempts
pathTraversalTests := []string{
"/../sensitive.txt",
"/../../sensitive.txt",
"/../../../etc/passwd",
"/..\\sensitive.txt",
"/static/../../../sensitive.txt",
}
for _, path := range pathTraversalTests {
t.Run(fmt.Sprintf("PathTraversal_%s", strings.ReplaceAll(path, "/", "_")), func(t *testing.T) {
req := httptest.NewRequest("GET", path, nil)
rec := httptest.NewRecorder()
serveFile(rec, req, logger)
// Should return 404 for path traversal attempts
if rec.Code != http.StatusNotFound {
t.Errorf("Expected status 404 for path traversal attempt %s, got %d", path, rec.Code)
}
// Should not contain sensitive data
body := rec.Body.String()
if strings.Contains(body, "sensitive data") {
t.Errorf("Path traversal successful for %s - sensitive data leaked", path)
}
})
}
}
func TestStaticFiles_DevMode_EmptyPath(t *testing.T) {
logger := createTestLogger()
// Create temporary test setup with index.html
tempDir := t.TempDir()
staticDir := filepath.Join(tempDir, "src", "webui", "static")
err := os.MkdirAll(staticDir, 0755)
if err != nil {
t.Fatalf("Failed to create temp static dir: %v", err)
}
indexContent := "<html><body>Index Page</body></html>"
indexPath := filepath.Join(staticDir, "index.html")
err = os.WriteFile(indexPath, []byte(indexContent), 0644)
if err != nil {
t.Fatalf("Failed to create index.html: %v", err)
}
// Change working directory temporarily
originalWd, err := os.Getwd()
if err != nil {
t.Fatalf("Failed to get working directory: %v", err)
}
defer os.Chdir(originalWd)
err = os.Chdir(tempDir)
if err != nil {
t.Fatalf("Failed to change working directory: %v", err)
}
// Test that empty path serves index.html
req := httptest.NewRequest("GET", "/", nil)
rec := httptest.NewRecorder()
serveFile(rec, req, logger)
if rec.Code != http.StatusOK {
t.Errorf("Expected status 200 for root path, got %d", rec.Code)
}
body := rec.Body.String()
if body != indexContent {
t.Errorf("Expected index content %q, got %q", indexContent, body)
}
contentType := rec.Header().Get("Content-Type")
if !strings.Contains(contentType, "text/html") {
t.Errorf("Expected Content-Type to contain text/html, got %s", contentType)
}
}