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.
This commit is contained in:
@@ -0,0 +1,216 @@
|
||||
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)
|
||||
}
|
||||
Reference in New Issue
Block a user