docs(milestone): complete OpenYield v0.2 (The Mesh)
---ci--- project: oy phase: 5 milestone: v0.2 status: complete phase_role: final requirements: covered: [REQ-009, REQ-011, REQ-015, REQ-016, REQ-017, REQ-018, REQ-020, REQ-021, REQ-012] partial: [] ---/ci--- Milestone v0.2 (The Mesh) complete. Skeleton+tests layer for 9 new modules + 1 extension: x/window (REQ-015, fullest), x/stand (REQ-016, 9 types), x/guild (REQ-017, HandPass 0%), x/pact (REQ-020, 6 types + Mission Lock), x/partner (REQ-018, 4-tier), x/council (REQ-011, 3 councils + Mission Lock const), x/forex (Forex v1, Bread/Asset pairs), x/bond (REQ-021, 8% cap Clamp), x/satellite (REQ-009, 5-chain L2 + ICS-20 v1), x/bearers EXTENDED (OY-LR/Beacon). 303 tests total (53 v0.1 baseline + 250 new). Coverage >=95.9% on all new/extended packages (8 at 100%). Lexicon firewall (REQ-012) project-wide + per-module. G-003 by-ID-string import invariant. 5 phases: P0 (v0.1.0) + P1-P4 (v0.1.1..v0.1.4) + P5 final (v0.1.5 = this milestone release). Per run.md patch-line model: no separate minor tag. 14 clarification decisions (D-020..D-033), 15 research assumptions (A-201..A-215), 31 tasks across 5 phases, 10 grill binding decisions (G-001..G-010) all applied. Next milestone: v0.3 (The Bearers) per ROADMAP Phase 3.
This commit is contained in:
@@ -0,0 +1,29 @@
|
||||
package types
|
||||
|
||||
import "fmt"
|
||||
|
||||
// ValidateAuditLogs enforces the append-only audit-log invariants (REQ-015):
|
||||
// 1. entry-ids are unique (no duplicate entry-id in the slice)
|
||||
// 2. timestamps are non-decreasing (append-only ordering)
|
||||
//
|
||||
// This is the data-engineer's genesis schema (G-008); the test assertions live
|
||||
// in types_test.go (security-engineer's territory). Called by ValidateGenesis
|
||||
// in types.go.
|
||||
func ValidateAuditLogs(logs []AuditEntry) error {
|
||||
seen := make(map[string]bool, len(logs))
|
||||
var lastTs int64 = -1
|
||||
for i, e := range logs {
|
||||
if e.EntryID == "" {
|
||||
return fmt.Errorf("audit log [%d]: empty entry-id", i)
|
||||
}
|
||||
if seen[e.EntryID] {
|
||||
return fmt.Errorf("audit log: duplicate entry-id %q", e.EntryID)
|
||||
}
|
||||
seen[e.EntryID] = true
|
||||
if i > 0 && e.Timestamp < lastTs {
|
||||
return fmt.Errorf("audit log: timestamps must be non-decreasing (entry %q)", e.EntryID)
|
||||
}
|
||||
lastTs = e.Timestamp
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
package types_test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"github.com/oy/openyield/x/window/types"
|
||||
)
|
||||
|
||||
// genesis_test.go holds the security-engineer's test assertions for the
|
||||
// data-engineer's genesis.go schema (G-008 split). The general lifecycle
|
||||
// and lexicon tests live in types_test.go; this file focuses on the
|
||||
// append-only audit-log genesis invariants (REQ-015, P1-01-03).
|
||||
|
||||
// TestGenesisAuditLogAppendOnlyShape asserts the GenesisState carries an
|
||||
// AuditLogs slice and the empty default is non-nil.
|
||||
func TestGenesisAuditLogAppendOnlyShape(t *testing.T) {
|
||||
gs := types.DefaultGenesisState()
|
||||
if gs.AuditLogs == nil {
|
||||
t.Fatal("DefaultGenesisState.AuditLogs should be non-nil empty slice")
|
||||
}
|
||||
// GenesisState must round-trip through JSON with the audit_logs field.
|
||||
bz, err := json.Marshal(gs)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal: %v", err)
|
||||
}
|
||||
var back types.GenesisState
|
||||
if err := json.Unmarshal(bz, &back); err != nil {
|
||||
t.Fatalf("unmarshal: %v", err)
|
||||
}
|
||||
if back.AuditLogs == nil {
|
||||
t.Error("unmarshalled AuditLogs should be non-nil")
|
||||
}
|
||||
}
|
||||
|
||||
// TestGenesisValidateAuditLogAppendOnlyOrdering is the data-engineer's
|
||||
// genesis invariant: timestamps must be non-decreasing (append-only).
|
||||
func TestGenesisValidateAuditLogAppendOnlyOrdering(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
logs []types.AuditEntry
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "single entry ok",
|
||||
logs: []types.AuditEntry{{EntryID: "e1", Timestamp: 100}},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "equal timestamps ok (append-only allows equal)",
|
||||
logs: []types.AuditEntry{
|
||||
{EntryID: "e1", Timestamp: 100},
|
||||
{EntryID: "e2", Timestamp: 100},
|
||||
},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "strictly increasing ok",
|
||||
logs: []types.AuditEntry{
|
||||
{EntryID: "e1", Timestamp: 100},
|
||||
{EntryID: "e2", Timestamp: 200},
|
||||
{EntryID: "e3", Timestamp: 300},
|
||||
},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "decreasing rejected",
|
||||
logs: []types.AuditEntry{
|
||||
{EntryID: "e1", Timestamp: 300},
|
||||
{EntryID: "e2", Timestamp: 100},
|
||||
},
|
||||
wantErr: true,
|
||||
},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
err := types.ValidateAuditLogs(tc.logs)
|
||||
if tc.wantErr && err == nil {
|
||||
t.Error("expected error, got nil")
|
||||
}
|
||||
if !tc.wantErr && err != nil {
|
||||
t.Errorf("expected nil, got: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestGenesisValidateAuditLogNoDupEntryIDs is the data-engineer's genesis
|
||||
// invariant: entry-ids must be unique.
|
||||
func TestGenesisValidateAuditLogNoDupEntryIDs(t *testing.T) {
|
||||
logs := []types.AuditEntry{
|
||||
{EntryID: "e1", Timestamp: 100},
|
||||
{EntryID: "e1", Timestamp: 200}, // dup id
|
||||
}
|
||||
if err := types.ValidateAuditLogs(logs); err == nil {
|
||||
t.Error("ValidateAuditLogs should reject duplicate entry-ids")
|
||||
}
|
||||
}
|
||||
|
||||
// TestGenesisValidateAuditLogRejectsEmptyEntryID asserts the schema rejects
|
||||
// empty entry-ids (every audit entry must be identifiable).
|
||||
func TestGenesisValidateAuditLogRejectsEmptyEntryID(t *testing.T) {
|
||||
logs := []types.AuditEntry{{EntryID: "", Timestamp: 100}}
|
||||
if err := types.ValidateAuditLogs(logs); err == nil {
|
||||
t.Error("ValidateAuditLogs should reject empty entry-id")
|
||||
}
|
||||
}
|
||||
|
||||
// TestGenesisValidateGenesisSurfacesAuditLogErrors asserts ValidateGenesis
|
||||
// composes the audit-log validation into the full genesis validation.
|
||||
func TestGenesisValidateGenesisSurfacesAuditLogErrors(t *testing.T) {
|
||||
gs := types.GenesisState{
|
||||
Windows: []types.Window{{WindowID: "w1"}},
|
||||
AuditLogs: []types.AuditEntry{
|
||||
{EntryID: "e1", Timestamp: 200},
|
||||
{EntryID: "e2", Timestamp: 100}, // out of order
|
||||
},
|
||||
}
|
||||
bz, _ := json.Marshal(gs)
|
||||
if err := types.ValidateGenesis(bz); err == nil {
|
||||
t.Error("ValidateGenesis should surface audit-log ordering error")
|
||||
}
|
||||
}
|
||||
|
||||
// TestGenesisValidateGenesisCleanAuditLog asserts a clean audit log passes
|
||||
// full genesis validation.
|
||||
func TestGenesisValidateGenesisCleanAuditLog(t *testing.T) {
|
||||
gs := types.GenesisState{
|
||||
Windows: []types.Window{{WindowID: "w1"}},
|
||||
AuditLogs: []types.AuditEntry{
|
||||
{EntryID: "e1", Timestamp: 100, Action: "open", Result: "ok", GranterRef: "reach:g"},
|
||||
{EntryID: "e2", Timestamp: 200, Action: "revoke", Result: "ok", GranterRef: "reach:g"},
|
||||
},
|
||||
}
|
||||
bz, _ := json.Marshal(gs)
|
||||
if err := types.ValidateGenesis(bz); err != nil {
|
||||
t.Errorf("ValidateGenesis should accept clean audit log, got: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
package types
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
const (
|
||||
ModuleName = "window"
|
||||
StoreKey = ModuleName
|
||||
RouterKey = ModuleName
|
||||
QuerierRoute = ModuleName
|
||||
)
|
||||
|
||||
// ScopeKind enumerates the access scopes a Window can open (§4.4, REQ-015).
|
||||
// A Window's scope is a structured (kind, resource-id) pair so downstream
|
||||
// modules (Pacts, Partners, Orgs) reference the scope by value, not by
|
||||
// importing this package's structs (G-003 by-ID-string invariant).
|
||||
type ScopeKind string
|
||||
|
||||
const (
|
||||
ScopeReadStash ScopeKind = "ReadStash" // read a Holder's Stash
|
||||
ScopeReadStanding ScopeKind = "ReadStanding" // read a Reach's Standing
|
||||
ScopeProcessPassActForStand ScopeKind = "ProcessPassActForStand" // process a Pass-Act on behalf of a Stand
|
||||
)
|
||||
|
||||
// Scope is a structured scope pair: what the Window opens.
|
||||
type Scope struct {
|
||||
Kind ScopeKind `json:"kind" yaml:"kind"`
|
||||
ResourceID string `json:"resource_id" yaml:"resource_id"`
|
||||
}
|
||||
|
||||
// RateLimit caps the number of actions a Window permits (REQ-015).
|
||||
// A-206: simple counter semantics (actionsConsumed vs maxActions); the
|
||||
// rate-limit algorithm (token bucket vs sliding window) is deferred to v0.3.
|
||||
type RateLimit struct {
|
||||
MaxActions uint32 `json:"max_actions" yaml:"max_actions"`
|
||||
PerDurationSeconds int64 `json:"per_duration_seconds" yaml:"per_duration_seconds"`
|
||||
ActionsConsumed uint32 `json:"actions_consumed" yaml:"actions_consumed"`
|
||||
}
|
||||
|
||||
// Consume increments actions-consumed by one. Returns true if the action was
|
||||
// permitted (under the cap), false if the cap was reached (blocked).
|
||||
// A-206: counter semantics — once actions-consumed == max-actions, further
|
||||
// consumes are blocked until the window resets (v0.3 will define reset).
|
||||
func (r *RateLimit) Consume() bool {
|
||||
if r.ActionsConsumed >= r.MaxActions {
|
||||
return false
|
||||
}
|
||||
r.ActionsConsumed++
|
||||
return true
|
||||
}
|
||||
|
||||
// AuditEntry is an append-only audit-log entry for a Window (REQ-015).
|
||||
// Append-only ordering is enforced by ValidateGenesis (timestamps non-decreasing).
|
||||
type AuditEntry struct {
|
||||
EntryID string `json:"entry_id" yaml:"entry_id"`
|
||||
Timestamp int64 `json:"timestamp" yaml:"timestamp"`
|
||||
Action string `json:"action" yaml:"action"`
|
||||
Result string `json:"result" yaml:"result"`
|
||||
GranterRef string `json:"granter_ref" yaml:"granter_ref"`
|
||||
}
|
||||
|
||||
// WindowStatus enumerates the lifecycle states of a Window (REQ-015).
|
||||
type WindowStatus string
|
||||
|
||||
const (
|
||||
StatusOpen WindowStatus = "Open" // window created, not yet active
|
||||
StatusActive WindowStatus = "Active" // window is live and consumable
|
||||
StatusRevoked WindowStatus = "Revoked" // Holder revoked before expiry
|
||||
StatusExpired WindowStatus = "Expired" // window end-time has passed
|
||||
)
|
||||
|
||||
// WindowStatusCount is the locked count of WindowStatus enum values.
|
||||
// A regression firewall: changing the lifecycle shape breaks this const's test.
|
||||
const WindowStatusCount = 4
|
||||
|
||||
// Window is a Holder-authorized, scope-bounded, time-limited, revocable
|
||||
// delegation of access (REQ-015). Modeled on x/authz Grant + x/feegrant
|
||||
// FeeAllowance + ocap caveat-bound tokens (macaroons), with a rate-limit and
|
||||
// append-only audit log.
|
||||
type Window struct {
|
||||
WindowID string `json:"window_id" yaml:"window_id"`
|
||||
GrantorHolder string `json:"grantor_holder" yaml:"grantor_holder"`
|
||||
Grantee string `json:"grantee" yaml:"grantee"`
|
||||
Scope Scope `json:"scope" yaml:"scope"`
|
||||
Start int64 `json:"start" yaml:"start"`
|
||||
End int64 `json:"end" yaml:"end"`
|
||||
RateLimit RateLimit `json:"rate_limit" yaml:"rate_limit"`
|
||||
Revoked bool `json:"revoked" yaml:"revoked"`
|
||||
Status WindowStatus `json:"status" yaml:"status"`
|
||||
AuditLogRefs []string `json:"audit_log_refs" yaml:"audit_log_refs"`
|
||||
}
|
||||
|
||||
// Revoke transitions a Window to the Revoked status (REQ-015).
|
||||
// Revoke is idempotent: revoking an already-revoked window is a no-op
|
||||
// (returns nil). Revoking an expired window is also a no-op (expired is
|
||||
// a terminal state that wins over revoke). The audit-log entry for the
|
||||
// revoke action is the caller's responsibility (skeleton stub).
|
||||
func (w *Window) Revoke() error {
|
||||
// Expired is terminal: revoke is a no-op on an expired window.
|
||||
if w.Status == StatusExpired {
|
||||
return nil
|
||||
}
|
||||
// Idempotent: revoking an already-revoked window is a no-op.
|
||||
if w.Status == StatusRevoked {
|
||||
return nil
|
||||
}
|
||||
w.Status = StatusRevoked
|
||||
w.Revoked = true
|
||||
return nil
|
||||
}
|
||||
|
||||
// Expire transitions a Window to the Expired status. Used by the (future)
|
||||
// keeper's end-block sweep when now > End. Expire is terminal: a later
|
||||
// Revoke on an expired window is a no-op.
|
||||
func (w *Window) Expire() {
|
||||
w.Status = StatusExpired
|
||||
}
|
||||
|
||||
// Activate transitions a Window from Open to Active (REQ-015 lifecycle).
|
||||
// Only an Open window can be activated.
|
||||
func (w *Window) Activate() error {
|
||||
if w.Status != StatusOpen {
|
||||
return fmt.Errorf("cannot activate window in status %q", w.Status)
|
||||
}
|
||||
w.Status = StatusActive
|
||||
return nil
|
||||
}
|
||||
|
||||
// Params for the window module (skeleton — no tunables in v0.2).
|
||||
type Params struct{}
|
||||
|
||||
func DefaultParams() Params { return Params{} }
|
||||
|
||||
// GenesisState defines the window module genesis state (REQ-015).
|
||||
// AuditLogs is the append-only audit-log slice; ValidateGenesis enforces
|
||||
// non-decreasing timestamps + no dup entry-ids (data-engineer schema, G-008).
|
||||
type GenesisState struct {
|
||||
Params Params `json:"params" yaml:"params"`
|
||||
Windows []Window `json:"windows" yaml:"windows"`
|
||||
AuditLogs []AuditEntry `json:"audit_logs" yaml:"audit_logs"`
|
||||
}
|
||||
|
||||
func DefaultGenesisState() *GenesisState {
|
||||
return &GenesisState{
|
||||
Params: DefaultParams(),
|
||||
Windows: []Window{},
|
||||
AuditLogs: []AuditEntry{},
|
||||
}
|
||||
}
|
||||
|
||||
// ValidateGenesis performs ID-uniqueness checks (A-212 upgrade from v0.1
|
||||
// no-op): rejects duplicate window-ids. Append-only audit-log ordering and
|
||||
// entry-id uniqueness are enforced by genesis.go's ValidateAuditLogs.
|
||||
func ValidateGenesis(bz json.RawMessage) error {
|
||||
var gs GenesisState
|
||||
if err := json.Unmarshal(bz, &gs); err != nil {
|
||||
return fmt.Errorf("window: invalid genesis: %w", err)
|
||||
}
|
||||
seen := make(map[string]bool, len(gs.Windows))
|
||||
for _, w := range gs.Windows {
|
||||
if w.WindowID == "" {
|
||||
return fmt.Errorf("window: empty window-id")
|
||||
}
|
||||
if seen[w.WindowID] {
|
||||
return fmt.Errorf("window: duplicate window-id %q", w.WindowID)
|
||||
}
|
||||
seen[w.WindowID] = true
|
||||
}
|
||||
if err := ValidateAuditLogs(gs.AuditLogs); err != nil {
|
||||
return fmt.Errorf("window: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,537 @@
|
||||
package types_test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"go/parser"
|
||||
"go/token"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/oy/openyield/lexicon"
|
||||
"github.com/oy/openyield/x/window/types"
|
||||
)
|
||||
|
||||
// TestWindowStatusCountLockedConst asserts the WindowStatus enum count is
|
||||
// exactly 4 (Open, Active, Revoked, Expired). A regression firewall: adding
|
||||
// or removing a status breaks this test.
|
||||
func TestWindowStatusCountLockedConst(t *testing.T) {
|
||||
if types.WindowStatusCount != 4 {
|
||||
t.Errorf("WindowStatusCount = %d, expected 4 (Open/Active/Revoked/Expired LOCKED)", types.WindowStatusCount)
|
||||
}
|
||||
statuses := []types.WindowStatus{
|
||||
types.StatusOpen, types.StatusActive, types.StatusRevoked, types.StatusExpired,
|
||||
}
|
||||
if len(statuses) != 4 {
|
||||
t.Errorf("expected 4 WindowStatus consts, got %d", len(statuses))
|
||||
}
|
||||
seen := map[types.WindowStatus]bool{}
|
||||
for _, s := range statuses {
|
||||
if seen[s] {
|
||||
t.Errorf("duplicate WindowStatus %q", s)
|
||||
}
|
||||
seen[s] = true
|
||||
}
|
||||
}
|
||||
|
||||
// TestWindowLifecycleOpenActiveRevokedExpired walks the full lifecycle:
|
||||
// Open → Active → Revoked → Expired (terminal).
|
||||
func TestWindowLifecycleOpenActiveRevokedExpired(t *testing.T) {
|
||||
w := types.Window{Status: types.StatusOpen}
|
||||
if w.Status != types.StatusOpen {
|
||||
t.Fatalf("expected Open, got %q", w.Status)
|
||||
}
|
||||
if err := w.Activate(); err != nil {
|
||||
t.Fatalf("Activate: %v", err)
|
||||
}
|
||||
if w.Status != types.StatusActive {
|
||||
t.Fatalf("expected Active, got %q", w.Status)
|
||||
}
|
||||
if err := w.Revoke(); err != nil {
|
||||
t.Fatalf("Revoke: %v", err)
|
||||
}
|
||||
if w.Status != types.StatusRevoked {
|
||||
t.Fatalf("expected Revoked, got %q", w.Status)
|
||||
}
|
||||
if !w.Revoked {
|
||||
t.Fatal("Revoked flag should be true after Revoke()")
|
||||
}
|
||||
// Expire is terminal and is invoked by the keeper end-block sweep.
|
||||
w.Expire()
|
||||
// Note: once Revoked, Expire() sets Status to Expired — the lifecycle
|
||||
// test exercises each transition; the terminal-wins-over-revoke invariant
|
||||
// is tested separately (TestRevokeAfterExpireIsNoOp).
|
||||
}
|
||||
|
||||
// TestRevokeTransitionsToRevoked asserts Revoke() on an Active window moves
|
||||
// it to Revoked and sets the Revoked flag.
|
||||
func TestRevokeTransitionsToRevoked(t *testing.T) {
|
||||
w := types.Window{Status: types.StatusActive}
|
||||
if err := w.Revoke(); err != nil {
|
||||
t.Fatalf("Revoke on Active: %v", err)
|
||||
}
|
||||
if w.Status != types.StatusRevoked {
|
||||
t.Errorf("expected Revoked, got %q", w.Status)
|
||||
}
|
||||
if !w.Revoked {
|
||||
t.Error("Revoked flag should be true")
|
||||
}
|
||||
}
|
||||
|
||||
// TestRevokeAfterExpireIsNoOp asserts Expired is terminal: a Revoke() call
|
||||
// on an Expired window is a no-op (status stays Expired, no error).
|
||||
func TestRevokeAfterExpireIsNoOp(t *testing.T) {
|
||||
w := types.Window{Status: types.StatusExpired}
|
||||
if err := w.Revoke(); err != nil {
|
||||
t.Fatalf("Revoke on Expired should be no-op, got error: %v", err)
|
||||
}
|
||||
if w.Status != types.StatusExpired {
|
||||
t.Errorf("Revoke on Expired should not change status; got %q", w.Status)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDoubleRevokeIdempotent asserts revoking an already-revoked window is
|
||||
// idempotent (no error, status stays Revoked). The plan says "double-revoke
|
||||
// is idempotent OR error (test both paths)" — the skeleton implements the
|
||||
// idempotent path (returns nil); this test locks that behavior.
|
||||
func TestDoubleRevokeIdempotent(t *testing.T) {
|
||||
w := types.Window{Status: types.StatusActive}
|
||||
_ = w.Revoke()
|
||||
if w.Status != types.StatusRevoked {
|
||||
t.Fatalf("first Revoke failed: %q", w.Status)
|
||||
}
|
||||
if err := w.Revoke(); err != nil {
|
||||
t.Fatalf("second Revoke should be idempotent (no error), got: %v", err)
|
||||
}
|
||||
if w.Status != types.StatusRevoked {
|
||||
t.Errorf("double-revoke should keep status Revoked; got %q", w.Status)
|
||||
}
|
||||
}
|
||||
|
||||
// TestActivateOnlyFromOpen asserts Activate rejects non-Open windows.
|
||||
func TestActivateOnlyFromOpen(t *testing.T) {
|
||||
w := types.Window{Status: types.StatusRevoked}
|
||||
if err := w.Activate(); err == nil {
|
||||
t.Error("Activate on Revoked should error")
|
||||
}
|
||||
w2 := types.Window{Status: types.StatusActive}
|
||||
if err := w2.Activate(); err == nil {
|
||||
t.Error("Activate on already-Active should error")
|
||||
}
|
||||
}
|
||||
|
||||
// TestRateLimitConsumeIncrementsAndBlocks asserts the A-206 counter
|
||||
// semantics: each Consume() increments actions-consumed while under the
|
||||
// cap, and blocks (returns false) once the cap is reached.
|
||||
func TestRateLimitConsumeIncrementsAndBlocks(t *testing.T) {
|
||||
tt := []struct {
|
||||
name string
|
||||
maxActions uint32
|
||||
consumeN int
|
||||
wantLast bool // expected return of the Nth consume
|
||||
wantCount uint32
|
||||
}{
|
||||
{"under cap", 5, 3, true, 3},
|
||||
{"exactly cap", 3, 3, true, 3},
|
||||
{"at cap then block", 2, 3, false, 2}, // 3rd consume blocked
|
||||
{"zero cap blocks all", 0, 1, false, 0},
|
||||
}
|
||||
for _, tc := range tt {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
r := types.RateLimit{MaxActions: tc.maxActions}
|
||||
var last bool
|
||||
for i := 0; i < tc.consumeN; i++ {
|
||||
last = r.Consume()
|
||||
}
|
||||
if last != tc.wantLast {
|
||||
t.Errorf("last Consume() = %v, want %v", last, tc.wantLast)
|
||||
}
|
||||
if r.ActionsConsumed != tc.wantCount {
|
||||
t.Errorf("ActionsConsumed = %d, want %d", r.ActionsConsumed, tc.wantCount)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestScopeKindEnumCoverage asserts all three ScopeKind values are distinct
|
||||
// and non-empty (REQ-015 scope set).
|
||||
func TestScopeKindEnumCoverage(t *testing.T) {
|
||||
kinds := []types.ScopeKind{
|
||||
types.ScopeReadStash, types.ScopeReadStanding, types.ScopeProcessPassActForStand,
|
||||
}
|
||||
if len(kinds) != 3 {
|
||||
t.Errorf("expected 3 ScopeKind consts, got %d", len(kinds))
|
||||
}
|
||||
seen := map[types.ScopeKind]bool{}
|
||||
for _, k := range kinds {
|
||||
if k == "" {
|
||||
t.Error("empty ScopeKind")
|
||||
}
|
||||
if seen[k] {
|
||||
t.Errorf("duplicate ScopeKind %q", k)
|
||||
}
|
||||
seen[k] = true
|
||||
}
|
||||
}
|
||||
|
||||
// TestScopeStruct asserts Scope carries kind + resource-id.
|
||||
func TestScopeStruct(t *testing.T) {
|
||||
s := types.Scope{Kind: types.ScopeReadStash, ResourceID: "reach:abc"}
|
||||
if s.Kind != types.ScopeReadStash {
|
||||
t.Errorf("Kind = %q", s.Kind)
|
||||
}
|
||||
if s.ResourceID != "reach:abc" {
|
||||
t.Errorf("ResourceID = %q", s.ResourceID)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDefaultGenesisStateEmpty asserts DefaultGenesisState returns empty
|
||||
// slices (not nil) for Windows and AuditLogs.
|
||||
func TestDefaultGenesisStateEmpty(t *testing.T) {
|
||||
gs := types.DefaultGenesisState()
|
||||
if gs == nil {
|
||||
t.Fatal("DefaultGenesisState returned nil")
|
||||
}
|
||||
if len(gs.Windows) != 0 {
|
||||
t.Errorf("Default Windows len = %d, want 0", len(gs.Windows))
|
||||
}
|
||||
if gs.Windows == nil {
|
||||
t.Error("Default Windows should be non-nil empty slice")
|
||||
}
|
||||
if len(gs.AuditLogs) != 0 {
|
||||
t.Errorf("Default AuditLogs len = %d, want 0", len(gs.AuditLogs))
|
||||
}
|
||||
if gs.AuditLogs == nil {
|
||||
t.Error("Default AuditLogs should be non-nil empty slice")
|
||||
}
|
||||
}
|
||||
|
||||
// TestValidateGenesisRejectsDupWindowIDs asserts A-212: duplicate window-ids
|
||||
// are rejected (upgrade from v0.1's no-op ValidateGenesis).
|
||||
func TestValidateGenesisRejectsDupWindowIDs(t *testing.T) {
|
||||
gs := types.GenesisState{
|
||||
Windows: []types.Window{
|
||||
{WindowID: "w1"},
|
||||
{WindowID: "w1"}, // dup
|
||||
},
|
||||
}
|
||||
bz, _ := json.Marshal(gs)
|
||||
if err := types.ValidateGenesis(bz); err == nil {
|
||||
t.Error("ValidateGenesis should reject duplicate window-ids")
|
||||
}
|
||||
}
|
||||
|
||||
// TestValidateGenesisAcceptsUniqueIDs asserts a clean genesis validates.
|
||||
func TestValidateGenesisAcceptsUniqueIDs(t *testing.T) {
|
||||
gs := types.GenesisState{
|
||||
Windows: []types.Window{
|
||||
{WindowID: "w1"},
|
||||
{WindowID: "w2"},
|
||||
},
|
||||
}
|
||||
bz, _ := json.Marshal(gs)
|
||||
if err := types.ValidateGenesis(bz); err != nil {
|
||||
t.Errorf("ValidateGenesis should accept unique ids, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestValidateGenesisRejectsEmptyWindowID asserts empty window-id is rejected.
|
||||
func TestValidateGenesisRejectsEmptyWindowID(t *testing.T) {
|
||||
gs := types.GenesisState{
|
||||
Windows: []types.Window{{WindowID: ""}},
|
||||
}
|
||||
bz, _ := json.Marshal(gs)
|
||||
if err := types.ValidateGenesis(bz); err == nil {
|
||||
t.Error("ValidateGenesis should reject empty window-id")
|
||||
}
|
||||
}
|
||||
|
||||
// TestValidateGenesisRejectsBadJSON asserts malformed JSON is rejected.
|
||||
func TestValidateGenesisRejectsBadJSON(t *testing.T) {
|
||||
if err := types.ValidateGenesis(json.RawMessage(`{not json`)); err == nil {
|
||||
t.Error("ValidateGenesis should reject malformed JSON")
|
||||
}
|
||||
}
|
||||
|
||||
// TestAuditLogAppendOnlyOrdering asserts ValidateAuditLogs rejects
|
||||
// non-decreasing timestamps (append-only invariant, data-engineer schema).
|
||||
func TestAuditLogAppendOnlyOrdering(t *testing.T) {
|
||||
tt := []struct {
|
||||
name string
|
||||
logs []types.AuditEntry
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "empty ok",
|
||||
logs: []types.AuditEntry{},
|
||||
},
|
||||
{
|
||||
name: "non-decreasing ok",
|
||||
logs: []types.AuditEntry{
|
||||
{EntryID: "a1", Timestamp: 100},
|
||||
{EntryID: "a2", Timestamp: 100},
|
||||
{EntryID: "a3", Timestamp: 200},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "decreasing rejected",
|
||||
logs: []types.AuditEntry{
|
||||
{EntryID: "a1", Timestamp: 200},
|
||||
{EntryID: "a2", Timestamp: 100}, // out of order
|
||||
},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "dup entry-id rejected",
|
||||
logs: []types.AuditEntry{
|
||||
{EntryID: "a1", Timestamp: 100},
|
||||
{EntryID: "a1", Timestamp: 200}, // dup id
|
||||
},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "empty entry-id rejected",
|
||||
logs: []types.AuditEntry{
|
||||
{EntryID: "", Timestamp: 100},
|
||||
},
|
||||
wantErr: true,
|
||||
},
|
||||
}
|
||||
for _, tc := range tt {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
err := types.ValidateAuditLogs(tc.logs)
|
||||
if tc.wantErr && err == nil {
|
||||
t.Error("expected error, got nil")
|
||||
}
|
||||
if !tc.wantErr && err != nil {
|
||||
t.Errorf("expected nil, got: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestValidateGenesisRejectsBadAuditLog asserts ValidateGenesis surfaces
|
||||
// audit-log errors.
|
||||
func TestValidateGenesisRejectsBadAuditLog(t *testing.T) {
|
||||
gs := types.GenesisState{
|
||||
AuditLogs: []types.AuditEntry{
|
||||
{EntryID: "a1", Timestamp: 200},
|
||||
{EntryID: "a2", Timestamp: 100}, // out of order
|
||||
},
|
||||
}
|
||||
bz, _ := json.Marshal(gs)
|
||||
if err := types.ValidateGenesis(bz); err == nil {
|
||||
t.Error("ValidateGenesis should reject out-of-order audit logs")
|
||||
}
|
||||
}
|
||||
|
||||
// TestAuditEntryStruct asserts AuditEntry carries all required fields.
|
||||
func TestAuditEntryStruct(t *testing.T) {
|
||||
e := types.AuditEntry{
|
||||
EntryID: "a1",
|
||||
Timestamp: 100,
|
||||
Action: "revoke",
|
||||
Result: "ok",
|
||||
GranterRef: "reach:granter",
|
||||
}
|
||||
if e.EntryID != "a1" || e.Timestamp != 100 || e.Action != "revoke" ||
|
||||
e.Result != "ok" || e.GranterRef != "reach:granter" {
|
||||
t.Error("AuditEntry fields not set correctly")
|
||||
}
|
||||
}
|
||||
|
||||
// TestWindowStructFields asserts Window carries all required fields.
|
||||
func TestWindowStructFields(t *testing.T) {
|
||||
w := types.Window{
|
||||
WindowID: "w1",
|
||||
GrantorHolder: "reach:grantor",
|
||||
Grantee: "reach:grantee",
|
||||
Scope: types.Scope{Kind: types.ScopeReadStash, ResourceID: "stash:1"},
|
||||
Start: 100,
|
||||
End: 200,
|
||||
RateLimit: types.RateLimit{MaxActions: 5, PerDurationSeconds: 60},
|
||||
Status: types.StatusOpen,
|
||||
AuditLogRefs: []string{"a1", "a2"},
|
||||
}
|
||||
if w.WindowID != "w1" || w.GrantorHolder != "reach:grantor" ||
|
||||
w.Grantee != "reach:grantee" || w.Start != 100 || w.End != 200 ||
|
||||
w.Status != types.StatusOpen || len(w.AuditLogRefs) != 2 {
|
||||
t.Error("Window fields not set correctly")
|
||||
}
|
||||
}
|
||||
|
||||
// TestModuleConsts asserts the four Cosmos-convention module consts.
|
||||
func TestModuleConsts(t *testing.T) {
|
||||
if types.ModuleName != "window" {
|
||||
t.Errorf("ModuleName = %q, want %q", types.ModuleName, "window")
|
||||
}
|
||||
if types.StoreKey != "window" {
|
||||
t.Errorf("StoreKey = %q", types.StoreKey)
|
||||
}
|
||||
if types.RouterKey != "window" {
|
||||
t.Errorf("RouterKey = %q", types.RouterKey)
|
||||
}
|
||||
if types.QuerierRoute != "window" {
|
||||
t.Errorf("QuerierRoute = %q", types.QuerierRoute)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDefaultParams asserts DefaultParams returns a zero-value Params.
|
||||
func TestDefaultParams(t *testing.T) {
|
||||
_ = types.DefaultParams() // no panics
|
||||
}
|
||||
|
||||
// --- Lexicon assertion (REQ-012) -------------------------------------------------
|
||||
//
|
||||
// The lexicon firewall scans the window package's .go files for the 9 banned
|
||||
// terms. v0.1 is lexicon-clean in practice but has ZERO lexicon tests (G-002);
|
||||
// this is the NEW v0.2 firewall. The project-wide meta-test in P1-04-02
|
||||
// extends this to all x/**/*.go files.
|
||||
|
||||
// TestLexiconNoBannedTermsInWindowPackage scans every non-test .go file in
|
||||
// the window/types package directory for the 9 banned terms (case-insensitive).
|
||||
// Production files only — the test file itself contains the banned terms as
|
||||
// the list of things to forbid, which is the standard lexicon-test
|
||||
// bootstrapping pattern. The project-wide meta-test (P1-04-02) scans all
|
||||
// x/**/*.go (including tests) with self-exclusion.
|
||||
func TestLexiconNoBannedTermsInWindowPackage(t *testing.T) {
|
||||
pkgDir := packageDir(t, "github.com/oy/openyield/x/window/types")
|
||||
files, err := filepath.Glob(filepath.Join(pkgDir, "*.go"))
|
||||
if err != nil {
|
||||
t.Fatalf("glob: %v", err)
|
||||
}
|
||||
prodFiles := []string{}
|
||||
for _, f := range files {
|
||||
if strings.HasSuffix(f, "_test.go") {
|
||||
continue
|
||||
}
|
||||
prodFiles = append(prodFiles, f)
|
||||
}
|
||||
if len(prodFiles) == 0 {
|
||||
t.Fatal("no production .go files found in window/types")
|
||||
}
|
||||
for _, f := range prodFiles {
|
||||
bz, err := os.ReadFile(f)
|
||||
if err != nil {
|
||||
t.Fatalf("read %s: %v", f, err)
|
||||
}
|
||||
if found, ok := lexicon.FindBannedTerm(string(bz)); ok {
|
||||
t.Errorf("%s: banned term %q (REQ-012 lexicon firewall)", filepath.Base(f), found)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- G-003 by-ID-string import invariant -----------------------------------------
|
||||
//
|
||||
// A-203/G-003: no production (non-test) .go file under x/ may import another
|
||||
// x/<module>/types package by struct (enforced as a TESTED invariant, not
|
||||
// just a convention). The skeleton keeps ALL inter-module refs by-ID-string
|
||||
// to avoid import cycles. This test scans every non-test .go file under x/
|
||||
// using go/parser and asserts no import path matches
|
||||
// github.com/oy/openyield/x/<other>/types.
|
||||
|
||||
// TestG003NoCrossModuleStructImportsInProduction scans every non-test .go
|
||||
// file under x/ for imports of other x/<module>/types packages.
|
||||
func TestG003NoCrossModuleStructImportsInProduction(t *testing.T) {
|
||||
xRoot := repoXRoot(t)
|
||||
fset := token.NewFileSet()
|
||||
violations := []string{}
|
||||
err := filepath.Walk(xRoot, func(path string, info os.FileInfo, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if info.IsDir() {
|
||||
return nil
|
||||
}
|
||||
if !strings.HasSuffix(path, ".go") {
|
||||
return nil
|
||||
}
|
||||
// Skip test files (G-003 is about production code only).
|
||||
if strings.HasSuffix(path, "_test.go") {
|
||||
return nil
|
||||
}
|
||||
// Parse imports only (no type checking needed).
|
||||
f, perr := parser.ParseFile(fset, path, nil, parser.ImportsOnly)
|
||||
if perr != nil {
|
||||
return perr
|
||||
}
|
||||
// Derive this file's own module to allow same-package imports.
|
||||
ownTypesPkg := ownTypesImport(path)
|
||||
for _, imp := range f.Imports {
|
||||
ip := strings.Trim(imp.Path.Value, `"`)
|
||||
// Allow a file to import its OWN types package (rare; e.g. an
|
||||
// alias file). Block imports of OTHER x/<module>/types packages.
|
||||
if isForeignTypesImport(ip) && ip != ownTypesPkg {
|
||||
rel, _ := filepath.Rel(xRoot, path)
|
||||
violations = append(violations, rel+" -> "+ip)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("walk: %v", err)
|
||||
}
|
||||
if len(violations) > 0 {
|
||||
t.Errorf("G-003 violation: production files importing foreign x/<module>/types:\n %s",
|
||||
strings.Join(violations, "\n "))
|
||||
}
|
||||
}
|
||||
|
||||
// isForeignTypesImport reports whether ip is an x/<module>/types import
|
||||
// (the form that would create a cross-module struct dependency). It returns
|
||||
// true only for imports matching github.com/oy/openyield/x/<anything>/types.
|
||||
func isForeignTypesImport(ip string) bool {
|
||||
const prefix = "github.com/oy/openyield/x/"
|
||||
if !strings.HasPrefix(ip, prefix) {
|
||||
return false
|
||||
}
|
||||
rest := strings.TrimPrefix(ip, prefix)
|
||||
// x/<module>/types has exactly one "/" after the prefix and ends in /types.
|
||||
// x/<module>/types/foo would be a sub-package (also blocked).
|
||||
parts := strings.Split(rest, "/")
|
||||
if len(parts) < 2 {
|
||||
return false
|
||||
}
|
||||
return parts[len(parts)-1] == "types"
|
||||
}
|
||||
|
||||
// ownTypesImport returns the x/<module>/types import path a file at the
|
||||
// given path belongs to, or "" if the file is not under a types package.
|
||||
func ownTypesImport(path string) string {
|
||||
dir := filepath.Dir(path)
|
||||
if filepath.Base(dir) != "types" {
|
||||
return ""
|
||||
}
|
||||
module := filepath.Base(filepath.Dir(dir))
|
||||
return "github.com/oy/openyield/x/" + module + "/types"
|
||||
}
|
||||
|
||||
// packageDir resolves a Go import path to its filesystem directory by
|
||||
// walking up from this test file. The v0.2 skeleton has zero external deps,
|
||||
// so we use runtime.Caller rather than go/build (which would need GOPATH
|
||||
// setup); the test file's own location anchors the resolution.
|
||||
func packageDir(t *testing.T, importPath string) string {
|
||||
t.Helper()
|
||||
_, file, _, ok := runtime.Caller(0)
|
||||
if !ok {
|
||||
t.Fatal("runtime.Caller failed")
|
||||
}
|
||||
// file = .../oy/x/window/types/types_test.go
|
||||
// repoRoot = .../oy (4 dirs up: types -> window -> x -> oy)
|
||||
repoRoot := filepath.Dir(filepath.Dir(filepath.Dir(filepath.Dir(file))))
|
||||
rel := strings.TrimPrefix(importPath, "github.com/oy/openyield/")
|
||||
return filepath.Join(repoRoot, rel)
|
||||
}
|
||||
|
||||
// repoXRoot returns the absolute path to the repo's x/ directory.
|
||||
func repoXRoot(t *testing.T) string {
|
||||
t.Helper()
|
||||
_, file, _, ok := runtime.Caller(0)
|
||||
if !ok {
|
||||
t.Fatal("runtime.Caller failed")
|
||||
}
|
||||
// file = .../oy/x/window/types/types_test.go -> x/ is 3 dirs up from file
|
||||
return filepath.Dir(filepath.Dir(filepath.Dir(file)))
|
||||
}
|
||||
Reference in New Issue
Block a user