diff --git a/web/handlers/server.go b/web/handlers/server.go index deb6c39..b52a7ad 100644 --- a/web/handlers/server.go +++ b/web/handlers/server.go @@ -15,6 +15,7 @@ import ( "path/filepath" "github.com/oy/openyield/web/store" + windowtypes "github.com/oy/openyield/x/window/types" ) // Server bundles the mock store + per-page templates + route registration. @@ -40,6 +41,16 @@ func New(s *store.Store, templatesDir string) (*Server, error) { } return grain / unit }, + "string": func(v any) string { + switch t := v.(type) { + case string: + return t + case windowtypes.WindowStatus: + return string(t) + default: + return "" + } + }, } basePath := filepath.Join(templatesDir, "base.html") base, err := template.New("base.html").Funcs(funcs).ParseFiles(basePath) @@ -75,7 +86,8 @@ func New(s *store.Store, templatesDir string) (*Server, error) { func (s *Server) Register(mux *http.ServeMux) { s.registerReach(mux) s.registerStash(mux) - // P3..P5 register their own routes (window, standing, bloom). + s.registerWindow(mux) + // P4..P5 register their own routes (standing, bloom). } // render executes the named page template with the given data, writing HTML diff --git a/web/handlers/window.go b/web/handlers/window.go new file mode 100644 index 0000000..1d1d177 --- /dev/null +++ b/web/handlers/window.go @@ -0,0 +1,119 @@ +package handlers + +import ( + "net/http" + "strconv" + "time" + + windowtypes "github.com/oy/openyield/x/window/types" +) + +// registerWindow wires the Window authorization routes (REQ-042) into the mux. +func (s *Server) registerWindow(mux *http.ServeMux) { + mux.HandleFunc("GET /window", s.handleWindowList) + mux.HandleFunc("GET /window/new", s.handleWindowNew) + mux.HandleFunc("POST /window", s.handleWindowOpen) + mux.HandleFunc("GET /window/{id}", s.handleWindowDetail) + mux.HandleFunc("POST /window/{id}/activate", s.handleWindowActivate) + mux.HandleFunc("POST /window/{id}/revoke", s.handleWindowRevoke) + mux.HandleFunc("POST /window/{id}/expire", s.handleWindowExpire) +} + +// handleWindowList renders all Windows for a grantor holder (defaults to +// holder-alia if no query param, so the list view has something to show). +func (s *Server) handleWindowList(w http.ResponseWriter, r *http.Request) { + grantor := r.URL.Query().Get("grantor") + if grantor == "" { + grantor = "holder-alia" + } + windows := s.Store.ListWindows(grantor) + s.render(w, "window_list.html", map[string]any{"Windows": windows, "Grantor": grantor}) +} + +// handleWindowNew renders the "Open a Window" form. +func (s *Server) handleWindowNew(w http.ResponseWriter, r *http.Request) { + s.render(w, "window_new.html", nil) +} + +// handleWindowOpen handles the POST from the "Open a Window" form. Calls +// store.OpenWindow (creates a Window status=Open + an initial AuditEntry). +func (s *Server) handleWindowOpen(w http.ResponseWriter, r *http.Request) { + grantor := r.FormValue("grantor_holder") + grantee := r.FormValue("grantee") + scopeKind := windowtypes.ScopeKind(r.FormValue("scope_kind")) + resourceID := r.FormValue("resource_id") + startStr := r.FormValue("start_unix") + endStr := r.FormValue("end_unix") + maxActionsStr := r.FormValue("max_actions") + + if grantor == "" { + http.Error(w, "grantor holder is required", http.StatusBadRequest) + return + } + if grantee == "" { + http.Error(w, "grantee is required", http.StatusBadRequest) + return + } + start, _ := strconv.ParseInt(startStr, 10, 64) + end, _ := strconv.ParseInt(endStr, 10, 64) + if start == 0 { + start = time.Now().Unix() + } + if end == 0 { + end = start + 3600 + } + maxActions, _ := strconv.ParseUint(maxActionsStr, 10, 32) + if maxActions == 0 { + maxActions = 10 + } + scope := windowtypes.Scope{Kind: scopeKind, ResourceID: resourceID} + rateLimit := windowtypes.RateLimit{MaxActions: uint32(maxActions), PerDurationSeconds: 3600} + win, err := s.Store.OpenWindow(grantor, grantee, scope, start, end, rateLimit) + if err != nil { + http.Error(w, "could not open a Window: "+err.Error(), http.StatusBadRequest) + return + } + http.Redirect(w, r, "/window/"+win.WindowID, http.StatusFound) +} + +// handleWindowDetail renders one Window + its lifecycle state + audit log. +func (s *Server) handleWindowDetail(w http.ResponseWriter, r *http.Request) { + id := r.PathValue("id") + win, ok := s.Store.GetWindow(id) + if !ok { + http.NotFound(w, r) + return + } + auditLog := s.Store.GetAuditLog(id) + s.render(w, "window_detail.html", map[string]any{"Window": win, "AuditLog": auditLog}) +} + +// handleWindowActivate transitions Open → Active (calls Window.Activate). +func (s *Server) handleWindowActivate(w http.ResponseWriter, r *http.Request) { + id := r.PathValue("id") + if err := s.Store.ActivateWindow(id); err != nil { + http.Error(w, "could not activate: "+err.Error(), http.StatusBadRequest) + return + } + http.Redirect(w, r, "/window/"+id, http.StatusSeeOther) +} + +// handleWindowRevoke transitions to Revoked (calls Window.Revoke; idempotent). +func (s *Server) handleWindowRevoke(w http.ResponseWriter, r *http.Request) { + id := r.PathValue("id") + if err := s.Store.RevokeWindow(id); err != nil { + http.Error(w, "could not revoke: "+err.Error(), http.StatusBadRequest) + return + } + http.Redirect(w, r, "/window/"+id, http.StatusSeeOther) +} + +// handleWindowExpire transitions to Expired (calls Window.Expire). +func (s *Server) handleWindowExpire(w http.ResponseWriter, r *http.Request) { + id := r.PathValue("id") + if err := s.Store.ExpireWindow(id); err != nil { + http.Error(w, "could not expire: "+err.Error(), http.StatusBadRequest) + return + } + http.Redirect(w, r, "/window/"+id, http.StatusSeeOther) +} diff --git a/web/handlers/window_test.go b/web/handlers/window_test.go new file mode 100644 index 0000000..5a3fc83 --- /dev/null +++ b/web/handlers/window_test.go @@ -0,0 +1,363 @@ +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) + } +} diff --git a/web/store/store.go b/web/store/store.go index 4d6db21..b43a6f6 100644 --- a/web/store/store.go +++ b/web/store/store.go @@ -15,6 +15,7 @@ import ( identitytypes "github.com/oy/openyield/x/identity/types" stashtypes "github.com/oy/openyield/x/stash/types" + windowtypes "github.com/oy/openyield/x/window/types" ) // seedBalanceGrain is the test balance seeded to a new Stash at signup (D-071 @@ -27,6 +28,8 @@ type Store struct { reaches map[string]identitytypes.Reach stashes map[string]stashtypes.Stash stashActivities map[string]stashtypes.StashActivity + windows map[string]windowtypes.Window + auditLogs map[string][]windowtypes.AuditEntry } // NewStore constructs a Store seeded from fixtures (fixtures.go). @@ -35,6 +38,8 @@ func NewStore() *Store { reaches: map[string]identitytypes.Reach{}, stashes: map[string]stashtypes.Stash{}, stashActivities: map[string]stashtypes.StashActivity{}, + windows: map[string]windowtypes.Window{}, + auditLogs: map[string][]windowtypes.AuditEntry{}, } s.seed() return s @@ -117,6 +122,148 @@ func (s *Store) GetStashActivity(stashID string) (stashtypes.StashActivity, bool return a, ok } +// OpenWindow creates a new Window in the Open status (REQ-042) with an initial +// AuditEntry. Returns the created Window. The Window is keyed by a generated +// windowID derived from the grantor + a counter (mock; not cryptographic). +func (s *Store) OpenWindow(grantorHolder, grantee string, scope windowtypes.Scope, start, end int64, rateLimit windowtypes.RateLimit) (windowtypes.Window, error) { + if grantorHolder == "" { + return windowtypes.Window{}, fmt.Errorf("grantor holder is required") + } + if grantee == "" { + return windowtypes.Window{}, fmt.Errorf("grantee is required") + } + s.mu.Lock() + defer s.mu.Unlock() + windowID := fmt.Sprintf("window-%s-%d", grantorHolder, len(s.windows)+1) + now := time.Now().Unix() + w := windowtypes.Window{ + WindowID: windowID, + GrantorHolder: grantorHolder, + Grantee: grantee, + Scope: scope, + Start: start, + End: end, + RateLimit: rateLimit, + Status: windowtypes.StatusOpen, + } + s.windows[windowID] = w + entry := windowtypes.AuditEntry{ + EntryID: windowID + "-audit-1", + Timestamp: now, + Action: "open", + Result: "created", + GranterRef: grantorHolder, + } + s.auditLogs[windowID] = []windowtypes.AuditEntry{entry} + w.AuditLogRefs = []string{entry.EntryID} + s.windows[windowID] = w + return w, nil +} + +// ActivateWindow transitions a Window from Open to Active by calling the real +// x/window/types.Window.Activate() method (not a reimplementation). Appends an +// AuditEntry. Returns an error if the Window is not in the Open status. +func (s *Store) ActivateWindow(windowID string) error { + s.mu.Lock() + defer s.mu.Unlock() + w, ok := s.windows[windowID] + if !ok { + return fmt.Errorf("window %q not found", windowID) + } + if err := w.Activate(); err != nil { + return err + } + s.windows[windowID] = w + s.appendAuditLocked(windowID, "activate", "active", w.GrantorHolder) + return nil +} + +// RevokeWindow transitions a Window to Revoked by calling the real +// x/window/types.Window.Revoke() method. Idempotent on already-revoked; +// no-op on Expired (terminal state wins — v0.2 type contract). Appends an +// AuditEntry only if the status actually changed. +func (s *Store) RevokeWindow(windowID string) error { + s.mu.Lock() + defer s.mu.Unlock() + w, ok := s.windows[windowID] + if !ok { + return fmt.Errorf("window %q not found", windowID) + } + prevStatus := w.Status + if err := w.Revoke(); err != nil { + return err + } + s.windows[windowID] = w + if w.Status != prevStatus { + s.appendAuditLocked(windowID, "revoke", "revoked", w.GrantorHolder) + } + return nil +} + +// ExpireWindow transitions a Window to Expired by calling the real +// x/window/types.Window.Expire() method. Appends an AuditEntry. +func (s *Store) ExpireWindow(windowID string) error { + s.mu.Lock() + defer s.mu.Unlock() + w, ok := s.windows[windowID] + if !ok { + return fmt.Errorf("window %q not found", windowID) + } + prevStatus := w.Status + w.Expire() + s.windows[windowID] = w + if w.Status != prevStatus { + s.appendAuditLocked(windowID, "expire", "expired", w.GrantorHolder) + } + return nil +} + +// ListWindows returns all Windows for a grantor holder. +func (s *Store) ListWindows(grantorHolder string) []windowtypes.Window { + s.mu.Lock() + defer s.mu.Unlock() + out := []windowtypes.Window{} + for _, w := range s.windows { + if w.GrantorHolder == grantorHolder { + out = append(out, w) + } + } + return out +} + +// GetWindow returns the Window for a windowID. +func (s *Store) GetWindow(windowID string) (windowtypes.Window, bool) { + s.mu.Lock() + defer s.mu.Unlock() + w, ok := s.windows[windowID] + return w, ok +} + +// GetAuditLog returns the audit-log entries for a windowID. +func (s *Store) GetAuditLog(windowID string) []windowtypes.AuditEntry { + s.mu.Lock() + defer s.mu.Unlock() + return s.auditLogs[windowID] +} + +// appendAuditLocked appends an AuditEntry to the window's audit log. Caller +// MUST hold s.mu. +func (s *Store) appendAuditLocked(windowID, action, result, granterRef string) { + logs := s.auditLogs[windowID] + now := time.Now().Unix() + entry := windowtypes.AuditEntry{ + EntryID: fmt.Sprintf("%s-audit-%d", windowID, len(logs)+1), + Timestamp: now, + Action: action, + Result: result, + GranterRef: granterRef, + } + s.auditLogs[windowID] = append(logs, entry) + w := s.windows[windowID] + w.AuditLogRefs = append(w.AuditLogRefs, entry.EntryID) + s.windows[windowID] = w +} + // validateReachInput enforces G-027: HolderID and PublicKey must be non-empty, // <=128 bytes, and contain no path separators or template syntax. This is a // prototype-robustness gate (the mock store uses holderID as a map key). diff --git a/web/store/store_test.go b/web/store/store_test.go index 13a863a..6d2405b 100644 --- a/web/store/store_test.go +++ b/web/store/store_test.go @@ -6,6 +6,7 @@ import ( identitytypes "github.com/oy/openyield/x/identity/types" stashtypes "github.com/oy/openyield/x/stash/types" + windowtypes "github.com/oy/openyield/x/window/types" ) func TestNewStoreSeedsFixtures(t *testing.T) { @@ -214,3 +215,222 @@ func stringOf(r rune, n int) string { } return string(b) } + +// --- Window tests (P3) --- + +func TestOpenWindowCreatesStatusOpenWithInitialAudit(t *testing.T) { + s := NewStore() + scope := windowtypes.Scope{Kind: windowtypes.ScopeReadStash, ResourceID: "stash-x"} + rl := windowtypes.RateLimit{MaxActions: 5, PerDurationSeconds: 3600} + w, err := s.OpenWindow("holder-alia", "service-1", scope, 1000, 2000, rl) + if err != nil { + t.Fatalf("OpenWindow: %v", err) + } + if w.Status != windowtypes.StatusOpen { + t.Errorf("OpenWindow status %q, want Open", w.Status) + } + if w.WindowID == "" { + t.Error("OpenWindow: empty WindowID") + } + audit := s.GetAuditLog(w.WindowID) + if len(audit) != 1 { + t.Errorf("OpenWindow: audit log len %d, want 1", len(audit)) + } + if audit[0].Action != "open" { + t.Errorf("OpenWindow: audit[0].Action %q, want open", audit[0].Action) + } +} + +func TestOpenWindowValidation(t *testing.T) { + scope := windowtypes.Scope{Kind: windowtypes.ScopeReadStash} + rl := windowtypes.RateLimit{MaxActions: 1} + cases := []struct { + name, grantor, grantee string + wantErr bool + }{ + {"empty grantor", "", "g", true}, + {"empty grantee", "h", "", true}, + {"valid", "h", "g", false}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + s := NewStore() + _, err := s.OpenWindow(c.grantor, c.grantee, scope, 1, 2, rl) + if c.wantErr && err == nil { + t.Errorf("expected error, got nil") + } + if !c.wantErr && err != nil { + t.Errorf("unexpected error: %v", err) + } + }) + } +} + +func TestActivateWindowTransitionsToActive(t *testing.T) { + s := NewStore() + scope := windowtypes.Scope{Kind: windowtypes.ScopeReadStash} + rl := windowtypes.RateLimit{MaxActions: 1} + w, _ := s.OpenWindow("holder-alia", "svc", scope, 1, 2, rl) + if err := s.ActivateWindow(w.WindowID); err != nil { + t.Fatalf("ActivateWindow: %v", err) + } + updated, _ := s.GetWindow(w.WindowID) + if updated.Status != windowtypes.StatusActive { + t.Errorf("after activate: %q, want Active", updated.Status) + } + audit := s.GetAuditLog(w.WindowID) + if len(audit) != 2 { + t.Errorf("after activate: audit len %d, want 2", len(audit)) + } +} + +func TestActivateWindowNotFound(t *testing.T) { + s := NewStore() + if err := s.ActivateWindow("window-nobody"); err == nil { + t.Error("ActivateWindow(nobody): expected error, got nil") + } +} + +func TestActivateWindowOnActiveFails(t *testing.T) { + s := NewStore() + scope := windowtypes.Scope{Kind: windowtypes.ScopeReadStash} + rl := windowtypes.RateLimit{MaxActions: 1} + w, _ := s.OpenWindow("holder-alia", "svc", scope, 1, 2, rl) + _ = s.ActivateWindow(w.WindowID) + // Activate again should fail (can only activate Open windows). + if err := s.ActivateWindow(w.WindowID); err == nil { + t.Error("activate on Active: expected error, got nil (Window.Activate rejects non-Open)") + } +} + +func TestRevokeWindowTransitionsToRevoked(t *testing.T) { + s := NewStore() + scope := windowtypes.Scope{Kind: windowtypes.ScopeReadStash} + rl := windowtypes.RateLimit{MaxActions: 1} + w, _ := s.OpenWindow("holder-alia", "svc", scope, 1, 2, rl) + if err := s.RevokeWindow(w.WindowID); err != nil { + t.Fatalf("RevokeWindow: %v", err) + } + updated, _ := s.GetWindow(w.WindowID) + if updated.Status != windowtypes.StatusRevoked { + t.Errorf("after revoke: %q, want Revoked", updated.Status) + } + if !updated.Revoked { + t.Error("after revoke: Revoked flag false, want true") + } +} + +func TestRevokeWindowIdempotent(t *testing.T) { + s := NewStore() + scope := windowtypes.Scope{Kind: windowtypes.ScopeReadStash} + rl := windowtypes.RateLimit{MaxActions: 1} + w, _ := s.OpenWindow("holder-alia", "svc", scope, 1, 2, rl) + _ = s.RevokeWindow(w.WindowID) + before := len(s.GetAuditLog(w.WindowID)) + _ = s.RevokeWindow(w.WindowID) + after := len(s.GetAuditLog(w.WindowID)) + if after != before { + t.Errorf("idempotent revoke: audit grew %d -> %d", before, after) + } +} + +func TestRevokeWindowOnExpiredIsNoOp(t *testing.T) { + s := NewStore() + scope := windowtypes.Scope{Kind: windowtypes.ScopeReadStash} + rl := windowtypes.RateLimit{MaxActions: 1} + w, _ := s.OpenWindow("holder-alia", "svc", scope, 1, 2, rl) + _ = s.ExpireWindow(w.WindowID) + before := len(s.GetAuditLog(w.WindowID)) + _ = s.RevokeWindow(w.WindowID) + updated, _ := s.GetWindow(w.WindowID) + if updated.Status != windowtypes.StatusExpired { + t.Errorf("revoke-on-expired: %q, want Expired (terminal wins)", updated.Status) + } + after := len(s.GetAuditLog(w.WindowID)) + if after != before { + t.Errorf("revoke-on-expired: audit grew %d -> %d (no-op)", before, after) + } +} + +func TestRevokeWindowNotFound(t *testing.T) { + s := NewStore() + if err := s.RevokeWindow("window-nobody"); err == nil { + t.Error("RevokeWindow(nobody): expected error, got nil") + } +} + +func TestExpireWindowTransitionsToExpired(t *testing.T) { + s := NewStore() + scope := windowtypes.Scope{Kind: windowtypes.ScopeReadStash} + rl := windowtypes.RateLimit{MaxActions: 1} + w, _ := s.OpenWindow("holder-alia", "svc", scope, 1, 2, rl) + if err := s.ExpireWindow(w.WindowID); err != nil { + t.Fatalf("ExpireWindow: %v", err) + } + updated, _ := s.GetWindow(w.WindowID) + if updated.Status != windowtypes.StatusExpired { + t.Errorf("after expire: %q, want Expired", updated.Status) + } +} + +func TestExpireWindowNotFound(t *testing.T) { + s := NewStore() + if err := s.ExpireWindow("window-nobody"); err == nil { + t.Error("ExpireWindow(nobody): expected error, got nil") + } +} + +func TestExpireWindowIdempotent(t *testing.T) { + s := NewStore() + scope := windowtypes.Scope{Kind: windowtypes.ScopeReadStash} + rl := windowtypes.RateLimit{MaxActions: 1} + w, _ := s.OpenWindow("holder-alia", "svc", scope, 1, 2, rl) + _ = s.ExpireWindow(w.WindowID) + before := len(s.GetAuditLog(w.WindowID)) + _ = s.ExpireWindow(w.WindowID) + after := len(s.GetAuditLog(w.WindowID)) + if after != before { + t.Errorf("idempotent expire: audit grew %d -> %d", before, after) + } +} + +func TestListWindowsFiltersByGrantor(t *testing.T) { + s := NewStore() + scope := windowtypes.Scope{Kind: windowtypes.ScopeReadStash} + rl := windowtypes.RateLimit{MaxActions: 1} + _, _ = s.OpenWindow("holder-alia", "svc1", scope, 1, 2, rl) + _, _ = s.OpenWindow("holder-alia", "svc2", scope, 1, 2, rl) + _, _ = s.OpenWindow("holder-bryn", "svc3", scope, 1, 2, rl) + alia := s.ListWindows("holder-alia") + if len(alia) != 2 { + t.Errorf("ListWindows(holder-alia) = %d, want 2", len(alia)) + } + bryn := s.ListWindows("holder-bryn") + if len(bryn) != 1 { + t.Errorf("ListWindows(holder-bryn) = %d, want 1", len(bryn)) + } + nobody := s.ListWindows("nobody") + if len(nobody) != 0 { + t.Errorf("ListWindows(nobody) = %d, want 0", len(nobody)) + } +} + +func TestGetWindowHitMiss(t *testing.T) { + s := NewStore() + scope := windowtypes.Scope{Kind: windowtypes.ScopeReadStash} + rl := windowtypes.RateLimit{MaxActions: 1} + w, _ := s.OpenWindow("holder-alia", "svc", scope, 1, 2, rl) + if _, ok := s.GetWindow(w.WindowID); !ok { + t.Errorf("GetWindow(%q) miss, want hit", w.WindowID) + } + if _, ok := s.GetWindow("window-nobody"); ok { + t.Error("GetWindow(nobody) hit, want miss") + } +} + +func TestGetAuditLogEmptyForMissing(t *testing.T) { + s := NewStore() + if logs := s.GetAuditLog("window-nobody"); logs != nil { + t.Errorf("GetAuditLog(nobody) = %v, want nil", logs) + } +} diff --git a/web/templates/window_detail.html b/web/templates/window_detail.html new file mode 100644 index 0000000..d370ef0 --- /dev/null +++ b/web/templates/window_detail.html @@ -0,0 +1,68 @@ +{{define "title"}}{{.Window.WindowID}} — OpenYield{{end}} + +{{define "content"}} +
+

