Files
vibetree/internal/web/web_test.go
T
Owen 218687a1ee Initial implementation of inventree
A small IT asset inventory system built around a nested folder tree,
matching the project brief: Go + html/template + Bootstrap 5 + vanilla JS,
SQLite storage (WAL, FKs, migrations), embedded templates/static assets,
single compiled binary.

- AJAX-driven inventory tree: lazy-loaded folder expansion with per-user
  persisted expand state, drag-and-drop move for folders/assets, safe
  delete-with-relocate-or-cascade confirmation, search with breadcrumbs.
- Assets: CRUD, duplicate, move, auto-generated IT-000001-style IDs,
  CSV import (with optional folder auto-create) and export.
- Admin-defined item types with custom fields (text/long_text/number/date/
  boolean/dropdown/url/ip_address).
- Configurable statuses, session-based auth (bcrypt, secure cookies),
  admin/user roles, user management.
- CLI: serve, version, database migrate, database backup; /healthz;
  graceful shutdown; Dockerfile/docker-compose/systemd unit; Go test suite.
2026-08-17 13:34:40 -05:00

217 lines
5.8 KiB
Go

package web_test
import (
"bytes"
"encoding/json"
"io"
"log/slog"
"net/http"
"net/http/httptest"
"path/filepath"
"strings"
"testing"
"inventree/internal/database"
"inventree/internal/models"
"inventree/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 == "inventree_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)
}