package httpserver import ( "encoding/json" "io" "log" "net/http" "net/http/httptest" "path/filepath" "strings" "sync" "testing" "github.com/example/notify-gateway/internal/config" "github.com/example/notify-gateway/internal/divera" ) func TestExtractCatalogFromKeyedPullData(t *testing.T) { raw := map[string]any{ "data": map[string]any{ "cluster": map[string]any{"101": map[string]any{"title": "Löschzug 1"}}, "group": map[string]any{"202": map[string]any{"title": "Atemschutz"}}, "user_cluster_relation": map[string]any{"303": map[string]any{"firstname": "Max", "lastname": "Muster"}}, "vehicle": map[string]any{"404": map[string]any{"name": "HLF 20", "ric": "1234567"}}, }, } got := extractCatalog(raw) if len(got.Units) != 1 || got.Units[0].ID != 101 || got.Units[0].Label != "Löschzug 1" { t.Fatalf("units = %#v", got.Units) } if len(got.Groups) != 1 || got.Groups[0].ID != 202 { t.Fatalf("groups = %#v", got.Groups) } if len(got.Persons) != 1 || got.Persons[0].ID != 303 || got.Persons[0].Label != "Max Muster" { t.Fatalf("persons = %#v", got.Persons) } if len(got.Vehicles) != 1 || got.Vehicles[0].ID != 404 { t.Fatalf("vehicles = %#v", got.Vehicles) } } func TestExtractCatalogPrefersUCRCollectionKeyOverGlobalUserID(t *testing.T) { // Divera247 documents cluster.consumer as the user master-data collection. // It can be keyed by UCR while a nested id refers to the global User-ID. // Alarm recipients need the UCR key (9876). raw := map[string]any{ "data": map[string]any{ "cluster": map[string]any{ "id": 42, "consumer": map[string]any{ "9876": map[string]any{ "id": 1234, "firstname": "Erika", "lastname": "Mustermann", "email": "erika@example.invalid", }, }, }, }, } got := extractCatalog(raw) if len(got.Persons) != 1 { t.Fatalf("persons = %#v", got.Persons) } if got.Persons[0].ID != 9876 { t.Fatalf("expected UCR id 9876, got %#v", got.Persons[0]) } if got.Persons[0].Label != "Erika Mustermann" { t.Fatalf("unexpected label: %#v", got.Persons[0]) } } func TestParseV3PersonsUsesUserClusterRelationID(t *testing.T) { body := []byte(`[ {"id":303,"cluster_id":42,"foreign_id":"ext-1","user":{"id":999,"firstname":"Max","lastname":"Muster","email":"max@example.invalid"}} ]`) got, err := parseV3Persons(body) if err != nil { t.Fatal(err) } if len(got) != 1 || got[0].ID != 303 || got[0].Label != "Max Muster" { t.Fatalf("persons = %#v", got) } if !strings.Contains(got[0].Subtitle, "Einheit 42") { t.Fatalf("subtitle = %q", got[0].Subtitle) } } func TestExtractUCRRefsFromPullAll(t *testing.T) { raw := map[string]any{ "data": map[string]any{ "ucr_active": float64(555), "ucr": []any{ map[string]any{"id": float64(555), "cluster_id": float64(42), "name": "Einheit Nord", "shortname": "N"}, map[string]any{"id": float64(666), "cluster_id": float64(43), "name": "Einheit Süd"}, }, }, } refs := extractUCRRefs(raw) if len(refs) != 2 { t.Fatalf("refs=%#v", refs) } if extractActiveUCR(raw) != 555 { t.Fatalf("active=%d", extractActiveUCR(raw)) } byID := map[int64]ucrRef{} for _, ref := range refs { byID[ref.UCRID] = ref } if byID[555].ClusterID != 42 || byID[666].ClusterID != 43 { t.Fatalf("refs=%#v", refs) } } func TestDiveraCatalogLoadsConsumersAcrossV2UCRsWithoutV3(t *testing.T) { var mu sync.Mutex pullCalls := []string{} v3Calls := 0 upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch r.URL.Path { case "/api/v2/pull/all": mu.Lock() pullCalls = append(pullCalls, r.URL.Query().Get("ucr")) mu.Unlock() if r.URL.Query().Get("accesskey") != "test-key" { t.Fatalf("missing accesskey: %s", r.URL.RawQuery) } switch r.URL.Query().Get("ucr") { case "555": _, _ = io.WriteString(w, `{"data":{"ucr_active":555,"ucr":[{"id":555,"cluster_id":42,"name":"Einheit 42"},{"id":666,"cluster_id":43,"name":"Einheit 43"}],"cluster":{"id":42,"consumer":{"777":{"id":12,"firstname":"Lisa","lastname":"Beispiel","email":"lisa@example.invalid"}},"group":{"11":{"title":"Gruppe Nord"}},"vehicle":{}}}}`) case "666": _, _ = io.WriteString(w, `{"data":{"ucr_active":666,"ucr":[{"id":555,"cluster_id":42,"name":"Einheit 42"},{"id":666,"cluster_id":43,"name":"Einheit 43"}],"cluster":{"id":43,"consumer":{"888":{"id":13,"firstname":"Max","lastname":"Süd"}},"group":{"22":{"title":"Gruppe Süd"}},"vehicle":{}}}}`) default: http.Error(w, "unexpected ucr", http.StatusBadRequest) } case "/api/v3/user-cluster-relations": v3Calls++ http.Error(w, `{"message":"Nicht autorisiert"}`, http.StatusForbidden) default: http.NotFound(w, r) } })) defer upstream.Close() store, err := config.Open(filepath.Join(t.TempDir(), "config.json")) if err != nil { t.Fatal(err) } if err := store.Update(func(c *config.Config) error { c.Divera.BaseURL = upstream.URL c.Divera.AccessKey = "test-key" c.Divera.UCR = 555 c.Divera.DryRun = true return nil }); err != nil { t.Fatal(err) } client := divera.New(func() config.DiveraConfig { return store.Get().Divera }) srv := New(store, nil, client, log.New(io.Discard, "", 0)) req := httptest.NewRequest(http.MethodGet, "/ui/api/divera247/catalog", nil) rr := httptest.NewRecorder() srv.apiDiveraCatalog(rr, req) if rr.Code != http.StatusOK { t.Fatalf("status=%d body=%s", rr.Code, rr.Body.String()) } var out struct { Catalog diveraCatalog `json:"catalog"` Warnings []string `json:"warnings"` Diagnostics struct { V2Calls int `json:"v2_ucr_calls"` V3Calls int `json:"v3_user_calls"` Source string `json:"person_source"` } `json:"diagnostics"` } if err := json.Unmarshal(rr.Body.Bytes(), &out); err != nil { t.Fatal(err) } if len(out.Catalog.Persons) != 2 { t.Fatalf("persons=%#v warnings=%#v", out.Catalog.Persons, out.Warnings) } ids := map[int64]bool{} for _, person := range out.Catalog.Persons { ids[person.ID] = true if !strings.Contains(person.Subtitle, "Cluster-ID") { t.Fatalf("missing unit subtitle: %#v", person) } } if !ids[777] || !ids[888] { t.Fatalf("persons=%#v", out.Catalog.Persons) } if v3Calls != 0 || out.Diagnostics.V3Calls != 0 { t.Fatalf("v3 must not be called when v2 consumers exist: upstream=%d diagnostics=%d", v3Calls, out.Diagnostics.V3Calls) } if out.Diagnostics.Source != "v2 pull/all · cluster.consumer" { t.Fatalf("source=%q", out.Diagnostics.Source) } mu.Lock() defer mu.Unlock() if len(pullCalls) != 2 { t.Fatalf("pullCalls=%#v", pullCalls) } } func TestDiveraCatalogStopsV3FallbackAfterForbidden(t *testing.T) { v3Calls := 0 upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch r.URL.Path { case "/api/v2/pull/all": ucr := r.URL.Query().Get("ucr") if ucr == "555" { _, _ = io.WriteString(w, `{"data":{"ucr_active":555,"ucr":[{"id":555,"cluster_id":42,"name":"Einheit 42"},{"id":666,"cluster_id":43,"name":"Einheit 43"}],"cluster":{"id":42,"consumer":{}}}}`) } else { _, _ = io.WriteString(w, `{"data":{"ucr_active":666,"ucr":[{"id":555,"cluster_id":42,"name":"Einheit 42"},{"id":666,"cluster_id":43,"name":"Einheit 43"}],"cluster":{"id":43,"consumer":{}}}}`) } case "/api/v3/user-cluster-relations": v3Calls++ w.WriteHeader(http.StatusForbidden) _, _ = io.WriteString(w, `{"name":"Forbidden","message":"Nicht autorisiert","code":0,"status":403}`) default: http.NotFound(w, r) } })) defer upstream.Close() store, err := config.Open(filepath.Join(t.TempDir(), "config.json")) if err != nil { t.Fatal(err) } if err := store.Update(func(c *config.Config) error { c.Divera.BaseURL = upstream.URL c.Divera.AccessKey = "test-key" c.Divera.UCR = 555 c.Divera.DryRun = true return nil }); err != nil { t.Fatal(err) } client := divera.New(func() config.DiveraConfig { return store.Get().Divera }) srv := New(store, nil, client, log.New(io.Discard, "", 0)) rr := httptest.NewRecorder() srv.apiDiveraCatalog(rr, httptest.NewRequest(http.MethodGet, "/ui/api/divera247/catalog", nil)) if rr.Code != http.StatusOK { t.Fatalf("status=%d body=%s", rr.Code, rr.Body.String()) } var out struct { Warnings []string `json:"warnings"` } if err := json.Unmarshal(rr.Body.Bytes(), &out); err != nil { t.Fatal(err) } if v3Calls != 1 { t.Fatalf("expected one v3 probe, got %d", v3Calls) } if len(out.Warnings) != 1 || !strings.Contains(out.Warnings[0], "HTTP 403") { t.Fatalf("warnings=%#v", out.Warnings) } } func TestEditableConfigDoesNotExposeSessionSecrets(t *testing.T) { c := config.Default() c.Server.AdminPasswordHash = "secret-hash" c.Server.SessionSecret = "session-secret" b, err := json.Marshal(makeEditable(c)) if err != nil { t.Fatal(err) } s := string(b) if strings.Contains(s, "secret-hash") || strings.Contains(s, "session-secret") || strings.Contains(s, "admin_password_hash") || strings.Contains(s, "session_secret") { t.Fatalf("editable config leaked internal auth data: %s", s) } }