package pi import ( "context" "errors" "io" "net/http" "os" "path/filepath" "strings" "testing" "time" ) type registryFunc func(context.Context, string) (string, error) func (f registryFunc) LatestStable(ctx context.Context, packageName string) (string, error) { return f(ctx, packageName) } type roundTripFunc func(*http.Request) (*http.Response, error) func (f roundTripFunc) RoundTrip(request *http.Request) (*http.Response, error) { return f(request) } func TestNPMRegistryClientSelectsHighestStableVersionAndIgnoresPrereleases(t *testing.T) { client := newNPMRegistryClient(&http.Client{Transport: roundTripFunc(func(request *http.Request) (*http.Response, error) { if request.URL.Scheme != "https" || request.URL.Host != "registry.npmjs.org" { t.Fatalf("registry request = %s; want authoritative HTTPS registry", request.URL) } if request.URL.Path != "/@earendil-works%2Fpi-coding-agent" && request.URL.EscapedPath() != "/@earendil-works%2Fpi-coding-agent" { t.Fatalf("registry path = %q", request.URL.EscapedPath()) } return registryResponse(http.StatusOK, `{"versions":{"0.80.3":{},"0.81.0-rc.1":{},"0.81.0":{},"1.0.0-beta.2":{}}}`), nil })}) got, err := client.LatestStable(context.Background(), "@earendil-works/pi-coding-agent") if err != nil { t.Fatal(err) } if got != "0.81.0" { t.Fatalf("LatestStable() = %q, want 0.81.0", got) } } func TestNPMRegistryClientRejectsInvalidResponses(t *testing.T) { for _, test := range []struct { name string status int body string wantText string }{ {name: "prerelease only", status: http.StatusOK, body: `{"versions":{"1.0.0-rc.1":{}}}`, wantText: "no stable"}, {name: "malformed JSON", status: http.StatusOK, body: `{`, wantText: "invalid"}, {name: "malformed semantic version key", status: http.StatusOK, body: `{"versions":{"0.81.0":{},"latest":{}}}`, wantText: "invalid"}, {name: "no version data", status: http.StatusOK, body: `{"versions":{}}`, wantText: "no stable"}, {name: "package not found", status: http.StatusNotFound, body: `{"error":"not_found"}`, wantText: "not found"}, } { t.Run(test.name, func(t *testing.T) { client := newNPMRegistryClient(&http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) { return registryResponse(test.status, test.body), nil })}) _, err := client.LatestStable(context.Background(), "@earendil-works/pi-coding-agent") if err == nil || !strings.Contains(strings.ToLower(err.Error()), test.wantText) { t.Fatalf("LatestStable() error = %v, want %q", err, test.wantText) } }) } } func TestNPMRegistryClientRejectsRedirectsOutsideTheAuthoritativePackageURL(t *testing.T) { for _, destination := range []string{ "http://registry.npmjs.org/@earendil-works%2Fpi-coding-agent", "https://mirror.example.invalid/@earendil-works%2Fpi-coding-agent", } { t.Run(destination, func(t *testing.T) { calls := 0 client := newNPMRegistryClient(&http.Client{Transport: roundTripFunc(func(request *http.Request) (*http.Response, error) { calls++ response := registryResponse(http.StatusFound, "") response.Header.Set("Location", destination) response.Request = request return response, nil })}) _, err := client.LatestStable(context.Background(), "@earendil-works/pi-coding-agent") if err == nil || !strings.Contains(strings.ToLower(err.Error()), "redirect") { t.Fatalf("LatestStable() redirect error = %v, want rejected redirect", err) } if calls != 1 { t.Fatalf("LatestStable() followed rejected redirect %d times", calls-1) } }) } } func TestNPMRegistryClientRejectsAnOversizedResponse(t *testing.T) { client := newNPMRegistryClient(&http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) { return registryResponse(http.StatusOK, `{"versions":{"1.2.3":{}}}`+strings.Repeat(" ", registryBodyLimit+1)), nil })}) _, err := client.LatestStable(context.Background(), "@earendil-works/pi-coding-agent") if err == nil || !strings.Contains(err.Error(), "too large") { t.Fatalf("LatestStable() error = %v, want bounded-response error", err) } } func TestNPMRegistryClientHonorsContextTimeout(t *testing.T) { client := newNPMRegistryClient(&http.Client{Transport: roundTripFunc(func(request *http.Request) (*http.Response, error) { <-request.Context().Done() return nil, request.Context().Err() })}) ctx, cancel := context.WithTimeout(context.Background(), 10*time.Millisecond) defer cancel() _, err := client.LatestStable(ctx, "@earendil-works/pi-coding-agent") if !errors.Is(err, context.DeadlineExceeded) { t.Fatalf("LatestStable() error = %v, want deadline exceeded", err) } } func TestResolveRequestedVersionBypassesRegistryForExplicitVersion(t *testing.T) { called := false got, err := ResolveRequestedVersion(context.Background(), "1.2.3-rc.1", "@earendil-works/pi-coding-agent", registryFunc(func(context.Context, string) (string, error) { called = true return "", errors.New("registry must not be called") })) if err != nil { t.Fatal(err) } if got != "1.2.3-rc.1" || called { t.Fatalf("ResolveRequestedVersion() = %q, called=%t; want explicit prerelease without lookup", got, called) } } func TestResolveRequestedVersionRejectsNonStrictSemver(t *testing.T) { for _, version := range []string{"1.2", "01.2.3", "v1.2.3", "1.2.3.4", "1.2.3-01"} { _, err := ResolveRequestedVersion(context.Background(), version, "@earendil-works/pi-coding-agent", nil) if err == nil || !strings.Contains(err.Error(), "semantic version") { t.Fatalf("ResolveRequestedVersion(%q) error = %v, want strict semver error", version, err) } } } func TestUpdateWithResolvedVersionDoesNotMutateWhenDiscoveryFails(t *testing.T) { for _, test := range []struct { name string err error }{ {name: "timeout", err: context.DeadlineExceeded}, {name: "package missing", err: errors.New("package not found")}, {name: "malformed", err: errors.New("registry response is invalid")}, {name: "no stable version", err: errors.New("registry contains no stable version")}, {name: "malformed semantic version key", err: errors.New("registry version key is invalid")}, } { t.Run(test.name, func(t *testing.T) { fake := newFakeRunner() statePath := t.TempDir() + "/state/update.json" _, err := UpdateWithResolvedVersion(context.Background(), fake, Request{StatePath: statePath, Source: BuildSource, Confirm: true, Drain: true}, "@earendil-works/pi-coding-agent", registryFunc(func(context.Context, string) (string, error) { return "", test.err })) if !errors.Is(err, test.err) { t.Fatalf("UpdateWithResolvedVersion() error = %v, want %v", err, test.err) } if len(fake.calls) != 0 { t.Fatalf("discovery failure invoked Docker or lifecycle commands: %v", fake.calls) } if _, statErr := os.Stat(filepath.Dir(statePath)); !errors.Is(statErr, os.ErrNotExist) { t.Fatalf("discovery failure created update state directory: %v", statErr) } }) } } func TestUpdateWithResolvedVersionDoesNotMutateForMalformedRegistryVersionKeys(t *testing.T) { fake := newFakeRunner() statePath := t.TempDir() + "/state/update.json" registry := newNPMRegistryClient(&http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) { return registryResponse(http.StatusOK, `{"versions":{"0.81.0":{},"not-a-semver":{}}}`), nil })}) _, err := UpdateWithResolvedVersion(context.Background(), fake, Request{StatePath: statePath, Source: BuildSource, Confirm: true, Drain: true}, "@earendil-works/pi-coding-agent", registry) if err == nil || !strings.Contains(err.Error(), "invalid") { t.Fatalf("UpdateWithResolvedVersion() error = %v, want malformed registry version rejection", err) } if len(fake.calls) != 0 { t.Fatalf("malformed registry version invoked Docker or lifecycle commands: %v", fake.calls) } if _, statErr := os.Stat(filepath.Dir(statePath)); !errors.Is(statErr, os.ErrNotExist) { t.Fatalf("malformed registry version created update state directory: %v", statErr) } } func TestReadRuntimePackageNameReadsThePiDependency(t *testing.T) { root := t.TempDir() path := filepath.Join(root, "docker", "pi-runtime") if err := os.MkdirAll(path, 0o755); err != nil { t.Fatal(err) } if err := os.WriteFile(filepath.Join(path, "package.json"), []byte(`{"dependencies":{"@earendil-works/pi-coding-agent":"0.80.3"}}`), 0o600); err != nil { t.Fatal(err) } got, err := ReadRuntimePackageName(root) if err != nil { t.Fatal(err) } if got != "@earendil-works/pi-coding-agent" { t.Fatalf("ReadRuntimePackageName() = %q", got) } } func registryResponse(status int, body string) *http.Response { return &http.Response{ StatusCode: status, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(body)), } }