Compare commits
14
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
71e094b2de | ||
|
|
f96704fa0f | ||
|
|
86fdf74a0a | ||
|
|
843281c0e5 | ||
|
|
ea53b2f920 | ||
|
|
a01715ddf5 | ||
|
|
a30c168512 | ||
|
|
f885250913 | ||
|
|
447e6a13cd | ||
|
|
c51a46b802 | ||
|
|
15a5fe09b6 | ||
|
|
3ded9e7800 | ||
|
|
e7aaa8420e | ||
|
|
22381d7522 |
@@ -15,6 +15,13 @@ package main
|
||||
// the desk says in its standing when that happened (`answers-lost-at`), and the router, which alone knows
|
||||
// what is still open and unanswered, says that again under its handle — replacing the notification in place,
|
||||
// so its buttons work again. Nothing resolved, silenced or cancelled comes back.
|
||||
// - **A store that does not answer does not lose where a message is shown, while the desk runs.** A message
|
||||
// is shown first and its notification ids kept after; when the state store does not answer then (the bus
|
||||
// reloading its accounts, 2026-10-26 09:18: the very outage that raised the condition), the work is taken
|
||||
// all the same and the ids are held in memory, recalled before the store, and kept at the start of the next
|
||||
// work and at the minute's standing. Otherwise the clearing of that message finds no notification to
|
||||
// replace and the urgent one stays on screen for good. A desk that restarts while it still holds some loses
|
||||
// where those are shown; the log says so when they are held.
|
||||
// - **Standing**: it can send when machines are named in its settings; which of them answered last is in its
|
||||
// status. A message no machine took is said to the router as failed, and tried again by it.
|
||||
|
||||
@@ -93,6 +100,9 @@ type Desk struct {
|
||||
pause map[string]pauseSeen
|
||||
// lost is when each machine's notifier last started again: the answers its notifications waited for are lost.
|
||||
lost map[string]time.Time
|
||||
// unkept is what was shown and not yet kept in the store, because it did not answer: recalled from here
|
||||
// before the store, and kept at the next piece of work.
|
||||
unkept map[string]Shown
|
||||
}
|
||||
|
||||
func (d *Desk) init() {
|
||||
@@ -107,6 +117,9 @@ func (d *Desk) init() {
|
||||
if d.lost == nil {
|
||||
d.lost = map[string]time.Time{}
|
||||
}
|
||||
if d.unkept == nil {
|
||||
d.unkept = map[string]Shown{}
|
||||
}
|
||||
if d.Now == nil {
|
||||
d.Now = time.Now
|
||||
}
|
||||
@@ -138,6 +151,7 @@ func (d *Desk) Take(subject string, body json.RawMessage) error {
|
||||
d.Logf("[desk-channel] work on %s could not be read; taken and dropped", subject)
|
||||
return nil
|
||||
}
|
||||
d.KeepUnkept()
|
||||
machines := d.Machines()
|
||||
if m.Secret {
|
||||
// Shown only where the notifier said lately it is not paused (the review of 2026-10-09): one held back
|
||||
@@ -167,12 +181,16 @@ func (d *Desk) Take(subject string, body json.RawMessage) error {
|
||||
switch {
|
||||
case edit:
|
||||
var err error
|
||||
if before, err = d.Store.Get(m.Handle); err != nil {
|
||||
if before, err = d.recall(m.Handle); err != nil {
|
||||
return err
|
||||
}
|
||||
if before == nil {
|
||||
d.Logf("[desk-channel] an edit of %s, which nothing here records as shown: shown as a new message; "+
|
||||
"a notification it was to replace, if any, stays", m.Handle)
|
||||
}
|
||||
case !m.Secret:
|
||||
var err error
|
||||
if before, err = d.Store.Get(m.Handle); err != nil {
|
||||
if before, err = d.recall(m.Handle); err != nil {
|
||||
d.Logf("[desk-channel] what is shown under this handle could not be read (%v): shown as a new message", err)
|
||||
before = nil
|
||||
}
|
||||
@@ -218,12 +236,91 @@ func (d *Desk) Take(subject string, body json.RawMessage) error {
|
||||
// Shown once and kept nowhere of this module's (a link's code): no edit or re-showing reaches it.
|
||||
return nil
|
||||
}
|
||||
if err := d.Store.Put(m.Handle, s); err != nil {
|
||||
d.Logf("[desk-channel] shown, and what was shown could not be kept: an edit of it will not reach it: %v", err)
|
||||
}
|
||||
d.keep(m.Handle, s)
|
||||
return nil
|
||||
}
|
||||
|
||||
// keep keeps what a handle shows in the store. When the store does not answer it is held in memory, where an
|
||||
// edit, a reminder and a click still find it, and kept later (KeepUnkept). The work is taken either way: offered
|
||||
// again it would race the message's own later edits, and a redelivery is bounded where an outage is not.
|
||||
func (d *Desk) keep(handle string, s Shown) {
|
||||
err := d.Store.Put(handle, s)
|
||||
d.mu.Lock()
|
||||
defer d.mu.Unlock()
|
||||
if err == nil {
|
||||
delete(d.unkept, handle)
|
||||
return
|
||||
}
|
||||
d.unkept[handle] = s
|
||||
d.Logf("[desk-channel] shown, and where it is shown could not be kept yet (%v): held in memory, so its edit "+
|
||||
"and clearing still reach it, and kept when the store answers; %d held, lost if the desk restarts first",
|
||||
err, len(d.unkept))
|
||||
}
|
||||
|
||||
// KeepUnkept keeps in the store what it did not answer for before: at the start of each piece of work and at
|
||||
// the minute's standing. It stops at the first write the store does not answer, so one call waits on it once.
|
||||
func (d *Desk) KeepUnkept() {
|
||||
d.init()
|
||||
d.mu.Lock()
|
||||
held := make(map[string]Shown, len(d.unkept))
|
||||
for h, s := range d.unkept {
|
||||
held[h] = s
|
||||
}
|
||||
d.mu.Unlock()
|
||||
for h, s := range held {
|
||||
if err := d.Store.Put(h, s); err != nil {
|
||||
return
|
||||
}
|
||||
d.mu.Lock()
|
||||
if cur, ok := d.unkept[h]; ok && sameShown(cur, s) {
|
||||
delete(d.unkept, h)
|
||||
}
|
||||
d.mu.Unlock()
|
||||
d.Logf("[desk-channel] where %s is shown is kept now", h)
|
||||
}
|
||||
}
|
||||
|
||||
func (d *Desk) unkeptOf(handle string) (Shown, bool) {
|
||||
d.mu.Lock()
|
||||
defer d.mu.Unlock()
|
||||
s, ok := d.unkept[handle]
|
||||
return s, ok
|
||||
}
|
||||
|
||||
// recall is what a handle shows: from memory when the store did not keep it yet, else from the store.
|
||||
func (d *Desk) recall(handle string) (*Shown, error) {
|
||||
if s, ok := d.unkeptOf(handle); ok {
|
||||
return &s, nil
|
||||
}
|
||||
return d.Store.Get(handle)
|
||||
}
|
||||
|
||||
// handles is every handle shown: the store's and those held in memory.
|
||||
func (d *Desk) handles() ([]string, error) {
|
||||
hs, err := d.Store.Handles()
|
||||
d.mu.Lock()
|
||||
defer d.mu.Unlock()
|
||||
seen := map[string]bool{}
|
||||
for _, h := range hs {
|
||||
seen[h] = true
|
||||
}
|
||||
for h := range d.unkept {
|
||||
if !seen[h] {
|
||||
hs = append(hs, h)
|
||||
}
|
||||
}
|
||||
if err != nil && len(d.unkept) == 0 {
|
||||
return nil, err
|
||||
}
|
||||
return hs, nil
|
||||
}
|
||||
|
||||
func sameShown(a, b Shown) bool {
|
||||
x, err1 := json.Marshal(a)
|
||||
y, err2 := json.Marshal(b)
|
||||
return err1 == nil && err2 == nil && string(x) == string(y)
|
||||
}
|
||||
|
||||
// show puts one message on one machine's notifier, in place of `replace` when it names one.
|
||||
func (d *Desk) show(machine string, m asks.Message, replace int) (int, error) {
|
||||
urgency := "normal"
|
||||
@@ -308,12 +405,12 @@ func (d *Desk) Chosen(event, machine, ticket string) {
|
||||
}
|
||||
|
||||
func (d *Desk) find(ticket string) (string, *Shown) {
|
||||
handles, err := d.Store.Handles()
|
||||
handles, err := d.handles()
|
||||
if err != nil {
|
||||
return "", nil
|
||||
}
|
||||
for _, h := range handles {
|
||||
s, err := d.Store.Get(h)
|
||||
s, err := d.recall(h)
|
||||
if err != nil || s == nil {
|
||||
continue
|
||||
}
|
||||
@@ -356,9 +453,9 @@ func (d *Desk) Started(event, machine string) int {
|
||||
d.lost[machine] = d.Now()
|
||||
d.mu.Unlock()
|
||||
n := 0
|
||||
if handles, err := d.Store.Handles(); err == nil {
|
||||
if handles, err := d.handles(); err == nil {
|
||||
for _, h := range handles {
|
||||
if s, err := d.Store.Get(h); err == nil && s != nil && len(s.Message.Buttons) > 0 {
|
||||
if s, err := d.recall(h); err == nil && s != nil && len(s.Message.Buttons) > 0 {
|
||||
if _, here := s.On[machine]; here {
|
||||
n++
|
||||
}
|
||||
@@ -472,6 +569,7 @@ func (d *Desk) Status() map[string]any {
|
||||
"it proves nothing about who clicked: any process of the operator's account can choose an action, so it answers acknowledgements only",
|
||||
"an answer is taken only on a machine its message was shown on; a message with buttons stays until dismissed",
|
||||
"what it remembers is never shown again: when a notifier started again, the answers its notifications waited for are lost; the desk says when in its standing (answers-lost-at), and the router says again what is still open and unanswered, replacing those notifications in place — nothing resolved, silenced or cancelled comes back (issue 384)",
|
||||
"a store that does not answer does not lose where a message is shown while the desk runs: it is held in memory, its edit and clearing still replace it in place, and it is kept when the store answers again; a desk restarted before then loses where those are shown, and its log said how many it held",
|
||||
"it can send while a named machine's notifier said lately that it is not paused; a link's code is shown only on such a machine, kept nowhere, and sent secret so the notifier keeps it out of its history",
|
||||
}}
|
||||
if s.Why != "" {
|
||||
|
||||
@@ -3,6 +3,7 @@ package main
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
@@ -363,9 +364,64 @@ func TestAMessageSaidAgainUnderItsHandleReplacesItsNotification(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// downStore is a store that does not answer while down: the bus reloading its accounts (2026-10-26 09:18).
|
||||
type downStore struct {
|
||||
memShown
|
||||
down *bool
|
||||
}
|
||||
|
||||
func (s downStore) Get(h string) (*Shown, error) {
|
||||
if *s.down {
|
||||
return nil, errors.New("nats: no responders available for request")
|
||||
}
|
||||
return s.memShown.Get(h)
|
||||
}
|
||||
func (s downStore) Put(h string, v Shown) error {
|
||||
if *s.down {
|
||||
return errors.New("nats: no responders available for request")
|
||||
}
|
||||
return s.memShown.Put(h, v)
|
||||
}
|
||||
|
||||
// The review of PR 176: a replace that fails on a machine keeps that machine's notification on record, so a
|
||||
// later edit or clearing still reaches it; failing everywhere, it is said to the router as failed.
|
||||
// later edit or clearing still reaches it; failing everywhere, it is said to the router as failed. And
|
||||
// (2026-10-26 09:18, an urgent "A new build of mesh-controller cannot be delivered" left on screen for hours
|
||||
// after its clearing): a message shown while the store does not answer keeps where it is shown in memory, its
|
||||
// work is taken, its clearing replaces it in place, and it is kept once the store answers.
|
||||
func TestAFailedReplaceKeepsTheNotificationItWasToReplace(t *testing.T) {
|
||||
// The raise, then its clearing, taken while the store does not answer; the store then stays down or comes
|
||||
// back. Each is taken (never offered again, where a redelivered raise would follow its clearing), the
|
||||
// clearing replaces the urgent notification in place, and the screen and the store end on the clearing.
|
||||
for _, back := range []bool{false, true} {
|
||||
t.Run(fmt.Sprintf("the store does not answer, back %v", back), func(t *testing.T) {
|
||||
d, shown, _, store := newDesk(t, "laptop")
|
||||
down := true
|
||||
d.Store = downStore{store, &down}
|
||||
raised, _ := json.Marshal(asks.Message{Handle: "m9", Title: "A new build cannot be delivered", Urgent: true})
|
||||
if err := d.Take(asks.ChannelSubject(asks.Send, Kind), raised); err != nil {
|
||||
t.Fatalf("the raise was not taken: %v", err)
|
||||
}
|
||||
cleared, _ := json.Marshal(asks.Message{Handle: "m9", Title: "Cleared: a new build can be delivered"})
|
||||
if err := d.Take(asks.ChannelSubject(asks.Edit, Kind), cleared); err != nil {
|
||||
t.Fatalf("the clearing was not taken: %v", err)
|
||||
}
|
||||
d.KeepUnkept() // the minute's standing, store still down
|
||||
down = !back
|
||||
d.KeepUnkept()
|
||||
take(t, d, asks.Send, asks.Message{Handle: "m10", Title: "another"})
|
||||
if len(*shown) != 3 || (*shown)[1].args["replace_id"] != 1 || (*shown)[1].args["summary"] != "Cleared: a new build can be delivered" {
|
||||
t.Fatalf("the clearing did not replace the urgent notification: %+v", *shown)
|
||||
}
|
||||
s, kept := store["m9"]
|
||||
if back != kept || (kept && s.Message.Title != "Cleared: a new build can be delivered") {
|
||||
t.Fatalf("store back %v, kept %v: %+v", back, kept, store)
|
||||
}
|
||||
if got, _ := d.recall("m9"); got == nil || got.Message.Title != "Cleared: a new build can be delivered" {
|
||||
t.Fatalf("recalled %+v", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
d, shown, said, store := newDesk(t, "laptop", "desktop")
|
||||
take(t, d, asks.Send, asks.Message{Handle: "m1", Title: "2 failed services on shanks"})
|
||||
first := store["m1"].On
|
||||
|
||||
@@ -100,6 +100,7 @@ func run(d *Desk) {
|
||||
}()
|
||||
go func() {
|
||||
for {
|
||||
d.KeepUnkept()
|
||||
d.SayStanding()
|
||||
time.Sleep(time.Minute)
|
||||
}
|
||||
|
||||
@@ -41,6 +41,8 @@ type PackageResult struct {
|
||||
Tests int `json:"tests"`
|
||||
// NoTests is a package with no test files (at this level: a file with the other level's build tag is none).
|
||||
NoTests bool `json:"no_tests,omitempty"`
|
||||
// Skipped is how many of the package's tests skipped: a pass that skipped proved less than it says, so the checker keeps no verdict for it.
|
||||
Skipped int `json:"skipped,omitempty"`
|
||||
}
|
||||
|
||||
// TestTime is one test and the time it took.
|
||||
@@ -227,6 +229,7 @@ func (r *reader) event(ev event) {
|
||||
r.res.Tests++
|
||||
if ev.Action == "skip" {
|
||||
r.res.Skipped++
|
||||
p.Skipped++
|
||||
}
|
||||
r.times = append(r.times, TestTime{Package: ev.Package, Test: ev.Test, Elapsed: ev.Elapsed})
|
||||
if ev.Action == "fail" {
|
||||
|
||||
@@ -101,3 +101,25 @@ func TestALoggedPanicIsNotTheCause(t *testing.T) { // 1901/AC4
|
||||
t.Errorf("1901/AC4: failures = %+v, want the timeout's panic as the cause", res.Failed)
|
||||
}
|
||||
}
|
||||
|
||||
// A package counts its own skipped tests, so the checker withholds a kept verdict from that package alone (issue 890).
|
||||
func TestEachPackageCountsItsOwnSkippedTests(t *testing.T) {
|
||||
log := `{"Action":"run","Package":"m/a","Test":"TestA"}
|
||||
{"Action":"skip","Package":"m/a","Test":"TestA","Elapsed":0}
|
||||
{"Action":"run","Package":"m/b","Test":"TestB"}
|
||||
{"Action":"pass","Package":"m/b","Test":"TestB","Elapsed":0}
|
||||
{"Action":"pass","Package":"m/a","Elapsed":0}
|
||||
{"Action":"pass","Package":"m/b","Elapsed":0}
|
||||
`
|
||||
res := &TestResult{}
|
||||
rd := newReader(res, ".")
|
||||
rd.read(log)
|
||||
rd.finish()
|
||||
got := map[string]int{}
|
||||
for _, p := range res.Packages {
|
||||
got[p.Package] = p.Skipped
|
||||
}
|
||||
if res.Skipped != 1 || got["m/a"] != 1 || got["m/b"] != 0 {
|
||||
t.Errorf("skipped = %d, per package %v, want 1 with m/a 1 and m/b 0", res.Skipped, got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -103,6 +103,33 @@ func TestUnitLevelNamesTheFailingTestAndFailsTheLevel(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// Issue 1921: a fixture with a failing test in each of two packages: one level's run names both, each with its own
|
||||
// cause, and counts every test, the passing one beside a failure too. go test runs every package to its end; nothing
|
||||
// stops at the first failure.
|
||||
func TestUnitLevelNamesEveryFailingTestOfEveryPackage(t *testing.T) {
|
||||
m, states, ws, calls := workspaceFor(t, "twofailing")
|
||||
res, st := runUnit(t, m, states, ws)
|
||||
if st.State != delivery.StepPassed || res.Passed {
|
||||
t.Fatalf("step %s (%s), result %+v: want the step done and the level failed", st.State, st.Reason, res)
|
||||
}
|
||||
var got []string
|
||||
for _, f := range res.Failed {
|
||||
got = append(got, f.Package+"."+f.Test+": "+f.Cause)
|
||||
}
|
||||
want := []string{"fixture/twofailing/one.TestOneIsTwo: One() = 1, want 2", "fixture/twofailing/two.TestTwoIsThree: Two() = 2, want 3"}
|
||||
if strings.Join(got, "\n") != strings.Join(want, "\n") {
|
||||
t.Errorf("failed\n%s\nwant\n%s", strings.Join(got, "\n"), strings.Join(want, "\n"))
|
||||
}
|
||||
if res.Tests != 3 {
|
||||
t.Errorf("%d tests counted, want 3", res.Tests)
|
||||
}
|
||||
for _, c := range *calls {
|
||||
if strings.Contains(c.line, " test ") && strings.Contains(c.line, "-failfast") {
|
||||
t.Errorf("go test ran with -failfast: %s", c.line)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 1290/AC2: a fixture whose tests take more than the bound (lowered by the module's option, not by waiting five
|
||||
// seconds): the level fails naming the bound and the slowest tests; under the real bound the same run passes.
|
||||
func TestUnitLevelOverItsBoundFailsNamingTheBoundAndTheSlowestTests(t *testing.T) {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
Each file is the standard output of `GOTOOLCHAIN=go1.27.1 GOFLAGS=-mod=mod go test -count=1 -json ./...` (go1.27.1)
|
||||
run in the fixture module of the same name under ../repos, captured on 2026-10-20 for issue 1290. The unit
|
||||
tests feed them to the parser through a fake runner, so no test here runs `go test`.
|
||||
tests feed them to the parser through a fake runner, so no test here runs `go test`. twofailing.json was captured the
|
||||
same way on 2026-10-24 for issue 1921: two packages with a failing test each, and a passing test beside one of them.
|
||||
|
||||
golist-external.json is `GOTOOLCHAIN=go1.27.1 GOFLAGS=-mod=mod go list -json=ImportPath,Standard,DepOnly,Module
|
||||
git.novox.be/novox/mesh-sdk/go/buildstep` run in this module (go1.27.1, 2026-10-20 for issue 1291): a package of a module
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
{"Time":"2026-10-24T23:45:37.721718705+02:00","Action":"start","Package":"fixture/twofailing/one"}
|
||||
{"Time":"2026-10-24T23:45:37.723685152+02:00","Action":"run","Package":"fixture/twofailing/one","Test":"TestOneIsTwo"}
|
||||
{"Time":"2026-10-24T23:45:37.723726472+02:00","Action":"output","Package":"fixture/twofailing/one","Test":"TestOneIsTwo","Output":"=== RUN TestOneIsTwo\n","OutputType":"frame"}
|
||||
{"Time":"2026-10-24T23:45:37.723743682+02:00","Action":"output","Package":"fixture/twofailing/one","Test":"TestOneIsTwo","Output":" one_test.go:6: checking One\n"}
|
||||
{"Time":"2026-10-24T23:45:37.723750622+02:00","Action":"output","Package":"fixture/twofailing/one","Test":"TestOneIsTwo","Output":" one_test.go:8: One() = 1, want 2\n","OutputType":"error"}
|
||||
{"Time":"2026-10-24T23:45:37.723759302+02:00","Action":"output","Package":"fixture/twofailing/one","Test":"TestOneIsTwo","Output":"--- FAIL: TestOneIsTwo (0.00s)\n","OutputType":"frame"}
|
||||
{"Time":"2026-10-24T23:45:37.723889303+02:00","Action":"start","Package":"fixture/twofailing/two"}
|
||||
{"Time":"2026-10-24T23:45:37.723762972+02:00","Action":"fail","Package":"fixture/twofailing/one","Test":"TestOneIsTwo","Elapsed":0}
|
||||
{"Time":"2026-10-24T23:45:37.723952274+02:00","Action":"run","Package":"fixture/twofailing/one","Test":"TestOneIsOne"}
|
||||
{"Time":"2026-10-24T23:45:37.723955484+02:00","Action":"output","Package":"fixture/twofailing/one","Test":"TestOneIsOne","Output":"=== RUN TestOneIsOne\n","OutputType":"frame"}
|
||||
{"Time":"2026-10-24T23:45:37.723959304+02:00","Action":"output","Package":"fixture/twofailing/one","Test":"TestOneIsOne","Output":"--- PASS: TestOneIsOne (0.00s)\n","OutputType":"frame"}
|
||||
{"Time":"2026-10-24T23:45:37.723964914+02:00","Action":"pass","Package":"fixture/twofailing/one","Test":"TestOneIsOne","Elapsed":0}
|
||||
{"Time":"2026-10-24T23:45:37.723967704+02:00","Action":"output","Package":"fixture/twofailing/one","Output":"FAIL\n","OutputType":"frame"}
|
||||
{"Time":"2026-10-24T23:45:37.724225126+02:00","Action":"output","Package":"fixture/twofailing/one","Output":"FAIL\tfixture/twofailing/one\t0.002s\n","OutputType":"frame"}
|
||||
{"Time":"2026-10-24T23:45:37.724231936+02:00","Action":"fail","Package":"fixture/twofailing/one","Elapsed":0.003}
|
||||
{"Time":"2026-10-24T23:45:37.725752949+02:00","Action":"run","Package":"fixture/twofailing/two","Test":"TestTwoIsThree"}
|
||||
{"Time":"2026-10-24T23:45:37.725796309+02:00","Action":"output","Package":"fixture/twofailing/two","Test":"TestTwoIsThree","Output":"=== RUN TestTwoIsThree\n","OutputType":"frame"}
|
||||
{"Time":"2026-10-24T23:45:37.725808239+02:00","Action":"output","Package":"fixture/twofailing/two","Test":"TestTwoIsThree","Output":" two_test.go:7: Two() = 2, want 3\n","OutputType":"error"}
|
||||
{"Time":"2026-10-24T23:45:37.72581671+02:00","Action":"output","Package":"fixture/twofailing/two","Test":"TestTwoIsThree","Output":"--- FAIL: TestTwoIsThree (0.00s)\n","OutputType":"frame"}
|
||||
{"Time":"2026-10-24T23:45:37.72582026+02:00","Action":"fail","Package":"fixture/twofailing/two","Test":"TestTwoIsThree","Elapsed":0}
|
||||
{"Time":"2026-10-24T23:45:37.72582549+02:00","Action":"output","Package":"fixture/twofailing/two","Output":"FAIL\n","OutputType":"frame"}
|
||||
{"Time":"2026-10-24T23:45:37.726319344+02:00","Action":"output","Package":"fixture/twofailing/two","Output":"FAIL\tfixture/twofailing/two\t0.002s\n","OutputType":"frame"}
|
||||
{"Time":"2026-10-24T23:45:37.726362544+02:00","Action":"fail","Package":"fixture/twofailing/two","Elapsed":0.002}
|
||||
@@ -0,0 +1,3 @@
|
||||
module fixture/twofailing
|
||||
|
||||
go 1.25.0
|
||||
@@ -0,0 +1,3 @@
|
||||
package one
|
||||
|
||||
func One() int { return 1 }
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
package one
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestOneIsTwo(t *testing.T) {
|
||||
t.Log("checking One")
|
||||
if One() != 2 {
|
||||
t.Fatalf("One() = %d, want 2", One())
|
||||
}
|
||||
}
|
||||
|
||||
func TestOneIsOne(t *testing.T) {
|
||||
if One() != 1 {
|
||||
t.Fatal("one")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
package two
|
||||
|
||||
func Two() int { return 2 }
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
package two
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestTwoIsThree(t *testing.T) {
|
||||
if Two() != 3 {
|
||||
t.Errorf("Two() = %d, want 3", Two())
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,7 @@ module meshbuildgo
|
||||
go 1.26.0
|
||||
|
||||
require (
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261025175617-518dae5ec9c8
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261026095702-79649c3fa9f4
|
||||
github.com/distribution/distribution/v3 v3.1.2
|
||||
github.com/nats-io/nats-server/v2 v2.11.17
|
||||
github.com/nats-io/nats.go v1.54.0
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261025175617-518dae5ec9c8 h1:x4mvY7yiGTP5lnzxbiwyj1o24p05hAmzZP7xHg3XX2M=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261025175617-518dae5ec9c8/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261026095702-79649c3fa9f4 h1:05sVel/UOvz4/g0dxrDOeVUL7BGakVrxPBlqRq02ZhM=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261026095702-79649c3fa9f4/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
|
||||
github.com/alicebob/miniredis/v2 v2.35.0 h1:QwLphYqCEAo1eu1TqPRN2jgVMPBweeQcR21jeqDCONI=
|
||||
github.com/alicebob/miniredis/v2 v2.35.0/go.mod h1:TcL7YfarKPGDAthEtl5NBeHZfeUQj6OXMm/+iu5cLMM=
|
||||
github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op h1:Z/MZK75wC/NSrkgqeNIa7jexam9uWzhLmFTSCPI/kn0=
|
||||
|
||||
@@ -17,11 +17,11 @@ import (
|
||||
"git.novox.be/novox/mesh-sdk/go/forgekey"
|
||||
)
|
||||
|
||||
// realMachine gives the tests the real toolchain of the module's own lock and makes two workspaces holding one fixture
|
||||
// bundle. The toolchain is the one this node's installed Node build module keeps (ADR 0533 §1): the integration level
|
||||
// is given its toolchains read-only and no network, so it is copied into the test's own cache from there. Only where
|
||||
// no toolchain is installed (a desk) is it installed with `npm ci --ignore-scripts` from the mesh's registry, as the
|
||||
// module does, which needs the network.
|
||||
// realMachine gives the tests the real toolchain of the module's own lock and makes three workspaces holding one fixture
|
||||
// bundle. The toolchain is the one this node's installed Node build module keeps (ADR 0533 §1), copied into the test's
|
||||
// own cache: the integration level is given its toolchains read-only and no network, so it is never installed from the
|
||||
// registry here. Where no Node build module has installed one (a node without it, a desk), the test is skipped by
|
||||
// name: an install from the public registry inside a shut-off level only fails, after 70 s of npm's retries.
|
||||
func realMachine(t *testing.T) (*Module, string) {
|
||||
t.Helper()
|
||||
for _, p := range []string{"node", "npm", "bwrap"} { // bwrap: tsc, esbuild and the scripts run shut off (1309)
|
||||
@@ -44,16 +44,19 @@ func realMachine(t *testing.T) (*Module, string) {
|
||||
run := forgekey.ExecRunnerFor(0)
|
||||
m := &Module{Node: "ace", Recipe: lock, Work: work, Run: run, Ctx: context.Background(),
|
||||
Toolchain: &Toolchain{Paths: Paths{Home: home, Cache: cache}, Run: run, Registry: func() (Registry, error) {
|
||||
return Registry{URL: "https://git.novox.be/api/packages/novox/npm/"}, nil
|
||||
return Registry{}, fmt.Errorf("the integration test installs no toolchain from a registry: it has no network")
|
||||
}}}
|
||||
withStore(t, m)
|
||||
if from := installedToolchain(t, filepath.Join(CacheDir, "toolchain"), filepath.Join(home, "toolchain", "package-lock.json")); from != "" {
|
||||
copyToolchain(t, from, m.Toolchain.Paths.Dir(m.Recipe.Version()))
|
||||
from := installedToolchain(t, filepath.Join(CacheDir, "toolchain"), filepath.Join(home, "toolchain", "package-lock.json"))
|
||||
if from == "" {
|
||||
t.Skipf("NOT RUN: no Node toolchain is installed under %s for this account to read (no Node build module on this node); "+
|
||||
"the integration level has no network to install one, so this runs on a node that holds the Node build module", filepath.Join(CacheDir, "toolchain"))
|
||||
}
|
||||
if err := m.Toolchain.Ensure(context.Background(), m.Recipe.Version()); err != nil {
|
||||
t.Fatalf("no toolchain of this lock is installed under %s, and installing it from the registry failed: %v", filepath.Join(CacheDir, "toolchain"), err)
|
||||
copyToolchain(t, from, m.Toolchain.Paths.Dir(m.Recipe.Version()))
|
||||
if err := m.Toolchain.Ensure(context.Background(), m.Recipe.Version()); err != nil { // present: opens it, installs nothing
|
||||
t.Fatalf("the toolchain copied from %s cannot be used: %v", from, err)
|
||||
}
|
||||
for _, id := range []string{"w1", "w2"} {
|
||||
for _, id := range []string{"w1", "w2", "w3"} {
|
||||
for p, body := range map[string]string{
|
||||
"modules/m/module.json": `{"module":"m","build":{"artifacts":[{"name":"tools","kind":"bundle","language":"typescript","entrypoints":["tools/index.js"]}]}}`,
|
||||
"modules/m/package.json": `{"name":"m","type":"module","scripts":{"test":"node -e \"process.exit(0)\""}}`,
|
||||
@@ -65,10 +68,36 @@ func realMachine(t *testing.T) (*Module, string) {
|
||||
return m, work
|
||||
}
|
||||
|
||||
// 1490/AC2: a fixture TypeScript bundle importing @novox/mesh-sdk, built twice by the real tsc and esbuild, has equal
|
||||
// digests, and its archive holds the bundled .js, the .serve.mjs launcher and package.json.
|
||||
func TestAFixtureBundleBuiltTwiceHasOneDigest(t *testing.T) {
|
||||
// 1490/AC2, 1491 and issue 1869, in one test so the toolchain is copied once: the real type-check and test script of the
|
||||
// fixture pass the unit level; a package whose test imports its own build output (as mesh-sdk's do) passes the unit level,
|
||||
// the test step emitting it with the real tsc, shut off, before the test script runs; and the fixture TypeScript bundle
|
||||
// importing @novox/mesh-sdk, built twice by the real tsc and esbuild, has equal digests, its archive holding the bundled
|
||||
// .js, the .serve.mjs launcher and package.json. The unit levels run in w3, so the two builds' workspaces stay alike.
|
||||
func TestTheFixturePassesTheUnitLevelAndBuildsTwiceToOneDigest(t *testing.T) {
|
||||
m, work := realMachine(t)
|
||||
out, err := m.test(context.Background(), map[string]any{"workspace": "w3", "module": "modules/m", "level": "unit"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if r := out.(*TestResult); !r.Passed || r.NoTests || len(r.Ran) != 2 {
|
||||
t.Errorf("the fixture's unit level: result = %+v", r)
|
||||
}
|
||||
for p, body := range map[string]string{
|
||||
"modules/p/package.json": `{"name":"p","type":"module","scripts":{"build":"tsc","test":"node --test test/a.test.mjs","prepare":"tsc"}}`,
|
||||
"modules/p/tsconfig.json": `{"compilerOptions":{"module":"NodeNext","moduleResolution":"NodeNext","target":"ES2022","outDir":"dist","rootDir":"src"},"include":["src"]}`,
|
||||
"modules/p/src/index.ts": "export const answer: number = 42;\n",
|
||||
"modules/p/test/a.test.mjs": "import { test } from 'node:test';\nimport assert from 'node:assert';\nimport { answer } from '../dist/index.js';\n" +
|
||||
"test('the build is there', () => assert.equal(answer, 42));\n",
|
||||
} {
|
||||
write(t, filepath.Join(work, "w3", "tree", filepath.FromSlash(p)), body, 0o644)
|
||||
}
|
||||
out, err = m.test(context.Background(), map[string]any{"workspace": "w3", "module": "modules/p", "level": "unit"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if r := out.(*TestResult); !r.Passed || !slices.Equal(r.Ran, []string{"build", "test"}) {
|
||||
t.Errorf("the package importing its build: result = %+v", r)
|
||||
}
|
||||
var digests []string
|
||||
for _, id := range []string{"w1", "w2"} {
|
||||
out, err := m.build(context.Background(), map[string]any{"workspace": id, "module": "modules/m", "artifact": "tools"})
|
||||
@@ -89,43 +118,6 @@ func TestAFixtureBundleBuiltTwiceHasOneDigest(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// 1491: the real type-check and test script of the same fixture pass the unit level.
|
||||
func TestTheFixturePassesTheUnitLevel(t *testing.T) {
|
||||
m, _ := realMachine(t)
|
||||
out, err := m.test(context.Background(), map[string]any{"workspace": "w1", "module": "modules/m", "level": "unit"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if r := out.(*TestResult); !r.Passed || r.NoTests || len(r.Ran) != 2 {
|
||||
t.Errorf("result = %+v", r)
|
||||
}
|
||||
}
|
||||
|
||||
// Issue 1869: a package whose test imports its own build output (as mesh-sdk's do) passes the unit level: the test step
|
||||
// emits it with the real tsc, shut off, before the test script runs.
|
||||
func TestAPackageWhoseTestsImportItsBuildPassesTheUnitLevel(t *testing.T) {
|
||||
if _, err := exec.LookPath("bwrap"); err != nil {
|
||||
t.Skip("NOT RUN: no bubblewrap installed where this check runs; every build node has it (the module declares it)")
|
||||
}
|
||||
m, work := realMachine(t)
|
||||
for p, body := range map[string]string{
|
||||
"modules/p/package.json": `{"name":"p","type":"module","scripts":{"build":"tsc","test":"node --test test/a.test.mjs","prepare":"tsc"}}`,
|
||||
"modules/p/tsconfig.json": `{"compilerOptions":{"module":"NodeNext","moduleResolution":"NodeNext","target":"ES2022","outDir":"dist","rootDir":"src"},"include":["src"]}`,
|
||||
"modules/p/src/index.ts": "export const answer: number = 42;\n",
|
||||
"modules/p/test/a.test.mjs": "import { test } from 'node:test';\nimport assert from 'node:assert';\nimport { answer } from '../dist/index.js';\n" +
|
||||
"test('the build is there', () => assert.equal(answer, 42));\n",
|
||||
} {
|
||||
write(t, filepath.Join(work, "w1", "tree", filepath.FromSlash(p)), body, 0o644)
|
||||
}
|
||||
out, err := m.test(context.Background(), map[string]any{"workspace": "w1", "module": "modules/p", "level": "unit"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if r := out.(*TestResult); !r.Passed || !slices.Equal(r.Ran, []string{"build", "test"}) {
|
||||
t.Errorf("result = %+v", r)
|
||||
}
|
||||
}
|
||||
|
||||
// installedToolchain is the toolchain directory under toolchains that this node's Node build module installed: the
|
||||
// one installed from the lock at lockPath, byte for byte, or, when the change carries a lock no delivered release has
|
||||
// installed yet, the one installed last, said in the test's log (ADR 0533 §3: a new lock is proven by the module that
|
||||
|
||||
@@ -3,7 +3,7 @@ module meshbuildnode
|
||||
go 1.26.0
|
||||
|
||||
require (
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261025175617-518dae5ec9c8
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261026095702-79649c3fa9f4
|
||||
github.com/distribution/distribution/v3 v3.1.2
|
||||
github.com/nats-io/nats.go v1.54.0
|
||||
)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261025175617-518dae5ec9c8 h1:x4mvY7yiGTP5lnzxbiwyj1o24p05hAmzZP7xHg3XX2M=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261025175617-518dae5ec9c8/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261026095702-79649c3fa9f4 h1:05sVel/UOvz4/g0dxrDOeVUL7BGakVrxPBlqRq02ZhM=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261026095702-79649c3fa9f4/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
|
||||
github.com/alicebob/miniredis/v2 v2.35.0 h1:QwLphYqCEAo1eu1TqPRN2jgVMPBweeQcR21jeqDCONI=
|
||||
github.com/alicebob/miniredis/v2 v2.35.0/go.mod h1:TcL7YfarKPGDAthEtl5NBeHZfeUQj6OXMm/+iu5cLMM=
|
||||
github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op h1:Z/MZK75wC/NSrkgqeNIa7jexam9uWzhLmFTSCPI/kn0=
|
||||
|
||||
@@ -515,6 +515,9 @@ func (k *KB) accept(ctx context.Context, held *Entry, w *Record, by, why, warran
|
||||
|
||||
// judgeSet holds the whole record set to its 4 rules and keeps what fails, which the module's health answers.
|
||||
func (k *KB) judgeSet(ctx context.Context) []Finding {
|
||||
if _, err := k.RecomputeMarks(ctx); err != nil {
|
||||
k.logf("[mesh-knowledge] %v", err)
|
||||
}
|
||||
all, err := k.Store.All(ctx)
|
||||
if err != nil {
|
||||
k.logf("[mesh-knowledge] the whole record set cannot be read to judge it: %v", err)
|
||||
|
||||
@@ -99,7 +99,7 @@ func serve(k *KB, status string) {
|
||||
k.kick = make(chan struct{}, 1)
|
||||
go hear(k)
|
||||
go background(k)
|
||||
if err := stdio.Serve("", append(Tools(k, callerOf), healthTool(status, k.now), highestTool(k))); err != nil {
|
||||
if err := stdio.Serve("", append(Tools(k, callerOf), healthTool(status, k.now), highestTool(k), marksTool(k))); err != nil {
|
||||
logf("%v", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
@@ -119,7 +119,7 @@ func TestItsProcessesAreThePreparationTheActAndTheJudge(t *testing.T) {
|
||||
}
|
||||
h, _ := watch["health"].(map[string]any)
|
||||
if watch == nil || watch["run"].([]any)[1] != "watch" || h["kind"] != "tool" || h["tool"] != HealthTool ||
|
||||
!reflect.DeepEqual(m.Tools, []string{HealthTool, HighestTool}) {
|
||||
!reflect.DeepEqual(m.Tools, []string{HealthTool, HighestTool, MarksTool}) {
|
||||
t.Fatalf("watch %v, tools %v", watch, m.Tools)
|
||||
}
|
||||
// The watch's verdict is whether the database serves, so its check names the provision it exercises: while the
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
package main
|
||||
|
||||
// The marks on a clause (changed, and by what) follow the accepted links, both ways: an acceptance that adds a
|
||||
// changes link sets the mark (accept), and a later accepted version that drops the link withdraws it here. Without
|
||||
// this a mark outlived its link, so holds and the whole-set rule `partial` said "changed" while links and get said
|
||||
// nothing (ADR 0536 v6 changed ADR 0525 §10; v12 only cited it).
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
stdio "git.novox.be/novox/mesh-sdk/go"
|
||||
)
|
||||
|
||||
// MarksTool is the module's tool that recomputes every mark now.
|
||||
const MarksTool = "knowledge_marks"
|
||||
|
||||
// markedBy reads "ADR 0536 §4" or "research 045" as the record it names.
|
||||
func markedBy(s string) (Key, bool) {
|
||||
f := strings.Fields(s)
|
||||
if len(f) < 2 {
|
||||
return Key{}, false
|
||||
}
|
||||
n, err := strconv.Atoi(f[1])
|
||||
if err != nil {
|
||||
return Key{}, false
|
||||
}
|
||||
switch strings.ToLower(f[0]) {
|
||||
case "adr":
|
||||
return Key{KindDecision, n}, true
|
||||
case "research":
|
||||
return Key{KindResearch, n}, true
|
||||
}
|
||||
return Key{}, false
|
||||
}
|
||||
|
||||
// StaleMarks drops, on every record of the set, each "changed by" mark whose changing record is accepted and no
|
||||
// longer links the clause with a changes link; a clause left with no mark is in force again. A mark of a record
|
||||
// not accepted (a change confirmed ahead of its acceptance) or of a record not held stays. It changes the
|
||||
// records in place and answers the keys it changed with what it did.
|
||||
func StaleMarks(set []*Record) map[Key][]string {
|
||||
byKey := map[Key]*Record{}
|
||||
for _, r := range set {
|
||||
byKey[r.Key()] = r
|
||||
}
|
||||
did := map[Key][]string{}
|
||||
for _, t := range set {
|
||||
if t.Status == StatusSuperseded {
|
||||
continue
|
||||
}
|
||||
for i := range t.Clauses {
|
||||
c := &t.Clauses[i]
|
||||
if c.Status != ClauseChanged {
|
||||
continue
|
||||
}
|
||||
var kept []string
|
||||
for _, by := range c.ChangedBy {
|
||||
from, ok := markedBy(by)
|
||||
src := byKey[from]
|
||||
if !ok || src == nil || !src.Accepted() || changesClause(src, t.Key(), c.N) {
|
||||
kept = append(kept, by)
|
||||
continue
|
||||
}
|
||||
did[t.Key()] = append(did[t.Key()], fmt.Sprintf("§%d no longer changed by %s", c.N, by))
|
||||
}
|
||||
if len(kept) != len(c.ChangedBy) {
|
||||
c.ChangedBy = kept
|
||||
if len(kept) == 0 {
|
||||
c.Status = ClauseInForce
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return did
|
||||
}
|
||||
|
||||
func changesClause(src *Record, to Key, clause int) bool {
|
||||
for _, l := range src.Links {
|
||||
if k, ok := l.To.Record(); ok && l.Kind == LinkChanges && k == to && l.To.Clause == clause {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// RecomputeMarks withdraws every stale mark of the record set now, each a new version of the record it frees, and
|
||||
// says what it withdrew. judgeSet runs it first, so an acceptance, the background work every few minutes and a
|
||||
// start all agree with the links.
|
||||
func (k *KB) RecomputeMarks(ctx context.Context) ([]string, error) {
|
||||
all, err := k.Store.All(ctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("the record set cannot be read to recompute its marks: %v", err)
|
||||
}
|
||||
set := make([]*Record, len(all))
|
||||
for i, e := range all {
|
||||
set[i] = e.Current.Clone()
|
||||
}
|
||||
did := StaleMarks(set)
|
||||
var said []string
|
||||
for i, e := range all {
|
||||
what := did[e.Current.Key()]
|
||||
if len(what) == 0 {
|
||||
continue
|
||||
}
|
||||
why := strings.Join(what, "; ") + " (its changing record's accepted links no longer change it)"
|
||||
if err := k.Store.Save(ctx, Version{By: "the knowledge base", At: k.now(), Why: why, Record: set[i]}, true, false, e.Revision); err != nil {
|
||||
k.logf("[mesh-knowledge] the marks of %s could not be recomputed (%v): the next judgement tries again", e.Current.Key(), err)
|
||||
continue
|
||||
}
|
||||
k.emit(EventChanged, set[i], "the knowledge base", why, e.Versions+1)
|
||||
said = append(said, fmt.Sprintf("%s: %s", e.Current.Key(), why))
|
||||
}
|
||||
return said, nil
|
||||
}
|
||||
|
||||
func marksTool(k *KB) stdio.Tool {
|
||||
return stdio.Tool{Name: MarksTool,
|
||||
Description: "Recompute every clause's changed and changed-by marks from the accepted links now, withdrawing each mark whose " +
|
||||
"changing record no longer links the clause (the clause is in force again), and answer what it withdrew. The " +
|
||||
"knowledge base does this on every acceptance and every few minutes; this runs it at once.",
|
||||
Input: schema(nil, map[string]any{}),
|
||||
Run: func(m map[string]any) (any, error) {
|
||||
if _, err := readArgs(MarksTool, m); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
k.mu.Lock()
|
||||
said, err := k.RecomputeMarks(context.Background())
|
||||
k.mu.Unlock()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
findings := k.judgeSet(context.Background())
|
||||
return map[string]any{"withdrawn": said, "findings": len(findings)}, nil
|
||||
}}
|
||||
}
|
||||
@@ -117,6 +117,37 @@ func TestARecordAcceptedCitingANewDecisionLetsItBeAcceptedAndDroppingTheLinkIsLo
|
||||
})
|
||||
}
|
||||
|
||||
// A mark follows the accepted links: a later accepted version that drops a changes link withdraws the clause's
|
||||
// mark, so holds, get and the rule `partial` agree with links (ADR 0536 v6 changed ADR 0525 §10; v12 only cited it).
|
||||
func TestACorrectionThatDropsAChangesLinkWithdrawsTheClausesMark(t *testing.T) {
|
||||
eachStore(t, func(t *testing.T, s Store) {
|
||||
w := imported(t, s)
|
||||
n := w.reserved(t, "author@g14")
|
||||
a := valid(n) // changes 0276 §1
|
||||
w.accepted(t, "author@g14", a)
|
||||
if c := mustGet(t, s, Key{KindDecision, 276}).Current.Clause(1); c.Status != ClauseChanged || len(c.ChangedBy) != 1 {
|
||||
t.Fatalf("0276 §1 is not marked changed by the acceptance: %+v", c)
|
||||
}
|
||||
a.Links = a.Links[:1] // only extends 0276 now
|
||||
a.Insight = "it no longer changes 0276 §1"
|
||||
w.accepted(t, "author@g14", a)
|
||||
c := mustGet(t, s, Key{KindDecision, 276}).Current.Clause(1)
|
||||
if c.Status != ClauseInForce || len(c.ChangedBy) != 0 {
|
||||
t.Fatalf("0276 §1 keeps a mark no accepted link makes: %+v", c)
|
||||
}
|
||||
f, _ := s.Findings(context.Background())
|
||||
for _, x := range f {
|
||||
if x.Rule == "partial" {
|
||||
t.Fatalf("a stale mark is still a finding: %+v", x)
|
||||
}
|
||||
}
|
||||
// Recomputing again changes nothing.
|
||||
if said, err := w.k.RecomputeMarks(context.Background()); err != nil || len(said) != 0 {
|
||||
t.Fatalf("a second recompute withdrew %v (%v)", said, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Each of the 4 whole-set rules, failing on a record set made to break it.
|
||||
func TestEachWholeSetRuleFails(t *testing.T) {
|
||||
acc := func(n int, links ...Link) *Record {
|
||||
|
||||
@@ -58,7 +58,8 @@
|
||||
],
|
||||
"tools": [
|
||||
"knowledge_health",
|
||||
"knowledge_highest"
|
||||
"knowledge_highest",
|
||||
"knowledge_marks"
|
||||
],
|
||||
"emits": [
|
||||
"reserved",
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
/mesh-vault
|
||||
@@ -42,8 +42,9 @@ type AccountHash struct {
|
||||
User string `json:"user"`
|
||||
// Hash is the bcrypt hash of the account's password, at the composer's cost.
|
||||
Hash string `json:"hash"`
|
||||
// Secret, Generation and Fingerprint are the vault's own secret the hash is of: absent for an adopted one.
|
||||
Secret string `json:"secret,omitempty"`
|
||||
// Of, Generation and Fingerprint name the vault's own secret the hash is of: absent for an adopted one. The field
|
||||
// is not called "secret": the runtime refuses a state field whose name says it is a credential (ADR 0201).
|
||||
Of string `json:"of,omitempty"`
|
||||
Generation int `json:"generation,omitempty"`
|
||||
Fingerprint string `json:"fingerprint,omitempty"`
|
||||
At time.Time `json:"at"`
|
||||
@@ -149,7 +150,7 @@ func accountOf(secret, value string) (user, password string, err error) {
|
||||
|
||||
// Hashed is what one hashing did: the key written or found, never the hash.
|
||||
type Hashed struct {
|
||||
Secret string `json:"secret"`
|
||||
Of string `json:"of"`
|
||||
User string `json:"user"`
|
||||
Generation int `json:"generation"`
|
||||
Fingerprint string `json:"fingerprint"`
|
||||
@@ -178,13 +179,13 @@ func (v *Vault) HashAccount(secret string, force bool) (Hashed, error) {
|
||||
return Hashed{}, err
|
||||
}
|
||||
fp := Fingerprint(value)
|
||||
h := Hashed{Secret: secret, User: user, Generation: rec.Generation, Fingerprint: fp}
|
||||
h := Hashed{Of: secret, User: user, Generation: rec.Generation, Fingerprint: fp}
|
||||
was, err := v.Accounts.Get(user)
|
||||
if err != nil {
|
||||
v.hashes.owe(secret, "its key could not be read: "+err.Error())
|
||||
return Hashed{}, fmt.Errorf("%s %s could not be read: %w", BusAccountsState, user, err)
|
||||
}
|
||||
if !force && was != nil && !was.Adopted && was.Secret == secret && was.Generation == rec.Generation && was.Fingerprint == fp {
|
||||
if !force && was != nil && !was.Adopted && was.Of == secret && was.Generation == rec.Generation && was.Fingerprint == fp {
|
||||
v.hashes.paid(secret)
|
||||
h.At = was.At
|
||||
return h, nil
|
||||
@@ -194,7 +195,7 @@ func (v *Vault) HashAccount(secret string, force bool) (Hashed, error) {
|
||||
return Hashed{}, err
|
||||
}
|
||||
h.At = v.Now().UTC()
|
||||
if err := v.Accounts.Put(AccountHash{User: user, Hash: string(hash), Secret: secret, Generation: rec.Generation,
|
||||
if err := v.Accounts.Put(AccountHash{User: user, Hash: string(hash), Of: secret, Generation: rec.Generation,
|
||||
Fingerprint: fp, At: h.At}); err != nil {
|
||||
v.hashes.owe(secret, "its key could not be written: "+err.Error())
|
||||
return Hashed{}, fmt.Errorf("the hash of %s (generation %d) could not be written to %s %s, tried again at the "+
|
||||
@@ -203,7 +204,7 @@ func (v *Vault) HashAccount(secret string, force bool) (Hashed, error) {
|
||||
v.hashes.paid(secret)
|
||||
h.Written = true
|
||||
if v.Emit != nil {
|
||||
if err := v.Emit(AccountHashedEvent, map[string]any{"user": user, "secret": secret, "generation": rec.Generation,
|
||||
if err := v.Emit(AccountHashedEvent, map[string]any{"user": user, "of": secret, "generation": rec.Generation,
|
||||
"fingerprint": fp, "at": h.At.Format(time.RFC3339Nano)}); err != nil {
|
||||
v.log("the hash of %s is written, but %s could not be said: %v", secret, AccountHashedEvent, err)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// credentialEndings and credentialField mirror the runtime's refusal of a state or log value with a field whose name says
|
||||
// it is a credential (mesh-tools node-tools/internal/bus/state.go, credentialField, ADR 0201): the runtime's check is
|
||||
// unexported and in another repository, so this is a copy and must follow it. A fake state that skips it let a
|
||||
// field named "secret" pass every test and be refused on novox (issue 1945).
|
||||
var credentialEndings = []string{"password", "passwd", "secret", "token", "credential", "credentials",
|
||||
"authorization", "apikey", "privatekey", "accesskey", "cookie"}
|
||||
|
||||
func credentialField(value []byte) string {
|
||||
var v any
|
||||
if json.Unmarshal(value, &v) != nil {
|
||||
return ""
|
||||
}
|
||||
var walk func(any) string
|
||||
walk = func(v any) string {
|
||||
switch t := v.(type) {
|
||||
case map[string]any:
|
||||
for k, x := range t {
|
||||
norm := strings.NewReplacer("-", "", "_", "", " ", "").Replace(strings.ToLower(k))
|
||||
for _, end := range credentialEndings {
|
||||
if strings.HasSuffix(norm, end) {
|
||||
return k
|
||||
}
|
||||
}
|
||||
if f := walk(x); f != "" {
|
||||
return f
|
||||
}
|
||||
}
|
||||
case []any:
|
||||
for _, x := range t {
|
||||
if f := walk(x); f != "" {
|
||||
return f
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
return walk(v)
|
||||
}
|
||||
|
||||
// The vault's bus-accounts records pass the runtime's credential-field check; a record with a "secret" field does not.
|
||||
func TestStateRecordsPassTheRuntimesCredentialFieldCheck(t *testing.T) {
|
||||
for _, c := range []struct {
|
||||
name string
|
||||
value any
|
||||
field string
|
||||
}{
|
||||
{"a hash record of the vault's own secret", AccountHash{User: "ace.mesh-build-go", Hash: "$2a$10$h", Of: "own/ace/mesh-build-go/broker", Generation: 1, Fingerprint: "ab12"}, ""},
|
||||
{"an adopted hash record", AccountHash{User: "ace.x", Hash: "$2a$10$h", Adopted: true}, ""},
|
||||
{"a record with a secret field is refused", map[string]any{"user": "u", "secret": "own/x"}, "secret"},
|
||||
} {
|
||||
raw, err := json.Marshal(c.value)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := credentialField(raw); got != c.field {
|
||||
t.Errorf("%s: credential field %q, want %q (%s)", c.name, got, c.field, raw)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,9 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
@@ -36,6 +38,11 @@ func (f *fakeAccounts) Put(a AccountHash) error {
|
||||
if f.keys == nil {
|
||||
f.keys = map[string]AccountHash{}
|
||||
}
|
||||
// What the runtime does to every state value (credential_field_test.go): the vault's own record is checked as written.
|
||||
raw, _ := json.Marshal(a)
|
||||
if field := credentialField(raw); field != "" {
|
||||
return fmt.Errorf("%s carries a field %q, which names a credential: no secret is kept in state", a.User, field)
|
||||
}
|
||||
f.keys[a.User] = a
|
||||
f.puts++
|
||||
return nil
|
||||
|
||||
@@ -761,8 +761,8 @@ func hashedRow(v *Vault, r Record, fp string, row map[string]any) {
|
||||
row["hashed"], row["hashWhy"] = false, "no key"
|
||||
case h.Adopted:
|
||||
row["hashed"], row["hashedAt"], row["hashWhy"] = false, h.At, "the key is adopted from the loaded list, not of this value"
|
||||
case h.Secret != r.Name || h.Generation != r.Generation || h.Fingerprint != fp:
|
||||
row["hashed"], row["hashedAt"], row["hashWhy"] = false, h.At, fmt.Sprintf("the key is of %s generation %d", h.Secret, h.Generation)
|
||||
case h.Of != r.Name || h.Generation != r.Generation || h.Fingerprint != fp:
|
||||
row["hashed"], row["hashedAt"], row["hashWhy"] = false, h.At, fmt.Sprintf("the key is of %s generation %d", h.Of, h.Generation)
|
||||
default:
|
||||
row["hashed"], row["hashedAt"] = true, h.At
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user