{{.Window.WindowID}}

+ + + + + + + + + + +
Window ID{{.Window.WindowID}}
Grantor{{.Window.GrantorHolder}}
Grantee{{.Window.Grantee}}
Scope{{.Window.Scope.Kind}} ({{.Window.Scope.ResourceID}})
Start{{.Window.Start}}
End{{.Window.End}}
Rate limit{{.Window.RateLimit.ActionsConsumed}} / {{.Window.RateLimit.MaxActions}} per {{.Window.RateLimit.PerDurationSeconds}}s
Revoked{{if .Window.Revoked}}yes{{else}}no{{end}}
Status + {{if eq (string .Window.Status) "Open"}}Open{{end}} + {{if eq (string .Window.Status) "Active"}}Active{{end}} + {{if eq (string .Window.Status) "Revoked"}}Revoked{{end}} + {{if eq (string .Window.Status) "Expired"}}Expired{{end}} +
+
+ +
+

Lifecycle actions

+

+ {{if eq (string .Window.Status) "Open"}} +

+ +
+ {{end}} + {{if or (eq (string .Window.Status) "Open") (eq (string .Window.Status) "Active")}} +
+ +
+ {{end}} + {{if or (eq (string .Window.Status) "Open") (eq (string .Window.Status) "Active")}} +
+ +
+ {{end}} +

