You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
tailscale/ipn/localapi/debug_test.go

1453 lines
36 KiB
Go

// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
//go:build !ts_omit_debug
package localapi
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/http/httptest"
"net/netip"
"strings"
"testing"
"time"
"tailscale.com/ipn"
"tailscale.com/ipn/ipnlocal"
"tailscale.com/tailcfg"
"tailscale.com/tstest"
"tailscale.com/types/netmap"
)
// mockBackendForDebug implements the subset of LocalBackend methods needed for debug tests
type mockBackendForDebug struct {
ipnlocal.NoOpBackend
getEndpointChanges func(context.Context, netip.Addr) (any, error)
setComponentDebugLogging func(string, time.Time) error
debugRebind func() error
debugReSTUN func() error
debugNotify func(ipn.Notify)
debugRotateDiscoKey func() error
setDevStateStore func(key, value string) error
netMap *netmap.NetworkMap
controlKnobs *tailcfg.ControlKnobs
}
func (m *mockBackendForDebug) GetPeerEndpointChanges(ctx context.Context, ip netip.Addr) (any, error) {
if m.getEndpointChanges != nil {
return m.getEndpointChanges(ctx, ip)
}
return nil, nil
}
func (m *mockBackendForDebug) SetComponentDebugLogging(component string, until time.Time) error {
if m.setComponentDebugLogging != nil {
return m.setComponentDebugLogging(component, until)
}
return nil
}
func (m *mockBackendForDebug) DebugRebind() error {
if m.debugRebind != nil {
return m.debugRebind()
}
return nil
}
func (m *mockBackendForDebug) DebugReSTUN() error {
if m.debugReSTUN != nil {
return m.debugReSTUN()
}
return nil
}
func (m *mockBackendForDebug) DebugNotify(n ipn.Notify) {
if m.debugNotify != nil {
m.debugNotify(n)
}
}
func (m *mockBackendForDebug) DebugRotateDiscoKey() error {
if m.debugRotateDiscoKey != nil {
return m.debugRotateDiscoKey()
}
return nil
}
func (m *mockBackendForDebug) SetDevStateStore(key, value string) error {
if m.setDevStateStore != nil {
return m.setDevStateStore(key, value)
}
return nil
}
func (m *mockBackendForDebug) NetMap() *netmap.NetworkMap {
return m.netMap
}
func (m *mockBackendForDebug) ControlKnobs() *tailcfg.ControlKnobs {
if m.controlKnobs != nil {
return m.controlKnobs
}
return &tailcfg.ControlKnobs{}
}
// TestServeDebugPeerEndpointChanges_MissingIP tests missing IP parameter
func TestServeDebugPeerEndpointChanges_MissingIP(t *testing.T) {
h := &Handler{
PermitRead: true,
b: &mockBackendForDebug{},
}
req := httptest.NewRequest("GET", "/localapi/v0/debug-peer-endpoint-changes", nil)
w := httptest.NewRecorder()
h.serveDebugPeerEndpointChanges(w, req)
if w.Code != http.StatusBadRequest {
t.Errorf("status = %d, want %d", w.Code, http.StatusBadRequest)
}
body := w.Body.String()
if !strings.Contains(body, "missing 'ip' parameter") {
t.Errorf("body = %q, want missing ip error", body)
}
}
// TestServeDebugPeerEndpointChanges_InvalidIP tests invalid IP parameter
func TestServeDebugPeerEndpointChanges_InvalidIP(t *testing.T) {
h := &Handler{
PermitRead: true,
b: &mockBackendForDebug{},
}
req := httptest.NewRequest("GET", "/localapi/v0/debug-peer-endpoint-changes?ip=invalid", nil)
w := httptest.NewRecorder()
h.serveDebugPeerEndpointChanges(w, req)
if w.Code != http.StatusBadRequest {
t.Errorf("status = %d, want %d", w.Code, http.StatusBadRequest)
}
body := w.Body.String()
if !strings.Contains(body, "invalid IP") {
t.Errorf("body = %q, want invalid IP error", body)
}
}
// TestServeDebugPeerEndpointChanges_Success tests successful endpoint changes retrieval
func TestServeDebugPeerEndpointChanges_Success(t *testing.T) {
testIP := netip.MustParseAddr("100.64.0.1")
mockChanges := map[string]interface{}{
"changes": []string{"endpoint1", "endpoint2"},
"count": 2,
}
h := &Handler{
PermitRead: true,
b: &mockBackendForDebug{
getEndpointChanges: func(ctx context.Context, ip netip.Addr) (any, error) {
if ip != testIP {
t.Errorf("ip = %v, want %v", ip, testIP)
}
return mockChanges, nil
},
},
}
req := httptest.NewRequest("GET", "/localapi/v0/debug-peer-endpoint-changes?ip=100.64.0.1", nil)
w := httptest.NewRecorder()
h.serveDebugPeerEndpointChanges(w, req)
if w.Code != http.StatusOK {
t.Errorf("status = %d, want %d", w.Code, http.StatusOK)
}
contentType := w.Header().Get("Content-Type")
if contentType != "application/json" {
t.Errorf("Content-Type = %q, want application/json", contentType)
}
var result map[string]interface{}
if err := json.NewDecoder(w.Body).Decode(&result); err != nil {
t.Fatalf("failed to decode JSON: %v", err)
}
}
// TestServeDebugPeerEndpointChanges_PermissionDenied tests permission check
func TestServeDebugPeerEndpointChanges_PermissionDenied(t *testing.T) {
h := &Handler{
PermitRead: false,
b: &mockBackendForDebug{},
}
req := httptest.NewRequest("GET", "/localapi/v0/debug-peer-endpoint-changes?ip=100.64.0.1", nil)
w := httptest.NewRecorder()
h.serveDebugPeerEndpointChanges(w, req)
if w.Code != http.StatusForbidden {
t.Errorf("status = %d, want %d", w.Code, http.StatusForbidden)
}
}
// TestServeDebugPeerEndpointChanges_BackendError tests backend error handling
func TestServeDebugPeerEndpointChanges_BackendError(t *testing.T) {
h := &Handler{
PermitRead: true,
b: &mockBackendForDebug{
getEndpointChanges: func(ctx context.Context, ip netip.Addr) (any, error) {
return nil, fmt.Errorf("backend error")
},
},
}
req := httptest.NewRequest("GET", "/localapi/v0/debug-peer-endpoint-changes?ip=100.64.0.1", nil)
w := httptest.NewRecorder()
h.serveDebugPeerEndpointChanges(w, req)
if w.Code != http.StatusInternalServerError {
t.Errorf("status = %d, want %d", w.Code, http.StatusInternalServerError)
}
}
// TestServeComponentDebugLogging_Success tests successful component logging
func TestServeComponentDebugLogging_Success(t *testing.T) {
componentSeen := ""
untilSeen := time.Time{}
h := &Handler{
PermitWrite: true,
b: &mockBackendForDebug{
setComponentDebugLogging: func(component string, until time.Time) error {
componentSeen = component
untilSeen = until
return nil
},
},
clock: tstest.Clock{},
}
req := httptest.NewRequest("POST", "/localapi/v0/component-debug-logging?component=magicsock&secs=60", nil)
w := httptest.NewRecorder()
h.serveComponentDebugLogging(w, req)
if w.Code != http.StatusOK {
t.Errorf("status = %d, want %d", w.Code, http.StatusOK)
}
if componentSeen != "magicsock" {
t.Errorf("component = %q, want magicsock", componentSeen)
}
var result struct {
Error string
}
if err := json.NewDecoder(w.Body).Decode(&result); err != nil {
t.Fatalf("failed to decode JSON: %v", err)
}
if result.Error != "" {
t.Errorf("error = %q, want empty", result.Error)
}
}
// TestServeComponentDebugLogging_PermissionDenied tests permission check
func TestServeComponentDebugLogging_PermissionDenied(t *testing.T) {
h := &Handler{
PermitWrite: false,
b: &mockBackendForDebug{},
}
req := httptest.NewRequest("POST", "/localapi/v0/component-debug-logging?component=test&secs=30", nil)
w := httptest.NewRecorder()
h.serveComponentDebugLogging(w, req)
if w.Code != http.StatusForbidden {
t.Errorf("status = %d, want %d", w.Code, http.StatusForbidden)
}
}
// TestServeComponentDebugLogging_BackendError tests backend error handling
func TestServeComponentDebugLogging_BackendError(t *testing.T) {
h := &Handler{
PermitWrite: true,
b: &mockBackendForDebug{
setComponentDebugLogging: func(component string, until time.Time) error {
return fmt.Errorf("logging error")
},
},
clock: tstest.Clock{},
}
req := httptest.NewRequest("POST", "/localapi/v0/component-debug-logging?component=test&secs=30", nil)
w := httptest.NewRecorder()
h.serveComponentDebugLogging(w, req)
if w.Code != http.StatusOK {
t.Errorf("status = %d, want %d", w.Code, http.StatusOK)
}
var result struct {
Error string
}
if err := json.NewDecoder(w.Body).Decode(&result); err != nil {
t.Fatalf("failed to decode JSON: %v", err)
}
if result.Error != "logging error" {
t.Errorf("error = %q, want 'logging error'", result.Error)
}
}
// TestServeDebugRotateDiscoKey_Success tests successful disco key rotation
func TestServeDebugRotateDiscoKey_Success(t *testing.T) {
rotateCalled := false
h := &Handler{
PermitWrite: true,
b: &mockBackendForDebug{
debugRotateDiscoKey: func() error {
rotateCalled = true
return nil
},
},
}
req := httptest.NewRequest("POST", "/localapi/v0/debug-rotate-disco-key", nil)
w := httptest.NewRecorder()
h.serveDebugRotateDiscoKey(w, req)
if w.Code != http.StatusOK {
t.Errorf("status = %d, want %d", w.Code, http.StatusOK)
}
if !rotateCalled {
t.Error("DebugRotateDiscoKey was not called")
}
body := w.Body.String()
if body != "done\n" {
t.Errorf("body = %q, want 'done\\n'", body)
}
}
// TestServeDebugRotateDiscoKey_PermissionDenied tests permission check
func TestServeDebugRotateDiscoKey_PermissionDenied(t *testing.T) {
h := &Handler{
PermitWrite: false,
b: &mockBackendForDebug{},
}
req := httptest.NewRequest("POST", "/localapi/v0/debug-rotate-disco-key", nil)
w := httptest.NewRecorder()
h.serveDebugRotateDiscoKey(w, req)
if w.Code != http.StatusForbidden {
t.Errorf("status = %d, want %d", w.Code, http.StatusForbidden)
}
}
// TestServeDebugRotateDiscoKey_MethodNotAllowed tests POST requirement
func TestServeDebugRotateDiscoKey_MethodNotAllowed(t *testing.T) {
h := &Handler{
PermitWrite: true,
b: &mockBackendForDebug{},
}
req := httptest.NewRequest("GET", "/localapi/v0/debug-rotate-disco-key", nil)
w := httptest.NewRecorder()
h.serveDebugRotateDiscoKey(w, req)
if w.Code != http.StatusMethodNotAllowed {
t.Errorf("status = %d, want %d", w.Code, http.StatusMethodNotAllowed)
}
}
// TestServeDebugRotateDiscoKey_BackendError tests backend error
func TestServeDebugRotateDiscoKey_BackendError(t *testing.T) {
h := &Handler{
PermitWrite: true,
b: &mockBackendForDebug{
debugRotateDiscoKey: func() error {
return fmt.Errorf("rotation failed")
},
},
}
req := httptest.NewRequest("POST", "/localapi/v0/debug-rotate-disco-key", nil)
w := httptest.NewRecorder()
h.serveDebugRotateDiscoKey(w, req)
if w.Code != http.StatusInternalServerError {
t.Errorf("status = %d, want %d", w.Code, http.StatusInternalServerError)
}
body := w.Body.String()
if !strings.Contains(body, "rotation failed") {
t.Errorf("body = %q, want rotation error", body)
}
}
// TestServeDevSetStateStore_Success tests successful state store set
func TestServeDevSetStateStore_Success(t *testing.T) {
keySeen := ""
valueSeen := ""
h := &Handler{
PermitWrite: true,
b: &mockBackendForDebug{
setDevStateStore: func(key, value string) error {
keySeen = key
valueSeen = value
return nil
},
},
}
req := httptest.NewRequest("POST", "/localapi/v0/dev-set-state-store?key=testkey&value=testvalue", nil)
w := httptest.NewRecorder()
h.serveDevSetStateStore(w, req)
if w.Code != http.StatusOK {
t.Errorf("status = %d, want %d", w.Code, http.StatusOK)
}
if keySeen != "testkey" {
t.Errorf("key = %q, want testkey", keySeen)
}
if valueSeen != "testvalue" {
t.Errorf("value = %q, want testvalue", valueSeen)
}
body := w.Body.String()
if body != "done\n" {
t.Errorf("body = %q, want 'done\\n'", body)
}
}
// TestServeDevSetStateStore_PermissionDenied tests permission check
func TestServeDevSetStateStore_PermissionDenied(t *testing.T) {
h := &Handler{
PermitWrite: false,
b: &mockBackendForDebug{},
}
req := httptest.NewRequest("POST", "/localapi/v0/dev-set-state-store?key=test&value=test", nil)
w := httptest.NewRecorder()
h.serveDevSetStateStore(w, req)
if w.Code != http.StatusForbidden {
t.Errorf("status = %d, want %d", w.Code, http.StatusForbidden)
}
}
// TestServeDevSetStateStore_MethodNotAllowed tests POST requirement
func TestServeDevSetStateStore_MethodNotAllowed(t *testing.T) {
h := &Handler{
PermitWrite: true,
b: &mockBackendForDebug{},
}
req := httptest.NewRequest("GET", "/localapi/v0/dev-set-state-store", nil)
w := httptest.NewRecorder()
h.serveDevSetStateStore(w, req)
if w.Code != http.StatusMethodNotAllowed {
t.Errorf("status = %d, want %d", w.Code, http.StatusMethodNotAllowed)
}
}
// TestServeDevSetStateStore_BackendError tests backend error
func TestServeDevSetStateStore_BackendError(t *testing.T) {
h := &Handler{
PermitWrite: true,
b: &mockBackendForDebug{
setDevStateStore: func(key, value string) error {
return fmt.Errorf("store error")
},
},
}
req := httptest.NewRequest("POST", "/localapi/v0/dev-set-state-store?key=test&value=test", nil)
w := httptest.NewRecorder()
h.serveDevSetStateStore(w, req)
if w.Code != http.StatusInternalServerError {
t.Errorf("status = %d, want %d", w.Code, http.StatusInternalServerError)
}
}
// TestServeDebugPacketFilterRules_Success tests successful packet filter rules retrieval
func TestServeDebugPacketFilterRules_Success(t *testing.T) {
testRules := []tailcfg.FilterRule{
{SrcIPs: []string{"100.64.0.0/10"}},
}
h := &Handler{
PermitWrite: true,
b: &mockBackendForDebug{
netMap: &netmap.NetworkMap{
PacketFilterRules: testRules,
},
},
}
req := httptest.NewRequest("GET", "/localapi/v0/debug-packet-filter-rules", nil)
w := httptest.NewRecorder()
h.serveDebugPacketFilterRules(w, req)
if w.Code != http.StatusOK {
t.Errorf("status = %d, want %d", w.Code, http.StatusOK)
}
contentType := w.Header().Get("Content-Type")
if contentType != "application/json" {
t.Errorf("Content-Type = %q, want application/json", contentType)
}
}
// TestServeDebugPacketFilterRules_NoNetmap tests nil netmap
func TestServeDebugPacketFilterRules_NoNetmap(t *testing.T) {
h := &Handler{
PermitWrite: true,
b: &mockBackendForDebug{},
}
req := httptest.NewRequest("GET", "/localapi/v0/debug-packet-filter-rules", nil)
w := httptest.NewRecorder()
h.serveDebugPacketFilterRules(w, req)
if w.Code != http.StatusNotFound {
t.Errorf("status = %d, want %d", w.Code, http.StatusNotFound)
}
body := w.Body.String()
if !strings.Contains(body, "no netmap") {
t.Errorf("body = %q, want no netmap error", body)
}
}
// TestServeDebugPacketFilterRules_PermissionDenied tests permission check
func TestServeDebugPacketFilterRules_PermissionDenied(t *testing.T) {
h := &Handler{
PermitWrite: false,
b: &mockBackendForDebug{},
}
req := httptest.NewRequest("GET", "/localapi/v0/debug-packet-filter-rules", nil)
w := httptest.NewRecorder()
h.serveDebugPacketFilterRules(w, req)
if w.Code != http.StatusForbidden {
t.Errorf("status = %d, want %d", w.Code, http.StatusForbidden)
}
}
// TestServeDebugPacketFilterMatches_Success tests successful packet filter matches retrieval
func TestServeDebugPacketFilterMatches_Success(t *testing.T) {
testFilter := []tailcfg.FilterRule{
{SrcIPs: []string{"*"}},
}
h := &Handler{
PermitWrite: true,
b: &mockBackendForDebug{
netMap: &netmap.NetworkMap{
PacketFilter: testFilter,
},
},
}
req := httptest.NewRequest("GET", "/localapi/v0/debug-packet-filter-matches", nil)
w := httptest.NewRecorder()
h.serveDebugPacketFilterMatches(w, req)
if w.Code != http.StatusOK {
t.Errorf("status = %d, want %d", w.Code, http.StatusOK)
}
contentType := w.Header().Get("Content-Type")
if contentType != "application/json" {
t.Errorf("Content-Type = %q, want application/json", contentType)
}
}
// TestServeDebugPacketFilterMatches_NoNetmap tests nil netmap
func TestServeDebugPacketFilterMatches_NoNetmap(t *testing.T) {
h := &Handler{
PermitWrite: true,
b: &mockBackendForDebug{},
}
req := httptest.NewRequest("GET", "/localapi/v0/debug-packet-filter-matches", nil)
w := httptest.NewRecorder()
h.serveDebugPacketFilterMatches(w, req)
if w.Code != http.StatusNotFound {
t.Errorf("status = %d, want %d", w.Code, http.StatusNotFound)
}
}
// TestServeDebugPacketFilterMatches_PermissionDenied tests permission check
func TestServeDebugPacketFilterMatches_PermissionDenied(t *testing.T) {
h := &Handler{
PermitWrite: false,
b: &mockBackendForDebug{},
}
req := httptest.NewRequest("GET", "/localapi/v0/debug-packet-filter-matches", nil)
w := httptest.NewRecorder()
h.serveDebugPacketFilterMatches(w, req)
if w.Code != http.StatusForbidden {
t.Errorf("status = %d, want %d", w.Code, http.StatusForbidden)
}
}
// TestServeDebugOptionalFeatures_Success tests optional features endpoint
func TestServeDebugOptionalFeatures_Success(t *testing.T) {
h := &Handler{
b: &mockBackendForDebug{},
}
req := httptest.NewRequest("GET", "/localapi/v0/debug-optional-features", nil)
w := httptest.NewRecorder()
h.serveDebugOptionalFeatures(w, req)
if w.Code != http.StatusOK {
t.Errorf("status = %d, want %d", w.Code, http.StatusOK)
}
contentType := w.Header().Get("Content-Type")
if contentType != "application/json" {
t.Errorf("Content-Type = %q, want application/json", contentType)
}
// Response should be valid JSON with Features field
var result struct {
Features []string
}
if err := json.NewDecoder(w.Body).Decode(&result); err != nil {
t.Fatalf("failed to decode JSON: %v", err)
}
}
// TestServeDebugLog_InvalidJSON tests invalid JSON body
func TestServeDebugLog_InvalidJSON(t *testing.T) {
h := &Handler{
PermitRead: true,
b: &mockBackendForDebug{},
clock: tstest.Clock{},
logf: t.Logf,
}
req := httptest.NewRequest("POST", "/localapi/v0/debug-log", bytes.NewReader([]byte("invalid json")))
w := httptest.NewRecorder()
h.serveDebugLog(w, req)
if w.Code != http.StatusBadRequest {
t.Errorf("status = %d, want %d", w.Code, http.StatusBadRequest)
}
body := w.Body.String()
if !strings.Contains(body, "invalid JSON") {
t.Errorf("body = %q, want invalid JSON error", body)
}
}
// TestServeDebugLog_Success tests successful log upload
func TestServeDebugLog_Success(t *testing.T) {
h := &Handler{
PermitRead: true,
b: &mockBackendForDebug{},
clock: tstest.Clock{},
logf: t.Logf,
}
logReq := struct {
Lines []string
Prefix string
}{
Lines: []string{"test log line 1", "test log line 2"},
Prefix: "test-prefix",
}
body, _ := json.Marshal(logReq)
req := httptest.NewRequest("POST", "/localapi/v0/debug-log", bytes.NewReader(body))
w := httptest.NewRecorder()
h.serveDebugLog(w, req)
if w.Code != http.StatusNoContent {
t.Errorf("status = %d, want %d", w.Code, http.StatusNoContent)
}
}
// TestServeDebugLog_PermissionDenied tests permission check
func TestServeDebugLog_PermissionDenied(t *testing.T) {
h := &Handler{
PermitRead: false,
b: &mockBackendForDebug{},
}
req := httptest.NewRequest("POST", "/localapi/v0/debug-log", nil)
w := httptest.NewRecorder()
h.serveDebugLog(w, req)
if w.Code != http.StatusForbidden {
t.Errorf("status = %d, want %d", w.Code, http.StatusForbidden)
}
}
// TestServeDebugLog_MethodNotAllowed tests POST requirement
func TestServeDebugLog_MethodNotAllowed(t *testing.T) {
h := &Handler{
PermitRead: true,
b: &mockBackendForDebug{},
}
req := httptest.NewRequest("GET", "/localapi/v0/debug-log", nil)
w := httptest.NewRecorder()
h.serveDebugLog(w, req)
if w.Code != http.StatusMethodNotAllowed {
t.Errorf("status = %d, want %d", w.Code, http.StatusMethodNotAllowed)
}
}
// TestServeDebugLog_DefaultPrefix tests default prefix when not provided
func TestServeDebugLog_DefaultPrefix(t *testing.T) {
h := &Handler{
PermitRead: true,
b: &mockBackendForDebug{},
clock: tstest.Clock{},
logf: t.Logf,
}
logReq := struct {
Lines []string
Prefix string
}{
Lines: []string{"test line"},
// Prefix intentionally empty
}
body, _ := json.Marshal(logReq)
req := httptest.NewRequest("POST", "/localapi/v0/debug-log", bytes.NewReader(body))
w := httptest.NewRecorder()
h.serveDebugLog(w, req)
if w.Code != http.StatusNoContent {
t.Errorf("status = %d, want %d", w.Code, http.StatusNoContent)
}
}
// TestServeDebugLog_EmptyLines tests empty lines array
func TestServeDebugLog_EmptyLines(t *testing.T) {
h := &Handler{
PermitRead: true,
b: &mockBackendForDebug{},
clock: tstest.Clock{},
logf: t.Logf,
}
logReq := struct {
Lines []string
Prefix string
}{
Lines: []string{},
Prefix: "test",
}
body, _ := json.Marshal(logReq)
req := httptest.NewRequest("POST", "/localapi/v0/debug-log", bytes.NewReader(body))
w := httptest.NewRecorder()
h.serveDebugLog(w, req)
if w.Code != http.StatusNoContent {
t.Errorf("status = %d, want %d", w.Code, http.StatusNoContent)
}
}
// TestServeDebug_MissingAction tests missing action parameter
func TestServeDebug_MissingAction(t *testing.T) {
h := &Handler{
PermitWrite: true,
b: &mockBackendForDebug{},
}
req := httptest.NewRequest("POST", "/localapi/v0/debug", nil)
w := httptest.NewRecorder()
h.serveDebug(w, req)
if w.Code != http.StatusBadRequest {
t.Errorf("status = %d, want %d", w.Code, http.StatusBadRequest)
}
body := w.Body.String()
if !strings.Contains(body, "missing parameter 'action'") {
t.Errorf("body = %q, want missing action error", body)
}
}
// TestServeDebug_UnknownAction tests unknown action
func TestServeDebug_UnknownAction(t *testing.T) {
h := &Handler{
PermitWrite: true,
b: &mockBackendForDebug{},
}
req := httptest.NewRequest("POST", "/localapi/v0/debug?action=unknown-action", nil)
w := httptest.NewRecorder()
h.serveDebug(w, req)
if w.Code != http.StatusBadRequest {
t.Errorf("status = %d, want %d", w.Code, http.StatusBadRequest)
}
body := w.Body.String()
if !strings.Contains(body, "unknown action") {
t.Errorf("body = %q, want unknown action error", body)
}
}
// TestServeDebug_PermissionDenied tests permission check
func TestServeDebug_PermissionDenied(t *testing.T) {
h := &Handler{
PermitWrite: false,
b: &mockBackendForDebug{},
}
req := httptest.NewRequest("POST", "/localapi/v0/debug?action=rebind", nil)
w := httptest.NewRecorder()
h.serveDebug(w, req)
if w.Code != http.StatusForbidden {
t.Errorf("status = %d, want %d", w.Code, http.StatusForbidden)
}
}
// TestServeDebug_MethodNotAllowed tests POST requirement
func TestServeDebug_MethodNotAllowed(t *testing.T) {
h := &Handler{
PermitWrite: true,
b: &mockBackendForDebug{},
}
req := httptest.NewRequest("GET", "/localapi/v0/debug", nil)
w := httptest.NewRecorder()
h.serveDebug(w, req)
if w.Code != http.StatusMethodNotAllowed {
t.Errorf("status = %d, want %d", w.Code, http.StatusMethodNotAllowed)
}
}
// TestServeDebug_RebindAction tests rebind action
func TestServeDebug_RebindAction(t *testing.T) {
rebindCalled := false
h := &Handler{
PermitWrite: true,
b: &mockBackendForDebug{
debugRebind: func() error {
rebindCalled = true
return nil
},
},
}
req := httptest.NewRequest("POST", "/localapi/v0/debug?action=rebind", nil)
w := httptest.NewRecorder()
h.serveDebug(w, req)
if w.Code != http.StatusOK {
t.Errorf("status = %d, want %d", w.Code, http.StatusOK)
}
if !rebindCalled {
t.Error("DebugRebind was not called")
}
body := w.Body.String()
if body != "done\n" {
t.Errorf("body = %q, want 'done\\n'", body)
}
}
// TestServeDebug_RestunAction tests restun action
func TestServeDebug_RestunAction(t *testing.T) {
restunCalled := false
h := &Handler{
PermitWrite: true,
b: &mockBackendForDebug{
debugReSTUN: func() error {
restunCalled = true
return nil
},
},
}
req := httptest.NewRequest("POST", "/localapi/v0/debug?action=restun", nil)
w := httptest.NewRecorder()
h.serveDebug(w, req)
if w.Code != http.StatusOK {
t.Errorf("status = %d, want %d", w.Code, http.StatusOK)
}
if !restunCalled {
t.Error("DebugReSTUN was not called")
}
}
// TestServeDebug_NotifyAction tests notify action with JSON body
func TestServeDebug_NotifyAction(t *testing.T) {
var notifySeen *ipn.Notify
h := &Handler{
PermitWrite: true,
b: &mockBackendForDebug{
debugNotify: func(n ipn.Notify) {
notifySeen = &n
},
},
}
notify := ipn.Notify{
State: ptr(ipn.Running),
}
body, _ := json.Marshal(notify)
req := httptest.NewRequest("POST", "/localapi/v0/debug", bytes.NewReader(body))
req.Header.Set("Debug-Action", "notify")
w := httptest.NewRecorder()
h.serveDebug(w, req)
if w.Code != http.StatusOK {
t.Errorf("status = %d, want %d", w.Code, http.StatusOK)
}
if notifySeen == nil {
t.Fatal("DebugNotify was not called")
}
if notifySeen.State == nil || *notifySeen.State != ipn.Running {
t.Errorf("notify state = %v, want Running", notifySeen.State)
}
}
// TestServeDebug_NotifyActionInvalidJSON tests notify with invalid JSON
func TestServeDebug_NotifyActionInvalidJSON(t *testing.T) {
h := &Handler{
PermitWrite: true,
b: &mockBackendForDebug{},
}
req := httptest.NewRequest("POST", "/localapi/v0/debug", bytes.NewReader([]byte("invalid")))
req.Header.Set("Debug-Action", "notify")
w := httptest.NewRecorder()
h.serveDebug(w, req)
if w.Code != http.StatusBadRequest {
t.Errorf("status = %d, want %d", w.Code, http.StatusBadRequest)
}
}
// TestServeDebug_RebindError tests rebind error handling
func TestServeDebug_RebindError(t *testing.T) {
h := &Handler{
PermitWrite: true,
b: &mockBackendForDebug{
debugRebind: func() error {
return fmt.Errorf("rebind failed")
},
},
}
req := httptest.NewRequest("POST", "/localapi/v0/debug?action=rebind", nil)
w := httptest.NewRecorder()
h.serveDebug(w, req)
if w.Code != http.StatusBadRequest {
t.Errorf("status = %d, want %d", w.Code, http.StatusBadRequest)
}
body := w.Body.String()
if !strings.Contains(body, "rebind failed") {
t.Errorf("body = %q, want rebind error", body)
}
}
// TestServeDebug_RotateDiscoKeyAction tests rotate-disco-key action
func TestServeDebug_RotateDiscoKeyAction(t *testing.T) {
rotateCalled := false
h := &Handler{
PermitWrite: true,
b: &mockBackendForDebug{
debugRotateDiscoKey: func() error {
rotateCalled = true
return nil
},
},
}
req := httptest.NewRequest("POST", "/localapi/v0/debug?action=rotate-disco-key", nil)
w := httptest.NewRecorder()
h.serveDebug(w, req)
if w.Code != http.StatusOK {
t.Errorf("status = %d, want %d", w.Code, http.StatusOK)
}
if !rotateCalled {
t.Error("DebugRotateDiscoKey was not called")
}
}
// TestServeDebug_RotateDiscoKeyError tests rotate-disco-key error
func TestServeDebug_RotateDiscoKeyError(t *testing.T) {
h := &Handler{
PermitWrite: true,
b: &mockBackendForDebug{
debugRotateDiscoKey: func() error {
return fmt.Errorf("rotation error")
},
},
}
req := httptest.NewRequest("POST", "/localapi/v0/debug?action=rotate-disco-key", nil)
w := httptest.NewRecorder()
h.serveDebug(w, req)
if w.Code != http.StatusBadRequest {
t.Errorf("status = %d, want %d", w.Code, http.StatusBadRequest)
}
}
// ptr is a helper to create a pointer to a value
func ptr[T any](v T) *T {
return &v
}
// TestDebugEventError_JSON tests debugEventError JSON encoding
func TestDebugEventError_JSON(t *testing.T) {
err := debugEventError{Error: "test error"}
data, jsonErr := json.Marshal(err)
if jsonErr != nil {
t.Fatalf("failed to marshal: %v", jsonErr)
}
var decoded debugEventError
if jsonErr := json.Unmarshal(data, &decoded); jsonErr != nil {
t.Fatalf("failed to unmarshal: %v", jsonErr)
}
if decoded.Error != "test error" {
t.Errorf("error = %q, want 'test error'", decoded.Error)
}
}
// TestServeDebugPeerEndpointChanges_IPv6 tests IPv6 address
func TestServeDebugPeerEndpointChanges_IPv6(t *testing.T) {
testIP := netip.MustParseAddr("fd7a:115c::1")
h := &Handler{
PermitRead: true,
b: &mockBackendForDebug{
getEndpointChanges: func(ctx context.Context, ip netip.Addr) (any, error) {
if ip != testIP {
t.Errorf("ip = %v, want %v", ip, testIP)
}
return map[string]string{"status": "ok"}, nil
},
},
}
req := httptest.NewRequest("GET", "/localapi/v0/debug-peer-endpoint-changes?ip=fd7a:115c::1", nil)
w := httptest.NewRecorder()
h.serveDebugPeerEndpointChanges(w, req)
if w.Code != http.StatusOK {
t.Errorf("status = %d, want %d", w.Code, http.StatusOK)
}
}
// TestServeComponentDebugLogging_ZeroSeconds tests zero seconds duration
func TestServeComponentDebugLogging_ZeroSeconds(t *testing.T) {
h := &Handler{
PermitWrite: true,
b: &mockBackendForDebug{},
clock: tstest.Clock{},
}
req := httptest.NewRequest("POST", "/localapi/v0/component-debug-logging?component=test&secs=0", nil)
w := httptest.NewRecorder()
h.serveComponentDebugLogging(w, req)
if w.Code != http.StatusOK {
t.Errorf("status = %d, want %d", w.Code, http.StatusOK)
}
}
// TestServeComponentDebugLogging_InvalidSeconds tests invalid seconds value
func TestServeComponentDebugLogging_InvalidSeconds(t *testing.T) {
h := &Handler{
PermitWrite: true,
b: &mockBackendForDebug{},
clock: tstest.Clock{},
}
// Invalid secs value should default to 0
req := httptest.NewRequest("POST", "/localapi/v0/component-debug-logging?component=test&secs=invalid", nil)
w := httptest.NewRecorder()
h.serveComponentDebugLogging(w, req)
if w.Code != http.StatusOK {
t.Errorf("status = %d, want %d", w.Code, http.StatusOK)
}
}
// TestServeDebugPacketFilterRules_EmptyRules tests empty packet filter rules
func TestServeDebugPacketFilterRules_EmptyRules(t *testing.T) {
h := &Handler{
PermitWrite: true,
b: &mockBackendForDebug{
netMap: &netmap.NetworkMap{
PacketFilterRules: []tailcfg.FilterRule{},
},
},
}
req := httptest.NewRequest("GET", "/localapi/v0/debug-packet-filter-rules", nil)
w := httptest.NewRecorder()
h.serveDebugPacketFilterRules(w, req)
if w.Code != http.StatusOK {
t.Errorf("status = %d, want %d", w.Code, http.StatusOK)
}
// Should return valid JSON even for empty rules
var rules []tailcfg.FilterRule
if err := json.NewDecoder(w.Body).Decode(&rules); err != nil {
t.Fatalf("failed to decode JSON: %v", err)
}
if len(rules) != 0 {
t.Errorf("len(rules) = %d, want 0", len(rules))
}
}
// TestServeDebugPacketFilterMatches_EmptyFilter tests empty packet filter
func TestServeDebugPacketFilterMatches_EmptyFilter(t *testing.T) {
h := &Handler{
PermitWrite: true,
b: &mockBackendForDebug{
netMap: &netmap.NetworkMap{
PacketFilter: []tailcfg.FilterRule{},
},
},
}
req := httptest.NewRequest("GET", "/localapi/v0/debug-packet-filter-matches", nil)
w := httptest.NewRecorder()
h.serveDebugPacketFilterMatches(w, req)
if w.Code != http.StatusOK {
t.Errorf("status = %d, want %d", w.Code, http.StatusOK)
}
}
// TestServeDebugLog_LargeLogRequest tests large number of log lines
func TestServeDebugLog_LargeLogRequest(t *testing.T) {
h := &Handler{
PermitRead: true,
b: &mockBackendForDebug{},
clock: tstest.Clock{},
logf: func(format string, args ...any) {}, // Discard logs
}
// Create 100 log lines
lines := make([]string, 100)
for i := range lines {
lines[i] = fmt.Sprintf("log line %d", i)
}
logReq := struct {
Lines []string
Prefix string
}{
Lines: lines,
Prefix: "large-test",
}
body, _ := json.Marshal(logReq)
req := httptest.NewRequest("POST", "/localapi/v0/debug-log", bytes.NewReader(body))
w := httptest.NewRecorder()
h.serveDebugLog(w, req)
if w.Code != http.StatusNoContent {
t.Errorf("status = %d, want %d", w.Code, http.StatusNoContent)
}
}
// TestServeDebugOptionalFeatures_ResponseStructure tests response structure
func TestServeDebugOptionalFeatures_ResponseStructure(t *testing.T) {
h := &Handler{
b: &mockBackendForDebug{},
}
req := httptest.NewRequest("GET", "/localapi/v0/debug-optional-features", nil)
w := httptest.NewRecorder()
h.serveDebugOptionalFeatures(w, req)
if w.Code != http.StatusOK {
t.Errorf("status = %d, want %d", w.Code, http.StatusOK)
}
// Verify response can be decoded
var response map[string]interface{}
if err := json.NewDecoder(w.Body).Decode(&response); err != nil {
t.Fatalf("failed to decode response: %v", err)
}
// Should have Features field
if _, ok := response["Features"]; !ok {
t.Error("response missing 'Features' field")
}
}
// TestServeDevSetStateStore_EmptyValue tests empty value parameter
func TestServeDevSetStateStore_EmptyValue(t *testing.T) {
keySeen := ""
valueSeen := "not-empty"
h := &Handler{
PermitWrite: true,
b: &mockBackendForDebug{
setDevStateStore: func(key, value string) error {
keySeen = key
valueSeen = value
return nil
},
},
}
req := httptest.NewRequest("POST", "/localapi/v0/dev-set-state-store?key=testkey&value=", nil)
w := httptest.NewRecorder()
h.serveDevSetStateStore(w, req)
if w.Code != http.StatusOK {
t.Errorf("status = %d, want %d", w.Code, http.StatusOK)
}
if keySeen != "testkey" {
t.Errorf("key = %q, want testkey", keySeen)
}
if valueSeen != "" {
t.Errorf("value = %q, want empty", valueSeen)
}
}
// TestServeDebugPeerEndpointChanges_ContextCancellation tests context cancellation
func TestServeDebugPeerEndpointChanges_ContextCancellation(t *testing.T) {
h := &Handler{
PermitRead: true,
b: &mockBackendForDebug{
getEndpointChanges: func(ctx context.Context, ip netip.Addr) (any, error) {
<-ctx.Done()
return nil, ctx.Err()
},
},
}
ctx, cancel := context.WithCancel(context.Background())
cancel() // Cancel immediately
req := httptest.NewRequest("GET", "/localapi/v0/debug-peer-endpoint-changes?ip=100.64.0.1", nil)
req = req.WithContext(ctx)
w := httptest.NewRecorder()
h.serveDebugPeerEndpointChanges(w, req)
if w.Code != http.StatusInternalServerError {
t.Errorf("status = %d, want %d (context cancelled)", w.Code, http.StatusInternalServerError)
}
}
// TestServeDebugLog_MultilineMessages tests log lines with newlines
func TestServeDebugLog_MultilineMessages(t *testing.T) {
h := &Handler{
PermitRead: true,
b: &mockBackendForDebug{},
clock: tstest.Clock{},
logf: t.Logf,
}
logReq := struct {
Lines []string
Prefix string
}{
Lines: []string{
"line 1\nwith newline",
"line 2\twith tab",
"line 3 normal",
},
Prefix: "multiline-test",
}
body, _ := json.Marshal(logReq)
req := httptest.NewRequest("POST", "/localapi/v0/debug-log", bytes.NewReader(body))
w := httptest.NewRecorder()
h.serveDebugLog(w, req)
if w.Code != http.StatusNoContent {
t.Errorf("status = %d, want %d", w.Code, http.StatusNoContent)
}
}
// TestServeDebugRotateDiscoKey_MultipleRotations tests multiple sequential rotations
func TestServeDebugRotateDiscoKey_MultipleRotations(t *testing.T) {
rotateCount := 0
h := &Handler{
PermitWrite: true,
b: &mockBackendForDebug{
debugRotateDiscoKey: func() error {
rotateCount++
return nil
},
},
}
// Rotate 3 times
for i := 0; i < 3; i++ {
req := httptest.NewRequest("POST", "/localapi/v0/debug-rotate-disco-key", nil)
w := httptest.NewRecorder()
h.serveDebugRotateDiscoKey(w, req)
if w.Code != http.StatusOK {
t.Errorf("rotation %d: status = %d, want %d", i, w.Code, http.StatusOK)
}
}
if rotateCount != 3 {
t.Errorf("rotateCount = %d, want 3", rotateCount)
}
}
// TestServeComponentDebugLogging_EmptyComponent tests empty component name
func TestServeComponentDebugLogging_EmptyComponent(t *testing.T) {
componentSeen := "not-empty"
h := &Handler{
PermitWrite: true,
b: &mockBackendForDebug{
setComponentDebugLogging: func(component string, until time.Time) error {
componentSeen = component
return nil
},
},
clock: tstest.Clock{},
}
req := httptest.NewRequest("POST", "/localapi/v0/component-debug-logging?component=&secs=30", nil)
w := httptest.NewRecorder()
h.serveComponentDebugLogging(w, req)
if w.Code != http.StatusOK {
t.Errorf("status = %d, want %d", w.Code, http.StatusOK)
}
if componentSeen != "" {
t.Errorf("component = %q, want empty", componentSeen)
}
}
// TestServeDebugPeerEndpointChanges_NilResult tests nil result from backend
func TestServeDebugPeerEndpointChanges_NilResult(t *testing.T) {
h := &Handler{
PermitRead: true,
b: &mockBackendForDebug{
getEndpointChanges: func(ctx context.Context, ip netip.Addr) (any, error) {
return nil, nil
},
},
}
req := httptest.NewRequest("GET", "/localapi/v0/debug-peer-endpoint-changes?ip=100.64.0.1", nil)
w := httptest.NewRecorder()
h.serveDebugPeerEndpointChanges(w, req)
if w.Code != http.StatusOK {
t.Errorf("status = %d, want %d", w.Code, http.StatusOK)
}
// Should encode null in JSON
body := w.Body.String()
if !strings.Contains(body, "null") {
t.Errorf("body = %q, want null", body)
}
}