package buildstep import ( "context" "encoding/json" "errors" "fmt" "sort" "strings" "sync" "testing" "time" "git.novox.be/novox/mesh-sdk/go/delivery" ) // credentialField is the node runtime's rule for a state value (mesh-tools node-tools/internal/bus/state.go, novox/hq // ADR 0201), copied whole: the first field anywhere in the value whose name, lower-cased and without - _ and spaces, ends // in a credential word, or "". A step's end is refused when it answers a field. func credentialField(value json.RawMessage) string { endings := []string{"password", "passwd", "secret", "token", "credential", "credentials", "authorization", "apikey", "privatekey", "accesskey", "cookie"} 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: keys := make([]string, 0, len(t)) for k := range t { keys = append(keys, k) } sort.Strings(keys) for _, k := range keys { norm := strings.NewReplacer("-", "", "_", "", " ", "").Replace(strings.ToLower(k)) for _, end := range endings { if strings.HasSuffix(norm, end) { return k } } if found := walk(t[k]); found != "" { return found } } case []any: for _, x := range t { if found := walk(x); found != "" { return found } } } return "" } return walk(v) } // runtimeState is a steps state that refuses a value as the node runtime does, and keeps every value it took. type runtimeState struct { mu sync.Mutex took map[string][]delivery.Step refused int } func newRuntimeState() *runtimeState { return &runtimeState{took: map[string][]delivery.Step{}} } func (s *runtimeState) put(key string, value any) (uint64, error) { raw, _ := value.(json.RawMessage) if f := credentialField(raw); f != "" { s.mu.Lock() s.refused++ s.mu.Unlock() return 0, fmt.Errorf("steps.%s carries a field %q, which names a credential", key, f) } st, err := delivery.DecodeStep(raw) if err != nil { return 0, err } s.mu.Lock() defer s.mu.Unlock() s.took[key] = append(s.took[key], st) return uint64(len(s.took[key])), nil } func (s *runtimeState) last(key string) (delivery.Step, bool) { s.mu.Lock() defer s.mu.Unlock() t := s.took[key] if len(t) == 0 { return delivery.Step{}, false } return t[len(t)-1], true } func fastEnds(t *testing.T) { attempts, retry := EndAttempts, EndRetry EndAttempts, EndRetry = 2, time.Millisecond t.Cleanup(func() { EndAttempts, EndRetry = attempts, retry }) } // 1930/AC1: given a check of a repository with the package .../internal/token, when its ask's answer is stored as the // step's end, then no field is keyed by a package path: the runtime's credential rule takes the value, and the package // and its digest read back from the stored end. func TestADigestOfAPackageNamedTokenIsStoredAsAList(t *testing.T) { fastEnds(t) r := &recorder{ fetch: map[string]any{"workspace": "/w"}, digests: map[string]string{"x/internal/token": "d-token", "x/pkg/a": "d-a"}, tests: map[string]bool{"x/internal/token": true, "x/pkg/a": true}, } out, err := RunAsk(context.Background(), r.steps(), ask(delivery.CheckJob{Levels: []string{delivery.LevelUnit}, Fetch: map[string]any{"repository": "novox/x", "commit": runCommit}})) if err != nil { t.Fatal(err) } old, _ := json.Marshal(map[string]any{"levels": []any{map[string]any{"level": "unit", "digest": map[string]any{"packages": r.digests}}}}) if credentialField(old) != "x/internal/token" { t.Fatalf("the rule does not refuse the form before issue 1930 (%s): the test proves nothing", old) } st := newRuntimeState() if err := finish(st.put, "shanks.a1", delivery.Step{ID: "a1", Tool: "check ask", Node: "shanks", State: delivery.StepRunning, Started: time.Now()}, out, nil, t.Logf); err != nil { t.Fatal(err) } s, ok := st.last("shanks.a1") if !ok || s.State != delivery.StepPassed || st.refused != 0 { t.Fatalf("the end %+v (written %v, refused %d), want it passed and taken at once", s, ok, st.refused) } var a delivery.CheckAnswer if err := json.Unmarshal(s.Result, &a); err != nil { t.Fatal(err) } if len(a.Levels) != 1 || a.Levels[0].Digest == nil { t.Fatalf("levels %+v, want the unit level with its digest", a.Levels) } want := []delivery.PackageDigest{{Package: "x/internal/token", Digest: "d-token"}, {Package: "x/pkg/a", Digest: "d-a"}} if got := a.Levels[0].Digest.Packages; fmt.Sprint(got) != fmt.Sprint(want) { t.Errorf("packages %v, want %v", got, want) } if !strings.Contains(string(s.Result), `"package":"x/internal/token"`) { t.Errorf("the stored end does not name the package as a value: %s", s.Result) } } // 1930/AC2: given an end the state refuses every time, when finish writes it, then it logs the refusal and writes a small // failed end without the result that names the write's error; and when no end can be written at all it answers an error. func TestARefusedEndIsSaidAndEndsTheStepFailed(t *testing.T) { fastEnds(t) big := errors.New("the value carries a field \"x/internal/token\", which names a credential") var refused int var stored []delivery.Step put := func(key string, value any) (uint64, error) { st, _ := delivery.DecodeStep(value.(json.RawMessage)) if len(st.Result) > 0 { refused++ return 0, big } stored = append(stored, st) return 1, nil } var logged []string logf := func(format string, args ...any) { logged = append(logged, fmt.Sprintf(format, args...)) } err := finish(put, "shanks.a2", delivery.Step{ID: "a2", Tool: "check ask", Node: "shanks", State: delivery.StepRunning, Started: time.Now()}, map[string]any{"levels": 1}, nil, logf) if err != nil { t.Fatalf("finish answered %v, want the small end written", err) } if refused != EndAttempts || len(stored) != 1 { t.Fatalf("refused %d, stored %d, want %d refusals and one small end", refused, len(stored), EndAttempts) } s := stored[0] if s.State != delivery.StepFailed || len(s.Result) != 0 || !strings.Contains(s.Reason, "could not be written") || !strings.Contains(s.Reason, big.Error()) || !strings.Contains(s.Reason, "passed") { t.Errorf("the small end %+v, want failed, no result, naming the write's error and that it passed", s) } if len(logged) == 0 || !strings.Contains(logged[0], big.Error()) { t.Errorf("logged %q, want the refusal said", logged) } none := func(string, any) (uint64, error) { return 0, errors.New("the bus is gone") } if err := finish(none, "shanks.a3", delivery.Step{ID: "a3", Tool: "check ask", Node: "shanks", Started: time.Now()}, nil, nil, logf); err == nil || !strings.Contains(err.Error(), "the bus is gone") { t.Errorf("finish with no write possible answered %v, want the error", err) } } // 1930/AC2: given an ask whose end the state refuses, when it runs, then the ask is acknowledged only once its small // failed end is written; when no end can be written at all, it is given back, never acknowledged as done. func TestAnAskWhoseEndCannotBeWrittenIsNotAcked(t *testing.T) { fastEnds(t) for _, tc := range []struct { name string smallOK bool wantAcked bool }{{"the small end written", true, true}, {"no end written", false, false}} { t.Run(tc.name, func(t *testing.T) { src := NewFakeAsks() a := testAsk(delivery.AskCheck) _ = src.PublishAsk(a) var mu sync.Mutex var ends []delivery.Step put := func(key string, value any) (uint64, error) { st, err := delivery.DecodeStep(value.(json.RawMessage)) if err != nil { return 0, err } if st.State != delivery.StepRunning && (len(st.Result) > 0 || !tc.smallOK) { return 0, errors.New("refused") } mu.Lock() ends = append(ends, st) mu.Unlock() return 1, nil } p := &Puller{Source: src, Language: delivery.AskGo, Node: "shanks", Put: put, Capacity: func() int { return 1 }, PullWait: 10 * time.Millisecond, Logf: t.Logf, Run: func(ctx context.Context, a delivery.StepAsk) (any, error) { return map[string]any{"ran": true}, nil }} ctx, cancel := context.WithCancel(context.Background()) defer cancel() go p.Pull(ctx) waitFor(t, "the ask's fate", func() bool { ac, tm, nk := src.Fates(); return len(ac)+len(tm)+len(nk) > 0 }) cancel() acked, termed, naked := src.Fates() if tc.wantAcked && (len(acked) != 1 || len(naked) != 0) || !tc.wantAcked && (len(acked) != 0 || len(naked) == 0) || len(termed) != 0 { t.Fatalf("acked %v naked %v termed %v", acked, naked, termed) } mu.Lock() defer mu.Unlock() if tc.smallOK { last := ends[len(ends)-1] if last.State != delivery.StepFailed || !strings.Contains(last.Reason, "refused") { t.Errorf("the end %+v, want the small failed end", last) } } }) } }