package web_test import ( "bytes" "encoding/json" "io" "log/slog" "net/http" "net/http/httptest" "path/filepath" "strings" "testing" "vibetree/internal/database" "vibetree/internal/models" "vibetree/internal/web" ) func testApp(t *testing.T) *web.App { t.Helper() dir := t.TempDir() db, err := database.Open(filepath.Join(dir, "test.db")) if err != nil { t.Fatalf("open db: %v", err) } t.Cleanup(func() { db.Close() }) log := slog.New(slog.NewTextHandler(io.Discard, nil)) app, err := web.NewApp(db, log, false) if err != nil { t.Fatalf("new app: %v", err) } return app } // loginAs signs in and returns the session cookie for subsequent requests. func loginAs(t *testing.T, srv *httptest.Server, username, password string) *http.Cookie { t.Helper() form := "username=" + username + "&password=" + password req, _ := http.NewRequest(http.MethodPost, srv.URL+"/login", strings.NewReader(form)) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") client := &http.Client{CheckRedirect: func(req *http.Request, via []*http.Request) error { return http.ErrUseLastResponse }} res, err := client.Do(req) if err != nil { t.Fatalf("login request: %v", err) } defer res.Body.Close() if res.StatusCode != http.StatusSeeOther { t.Fatalf("expected redirect on login, got %d", res.StatusCode) } for _, c := range res.Cookies() { if c.Name == "vibetree_session" { return c } } t.Fatal("no session cookie set") return nil } func TestHealthz(t *testing.T) { app := testApp(t) srv := httptest.NewServer(app.Router()) defer srv.Close() res, err := http.Get(srv.URL + "/healthz") if err != nil { t.Fatalf("get: %v", err) } defer res.Body.Close() if res.StatusCode != http.StatusOK { t.Fatalf("expected 200, got %d", res.StatusCode) } } func TestLoginAndInventoryPage(t *testing.T) { app := testApp(t) srv := httptest.NewServer(app.Router()) defer srv.Close() cookie := loginAs(t, srv, "admin", "admin") req, _ := http.NewRequest(http.MethodGet, srv.URL+"/inventory", nil) req.AddCookie(cookie) res, err := http.DefaultClient.Do(req) if err != nil { t.Fatalf("get inventory: %v", err) } defer res.Body.Close() if res.StatusCode != http.StatusOK { t.Fatalf("expected 200, got %d", res.StatusCode) } body, _ := io.ReadAll(res.Body) if !bytes.Contains(body, []byte("Inventory")) { t.Fatalf("expected inventory page content") } } func TestUnauthenticatedRedirectsToLogin(t *testing.T) { app := testApp(t) srv := httptest.NewServer(app.Router()) defer srv.Close() client := &http.Client{CheckRedirect: func(req *http.Request, via []*http.Request) error { return http.ErrUseLastResponse }} res, err := client.Get(srv.URL + "/inventory") if err != nil { t.Fatalf("get: %v", err) } defer res.Body.Close() if res.StatusCode != http.StatusSeeOther { t.Fatalf("expected redirect to login, got %d", res.StatusCode) } } func TestFolderCreateRequiresAdmin(t *testing.T) { app := testApp(t) srv := httptest.NewServer(app.Router()) defer srv.Close() // Create a normal (non-admin) user directly via the store. normalUser := &models.User{Username: "normie", Role: models.RoleUser, Enabled: true} if err := app.Users.Create(t.Context(), normalUser, "normiepass"); err != nil { t.Fatalf("create user: %v", err) } cookie := loginAs(t, srv, "normie", "normiepass") payload := `{"name":"Should Fail"}` req, _ := http.NewRequest(http.MethodPost, srv.URL+"/app/folders", strings.NewReader(payload)) req.Header.Set("Content-Type", "application/json") req.AddCookie(cookie) res, err := http.DefaultClient.Do(req) if err != nil { t.Fatalf("post folder: %v", err) } defer res.Body.Close() if res.StatusCode != http.StatusForbidden { t.Fatalf("expected 403 for non-admin folder create, got %d", res.StatusCode) } } func TestFolderAndAssetLifecycleAsAdmin(t *testing.T) { app := testApp(t) srv := httptest.NewServer(app.Router()) defer srv.Close() cookie := loginAs(t, srv, "admin", "admin") folderID := postJSON(t, srv, cookie, "/app/folders", `{"name":"Main Office"}`)["ID"].(float64) assetPayload := `{"name":"Core Switch","item_type_id":4,"folder_id":` + itoa(int64(folderID)) + `,"status_id":1,"fields":{}}` req, _ := http.NewRequest(http.MethodPost, srv.URL+"/app/assets", strings.NewReader(assetPayload)) req.Header.Set("Content-Type", "application/json") req.AddCookie(cookie) res, err := http.DefaultClient.Do(req) if err != nil { t.Fatalf("post asset: %v", err) } defer res.Body.Close() if res.StatusCode != http.StatusOK { body, _ := io.ReadAll(res.Body) t.Fatalf("expected 200 creating asset, got %d: %s", res.StatusCode, body) } var envelope struct { Data struct { AssetID string `json:"asset_id"` } `json:"data"` } if err := json.NewDecoder(res.Body).Decode(&envelope); err != nil { t.Fatalf("decode: %v", err) } if envelope.Data.AssetID != "IT-000001" { t.Fatalf("expected auto-generated IT-000001, got %s", envelope.Data.AssetID) } } func postJSON(t *testing.T, srv *httptest.Server, cookie *http.Cookie, path, payload string) map[string]any { t.Helper() req, _ := http.NewRequest(http.MethodPost, srv.URL+path, strings.NewReader(payload)) req.Header.Set("Content-Type", "application/json") req.AddCookie(cookie) res, err := http.DefaultClient.Do(req) if err != nil { t.Fatalf("post %s: %v", path, err) } defer res.Body.Close() if res.StatusCode != http.StatusOK { body, _ := io.ReadAll(res.Body) t.Fatalf("expected 200 for %s, got %d: %s", path, res.StatusCode, body) } var envelope struct { Data map[string]any `json:"data"` } if err := json.NewDecoder(res.Body).Decode(&envelope); err != nil { t.Fatalf("decode: %v", err) } return envelope.Data } func itoa(n int64) string { if n == 0 { return "0" } neg := n < 0 if neg { n = -n } var b []byte for n > 0 { b = append([]byte{byte('0' + n%10)}, b...) n /= 10 } if neg { b = append([]byte{'-'}, b...) } return string(b) }