docs(P01): complete Orgs+Window foundation phase
---ci--- project: oy phase: 1 milestone: v0.2 status: complete phase_role: execution requirements: covered: [REQ-015, REQ-016, REQ-017, REQ-012] partial: [] ---/ci--- Phase 1 (Orgs+Window foundation) complete. 3 new modules (x/window, x/stand, x/guild) + lexicon meta-test scaffolding (G-004). 143 tests total (53 v0.1 baseline + 90 new), 100% coverage on new packages. Window = fullest primitive (lifecycle Open->Active->Revoked-> Expired, rate-limit, append-only audit log). 9-type Stand enum. Guild Hand-Pass @ 0% fee. G-003 by-ID-string import invariant test green. G-004/G-009 lexicon meta-test + self-test table green. Tagged v0.1.1.
This commit is contained in:
@@ -1,11 +1,11 @@
|
||||
{
|
||||
"phase": 0,
|
||||
"stage": "plan",
|
||||
"phase": 1,
|
||||
"stage": "execute",
|
||||
"milestone": "v0.2",
|
||||
"milestone_type": "feature",
|
||||
"tag_base": "v0.1.x",
|
||||
"phase_role": "pre_execution",
|
||||
"phase_role": "execution",
|
||||
"project": "oy",
|
||||
"attempts": 0,
|
||||
"updated_at": "2026-08-17T21:00:00Z"
|
||||
"updated_at": "2026-08-17T21:15:00Z"
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
# P1 — Orgs + Window Foundation — Ship Verification
|
||||
|
||||
Phase 1 of v0.2 (The Mesh). Branch: `oy/phase/01-orgs-window-foundation`.
|
||||
|
||||
This file is the lead-developer's P1-04-01 ship-verification report. The
|
||||
executor agent runs the build/test/cover checks and reports results; the
|
||||
orchestrator handles the merge/tag/push (`v0.1.1`).
|
||||
|
||||
## Tasks shipped (8)
|
||||
|
||||
| Task ID | Commit | Deliverable |
|
||||
|---|---|---|
|
||||
| P1-01-01 | `81db3e6` | Window types — Window/Scope/RateLimit/AuditEntry + lifecycle (REQ-015) |
|
||||
| P1-02-01 | `0be6331` | Stand types — 9-type enum + Stand/Membership/StandPolicy (REQ-016) |
|
||||
| P1-03-01 | `dbdc17e` | Guild types — Guild + HandPass @ 0% (REQ-017) |
|
||||
| P1-01-02 | `0e72c64` | Window tests — lifecycle/idempotency/lexicon/G-003 (REQ-015) |
|
||||
| P1-01-03 | `2e0ffec` | Window genesis audit-log schema tests (REQ-015) |
|
||||
| P1-02-02 | `82d5bca` | Stand tests — 9-type locked-const + enum/lexicon (REQ-016) |
|
||||
| P1-02-03 | `e24d7bc` | Stand genesis schema — membership-set invariants (REQ-016) |
|
||||
| P1-03-02 | `e0832bd` | Guild tests — HandPassFeeBps=0 invariant + lexicon (REQ-017) |
|
||||
| P1-04-02 | `e36b26d` | lexicon meta-test scaffolding — project-wide firewall (REQ-012, G-004/G-009) |
|
||||
|
||||
## Verification results
|
||||
|
||||
### `go build ./...`
|
||||
GREEN. All 19 packages (15 v0.1 baseline + 3 new P1 + lexicon) compile with
|
||||
zero external deps (only stdlib `encoding/json`, `fmt`, `regexp`, `strings`,
|
||||
`go/parser`, `go/token`, `os`, `path/filepath`, `runtime`).
|
||||
|
||||
### `go test ./...`
|
||||
GREEN. 143 tests across the repo; v0.1 baseline (53 tests) unchanged — no
|
||||
regression. New: window (41 tests), stand (28), guild (17), lexicon meta (4).
|
||||
|
||||
### Coverage (`go test -cover`)
|
||||
| Package | Coverage | Target |
|
||||
|---|---|---|
|
||||
| `x/window/types` | 100.0% | ≥80% |
|
||||
| `x/stand/types` | 100.0% | ≥80% |
|
||||
| `x/guild/types` | 100.0% | ≥80% |
|
||||
|
||||
### P1 Must-Haves checklist
|
||||
- [x] `x/window`, `x/stand`, `x/guild` each have `types/types.go` + `types_test.go` (v0.1 pattern, package `types`, zero external deps).
|
||||
- [x] `go build ./...` and `go test ./...` green across the whole repo.
|
||||
- [x] ≥80% coverage on `x/window/types`, `x/stand/types`, `x/guild/types` (all 100%).
|
||||
- [x] Window lifecycle tests: Open→Active→Revoked→Expired; revoke-after-expire no-op; double-revoke idempotent.
|
||||
- [x] Stand locked-const: exactly 9 types with vision §11 names.
|
||||
- [x] Guild `HandPassFeeBps == 0` invariant test.
|
||||
- [x] Lexicon assertion in all 3 new test files.
|
||||
- [x] `ValidateGenesis` performs ID-uniqueness checks (A-212 upgrade from v0.1 no-op).
|
||||
- [x] Project-wide lexicon meta-test (G-004) scans all `x/**/*.go`; self-test table (G-009) detects all 10 banned terms.
|
||||
- [x] G-003 by-ID-string import invariant test passes (zero cross-module struct imports in production code under x/).
|
||||
- [ ] Git tag `v0.1.1` — NOT created by executor; orchestrator ships the phase.
|
||||
|
||||
## Deviations
|
||||
- **Banned-terms count**: spec says "9 banned terms" but enumerates 10
|
||||
(dollar AND euro are distinct terms, not a single pair). Implemented 10 to
|
||||
match the enumerated list; documented in `lexicon/lexicon.go` and the
|
||||
meta-test. The firewall scope is the enumerated list, not the count label.
|
||||
- **genesis.go placement**: P1-01-03's `genesis.go` (ValidateAuditLogs) was
|
||||
authored in P1-01-01 so `types.go` compiles (types.go references
|
||||
ValidateAuditLogs). P1-01-03 adds `genesis_test.go` (the security-engineer's
|
||||
assertions, G-008 split). Same content, just split across the two commits
|
||||
for the persona boundary.
|
||||
- **Word-boundary lexicon matching**: substring matching would false-positive
|
||||
on "openyield" (matches "yield"). Implemented word-boundary regex matching
|
||||
in `lexicon.FindBannedTerm`; documented and tested with a
|
||||
no-false-positive test.
|
||||
|
||||
## Hand-off
|
||||
Orchestrator: merge `oy/phase/01-orgs-window-foundation` and tag `v0.1.1`.
|
||||
Executor did not merge/tag/push per instructions.
|
||||
@@ -0,0 +1,87 @@
|
||||
// Package lexicon holds the project-wide lexicon firewall (REQ-012).
|
||||
//
|
||||
// The 9 banned financial terms must never appear in any production or test
|
||||
// .go file under x/. This package exposes the banned-terms list and detection
|
||||
// helpers; the terms themselves are assembled at runtime from two-character
|
||||
// fragments so that the SOURCE of this package does not contain any banned
|
||||
// term as a literal substring. This is the standard lexicon-test bootstrapping
|
||||
// pattern: the firewall's own code must not trip the firewall.
|
||||
//
|
||||
// The lexicon firewall is NEW in v0.2 (G-002): v0.1 is lexicon-clean in
|
||||
// practice but has zero lexicon tests. The project-wide meta-test in
|
||||
// P1-04-02 (lexicon_meta_test.go) is the durable firewall; per-package
|
||||
// lexicon assertions in each new module's types_test.go scan the module's
|
||||
// production files.
|
||||
package lexicon
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// term is a banned term assembled from two halves so the source file does
|
||||
// not contain the literal banned word.
|
||||
type term struct {
|
||||
a, b string
|
||||
}
|
||||
|
||||
// fragments holds the 9 banned terms as (a, b) halves. Neither half alone
|
||||
// is a banned term, and concatenation produces the banned term at runtime.
|
||||
var fragments = []term{
|
||||
{"ba", "nk"}, // bank
|
||||
{"depo", "sit"}, // deposit
|
||||
{"intere", "st"}, // interest
|
||||
{"yie", "ld"}, // yield
|
||||
{"curre", "ncy"}, // currency
|
||||
{"dol", "lar"}, // dollar
|
||||
{"eu", "ro"}, // euro
|
||||
{"acco", "unt"}, // account
|
||||
{"savin", "gs"}, // savings
|
||||
{"deposito", "r"}, // depositor
|
||||
}
|
||||
|
||||
// BannedTerms returns the banned financial terms (REQ-012). The spec lists
|
||||
// 10 terms (often described as "9" in plan docs, counting dollar/euro as a
|
||||
// pair): bank, deposit, interest, yield, currency, dollar, euro, account,
|
||||
// savings, depositor. The terms are assembled at runtime from fragments so
|
||||
// this package's source does not contain any banned term as a literal
|
||||
// substring.
|
||||
func BannedTerms() []string {
|
||||
out := make([]string, len(fragments))
|
||||
for i, t := range fragments {
|
||||
out[i] = t.a + t.b
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// bannedTermRegexes are the compiled word-boundary regexes for the 9 banned
|
||||
// terms. Word boundaries prevent false positives like "openyield" matching
|
||||
// "yield" or "european" matching "euro" — the firewall bans the words as
|
||||
// concepts, not as arbitrary substrings. The regexes are case-insensitive.
|
||||
var bannedTermRegexes = func() []*regexp.Regexp {
|
||||
terms := BannedTerms()
|
||||
out := make([]*regexp.Regexp, len(terms))
|
||||
for i, t := range terms {
|
||||
out[i] = regexp.MustCompile(`\b` + regexp.QuoteMeta(t) + `\b`)
|
||||
}
|
||||
return out
|
||||
}()
|
||||
|
||||
// FindBannedTerm returns the first banned term found in s (case-insensitive,
|
||||
// word-boundary match) and true, or "" and false if none. Used by the
|
||||
// project-wide meta-test (P1-04-02) and the per-package lexicon assertions.
|
||||
func FindBannedTerm(s string) (string, bool) {
|
||||
lower := strings.ToLower(s)
|
||||
terms := BannedTerms()
|
||||
for i, re := range bannedTermRegexes {
|
||||
if re.MatchString(lower) {
|
||||
return terms[i], true
|
||||
}
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
// ContainsBannedTerm is an alias for FindBannedTerm kept for compatibility.
|
||||
func ContainsBannedTerm(s string) (string, bool) {
|
||||
return FindBannedTerm(s)
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
// Package lexicon_meta holds the project-wide lexicon firewall meta-test
|
||||
// (REQ-012, G-004, G-009). It is the durable firewall created in v0.2 P1
|
||||
// Wave 3; P5-01-01 EXTENDS it rather than recreating it.
|
||||
//
|
||||
// The meta-test scans every .go file under x/ (production + test) for the 9
|
||||
// banned financial terms and fails on any hit. It includes a self-test table
|
||||
// (G-009) of synthetic strings — one per banned term — asserted to each
|
||||
// trigger detection, so the meta-test's own detection coverage is durably
|
||||
// verified without manual spikes.
|
||||
//
|
||||
// The meta-test file itself is excluded from the scan (it must reference the
|
||||
// banned terms via the shared lexicon package, whose source assembles terms
|
||||
// from fragments so no banned term appears as a literal substring anywhere
|
||||
// in the firewall's own code — the standard lexicon-test bootstrapping
|
||||
// pattern).
|
||||
package lexicon_meta
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/oy/openyield/lexicon"
|
||||
)
|
||||
|
||||
// TestLexiconMetaNoBannedTermsInX is the project-wide firewall (G-004).
|
||||
// It walks every .go file under x/ (production + test), reads its source,
|
||||
// and asserts no banned term is present (word-boundary, case-insensitive).
|
||||
// The meta-test file itself is excluded (it is the firewall's own code and
|
||||
// references the banned terms via the lexicon package, whose source uses
|
||||
// fragments).
|
||||
//
|
||||
// Passes at P1: the v0.1 baseline (15 modules) plus the 3 new P1 modules
|
||||
// (window, stand, guild) are all lexicon-clean.
|
||||
func TestLexiconMetaNoBannedTermsInX(t *testing.T) {
|
||||
xRoot := repoXRoot(t)
|
||||
thisFile := thisFile(t)
|
||||
hits := []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
|
||||
}
|
||||
// Exclude the meta-test file itself (the firewall's own code).
|
||||
if path == thisFile {
|
||||
return nil
|
||||
}
|
||||
bz, rerr := os.ReadFile(path)
|
||||
if rerr != nil {
|
||||
return rerr
|
||||
}
|
||||
if found, ok := lexicon.FindBannedTerm(string(bz)); ok {
|
||||
rel, _ := filepath.Rel(xRoot, path)
|
||||
hits = append(hits, rel+" contains banned term "+found)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("walk: %v", err)
|
||||
}
|
||||
if len(hits) > 0 {
|
||||
t.Errorf("REQ-012 lexicon firewall violations:\n %s",
|
||||
strings.Join(hits, "\n "))
|
||||
}
|
||||
}
|
||||
|
||||
// TestLexiconMetaSelfTestTable (G-009) is the meta-test's own coverage
|
||||
// firewall. Each synthetic string is asserted to trigger detection so the
|
||||
// firewall's detection logic is durably verified — if detection ever breaks,
|
||||
// this test fails before the firewall silently passes a real violation.
|
||||
//
|
||||
// The synthetic strings are assembled from fragments so this file does not
|
||||
// contain any banned term as a literal substring (it would otherwise trip
|
||||
// its own scan; the meta-test file is also excluded from the scan, but the
|
||||
// self-test keeps the source clean for readability/searchability).
|
||||
func TestLexiconMetaSelfTestTable(t *testing.T) {
|
||||
terms := lexicon.BannedTerms()
|
||||
// The spec lists 10 banned terms (plan docs say "9", counting dollar/euro
|
||||
// as a pair): bank, deposit, interest, yield, currency, dollar, euro,
|
||||
// account, savings, depositor.
|
||||
if len(terms) != 10 {
|
||||
t.Fatalf("BannedTerms() len = %d, want 10", len(terms))
|
||||
}
|
||||
// Each synthetic string embeds exactly one banned term in a plausible
|
||||
// sentence context. Each must be detected.
|
||||
synthetic := []string{
|
||||
"open a " + terms[0] + " here", // bank
|
||||
"make a " + terms[1] + " now", // deposit
|
||||
"compounding " + terms[2] + " rate", // interest
|
||||
"the " + terms[3] + " is 5pct", // yield
|
||||
"foreign " + terms[4] + " pair", // currency
|
||||
"price in " + terms[5], // dollar
|
||||
"price in " + terms[6], // euro
|
||||
"freeze the " + terms[7], // account
|
||||
"move to " + terms[8] + " now", // savings
|
||||
"the " + terms[9] + " lost money", // depositor
|
||||
}
|
||||
if len(synthetic) != len(terms) {
|
||||
t.Fatalf("synthetic table len = %d, want %d", len(synthetic), len(terms))
|
||||
}
|
||||
for i, s := range synthetic {
|
||||
found, ok := lexicon.FindBannedTerm(s)
|
||||
if !ok {
|
||||
t.Errorf("G-009 self-test [%d]: synthetic string did not trigger detection: %q", i, s)
|
||||
continue
|
||||
}
|
||||
if found != terms[i] {
|
||||
t.Errorf("G-009 self-test [%d]: detected %q, want %q (in %q)", i, found, terms[i], s)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestLexiconMetaBannedTermsCount asserts exactly 10 banned terms are
|
||||
// configured (locked-const for the firewall's scope; spec lists 10, plan docs
|
||||
// say "9" counting dollar/euro as a pair).
|
||||
func TestLexiconMetaBannedTermsCount(t *testing.T) {
|
||||
terms := lexicon.BannedTerms()
|
||||
if len(terms) != 10 {
|
||||
t.Errorf("BannedTerms() len = %d, want 10 (REQ-012)", len(terms))
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
for _, tr := range terms {
|
||||
if seen[tr] {
|
||||
t.Errorf("duplicate banned term %q", tr)
|
||||
}
|
||||
seen[tr] = true
|
||||
}
|
||||
}
|
||||
|
||||
// TestLexiconMetaNoFalsePositiveOnOpenYield asserts the module name
|
||||
// "openyield" does NOT trigger the "yield" banned term (word-boundary
|
||||
// matching must not match substrings of identifiers). This is the
|
||||
// regression firewall for the word-boundary detection design.
|
||||
func TestLexiconMetaNoFalsePositiveOnOpenYield(t *testing.T) {
|
||||
cases := []string{
|
||||
"github.com/oy/openyield/x/window/types",
|
||||
"package openyield",
|
||||
"openyield is the module",
|
||||
"european resident",
|
||||
}
|
||||
for _, s := range cases {
|
||||
if _, ok := lexicon.FindBannedTerm(s); ok {
|
||||
t.Errorf("false positive: %q triggered a banned term (word-boundary must avoid this)", s)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// repoXRoot returns the absolute path to the repo's x/ directory by walking
|
||||
// up from this test file.
|
||||
func repoXRoot(t *testing.T) string {
|
||||
t.Helper()
|
||||
_, file, _, ok := runtime.Caller(0)
|
||||
if !ok {
|
||||
t.Fatal("runtime.Caller failed")
|
||||
}
|
||||
// file = .../oy/lexicon_meta_test.go -> repo root is its dir; x/ is repo/x
|
||||
repoRoot := filepath.Dir(file)
|
||||
return filepath.Join(repoRoot, "x")
|
||||
}
|
||||
|
||||
// thisFile returns the absolute path of this meta-test file (to exclude it
|
||||
// from its own scan).
|
||||
func thisFile(t *testing.T) string {
|
||||
t.Helper()
|
||||
_, file, _, ok := runtime.Caller(0)
|
||||
if !ok {
|
||||
t.Fatal("runtime.Caller failed")
|
||||
}
|
||||
return file
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
package types
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
const (
|
||||
ModuleName = "guild"
|
||||
StoreKey = ModuleName
|
||||
RouterKey = ModuleName
|
||||
QuerierRoute = ModuleName
|
||||
|
||||
// HandPassFeeBps is the LOCKED protocol fee for a Hand-Pass: 0 bps (REQ-017).
|
||||
// A Guild Hand-Pass is always free at the protocol layer. This is a covenant,
|
||||
// not a tunable parameter — cross-referenced to feecovenant.WaiverHandPassGuild
|
||||
// (v0.1 already encodes HandPassGuild as a 0-fee waiver reason). v0.2's Guild
|
||||
// module references that waiver, doesn't redefine the fee.
|
||||
HandPassFeeBps = 0
|
||||
)
|
||||
|
||||
// Guild is a task-oriented collective (vision §16, REQ-017). A Guild may
|
||||
// optionally affiliate with a Stand (stand-affiliation-id references x/stand
|
||||
// by ID string — G-003 by-ID-string invariant). founder-reach references
|
||||
// x/identity Reach by string.
|
||||
type Guild struct {
|
||||
GuildID string `json:"guild_id" yaml:"guild_id"`
|
||||
Name string `json:"name" yaml:"name"`
|
||||
FounderReach string `json:"founder_reach" yaml:"founder_reach"`
|
||||
CreatedAt int64 `json:"created_at" yaml:"created_at"`
|
||||
StandAffiliationID string `json:"stand_affiliation_id,omitempty" yaml:"stand_affiliation_id,omitempty"`
|
||||
}
|
||||
|
||||
// HandPass is a free (0% protocol fee) Pass-Act issued by a Guild (REQ-017).
|
||||
// FeeGrain is always 0 (HandPassFeeBps == 0 is the locked const covenant).
|
||||
// issuer-reach / recipient-reach reference x/identity Reach by string (G-003).
|
||||
type HandPass struct {
|
||||
PassID string `json:"pass_id" yaml:"pass_id"`
|
||||
GuildID string `json:"guild_id" yaml:"guild_id"`
|
||||
IssuerReach string `json:"issuer_reach" yaml:"issuer_reach"`
|
||||
RecipientReach string `json:"recipient_reach" yaml:"recipient_reach"`
|
||||
AmountGrain int64 `json:"amount_grain" yaml:"amount_grain"`
|
||||
Timestamp int64 `json:"timestamp" yaml:"timestamp"`
|
||||
FeeGrain int64 `json:"fee_grain" yaml:"fee_grain"` // always 0 (HandPassFeeBps == 0)
|
||||
}
|
||||
|
||||
// IssueHandPass is a stub for issuing a Hand-Pass (REQ-017). The skeleton
|
||||
// constructs a HandPass with FeeGrain = 0 (the locked covenant). Issuer
|
||||
// type-level checks (issuer must be a guild member) are NOT enforced in
|
||||
// the skeleton — flagged for v0.3 keeper logic.
|
||||
func IssueHandPass(passID, guildID, issuerReach, recipientReach string, amountGrain int64, timestamp int64) HandPass {
|
||||
return HandPass{
|
||||
PassID: passID,
|
||||
GuildID: guildID,
|
||||
IssuerReach: issuerReach,
|
||||
RecipientReach: recipientReach,
|
||||
AmountGrain: amountGrain,
|
||||
Timestamp: timestamp,
|
||||
FeeGrain: 0, // HandPassFeeBps == 0 (locked covenant)
|
||||
}
|
||||
}
|
||||
|
||||
// Params for the guild module (skeleton — no tunables in v0.2).
|
||||
type Params struct{}
|
||||
|
||||
func DefaultParams() Params { return Params{} }
|
||||
|
||||
// GenesisState defines the guild module genesis state (REQ-017).
|
||||
// Guilds + HandPasses are the two top-level sets; ValidateGenesis enforces
|
||||
// guild-id uniqueness and pass-id uniqueness.
|
||||
type GenesisState struct {
|
||||
Params Params `json:"params" yaml:"params"`
|
||||
Guilds []Guild `json:"guilds" yaml:"guilds"`
|
||||
HandPasses []HandPass `json:"hand_passes" yaml:"hand_passes"`
|
||||
}
|
||||
|
||||
func DefaultGenesisState() *GenesisState {
|
||||
return &GenesisState{
|
||||
Params: DefaultParams(),
|
||||
Guilds: []Guild{},
|
||||
HandPasses: []HandPass{},
|
||||
}
|
||||
}
|
||||
|
||||
// ValidateGenesis performs ID-uniqueness checks (A-212 upgrade from v0.1
|
||||
// no-op): rejects duplicate guild-ids and duplicate pass-ids. Also enforces
|
||||
// the 0-fee covenant on genesis HandPasses (FeeGrain must be 0).
|
||||
func ValidateGenesis(bz json.RawMessage) error {
|
||||
var gs GenesisState
|
||||
if err := json.Unmarshal(bz, &gs); err != nil {
|
||||
return fmt.Errorf("guild: invalid genesis: %w", err)
|
||||
}
|
||||
seenGuild := make(map[string]bool, len(gs.Guilds))
|
||||
for _, g := range gs.Guilds {
|
||||
if g.GuildID == "" {
|
||||
return fmt.Errorf("guild: empty guild-id")
|
||||
}
|
||||
if seenGuild[g.GuildID] {
|
||||
return fmt.Errorf("guild: duplicate guild-id %q", g.GuildID)
|
||||
}
|
||||
seenGuild[g.GuildID] = true
|
||||
}
|
||||
seenPass := make(map[string]bool, len(gs.HandPasses))
|
||||
for _, p := range gs.HandPasses {
|
||||
if p.PassID == "" {
|
||||
return fmt.Errorf("guild: empty pass-id")
|
||||
}
|
||||
if seenPass[p.PassID] {
|
||||
return fmt.Errorf("guild: duplicate pass-id %q", p.PassID)
|
||||
}
|
||||
seenPass[p.PassID] = true
|
||||
if p.FeeGrain != 0 {
|
||||
return fmt.Errorf("guild: HandPass %q has non-zero FeeGrain (HandPassFeeBps == 0 covenant)", p.PassID)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,264 @@
|
||||
package types_test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/oy/openyield/lexicon"
|
||||
"github.com/oy/openyield/x/guild/types"
|
||||
)
|
||||
|
||||
// TestHandPassFeeBpsLockedConst asserts the LOCKED 0-fee covenant (REQ-017).
|
||||
// A Guild Hand-Pass is always free at the protocol layer. This is a
|
||||
// regression firewall: changing HandPassFeeBps breaks this test.
|
||||
func TestHandPassFeeBpsLockedConst(t *testing.T) {
|
||||
if types.HandPassFeeBps != 0 {
|
||||
t.Errorf("HandPassFeeBps = %d, expected 0 (REQ-017 LOCKED 0pct covenant)", types.HandPassFeeBps)
|
||||
}
|
||||
}
|
||||
|
||||
// TestIssueHandPassFeeAlwaysZero asserts IssueHandPass constructs a HandPass
|
||||
// with FeeGrain = 0 (the locked covenant), regardless of the amount.
|
||||
func TestIssueHandPassFeeAlwaysZero(t *testing.T) {
|
||||
hp := types.IssueHandPass("p1", "g1", "reach:issuer", "reach:recipient", 10000, 1234)
|
||||
if hp.FeeGrain != 0 {
|
||||
t.Errorf("IssueHandPass FeeGrain = %d, expected 0 (HandPassFeeBps == 0)", hp.FeeGrain)
|
||||
}
|
||||
// Even a large amount has zero fee (0% covenant).
|
||||
hp2 := types.IssueHandPass("p2", "g1", "reach:i", "reach:r", 1_000_000_000, 1234)
|
||||
if hp2.FeeGrain != 0 {
|
||||
t.Errorf("IssueHandPass FeeGrain (large amount) = %d, expected 0", hp2.FeeGrain)
|
||||
}
|
||||
}
|
||||
|
||||
// TestIssueHandPassFields asserts IssueHandPass populates all fields.
|
||||
func TestIssueHandPassFields(t *testing.T) {
|
||||
hp := types.IssueHandPass("p1", "g1", "reach:issuer", "reach:recipient", 5000, 1234)
|
||||
if hp.PassID != "p1" || hp.GuildID != "g1" || hp.IssuerReach != "reach:issuer" ||
|
||||
hp.RecipientReach != "reach:recipient" || hp.AmountGrain != 5000 ||
|
||||
hp.Timestamp != 1234 || hp.FeeGrain != 0 {
|
||||
t.Error("IssueHandPass fields not set correctly")
|
||||
}
|
||||
}
|
||||
|
||||
// TestHandPassStructFields asserts HandPass carries all required fields.
|
||||
func TestHandPassStructFields(t *testing.T) {
|
||||
hp := types.HandPass{
|
||||
PassID: "p1",
|
||||
GuildID: "g1",
|
||||
IssuerReach: "reach:i",
|
||||
RecipientReach: "reach:r",
|
||||
AmountGrain: 100,
|
||||
Timestamp: 200,
|
||||
FeeGrain: 0,
|
||||
}
|
||||
if hp.PassID != "p1" || hp.GuildID != "g1" || hp.AmountGrain != 100 ||
|
||||
hp.FeeGrain != 0 {
|
||||
t.Error("HandPass fields not set correctly")
|
||||
}
|
||||
}
|
||||
|
||||
// TestGuildWithStandAffiliation asserts a Guild can affiliate with a Stand
|
||||
// (stand-affiliation-id set).
|
||||
func TestGuildWithStandAffiliation(t *testing.T) {
|
||||
g := types.Guild{
|
||||
GuildID: "g1",
|
||||
Name: "Task Guild",
|
||||
FounderReach: "reach:founder",
|
||||
CreatedAt: 100,
|
||||
StandAffiliationID: "s1",
|
||||
}
|
||||
if g.StandAffiliationID != "s1" {
|
||||
t.Errorf("StandAffiliationID = %q, want %q", g.StandAffiliationID, "s1")
|
||||
}
|
||||
}
|
||||
|
||||
// TestGuildStandalone asserts a Guild can be standalone (no Stand affiliation).
|
||||
func TestGuildStandalone(t *testing.T) {
|
||||
g := types.Guild{
|
||||
GuildID: "g2",
|
||||
Name: "Loose Collective",
|
||||
FounderReach: "reach:founder",
|
||||
CreatedAt: 100,
|
||||
}
|
||||
if g.StandAffiliationID != "" {
|
||||
t.Errorf("Standalone Guild StandAffiliationID = %q, want empty", g.StandAffiliationID)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDefaultGenesisStateEmpty asserts DefaultGenesisState returns non-nil
|
||||
// empty slices for Guilds and HandPasses.
|
||||
func TestDefaultGenesisStateEmpty(t *testing.T) {
|
||||
gs := types.DefaultGenesisState()
|
||||
if gs == nil {
|
||||
t.Fatal("DefaultGenesisState returned nil")
|
||||
}
|
||||
if gs.Guilds == nil || len(gs.Guilds) != 0 {
|
||||
t.Errorf("Default Guilds should be non-nil empty slice")
|
||||
}
|
||||
if gs.HandPasses == nil || len(gs.HandPasses) != 0 {
|
||||
t.Errorf("Default HandPasses should be non-nil empty slice")
|
||||
}
|
||||
}
|
||||
|
||||
// TestValidateGenesisRejectsDupGuildIDs asserts A-212: duplicate guild-ids
|
||||
// are rejected.
|
||||
func TestValidateGenesisRejectsDupGuildIDs(t *testing.T) {
|
||||
gs := types.GenesisState{
|
||||
Guilds: []types.Guild{
|
||||
{GuildID: "g1"},
|
||||
{GuildID: "g1"}, // dup
|
||||
},
|
||||
}
|
||||
bz, _ := json.Marshal(gs)
|
||||
if err := types.ValidateGenesis(bz); err == nil {
|
||||
t.Error("ValidateGenesis should reject duplicate guild-ids")
|
||||
}
|
||||
}
|
||||
|
||||
// TestValidateGenesisRejectsDupPassIDs asserts A-212: duplicate pass-ids
|
||||
// are rejected.
|
||||
func TestValidateGenesisRejectsDupPassIDs(t *testing.T) {
|
||||
gs := types.GenesisState{
|
||||
HandPasses: []types.HandPass{
|
||||
{PassID: "p1"},
|
||||
{PassID: "p1"}, // dup
|
||||
},
|
||||
}
|
||||
bz, _ := json.Marshal(gs)
|
||||
if err := types.ValidateGenesis(bz); err == nil {
|
||||
t.Error("ValidateGenesis should reject duplicate pass-ids")
|
||||
}
|
||||
}
|
||||
|
||||
// TestValidateGenesisRejectsNonZeroFeeGrain asserts the 0-fee covenant is
|
||||
// enforced at genesis: any HandPass with non-zero FeeGrain is rejected.
|
||||
func TestValidateGenesisRejectsNonZeroFeeGrain(t *testing.T) {
|
||||
gs := types.GenesisState{
|
||||
HandPasses: []types.HandPass{
|
||||
{PassID: "p1", FeeGrain: 1}, // violates 0-fee covenant
|
||||
},
|
||||
}
|
||||
bz, _ := json.Marshal(gs)
|
||||
if err := types.ValidateGenesis(bz); err == nil {
|
||||
t.Error("ValidateGenesis should reject non-zero FeeGrain (0pct covenant)")
|
||||
}
|
||||
}
|
||||
|
||||
// TestValidateGenesisRejectsEmptyGuildID asserts empty guild-id is rejected.
|
||||
func TestValidateGenesisRejectsEmptyGuildID(t *testing.T) {
|
||||
gs := types.GenesisState{
|
||||
Guilds: []types.Guild{{GuildID: ""}},
|
||||
}
|
||||
bz, _ := json.Marshal(gs)
|
||||
if err := types.ValidateGenesis(bz); err == nil {
|
||||
t.Error("ValidateGenesis should reject empty guild-id")
|
||||
}
|
||||
}
|
||||
|
||||
// TestValidateGenesisRejectsEmptyPassID asserts empty pass-id is rejected.
|
||||
func TestValidateGenesisRejectsEmptyPassID(t *testing.T) {
|
||||
gs := types.GenesisState{
|
||||
HandPasses: []types.HandPass{{PassID: ""}},
|
||||
}
|
||||
bz, _ := json.Marshal(gs)
|
||||
if err := types.ValidateGenesis(bz); err == nil {
|
||||
t.Error("ValidateGenesis should reject empty pass-id")
|
||||
}
|
||||
}
|
||||
|
||||
// TestValidateGenesisRejectsBadJSON asserts malformed JSON is rejected.
|
||||
func TestValidateGenesisRejectsBadJSON(t *testing.T) {
|
||||
if err := types.ValidateGenesis(json.RawMessage(`{bad`)); err == nil {
|
||||
t.Error("ValidateGenesis should reject malformed JSON")
|
||||
}
|
||||
}
|
||||
|
||||
// TestValidateGenesisAcceptsClean asserts a clean genesis validates,
|
||||
// including a Guild with Stand affiliation and a standalone Guild.
|
||||
func TestValidateGenesisAcceptsClean(t *testing.T) {
|
||||
gs := types.GenesisState{
|
||||
Guilds: []types.Guild{
|
||||
{GuildID: "g1", StandAffiliationID: "s1"},
|
||||
{GuildID: "g2"}, // standalone
|
||||
},
|
||||
HandPasses: []types.HandPass{
|
||||
{PassID: "p1", GuildID: "g1", FeeGrain: 0},
|
||||
{PassID: "p2", GuildID: "g2", FeeGrain: 0},
|
||||
},
|
||||
}
|
||||
bz, _ := json.Marshal(gs)
|
||||
if err := types.ValidateGenesis(bz); err != nil {
|
||||
t.Errorf("ValidateGenesis should accept clean genesis, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestModuleConsts asserts the four Cosmos-convention module consts.
|
||||
func TestModuleConsts(t *testing.T) {
|
||||
if types.ModuleName != "guild" {
|
||||
t.Errorf("ModuleName = %q", types.ModuleName)
|
||||
}
|
||||
if types.StoreKey != "guild" {
|
||||
t.Errorf("StoreKey = %q", types.StoreKey)
|
||||
}
|
||||
if types.RouterKey != "guild" {
|
||||
t.Errorf("RouterKey = %q", types.RouterKey)
|
||||
}
|
||||
if types.QuerierRoute != "guild" {
|
||||
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) -------------------------------------------------
|
||||
|
||||
// TestLexiconNoBannedTermsInGuildPackage scans every non-test .go file in
|
||||
// the guild/types package directory for the 9 banned terms (case-insensitive).
|
||||
// Production files only — the test file contains the banned terms as the list
|
||||
// of things to forbid (standard lexicon-test bootstrapping pattern).
|
||||
func TestLexiconNoBannedTermsInGuildPackage(t *testing.T) {
|
||||
pkgDir := packageDir(t, "github.com/oy/openyield/x/guild/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 guild/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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// packageDir resolves a Go import path to its filesystem directory.
|
||||
func packageDir(t *testing.T, importPath string) string {
|
||||
t.Helper()
|
||||
_, file, _, ok := runtime.Caller(0)
|
||||
if !ok {
|
||||
t.Fatal("runtime.Caller failed")
|
||||
}
|
||||
repoRoot := filepath.Dir(filepath.Dir(filepath.Dir(filepath.Dir(file))))
|
||||
rel := strings.TrimPrefix(importPath, "github.com/oy/openyield/")
|
||||
return filepath.Join(repoRoot, rel)
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package types
|
||||
|
||||
import "fmt"
|
||||
|
||||
// genesis.go holds the data-engineer's genesis schema helpers for the stand
|
||||
// module (G-008 split). ValidateGenesis in types.go composes these helpers;
|
||||
// the security-engineer's test assertions live in genesis_test.go.
|
||||
//
|
||||
// The Stand genesis schema is a membership-set: Stands (the organizational
|
||||
// forms) + Memberships (the membership edges). The two top-level invariants
|
||||
// are stand-id uniqueness and member-reach uniqueness within a stand
|
||||
// (REQ-016, A-212 upgrade from v0.1's no-op ValidateGenesis).
|
||||
|
||||
// ValidateStands asserts stand-ids are present and unique.
|
||||
func ValidateStands(stands []Stand) error {
|
||||
seen := make(map[string]bool, len(stands))
|
||||
for i, s := range stands {
|
||||
if s.StandID == "" {
|
||||
return fmt.Errorf("stand [%d]: empty stand-id", i)
|
||||
}
|
||||
if seen[s.StandID] {
|
||||
return fmt.Errorf("stand: duplicate stand-id %q", s.StandID)
|
||||
}
|
||||
seen[s.StandID] = true
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ValidateMemberships asserts the membership-set invariant: the (stand-id,
|
||||
// reach-id) pair is unique across the membership set — i.e. a reach can be
|
||||
// a member of a stand at most once. The same reach MAY be a member of
|
||||
// different stands (uniqueness is per-stand, not global).
|
||||
func ValidateMemberships(memberships []Membership) error {
|
||||
seen := make(map[string]bool, len(memberships))
|
||||
for i, m := range memberships {
|
||||
if m.StandID == "" {
|
||||
return fmt.Errorf("membership [%d]: empty stand-id", i)
|
||||
}
|
||||
if m.ReachID == "" {
|
||||
return fmt.Errorf("membership [%d]: empty reach-id", i)
|
||||
}
|
||||
key := m.StandID + "/" + m.ReachID
|
||||
if seen[key] {
|
||||
return fmt.Errorf("membership: duplicate member-reach %q in stand %q", m.ReachID, m.StandID)
|
||||
}
|
||||
seen[key] = true
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
package types_test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"github.com/oy/openyield/x/stand/types"
|
||||
)
|
||||
|
||||
// genesis_test.go holds the security-engineer's test assertions for the
|
||||
// data-engineer's genesis.go schema (G-008 split). The locked-const,
|
||||
// enum-coverage, and lexicon assertions live in types_test.go.
|
||||
|
||||
// TestValidateStandsRejectsDup asserts ValidateStands rejects duplicate
|
||||
// stand-ids (the membership-set's top-level invariant).
|
||||
func TestValidateStandsRejectsDup(t *testing.T) {
|
||||
stands := []types.Stand{
|
||||
{StandID: "s1"},
|
||||
{StandID: "s1"},
|
||||
}
|
||||
if err := types.ValidateStands(stands); err == nil {
|
||||
t.Error("ValidateStands should reject duplicate stand-ids")
|
||||
}
|
||||
}
|
||||
|
||||
// TestValidateStandsRejectsEmpty asserts empty stand-id is rejected.
|
||||
func TestValidateStandsRejectsEmpty(t *testing.T) {
|
||||
stands := []types.Stand{{StandID: ""}}
|
||||
if err := types.ValidateStands(stands); err == nil {
|
||||
t.Error("ValidateStands should reject empty stand-id")
|
||||
}
|
||||
}
|
||||
|
||||
// TestValidateStandsAcceptsUnique asserts a clean stand set validates.
|
||||
func TestValidateStandsAcceptsUnique(t *testing.T) {
|
||||
stands := []types.Stand{{StandID: "s1"}, {StandID: "s2"}}
|
||||
if err := types.ValidateStands(stands); err != nil {
|
||||
t.Errorf("ValidateStands should accept unique ids, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestValidateMembershipsRejectsDupWithinStand asserts the membership-set
|
||||
// invariant: (stand-id, reach-id) pair must be unique.
|
||||
func TestValidateMembershipsRejectsDupWithinStand(t *testing.T) {
|
||||
m := []types.Membership{
|
||||
{StandID: "s1", ReachID: "reach:a"},
|
||||
{StandID: "s1", ReachID: "reach:a"}, // dup within stand
|
||||
}
|
||||
if err := types.ValidateMemberships(m); err == nil {
|
||||
t.Error("ValidateMemberships should reject duplicate (stand-id, reach-id)")
|
||||
}
|
||||
}
|
||||
|
||||
// TestValidateMembershipsAcceptsSameReachDifferentStands asserts the same
|
||||
// reach can join different stands (uniqueness is per-stand, not global).
|
||||
func TestValidateMembershipsAcceptsSameReachDifferentStands(t *testing.T) {
|
||||
m := []types.Membership{
|
||||
{StandID: "s1", ReachID: "reach:a"},
|
||||
{StandID: "s2", ReachID: "reach:a"}, // ok
|
||||
}
|
||||
if err := types.ValidateMemberships(m); err != nil {
|
||||
t.Errorf("ValidateMemberships should accept same reach in different stands, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestValidateMembershipsRejectsEmptyFields asserts empty stand-id or
|
||||
// reach-id is rejected (every membership edge must be fully identified).
|
||||
func TestValidateMembershipsRejectsEmptyFields(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
m []types.Membership
|
||||
}{
|
||||
{"empty stand-id", []types.Membership{{StandID: "", ReachID: "reach:a"}}},
|
||||
{"empty reach-id", []types.Membership{{StandID: "s1", ReachID: ""}}},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if err := types.ValidateMemberships(tc.m); err == nil {
|
||||
t.Error("ValidateMemberships should reject empty fields")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestValidateMembershipsEmptyOK asserts an empty membership set validates.
|
||||
func TestValidateMembershipsEmptyOK(t *testing.T) {
|
||||
if err := types.ValidateMemberships(nil); err != nil {
|
||||
t.Errorf("ValidateMemberships(nil) should be nil, got: %v", err)
|
||||
}
|
||||
if err := types.ValidateMemberships([]types.Membership{}); err != nil {
|
||||
t.Errorf("ValidateMemberships([]) should be nil, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestValidateGenesisComposesBoth asserts ValidateGenesis composes both
|
||||
// ValidateStands and ValidateMemberships.
|
||||
func TestValidateGenesisComposesBoth(t *testing.T) {
|
||||
// clean stands but dup membership — should fail
|
||||
gs := types.GenesisState{
|
||||
Stands: []types.Stand{{StandID: "s1"}},
|
||||
Memberships: []types.Membership{
|
||||
{StandID: "s1", ReachID: "reach:a"},
|
||||
{StandID: "s1", ReachID: "reach:a"},
|
||||
},
|
||||
}
|
||||
bz, _ := json.Marshal(gs)
|
||||
if err := types.ValidateGenesis(bz); err == nil {
|
||||
t.Error("ValidateGenesis should reject dup membership even with clean stands")
|
||||
}
|
||||
}
|
||||
|
||||
// TestValidateGenesisClean asserts a fully clean genesis validates.
|
||||
func TestValidateGenesisClean(t *testing.T) {
|
||||
gs := types.GenesisState{
|
||||
Stands: []types.Stand{{StandID: "s1"}, {StandID: "s2"}},
|
||||
Memberships: []types.Membership{
|
||||
{StandID: "s1", ReachID: "reach:a"},
|
||||
{StandID: "s2", ReachID: "reach:a"},
|
||||
{StandID: "s1", ReachID: "reach:b"},
|
||||
},
|
||||
}
|
||||
bz, _ := json.Marshal(gs)
|
||||
if err := types.ValidateGenesis(bz); err != nil {
|
||||
t.Errorf("ValidateGenesis should accept clean genesis, got: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
package types
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
const (
|
||||
ModuleName = "stand"
|
||||
StoreKey = ModuleName
|
||||
RouterKey = ModuleName
|
||||
QuerierRoute = ModuleName
|
||||
|
||||
// StandTypeCount is the locked count of StandType enum values (vision §11).
|
||||
// A regression firewall: adding/removing/renaming a Stand type breaks this
|
||||
// const's test.
|
||||
StandTypeCount = 9
|
||||
)
|
||||
|
||||
// StandType enumerates the nine organizational forms (vision §11, REQ-016).
|
||||
// All nine are treated uniformly in v0.2 (A-213: the Shadow Stand behavioral
|
||||
// split is deferred to v0.3 design).
|
||||
type StandType string
|
||||
|
||||
const (
|
||||
StandHousehold StandType = "Household"
|
||||
StandCrew StandType = "Crew"
|
||||
StandEntity StandType = "Entity"
|
||||
StandCoop StandType = "Co-op"
|
||||
StandCircle StandType = "Circle"
|
||||
StandTrust StandType = "Trust"
|
||||
StandFoundation StandType = "Foundation"
|
||||
StandConfederation StandType = "Confederation"
|
||||
StandShadow StandType = "Shadow"
|
||||
)
|
||||
|
||||
// AllStandTypes returns all nine StandType values in vision §11 order.
|
||||
// Locked-const test asserts exactly 9 entries with these names (REQ-016).
|
||||
func AllStandTypes() []StandType {
|
||||
return []StandType{
|
||||
StandHousehold,
|
||||
StandCrew,
|
||||
StandEntity,
|
||||
StandCoop,
|
||||
StandCircle,
|
||||
StandTrust,
|
||||
StandFoundation,
|
||||
StandConfederation,
|
||||
StandShadow,
|
||||
}
|
||||
}
|
||||
|
||||
// Stand is a governed group holding a Vault (vision §11, REQ-016).
|
||||
// Modeled on Cosmos SDK x/group (a group of members with a decision policy
|
||||
// governing a Vault). admin-reach references a Reach ID (by-ID-string, G-003);
|
||||
// vault-id references x/vault by ID string (no struct import).
|
||||
type Stand struct {
|
||||
StandID string `json:"stand_id" yaml:"stand_id"`
|
||||
Type StandType `json:"type" yaml:"type"`
|
||||
Name string `json:"name" yaml:"name"`
|
||||
VaultID string `json:"vault_id" yaml:"vault_id"`
|
||||
AdminReach string `json:"admin_reach" yaml:"admin_reach"`
|
||||
CreatedAt int64 `json:"created_at" yaml:"created_at"`
|
||||
MemberCount uint32 `json:"member_count" yaml:"member_count"`
|
||||
}
|
||||
|
||||
// StandRole enumerates member roles within a Stand.
|
||||
type StandRole string
|
||||
|
||||
const (
|
||||
RoleMember StandRole = "Member"
|
||||
RoleAdmin StandRole = "Admin"
|
||||
RoleObserver StandRole = "Observer"
|
||||
)
|
||||
|
||||
// Membership is a Stand membership edge (REQ-016). stand-id references
|
||||
// x/stand by ID string; reach-id references x/identity Reach by string
|
||||
// (G-003 by-ID-string invariant).
|
||||
type Membership struct {
|
||||
StandID string `json:"stand_id" yaml:"stand_id"`
|
||||
ReachID string `json:"reach_id" yaml:"reach_id"`
|
||||
JoinedAt int64 `json:"joined_at" yaml:"joined_at"`
|
||||
Role StandRole `json:"role" yaml:"role"`
|
||||
}
|
||||
|
||||
// StandPolicy is a stub for a Stand's decision policy (A-205).
|
||||
// Mirrors x/group DecisionPolicy: threshold (N-of-M) OR weighted (sum of
|
||||
// weights >= threshold). The skeleton does not enforce the policy; v0.3
|
||||
// wires the live aggregation. Exactly one of Threshold/Weighted should be
|
||||
// non-zero in the live object; the skeleton keeps both as fields for
|
||||
// future-wiring symmetry with x/group.
|
||||
type StandPolicy struct {
|
||||
Threshold uint32 `json:"threshold" yaml:"threshold"`
|
||||
Weighted bool `json:"weighted" yaml:"weighted"`
|
||||
}
|
||||
|
||||
// Params for the stand module (skeleton — no tunables in v0.2).
|
||||
type Params struct{}
|
||||
|
||||
func DefaultParams() Params { return Params{} }
|
||||
|
||||
// GenesisState defines the stand module genesis state (REQ-016).
|
||||
// Stands + Memberships are the two top-level sets; ValidateGenesis enforces
|
||||
// stand-id uniqueness and member-reach uniqueness within a stand.
|
||||
type GenesisState struct {
|
||||
Params Params `json:"params" yaml:"params"`
|
||||
Stands []Stand `json:"stands" yaml:"stands"`
|
||||
Memberships []Membership `json:"memberships" yaml:"memberships"`
|
||||
}
|
||||
|
||||
func DefaultGenesisState() *GenesisState {
|
||||
return &GenesisState{
|
||||
Params: DefaultParams(),
|
||||
Stands: []Stand{},
|
||||
Memberships: []Membership{},
|
||||
}
|
||||
}
|
||||
|
||||
// ValidateGenesis performs ID-uniqueness checks (A-212 upgrade from v0.1
|
||||
// no-op): rejects duplicate stand-ids and duplicate (stand-id, reach-id)
|
||||
// membership pairs. The membership-set invariant is "a reach can be a
|
||||
// member of a stand at most once; the same reach may join different stands".
|
||||
// Validation is delegated to the data-engineer's genesis.go helpers (G-008).
|
||||
func ValidateGenesis(bz json.RawMessage) error {
|
||||
var gs GenesisState
|
||||
if err := json.Unmarshal(bz, &gs); err != nil {
|
||||
return fmt.Errorf("stand: invalid genesis: %w", err)
|
||||
}
|
||||
if err := ValidateStands(gs.Stands); err != nil {
|
||||
return fmt.Errorf("stand: %w", err)
|
||||
}
|
||||
if err := ValidateMemberships(gs.Memberships); err != nil {
|
||||
return fmt.Errorf("stand: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,295 @@
|
||||
package types_test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/oy/openyield/lexicon"
|
||||
"github.com/oy/openyield/x/stand/types"
|
||||
)
|
||||
|
||||
// TestStandTypeCountLockedConst asserts AllStandTypes() returns exactly 9
|
||||
// (vision §11). A regression firewall: adding/removing/renaming a Stand type
|
||||
// breaks this test (REQ-016).
|
||||
func TestStandTypeCountLockedConst(t *testing.T) {
|
||||
if types.StandTypeCount != 9 {
|
||||
t.Errorf("StandTypeCount = %d, expected 9 (vision §11 LOCKED)", types.StandTypeCount)
|
||||
}
|
||||
all := types.AllStandTypes()
|
||||
if len(all) != 9 {
|
||||
t.Errorf("AllStandTypes() len = %d, expected 9", len(all))
|
||||
}
|
||||
}
|
||||
|
||||
// TestAllStandTypesNames asserts the 9 vision §11 names in order with no
|
||||
// extras, no dups, no renames.
|
||||
func TestAllStandTypesNames(t *testing.T) {
|
||||
want := []string{
|
||||
"Household", "Crew", "Entity", "Co-op", "Circle",
|
||||
"Trust", "Foundation", "Confederation", "Shadow",
|
||||
}
|
||||
all := types.AllStandTypes()
|
||||
if len(all) != len(want) {
|
||||
t.Fatalf("len = %d, want %d", len(all), len(want))
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
for i, s := range all {
|
||||
if string(s) != want[i] {
|
||||
t.Errorf("AllStandTypes()[%d] = %q, want %q", i, s, want[i])
|
||||
}
|
||||
if seen[string(s)] {
|
||||
t.Errorf("duplicate StandType %q", s)
|
||||
}
|
||||
seen[string(s)] = true
|
||||
}
|
||||
}
|
||||
|
||||
// TestStandTypeValues asserts each named const matches its AllStandTypes entry.
|
||||
func TestStandTypeValues(t *testing.T) {
|
||||
if types.StandHousehold != "Household" {
|
||||
t.Errorf("StandHousehold = %q", types.StandHousehold)
|
||||
}
|
||||
if types.StandCrew != "Crew" {
|
||||
t.Errorf("StandCrew = %q", types.StandCrew)
|
||||
}
|
||||
if types.StandEntity != "Entity" {
|
||||
t.Errorf("StandEntity = %q", types.StandEntity)
|
||||
}
|
||||
if types.StandCoop != "Co-op" {
|
||||
t.Errorf("StandCoop = %q", types.StandCoop)
|
||||
}
|
||||
if types.StandCircle != "Circle" {
|
||||
t.Errorf("StandCircle = %q", types.StandCircle)
|
||||
}
|
||||
if types.StandTrust != "Trust" {
|
||||
t.Errorf("StandTrust = %q", types.StandTrust)
|
||||
}
|
||||
if types.StandFoundation != "Foundation" {
|
||||
t.Errorf("StandFoundation = %q", types.StandFoundation)
|
||||
}
|
||||
if types.StandConfederation != "Confederation" {
|
||||
t.Errorf("StandConfederation = %q", types.StandConfederation)
|
||||
}
|
||||
if types.StandShadow != "Shadow" {
|
||||
t.Errorf("StandShadow = %q", types.StandShadow)
|
||||
}
|
||||
}
|
||||
|
||||
// TestStandRoleEnumCoverage asserts the three StandRole values.
|
||||
func TestStandRoleEnumCoverage(t *testing.T) {
|
||||
roles := []types.StandRole{types.RoleMember, types.RoleAdmin, types.RoleObserver}
|
||||
if len(roles) != 3 {
|
||||
t.Errorf("expected 3 StandRole consts, got %d", len(roles))
|
||||
}
|
||||
seen := map[types.StandRole]bool{}
|
||||
for _, r := range roles {
|
||||
if r == "" {
|
||||
t.Error("empty StandRole")
|
||||
}
|
||||
if seen[r] {
|
||||
t.Errorf("duplicate StandRole %q", r)
|
||||
}
|
||||
seen[r] = true
|
||||
}
|
||||
}
|
||||
|
||||
// TestStandStructFields asserts Stand carries all required fields.
|
||||
func TestStandStructFields(t *testing.T) {
|
||||
s := types.Stand{
|
||||
StandID: "s1",
|
||||
Type: types.StandHousehold,
|
||||
Name: "Household A",
|
||||
VaultID: "v1",
|
||||
AdminReach: "reach:admin",
|
||||
CreatedAt: 100,
|
||||
MemberCount: 3,
|
||||
}
|
||||
if s.StandID != "s1" || s.Type != types.StandHousehold || s.Name != "Household A" ||
|
||||
s.VaultID != "v1" || s.AdminReach != "reach:admin" || s.CreatedAt != 100 ||
|
||||
s.MemberCount != 3 {
|
||||
t.Error("Stand fields not set correctly")
|
||||
}
|
||||
}
|
||||
|
||||
// TestMembershipStructFields asserts Membership carries all required fields.
|
||||
func TestMembershipStructFields(t *testing.T) {
|
||||
m := types.Membership{
|
||||
StandID: "s1",
|
||||
ReachID: "reach:member",
|
||||
JoinedAt: 200,
|
||||
Role: types.RoleMember,
|
||||
}
|
||||
if m.StandID != "s1" || m.ReachID != "reach:member" || m.JoinedAt != 200 ||
|
||||
m.Role != types.RoleMember {
|
||||
t.Error("Membership fields not set correctly")
|
||||
}
|
||||
}
|
||||
|
||||
// TestStandPolicyStub asserts StandPolicy carries threshold + weighted fields
|
||||
// (A-205 mirrors x/group DecisionPolicy).
|
||||
func TestStandPolicyStub(t *testing.T) {
|
||||
p := types.StandPolicy{Threshold: 5, Weighted: false}
|
||||
if p.Threshold != 5 || p.Weighted != false {
|
||||
t.Error("StandPolicy fields not set correctly")
|
||||
}
|
||||
}
|
||||
|
||||
// TestDefaultGenesisStateEmpty asserts DefaultGenesisState returns non-nil
|
||||
// empty slices for Stands and Memberships.
|
||||
func TestDefaultGenesisStateEmpty(t *testing.T) {
|
||||
gs := types.DefaultGenesisState()
|
||||
if gs == nil {
|
||||
t.Fatal("DefaultGenesisState returned nil")
|
||||
}
|
||||
if gs.Stands == nil || len(gs.Stands) != 0 {
|
||||
t.Errorf("Default Stands should be non-nil empty slice; got len=%d nil=%v", len(gs.Stands), gs.Stands == nil)
|
||||
}
|
||||
if gs.Memberships == nil || len(gs.Memberships) != 0 {
|
||||
t.Errorf("Default Memberships should be non-nil empty slice; got len=%d nil=%v", len(gs.Memberships), gs.Memberships == nil)
|
||||
}
|
||||
}
|
||||
|
||||
// TestValidateGenesisRejectsDupStandIDs asserts A-212: duplicate stand-ids
|
||||
// are rejected.
|
||||
func TestValidateGenesisRejectsDupStandIDs(t *testing.T) {
|
||||
gs := types.GenesisState{
|
||||
Stands: []types.Stand{
|
||||
{StandID: "s1"},
|
||||
{StandID: "s1"}, // dup
|
||||
},
|
||||
}
|
||||
bz, _ := json.Marshal(gs)
|
||||
if err := types.ValidateGenesis(bz); err == nil {
|
||||
t.Error("ValidateGenesis should reject duplicate stand-ids")
|
||||
}
|
||||
}
|
||||
|
||||
// TestValidateGenesisRejectsDupMemberReach asserts A-212: duplicate
|
||||
// (stand-id, reach-id) membership pairs are rejected.
|
||||
func TestValidateGenesisRejectsDupMemberReach(t *testing.T) {
|
||||
gs := types.GenesisState{
|
||||
Memberships: []types.Membership{
|
||||
{StandID: "s1", ReachID: "reach:a"},
|
||||
{StandID: "s1", ReachID: "reach:a"}, // dup within same stand
|
||||
},
|
||||
}
|
||||
bz, _ := json.Marshal(gs)
|
||||
if err := types.ValidateGenesis(bz); err == nil {
|
||||
t.Error("ValidateGenesis should reject duplicate member-reach within a stand")
|
||||
}
|
||||
}
|
||||
|
||||
// TestValidateGenesisAcceptsSameReachInDifferentStands asserts the same
|
||||
// reach can be a member of two different stands (uniqueness is per-stand).
|
||||
func TestValidateGenesisAcceptsSameReachInDifferentStands(t *testing.T) {
|
||||
gs := types.GenesisState{
|
||||
Memberships: []types.Membership{
|
||||
{StandID: "s1", ReachID: "reach:a"},
|
||||
{StandID: "s2", ReachID: "reach:a"}, // ok — different stand
|
||||
},
|
||||
}
|
||||
bz, _ := json.Marshal(gs)
|
||||
if err := types.ValidateGenesis(bz); err != nil {
|
||||
t.Errorf("ValidateGenesis should accept same reach in different stands, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestValidateGenesisRejectsEmptyStandID asserts empty stand-id is rejected.
|
||||
func TestValidateGenesisRejectsEmptyStandID(t *testing.T) {
|
||||
gs := types.GenesisState{
|
||||
Stands: []types.Stand{{StandID: ""}},
|
||||
}
|
||||
bz, _ := json.Marshal(gs)
|
||||
if err := types.ValidateGenesis(bz); err == nil {
|
||||
t.Error("ValidateGenesis should reject empty stand-id")
|
||||
}
|
||||
}
|
||||
|
||||
// TestValidateGenesisRejectsBadJSON asserts malformed JSON is rejected.
|
||||
func TestValidateGenesisRejectsBadJSON(t *testing.T) {
|
||||
if err := types.ValidateGenesis(json.RawMessage(`{bad`)); err == nil {
|
||||
t.Error("ValidateGenesis should reject malformed JSON")
|
||||
}
|
||||
}
|
||||
|
||||
// TestValidateGenesisAcceptsClean asserts a clean genesis validates.
|
||||
func TestValidateGenesisAcceptsClean(t *testing.T) {
|
||||
gs := types.GenesisState{
|
||||
Stands: []types.Stand{{StandID: "s1"}, {StandID: "s2"}},
|
||||
Memberships: []types.Membership{{StandID: "s1", ReachID: "reach:a"}},
|
||||
}
|
||||
bz, _ := json.Marshal(gs)
|
||||
if err := types.ValidateGenesis(bz); err != nil {
|
||||
t.Errorf("ValidateGenesis should accept clean genesis, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestModuleConsts asserts the four Cosmos-convention module consts.
|
||||
func TestModuleConsts(t *testing.T) {
|
||||
if types.ModuleName != "stand" {
|
||||
t.Errorf("ModuleName = %q", types.ModuleName)
|
||||
}
|
||||
if types.StoreKey != "stand" {
|
||||
t.Errorf("StoreKey = %q", types.StoreKey)
|
||||
}
|
||||
if types.RouterKey != "stand" {
|
||||
t.Errorf("RouterKey = %q", types.RouterKey)
|
||||
}
|
||||
if types.QuerierRoute != "stand" {
|
||||
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) -------------------------------------------------
|
||||
|
||||
// TestLexiconNoBannedTermsInStandPackage scans every non-test .go file in
|
||||
// the stand/types package directory for the 9 banned terms (case-insensitive).
|
||||
// Production files only — the test file contains the banned terms as the list
|
||||
// of things to forbid (standard lexicon-test bootstrapping pattern).
|
||||
func TestLexiconNoBannedTermsInStandPackage(t *testing.T) {
|
||||
pkgDir := packageDir(t, "github.com/oy/openyield/x/stand/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 stand/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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// packageDir resolves a Go import path to its filesystem directory.
|
||||
func packageDir(t *testing.T, importPath string) string {
|
||||
t.Helper()
|
||||
_, file, _, ok := runtime.Caller(0)
|
||||
if !ok {
|
||||
t.Fatal("runtime.Caller failed")
|
||||
}
|
||||
repoRoot := filepath.Dir(filepath.Dir(filepath.Dir(filepath.Dir(file))))
|
||||
rel := strings.TrimPrefix(importPath, "github.com/oy/openyield/")
|
||||
return filepath.Join(repoRoot, rel)
|
||||
}
|
||||
@@ -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