feat(P3): Window authorization — open/lifecycle/audit log (REQ-042)
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---
This commit is contained in:
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user