Merge feat/modules-name-no-node into bulk-a
This commit is contained in:
@@ -1,3 +0,0 @@
|
||||
# The modules the check covers (novox/hq ADR 0507 §9, issue 1838), one a line; issue 1838 widens it.
|
||||
pacman
|
||||
kernel
|
||||
+41
-16
@@ -1,12 +1,14 @@
|
||||
// Command particulars keeps this mesh's node names out of the modules it covers (novox/hq ADR 0507 §9,
|
||||
// issue 1838): a module is for any mesh, so no manifest, file, code string, identifier, comment, test name or
|
||||
// fixture of a covered module names a node of this one.
|
||||
// fixture of any module names a node of this one.
|
||||
//
|
||||
// The names are in the file `particulars` at the catalogue's root, one a line, `#` comments — the one place
|
||||
// they may be written. The modules covered are in `covered` at checks/particulars/covered, one a line. A name is matched
|
||||
// whole and in any case; a hyphen, an underscore or a CamelCase boundary parts one, so a longer word holding a name is another word.
|
||||
// The forge is not a node name: its host (git.novox.be) and the organisation path (novox/<repo>) are blanked
|
||||
// before a line is read.
|
||||
// they may be written. Every directory under modules/ is read, with no list of covered modules, so a new module
|
||||
// is covered from its first commit. A name is matched whole and in any case; a hyphen, an underscore or a
|
||||
// CamelCase boundary parts one, so a longer word holding a name is another word.
|
||||
// Three names hold a node name and are not one, so they are blanked before a line is read: the forge's host
|
||||
// (git.novox.be) and organisation path (novox/<repo>), the hardware model (asus-zephyrus-g14, ROG Zephyrus G14)
|
||||
// and the software's own name (Novox Mesh, novox-mesh).
|
||||
//
|
||||
// go run . <catalogue root>
|
||||
//
|
||||
@@ -29,6 +31,11 @@ import (
|
||||
// (testdata/novox/x, /srv/novox/data) is not blanked.
|
||||
var forge = regexp.MustCompile(`(?i)git\.novox\.be(?:/novox/[\w.-]+)?|(?:^|[^/\w.-])novox/[\w.-]+`)
|
||||
|
||||
// ownNames are the hardware model's and the software's own names, blanked before a line is read: the model
|
||||
// in any separator and case (asus-zephyrus-g14, ASUS_ZEPHYRUS_G14, ROG Zephyrus G14), the software as two
|
||||
// words or hyphened, whole (Novox Mesh, novox mesh, novox-mesh), so novox-mesh-vault stays a node name joined.
|
||||
var ownNames = regexp.MustCompile(`(?i)(?:asus[-_ ])?zephyrus[-_ ]g14|(?:^|[^\w-])novox[- ]mesh(?:$|[^\w-])`)
|
||||
|
||||
func main() {
|
||||
root := "."
|
||||
if len(os.Args) > 1 {
|
||||
@@ -43,18 +50,18 @@ func check(root string) int {
|
||||
if err == nil && len(names) == 0 {
|
||||
err = fmt.Errorf("particulars lists no name — the check would pass on anything")
|
||||
}
|
||||
var covered []string
|
||||
var modules []string
|
||||
if err == nil {
|
||||
covered, err = readList(filepath.Join(root, "checks/particulars/covered"))
|
||||
if err == nil && len(covered) == 0 {
|
||||
err = fmt.Errorf("covered lists no module — the check would pass on anything")
|
||||
modules, err = moduleDirs(root)
|
||||
if err == nil && len(modules) == 0 {
|
||||
err = fmt.Errorf("modules/ holds no module — the check would pass on anything")
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, "particulars:", err)
|
||||
return 2
|
||||
}
|
||||
findings, err := run(root, names, covered)
|
||||
findings, err := run(root, names, modules)
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, "particulars:", err)
|
||||
return 2
|
||||
@@ -66,7 +73,7 @@ func check(root string) int {
|
||||
fmt.Printf("particulars: %d node name(s) in a module for any mesh (novox/hq ADR 0507 §9) — say what it shows, not where it was seen\n", len(findings))
|
||||
return 1
|
||||
}
|
||||
fmt.Printf("particulars: none of %d node names in the %d covered modules\n", len(names), len(covered))
|
||||
fmt.Printf("particulars: none of %d node names in the %d modules\n", len(names), len(modules))
|
||||
return 0
|
||||
}
|
||||
|
||||
@@ -89,10 +96,25 @@ func readList(path string) ([]string, error) {
|
||||
return out, sc.Err()
|
||||
}
|
||||
|
||||
// run reads every text file of each covered module and returns the findings, sorted.
|
||||
func run(root string, names, covered []string) ([]string, error) {
|
||||
// moduleDirs returns every directory under the catalogue's modules/, sorted.
|
||||
func moduleDirs(root string) ([]string, error) {
|
||||
entries, err := os.ReadDir(filepath.Join(root, "modules"))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var out []string
|
||||
for _, m := range covered {
|
||||
for _, e := range entries {
|
||||
if e.IsDir() {
|
||||
out = append(out, e.Name())
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// run reads every text file of each module named and returns the findings, sorted.
|
||||
func run(root string, names, modules []string) ([]string, error) {
|
||||
var out []string
|
||||
for _, m := range modules {
|
||||
dir := filepath.Join(root, "modules", m)
|
||||
err := filepath.WalkDir(dir, func(path string, d fs.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
@@ -143,9 +165,12 @@ func scan(file, src string, names []string) []string {
|
||||
}
|
||||
|
||||
// hits returns the node names one line holds: as a word (a hyphen or underscore parts it), as a part of a
|
||||
// CamelCase identifier (TestAce, novoxTree), the forge's host and organisation path blanked first.
|
||||
// CamelCase identifier (TestAce, novoxTree), the forge's host and organisation path and the hardware's and the
|
||||
// software's own names blanked first.
|
||||
func hits(line string, names []string) []string {
|
||||
line = forge.ReplaceAllStringFunc(line, func(s string) string { return strings.Repeat(" ", len(s)) })
|
||||
blank := func(s string) string { return strings.Repeat(" ", len(s)) }
|
||||
line = forge.ReplaceAllStringFunc(line, blank)
|
||||
line = ownNames.ReplaceAllStringFunc(line, blank)
|
||||
var out []string
|
||||
for _, n := range names {
|
||||
q := regexp.QuoteMeta(n)
|
||||
|
||||
@@ -82,7 +82,6 @@ func TestCheckReturnsOneOnAFindingAndTwoOnAnUnreadableList(t *testing.T) {
|
||||
os.WriteFile(p, []byte(s), 0o644)
|
||||
}
|
||||
write("particulars", "ace\n")
|
||||
write("checks/particulars/covered", "kernel\n")
|
||||
write("modules/kernel/a", "clean\n")
|
||||
if got := check(root); got != 0 {
|
||||
t.Errorf("clean = %d, want 0", got)
|
||||
@@ -97,7 +96,7 @@ func TestCheckReturnsOneOnAFindingAndTwoOnAnUnreadableList(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestOnlyCoveredModulesAreRead(t *testing.T) {
|
||||
func TestRunReadsOnlyTheModulesItIsGiven(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
write := func(p, s string) {
|
||||
p = filepath.Join(root, p)
|
||||
@@ -114,3 +113,59 @@ func TestOnlyCoveredModulesAreRead(t *testing.T) {
|
||||
t.Errorf("got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// 1838/AC1: the check reads every directory under modules/ and no list of covered modules, so a module is
|
||||
// covered from its first commit.
|
||||
func TestEveryModuleIsReadWithoutAList(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
write := func(p, s string) {
|
||||
p = filepath.Join(root, p)
|
||||
os.MkdirAll(filepath.Dir(p), 0o755)
|
||||
os.WriteFile(p, []byte(s), 0o644)
|
||||
}
|
||||
write("particulars", "novox\n")
|
||||
write("modules/kernel/files/a", "clean\n")
|
||||
if got := check(root); got != 0 {
|
||||
t.Errorf("clean, no covered list = %d, want 0", got)
|
||||
}
|
||||
write("modules/new-module/files/a", "on novox\n")
|
||||
if got := check(root); got != 1 {
|
||||
t.Errorf("a finding in a module no list names = %d, want 1", got)
|
||||
}
|
||||
}
|
||||
|
||||
// 1838/AC2: the hardware model's own name and the software's own name are not node names.
|
||||
func TestTheHardwareModelAndTheSoftwareAreNotNodeNames(t *testing.T) {
|
||||
for _, s := range []string{
|
||||
"modules/asus-zephyrus-g14/README.md",
|
||||
"the asus-zephyrus-g14 module",
|
||||
"ASUS_ZEPHYRUS_G14=1",
|
||||
"product_family: ROG Zephyrus G14",
|
||||
"# This machine is a node of a Novox mesh",
|
||||
"Novox Mesh turns a handful of personal machines into one system",
|
||||
"<!-- novox mesh: this file was changed on two nodes",
|
||||
`const sealInfo = "novox-mesh sealed box v1"`,
|
||||
"module git.novox.be/novox/mesh-catalog/modules/kernel",
|
||||
} {
|
||||
if got := scan("f", s, names); len(got) != 0 {
|
||||
t.Errorf("%q refused: %v", s, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 1838/AC2: a node name standing alone is still one, beside the hardware's or the software's name too.
|
||||
func TestANodeNameBesideTheHardwareOrTheSoftwareIsRefused(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
"g14": "the asus-zephyrus-g14 module on g14",
|
||||
"shanks": "Novox Mesh on shanks",
|
||||
"novox": "resolves under novox.internal",
|
||||
"ace": "ROG Zephyrus G14 beside ace",
|
||||
"novox mesh-ish": "mesh.mod.gitea.call.x.novox-mesh-vault",
|
||||
}
|
||||
for want, s := range cases {
|
||||
got := scan("modules/m/a.go", s, names)
|
||||
if len(got) != 1 || !strings.HasPrefix(got[0], "modules/m/a.go:1: ") {
|
||||
t.Errorf("%s: %q gave %v, want one finding naming file:line", want, s, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,7 +38,7 @@ the SDK, or the catalogue's bus, forge or build agent) resolves with `replay` or
|
||||
needs a `reason`. A refused change changes nothing and says which rule refused it.
|
||||
|
||||
**Who works on it** (hq ADR 0284). A move to `diagnosing` or `fixing` names in `by` the issue's **worker**: who
|
||||
works on it and its node, as `who@node` (such as `fix-399@g14` or `jochen@shanks`, kept in lower case). The issue
|
||||
works on it and its node, as `who@node` (such as `fix-399@node-b` or `operator@node-d`, kept in lower case). The issue
|
||||
keeps its worker (`worker`: `by`, `since`, `active`) while it stays at either status; any other status ends the
|
||||
work. `get` shows it, with `worker-stale`, and each `list` row shows `worker`, `since` and `stale`. A move to either
|
||||
status by someone else while the worker is current is refused, naming the worker; with `take-over` saying why, the
|
||||
@@ -342,7 +342,7 @@ when. A change whose event the bus did not take is kept, and its answer says the
|
||||
ADR 0275 §5 records the caller from the bus credential, never from an argument. **Who acted is the bus user
|
||||
that called** (hq issue 365): a call on a tool's caller-named subject carries the caller's bus user as its last
|
||||
token, the bus lets each user publish there with its own name alone, and the tool runner hands the module that
|
||||
user from the subject the call arrived on (mesh-sdk `stdio.CallerOf`) — `shanks.node-tools` for an agent on
|
||||
user from the subject the call arrived on (mesh-sdk `stdio.CallerOf`) — `node-d.node-tools` for an agent on
|
||||
that machine's console, `person.<name>` for a person's credential. A call on the subject that names no caller
|
||||
(served beside it for one release, hq issue 464), or through a runtime from before issue 365, is recorded as
|
||||
by `unproven`. No verb takes an argument naming a caller, and none can be given one. The caller is only as sound
|
||||
@@ -351,7 +351,8 @@ as the credential, which ADR 0266 keeps agents from reading (hq issue 333).
|
||||
## Settings
|
||||
|
||||
`duplicate-check` (`on`; `off` stops the triage of new issues), `judge-model` (`sonnet`; `haiku` or `opus`) and
|
||||
`forge-owner` (the forge's owner of the repositories `fixed-by` names, for asking a delivery) steer the triage;
|
||||
`forge-owner` (the forge's owner of the repositories `fixed-by` names, for asking a delivery: required, with no
|
||||
default, novox/hq ADR 0507 §9) steer the triage;
|
||||
`stuck-after` (`2h`) is how long a merged fix may take to be delivered before it is said;
|
||||
`delivered-not-resolved-after` (`1h`) how long an issue may stay in fixing once every fix is delivered before it is
|
||||
said (hq issue 483).
|
||||
|
||||
@@ -427,9 +427,16 @@ func nonZero(t *time.Time) *time.Time {
|
||||
// DeliveryState asks mesh-delivery for one pull request's delivery.
|
||||
type DeliveryState func(repository string, number string) (Delivery, error)
|
||||
|
||||
// errNoForgeOwner is what asking the forge says when no forge owner is set: the forge is not asked under a
|
||||
// name nobody gave (novox/hq ADR 0507 §9).
|
||||
var errNoForgeOwner = errors.New("forge-owner is not set: the forge is not asked (set the module's forge-owner)")
|
||||
|
||||
// busDeliveryState asks mesh-delivery's checks over the bus.
|
||||
func busDeliveryState(owner string) DeliveryState {
|
||||
return func(repository, number string) (Delivery, error) {
|
||||
if owner == "" {
|
||||
return Delivery{}, errNoForgeOwner
|
||||
}
|
||||
raw, err := stdio.Ask("seat:mesh-delivery.checks", map[string]any{"repository": owner + "/" + repository, "number": number})
|
||||
if err != nil {
|
||||
return Delivery{}, err
|
||||
|
||||
@@ -362,3 +362,15 @@ func TestATransitionIsTakenUnderEveryNameTheTrackerHears(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// With no forge owner, the forge is not asked under a name nobody gave: the delivery and the approval each say
|
||||
// the owner is not set (novox/hq ADR 0507 §9).
|
||||
// 1838/AC4
|
||||
func TestAnUnsetForgeOwnerAsksNoForgeAndSaysWhy(t *testing.T) {
|
||||
if _, err := busDeliveryState("")("mesh-catalog", "5"); err == nil || !strings.Contains(err.Error(), "forge-owner is not set") {
|
||||
t.Errorf("delivery with no owner: err %v, want it to say forge-owner is not set", err)
|
||||
}
|
||||
if _, _, err := busApproval(func() string { return "" })("mesh-catalog", 5); err == nil || !strings.Contains(err.Error(), "forge-owner is not set") {
|
||||
t.Errorf("approval with no owner: err %v, want it to say forge-owner is not set", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -638,7 +638,11 @@ func verdictApproves(body, commit string) bool {
|
||||
// busApproval asks the forge's reviews of a pull request over the bus (gitea_pull_reviews).
|
||||
func busApproval(owner func() string) Approval {
|
||||
return func(repository string, number int) (time.Time, bool, error) {
|
||||
raw, err := stdio.Ask("gitea.gitea_pull_reviews", map[string]any{"owner": owner(), "repo": repository, "number": number})
|
||||
o := owner()
|
||||
if o == "" {
|
||||
return time.Time{}, false, errNoForgeOwner
|
||||
}
|
||||
raw, err := stdio.Ask("gitea.gitea_pull_reviews", map[string]any{"owner": o, "repo": repository, "number": number})
|
||||
if err != nil {
|
||||
return time.Time{}, false, err
|
||||
}
|
||||
|
||||
@@ -39,7 +39,8 @@ type Settings struct {
|
||||
// JudgeModel is the model the duplicate check asks; DefaultJudgeModel when empty.
|
||||
JudgeModel string `json:"judge-model,omitempty"`
|
||||
// ForgeOwner is the owner on the forge of the repositories fixed-by names, for asking a pull request's
|
||||
// delivery (ADR 0279 §3).
|
||||
// delivery (ADR 0279 §3): the operator's, a required config value with no default (ADR 0507 §9); empty,
|
||||
// the forge is not asked and each asking says so.
|
||||
ForgeOwner string `json:"forge-owner,omitempty"`
|
||||
// StuckAfter is how long a fix may be merged and not delivered before it is said stuck (ADR 0281), as a
|
||||
// duration such as 2h; DefaultStuckAfter when empty.
|
||||
@@ -99,7 +100,7 @@ func main() {
|
||||
if s, err := t.Settings(); err == nil && strings.TrimSpace(s.ForgeOwner) != "" {
|
||||
return strings.TrimSpace(s.ForgeOwner)
|
||||
}
|
||||
return "novox"
|
||||
return ""
|
||||
}
|
||||
t.Deliveries = func(repository, number string) (Delivery, error) {
|
||||
return busDeliveryState(forgeOwner())(repository, number)
|
||||
@@ -533,7 +534,7 @@ func Tools(t *Tracker) []stdio.Tool {
|
||||
{Name: Seat + ".move",
|
||||
Description: "Change an issue's status — " + statuses + " — with what that status needs, under the " +
|
||||
"development cycle's rules. Move an issue to diagnosing or fixing when you start work on it, with by: " +
|
||||
"who works on it and its node, as who@node (such as fix-399@g14); the issue shows it until it moves on. " +
|
||||
"who works on it and its node, as who@node (such as fix-399@node-b); the issue shows it until it moves on. " +
|
||||
"Refused while someone else works on it, naming them, unless take-over says why (noted on the issue); a " +
|
||||
"worker who did nothing on it for a day may be taken over from without a reason, and with no pull request open " +
|
||||
"the answer says it was abandoned. located needs located-in, owners on " +
|
||||
@@ -551,7 +552,7 @@ func Tools(t *Tracker) []stdio.Tool {
|
||||
"replay": text("for a core issue resolved: the replay's id in mesh-lab's replays register, such as R361"),
|
||||
"replay-none": text("for a core issue resolved: why no replay is possible"),
|
||||
"reason": text("for wontfix: why it will not be fixed"),
|
||||
"by": text("for diagnosing and fixing: who works on it and its node, as who@node, such as fix-399@g14 or jochen@shanks"),
|
||||
"by": text("for diagnosing and fixing: who works on it and its node, as who@node, such as fix-399@node-b or operator@node-d"),
|
||||
"take-over": text("for diagnosing and fixing, when someone else works on it: why you take it over"),
|
||||
}),
|
||||
Run: func(m map[string]any) (any, error) {
|
||||
@@ -734,7 +735,7 @@ func Tools(t *Tracker) []stdio.Tool {
|
||||
"operator's channel asks it to say in a condition's message which issue tracks it. Refused: a key not of a condition " +
|
||||
"key's shape.",
|
||||
Input: schema(nil, map[string]any{
|
||||
"conditions": texts("only these condition keys, such as module.mounts.shanks.unhealthy; all when not given"),
|
||||
"conditions": texts("only these condition keys, such as module.mounts.node-d.unhealthy; all when not given"),
|
||||
}),
|
||||
Run: func(m map[string]any) (any, error) {
|
||||
a, err := readArgs("tracking", m, "conditions")
|
||||
|
||||
@@ -270,3 +270,40 @@ func TestEveryCodeModuleOfTheCatalogueIsAnOwner(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The forge's owner differs between meshes, so it is a config value the operator gives, with no default naming
|
||||
// this mesh, and the template the config merges into holds none either (novox/hq ADR 0507 §9).
|
||||
// 1838/AC4
|
||||
func TestTheForgeOwnerIsARequiredValueWithNoDefault(t *testing.T) {
|
||||
raw, err := os.ReadFile(filepath.Join("..", "..", "module.json"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var m struct {
|
||||
Config map[string]map[string]any `json:"config"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &m); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
owner, ok := m.Config["forge-owner"]
|
||||
if !ok {
|
||||
t.Fatal("no forge-owner config value")
|
||||
}
|
||||
if d, has := owner["default"]; has {
|
||||
t.Errorf("forge-owner has the default %v: the forge's owner is the operator's, with no default", d)
|
||||
}
|
||||
if owner["required"] != true {
|
||||
t.Errorf("forge-owner is not required: an unset owner would compose and ask no forge in silence")
|
||||
}
|
||||
tmpl, err := os.ReadFile(filepath.Join("..", "..", "files", "settings.json"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var s map[string]any
|
||||
if err := json.Unmarshal(tmpl, &s); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if v, has := s["forge-owner"]; has {
|
||||
t.Errorf("files/settings.json holds forge-owner %v: the value comes from the config only", v)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -393,7 +393,7 @@ func (t *Tracker) move(by string, a MoveAsk) (*Moved, error) {
|
||||
if worked(to) {
|
||||
if strings.TrimSpace(a.By) == "" {
|
||||
return nil, fmt.Errorf("a move to %s needs by: who works on it and its node, as who@node, such as "+
|
||||
"fix-399@g14 or jochen@shanks", to)
|
||||
"fix-399@node-b or operator@node-d", to)
|
||||
}
|
||||
if worker, err = checkWorker(a.By); err != nil {
|
||||
return nil, err
|
||||
|
||||
@@ -53,7 +53,7 @@ func checkWorkerField(field, s string) (string, error) {
|
||||
s = strings.ToLower(s)
|
||||
if !workerName.MatchString(s) {
|
||||
return "", fmt.Errorf("%s %q is not who works on it and its node: give who@node, the agent or person and the "+
|
||||
"machine it works on, such as fix-399@g14 or jochen@shanks", field, s)
|
||||
"machine it works on, such as fix-399@node-b or operator@node-d", field, s)
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
@@ -161,7 +161,6 @@
|
||||
],
|
||||
"duplicate-check": "on",
|
||||
"judge-model": "sonnet",
|
||||
"forge-owner": "novox",
|
||||
"stuck-after": "2h",
|
||||
"worker-stale-after": "24h",
|
||||
"delivered-not-resolved-after": "1h",
|
||||
|
||||
@@ -267,9 +267,9 @@
|
||||
},
|
||||
"forge-owner": {
|
||||
"type": "string",
|
||||
"description": "The owner on the forge of the repositories an issue's fixed-by names, for asking after a pull request's delivery (novox/hq ADR 0279 §3); empty asks no forge.",
|
||||
"description": "The owner on the forge of the repositories an issue's fixed-by names, for asking after a pull request's delivery (novox/hq ADR 0279 §3), such as the organisation that holds them: the operator's, with no default (novox/hq ADR 0507 §9).",
|
||||
"trusted": true,
|
||||
"default": "novox"
|
||||
"required": true
|
||||
},
|
||||
"stuck-after": {
|
||||
"type": "string",
|
||||
|
||||
@@ -10,7 +10,7 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// respond answers one query: novox.internal A 10.10.0.1 via a compressed name, anything else rcode.
|
||||
// respond answers one query: node-c.internal A 10.10.0.1 via a compressed name, anything else rcode.
|
||||
func respond(q []byte, rcode byte) []byte {
|
||||
end := 12
|
||||
for q[end] != 0 {
|
||||
@@ -20,7 +20,7 @@ func respond(q []byte, rcode byte) []byte {
|
||||
name := strings.ToLower(string(q[13:end]))
|
||||
r := append([]byte{}, q[:2]...)
|
||||
r = append(r, 0x81, 0x80|rcode, 0, 1)
|
||||
answer := rcode == 0 && strings.Contains(name, "novox") && binary.BigEndian.Uint16(q[end+1:]) == 1
|
||||
answer := rcode == 0 && strings.Contains(name, "node-c") && binary.BigEndian.Uint16(q[end+1:]) == 1
|
||||
if answer {
|
||||
r = append(r, 0, 1, 0, 0, 0, 0)
|
||||
} else {
|
||||
@@ -80,11 +80,11 @@ func tcpResolver(t *testing.T) string {
|
||||
|
||||
// Issue 348: the question that could not be asked during the outage — does the resolver at one address answer?
|
||||
func TestDNSAsksANamedResolverOverUDPAndTCPAndSaysItsResponseCode(t *testing.T) {
|
||||
got, err := AskDNS("novox.internal", "A", udpResolver(t, 0), false, time.Second)
|
||||
got, err := AskDNS("node-c.internal", "A", udpResolver(t, 0), false, time.Second)
|
||||
if err != nil || got.Rcode != "NOERROR" || got.Protocol != "udp" || len(got.Answers) != 1 || got.Answers[0] != "10.10.0.1" || got.Error != "" {
|
||||
t.Fatalf("udp: %+v, %v", got, err)
|
||||
}
|
||||
got, err = AskDNS("novox.internal", "A", tcpResolver(t), true, time.Second)
|
||||
got, err = AskDNS("node-c.internal", "A", tcpResolver(t), true, time.Second)
|
||||
if err != nil || got.Rcode != "NOERROR" || got.Protocol != "tcp" || len(got.Answers) != 1 || got.Answers[0] != "10.10.0.1" {
|
||||
t.Fatalf("tcp: %+v, %v", got, err)
|
||||
}
|
||||
@@ -92,7 +92,7 @@ func TestDNSAsksANamedResolverOverUDPAndTCPAndSaysItsResponseCode(t *testing.T)
|
||||
if got.Rcode != "NXDOMAIN" || got.Error == "" || len(got.Answers) != 0 {
|
||||
t.Fatalf("no such name is a result with its code: %+v", got)
|
||||
}
|
||||
got, _ = AskDNS("novox.internal", "A", udpResolver(t, 5), false, time.Second)
|
||||
got, _ = AskDNS("node-c.internal", "A", udpResolver(t, 5), false, time.Second)
|
||||
if got.Rcode != "REFUSED" || got.Error == "" {
|
||||
t.Fatalf("a refusal: %+v", got)
|
||||
}
|
||||
@@ -102,14 +102,14 @@ func TestDNSSaysARefusedPortAndRefusesANameAsServer(t *testing.T) {
|
||||
l, _ := net.Listen("tcp", "127.0.0.1:0")
|
||||
addr := l.Addr().String()
|
||||
l.Close()
|
||||
got, err := AskDNS("novox.internal", "A", addr, true, time.Second)
|
||||
got, err := AskDNS("node-c.internal", "A", addr, true, time.Second)
|
||||
if err != nil || !strings.Contains(got.Error, "connection refused") {
|
||||
t.Fatalf("nothing listening is a result naming it: %+v, %v", got, err)
|
||||
}
|
||||
if _, err := AskDNS("novox.internal", "A", "resolver.example", false, time.Second); err == nil {
|
||||
if _, err := AskDNS("node-c.internal", "A", "resolver.example", false, time.Second); err == nil {
|
||||
t.Fatal("a server given by name was accepted")
|
||||
}
|
||||
if _, err := AskDNS("novox.internal", "A", "", true, time.Second); err == nil {
|
||||
if _, err := AskDNS("node-c.internal", "A", "", true, time.Second); err == nil {
|
||||
t.Fatal("tcp without a server was accepted")
|
||||
}
|
||||
if a, _ := serverAddress("10.10.0.1"); a != "10.10.0.1:53" {
|
||||
|
||||
@@ -13,8 +13,8 @@ func dump(now time.Time) string {
|
||||
fresh := now.Add(-40 * time.Second).Unix()
|
||||
old := now.Add(-10 * time.Minute).Unix()
|
||||
return privateKey + "\tPUBkeyOfTheHubAAAAAAAAAAAAAAAAAAAAAAAAAAAA=\t51820\toff\n" +
|
||||
"PUBkeyAceAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=\t(none)\t84.193.151.6:40122\t10.10.0.2/32\t" + itoa(old) + "\t1000\t2000\t25\n" +
|
||||
"PUBkeyShanksAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=\t(none)\t84.193.151.6:51820\t10.10.0.3/32\t" + itoa(fresh) + "\t3000\t4000\toff\n" +
|
||||
"PUBkeyNodeAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=\t(none)\t84.193.151.6:40122\t10.10.0.2/32\t" + itoa(old) + "\t1000\t2000\t25\n" +
|
||||
"PUBkeyNodeDAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=\t(none)\t84.193.151.6:51820\t10.10.0.3/32\t" + itoa(fresh) + "\t3000\t4000\toff\n" +
|
||||
"PUBkeyNewAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=\t(none)\t(none)\t10.10.0.9/32\t0\t0\t0\toff\n"
|
||||
}
|
||||
|
||||
@@ -33,12 +33,12 @@ func TestTheDumpIsReadAndNoKeyIsSaid(t *testing.T) {
|
||||
if tun.ListenPort != 51820 || len(tun.Peers) != 3 {
|
||||
t.Fatalf("got %+v", tun)
|
||||
}
|
||||
ace, shanks, fresh := tun.Peers[0], tun.Peers[1], tun.Peers[2]
|
||||
if ace.Endpoint != "84.193.151.6:40122" || ace.HandshakeAge != 600 || !ace.Stale || ace.Keepalive != "25s" || ace.Sent != 2000 {
|
||||
t.Errorf("ace %+v", ace)
|
||||
nodeA, nodeD, fresh := tun.Peers[0], tun.Peers[1], tun.Peers[2]
|
||||
if nodeA.Endpoint != "84.193.151.6:40122" || nodeA.HandshakeAge != 600 || !nodeA.Stale || nodeA.Keepalive != "25s" || nodeA.Sent != 2000 {
|
||||
t.Errorf("nodeA %+v", nodeA)
|
||||
}
|
||||
if shanks.Stale || shanks.HandshakeAge != 40 || shanks.Keepalive != "" {
|
||||
t.Errorf("shanks %+v", shanks)
|
||||
if nodeD.Stale || nodeD.HandshakeAge != 40 || nodeD.Keepalive != "" {
|
||||
t.Errorf("nodeD %+v", nodeD)
|
||||
}
|
||||
if fresh.Endpoint != "" || fresh.HandshakeAge != -1 || !fresh.Stale || fresh.Handshake != "" {
|
||||
t.Errorf("a peer never heard from %+v", fresh)
|
||||
|
||||
@@ -81,16 +81,16 @@ func TestWithoutTheBackupListTheExportsStayAndItIsSaid(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestBackupFromTheSeatsAndTheData(t *testing.T) {
|
||||
seats := json.RawMessage(`{"answer":{"seats":[{"seat":"node-backup","scope":"node","holders":[{"module":"restic","node":"ace"},{"module":"borg","node":"shanks"}]}]},"ok":true}`)
|
||||
data := json.RawMessage(`{"answer":{"data":[{"machine":"ace","module":"restic","item":"repository","path":"/storage/restic"},{"machine":"ace","module":"restic","item":"state","path":"/var/lib/restic"},{"machine":"ace","module":"plex","item":"data","path":"/mnt/plex/data"}]},"ok":true}`)
|
||||
holder, paths, err := BackupFrom("ace", seats, data)
|
||||
seats := json.RawMessage(`{"answer":{"seats":[{"seat":"node-backup","scope":"node","holders":[{"module":"restic","node":"node-a"},{"module":"borg","node":"node-d"}]}]},"ok":true}`)
|
||||
data := json.RawMessage(`{"answer":{"data":[{"machine":"node-a","module":"restic","item":"repository","path":"/storage/restic"},{"machine":"node-a","module":"restic","item":"state","path":"/var/lib/restic"},{"machine":"node-a","module":"plex","item":"data","path":"/mnt/plex/data"}]},"ok":true}`)
|
||||
holder, paths, err := BackupFrom("node-a", seats, data)
|
||||
if err != nil || holder != "restic" || strings.Join(paths, " ") != "/storage/restic /var/lib/restic" {
|
||||
t.Errorf("got %q %v %v", holder, paths, err)
|
||||
}
|
||||
if _, _, err := BackupFrom("g14", seats, data); err == nil {
|
||||
if _, _, err := BackupFrom("node-b", seats, data); err == nil {
|
||||
t.Errorf("a machine whose backup seat nobody holds is not said")
|
||||
}
|
||||
if _, _, err := BackupFrom("ace", seats, json.RawMessage(`{"ok":true}`)); err == nil {
|
||||
if _, _, err := BackupFrom("node-a", seats, json.RawMessage(`{"ok":true}`)); err == nil {
|
||||
t.Errorf("an answer with no data list reads as an empty list")
|
||||
}
|
||||
}
|
||||
@@ -99,11 +99,11 @@ func TestBackupFromTheSeatsAndTheData(t *testing.T) {
|
||||
func TestAFailedAskKeepsTheLastBackupPaths(t *testing.T) {
|
||||
f := newFake(t)
|
||||
m := f.machine()
|
||||
m.TakeBackupWith("ace", func(string, any) (json.RawMessage, error) {
|
||||
m.TakeBackupWith("node-a", func(string, any) (json.RawMessage, error) {
|
||||
return nil, errors.New("no responders")
|
||||
})
|
||||
f.backup("restic", "/storage/restic")
|
||||
m.TakeBackupWith("ace", func(string, any) (json.RawMessage, error) {
|
||||
m.TakeBackupWith("node-a", func(string, any) (json.RawMessage, error) {
|
||||
return nil, errors.New("timeout")
|
||||
})
|
||||
var b BackupFromBus
|
||||
|
||||
@@ -64,9 +64,9 @@ func TestAFailedReloadIsTriedAgainWhenNothingElseAsks(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestBackupFromRefusesAnAnswerWithoutADataList(t *testing.T) {
|
||||
seats := json.RawMessage(`{"answer":{"seats":[{"seat":"node-backup","holders":[{"module":"restic","node":"ace"}]}]}}`)
|
||||
seats := json.RawMessage(`{"answer":{"seats":[{"seat":"node-backup","holders":[{"module":"restic","node":"node-a"}]}]}}`)
|
||||
for _, data := range []string{`{"answer":{}}`, `{"answer":{"data":null}}`} {
|
||||
if _, paths, err := BackupFrom("ace", seats, json.RawMessage(data)); err == nil {
|
||||
if _, paths, err := BackupFrom("node-a", seats, json.RawMessage(data)); err == nil {
|
||||
t.Errorf("%s was read as the empty list %v: never \"nothing to fear\"", data, paths)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
//
|
||||
// **Which broker nodes are the mesh's.** Never guessed: a flow may talk to a broker that has nothing
|
||||
// to do with this mesh. The step owns the node it creates itself (id `mesh-mqtt-topic`, "mesh:
|
||||
// mqtt-topic") and the ones an assignment names in settings (`mqtt.brokers`: node ids — how ace's
|
||||
// mqtt-topic") and the ones an assignment names in settings (`mqtt.brokers`: node ids — how node-a's
|
||||
// existing broker node, which every one of its MQTT flows uses, is handed over). With none named and
|
||||
// none made yet, it makes one, so a fresh Node-RED has a broker the flows can pick.
|
||||
//
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
// differs; a node that is not named is never touched; a loopback broker address is refused.
|
||||
//
|
||||
// Node-RED and the broker are fakes answering as the real ones do (admin API v2 of nodered/node-red
|
||||
// 5.0.7, the build ace runs).
|
||||
// 5.0.7, the build node-a runs).
|
||||
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
@@ -13,10 +13,10 @@ import { MESH_BROKER_ID, namedBrokers, reconcileBrokers, type Binding, type Flow
|
||||
import type { Probe } from "../mqtt/probe.ts";
|
||||
|
||||
const MINTED = "mesh-minted-password";
|
||||
const binding = (at = "ace.internal"): Binding => ({ provision: "mqtt-topic", from: "ace", at, as: "mesh_ace_nodered", serves: { scheme: "mqtt", port: 1883 } });
|
||||
const binding = (at = "node-a.internal"): Binding => ({ provision: "mqtt-topic", from: "node-a", at, as: "mesh_node_a_nodered", serves: { scheme: "mqtt", port: 1883 } });
|
||||
|
||||
/** ace's flows, reduced: its one broker node (dead, zurag.be:1884) and a node that uses it. */
|
||||
function aceFlows(): FlowNode[] {
|
||||
/** node-a's flows, reduced: its one broker node (dead, zurag.be:1884) and a node that uses it. */
|
||||
function nodeAFlows(): FlowNode[] {
|
||||
return [
|
||||
{ id: "2b0aece9c5f3b307", type: "mqtt-broker", name: "MQTT Broker", broker: "zurag.be", port: "1884", clientid: "", usetls: false, protocolVersion: "4", keepalive: "60" },
|
||||
{ id: "fe0cae96f1e3ae4d", type: "mqtt in", topic: "stat/sonoff_office_light_switch/RESULT", broker: "2b0aece9c5f3b307", z: "t" },
|
||||
@@ -52,17 +52,17 @@ const marks = (): Marks & { store: Map<string, string> } => {
|
||||
const store = new Map<string, string>();
|
||||
return { store, get: async (k) => store.get(k), set: async (k, v) => void store.set(k, v) };
|
||||
};
|
||||
const takes: Probe = async (_h, _p, user, pass) => ({ connack: user === "mesh_ace_nodered" && pass === MINTED ? 0 : 5, suback: 0 });
|
||||
const takes: Probe = async (_h, _p, user, pass) => ({ connack: user === "mesh_node_a_nodered" && pass === MINTED ? 0 : 5, suback: 0 });
|
||||
|
||||
test("ace: the named broker node is moved to the bound broker and login; the other broker is not touched", async () => {
|
||||
const f = fakeNodeRed(aceFlows(), { "2b0aece9c5f3b307": { user: "luffy", password: "old" }, "other-broker": { user: "x", password: "y" } });
|
||||
test("node-a: the named broker node is moved to the bound broker and login; the other broker is not touched", async () => {
|
||||
const f = fakeNodeRed(nodeAFlows(), { "2b0aece9c5f3b307": { user: "luffy", password: "old" }, "other-broker": { user: "x", password: "y" } });
|
||||
const m = marks();
|
||||
const out = await reconcileBrokers({ nodered: f.nodered, probe: takes, marks: m }, binding(), `${MINTED}\n`, ["2b0aece9c5f3b307"]);
|
||||
assert.deepEqual(out, [{ what: "broker 2b0aece9c5f3b307", result: "written", fields: ["broker", "port", "user", "password"] }]);
|
||||
const node = f.flows.find((n) => n.id === "2b0aece9c5f3b307");
|
||||
assert.deepEqual(node, { ...aceFlows()[0], broker: "ace.internal", port: "1883", usetls: false });
|
||||
assert.deepEqual(f.creds["2b0aece9c5f3b307"], { user: "mesh_ace_nodered", password: MINTED });
|
||||
assert.deepEqual(f.flows.find((n) => n.id === "other-broker"), aceFlows()[2]);
|
||||
assert.deepEqual(node, { ...nodeAFlows()[0], broker: "node-a.internal", port: "1883", usetls: false });
|
||||
assert.deepEqual(f.creds["2b0aece9c5f3b307"], { user: "mesh_node_a_nodered", password: MINTED });
|
||||
assert.deepEqual(f.flows.find((n) => n.id === "other-broker"), nodeAFlows()[2]);
|
||||
assert.deepEqual(f.creds["other-broker"], { user: "x", password: "y" });
|
||||
// Only the changed node carried credentials in the deploy.
|
||||
assert.deepEqual(f.deploys[0].filter((n) => n.credentials).map((n) => n.id), ["2b0aece9c5f3b307"]);
|
||||
@@ -78,19 +78,19 @@ test("fresh: with nothing named, the step makes its own broker node", async () =
|
||||
const out = await reconcileBrokers({ nodered: f.nodered, probe: takes, marks: marks() }, binding(), MINTED, []);
|
||||
assert.deepEqual(out, [{ what: `broker ${MESH_BROKER_ID}`, result: "written", fields: ["node"] }]);
|
||||
assert.equal(f.flows[0].type, "mqtt-broker");
|
||||
assert.equal(f.flows[0].broker, "ace.internal");
|
||||
assert.deepEqual(f.creds[MESH_BROKER_ID], { user: "mesh_ace_nodered", password: MINTED });
|
||||
assert.equal(f.flows[0].broker, "node-a.internal");
|
||||
assert.deepEqual(f.creds[MESH_BROKER_ID], { user: "mesh_node_a_nodered", password: MINTED });
|
||||
});
|
||||
|
||||
test("a login the broker does not take is never written", async () => {
|
||||
const f = fakeNodeRed(aceFlows());
|
||||
const f = fakeNodeRed(nodeAFlows());
|
||||
const out = await reconcileBrokers({ nodered: f.nodered, probe: async () => ({ connack: 5 }), marks: marks() }, binding(), MINTED, ["2b0aece9c5f3b307"]);
|
||||
assert.equal(out[0].result, "refused");
|
||||
assert.equal(f.deploys.length, 0);
|
||||
});
|
||||
|
||||
test("a named node that is not there, or a loopback broker, is refused", async () => {
|
||||
const f = fakeNodeRed(aceFlows());
|
||||
const f = fakeNodeRed(nodeAFlows());
|
||||
const out = await reconcileBrokers({ nodered: f.nodered, probe: takes, marks: marks() }, binding(), MINTED, ["gone"]);
|
||||
assert.match((out[0] as { problem: string }).problem, /no such node/);
|
||||
const lo = await reconcileBrokers({ nodered: f.nodered, probe: takes, marks: marks() }, binding("127.0.0.1"), MINTED, []);
|
||||
@@ -99,7 +99,7 @@ test("a named node that is not there, or a loopback broker, is refused", async (
|
||||
});
|
||||
|
||||
test("a deploy that raced another is read again once", async () => {
|
||||
const f = fakeNodeRed(aceFlows());
|
||||
const f = fakeNodeRed(nodeAFlows());
|
||||
let first = true;
|
||||
const racing: NodeRed = {
|
||||
...f.nodered,
|
||||
|
||||
@@ -217,7 +217,7 @@ func TestAQuestionIsShownWithItsTicketsAndRingsAndAWarningIsSilent(t *testing.T)
|
||||
if f.of("sendMessage")[1].Body["disable_notification"] != true {
|
||||
t.Error("a clearing rang (D4)")
|
||||
}
|
||||
subject, body = work(asks.Send, asks.Message{Handle: "m3", Title: "Urgent: ace silent", Urgent: true, To: "42"})
|
||||
subject, body = work(asks.Send, asks.Message{Handle: "m3", Title: "Urgent: node-a silent", Urgent: true, To: "42"})
|
||||
_ = c.Take(subject, body)
|
||||
if f.of("sendMessage")[2].Body["disable_notification"] != false {
|
||||
t.Error("an urgent message was silent (D4)")
|
||||
@@ -243,7 +243,7 @@ func TestALongMessageIsCutAndTheCutSaid(t *testing.T) {
|
||||
// nor injects. The tap on it is still compared by the plain words, which is what Telegram gives back.
|
||||
func TestAMessageIsRenderedInHTMLWithItsMarkupEscaped(t *testing.T) {
|
||||
c, f, s, _ := newChannel(t, goodToken)
|
||||
subject, body := work(asks.Show, asks.Message{Handle: "q1", Title: "Set mounts on <shanks> & co?",
|
||||
subject, body := work(asks.Show, asks.Message{Handle: "q1", Title: "Set mounts on <node-d> & co?",
|
||||
Body: "Changes two settings.\n+ shares: media=/storage/media\n~ sources: a=b (was: c)",
|
||||
Note: "Asked by mesh-controller at 15:11.\nFingerprint 1234 5678 9abc.",
|
||||
To: "42", Buttons: []asks.Button{{Label: "Approve", Ticket: "tkt"}}})
|
||||
@@ -251,7 +251,7 @@ func TestAMessageIsRenderedInHTMLWithItsMarkupEscaped(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sent := f.of("sendMessage")
|
||||
want := "<b>Set mounts on <shanks> & co?</b>\nChanges two settings.\n<code>+ shares: media=/storage/media</code>\n" +
|
||||
want := "<b>Set mounts on <node-d> & co?</b>\nChanges two settings.\n<code>+ shares: media=/storage/media</code>\n" +
|
||||
"<code>~ sources: a=b (was: c)</code>\n\n<blockquote expandable>Asked by mesh-controller at 15:11.\nFingerprint 1234 5678 9abc.</blockquote>"
|
||||
if len(sent) != 1 || sent[0].Body["text"] != want || sent[0].Body["parse_mode"] != "HTML" {
|
||||
t.Fatalf("sent %v", sent)
|
||||
@@ -262,7 +262,7 @@ func TestAMessageIsRenderedInHTMLWithItsMarkupEscaped(t *testing.T) {
|
||||
// Telegram gives the words back plain, the space around the blockquote as it pleases: the tap on them is taken.
|
||||
c.Handle(Update{UpdateID: 1, Callback: &Callback{ID: "cb1", Data: "tkt", From: User{ID: 42, FirstName: "Jo"},
|
||||
Message: &Msg{MessageID: shown, Chat: Chat{ID: 42, Type: "private"},
|
||||
Text: "Set mounts on <shanks> & co?\nChanges two settings.\n+ shares: media=/storage/media\n~ sources: a=b (was: c)\n" +
|
||||
Text: "Set mounts on <node-d> & co?\nChanges two settings.\n+ shares: media=/storage/media\n~ sources: a=b (was: c)\n" +
|
||||
"Asked by mesh-controller at 15:11.\nFingerprint 1234 5678 9abc."}}})
|
||||
if got := s.of(asks.IntakeSubject(asks.Chosen, Kind)); len(got) != 1 {
|
||||
t.Fatalf("the tap on the rendered message was not taken: %s", got)
|
||||
@@ -270,20 +270,20 @@ func TestAMessageIsRenderedInHTMLWithItsMarkupEscaped(t *testing.T) {
|
||||
// Words changed by another holder of the token are still told apart.
|
||||
c.Handle(Update{UpdateID: 2, Callback: &Callback{ID: "cb2", Data: "tkt", From: User{ID: 42, FirstName: "Jo"},
|
||||
Message: &Msg{MessageID: shown, Chat: Chat{ID: 42, Type: "private"},
|
||||
Text: "Set mounts on <shanks> & co?\nChanges three settings.\n+ shares: media=/storage/media\n~ sources: a=b (was: c)\n" +
|
||||
Text: "Set mounts on <node-d> & co?\nChanges three settings.\n+ shares: media=/storage/media\n~ sources: a=b (was: c)\n" +
|
||||
"Asked by mesh-controller at 15:11.\nFingerprint 1234 5678 9abc."}}})
|
||||
if got := s.of(asks.IntakeSubject(asks.Chosen, Kind)); len(got) != 1 {
|
||||
t.Fatalf("a tap on changed words was taken: %s", got)
|
||||
}
|
||||
// The edit says what was chosen, one line with the headline, and is in the same mode.
|
||||
subject, body = work(asks.Edit, asks.Message{Handle: "q1", Title: "You chose Approve at 15:06 on Telegram · Set mounts on <shanks> & co?",
|
||||
subject, body = work(asks.Edit, asks.Message{Handle: "q1", Title: "You chose Approve at 15:06 on Telegram · Set mounts on <node-d> & co?",
|
||||
Note: "Told to mesh-controller, which acts on it itself.", To: "42"})
|
||||
if err := c.Take(subject, body); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ed := f.of("editMessageText")
|
||||
if len(ed) != 1 || ed[0].Body["parse_mode"] != "HTML" ||
|
||||
ed[0].Body["text"] != "<b>You chose Approve at 15:06 on Telegram · Set mounts on <shanks> & co?</b>\n\n<blockquote expandable>Told to mesh-controller, which acts on it itself.</blockquote>" {
|
||||
ed[0].Body["text"] != "<b>You chose Approve at 15:06 on Telegram · Set mounts on <node-d> & co?</b>\n\n<blockquote expandable>Told to mesh-controller, which acts on it itself.</blockquote>" {
|
||||
t.Errorf("edited %v", ed)
|
||||
}
|
||||
// A plain answer in the chat is escaped too.
|
||||
@@ -657,7 +657,7 @@ func TestAToastIsCutAtTelegramsBound(t *testing.T) {
|
||||
// loses its buttons. A new handle is a new message, and nothing is deleted.
|
||||
func TestAMessageSaidAgainUnderItsHandleReplacesTheEarlierOne(t *testing.T) {
|
||||
c, f, _, st := newChannel(t, goodToken)
|
||||
subject, body := work(asks.Show, asks.Message{Handle: "m1", Title: "Urgent: ace silent", Urgent: true, To: "42",
|
||||
subject, body := work(asks.Show, asks.Message{Handle: "m1", Title: "Urgent: node-a silent", Urgent: true, To: "42",
|
||||
Buttons: []asks.Button{{Label: "Silence for a week", Ticket: "0123456789abcdef0123456789abcdef"}}})
|
||||
if err := c.Take(subject, body); err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -668,7 +668,7 @@ func TestAMessageSaidAgainUnderItsHandleReplacesTheEarlierOne(t *testing.T) {
|
||||
if len(f.of("deleteMessage")) != 0 {
|
||||
t.Fatal("a new handle deleted a message")
|
||||
}
|
||||
subject, body = work(asks.Show, asks.Message{Handle: "m1", Title: "Still open: ace silent", Urgent: true, To: "42",
|
||||
subject, body = work(asks.Show, asks.Message{Handle: "m1", Title: "Still open: node-a silent", Urgent: true, To: "42",
|
||||
Buttons: []asks.Button{{Label: "Silence for a week", Ticket: "fedcba9876543210fedcba9876543210"}}})
|
||||
if err := c.Take(subject, body); err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -686,7 +686,7 @@ func TestAMessageSaidAgainUnderItsHandleReplacesTheEarlierOne(t *testing.T) {
|
||||
return 400, `{"ok":false,"error_code":400,"description":"Bad Request: message can't be deleted"}`
|
||||
}
|
||||
second := st.placed["m1"].ID
|
||||
subject, body = work(asks.Send, asks.Message{Handle: "m1", Title: "Now urgent: ace silent", Urgent: true, To: "42"})
|
||||
subject, body = work(asks.Send, asks.Message{Handle: "m1", Title: "Now urgent: node-a silent", Urgent: true, To: "42"})
|
||||
_ = c.Take(subject, body)
|
||||
ed := f.of("editMessageText")
|
||||
if len(ed) != 1 || ed[0].Body["message_id"] != float64(second) || !strings.Contains(ed[0].Body["text"].(string), "Replaced") {
|
||||
@@ -705,7 +705,7 @@ func TestAReplacementThatCannotBeSentLeavesTheEarlierMessage(t *testing.T) {
|
||||
f.answer["sendMessage"] = func(map[string]any) (int, string) {
|
||||
return 400, `{"ok":false,"error_code":400,"description":"Bad Request: chat not found"}`
|
||||
}
|
||||
subject, body := work(asks.Send, asks.Message{Handle: "m1", Title: "Still open: ace silent", Urgent: true, To: "42"})
|
||||
subject, body := work(asks.Send, asks.Message{Handle: "m1", Title: "Still open: node-a silent", Urgent: true, To: "42"})
|
||||
_ = c.Take(subject, body)
|
||||
if len(f.of("deleteMessage")) != 0 || st.placed["m1"].ID != 7 {
|
||||
t.Fatalf("the earlier message was taken away though its replacement was not sent: %+v", f.of("deleteMessage"))
|
||||
|
||||
@@ -20,7 +20,7 @@ var adr0343 = asks.Message{Handle: "h-343", To: "42",
|
||||
"• A deleted file is marked, and a newer change elsewhere outlives the deletion",
|
||||
Note: "Asked by mesh-knowledge at 16:05. Unanswered by tomorrow 15:55: the record stays proposed; nothing of it is in force.\n" +
|
||||
"Approve: the record is accepted and its clauses are in force. Decline: the record stays proposed; nothing of it is in force.\n" +
|
||||
"ADR 0343, proposed by fix-367@g14; the knowledge base's own review approved it. Fingerprint a59f 361b f60e cb5e d237 a837.\n" +
|
||||
"ADR 0343, proposed by fix-367@node-b; the knowledge base's own review approved it. Fingerprint a59f 361b f60e cb5e d237 a837.\n" +
|
||||
"Read its 13 clauses with the knowledge base's get, number 343.",
|
||||
Buttons: []asks.Button{{Label: "Approve", Ticket: "t-approve"}, {Label: "Decline", Ticket: "t-decline"}}}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user