package httpapi import ( "context" "encoding/json" "net/http" "net/http/httptest" "os" "path/filepath" "strings" "testing" "safesight-agent/internal/config" "safesight-agent/internal/mediaserver" ) func TestHandleConfigCandidateApplyPromotesCandidateAndBacksUpCurrent(t *testing.T) { reloadCalls := 0 msServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method == http.MethodPost && r.URL.Path == "/api/config/reload" { reloadCalls++ w.WriteHeader(http.StatusOK) _, _ = w.Write([]byte(`{"ok":true}`)) return } t.Fatalf("unexpected media-server request %s %s", r.Method, r.URL.Path) })) defer msServer.Close() ms, err := mediaserver.New(msServer.URL, 3000, 1, nil) if err != nil { t.Fatalf("new mediaserver client: %v", err) } dir := t.TempDir() cfgPath := filepath.Join(dir, "media-server.json") currentBody := []byte(`{"templates":{"tpl":{"nodes":[],"edges":[]}},"instances":[],"metadata":{"config_id":"current","config_version":"v1"}}`) candidateBody := []byte(`{"templates":{"tpl":{"nodes":[],"edges":[]}},"instances":[],"metadata":{"config_id":"candidate","config_version":"v2"}}`) if err := os.WriteFile(cfgPath, currentBody, 0o644); err != nil { t.Fatalf("write current: %v", err) } if err := os.WriteFile(cfgPath+".candidate.json", candidateBody, 0o644); err != nil { t.Fatalf("write candidate: %v", err) } s := &Server{ agentCfg: config.AgentConfig{ConfigPath: cfgPath, Token: "test-token"}, ms: ms, } req := httptest.NewRequest(http.MethodPost, "/v1/config/candidate/apply", strings.NewReader(`{}`)) req.Header.Set("Content-Type", "application/json") req.Header.Set("X-RK-Token", "test-token") rr := httptest.NewRecorder() s.handleConfigCandidateApply(rr, req) if rr.Code != http.StatusOK { t.Fatalf("status code: got %d body=%s", rr.Code, rr.Body.String()) } if reloadCalls != 1 { t.Fatalf("reload calls = %d", reloadCalls) } gotCurrent, err := os.ReadFile(cfgPath) if err != nil { t.Fatalf("read current: %v", err) } if strings.TrimSpace(string(gotCurrent)) != string(candidateBody) { t.Fatalf("current body = %s", gotCurrent) } gotLastGood, err := os.ReadFile(cfgPath + ".last_good.json") if err != nil { t.Fatalf("read last_good: %v", err) } if strings.TrimSpace(string(gotLastGood)) != string(currentBody) { t.Fatalf("last_good body = %s", gotLastGood) } if _, err := os.Stat(cfgPath + ".candidate.json"); !os.IsNotExist(err) { t.Fatalf("candidate should be removed, stat err=%v", err) } var resp map[string]any if err := json.Unmarshal(rr.Body.Bytes(), &resp); err != nil { t.Fatalf("decode response: %v", err) } if resp["ok"] != true { t.Fatalf("response = %#v", resp) } status, ok := resp["status"].(map[string]any) if !ok { t.Fatalf("status missing: %#v", resp) } metadata, ok := status["metadata"].(map[string]any) if !ok || metadata["config_id"] != "candidate" { t.Fatalf("status metadata = %#v", status["metadata"]) } candidate, ok := status["candidate"].(map[string]any) if !ok || candidate["exists"] != false { t.Fatalf("status candidate = %#v", status["candidate"]) } } func TestApplyCandidateConfigBytes(t *testing.T) { reloadCalls := 0 msServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method == http.MethodPost && r.URL.Path == "/api/config/reload" { reloadCalls++ w.WriteHeader(http.StatusOK) return } t.Fatalf("unexpected media-server request %s %s", r.Method, r.URL.Path) })) defer msServer.Close() ms, err := mediaserver.New(msServer.URL, 3000, 1, nil) if err != nil { t.Fatalf("new mediaserver client: %v", err) } dir := t.TempDir() cfgPath := filepath.Join(dir, "media-server.json") currentBody := []byte(`{"templates":{"tpl":{"nodes":[],"edges":[]}},"instances":[],"metadata":{"config_id":"current","config_version":"v1"}}`) candidateBody := []byte(`{"templates":{"tpl":{"nodes":[],"edges":[]}},"instances":[],"metadata":{"config_id":"candidate","config_version":"v2"}}`) if err := os.WriteFile(cfgPath, currentBody, 0o644); err != nil { t.Fatalf("write current: %v", err) } s := &Server{ agentCfg: config.AgentConfig{ConfigPath: cfgPath}, ms: ms, } if err := s.applyCandidateConfigBytes(context.Background(), candidateBody); err != nil { t.Fatalf("applyCandidateConfigBytes: %v", err) } if reloadCalls != 1 { t.Fatalf("reload calls = %d", reloadCalls) } gotLastGood, err := os.ReadFile(cfgPath + ".last_good.json") if err != nil { t.Fatalf("read last_good: %v", err) } if strings.TrimSpace(string(gotLastGood)) != string(currentBody) { t.Fatalf("last_good body = %s", gotLastGood) } } func TestHandleMediaRollbackRestoresPreviousConfig(t *testing.T) { reloadCalls := 0 msServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method == http.MethodPost && r.URL.Path == "/api/config/reload" { reloadCalls++ w.WriteHeader(http.StatusOK) _, _ = w.Write([]byte(`{"ok":true}`)) return } t.Fatalf("unexpected media-server request %s %s", r.Method, r.URL.Path) })) defer msServer.Close() ms, err := mediaserver.New(msServer.URL, 3000, 1, nil) if err != nil { t.Fatalf("new mediaserver client: %v", err) } dir := t.TempDir() cfgPath := filepath.Join(dir, "media-server.json") currentBody := []byte(`{"templates":{"tpl":{"nodes":[],"edges":[]}},"instances":[],"metadata":{"config_id":"current","config_version":"v2"}}`) previousBody := []byte(`{"templates":{"tpl":{"nodes":[],"edges":[]}},"instances":[],"metadata":{"config_id":"previous","config_version":"v1"}}`) if err := os.WriteFile(cfgPath, currentBody, 0o644); err != nil { t.Fatalf("write current: %v", err) } if err := os.WriteFile(cfgPath+".last_good.json", previousBody, 0o644); err != nil { t.Fatalf("write previous: %v", err) } s := &Server{ agentCfg: config.AgentConfig{ConfigPath: cfgPath, Token: "test-token"}, ms: ms, } req := httptest.NewRequest(http.MethodPost, "/v1/media-server/rollback", nil) req.Header.Set("X-RK-Token", "test-token") rr := httptest.NewRecorder() s.handleMediaRollback(rr, req) if rr.Code != http.StatusOK { t.Fatalf("status code: got %d body=%s", rr.Code, rr.Body.String()) } if reloadCalls != 1 { t.Fatalf("reload calls = %d", reloadCalls) } gotCurrent, err := os.ReadFile(cfgPath) if err != nil { t.Fatalf("read current: %v", err) } if strings.TrimSpace(string(gotCurrent)) != string(previousBody) { t.Fatalf("current body = %s", gotCurrent) } } func TestApplyRootConfigBytesRestoresPreviousWhenReloadFails(t *testing.T) { reloadCalls := 0 msServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method == http.MethodPost && r.URL.Path == "/api/config/reload" { reloadCalls++ if reloadCalls == 1 { w.WriteHeader(http.StatusInternalServerError) _, _ = w.Write([]byte(`{"error":"reload failed"}`)) return } w.WriteHeader(http.StatusOK) _, _ = w.Write([]byte(`{"ok":true}`)) return } t.Fatalf("unexpected media-server request %s %s", r.Method, r.URL.Path) })) defer msServer.Close() ms, err := mediaserver.New(msServer.URL, 3000, 1, nil) if err != nil { t.Fatalf("new mediaserver client: %v", err) } dir := t.TempDir() cfgPath := filepath.Join(dir, "media-server.json") currentBody := []byte(`{"templates":{"tpl":{"nodes":[],"edges":[]}},"instances":[],"metadata":{"config_id":"current","config_version":"v1"}}`) newBody := []byte(`{"templates":{"tpl":{"nodes":[],"edges":[]}},"instances":[],"metadata":{"config_id":"new","config_version":"v2"}}`) if err := os.WriteFile(cfgPath, currentBody, 0o644); err != nil { t.Fatalf("write current: %v", err) } s := &Server{ agentCfg: config.AgentConfig{ConfigPath: cfgPath}, ms: ms, } err = s.applyRootConfigBytes(context.Background(), newBody) if err == nil || !strings.Contains(err.Error(), "restored previous config") { t.Fatalf("applyRootConfigBytes err = %v", err) } if reloadCalls != 2 { t.Fatalf("reload calls = %d", reloadCalls) } gotCurrent, err := os.ReadFile(cfgPath) if err != nil { t.Fatalf("read current: %v", err) } if strings.TrimSpace(string(gotCurrent)) != string(currentBody) { t.Fatalf("current body = %s", gotCurrent) } }