217 lines
8.6 KiB
Go
217 lines
8.6 KiB
Go
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)),
|
|
}
|
|
}
|