+
+ +
+

Audit log

+ {{if .AuditLog}} + + + + {{range .AuditLog}} + + + + + + + + {{end}} + +
Entry IDTimestampActionResultGranter
{{.EntryID}}{{.Timestamp}}{{.Action}}{{.Result}}{{.GranterRef}}
+ {{else}} +

No audit entries yet.

+ {{end}} +
+ +

Back to Window list

+{{end}} \ No newline at end of file diff --git a/web/templates/window_list.html b/web/templates/window_list.html new file mode 100644 index 0000000..32a15a6 --- /dev/null +++ b/web/templates/window_list.html @@ -0,0 +1,33 @@ +{{define "title"}}Window — OpenYield{{end}} + +{{define "content"}} +
+

Window

+

A Window is a Holder-authorized, scope-bounded, time-limited, revocable + delegation of access (REQ-015). The Holder opens a Window so a partner or + service can read a Stash or process a Pass-Act — without giving up custody. + The Window is revocable, rate-limited, and audited.

+

Open a Window

+
+ +
+

Windows for {{.Grantor}}

+ {{if .Windows}} + + + + {{range .Windows}} + + + + + + + {{end}} + +
Window IDGranteeScopeStatus
{{.WindowID}}{{.Grantee}}{{.Scope.Kind}} ({{.Scope.ResourceID}}){{.Status}}
+ {{else}} +

No Windows yet for {{.Grantor}}. Open a Window to begin.

+ {{end}} +
+{{end}} \ No newline at end of file diff --git a/web/templates/window_new.html b/web/templates/window_new.html new file mode 100644 index 0000000..ea7bb70 --- /dev/null +++ b/web/templates/window_new.html @@ -0,0 +1,36 @@ +{{define "title"}}Open a Window — OpenYield{{end}} + +{{define "content"}} +
+

Open a Window

+

A Window delegates scoped access to a partner or service without giving + up custody. The Holder sets the scope, the duration, and a rate-limit; the + Window is revocable at any time.

+ +
+ + + + + + + + + + + + + + + +
+

Back to Window list

+
+{{end}} \ No newline at end of file