304 lines
12 KiB
Go
304 lines
12 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"os"
|
|
"path/filepath"
|
|
"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_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)
|
|
}
|
|
}
|
|
// 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")
|
|
}
|
|
}
|