package web import ( "context" "io" "log/slog" "net/http" "net/http/httptest" "net/url" "path/filepath" "strconv" "strings" "sync/atomic" "testing" "github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/model" "github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/store" ) func TestPiHolePageAndRemovalNeverContactUnavailablePiHole(t *testing.T) { t.Parallel() var requests atomic.Int32 api := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { requests.Add(1); http.Error(w, "unavailable", 503) })) defer api.Close() ctx := context.Background() database, err := store.Open(ctx, filepath.Join(t.TempDir(), "app.db")) if err != nil { t.Fatal(err) } defer database.Close() integration, err := database.CreatePiHoleIntegration(ctx, model.PiHoleIntegration{Name: "Pi-hole", BaseURL: api.URL + "/api"}, model.PiHoleCredential{Ciphertext: []byte("secret"), Nonce: []byte("secret")}) if err != nil { t.Fatal(err) } if _, err = database.SyncPiHoleRecordLinks(ctx, integration.ID, []string{"192.168.1.10 nas.lan"}); err != nil { t.Fatal(err) } if err = database.MarkPiHoleSynced(ctx, integration.ID, "connection timed out"); err != nil { t.Fatal(err) } templates, err := loadTemplates() if err != nil { t.Fatal(err) } server := &Server{store: database, templates: templates, logger: slog.New(slog.NewTextHandler(io.Discard, nil))} session := &model.Session{CSRFToken: "csrf", Username: "admin", Role: "admin"} response := httptest.NewRecorder() server.piHolePage(response, httptest.NewRequest(http.MethodGet, "/pihole", nil), session) if response.Code != 200 || !strings.Contains(response.Body.String(), "nas.lan") || !strings.Contains(response.Body.String(), "Lokale Verbindung entfernen") { t.Fatalf("offline page unavailable: %d", response.Code) } id := strconv.FormatInt(integration.ID, 10) request := httptest.NewRequest(http.MethodPost, "/pihole/"+id+"/remove", strings.NewReader(url.Values{"csrf": {"csrf"}}.Encode())) request.Header.Set("Content-Type", "application/x-www-form-urlencoded") request.SetPathValue("id", id) response = httptest.NewRecorder() server.piHoleRemove(response, request, session) if response.Code != http.StatusSeeOther { t.Fatalf("offline removal failed: %d", response.Code) } integrations, err := database.ListPiHoleIntegrations(ctx) if err != nil || len(integrations) != 0 { t.Fatal("connection not removed") } if requests.Load() != 0 { t.Fatalf("offline page/removal contacted Pi-hole %d times", requests.Load()) } } func TestDiagnosticDownloadExcludesStoredSecretsAndFreeText(t *testing.T) { t.Parallel() ctx := context.Background() database, err := store.Open(ctx, filepath.Join(t.TempDir(), "app.db")) if err != nil { t.Fatal(err) } defer database.Close() const secret = "git-hetzner-pihole-cookie-password-secret" if err = database.SaveSoftwareUpdateCredential(ctx, secret, []byte(secret), []byte(secret)); err != nil { t.Fatal(err) } if err = database.AddAudit(ctx, "arbitrary", secret); err != nil { t.Fatal(err) } id, err := database.CreateUpdateRun(ctx) if err != nil { t.Fatal(err) } if err = database.FinishUpdateRun(ctx, id, "error", secret, secret, "https://user:"+secret+"@host/?token="+secret); err != nil { t.Fatal(err) } server := &Server{store: database, logger: slog.New(slog.NewTextHandler(io.Discard, nil))} response := httptest.NewRecorder() server.downloadDiagnostics(response, httptest.NewRequest(http.MethodGet, "/errors/diagnostics", nil), &model.Session{Role: "admin"}) if response.Code != 200 || !strings.Contains(response.Header().Get("Content-Disposition"), "attachment") || response.Header().Get("Cache-Control") != "no-store" { t.Fatalf("invalid download: %d", response.Code) } if strings.Contains(response.Body.String(), secret) { t.Fatal("secret included in download") } if !strings.Contains(response.Body.String(), `"status": "error"`) { t.Fatal("run status missing") } anonymous := httptest.NewRecorder() server.Handler().ServeHTTP(anonymous, httptest.NewRequest(http.MethodGet, "/errors/diagnostics", nil)) if anonymous.Code == 200 { t.Fatal("anonymous diagnostic access accepted") } }