Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 93a8a3b311 |
@@ -1,11 +1,11 @@
|
|||||||
{
|
{
|
||||||
"phase": 0,
|
"phase": 1,
|
||||||
"stage": "plan",
|
"stage": "execute",
|
||||||
"milestone": "v0.2",
|
"milestone": "v0.2",
|
||||||
"milestone_type": "feature",
|
"milestone_type": "feature",
|
||||||
"tag_base": "v0.1.x",
|
"tag_base": "v0.1.x",
|
||||||
"phase_role": "pre_execution",
|
"phase_role": "execution",
|
||||||
"project": "oy",
|
"project": "oy",
|
||||||
"attempts": 0,
|
"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