From 41225bf4599fe1ea0b0faec0408467deb344430f Mon Sep 17 00:00:00 2001 From: mesh-tools Date: Tue, 20 Oct 2026 18:52:27 +0000 Subject: [PATCH] Test the forge bridge's file reads against the stand-in forge and the bus (issue 1448, design 47 R4c-2) --- .../integration_test.go | 83 +++++++++++++++++-- .../mesh-delivery-forge-bridge/main_test.go | 69 +++++++++++++-- 2 files changed, 138 insertions(+), 14 deletions(-) diff --git a/modules/mesh-delivery-forge-bridge/cmd/mesh-delivery-forge-bridge/integration_test.go b/modules/mesh-delivery-forge-bridge/cmd/mesh-delivery-forge-bridge/integration_test.go index aa86ce5..3281f5c 100644 --- a/modules/mesh-delivery-forge-bridge/cmd/mesh-delivery-forge-bridge/integration_test.go +++ b/modules/mesh-delivery-forge-bridge/cmd/mesh-delivery-forge-bridge/integration_test.go @@ -4,6 +4,8 @@ package main import ( "context" + "crypto/sha256" + "encoding/hex" "encoding/json" "fmt" "io" @@ -29,8 +31,8 @@ import ( // bucket performed is the bridge's own (its state), and the five forge tools of the gate's acts are invoked (issue 1341, 1342). // // The lists are what mesh-controller's broker.PermissionsFor answers (internal/broker/nats.go:577) for a module principal -// with the manifest of module.json: consumes the three gitea.pull events, invokes gitea.gitea_open_pulls and -// gitea.gitea_commit_trees, state heads, and the stream forge; `**` is written `>` on the wire (modstreams.go +// with the manifest of module.json: consumes the three gitea.pull events, invokes gitea.gitea_open_pulls, +// gitea.gitea_commit_trees and gitea.gitea_get_file, state heads, and the stream forge; `**` is written `>` on the wire (modstreams.go // WirePattern). The consumer's durable is _ on EVENTS (nats.go consumerDurable). The controller's own // user keeps all rights, as it has. const busConf = ` @@ -59,6 +61,10 @@ accounts { "mesh.mod.gitea.call.gitea_open_pulls.n1~mesh-delivery-forge-bridge" "mesh.mod.gitea.tool.gitea_commit_trees" "mesh.mod.gitea.tool.gitea_commit_trees.*" + "mesh.mod.gitea.call.gitea_get_file.*.n1~mesh-delivery-forge-bridge" + "mesh.mod.gitea.call.gitea_get_file.n1~mesh-delivery-forge-bridge" + "mesh.mod.gitea.tool.gitea_get_file" + "mesh.mod.gitea.tool.gitea_get_file.*" "mesh.mod.gitea.tool.gitea_open_pulls" "mesh.mod.gitea.tool.gitea_open_pulls.*" "$JS.API.CONSUMER.CREATE.KV_mesh-delivery-forge-bridge_performed.>" @@ -160,6 +166,21 @@ type bed struct { ctx context.Context done <-chan error reply func(string) + // files sets what the stand-in forge answers to gitea_get_file from then on; it is asked with the tool's arguments. + files func(func(owner, repo, path, ref string) (string, bool)) +} + +// getFileReply is the reply envelope for gitea_get_file as mesh-catalog modules/gitea tools/index.ts and client.ts getFile +// answer it ({file: {path, ref, sha, size, content}}), and for a path the forge does not have the error client.ts request +// writes: `Gitea API : 404 `. +func getFileReply(path, ref, content string, found bool) []byte { + if !found { + b, _ := json.Marshal(map[string]any{"error": "Gitea API /repos/novox/mesh-delivery/contents/" + path + "?ref=" + ref + `: 404 {"message":"GetContentsOrList","url":"https://forge.example/api/swagger"}`}) + return b + } + b, _ := json.Marshal(map[string]any{"result": map[string]any{"file": map[string]any{"path": path, "ref": ref, "sha": "0123456789abcdef0123456789abcdef01234567", + "size": len(content), "content": content}}, "node": "n0"}) + return b } // startBed is startBridge with a step between the streams and the consumer: prepare writes what the streams hold before @@ -222,6 +243,26 @@ func startBed(t *testing.T, openReply string, prepare func(ctx context.Context, t.Fatal(err) } + // A stand-in answers the forge's gitea_get_file: by default a module.json of {} at every path and no other file. + var fmu sync.Mutex + filesOf := func(_, _, path, _ string) (string, bool) { return "{}", strings.HasSuffix(path, "module.json") } + getFile, err := ctl.Subscribe("mesh.mod.gitea.tool.gitea_get_file", func(m *nats.Msg) { + var a struct{ Owner, Repo, Path, Ref string } + _ = json.Unmarshal(m.Data, &a) + fmu.Lock() + f := filesOf + fmu.Unlock() + content, found := f(a.Owner, a.Repo, a.Path, a.Ref) + _ = m.Respond(getFileReply(a.Path, a.Ref, content, found)) + }) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = getFile.Unsubscribe() }) + if err := ctl.Flush(); err != nil { + t.Fatal(err) + } + broker := filepath.Join(t.TempDir(), "broker") cred, _ := json.Marshal(credential{URL: srv.ClientURL(), Node: "n1", Module: "mesh-delivery-forge-bridge", User: "n1.mesh-delivery-forge-bridge", Password: "pw"}) if err := os.WriteFile(broker, cred, 0o600); err != nil { @@ -232,7 +273,8 @@ func startBed(t *testing.T, openReply string, prepare func(ctx context.Context, done := make(chan error, 1) go func() { done <- run(runCtx, func(k string) string { return env[k] }, io.Discard) }() t.Cleanup(stop) - return &bed{srv: srv, ctl: ctl, js: js, ctx: ctx, done: done, reply: func(s string) { mu.Lock(); current = s; mu.Unlock() }} + return &bed{srv: srv, ctl: ctl, js: js, ctx: ctx, done: done, reply: func(s string) { mu.Lock(); current = s; mu.Unlock() }, + files: func(f func(owner, repo, path, ref string) (string, bool)) { fmu.Lock(); filesOf = f; fmu.Unlock() }} } // TestBridgeWritesPullMessages starts the program the way the node does (its entry, run, with MESH_BROKER_FILE). @@ -270,7 +312,7 @@ func TestBridgeWritesPullMessages(t *testing.T) { // 1235/AC2 t.Fatal(err) } want := delivery.Forge{V: delivery.Version, Kind: "pull", Repository: "novox/mesh-delivery", Branch: "feat/x", Number: 12, Head: head, - Action: "updated", Dirs: []string{"modules/a"}, DirsKnown: true} + Action: "updated", Dirs: []string{"modules/a"}, DirsKnown: true, Manifests: map[string]string{"modules/a": "{}"}} if !delivery.SameForge(f, want) { t.Errorf("message = %+v, want %+v", f, want) } @@ -295,8 +337,31 @@ const merge = "89abcdef0123456789abcdef0123456789abcdef" // TestBridgeWritesMergedMessage has a stand-in answer the forge's gitea_commit_trees on its tool subject // (mesh-control internal/broker/nats.go invokedSubjects: mesh.mod..tool.), in the reply envelope of // mesh-sdk go/process/call.go ({result, error, node}). -func TestBridgeWritesMergedMessage(t *testing.T) { // 1236/AC4 - ctl, js, ctx, _ := startBridge(t, `{"result":{"pulls":[]}}`) +func TestBridgeWritesMergedMessage(t *testing.T) { // 1236/AC4, 1448/AC5 + bed := startBed(t, `{"result":{"pulls":[]}}`, nil) + ctl, js, ctx := bed.ctl, bed.js, bed.ctx + // The stand-in forge's files are two real files of this repository (this module's module.json and go.sum, read from the + // working tree), in gitea_get_file's real answer shape: the root has a manifest only, modules/a a manifest and go.sum. + manifest, err := os.ReadFile("../../module.json") + if err != nil { + t.Fatal(err) + } + sum, err := os.ReadFile("../../go.sum") + if err != nil { + t.Fatal(err) + } + bed.files(func(_, _, path, ref string) (string, bool) { + if ref != merge { + t.Errorf("the forge was asked for %s at %s, want the merge commit %s", path, ref, merge) + } + switch path { + case "module.json", "modules/a/module.json": + return string(manifest), true + case "modules/a/go.sum": + return string(sum), true + } + return "", false + }) var asked chan []byte = make(chan []byte, 4) sub, err := ctl.Subscribe("mesh.mod.gitea.tool.gitea_commit_trees", func(m *nats.Msg) { asked <- m.Data @@ -342,7 +407,9 @@ func TestBridgeWritesMergedMessage(t *testing.T) { // 1236/AC4 t.Fatal(err) } want := delivery.Forge{V: delivery.Version, Kind: "merged", Repository: "novox/mesh-delivery", Branch: "feat/x", Number: 12, Head: head, - Merge: merge, Root: "r0", Trees: map[string]string{"": "t0", "modules/a": "t1"}} + Merge: merge, Root: "r0", Trees: map[string]string{"": "t0", "modules/a": "t1"}, + Manifests: map[string]string{"": string(manifest), "modules/a": string(manifest)}, + Locks: map[string]map[string]string{"modules/a": {"go.sum": hex.EncodeToString(sha256Sum(sum))}}} if !delivery.SameForge(f, want) { t.Errorf("message = %+v, want %+v", f, want) } @@ -637,3 +704,5 @@ func forgeMerged(t *testing.T, b *bed) []string { } return out } + +func sha256Sum(b []byte) []byte { s := sha256.Sum256(b); return s[:] } diff --git a/modules/mesh-delivery-forge-bridge/cmd/mesh-delivery-forge-bridge/main_test.go b/modules/mesh-delivery-forge-bridge/cmd/mesh-delivery-forge-bridge/main_test.go index 123bd49..b74d8da 100644 --- a/modules/mesh-delivery-forge-bridge/cmd/mesh-delivery-forge-bridge/main_test.go +++ b/modules/mesh-delivery-forge-bridge/cmd/mesh-delivery-forge-bridge/main_test.go @@ -6,6 +6,7 @@ import ( "encoding/json" "errors" "log" + "slices" "strings" "testing" "time" @@ -14,6 +15,8 @@ import ( "github.com/nats-io/nats.go/jetstream" "git.novox.be/novox/mesh-sdk/go/delivery" + + "mesh-delivery-forge-bridge/internal/bridge" ) const head = "0123456789abcdef0123456789abcdef01234567" @@ -64,7 +67,7 @@ func body(m map[string]any) []byte { func TestReadableEventIsWrittenOnForgeAndAcked(t *testing.T) { // 1235/AC2 (unit part; the bus is the integration test) var logs bytes.Buffer m, pub := &fakeMsg{subject: "mesh.mod.gitea.event.pull.updated", data: body(nil)}, &fakePub{} - handle(context.Background(), m, pub, nil, log.New(&logs, "", 0)) + handle(context.Background(), m, pub, nil, someFiles, log.New(&logs, "", 0)) if len(pub.subjects) != 1 || pub.subjects[0] != "mesh.stream.mesh-delivery-forge-bridge.event.forge.novox.mesh-delivery.pull" { t.Fatalf("published on %v", pub.subjects) } @@ -80,7 +83,7 @@ func TestReadableEventIsWrittenOnForgeAndAcked(t *testing.T) { // 1235/AC2 (unit func TestUnreadableEventIsTerminatedAndLogged(t *testing.T) { // 1235/AC5 var logs bytes.Buffer m, pub := &fakeMsg{subject: "mesh.mod.gitea.event.pull.updated", data: body(map[string]any{"head_sha": "short"})}, &fakePub{} - handle(context.Background(), m, pub, nil, log.New(&logs, "", 0)) + handle(context.Background(), m, pub, nil, someFiles, log.New(&logs, "", 0)) if len(pub.subjects) != 0 { t.Errorf("a message was written: %v", pub.subjects) } @@ -95,7 +98,7 @@ func TestUnreadableEventIsTerminatedAndLogged(t *testing.T) { // 1235/AC5 func TestUnwrittenMessageIsNotAcked(t *testing.T) { // 1235 (a failed write is redelivered by the server, not retried here) var logs bytes.Buffer m, pub := &fakeMsg{subject: "mesh.mod.gitea.event.pull.updated", data: body(nil)}, &fakePub{err: errors.New("no stream")} - handle(context.Background(), m, pub, nil, log.New(&logs, "", 0)) + handle(context.Background(), m, pub, nil, someFiles, log.New(&logs, "", 0)) if m.acked != 0 || m.termd != 0 { t.Errorf("acked %d terminated %d, want neither", m.acked, m.termd) } @@ -117,7 +120,7 @@ func TestTreesRefusedLeavesTheEventForTenSecondsAndLogs(t *testing.T) { // 1236/ call := func(context.Context, string, any) (json.RawMessage, error) { return nil, errors.New("the tree listing of novox/mesh-delivery was cut") } - handle(context.Background(), m, pub, treesVia(context.Background(), call), log.New(&logs, "", 0)) + handle(context.Background(), m, pub, treesVia(context.Background(), call), filesVia(context.Background(), call), log.New(&logs, "", 0)) if len(pub.subjects) != 0 { t.Errorf("a message was written: %v", pub.subjects) } @@ -138,11 +141,14 @@ func TestMergeCallsTheToolOnceAndWritesTheMergedMessage(t *testing.T) { // 1236/ var args []any call := func(_ context.Context, key string, body any) (json.RawMessage, error) { keys, args = append(keys, key), append(args, body) + if key == "gitea.gitea_get_file" { // the manifests, 1448; the trees are what this test is about + return json.RawMessage(`{"file":{"path":"p","sha":"0123","size":2,"content":"{}"}}`), nil + } return json.RawMessage(`{"root":"r0","trees":{"":"t0","modules/a":"t1"}}`), nil } - handle(context.Background(), m, pub, treesVia(context.Background(), call), log.New(&logs, "", 0)) - if len(keys) != 1 || keys[0] != "gitea.gitea_commit_trees" { - t.Fatalf("calls %v", keys) + handle(context.Background(), m, pub, treesVia(context.Background(), call), filesVia(context.Background(), call), log.New(&logs, "", 0)) + if len(keys) == 0 || keys[0] != "gitea.gitea_commit_trees" || slices.Index(keys[1:], "gitea.gitea_commit_trees") >= 0 { + t.Fatalf("calls %v, want the trees once and first", keys) } if got, _ := json.Marshal(args[0]); string(got) != `{"owner":"novox","repo":"mesh-delivery","sha":"89abcdef0123456789abcdef0123456789abcdef"}` { t.Errorf("arguments %s", got) @@ -171,3 +177,52 @@ func TestAnAnswerWithoutARootIsNotUsed(t *testing.T) { // 1236 (a shape the tool t.Error("an answer that is not an object was taken") } } + +// The answer of gitea_get_file as mesh-catalog modules/gitea tools/index.ts and client.ts getFile write it, and the +// error of a path the forge does not have as client.ts request writes it (`Gitea API ${path}: ${status} ${text}`). +const getFileAnswer = `{"file":{"path":"modules/a/go.sum","ref":"89abcdef0123456789abcdef0123456789abcdef","sha":"0123","size":4,"content":"x y\n"}}` +const getFile404 = "Gitea API /repos/novox/mesh-delivery/contents/modules/a/go.sum?ref=89abcdef: 404 {\"message\":\"GetContentsOrList\",\"url\":\"https://forge.example/api/swagger\"}" + +func TestFilesViaAsksByOwnerRepoPathRefAndTakesTheContent(t *testing.T) { // 1448/AC4 + var keys []string + var args []any + call := func(_ context.Context, key string, body any) (json.RawMessage, error) { + keys, args = append(keys, key), append(args, body) + return json.RawMessage(getFileAnswer), nil + } + got, err := filesVia(context.Background(), call)("novox", "mesh-delivery", "modules/a/go.sum", "89abcdef0123456789abcdef0123456789abcdef") + if err != nil || string(got) != "x y\n" { + t.Fatalf("got %q err %v", got, err) + } + if len(keys) != 1 || keys[0] != "gitea.gitea_get_file" { + t.Fatalf("calls %v", keys) + } + if b, _ := json.Marshal(args[0]); string(b) != `{"owner":"novox","path":"modules/a/go.sum","ref":"89abcdef0123456789abcdef0123456789abcdef","repo":"mesh-delivery"}` { + t.Errorf("arguments %s", b) + } +} + +func TestFilesViaTellsNotFoundFromOtherErrorsAndOtherShapes(t *testing.T) { // 1448/AC3 + call := func(err error, raw string) caller { + return func(context.Context, string, any) (json.RawMessage, error) { return json.RawMessage(raw), err } + } + if _, err := filesVia(context.Background(), call(errors.New(getFile404), ""))("o", "r", "p", "s"); !errors.Is(err, bridge.ErrNotFound) { + t.Errorf("a 404 is %v, want ErrNotFound", err) + } + if _, err := filesVia(context.Background(), call(errors.New("Gitea API /repos/o/r/contents/p: 502 bad gateway"), ""))("o", "r", "p", "s"); err == nil || errors.Is(err, bridge.ErrNotFound) { + t.Errorf("a 502 is %v, want an error that is not ErrNotFound", err) + } + for _, raw := range []string{`{"file":null}`, `{}`, `[1]`, `{"file":{"path":"p"}}`} { + if _, err := filesVia(context.Background(), call(nil, raw))("o", "r", "p", "s"); err == nil || errors.Is(err, bridge.ErrNotFound) { + t.Errorf("the answer %s gave %v, want an error", raw, err) + } + } +} + +// someFiles is a forge that has a module.json at every path and no other file, for the tests that are not about the files. +func someFiles(_, _, path, _ string) ([]byte, error) { + if strings.HasSuffix(path, "module.json") { + return []byte("{}"), nil + } + return nil, bridge.ErrNotFound +}