package httpapi import ( "context" "encoding/json" "net/http" "testing" "time" "cloudipvalidator/internal/db" ) // TestScanFloatingIPsEndpoint proves POST /api/v1/admin/ips/scan?wait=true // (the synchronous form) only queues floating IPs that are currently // unassociated in OpenStack and answers with the classic counters body. func TestScanFloatingIPsEndpoint(t *testing.T) { fc, _, _, mock := newConfigTestHarness(t) mock.Seed("fip-free", "5.5.5.5", "svc-project") mock.SeedWithPort("fip-occupied", "6.6.6.6", "svc-project", "some-port") resp, body := fc.do(http.MethodPost, "/api/v1/admin/ips/scan?wait=true", nil) if resp.StatusCode != http.StatusOK { t.Fatalf("scan: status=%d body=%s", resp.StatusCode, body) } var scanResp scanIPsResponse if err := json.Unmarshal(body, &scanResp); err != nil { t.Fatalf("unmarshal scan response: %v", err) } if scanResp.ScannedFree != 1 { t.Fatalf("expected 1 free fip scanned, got %+v", scanResp) } if len(scanResp.Added) != 1 || scanResp.Added[0] != "5.5.5.5" { t.Fatalf("expected only 5.5.5.5 added, got %+v", scanResp) } _, body = fc.do(http.MethodGet, "/api/v1/admin/ips", nil) var ips []db.IPQueueItem if err := json.Unmarshal(body, &ips); err != nil { t.Fatalf("unmarshal ips: %v", err) } if len(ips) != 1 || ips[0].IPAddress != "5.5.5.5" { t.Fatalf("expected only the free address queued, got %+v", ips) } } // TestRegistryHistoryOutlivesIPDeletion proves that after an address // completes a full check cycle and is then deleted from the queue, its // history is still reachable via the registry endpoints (though the plain // /ips/{ip} endpoint now 404s), and that resubmitting the same address adds // a second, distinct cycle to the same registry entry. func TestRegistryHistoryOutlivesIPDeletion(t *testing.T) { fc, d, orch, mock := newConfigTestHarness(t) ctx := context.Background() mock.Seed("fip-1", "9.9.9.9", "svc-project") fc.do(http.MethodPost, "/api/v1/admin/config/validators", createValidatorRequest{ValidatorID: "validator-1", OSPortID: "port-1"}) fc.do(http.MethodPut, "/api/v1/admin/config/targets/web", putTargetGroupRequest{Targets: []string{"https://example.test"}}) fc.do(http.MethodPut, "/api/v1/admin/config/check-types/https", putCheckTypeRequest{Enabled: true, Targets: []string{"web"}}) fc.do(http.MethodPost, "/api/v1/agents/register", registerAgentRequest{ValidatorID: "validator-1"}) runOneCycle := func() { orch.Tick(ctx) _, body := fc.do(http.MethodGet, "/api/v1/agents/validator-1/assignment", nil) var assignment assignmentResponse if err := json.Unmarshal(body, &assignment); err != nil { t.Fatalf("unmarshal assignment: %v", err) } fc.do(http.MethodPost, "/api/v1/agents/validator-1/self-check", selfCheckRequest{ IPID: assignment.IPID, DetectedEgress: "9.9.9.9", Success: true, }) fc.do(http.MethodPost, "/api/v1/agents/validator-1/results", agentResultsRequest{ Results: []checkResultDTO{{ IPID: assignment.IPID, CheckType: "https", Target: "https://example.test", Success: true, CheckedAt: time.Now().Format(time.RFC3339Nano), }}, }) fc.do(http.MethodPost, "/api/v1/agents/validator-1/complete", agentCompleteRequest{IPID: assignment.IPID}) orch.Tick(ctx) } fc.do(http.MethodPost, "/api/v1/admin/ips", submitIPsRequest{Addresses: []string{"9.9.9.9"}}) runOneCycle() item, err := d.GetIPByAddress(ctx, "9.9.9.9") if err != nil { t.Fatalf("get ip: %v", err) } if item.State != db.IPDone { t.Fatalf("expected done after first cycle, got %s", item.State) } resp, _ := fc.do(http.MethodDelete, "/api/v1/admin/ips/9.9.9.9", nil) if resp.StatusCode != http.StatusOK { t.Fatalf("delete ip: status=%d", resp.StatusCode) } resp, _ = fc.do(http.MethodGet, "/api/v1/admin/ips/9.9.9.9", nil) if resp.StatusCode != http.StatusNotFound { t.Fatalf("expected 404 for deleted address on /ips, got %d", resp.StatusCode) } resp, body := fc.do(http.MethodGet, "/api/v1/admin/registry/9.9.9.9", nil) if resp.StatusCode != http.StatusOK { t.Fatalf("registry history: status=%d body=%s", resp.StatusCode, body) } var hist struct { Registry registryDTO `json:"registry"` Checks []db.Check `json:"checks"` } if err := json.Unmarshal(body, &hist); err != nil { t.Fatalf("unmarshal registry history: %v", err) } if hist.Registry.TotalCycles != 1 { t.Fatalf("expected 1 retained cycle, got %+v", hist.Registry) } if len(hist.Checks) == 0 { t.Fatalf("expected retained check history, got none") } if hist.Registry.InQueue { t.Fatalf("expected registry entry to report not-in-queue after delete, got %+v", hist.Registry) } // Resubmitting starts a second, distinct cycle on the same registry // entry rather than colliding with the first. mock.Seed("fip-1", "9.9.9.9", "svc-project") fc.do(http.MethodPost, "/api/v1/admin/ips", submitIPsRequest{Addresses: []string{"9.9.9.9"}}) runOneCycle() resp, body = fc.do(http.MethodGet, "/api/v1/admin/registry/9.9.9.9", nil) if resp.StatusCode != http.StatusOK { t.Fatalf("registry history (2nd): status=%d body=%s", resp.StatusCode, body) } if err := json.Unmarshal(body, &hist); err != nil { t.Fatalf("unmarshal registry history (2nd): %v", err) } if hist.Registry.TotalCycles != 2 { t.Fatalf("expected 2 retained cycles after resubmission, got %+v", hist.Registry) } if !hist.Registry.InQueue { t.Fatalf("expected registry entry to report in-queue again, got %+v", hist.Registry) } _, body = fc.do(http.MethodGet, "/api/v1/admin/registry", nil) var all []registryDTO if err := json.Unmarshal(body, &all); err != nil { t.Fatalf("unmarshal registry list: %v", err) } if len(all) != 1 || all[0].IPAddress != "9.9.9.9" { t.Fatalf("expected a single registry entry for the address, got %+v", all) } }