package main import ( "context" "os" "path/filepath" "slices" "strings" "testing" "time" "git.novox.be/novox/mesh-sdk/go/forgekey" ) const commitSHA = "0123456789abcdef0123456789abcdef01234567" var fetchArgs = map[string]any{"repository": "novox/mesh-catalog", "commit": commitSHA} // fetchMachine is a machine whose git checks out the given files into the tree it is asked to fetch into (the fake // answers the commands of FetchCommit and `git ls-files`), and whose npm ci writes the packages the test names. func fetchMachine(t *testing.T, files map[string]string, installs map[string]string) (*Module, *fakeMachine) { t.Helper() m, f, _ := newMachine(t) f.answer["git rev-parse"] = forgekey.Ran{Stdout: commitSHA + "\n"} var manifests []string for p := range files { if strings.HasSuffix(p, "module.json") { manifests = append(manifests, p) } } f.answer["git ls-files"] = forgekey.Ran{Stdout: strings.Join(manifests, "\n") + "\n"} f.effect = func(c call) { switch { case c.name == "git" && slices_contains(c.args, "checkout"): for i, a := range c.args { if a == "-C" { for p, body := range files { write(t, filepath.Join(c.args[i+1], filepath.FromSlash(p)), body, 0o644) } } } case c.name == "npm": for i, a := range c.args { if a == "--prefix" { for p, body := range installs { write(t, filepath.Join(c.args[i+1], "node_modules", filepath.FromSlash(p)), body, 0o644) } } } } } return m, f } func slices_contains(l []string, s string) bool { for _, x := range l { if x == s { return true } } return false } func typeScriptModule(pkg string) map[string]string { return 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": pkg, } } func npmCalls(f *fakeMachine) []call { var out []call for _, c := range f.calls { if c.name == "npm" { out = append(out, c) } } return out } // 1489/AC1: a module whose package.json names only @novox/mesh-sdk and @types/node (and typescript) installs nothing // and the step passes. func TestAModuleNamingOnlyTheSdkAndTypesInstallsNothing(t *testing.T) { m, f := fetchMachine(t, typeScriptModule(`{"dependencies":{"@novox/mesh-sdk":"^0.1.6"},"devDependencies":{"@types/node":"^22","typescript":"^5"}}`), nil) out, err := m.fetch(context.Background(), fetchArgs) if err != nil { t.Fatal(err) } r := out.(FetchResult) if len(npmCalls(f)) != 0 || len(r.Installed) != 0 || len(r.Modules) != 1 || r.Modules[0] != "modules/m" || r.Commit != commitSHA { t.Errorf("result = %+v, npm calls %d, want the module found and nothing installed", r, len(npmCalls(f))) } if _, err := os.Stat(filepath.Join(m.Work, r.Workspace, "tree", "modules", "m", "module.json")); err != nil { t.Errorf("the commit is not checked out in the workspace: %v", err) } } // 1489/AC2: a module naming lodash with a lock gets npm ci --omit=dev --ignore-scripts (fake runner), its install put // at its node_modules without the SDK and without any copy of it; without a lock the step fails naming the directory. func TestAModuleNamingLodashWithALockIsInstalledWithoutTheSdkAndWithoutALockFails(t *testing.T) { files := typeScriptModule(`{"dependencies":{"@novox/mesh-sdk":"^0.1.6","lodash":"^4"},"devDependencies":{"x":"1"},"scripts":{"test":"y"}}`) files["modules/m/package-lock.json"] = `{"lockfileVersion":3}` m, f := fetchMachine(t, files, map[string]string{"lodash/index.js": "l", "@novox/mesh-sdk/index.js": "sdk", "a/node_modules/@novox/mesh-sdk/i.js": "nested"}) out, err := m.fetch(context.Background(), fetchArgs) if err != nil { t.Fatal(err) } r := out.(FetchResult) calls := npmCalls(f) if len(calls) != 1 { t.Fatalf("npm ran %d times, want once", len(calls)) } line := strings.Join(calls[0].args, " ") if !strings.HasPrefix(line, "ci --omit=dev --omit=peer --ignore-scripts --no-bin-links") { t.Errorf("ran npm %s", line) } nm := filepath.Join(m.Work, r.Workspace, "tree", "modules", "m", "node_modules") if _, err := os.Stat(filepath.Join(nm, "lodash", "index.js")); err != nil { t.Errorf("lodash is not at the module's node_modules: %v", err) } for _, gone := range []string{"@novox/mesh-sdk", "a/node_modules/@novox/mesh-sdk"} { if _, err := os.Stat(filepath.Join(nm, gone)); err == nil { t.Errorf("a copy of the SDK is left at %s", gone) } } if got := strings.Join(r.Installed, ","); got != "modules/m" { t.Errorf("installed = %q", got) } // the module's own package.json is untouched: the install worked on a scratch copy. if raw, _ := os.ReadFile(filepath.Join(m.Work, r.Workspace, "tree", "modules", "m", "package.json")); !strings.Contains(string(raw), "scripts") { t.Errorf("the module's package.json was rewritten: %s", raw) } var scratch []string for _, e := range mustReadDir(t, filepath.Join(m.Work, r.Workspace)) { scratch = append(scratch, e) } if strings.Contains(strings.Join(scratch, ","), "npm-") { t.Errorf("the scratch install is left in the workspace: %v", scratch) } } // 1489 (the 1459 settlement): a module naming packages with no package-lock.json does not fail the fetch: it passes, // installs nothing for that module, and its result lists the refused bundle with its reason; a build of that bundle // fails naming it. func TestAModuleWithoutALockIsRefusedInTheResultNotTheFetch(t *testing.T) { files := typeScriptModule(`{"dependencies":{"@novox/mesh-sdk":"^0.1.6","lodash":"^4"}}`) files["modules/ok/module.json"] = `{"module":"ok","build":{"artifacts":[{"name":"tools","kind":"bundle","language":"typescript","entrypoints":["tools/index.js"]}]}}` files["modules/ok/package.json"] = `{"dependencies":{"@novox/mesh-sdk":"^0.1.6"}}` m, f := fetchMachine(t, files, nil) out, err := m.fetch(context.Background(), fetchArgs) if err != nil { t.Fatalf("a module without a lock failed the fetch: %v", err) } r := out.(FetchResult) if len(npmCalls(f)) != 0 || len(r.Installed) != 0 || len(r.Modules) != 2 { t.Errorf("result = %+v, npm calls %d", r, len(npmCalls(f))) } if len(r.Refused) != 1 || r.Refused[0].Module != "modules/m" || !strings.Contains(r.Refused[0].Reason, "names packages beyond the SDK and has no package-lock.json") || !strings.Contains(r.Refused[0].Reason, "lodash") { t.Fatalf("refused = %+v, want modules/m with its reason", r.Refused) } // the build of that bundle fails naming it, without running a compiler. f.calls = nil _, err = m.build(context.Background(), map[string]any{"workspace": r.Workspace, "module": "modules/m", "artifact": "tools"}) if err == nil || !strings.Contains(err.Error(), "modules/m") || !strings.Contains(err.Error(), "no package-lock.json") || len(f.calls) != 0 { t.Errorf("build = %v after %d commands, want the refusal naming modules/m and nothing run", err, len(f.calls)) } } // 1489 (review 4): a bundle depending on another @novox package is installed, the mesh's registry given to npm ci // through --userconfig, as the toolchain's install gives it. func TestAnotherNovoxPackageIsInstalledFromTheRegistryThroughAUserconfig(t *testing.T) { files := typeScriptModule(`{"dependencies":{"@novox/other":"^1","lodash":"^4"}}`) files["modules/m/package-lock.json"] = `{}` m, f := fetchMachine(t, files, map[string]string{"@novox/other/index.js": "o", "lodash/index.js": "l"}) m.Toolchain.Registry = func() (Registry, error) { return Registry{URL: "https://git.novox.be/api/packages/novox/npm/", Username: "mesh-build-node", Password: "pw"}, nil } var npmrc string inner := f.effect f.effect = func(c call) { inner(c) for i, a := range c.args { if c.name == "npm" && a == "--userconfig" { raw, _ := os.ReadFile(c.args[i+1]) npmrc = string(raw) } } } out, err := m.fetch(context.Background(), fetchArgs) if err != nil { t.Fatal(err) } if !strings.Contains(npmrc, "@novox:registry=https://git.novox.be/api/packages/novox/npm/") || !strings.Contains(npmrc, ":_auth=") { t.Errorf("npm ci was given the config %q", npmrc) } if strings.Contains(strings.Join(npmCalls(f)[0].args, " "), "pw") { t.Errorf("the credential is on npm's command line") } r := out.(FetchResult) if _, err := os.Stat(filepath.Join(m.Work, r.Workspace, "tree", "modules", "m", "node_modules", "@novox", "other", "index.js")); err != nil { t.Errorf("@novox/other was not installed: %v", err) } for _, e := range mustReadDir(t, filepath.Join(m.Work, r.Workspace)) { if strings.HasPrefix(e, "npm-") { t.Errorf("the scratch install (and its credential file) is left: %s", e) } } } func mustReadDir(t *testing.T, dir string) []string { t.Helper() es, err := os.ReadDir(dir) if err != nil { t.Fatal(err) } var out []string for _, e := range es { out = append(out, e.Name()) } return out } // 1489/AC3: given a process environment holding a token, no command of the step sees it. The commands the step makes // are given PATH, HOME and the locale and their own words; and the real clean runner, run on `env` here, prints only // those. func TestNoCommandOfTheStepSeesATokenOfTheProcess(t *testing.T) { t.Setenv("MESH_BUILD_CPU_SHARE", "50") t.Setenv("MESH_BUS_CREDENTIAL", "hunter2") t.Setenv("NPM_TOKEN", "hunter3") files := typeScriptModule(`{"dependencies":{"lodash":"^4"}}`) files["modules/m/package-lock.json"] = `{}` m, f := fetchMachine(t, files, map[string]string{"lodash/index.js": "l"}) if _, err := m.fetch(context.Background(), fetchArgs); err != nil { t.Fatal(err) } if len(f.calls) < 6 { t.Fatalf("only %d commands ran", len(f.calls)) } for _, c := range f.calls { for _, e := range c.env { if strings.Contains(e, "hunter") || strings.HasPrefix(e, "MESH_") || strings.HasPrefix(e, "NPM_TOKEN") { t.Errorf("%s %v was given %s", c.name, c.args[:min(len(c.args), 3)], e) } } if len(c.env) < 3 || !strings.HasPrefix(c.env[0], "PATH=") || !strings.HasPrefix(c.env[1], "HOME=") || c.env[2] != "LC_ALL=C" { t.Errorf("%s was given %v, want PATH, HOME and the locale first", c.name, c.env) } // And its threads, its part of the node's share the composer gave (ADR 0553). if !slices.ContainsFunc(c.env, func(e string) bool { return strings.HasPrefix(e, "GOMAXPROCS=") }) || !slices.ContainsFunc(c.env, func(e string) bool { return strings.HasPrefix(e, "UV_THREADPOOL_SIZE=") }) { t.Errorf("%s was given %v, without its threads", c.name, c.env) } } // The runner main wires: what it hands a command is exactly what the step gave. ran := forgekey.ExecRunnerClean(10*time.Second)(context.Background(), append(m.baseEnv(), "ONLY=this"), "env") if ran.Status != 0 || strings.Contains(ran.Stdout, "hunter") || !strings.Contains(ran.Stdout, "ONLY=this") { t.Errorf("the clean runner printed %q", ran.Stdout) } } func TestFetchRefusesWhatIsNotARepositoryBeforeAnythingRuns(t *testing.T) { m, f := fetchMachine(t, nil, nil) for _, repo := range []string{"", "x", "../etc/passwd", "a/b/c", "a/..", "o/r;rm"} { if _, err := m.fetch(context.Background(), map[string]any{"repository": repo, "commit": commitSHA}); err == nil { t.Errorf("%q was accepted", repo) } } if len(f.calls) != 0 { t.Errorf("%d commands ran", len(f.calls)) } } func TestFetchAsksGitForTheSshRemote(t *testing.T) { m, f := fetchMachine(t, typeScriptModule(`{}`), nil) if _, err := m.fetch(context.Background(), fetchArgs); err != nil { t.Fatal(err) } var all []string for _, c := range f.calls { all = append(all, c.name+" "+strings.Join(c.args, " ")) } lines := strings.Join(all, "\n") for _, want := range []string{"remote add origin ssh://mesh-forge/novox/mesh-catalog.git", "fetch --no-tags --depth 1 origin " + commitSHA, "checkout -q --detach " + commitSHA} { if !strings.Contains(lines, want) { t.Errorf("git was not asked %q:\n%s", want, lines) } } } func TestWorkspacesOlderThanADayAreRemoved(t *testing.T) { m, _, work := newMachine(t) old, fresh := filepath.Join(work, "aaaa"), filepath.Join(work, "bbbb") for _, d := range []string{old, fresh, filepath.Join(work, "Not-A-Workspace")} { os.MkdirAll(d, 0o755) } os.Chtimes(old, time.Now().Add(-25*time.Hour), time.Now().Add(-25*time.Hour)) os.Chtimes(filepath.Join(work, "Not-A-Workspace"), time.Now().Add(-48*time.Hour), time.Now().Add(-48*time.Hour)) m.CleanWorkspaces(time.Now()) if _, err := os.Stat(old); err == nil { t.Errorf("the old workspace is kept") } if _, err := os.Stat(fresh); err != nil { t.Errorf("the fresh workspace was removed") } if _, err := os.Stat(filepath.Join(work, "Not-A-Workspace")); err != nil { t.Errorf("a directory that is no workspace was removed") } }