Git Repository

Stackyard

Stackyard scannt lokale Entwicklungsordner und bündelt Projekte, Git-Status, TODOs, Toolchains, Docker-Compose-Setups, Datei- und Launcher-Funktionen sowie Operations-Informationen in einer lokalen WebUI.

Projektseite ↗
HTTPShttps://zanvex.de/git/stackyard.git

internal/web/web_test.go

Zum Verzeichnis
package web

import (
	"context"
	"net/http"
	"net/http/httptest"
	"os"
	"path/filepath"
	"reflect"
	"strings"
	"testing"
	"time"

	"stackyard/internal/config"
	"stackyard/internal/project"
	"stackyard/internal/storage"
)

func TestBuildFileTreeIgnoresConfiguredDirectories(t *testing.T) {
	root := t.TempDir()
	mustWriteTreeFile(t, filepath.Join(root, "README.md"))
	mustWriteTreeFile(t, filepath.Join(root, "cmd", "main.go"))
	mustWriteTreeFile(t, filepath.Join(root, "node_modules", "left-pad", "index.js"))

	nodes, err := buildFileTree(root, root, map[string]struct{}{"node_modules": {}})
	if err != nil {
		t.Fatalf("buildFileTree: %v", err)
	}

	if len(nodes) != 2 {
		t.Fatalf("expected 2 visible top-level nodes, got %d", len(nodes))
	}
	if nodes[0].Name != "cmd/" || nodes[1].Name != "README.md" {
		t.Fatalf("unexpected nodes: %+v", nodes)
	}
}

func mustWriteTreeFile(t *testing.T, path string) {
	t.Helper()
	if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
		t.Fatalf("mkdir %s: %v", path, err)
	}
	if err := os.WriteFile(path, []byte("x"), 0o644); err != nil {
		t.Fatalf("write %s: %v", path, err)
	}
}

func TestRenderMarkdownRendersBasicBlocks(t *testing.T) {
	html := renderMarkdown("# Title\n\n- one\n- two\n\n```go\nfmt.Println(1)\n```")
	for _, want := range []string{"<h1>Title</h1>", "<ul><li>one</li><li>two</li></ul>", "<pre><code>fmt.Println(1)"} {
		if !strings.Contains(html, want) {
			t.Fatalf("expected rendered markdown to contain %q, got %q", want, html)
		}
	}
}

func TestRedirectWithFlashAddsQueryParameters(t *testing.T) {
	req := httptest.NewRequest("POST", "/projects/abc/todo", nil)
	rec := httptest.NewRecorder()

	redirectWithFlash(rec, req, "/projects/abc", "success", "saved ok")

	location := rec.Header().Get("Location")
	if !strings.Contains(location, "flash_kind=success") || !strings.Contains(location, "flash=saved%20ok") {
		t.Fatalf("unexpected redirect location: %q", location)
	}
}

func TestBackToRejectsExternalRedirect(t *testing.T) {
	req := httptest.NewRequest(http.MethodPost, "/action", strings.NewReader("return_to=https%3A%2F%2Fexample.com"))
	req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
	if got := backTo(req, "/operations"); got != "/operations" {
		t.Fatalf("expected safe fallback, got %q", got)
	}
}

func TestFilterProjects(t *testing.T) {
	projects := []project.Project{
		{Name: "alpha", PrimaryType: "go", Ports: []int{8080}},
		{Name: "beta", MiseTools: []string{"node 22"}},
	}

	got := filterProjects(projects, "8080")
	if !reflect.DeepEqual(got, []project.Project{projects[0]}) {
		t.Fatalf("unexpected filter result for port: %#v", got)
	}

	got = filterProjects(projects, "node 22")
	if !reflect.DeepEqual(got, []project.Project{projects[1]}) {
		t.Fatalf("unexpected filter result for mise: %#v", got)
	}
}

func TestFilterByStateAndFavorites(t *testing.T) {
	projects := []project.Project{
		{Name: "alpha", State: project.StateActive},
		{Name: "beta", State: project.StatePaused, Favorite: true},
	}

	got := filterByState(projects, "paused")
	if !reflect.DeepEqual(got, []project.Project{projects[1]}) {
		t.Fatalf("unexpected state filter result: %#v", got)
	}

	got = filterFavorites(projects)
	if !reflect.DeepEqual(got, []project.Project{projects[1]}) {
		t.Fatalf("unexpected favorites filter result: %#v", got)
	}
}

func TestEditorFileRoundTrip(t *testing.T) {
	root := t.TempDir()
	path := filepath.Join(root, "internal", "main.go")
	mustWriteTreeFile(t, path)

	if err := writeEditorFile(root, "internal/main.go", "package main\n"); err != nil {
		t.Fatalf("writeEditorFile: %v", err)
	}
	got, err := readEditorFile(root, "internal/main.go")
	if err != nil {
		t.Fatalf("readEditorFile: %v", err)
	}
	if got != "package main\n" {
		t.Fatalf("unexpected editor content: %q", got)
	}
}

