Files
paliad/internal/handlers/approvals_test.go
m 073af975f7 feat(approvals/t-paliad-160 slice2): admin UI flip + badge + withdraw + inbox visibility hardening
A3 — admin/approval-policies 2-control flip:
  Each cell becomes [✓] requires_approval checkbox + role select + clear
  button. The "none" option in the role dropdown is gone — the checkbox
  replaces it. Role select is greyed when the checkbox is off (gate
  closed). Clear button explicitly drops the cell back to inheritance.
  Project matrix surfaces inherited "no approval" state with its own
  attribution chip ("Geerbt · keine Genehmigung") so admins can tell a
  silently-inherited off-state from a never-authored cell.

  PUT /api/.../approval-policies/{entity}/{lifecycle} accepts the new
  shape `{requires_approval: bool, min_role: string|null}` while still
  honouring the legacy `{required_role: "..."}` body during the M1
  dual-read window (decodePolicyBody routes to UpsertProjectPolicySplit
  vs UpsertProjectPolicy accordingly).

C+E — Pending-approval badge + Withdraw button:
  deadlines-detail + appointments-detail surface a "Wartet auf
  Genehmigung" badge when approval_status='pending'. Hover-tooltip
  carries requested_at + required_role + requester_name. Action
  controls (Complete, Edit, Delete) freeze while pending — caller
  would get a 409 anyway, no point letting them try.

  Withdraw button visible only to the requester (me.id ===
  pending_request.requested_by). Click → POST /api/approval-requests/
  {id}/revoke (existing endpoint, no new server route). On success,
  the entity flips back to approval_status='approved' and the page
  re-renders with normal controls.

  Complete button now handles 409 from the server gracefully:
  surfaces the new mapApprovalError body's `message` instead of
  silently disabling itself.

D — /inbox "Meine Anfragen" visibility hardening:
  Three defence-in-depth fixes for the "tab shows empty" report:
    1. handlers force `[]` (not Go-nil → JSON null) on every inbox
       endpoint so the frontend never trips on `rows.length` of null.
    2. parseInboxFilter validates ?status= against an allowlist
       (pending|approved|rejected|revoked|superseded). Anything else
       is silently dropped — a stray ?status=foo from a stale
       frontend build can no longer shadow rows out of the result.
       entity_type filter same treatment (deadline|appointment).
    3. Frontend inbox.ts coerces null body → [] so older / cached
       builds talking to the new server still don't crash.

  Test coverage: TestParseInboxFilter_DropsUnknownStatus +
  TestApprovalService_ListSubmittedByUser_PendingVisible (live-DB,
  skipped without TEST_DATABASE_URL).

Build clean: bun build OK, go test ./... OK.

Defers: M2 (drop required_role column) — only fires once all
in-tree writers are confirmed off the legacy column path.
2026-05-08 17:07:46 +02:00

114 lines
3.8 KiB
Go

package handlers
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"mgit.msbls.de/m/paliad/internal/services"
)
// Pins t-paliad-160 §B: mapApprovalError must surface ErrConcurrentPending
// as a 409 with code=awaiting_approval, and PendingApprovalError must
// additionally carry the request_id + required_role so the UI can offer a
// withdraw button.
func TestMapApprovalError_ConcurrentPending409(t *testing.T) {
w := httptest.NewRecorder()
if !mapApprovalError(w, services.ErrConcurrentPending) {
t.Fatal("mapApprovalError returned false for ErrConcurrentPending")
}
if w.Code != http.StatusConflict {
t.Fatalf("status = %d, want 409", w.Code)
}
var body map[string]string
if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil {
t.Fatalf("decode body: %v", err)
}
if body["code"] != "awaiting_approval" {
t.Errorf("code = %q, want awaiting_approval", body["code"])
}
if _, ok := body["request_id"]; ok {
t.Errorf("bare ErrConcurrentPending should not carry request_id, got %q", body["request_id"])
}
}
func TestMapApprovalError_PendingApprovalErrorCarriesRequestID(t *testing.T) {
w := httptest.NewRecorder()
pe := services.NewPendingApprovalError("11111111-2222-3333-4444-555555555555", "associate")
if !mapApprovalError(w, pe) {
t.Fatal("mapApprovalError returned false for PendingApprovalError")
}
if w.Code != http.StatusConflict {
t.Fatalf("status = %d, want 409", w.Code)
}
var body map[string]string
if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil {
t.Fatalf("decode body: %v", err)
}
if body["code"] != "awaiting_approval" {
t.Errorf("code = %q, want awaiting_approval", body["code"])
}
if body["request_id"] != "11111111-2222-3333-4444-555555555555" {
t.Errorf("request_id = %q, want the wrapped uuid", body["request_id"])
}
if body["required_role"] != "associate" {
t.Errorf("required_role = %q, want associate", body["required_role"])
}
}
func TestMapApprovalError_NoQualifiedApprover409(t *testing.T) {
w := httptest.NewRecorder()
if !mapApprovalError(w, services.ErrNoQualifiedApprover) {
t.Fatal("mapApprovalError returned false for ErrNoQualifiedApprover")
}
if w.Code != http.StatusConflict {
t.Fatalf("status = %d, want 409", w.Code)
}
var body map[string]string
_ = json.Unmarshal(w.Body.Bytes(), &body)
if body["code"] != "no_qualified_approver" {
t.Errorf("code = %q, want no_qualified_approver", body["code"])
}
}
func TestMapApprovalError_MissReturnsFalse(t *testing.T) {
w := httptest.NewRecorder()
if mapApprovalError(w, services.ErrInvalidInput) {
t.Error("mapApprovalError matched ErrInvalidInput; that's writeServiceError's job")
}
if w.Code != http.StatusOK {
t.Errorf("status = %d, want 200 (recorder default — nothing written)", w.Code)
}
}
// TestParseInboxFilter_DropsUnknownStatus pins t-paliad-160 §D regression
// hardening: a stray ?status=foo from a stale frontend build (or an
// attacker scoping us out of our own list) must NOT shadow rows out of
// the result set. The handler silently drops anything not on the allowlist.
func TestParseInboxFilter_DropsUnknownStatus(t *testing.T) {
cases := []struct {
raw string
want string
}{
{"", ""},
{"pending", "pending"},
{"approved", "approved"},
{"rejected", "rejected"},
{"revoked", "revoked"},
{"superseded", "superseded"},
{"foo", ""}, // unknown — dropped
{"DROP+TABLE", ""}, // hostile — dropped
{"PENDING", ""}, // case mismatch — dropped (we don't normalise)
}
for _, tc := range cases {
t.Run(tc.raw, func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/api/inbox/mine?status="+tc.raw, nil)
f := parseInboxFilter(req)
if f.Status != tc.want {
t.Errorf("parseInboxFilter(%q).Status = %q, want %q", tc.raw, f.Status, tc.want)
}
})
}
}