diff --git a/internal/api/audit_handlers_test.go b/internal/api/audit_handlers_test.go new file mode 100644 index 0000000..c9e06c4 --- /dev/null +++ b/internal/api/audit_handlers_test.go @@ -0,0 +1,106 @@ +package api + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/go-chi/chi/v5" +) + +func newAuditRouter(s *Server) http.Handler { + r := chi.NewRouter() + r.Get("/api/audit/completeness", s.HandleAuditCompleteness) + r.Get("/api/audit/completeness/{partNumber}", s.HandleAuditItemDetail) + return r +} + +func TestHandleAuditCompletenessEmpty(t *testing.T) { + s := newTestServerWithSchemas(t) + router := newAuditRouter(s) + + req := httptest.NewRequest("GET", "/api/audit/completeness", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("status: got %d, want %d; body: %s", w.Code, http.StatusOK, w.Body.String()) + } +} + +func TestHandleAuditCompleteness(t *testing.T) { + s := newTestServerWithSchemas(t) + router := newAuditRouter(s) + + createItemDirect(t, s, "AUD-001", "audit item 1", nil) + createItemDirect(t, s, "AUD-002", "audit item 2", nil) + + req := httptest.NewRequest("GET", "/api/audit/completeness", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("status: got %d, want %d; body: %s", w.Code, http.StatusOK, w.Body.String()) + } + + var resp map[string]any + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { + t.Fatalf("decoding response: %v", err) + } + // Should have items array + items, ok := resp["items"] + if !ok { + t.Fatal("response missing 'items' key") + } + itemList, ok := items.([]any) + if !ok { + t.Fatal("'items' is not an array") + } + if len(itemList) < 2 { + t.Errorf("expected at least 2 audit items, got %d", len(itemList)) + } +} + +func TestHandleAuditItemDetail(t *testing.T) { + s := newTestServerWithSchemas(t) + router := newAuditRouter(s) + + cost := 50.0 + createItemDirect(t, s, "AUDDET-001", "audit detail item", &cost) + + req := httptest.NewRequest("GET", "/api/audit/completeness/AUDDET-001", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("status: got %d, want %d; body: %s", w.Code, http.StatusOK, w.Body.String()) + } + + var resp map[string]any + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { + t.Fatalf("decoding response: %v", err) + } + if resp["part_number"] != "AUDDET-001" { + t.Errorf("part_number: got %v, want %q", resp["part_number"], "AUDDET-001") + } + if _, ok := resp["score"]; !ok { + t.Error("response missing 'score' field") + } + if _, ok := resp["tier"]; !ok { + t.Error("response missing 'tier' field") + } +} + +func TestHandleAuditItemDetailNotFound(t *testing.T) { + s := newTestServerWithSchemas(t) + router := newAuditRouter(s) + + req := httptest.NewRequest("GET", "/api/audit/completeness/NOPE-999", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusNotFound { + t.Errorf("status: got %d, want %d", w.Code, http.StatusNotFound) + } +} diff --git a/internal/api/auth_handlers_test.go b/internal/api/auth_handlers_test.go new file mode 100644 index 0000000..323a437 --- /dev/null +++ b/internal/api/auth_handlers_test.go @@ -0,0 +1,206 @@ +package api + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/go-chi/chi/v5" + "github.com/kindredsystems/silo/internal/auth" + "github.com/kindredsystems/silo/internal/db" + "github.com/kindredsystems/silo/internal/schema" + "github.com/kindredsystems/silo/internal/testutil" + "github.com/rs/zerolog" +) + +// newAuthTestServer creates a Server with a real auth service (for token tests). +func newAuthTestServer(t *testing.T) *Server { + t.Helper() + pool := testutil.MustConnectTestPool(t) + database := db.NewFromPool(pool) + users := db.NewUserRepository(database) + tokens := db.NewTokenRepository(database) + authSvc := auth.NewService(zerolog.Nop(), users, tokens) + broker := NewBroker(zerolog.Nop()) + state := NewServerState(zerolog.Nop(), nil, broker) + return NewServer( + zerolog.Nop(), + database, + map[string]*schema.Schema{}, + "", // schemasDir + nil, // storage + authSvc, // authService + nil, // sessionManager + nil, // oidcBackend + nil, // authConfig + broker, + state, + ) +} + +// ensureTestUser creates a user in the DB and returns their ID. +func ensureTestUser(t *testing.T, s *Server, username string) string { + t.Helper() + u := &db.User{ + Username: username, + DisplayName: "Test " + username, + Email: username + "@test.local", + AuthSource: "local", + Role: "admin", + } + users := db.NewUserRepository(s.db) + if err := users.Upsert(context.Background(), u); err != nil { + t.Fatalf("upserting user: %v", err) + } + return u.ID +} + +func newAuthRouter(s *Server) http.Handler { + r := chi.NewRouter() + r.Get("/api/auth/me", s.HandleGetCurrentUser) + r.Post("/api/auth/tokens", s.HandleCreateToken) + r.Get("/api/auth/tokens", s.HandleListTokens) + r.Delete("/api/auth/tokens/{id}", s.HandleRevokeToken) + r.Get("/api/auth/config", s.HandleAuthConfig) + return r +} + +func TestHandleGetCurrentUser(t *testing.T) { + s := newTestServer(t) + router := newAuthRouter(s) + + req := authRequest(httptest.NewRequest("GET", "/api/auth/me", nil)) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("status: got %d, want %d; body: %s", w.Code, http.StatusOK, w.Body.String()) + } + + var resp map[string]any + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { + t.Fatalf("decoding response: %v", err) + } + if resp["username"] != "testadmin" { + t.Errorf("username: got %v, want %q", resp["username"], "testadmin") + } + if resp["role"] != "admin" { + t.Errorf("role: got %v, want %q", resp["role"], "admin") + } +} + +func TestHandleGetCurrentUserUnauth(t *testing.T) { + s := newTestServer(t) + router := newAuthRouter(s) + + // No auth context + req := httptest.NewRequest("GET", "/api/auth/me", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusUnauthorized { + t.Errorf("status: got %d, want %d", w.Code, http.StatusUnauthorized) + } +} + +func TestHandleAuthConfig(t *testing.T) { + s := newTestServer(t) + router := newAuthRouter(s) + + req := httptest.NewRequest("GET", "/api/auth/config", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("status: got %d, want %d; body: %s", w.Code, http.StatusOK, w.Body.String()) + } + + var resp map[string]any + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { + t.Fatalf("decoding response: %v", err) + } + // With nil oidc and nil authConfig, both should be false + if resp["oidc_enabled"] != false { + t.Errorf("oidc_enabled: got %v, want false", resp["oidc_enabled"]) + } +} + +func TestHandleCreateAndListTokens(t *testing.T) { + s := newAuthTestServer(t) + router := newAuthRouter(s) + + // Create a user in the DB so token generation can associate + userID := ensureTestUser(t, s, "tokenuser") + + // Inject user with the DB-assigned ID + u := &auth.User{ + ID: userID, + Username: "tokenuser", + DisplayName: "Test tokenuser", + Role: auth.RoleAdmin, + AuthSource: "local", + } + + // Create token + body := `{"name":"test-token"}` + req := httptest.NewRequest("POST", "/api/auth/tokens", strings.NewReader(body)) + req = req.WithContext(auth.ContextWithUser(req.Context(), u)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusCreated { + t.Fatalf("create token status: got %d, want %d; body: %s", w.Code, http.StatusCreated, w.Body.String()) + } + + var createResp map[string]any + if err := json.Unmarshal(w.Body.Bytes(), &createResp); err != nil { + t.Fatalf("decoding create response: %v", err) + } + if createResp["token"] == nil || createResp["token"] == "" { + t.Error("expected token in response") + } + tokenID, _ := createResp["id"].(string) + + // List tokens + req = httptest.NewRequest("GET", "/api/auth/tokens", nil) + req = req.WithContext(auth.ContextWithUser(req.Context(), u)) + w = httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("list tokens status: got %d, want %d; body: %s", w.Code, http.StatusOK, w.Body.String()) + } + + var tokens []map[string]any + if err := json.Unmarshal(w.Body.Bytes(), &tokens); err != nil { + t.Fatalf("decoding list response: %v", err) + } + if len(tokens) != 1 { + t.Errorf("expected 1 token, got %d", len(tokens)) + } + + // Revoke token + req = httptest.NewRequest("DELETE", "/api/auth/tokens/"+tokenID, nil) + req = req.WithContext(auth.ContextWithUser(req.Context(), u)) + w = httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusNoContent { + t.Errorf("revoke token status: got %d, want %d; body: %s", w.Code, http.StatusNoContent, w.Body.String()) + } + + // List again — should be empty + req = httptest.NewRequest("GET", "/api/auth/tokens", nil) + req = req.WithContext(auth.ContextWithUser(req.Context(), u)) + w = httptest.NewRecorder() + router.ServeHTTP(w, req) + + json.Unmarshal(w.Body.Bytes(), &tokens) + if len(tokens) != 0 { + t.Errorf("expected 0 tokens after revoke, got %d", len(tokens)) + } +} diff --git a/internal/api/revision_handlers_test.go b/internal/api/revision_handlers_test.go new file mode 100644 index 0000000..b64d782 --- /dev/null +++ b/internal/api/revision_handlers_test.go @@ -0,0 +1,222 @@ +package api + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/go-chi/chi/v5" +) + +func newRevisionRouter(s *Server) http.Handler { + r := chi.NewRouter() + r.Route("/api/items/{partNumber}", func(r chi.Router) { + r.Get("/revisions", s.HandleListRevisions) + r.Get("/revisions/compare", s.HandleCompareRevisions) + r.Get("/revisions/{revision}", s.HandleGetRevision) + r.Post("/revisions", s.HandleCreateRevision) + r.Patch("/revisions/{revision}", s.HandleUpdateRevision) + r.Post("/revisions/{revision}/rollback", s.HandleRollbackRevision) + }) + return r +} + +func TestHandleListRevisions(t *testing.T) { + s := newTestServer(t) + router := newRevisionRouter(s) + + createItemDirect(t, s, "REV-API-001", "revision list", nil) + + req := httptest.NewRequest("GET", "/api/items/REV-API-001/revisions", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("status: got %d, want %d; body: %s", w.Code, http.StatusOK, w.Body.String()) + } + + var revisions []RevisionResponse + if err := json.Unmarshal(w.Body.Bytes(), &revisions); err != nil { + t.Fatalf("decoding response: %v", err) + } + if len(revisions) != 1 { + t.Errorf("expected 1 revision (initial), got %d", len(revisions)) + } +} + +func TestHandleListRevisionsNotFound(t *testing.T) { + s := newTestServer(t) + router := newRevisionRouter(s) + + req := httptest.NewRequest("GET", "/api/items/NOEXIST/revisions", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusNotFound { + t.Errorf("status: got %d, want %d", w.Code, http.StatusNotFound) + } +} + +func TestHandleGetRevision(t *testing.T) { + s := newTestServer(t) + router := newRevisionRouter(s) + + createItemDirect(t, s, "REVGET-001", "get revision", nil) + + req := httptest.NewRequest("GET", "/api/items/REVGET-001/revisions/1", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("status: got %d, want %d; body: %s", w.Code, http.StatusOK, w.Body.String()) + } + + var rev RevisionResponse + if err := json.Unmarshal(w.Body.Bytes(), &rev); err != nil { + t.Fatalf("decoding response: %v", err) + } + if rev.RevisionNumber != 1 { + t.Errorf("revision_number: got %d, want 1", rev.RevisionNumber) + } +} + +func TestHandleGetRevisionNotFound(t *testing.T) { + s := newTestServer(t) + router := newRevisionRouter(s) + + createItemDirect(t, s, "REVNF-001", "rev not found", nil) + + req := httptest.NewRequest("GET", "/api/items/REVNF-001/revisions/99", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusNotFound { + t.Errorf("status: got %d, want %d", w.Code, http.StatusNotFound) + } +} + +func TestHandleCreateRevision(t *testing.T) { + s := newTestServer(t) + router := newRevisionRouter(s) + + createItemDirect(t, s, "REVCR-001", "create revision", nil) + + body := `{"properties":{"material":"steel"},"comment":"added material"}` + req := authRequest(httptest.NewRequest("POST", "/api/items/REVCR-001/revisions", strings.NewReader(body))) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusCreated { + t.Fatalf("status: got %d, want %d; body: %s", w.Code, http.StatusCreated, w.Body.String()) + } + + var rev RevisionResponse + if err := json.Unmarshal(w.Body.Bytes(), &rev); err != nil { + t.Fatalf("decoding response: %v", err) + } + if rev.RevisionNumber != 2 { + t.Errorf("revision_number: got %d, want 2", rev.RevisionNumber) + } +} + +func TestHandleUpdateRevision(t *testing.T) { + s := newTestServer(t) + router := newRevisionRouter(s) + + createItemDirect(t, s, "REVUP-001", "update revision", nil) + + body := `{"status":"released","labels":["production"]}` + req := authRequest(httptest.NewRequest("PATCH", "/api/items/REVUP-001/revisions/1", strings.NewReader(body))) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("status: got %d, want %d; body: %s", w.Code, http.StatusOK, w.Body.String()) + } + + var rev RevisionResponse + if err := json.Unmarshal(w.Body.Bytes(), &rev); err != nil { + t.Fatalf("decoding response: %v", err) + } + if rev.Status != "released" { + t.Errorf("status: got %q, want %q", rev.Status, "released") + } + if len(rev.Labels) != 1 || rev.Labels[0] != "production" { + t.Errorf("labels: got %v, want [production]", rev.Labels) + } +} + +func TestHandleCompareRevisions(t *testing.T) { + s := newTestServer(t) + router := newRevisionRouter(s) + + // Create item with properties, then create second revision with changed properties + cost := 10.0 + createItemDirect(t, s, "REVCMP-001", "compare revisions", &cost) + + body := `{"properties":{"standard_cost":20,"material":"aluminum"},"comment":"updated cost"}` + req := authRequest(httptest.NewRequest("POST", "/api/items/REVCMP-001/revisions", strings.NewReader(body))) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + if w.Code != http.StatusCreated { + t.Fatalf("create rev 2: status %d; body: %s", w.Code, w.Body.String()) + } + + // Compare rev 1 vs rev 2 + req = httptest.NewRequest("GET", "/api/items/REVCMP-001/revisions/compare?from=1&to=2", nil) + w = httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("status: got %d, want %d; body: %s", w.Code, http.StatusOK, w.Body.String()) + } + + var diff RevisionDiffResponse + if err := json.Unmarshal(w.Body.Bytes(), &diff); err != nil { + t.Fatalf("decoding response: %v", err) + } + if diff.FromRevision != 1 || diff.ToRevision != 2 { + t.Errorf("revisions: got from=%d to=%d, want from=1 to=2", diff.FromRevision, diff.ToRevision) + } +} + +func TestHandleRollbackRevision(t *testing.T) { + s := newTestServer(t) + router := newRevisionRouter(s) + + createItemDirect(t, s, "REVRB-001", "rollback test", nil) + + // Create rev 2 + body := `{"properties":{"version":"v2"},"comment":"version 2"}` + req := authRequest(httptest.NewRequest("POST", "/api/items/REVRB-001/revisions", strings.NewReader(body))) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + if w.Code != http.StatusCreated { + t.Fatalf("create rev 2: status %d; body: %s", w.Code, w.Body.String()) + } + + // Rollback to rev 1 — should create rev 3 + body = `{"comment":"rolling back"}` + req = authRequest(httptest.NewRequest("POST", "/api/items/REVRB-001/revisions/1/rollback", strings.NewReader(body))) + req.Header.Set("Content-Type", "application/json") + w = httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusCreated { + t.Fatalf("status: got %d, want %d; body: %s", w.Code, http.StatusCreated, w.Body.String()) + } + + var rev RevisionResponse + if err := json.Unmarshal(w.Body.Bytes(), &rev); err != nil { + t.Fatalf("decoding response: %v", err) + } + if rev.RevisionNumber != 3 { + t.Errorf("revision_number: got %d, want 3", rev.RevisionNumber) + } +} diff --git a/internal/api/schema_handlers_test.go b/internal/api/schema_handlers_test.go new file mode 100644 index 0000000..19794d5 --- /dev/null +++ b/internal/api/schema_handlers_test.go @@ -0,0 +1,100 @@ +package api + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/go-chi/chi/v5" +) + +func newSchemaRouter(s *Server) http.Handler { + r := chi.NewRouter() + r.Get("/api/schemas", s.HandleListSchemas) + r.Get("/api/schemas/{name}", s.HandleGetSchema) + r.Get("/api/schemas/{name}/form", s.HandleGetFormDescriptor) + return r +} + +func TestHandleListSchemas(t *testing.T) { + s := newTestServerWithSchemas(t) + router := newSchemaRouter(s) + + req := httptest.NewRequest("GET", "/api/schemas", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("status: got %d, want %d; body: %s", w.Code, http.StatusOK, w.Body.String()) + } + + var schemas []map[string]any + if err := json.Unmarshal(w.Body.Bytes(), &schemas); err != nil { + t.Fatalf("decoding response: %v", err) + } + if len(schemas) == 0 { + t.Error("expected at least 1 schema") + } +} + +func TestHandleGetSchema(t *testing.T) { + s := newTestServerWithSchemas(t) + router := newSchemaRouter(s) + + req := httptest.NewRequest("GET", "/api/schemas/kindred-rd", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("status: got %d, want %d; body: %s", w.Code, http.StatusOK, w.Body.String()) + } + + var schema map[string]any + if err := json.Unmarshal(w.Body.Bytes(), &schema); err != nil { + t.Fatalf("decoding response: %v", err) + } + if schema["name"] != "kindred-rd" { + t.Errorf("name: got %v, want %q", schema["name"], "kindred-rd") + } +} + +func TestHandleGetSchemaNotFound(t *testing.T) { + s := newTestServerWithSchemas(t) + router := newSchemaRouter(s) + + req := httptest.NewRequest("GET", "/api/schemas/nonexistent", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusNotFound { + t.Errorf("status: got %d, want %d", w.Code, http.StatusNotFound) + } +} + +func TestHandleGetFormDescriptor(t *testing.T) { + s := newTestServerWithSchemas(t) + router := newSchemaRouter(s) + + req := httptest.NewRequest("GET", "/api/schemas/kindred-rd/form", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("status: got %d, want %d; body: %s", w.Code, http.StatusOK, w.Body.String()) + } + + var form map[string]any + if err := json.Unmarshal(w.Body.Bytes(), &form); err != nil { + t.Fatalf("decoding response: %v", err) + } + // Form descriptor should have fields + if _, ok := form["fields"]; !ok { + // Some schemas may use "categories" or "segments" instead + if _, ok := form["categories"]; !ok { + if _, ok := form["segments"]; !ok { + t.Error("form descriptor missing fields/categories/segments key") + } + } + } +}