func TestEditorRejectsPathOutsideProject(t *testing.T) {
	root := t.TempDir()
	if _, err := resolveEditorFile(root, "../secret.txt"); err == nil {
		t.Fatal("expected path traversal to be rejected")
	}
}

func TestEditorRejectsBinaryFile(t *testing.T) {
	root := t.TempDir()
	path := filepath.Join(root, "image.bin")
	if err := os.WriteFile(path, []byte{'x', 0, 'y'}, 0o644); err != nil {
		t.Fatal(err)
	}
	if _, err := readEditorFile(root, "image.bin"); err == nil {
		t.Fatal("expected binary file to be rejected")
	}
}

func TestEditorRejectsProtectedFiles(t *testing.T) {
	root := t.TempDir()
	for _, name := range []string{".env", "server.key", "go.sum", "package-lock.json"} {
		path := filepath.Join(root, name)
		if err := os.WriteFile(path, []byte("secret"), 0o644); err != nil {
			t.Fatal(err)
		}
		if err := writeEditorFile(root, name, "changed"); err == nil {
			t.Fatalf("expected %s to be protected", name)
		}
	}
}

func TestCSRFGuardRejectsCrossSitePost(t *testing.T) {
	called := false
	handler := csrfGuard(http.HandlerFunc(func(http.ResponseWriter, *http.Request) { called = true }))
	req := httptest.NewRequest(http.MethodPost, "http://stackyard.local/collections/create", nil)
	req.Header.Set("Origin", "https://attacker.example")
	rec := httptest.NewRecorder()
	handler.ServeHTTP(rec, req)
	if rec.Code != http.StatusForbidden || called {
		t.Fatalf("expected cross-site POST rejection, status=%d called=%v", rec.Code, called)
	}
}

func TestSortProjectsByLastOpened(t *testing.T) {
	projects := []project.Project{{Name: "old", LastOpenedAt: time.Unix(1, 0)}, {Name: "new", LastOpenedAt: time.Unix(2, 0)}}
	sortProjects(projects, "opened")
	if projects[0].Name != "new" {
		t.Fatalf("unexpected sort order: %+v", projects)
	}
}

func TestFilterWithoutArchived(t *testing.T) {
	projects := []project.Project{{Name: "active", State: project.StateActive}, {Name: "old", State: project.StateArchived}}
	got := filterWithoutArchived(projects)
	if len(got) != 1 || got[0].Name != "active" {
		t.Fatalf("unexpected default state filter: %+v", got)
	}
}

func TestProjectGitShowsUnavailablePanelForMissingProjectPath(t *testing.T) {
	store, err := storage.Open(filepath.Join(t.TempDir(), "stackyard.db"))
	if err != nil {
		t.Fatalf("open store: %v", err)
	}
	t.Cleanup(func() { _ = store.Close() })

	missingPath := filepath.Join(t.TempDir(), "missing")
	current := project.Project{
		ID:       project.IDFromPath(missingPath),
		Name:     "missing",
		RootName: "DEV",
		Path:     missingPath,
		State:    project.StateActive,
		Git:      project.GitInfo{IsRepo: true},
	}
	if err := store.UpsertProjects(context.Background(), []project.Project{current}); err != nil {
		t.Fatalf("store project: %v", err)
	}

	server := NewServer(config.Config{}, store)
	req := httptest.NewRequest(http.MethodGet, "/projects/"+current.ID+"/git", nil)
	rec := httptest.NewRecorder()
	server.Handler().ServeHTTP(rec, req)

	if rec.Code != http.StatusOK {
		t.Fatalf("expected status 200, got %d: %s", rec.Code, rec.Body.String())
	}
	if body := rec.Body.String(); !strings.Contains(body, "Git nicht verfügbar") || !strings.Contains(body, "Projektpfad ist momentan nicht erreichbar") {
		t.Fatalf("unexpected response body: %s", body)
	}
}

func TestDashboardWarningsPrioritizeMissingPath(t *testing.T) {
	current := project.Project{ID: "missing", Name: "Missing", Path: filepath.Join(t.TempDir(), "gone"), Git: project.GitInfo{IsRepo: true, Dirty: true}}
	warnings := dashboardWarnings(current)
	if len(warnings) != 1 || warnings[0].Level != "critical" || warnings[0].Priority != 0 {
		t.Fatalf("unexpected warnings: %+v", warnings)
	}
}

func TestProjectPageUsesFullNavigationForDashboardLink(t *testing.T) {
	var rendered strings.Builder
	if err := projectTemplate.Execute(&rendered, map[string]any{
		"Project": project.Project{ID: "project", Name: "Project", State: project.StateActive},
	}); err != nil {
		t.Fatalf("render project page: %v", err)
	}
	body := rendered.String()
	if strings.Contains(body, `hx-boost="true"`) {
		t.Fatal("project page must not boost full-page navigation because page styles differ")
	}
	if !strings.Contains(body, `<a class="crumb" href="/">← Alle Projekte</a>`) {
		t.Fatal("dashboard link is missing")
	}
}