79a3358810
web/handlers/window.go: GET /window (list), GET /window/new (form),
POST /window (open), GET /window/{id} (detail+lifecycle+audit), POST
/window/{id}/activate|revoke|expire (lifecycle transitions). Store
extensions: OpenWindow/ActivateWindow/RevokeWindow/ExpireWindow/
ListWindows/GetWindow/GetAuditLog — all call the REAL x/window/types
Window.Activate/Revoke/Expire methods (not reimplementation). Revoke on
Expired is a no-op (v0.2 terminal-state contract). AuditEntry appended on
each transition. 3 Window templates. window_test.go: lifecycle
correctness (asserts real methods invoked), idempotent revoke, revoke-on-
expired no-op, G-026 error lexicon checks. Coverage: store 99.2%,
handlers 87.7%.
---ci---
project: oy
phase: 3
milestone: v0.6
status: execute
---/ci---
364 lines
13 KiB
Go
364 lines
13 KiB
Go
package handlers
|
|
|
|
import (
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"testing"
|
|
|
|
windowtypes "github.com/oy/openyield/x/window/types"
|
|
)
|
|
|
|
func TestWindowOpenCreatesWindowStatusOpen(t *testing.T) {
|
|
srv := newTestServer(t)
|
|
mux := http.NewServeMux()
|
|
srv.Register(mux)
|
|
rec := httptest.NewRecorder()
|
|
body := "grantor_holder=holder-alia&grantee=service-1&scope_kind=ReadStash&resource_id=stash-holder-alia&max_actions=5"
|
|
req := httptest.NewRequest("POST", "/window", strings.NewReader(body))
|
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
|
mux.ServeHTTP(rec, req)
|
|
if rec.Code != http.StatusFound {
|
|
t.Fatalf("POST /window: status %d, want 302", rec.Code)
|
|
}
|
|
loc := rec.Header().Get("Location")
|
|
if !strings.HasPrefix(loc, "/window/window-") {
|
|
t.Errorf("POST /window: Location %q, want /window/window-...", loc)
|
|
}
|
|
// Extract the windowID and verify it exists with Status=Open + an initial AuditEntry.
|
|
windowID := strings.TrimPrefix(loc, "/window/")
|
|
win, ok := srv.Store.GetWindow(windowID)
|
|
if !ok {
|
|
t.Fatalf("POST /window: GetWindow(%q) miss", windowID)
|
|
}
|
|
if win.Status != windowtypes.StatusOpen {
|
|
t.Errorf("POST /window: created Window status %q, want Open", win.Status)
|
|
}
|
|
audit := srv.Store.GetAuditLog(windowID)
|
|
if len(audit) != 1 {
|
|
t.Errorf("POST /window: audit log len %d, want 1 (initial entry)", len(audit))
|
|
}
|
|
if audit[0].Action != "open" {
|
|
t.Errorf("POST /window: initial audit action %q, want open", audit[0].Action)
|
|
}
|
|
assertNoBannedTerms(t, rec.Body.String())
|
|
}
|
|
|
|
func TestWindowActivateTransitionsOpenToActive(t *testing.T) {
|
|
srv := newTestServer(t)
|
|
mux := http.NewServeMux()
|
|
srv.Register(mux)
|
|
scope := windowtypes.Scope{Kind: windowtypes.ScopeReadStash, ResourceID: "stash-holder-alia"}
|
|
rl := windowtypes.RateLimit{MaxActions: 10, PerDurationSeconds: 3600}
|
|
win, err := srv.Store.OpenWindow("holder-alia", "service-1", scope, 1000, 2000, rl)
|
|
if err != nil {
|
|
t.Fatalf("OpenWindow: %v", err)
|
|
}
|
|
rec := httptest.NewRecorder()
|
|
req := httptest.NewRequest("POST", "/window/"+win.WindowID+"/activate", nil)
|
|
mux.ServeHTTP(rec, req)
|
|
if rec.Code != http.StatusSeeOther {
|
|
t.Fatalf("POST activate: status %d, want 303", rec.Code)
|
|
}
|
|
// Lifecycle correctness: assert the real Window.Activate() was invoked
|
|
// (the handler calls store.ActivateWindow which calls w.Activate()).
|
|
updated, ok := srv.Store.GetWindow(win.WindowID)
|
|
if !ok {
|
|
t.Fatal("window missing after activate")
|
|
}
|
|
if updated.Status != windowtypes.StatusActive {
|
|
t.Errorf("after activate: status %q, want Active (Window.Activate was NOT invoked)", updated.Status)
|
|
}
|
|
audit := srv.Store.GetAuditLog(win.WindowID)
|
|
if len(audit) != 2 {
|
|
t.Errorf("after activate: audit log len %d, want 2 (initial + activate)", len(audit))
|
|
}
|
|
if audit[1].Action != "activate" {
|
|
t.Errorf("after activate: audit[1].Action %q, want activate", audit[1].Action)
|
|
}
|
|
assertNoBannedTerms(t, rec.Body.String())
|
|
}
|
|
|
|
func TestWindowRevokeTransitionsToRevoked(t *testing.T) {
|
|
srv := newTestServer(t)
|
|
mux := http.NewServeMux()
|
|
srv.Register(mux)
|
|
scope := windowtypes.Scope{Kind: windowtypes.ScopeReadStash, ResourceID: "stash-holder-alia"}
|
|
rl := windowtypes.RateLimit{MaxActions: 10, PerDurationSeconds: 3600}
|
|
win, _ := srv.Store.OpenWindow("holder-alia", "service-1", scope, 1000, 2000, rl)
|
|
_ = srv.Store.ActivateWindow(win.WindowID)
|
|
|
|
rec := httptest.NewRecorder()
|
|
req := httptest.NewRequest("POST", "/window/"+win.WindowID+"/revoke", nil)
|
|
mux.ServeHTTP(rec, req)
|
|
if rec.Code != http.StatusSeeOther {
|
|
t.Fatalf("POST revoke: status %d, want 303", rec.Code)
|
|
}
|
|
updated, _ := srv.Store.GetWindow(win.WindowID)
|
|
if updated.Status != windowtypes.StatusRevoked {
|
|
t.Errorf("after revoke: status %q, want Revoked (Window.Revoke was NOT invoked)", updated.Status)
|
|
}
|
|
if !updated.Revoked {
|
|
t.Errorf("after revoke: Revoked flag false, want true")
|
|
}
|
|
assertNoBannedTerms(t, rec.Body.String())
|
|
}
|
|
|
|
func TestWindowRevokeIdempotentOnAlreadyRevoked(t *testing.T) {
|
|
srv := newTestServer(t)
|
|
scope := windowtypes.Scope{Kind: windowtypes.ScopeReadStash, ResourceID: "stash-holder-alia"}
|
|
rl := windowtypes.RateLimit{MaxActions: 10, PerDurationSeconds: 3600}
|
|
win, _ := srv.Store.OpenWindow("holder-alia", "service-1", scope, 1000, 2000, rl)
|
|
_ = srv.Store.ActivateWindow(win.WindowID)
|
|
_ = srv.Store.RevokeWindow(win.WindowID)
|
|
auditBefore := len(srv.Store.GetAuditLog(win.WindowID))
|
|
|
|
// Second revoke is a no-op (idempotent): no new AuditEntry.
|
|
_ = srv.Store.RevokeWindow(win.WindowID)
|
|
auditAfter := len(srv.Store.GetAuditLog(win.WindowID))
|
|
if auditAfter != auditBefore {
|
|
t.Errorf("idempotent revoke: audit log grew %d -> %d (revoke on already-revoked must be a no-op)", auditBefore, auditAfter)
|
|
}
|
|
}
|
|
|
|
func TestWindowRevokeOnExpiredIsNoOp(t *testing.T) {
|
|
srv := newTestServer(t)
|
|
scope := windowtypes.Scope{Kind: windowtypes.ScopeReadStash, ResourceID: "stash-holder-alia"}
|
|
rl := windowtypes.RateLimit{MaxActions: 10, PerDurationSeconds: 3600}
|
|
win, _ := srv.Store.OpenWindow("holder-alia", "service-1", scope, 1000, 2000, rl)
|
|
_ = srv.Store.ActivateWindow(win.WindowID)
|
|
_ = srv.Store.ExpireWindow(win.WindowID)
|
|
auditBefore := len(srv.Store.GetAuditLog(win.WindowID))
|
|
|
|
// Revoke on an Expired window is a no-op (Expired is terminal — v0.2 contract).
|
|
_ = srv.Store.RevokeWindow(win.WindowID)
|
|
updated, _ := srv.Store.GetWindow(win.WindowID)
|
|
if updated.Status != windowtypes.StatusExpired {
|
|
t.Errorf("revoke-on-expired: status %q, want Expired (terminal state must win)", updated.Status)
|
|
}
|
|
auditAfter := len(srv.Store.GetAuditLog(win.WindowID))
|
|
if auditAfter != auditBefore {
|
|
t.Errorf("revoke-on-expired: audit log grew %d -> %d (must be a no-op)", auditBefore, auditAfter)
|
|
}
|
|
}
|
|
|
|
func TestWindowExpireTransitionsToExpired(t *testing.T) {
|
|
srv := newTestServer(t)
|
|
scope := windowtypes.Scope{Kind: windowtypes.ScopeReadStash, ResourceID: "stash-holder-alia"}
|
|
rl := windowtypes.RateLimit{MaxActions: 10, PerDurationSeconds: 3600}
|
|
win, _ := srv.Store.OpenWindow("holder-alia", "service-1", scope, 1000, 2000, rl)
|
|
_ = srv.Store.ActivateWindow(win.WindowID)
|
|
|
|
_ = srv.Store.ExpireWindow(win.WindowID)
|
|
updated, _ := srv.Store.GetWindow(win.WindowID)
|
|
if updated.Status != windowtypes.StatusExpired {
|
|
t.Errorf("after expire: status %q, want Expired (Window.Expire was NOT invoked)", updated.Status)
|
|
}
|
|
}
|
|
|
|
func TestWindowDetailRendersLifecycleAndAuditLog(t *testing.T) {
|
|
srv := newTestServer(t)
|
|
mux := http.NewServeMux()
|
|
srv.Register(mux)
|
|
scope := windowtypes.Scope{Kind: windowtypes.ScopeReadStash, ResourceID: "stash-holder-alia"}
|
|
rl := windowtypes.RateLimit{MaxActions: 10, PerDurationSeconds: 3600}
|
|
win, _ := srv.Store.OpenWindow("holder-alia", "service-1", scope, 1000, 2000, rl)
|
|
_ = srv.Store.ActivateWindow(win.WindowID)
|
|
|
|
rec := httptest.NewRecorder()
|
|
req := httptest.NewRequest("GET", "/window/"+win.WindowID, nil)
|
|
mux.ServeHTTP(rec, req)
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("GET /window/%s: status %d, want 200", win.WindowID, rec.Code)
|
|
}
|
|
body := rec.Body.String()
|
|
if !strings.Contains(body, "Active") {
|
|
t.Errorf("detail: body missing Active badge")
|
|
}
|
|
if !strings.Contains(body, "activate") {
|
|
t.Errorf("detail: body missing activate audit-log entry")
|
|
}
|
|
if !strings.Contains(body, "open") {
|
|
t.Errorf("detail: body missing open audit-log entry")
|
|
}
|
|
assertNoBannedTerms(t, body)
|
|
}
|
|
|
|
func TestWindowDetailMissingReturns404(t *testing.T) {
|
|
srv := newTestServer(t)
|
|
mux := http.NewServeMux()
|
|
srv.Register(mux)
|
|
rec := httptest.NewRecorder()
|
|
req := httptest.NewRequest("GET", "/window/window-nobody", nil)
|
|
mux.ServeHTTP(rec, req)
|
|
if rec.Code != http.StatusNotFound {
|
|
t.Fatalf("GET /window/window-nobody: status %d, want 404", rec.Code)
|
|
}
|
|
assertNoBannedTerms(t, rec.Body.String())
|
|
}
|
|
|
|
func TestWindowOpenEmptyGrantorReturns400(t *testing.T) {
|
|
srv := newTestServer(t)
|
|
mux := http.NewServeMux()
|
|
srv.Register(mux)
|
|
rec := httptest.NewRecorder()
|
|
body := "grantor_holder=&grantee=service-1&scope_kind=ReadStash"
|
|
req := httptest.NewRequest("POST", "/window", strings.NewReader(body))
|
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
|
mux.ServeHTTP(rec, req)
|
|
if rec.Code != http.StatusBadRequest {
|
|
t.Fatalf("POST /window empty grantor: status %d, want 400", rec.Code)
|
|
}
|
|
// G-026: rendered-HTML lexicon check on the ERROR response body too.
|
|
assertNoBannedTerms(t, rec.Body.String())
|
|
}
|
|
|
|
// Compile-time assertion that the handler uses the real x/window/types struct
|
|
// (D-067: the UI grounds in the real Go type definitions).
|
|
var _ windowtypes.Window
|
|
|
|
func TestWindowListRendersSeededEmpty(t *testing.T) {
|
|
srv := newTestServer(t)
|
|
mux := http.NewServeMux()
|
|
srv.Register(mux)
|
|
rec := httptest.NewRecorder()
|
|
req := httptest.NewRequest("GET", "/window", nil)
|
|
mux.ServeHTTP(rec, req)
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("GET /window: status %d, want 200", rec.Code)
|
|
}
|
|
body := rec.Body.String()
|
|
// No windows yet for holder-alia (fresh store) -> empty message.
|
|
if !strings.Contains(body, "Open a Window") {
|
|
t.Errorf("GET /window: body missing 'Open a Window' link")
|
|
}
|
|
assertNoBannedTerms(t, body)
|
|
}
|
|
|
|
func TestWindowListRendersCreatedWindows(t *testing.T) {
|
|
srv := newTestServer(t)
|
|
mux := http.NewServeMux()
|
|
srv.Register(mux)
|
|
scope := windowtypes.Scope{Kind: windowtypes.ScopeReadStash, ResourceID: "stash-holder-alia"}
|
|
rl := windowtypes.RateLimit{MaxActions: 5, PerDurationSeconds: 3600}
|
|
w, _ := srv.Store.OpenWindow("holder-alia", "service-1", scope, 1000, 2000, rl)
|
|
|
|
rec := httptest.NewRecorder()
|
|
req := httptest.NewRequest("GET", "/window", nil)
|
|
mux.ServeHTTP(rec, req)
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("GET /window: status %d, want 200", rec.Code)
|
|
}
|
|
body := rec.Body.String()
|
|
if !strings.Contains(body, w.WindowID) {
|
|
t.Errorf("GET /window: body missing created window %s", w.WindowID)
|
|
}
|
|
if !strings.Contains(body, "service-1") {
|
|
t.Errorf("GET /window: body missing grantee service-1")
|
|
}
|
|
assertNoBannedTerms(t, body)
|
|
}
|
|
|
|
func TestWindowNewRendersForm(t *testing.T) {
|
|
srv := newTestServer(t)
|
|
mux := http.NewServeMux()
|
|
srv.Register(mux)
|
|
rec := httptest.NewRecorder()
|
|
req := httptest.NewRequest("GET", "/window/new", nil)
|
|
mux.ServeHTTP(rec, req)
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("GET /window/new: status %d, want 200", rec.Code)
|
|
}
|
|
body := rec.Body.String()
|
|
if !strings.Contains(body, "Open a Window") {
|
|
t.Errorf("GET /window/new: body missing 'Open a Window' label")
|
|
}
|
|
if !strings.Contains(body, "ReadStash") {
|
|
t.Errorf("GET /window/new: body missing ScopeKind option ReadStash")
|
|
}
|
|
if !strings.Contains(body, "ProcessPassActForStand") {
|
|
t.Errorf("GET /window/new: body missing ScopeKind option ProcessPassActForStand")
|
|
}
|
|
assertNoBannedTerms(t, body)
|
|
}
|
|
|
|
func TestWindowOpenEmptyGranteeReturns400(t *testing.T) {
|
|
srv := newTestServer(t)
|
|
mux := http.NewServeMux()
|
|
srv.Register(mux)
|
|
rec := httptest.NewRecorder()
|
|
body := "grantor_holder=holder-alia&grantee=&scope_kind=ReadStash"
|
|
req := httptest.NewRequest("POST", "/window", strings.NewReader(body))
|
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
|
mux.ServeHTTP(rec, req)
|
|
if rec.Code != http.StatusBadRequest {
|
|
t.Fatalf("POST /window empty grantee: status %d, want 400", rec.Code)
|
|
}
|
|
assertNoBannedTerms(t, rec.Body.String())
|
|
}
|
|
|
|
func TestWindowActivateNotFoundReturns400(t *testing.T) {
|
|
srv := newTestServer(t)
|
|
mux := http.NewServeMux()
|
|
srv.Register(mux)
|
|
rec := httptest.NewRecorder()
|
|
req := httptest.NewRequest("POST", "/window/window-nobody/activate", nil)
|
|
mux.ServeHTTP(rec, req)
|
|
if rec.Code != http.StatusBadRequest {
|
|
t.Fatalf("POST activate nobody: status %d, want 400", rec.Code)
|
|
}
|
|
assertNoBannedTerms(t, rec.Body.String())
|
|
}
|
|
|
|
func TestWindowRevokeNotFoundReturns400(t *testing.T) {
|
|
srv := newTestServer(t)
|
|
mux := http.NewServeMux()
|
|
srv.Register(mux)
|
|
rec := httptest.NewRecorder()
|
|
req := httptest.NewRequest("POST", "/window/window-nobody/revoke", nil)
|
|
mux.ServeHTTP(rec, req)
|
|
if rec.Code != http.StatusBadRequest {
|
|
t.Fatalf("POST revoke nobody: status %d, want 400", rec.Code)
|
|
}
|
|
assertNoBannedTerms(t, rec.Body.String())
|
|
}
|
|
|
|
func TestWindowExpireNotFoundReturns400(t *testing.T) {
|
|
srv := newTestServer(t)
|
|
mux := http.NewServeMux()
|
|
srv.Register(mux)
|
|
rec := httptest.NewRecorder()
|
|
req := httptest.NewRequest("POST", "/window/window-nobody/expire", nil)
|
|
mux.ServeHTTP(rec, req)
|
|
if rec.Code != http.StatusBadRequest {
|
|
t.Fatalf("POST expire nobody: status %d, want 400", rec.Code)
|
|
}
|
|
assertNoBannedTerms(t, rec.Body.String())
|
|
}
|
|
|
|
func TestWindowRevokeAndExpireHandlersRedirect(t *testing.T) {
|
|
srv := newTestServer(t)
|
|
mux := http.NewServeMux()
|
|
srv.Register(mux)
|
|
scope := windowtypes.Scope{Kind: windowtypes.ScopeReadStash, ResourceID: "stash-holder-alia"}
|
|
rl := windowtypes.RateLimit{MaxActions: 10, PerDurationSeconds: 3600}
|
|
win, _ := srv.Store.OpenWindow("holder-alia", "service-1", scope, 1000, 2000, rl)
|
|
_ = srv.Store.ActivateWindow(win.WindowID)
|
|
|
|
rec := httptest.NewRecorder()
|
|
req := httptest.NewRequest("POST", "/window/"+win.WindowID+"/revoke", nil)
|
|
mux.ServeHTTP(rec, req)
|
|
if rec.Code != http.StatusSeeOther {
|
|
t.Fatalf("POST revoke: status %d, want 303", rec.Code)
|
|
}
|
|
|
|
// Expire on a revoked window: revoked is not terminal for expire, so it
|
|
// transitions to Expired (Window.Expire sets status unconditionally).
|
|
rec2 := httptest.NewRecorder()
|
|
req2 := httptest.NewRequest("POST", "/window/"+win.WindowID+"/expire", nil)
|
|
mux.ServeHTTP(rec2, req2)
|
|
if rec2.Code != http.StatusSeeOther {
|
|
t.Fatalf("POST expire: status %d, want 303", rec2.Code)
|
|
}
|
|
}
|