Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 50c84d0351 | |||
| 5008f85da1 | |||
| 79a3358810 | |||
| edd285e5c0 | |||
| 07053cc13a | |||
| 973e25a7c9 |
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"phase": 1,
|
||||
"phase": 3,
|
||||
"stage": "complete",
|
||||
"milestone": "v0.6",
|
||||
"milestone_type": "feature",
|
||||
@@ -7,7 +7,7 @@
|
||||
"phase_role": "execution",
|
||||
"project": "oy",
|
||||
"attempts": 0,
|
||||
"updated_at": "2026-08-18T13:55:00Z",
|
||||
"updated_at": "2026-08-18T14:20:00Z",
|
||||
"milestone_complete": false,
|
||||
"requirements_covered": ["REQ-040", "REQ-045"]
|
||||
}
|
||||
"requirements_covered": ["REQ-040", "REQ-041", "REQ-042", "REQ-045"]
|
||||
}
|
||||
|
||||
+23
-2
@@ -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.
|
||||
@@ -33,8 +34,26 @@ type Server struct {
|
||||
// New constructs a Server with the given store + per-page templates loaded
|
||||
// from templatesDir (the absolute or relative path to web/templates/).
|
||||
func New(s *store.Store, templatesDir string) (*Server, error) {
|
||||
funcs := template.FuncMap{
|
||||
"divGrain": func(grain, unit int64) int64 {
|
||||
if unit == 0 {
|
||||
return 0
|
||||
}
|
||||
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.ParseFiles(basePath)
|
||||
base, err := template.New("base.html").Funcs(funcs).ParseFiles(basePath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse base: %w", err)
|
||||
}
|
||||
@@ -66,7 +85,9 @@ func New(s *store.Store, templatesDir string) (*Server, error) {
|
||||
// patterns). Called by web/server.go after constructing the Server.
|
||||
func (s *Server) Register(mux *http.ServeMux) {
|
||||
s.registerReach(mux)
|
||||
// P2..P5 register their own routes (stash, window, standing, bloom).
|
||||
s.registerStash(mux)
|
||||
s.registerWindow(mux)
|
||||
// P4..P5 register their own routes (standing, bloom).
|
||||
}
|
||||
|
||||
// render executes the named page template with the given data, writing HTML
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
breadtypes "github.com/oy/openyield/x/bread/types"
|
||||
stashtypes "github.com/oy/openyield/x/stash/types"
|
||||
)
|
||||
|
||||
// registerStash wires the Stash dashboard route (REQ-041) into the mux.
|
||||
func (s *Server) registerStash(mux *http.ServeMux) {
|
||||
mux.HandleFunc("GET /stash/{holderID}", s.handleStashDashboard)
|
||||
}
|
||||
|
||||
// stashViewData is the template data for the Stash dashboard. It carries the
|
||||
// real x/*/types structs plus the Bread-scale conversion (computed from the
|
||||
// x/bread/types code constants per D-073) and the maturity progress.
|
||||
type stashViewData struct {
|
||||
Stash stashtypes.Stash
|
||||
Activity stashtypes.StashActivity
|
||||
Found bool
|
||||
BreadScale []breadtypes.BreadScale
|
||||
BalanceBread int64
|
||||
MaturityPct int
|
||||
Mature bool
|
||||
ThresholdDays uint32
|
||||
MaxGapDays uint32
|
||||
}
|
||||
|
||||
// handleStashDashboard renders the Stash dashboard (REQ-041): balance in Grain
|
||||
// + Bread-scale conversion (using x/bread/types.BreadScaleAll() + GrainsPerBread
|
||||
// per D-073 — code constants, NOT docs) + 90-day maturity progress bar
|
||||
// (StashActivity.IsMature, MaturityThresholdDays=90).
|
||||
func (s *Server) handleStashDashboard(w http.ResponseWriter, r *http.Request) {
|
||||
holderID := r.PathValue("holderID")
|
||||
stash, ok := s.Store.GetStash(holderID)
|
||||
if !ok {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
activity, _ := s.Store.GetStashActivity(stash.StashID)
|
||||
|
||||
// D-073: Bread-scale conversion from x/bread/types code constants.
|
||||
scale := breadtypes.BreadScaleAll()
|
||||
balanceBread := stash.BalanceGrain / breadtypes.GrainsPerBread
|
||||
|
||||
// Maturity progress: ActiveDays / MaturityThresholdDays, capped at 100%.
|
||||
threshold := uint32(stashtypes.MaturityThresholdDays)
|
||||
pct := int(float64(activity.ActiveDays) / float64(threshold) * 100)
|
||||
if pct > 100 {
|
||||
pct = 100
|
||||
}
|
||||
if pct < 0 {
|
||||
pct = 0
|
||||
}
|
||||
|
||||
s.render(w, "stash.html", stashViewData{
|
||||
Stash: stash,
|
||||
Activity: activity,
|
||||
Found: true,
|
||||
BreadScale: scale,
|
||||
BalanceBread: balanceBread,
|
||||
MaturityPct: pct,
|
||||
Mature: activity.IsMature(),
|
||||
ThresholdDays: threshold,
|
||||
MaxGapDays: stashtypes.MaxGapForMaturity,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
breadtypes "github.com/oy/openyield/x/bread/types"
|
||||
stashtypes "github.com/oy/openyield/x/stash/types"
|
||||
)
|
||||
|
||||
func TestStashDashboardSeededMatureHolder(t *testing.T) {
|
||||
srv := newTestServer(t)
|
||||
mux := http.NewServeMux()
|
||||
srv.Register(mux)
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest("GET", "/stash/holder-alia", nil)
|
||||
mux.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("GET /stash/holder-alia: status %d, want 200", rec.Code)
|
||||
}
|
||||
body := rec.Body.String()
|
||||
// Balance in Grain present.
|
||||
if !strings.Contains(body, "Grain") {
|
||||
t.Errorf("body missing 'Grain' balance")
|
||||
}
|
||||
// Bread-scale conversion table present (all 11 denominations from BreadScaleAll).
|
||||
for _, ds := range breadtypes.BreadScaleAll() {
|
||||
if !strings.Contains(body, ds.Name) {
|
||||
t.Errorf("body missing Bread-scale denomination %q", ds.Name)
|
||||
}
|
||||
}
|
||||
// Mature holder (ActiveDays=92, MaxGap=10): progress ~100%, Mature badge.
|
||||
if !strings.Contains(body, "Mature") {
|
||||
t.Errorf("body missing 'Mature' badge for mature holder-alia")
|
||||
}
|
||||
assertNoBannedTerms(t, body)
|
||||
}
|
||||
|
||||
func TestStashDashboardImmatureHolder(t *testing.T) {
|
||||
srv := newTestServer(t)
|
||||
mux := http.NewServeMux()
|
||||
srv.Register(mux)
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest("GET", "/stash/holder-bryn", nil)
|
||||
mux.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("GET /stash/holder-bryn: status %d, want 200", rec.Code)
|
||||
}
|
||||
body := rec.Body.String()
|
||||
// Immature holder (ActiveDays=45, MaxGap=5): Not mature badge.
|
||||
if !strings.Contains(body, "Not mature") {
|
||||
t.Errorf("body missing 'Not mature' badge for immature holder-bryn")
|
||||
}
|
||||
// Progress bar at 50% (45/90).
|
||||
if !strings.Contains(body, "50%") {
|
||||
t.Errorf("body missing 50%% progress for holder-bryn (45/90 days)")
|
||||
}
|
||||
assertNoBannedTerms(t, body)
|
||||
}
|
||||
|
||||
func TestStashDashboardMissingReturns404(t *testing.T) {
|
||||
srv := newTestServer(t)
|
||||
mux := http.NewServeMux()
|
||||
srv.Register(mux)
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest("GET", "/stash/nobody", nil)
|
||||
mux.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusNotFound {
|
||||
t.Fatalf("GET /stash/nobody: status %d, want 404", rec.Code)
|
||||
}
|
||||
// G-026: rendered-HTML lexicon check on the ERROR response body too.
|
||||
assertNoBannedTerms(t, rec.Body.String())
|
||||
}
|
||||
|
||||
// TestStashBreadScaleConversionCorrectness (D-073 regression guard): asserts
|
||||
// the Stash dashboard uses x/bread/types code constants (GrainsPerBread=10000,
|
||||
// BreadScaleAll() with Grain=1, Crumb=100, Bread=10000...), NOT the outdated
|
||||
// docs/shared/bread-scale.md (which claims 1,000x ratios). This test would FAIL
|
||||
// if the handler hardcoded the docs values instead of using the code constants.
|
||||
func TestStashBreadScaleConversionCorrectness(t *testing.T) {
|
||||
srv := newTestServer(t)
|
||||
mux := http.NewServeMux()
|
||||
srv.Register(mux)
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest("GET", "/stash/holder-alia", nil)
|
||||
mux.ServeHTTP(rec, req)
|
||||
body := rec.Body.String()
|
||||
|
||||
// D-073: the code constants are the source of truth.
|
||||
// GrainsPerBread must be 10000 (code), NOT 1000 (docs claim 1 Crumb=1000 Grain).
|
||||
if breadtypes.GrainsPerBread != 10000 {
|
||||
t.Fatalf("D-073: x/bread/types.GrainsPerBread = %d, want 10000 (code constant)", breadtypes.GrainsPerBread)
|
||||
}
|
||||
|
||||
// The handler computes BalanceBread = BalanceGrain / GrainsPerBread.
|
||||
// holder-alia seed: BalanceGrain = 920000 -> 92 Bread.
|
||||
stash, ok := srv.Store.GetStash("holder-alia")
|
||||
if !ok {
|
||||
t.Fatal("seeded holder-alia stash missing")
|
||||
}
|
||||
wantBread := stash.BalanceGrain / breadtypes.GrainsPerBread
|
||||
wantBreadStr := []byte(formatInt(wantBread))
|
||||
if !strings.Contains(body, string(wantBreadStr)) {
|
||||
t.Errorf("D-073: body missing expected Bread conversion %d (from %d Grain / %d GrainsPerBread)",
|
||||
wantBread, stash.BalanceGrain, breadtypes.GrainsPerBread)
|
||||
}
|
||||
|
||||
// The Bread-scale table must include the code-constant Grain values.
|
||||
scale := breadtypes.BreadScaleAll()
|
||||
for _, ds := range scale {
|
||||
if !strings.Contains(body, formatInt(ds.GrainValue)) {
|
||||
t.Errorf("D-073: body missing Bread-scale GrainValue %d for %s", ds.GrainValue, ds.Name)
|
||||
}
|
||||
}
|
||||
|
||||
// Regression guard: if someone used the outdated docs value (1 Crumb = 1000
|
||||
// Grain) instead of the code constant (1 Crumb = 100 Grain), the Crumb row
|
||||
// would show 1000 — assert it shows 100 (the code value).
|
||||
crumbs := scale[1] // index 1 = Crumb
|
||||
if crumbs.Name != "Crumb" || crumbs.GrainValue != 100 {
|
||||
t.Fatalf("D-073: BreadScaleAll()[1] = {%s, %d}, want {Crumb, 100}", crumbs.Name, crumbs.GrainValue)
|
||||
}
|
||||
if !strings.Contains(body, "100") {
|
||||
t.Errorf("D-073: body missing code-constant Crumb=100 Grain (would show 1000 if docs values were used)")
|
||||
}
|
||||
}
|
||||
|
||||
// Compile-time assertions that the handler uses the real x/*/types structs
|
||||
// (D-067: the UI grounds in the real Go type definitions).
|
||||
var _ stashtypes.Stash
|
||||
var _ stashtypes.StashActivity
|
||||
|
||||
// formatInt is a tiny strconv.Itoa without the import (keeps test deps minimal).
|
||||
func formatInt(n int64) string {
|
||||
if n == 0 {
|
||||
return "0"
|
||||
}
|
||||
neg := n < 0
|
||||
if neg {
|
||||
n = -n
|
||||
}
|
||||
var buf [20]byte
|
||||
i := len(buf)
|
||||
for n > 0 {
|
||||
i--
|
||||
buf[i] = byte('0' + n%10)
|
||||
n /= 10
|
||||
}
|
||||
if neg {
|
||||
i--
|
||||
buf[i] = '-'
|
||||
}
|
||||
return string(buf[i:])
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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).
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
{{define "title"}}Stash — OpenYield{{end}}
|
||||
|
||||
{{define "content"}}
|
||||
<section class="panel">
|
||||
<h1>Stash</h1>
|
||||
<p>A Stash is a Holder's personal storage — the place a Nomad holds Grain.
|
||||
It is a storage layer, not a custodial position: the Holder owns it,
|
||||
controls it, and can delegate a scoped, time-limited Window to a partner
|
||||
without giving up custody.</p>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<h2>Balance</h2>
|
||||
<table class="kv">
|
||||
<tr><th>Stash ID</th><td>{{.Stash.StashID}}</td></tr>
|
||||
<tr><th>Holder ID</th><td>{{.Stash.HolderID}}</td></tr>
|
||||
<tr><th>Balance</th><td>{{.Stash.BalanceGrain}} Grain ({{.BalanceBread}} Bread)</td></tr>
|
||||
<tr><th>Created</th><td>{{.Stash.CreatedAt}}</td></tr>
|
||||
<tr><th>Last active</th><td>{{.Stash.LastActive}}</td></tr>
|
||||
<tr><th>Still</th><td>{{if .Stash.IsStill}}paused{{else}}active{{end}}</td></tr>
|
||||
</table>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<h2>Bread scale</h2>
|
||||
<p>1 Bread = 10,000 Grain. The full scale (from the protocol code constants):</p>
|
||||
<table>
|
||||
<thead><tr><th>Denomination</th><th>Grain value</th><th>Equivalent in this Stash</th></tr></thead>
|
||||
<tbody>
|
||||
{{range .BreadScale}}
|
||||
<tr>
|
||||
<td>{{.Name}}</td>
|
||||
<td>{{.GrainValue}}</td>
|
||||
<td>{{if eq .Name "Grain"}}{{$.Stash.BalanceGrain}}{{else}}{{divGrain $.Stash.BalanceGrain .GrainValue}}{{end}}</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<h2>Maturity progress</h2>
|
||||
<p>Holding a Stash continuously for 90 days is the first of the four
|
||||
Freeholder signals. The signal is about continuity, not size.</p>
|
||||
<div class="progress-track">
|
||||
<div class="progress-bar" style="width: {{.MaturityPct}}%">{{.MaturityPct}}%</div>
|
||||
</div>
|
||||
<table class="kv">
|
||||
<tr><th>Active days</th><td>{{.Activity.ActiveDays}} / {{.ThresholdDays}}</td></tr>
|
||||
<tr><th>Max gap days</th><td>{{.Activity.MaxGapDays}} / {{.MaxGapDays}} (max allowed)</td></tr>
|
||||
<tr><th>Mature</th><td>{{if .Mature}}<span class="badge green">Mature</span>{{else}}<span class="badge amber">Not mature</span>{{end}}</td></tr>
|
||||
</table>
|
||||
</section>
|
||||
|
||||
<p><a href="/reach/{{.Stash.HolderID}}">Back to Reach</a></p>
|
||||
{{end}}
|
||||
@@ -0,0 +1,68 @@
|
||||
{{define "title"}}{{.Window.WindowID}} — OpenYield{{end}}
|
||||
|
||||
{{define "content"}}
|
||||
<section class="panel">
|
||||
<h1>{{.Window.WindowID}}</h1>
|
||||
<table class="kv">
|
||||
<tr><th>Window ID</th><td>{{.Window.WindowID}}</td></tr>
|
||||
<tr><th>Grantor</th><td>{{.Window.GrantorHolder}}</td></tr>
|
||||
<tr><th>Grantee</th><td>{{.Window.Grantee}}</td></tr>
|
||||
<tr><th>Scope</th><td>{{.Window.Scope.Kind}} ({{.Window.Scope.ResourceID}})</td></tr>
|
||||
<tr><th>Start</th><td>{{.Window.Start}}</td></tr>
|
||||
<tr><th>End</th><td>{{.Window.End}}</td></tr>
|
||||
<tr><th>Rate limit</th><td>{{.Window.RateLimit.ActionsConsumed}} / {{.Window.RateLimit.MaxActions}} per {{.Window.RateLimit.PerDurationSeconds}}s</td></tr>
|
||||
<tr><th>Revoked</th><td>{{if .Window.Revoked}}yes{{else}}no{{end}}</td></tr>
|
||||
<tr><th>Status</th><td>
|
||||
{{if eq (string .Window.Status) "Open"}}<span class="badge amber">Open</span>{{end}}
|
||||
{{if eq (string .Window.Status) "Active"}}<span class="badge green">Active</span>{{end}}
|
||||
{{if eq (string .Window.Status) "Revoked"}}<span class="badge red">Revoked</span>{{end}}
|
||||
{{if eq (string .Window.Status) "Expired"}}<span class="badge grey">Expired</span>{{end}}
|
||||
</td></tr>
|
||||
</table>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<h2>Lifecycle actions</h2>
|
||||
<p>
|
||||
{{if eq (string .Window.Status) "Open"}}
|
||||
<form method="POST" action="/window/{{.Window.WindowID}}/activate" style="display:inline">
|
||||
<button type="submit">Activate</button>
|
||||
</form>
|
||||
{{end}}
|
||||
{{if or (eq (string .Window.Status) "Open") (eq (string .Window.Status) "Active")}}
|
||||
<form method="POST" action="/window/{{.Window.WindowID}}/revoke" style="display:inline">
|
||||
<button type="submit">Revoke</button>
|
||||
</form>
|
||||
{{end}}
|
||||
{{if or (eq (string .Window.Status) "Open") (eq (string .Window.Status) "Active")}}
|
||||
<form method="POST" action="/window/{{.Window.WindowID}}/expire" style="display:inline">
|
||||
<button type="submit">Expire</button>
|
||||
</form>
|
||||
{{end}}
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<h2>Audit log</h2>
|
||||
{{if .AuditLog}}
|
||||
<table>
|
||||
<thead><tr><th>Entry ID</th><th>Timestamp</th><th>Action</th><th>Result</th><th>Granter</th></tr></thead>
|
||||
<tbody>
|
||||
{{range .AuditLog}}
|
||||
<tr>
|
||||
<td>{{.EntryID}}</td>
|
||||
<td>{{.Timestamp}}</td>
|
||||
<td>{{.Action}}</td>
|
||||
<td>{{.Result}}</td>
|
||||
<td>{{.GranterRef}}</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
{{else}}
|
||||
<p>No audit entries yet.</p>
|
||||
{{end}}
|
||||
</section>
|
||||
|
||||
<p><a href="/window">Back to Window list</a></p>
|
||||
{{end}}
|
||||
@@ -0,0 +1,33 @@
|
||||
{{define "title"}}Window — OpenYield{{end}}
|
||||
|
||||
{{define "content"}}
|
||||
<section class="panel">
|
||||
<h1>Window</h1>
|
||||
<p>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.</p>
|
||||
<p><a href="/window/new" class="btn">Open a Window</a></p>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<h2>Windows for {{.Grantor}}</h2>
|
||||
{{if .Windows}}
|
||||
<table>
|
||||
<thead><tr><th>Window ID</th><th>Grantee</th><th>Scope</th><th>Status</th></tr></thead>
|
||||
<tbody>
|
||||
{{range .Windows}}
|
||||
<tr>
|
||||
<td><a href="/window/{{.WindowID}}">{{.WindowID}}</a></td>
|
||||
<td>{{.Grantee}}</td>
|
||||
<td>{{.Scope.Kind}} ({{.Scope.ResourceID}})</td>
|
||||
<td>{{.Status}}</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
{{else}}
|
||||
<p>No Windows yet for {{.Grantor}}. <a href="/window/new">Open a Window</a> to begin.</p>
|
||||
{{end}}
|
||||
</section>
|
||||
{{end}}
|
||||
@@ -0,0 +1,36 @@
|
||||
{{define "title"}}Open a Window — OpenYield{{end}}
|
||||
|
||||
{{define "content"}}
|
||||
<section class="panel">
|
||||
<h1>Open a Window</h1>
|
||||
<p>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.</p>
|
||||
|
||||
<form method="POST" action="/window" hx-post="/window" hx-target="body">
|
||||
<label for="grantor_holder">Grantor Holder ID</label>
|
||||
<input type="text" id="grantor_holder" name="grantor_holder" required
|
||||
maxlength="128" placeholder="the Holder opening the Window">
|
||||
<label for="grantee">Grantee</label>
|
||||
<input type="text" id="grantee" name="grantee" required
|
||||
maxlength="128" placeholder="the partner or service receiving access">
|
||||
<label for="scope_kind">Scope kind</label>
|
||||
<select id="scope_kind" name="scope_kind">
|
||||
<option value="ReadStash">ReadStash</option>
|
||||
<option value="ReadStanding">ReadStanding</option>
|
||||
<option value="ProcessPassActForStand">ProcessPassActForStand</option>
|
||||
</select>
|
||||
<label for="resource_id">Resource ID</label>
|
||||
<input type="text" id="resource_id" name="resource_id"
|
||||
maxlength="128" placeholder="the Stash or Stand this Window scopes to">
|
||||
<label for="start_unix">Start (unix seconds, blank = now)</label>
|
||||
<input type="number" id="start_unix" name="start_unix" placeholder="blank = now">
|
||||
<label for="end_unix">End (unix seconds, blank = now+1h)</label>
|
||||
<input type="number" id="end_unix" name="end_unix" placeholder="blank = now+1h">
|
||||
<label for="max_actions">Max actions (rate-limit, blank = 10)</label>
|
||||
<input type="number" id="max_actions" name="max_actions" placeholder="10">
|
||||
<button type="submit">Open a Window</button>
|
||||
</form>
|
||||
<p><a href="/window">Back to Window list</a></p>
|
||||
</section>
|
||||
{{end}}
|
||||
Reference in New Issue
Block a user