Publish ITWorx Pulse source
Public source validation / validate (push) Failing after 3m8s

This commit is contained in:
ITWorx Pulse release export
2026-09-03 02:09:19 +02:00
commit bd774932d5
614 changed files with 77116 additions and 0 deletions
+407
View File
@@ -0,0 +1,407 @@
package dashboardapi
import (
"encoding/json"
"errors"
"io"
"net/http"
"strconv"
"strings"
"github.com/itworx/pulse/internal/audit"
"github.com/itworx/pulse/internal/auth"
"github.com/itworx/pulse/internal/correlation"
"github.com/itworx/pulse/internal/dashboard"
"github.com/itworx/pulse/internal/problem"
"github.com/itworx/pulse/internal/widgetpreview"
)
type Handler struct {
Repository dashboard.Repository
Audit audit.Store
}
func (h Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
principal, ok := auth.PrincipalFromContext(r.Context())
if !ok {
fail(w, r, http.StatusUnauthorized, "UNAUTHORIZED", "Authentication required.")
return
}
path := strings.TrimPrefix(r.URL.Path, "/api/v1/dashboards")
if path == "" || path == "/" {
switch r.Method {
case http.MethodGet:
h.list(w, r, principal.Subject)
case http.MethodPost:
if !auth.Allows(principal.Role, auth.PermissionEdit) {
fail(w, r, http.StatusForbidden, "FORBIDDEN", "Dashboard editing is not allowed for this role.")
return
}
h.create(w, r, principal.Subject)
default:
fail(w, r, http.StatusMethodNotAllowed, "METHOD_NOT_ALLOWED", "This method is not supported.")
}
return
}
parts := strings.Split(strings.Trim(path, "/"), "/")
if len(parts) == 0 || parts[0] == "" {
fail(w, r, http.StatusNotFound, "NOT_FOUND", "Dashboard not found.")
return
}
id := parts[0]
if len(parts) == 2 && parts[1] == "versions" && r.Method == http.MethodGet {
h.versions(w, r, id, principal.Subject)
return
}
if len(parts) == 3 && parts[1] == "versions" && r.Method == http.MethodGet {
h.version(w, r, id, parts[2], principal.Subject)
return
}
if len(parts) == 2 && parts[1] == "document" && r.Method == http.MethodPut {
if !requireEdit(w, r, principal.Role) {
return
}
h.update(w, r, id, principal.Subject)
return
}
if len(parts) == 2 && parts[1] == "preview" && r.Method == http.MethodPost {
if !requireEdit(w, r, principal.Role) {
return
}
h.preview(w, r, id, principal.Subject)
return
}
if len(parts) == 2 && parts[1] == "clone" && r.Method == http.MethodPost {
if !requireEdit(w, r, principal.Role) {
return
}
h.clone(w, r, id, principal.Subject)
return
}
if len(parts) == 2 && parts[1] == "restore" && r.Method == http.MethodPost {
if !requireEdit(w, r, principal.Role) {
return
}
h.restore(w, r, id, principal.Subject, "")
return
}
if len(parts) == 3 && parts[1] == "restore" && r.Method == http.MethodPost {
if !requireEdit(w, r, principal.Role) {
return
}
h.restore(w, r, id, principal.Subject, parts[2])
return
}
if len(parts) == 1 && r.Method == http.MethodGet {
h.get(w, r, id, principal.Subject)
return
}
if len(parts) == 1 && r.Method == http.MethodPatch {
if !requireEdit(w, r, principal.Role) {
return
}
h.metadata(w, r, id, principal.Subject)
return
}
if len(parts) == 1 && r.Method == http.MethodDelete {
if !requireEdit(w, r, principal.Role) {
return
}
h.archive(w, r, id, principal.Subject)
return
}
fail(w, r, http.StatusNotFound, "NOT_FOUND", "Dashboard route not found.")
}
func requireEdit(w http.ResponseWriter, r *http.Request, role auth.Role) bool {
if auth.Allows(role, auth.PermissionEdit) {
return true
}
fail(w, r, http.StatusForbidden, "FORBIDDEN", "Dashboard editing is not allowed for this role.")
return false
}
func (h Handler) create(w http.ResponseWriter, r *http.Request, actor string) {
var doc dashboard.Document
if err := decode(r, &doc); err != nil {
fail(w, r, http.StatusBadRequest, "INVALID_DOCUMENT", "The dashboard document is invalid.")
return
}
s, v, err := h.Repository.Create(r.Context(), actor, doc, "initial version")
if err != nil {
h.repositoryFailure(w, r, err, "Dashboard could not be created.")
return
}
if err := h.record(r, actor, "dashboard.create", s.ID, nil, map[string]any{"revision": s.Revision}); err != nil {
fail(w, r, http.StatusServiceUnavailable, "AUDIT_UNAVAILABLE", "The audit event could not be recorded.")
return
}
write(w, http.StatusCreated, map[string]any{"dashboard": s, "version": v})
}
func (h Handler) list(w http.ResponseWriter, r *http.Request, actor string) {
limit := 100
if value := r.URL.Query().Get("limit"); value != "" {
parsed, err := strconv.Atoi(value)
if err != nil || parsed < 1 || parsed > 100 {
fail(w, r, http.StatusBadRequest, "INVALID_LIMIT", "The dashboard limit must be between 1 and 100.")
return
}
limit = parsed
}
items, err := h.Repository.List(r.Context(), actor, limit)
if err != nil {
h.repositoryFailure(w, r, err, "Dashboard list unavailable.")
return
}
write(w, http.StatusOK, map[string]any{"items": items})
}
func (h Handler) get(w http.ResponseWriter, r *http.Request, id, actor string) {
s, v, err := h.Repository.Get(r.Context(), id, actor)
if err != nil {
h.repositoryFailure(w, r, err, "Dashboard not found.")
return
}
write(w, http.StatusOK, map[string]any{"dashboard": s, "version": v})
}
func (h Handler) preview(w http.ResponseWriter, r *http.Request, id, actor string) {
allowed, err := h.Repository.CanAccess(r.Context(), id, actor)
if err != nil {
h.repositoryFailure(w, r, err, "Dashboard preview unavailable.")
return
}
if !allowed {
fail(w, r, http.StatusForbidden, "FORBIDDEN", "You cannot preview this dashboard.")
return
}
var body struct {
Widget map[string]any
State string
}
if err := decode(r, &body); err != nil || body.Widget == nil {
fail(w, r, http.StatusBadRequest, "INVALID_PREVIEW", "A widget configuration is required.")
return
}
result, err := widgetpreview.Preview(body.Widget, body.State)
if err != nil {
var invalid widgetpreview.InvalidConfig
if errors.As(err, &invalid) {
fail(w, r, http.StatusBadRequest, "INVALID_WIDGET_CONFIG", "The widget configuration is invalid.", invalid.Fields)
return
}
fail(w, r, http.StatusBadRequest, "INVALID_PREVIEW", "The widget preview is invalid.")
return
}
write(w, http.StatusOK, map[string]any{"preview": result})
}
func (h Handler) update(w http.ResponseWriter, r *http.Request, id, actor string) {
expected, err := revision(r)
if err != nil {
fail(w, r, http.StatusBadRequest, "INVALID_REVISION", "A valid If-Match or revision value is required.")
return
}
var doc dashboard.Document
if err := decode(r, &doc); err != nil {
fail(w, r, http.StatusBadRequest, "INVALID_DOCUMENT", "The dashboard document is invalid.")
return
}
s, err := h.Repository.UpdateDocument(r.Context(), id, actor, expected, doc, "document update")
if err != nil {
h.repositoryFailure(w, r, err, "Dashboard update failed.")
return
}
if err := h.record(r, actor, "dashboard.update_document", s.ID, map[string]any{"revision": expected}, map[string]any{"revision": s.Revision}); err != nil {
fail(w, r, http.StatusServiceUnavailable, "AUDIT_UNAVAILABLE", "The audit event could not be recorded.")
return
}
write(w, http.StatusOK, map[string]any{"dashboard": s})
}
func (h Handler) metadata(w http.ResponseWriter, r *http.Request, id, actor string) {
expected, err := revision(r)
if err != nil {
fail(w, r, http.StatusBadRequest, "INVALID_REVISION", "A valid If-Match or revision value is required.")
return
}
var body struct {
Name string
Description string
}
if err := decode(r, &body); err != nil || strings.TrimSpace(body.Name) == "" {
fail(w, r, http.StatusBadRequest, "INVALID_METADATA", "A dashboard name is required.")
return
}
s, err := h.Repository.UpdateMetadata(r.Context(), id, actor, expected, body.Name, body.Description)
if err != nil {
h.repositoryFailure(w, r, err, "Metadata update failed.")
return
}
if err := h.record(r, actor, "dashboard.update_metadata", s.ID, map[string]any{"revision": expected}, map[string]any{"revision": s.Revision}); err != nil {
fail(w, r, http.StatusServiceUnavailable, "AUDIT_UNAVAILABLE", "The audit event could not be recorded.")
return
}
write(w, http.StatusOK, map[string]any{"dashboard": s})
}
func (h Handler) archive(w http.ResponseWriter, r *http.Request, id, actor string) {
expected, err := revision(r)
if err != nil {
fail(w, r, http.StatusBadRequest, "INVALID_REVISION", "A valid If-Match or revision value is required.")
return
}
s, err := h.Repository.Archive(r.Context(), id, actor, expected)
if err != nil {
h.repositoryFailure(w, r, err, "Dashboard archive failed.")
return
}
if err := h.record(r, actor, "dashboard.archive", s.ID, map[string]any{"revision": expected}, map[string]any{"revision": s.Revision}); err != nil {
fail(w, r, http.StatusServiceUnavailable, "AUDIT_UNAVAILABLE", "The audit event could not be recorded.")
return
}
write(w, http.StatusOK, map[string]any{"dashboard": s})
}
func (h Handler) versions(w http.ResponseWriter, r *http.Request, id, actor string) {
items, err := h.Repository.Versions(r.Context(), id, actor, 100)
if err != nil {
h.repositoryFailure(w, r, err, "Version history unavailable.")
return
}
write(w, http.StatusOK, map[string]any{"items": items})
}
func (h Handler) version(w http.ResponseWriter, r *http.Request, id, value, actor string) {
number, err := strconv.Atoi(value)
if err != nil || number < 1 {
fail(w, r, http.StatusBadRequest, "INVALID_VERSION", "The version number is invalid.")
return
}
item, err := h.Repository.GetVersion(r.Context(), id, actor, number)
if err != nil {
h.repositoryFailure(w, r, err, "Dashboard version not found.")
return
}
write(w, http.StatusOK, map[string]any{"version": item})
}
func (h Handler) clone(w http.ResponseWriter, r *http.Request, id, actor string) {
var body struct {
Slug string
Name string
}
if err := decode(r, &body); err != nil {
fail(w, r, http.StatusBadRequest, "INVALID_CLONE", "The clone options are invalid.")
return
}
s, v, err := h.Repository.Clone(r.Context(), actor, id, body.Slug, body.Name)
if err != nil {
h.repositoryFailure(w, r, err, "Dashboard could not be cloned.")
return
}
if err := h.record(r, actor, "dashboard.clone", s.ID, nil, map[string]any{"revision": s.Revision}); err != nil {
fail(w, r, http.StatusServiceUnavailable, "AUDIT_UNAVAILABLE", "The audit event could not be recorded.")
return
}
write(w, http.StatusCreated, map[string]any{"dashboard": s, "version": v})
}
func (h Handler) restore(w http.ResponseWriter, r *http.Request, id, actor, value string) {
number := value
if number == "" {
var body struct{ Version int }
if err := decode(r, &body); err != nil {
fail(w, r, http.StatusBadRequest, "INVALID_VERSION", "A version number is required.")
return
}
number = strconv.Itoa(body.Version)
}
versionNumber, err := strconv.Atoi(number)
if err != nil || versionNumber < 1 {
fail(w, r, http.StatusBadRequest, "INVALID_VERSION", "The version number is invalid.")
return
}
expected, err := revision(r)
if err != nil {
fail(w, r, http.StatusBadRequest, "INVALID_REVISION", "A valid If-Match or revision value is required.")
return
}
s, err := h.Repository.Restore(r.Context(), id, actor, expected, versionNumber)
if err != nil {
h.repositoryFailure(w, r, err, "Dashboard could not be restored.")
return
}
if err := h.record(r, actor, "dashboard.restore", s.ID, map[string]any{"version": versionNumber}, map[string]any{"revision": s.Revision}); err != nil {
fail(w, r, http.StatusServiceUnavailable, "AUDIT_UNAVAILABLE", "The audit event could not be recorded.")
return
}
write(w, http.StatusOK, map[string]any{"dashboard": s})
}
func (h Handler) record(r *http.Request, actor, action, resourceID string, before, after map[string]any) error {
if h.Audit == nil {
return nil
}
return h.Audit.Append(r.Context(), audit.Event{Actor: actor, Action: action, ResourceType: "dashboard", ResourceID: resourceID, Result: "success", CorrelationID: correlation.FromContext(r.Context()), Before: before, After: after})
}
func (h Handler) repositoryFailure(w http.ResponseWriter, r *http.Request, err error, fallback string) {
switch {
case errors.Is(err, dashboard.ErrConflict):
fail(w, r, http.StatusConflict, "REVISION_CONFLICT", "The dashboard was changed by another request.")
case errors.Is(err, dashboard.ErrForbidden):
fail(w, r, http.StatusForbidden, "FORBIDDEN", "You cannot change this dashboard.")
case errors.Is(err, dashboard.ErrNotFound):
fail(w, r, http.StatusNotFound, "NOT_FOUND", fallback)
default:
fail(w, r, http.StatusBadRequest, "DASHBOARD_REQUEST_FAILED", fallback)
}
}
func decode(r *http.Request, target any) error {
contentType := strings.ToLower(strings.TrimSpace(strings.Split(r.Header.Get("Content-Type"), ";")[0]))
if contentType != "" && contentType != "application/json" {
return errors.New("unsupported content type")
}
body, err := io.ReadAll(io.LimitReader(r.Body, 2<<20+1))
if err != nil {
return err
}
defer r.Body.Close()
if len(body) > 2<<20 {
return errors.New("request too large")
}
decoder := json.NewDecoder(strings.NewReader(string(body)))
if err := decoder.Decode(target); err != nil {
return err
}
var extra any
if err := decoder.Decode(&extra); err != io.EOF {
return errors.New("multiple JSON values")
}
return nil
}
func revision(r *http.Request) (int64, error) {
value := r.Header.Get("If-Match")
if value == "" {
value = r.URL.Query().Get("revision")
}
return strconv.ParseInt(strings.Trim(value, "\""), 10, 64)
}
func fail(w http.ResponseWriter, r *http.Request, status int, code, detail string, fields ...map[string]string) {
var extra map[string]string
if len(fields) > 0 {
extra = fields[0]
}
problem.Write(w, r, status, code, http.StatusText(status), detail, extra)
}
func write(w http.ResponseWriter, status int, value any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(value)
}
+112
View File
@@ -0,0 +1,112 @@
package dashboardapi
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/itworx/pulse/internal/audit"
"github.com/itworx/pulse/internal/auth"
"github.com/itworx/pulse/internal/correlation"
"github.com/itworx/pulse/internal/dashboard"
)
func requestWithPrincipal(method, path, body string, principal *auth.Principal) *httptest.ResponseRecorder {
request := httptest.NewRequest(method, path, strings.NewReader(body))
request.Header.Set("Content-Type", "application/json")
request = request.WithContext(correlation.WithContext(request.Context(), "m3-03-test-correlation"))
if principal != nil {
request = request.WithContext(auth.WithPrincipal(request.Context(), *principal))
}
response := httptest.NewRecorder()
(Handler{Repository: dashboard.Repository{}}).ServeHTTP(response, request)
return response
}
func problemCode(t *testing.T, response *httptest.ResponseRecorder) string {
t.Helper()
var body struct{ Code string }
if err := json.Unmarshal(response.Body.Bytes(), &body); err != nil {
t.Fatalf("problem response is not JSON: %v", err)
}
return body.Code
}
func TestHandlerRequiresAuthenticationWithProblemResponse(t *testing.T) {
response := requestWithPrincipal(http.MethodGet, "/api/v1/dashboards", "", nil)
if response.Code != http.StatusUnauthorized {
t.Fatalf("status=%d", response.Code)
}
if got := response.Header().Get("Content-Type"); got != "application/problem+json" {
t.Fatalf("content type=%q", got)
}
if got := problemCode(t, response); got != "UNAUTHORIZED" {
t.Fatalf("code=%q", got)
}
if got := response.Header().Get(correlation.Header); got != "m3-03-test-correlation" {
t.Fatalf("correlation=%q", got)
}
}
func TestHandlerEnforcesEditorForMutation(t *testing.T) {
viewer := auth.Principal{Subject: "viewer", Role: auth.RoleViewer}
response := requestWithPrincipal(http.MethodPost, "/api/v1/dashboards", "{}", &viewer)
if response.Code != http.StatusForbidden {
t.Fatalf("status=%d", response.Code)
}
if got := problemCode(t, response); got != "FORBIDDEN" {
t.Fatalf("code=%q", got)
}
}
func TestHandlerEnforcesEditorForPreview(t *testing.T) {
viewer := auth.Principal{Subject: "viewer", Role: auth.RoleViewer}
response := requestWithPrincipal(http.MethodPost, "/api/v1/dashboards/00000000-0000-0000-0000-000000000001/preview", `{"widget":{}}`, &viewer)
if response.Code != http.StatusForbidden || problemCode(t, response) != "FORBIDDEN" {
t.Fatalf("response=%d %s", response.Code, response.Body.String())
}
}
func TestHandlerRejectsInvalidContentTypeAndVersion(t *testing.T) {
editor := auth.Principal{Subject: "editor", Role: auth.RoleEditor}
request := httptest.NewRequest(http.MethodPost, "/api/v1/dashboards", strings.NewReader("{}"))
request.Header.Set("Content-Type", "text/plain")
request = request.WithContext(auth.WithPrincipal(context.Background(), editor))
response := httptest.NewRecorder()
(Handler{Repository: dashboard.Repository{}}).ServeHTTP(response, request)
if response.Code != http.StatusBadRequest || problemCode(t, response) != "INVALID_DOCUMENT" {
t.Fatalf("response=%d %s", response.Code, response.Body.String())
}
response = requestWithPrincipal(http.MethodPost, "/api/v1/dashboards/00000000-0000-0000-0000-000000000001/restore/not-a-number", "{}", &editor)
if response.Code != http.StatusBadRequest || problemCode(t, response) != "INVALID_VERSION" {
t.Fatalf("response=%d %s", response.Code, response.Body.String())
}
}
func TestHandlerRejectsUnsupportedMethod(t *testing.T) {
viewer := auth.Principal{Subject: "viewer", Role: auth.RoleViewer}
response := requestWithPrincipal(http.MethodPut, "/api/v1/dashboards", "{}", &viewer)
if response.Code != http.StatusMethodNotAllowed {
t.Fatalf("status=%d", response.Code)
}
}
func TestHandlerRecordsAuditedSafeDiff(t *testing.T) {
store := &audit.MemoryStore{}
request := httptest.NewRequest(http.MethodPost, "/api/v1/dashboards", strings.NewReader("{}"))
request = request.WithContext(correlation.WithContext(request.Context(), "audit-correlation"))
handler := Handler{Audit: store}
if err := handler.record(request, "subject-1", "dashboard.update_document", "00000000-0000-0000-0000-000000000001", map[string]any{"revision": 1}, map[string]any{"revision": 2}); err != nil {
t.Fatal(err)
}
if len(store.Events) != 1 || store.Events[0].Action != "dashboard.update_document" || store.Events[0].CorrelationID != "audit-correlation" {
t.Fatalf("events=%+v", store.Events)
}
if _, ok := store.Events[0].After["document"]; ok {
t.Fatal("audit event unexpectedly contains full document")
}
}