diff --git a/.github/workflows/canary.yml b/.github/workflows/canary.yml new file mode 100644 index 0000000..52dd7ca --- /dev/null +++ b/.github/workflows/canary.yml @@ -0,0 +1,24 @@ +name: canary + +on: + schedule: + - cron: "0 6 * * *" # daily at 6am UTC + workflow_dispatch: + +jobs: + live: + name: Live wp.org Canary + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - uses: actions/setup-go@v6 + with: + go-version-file: go.mod + - uses: shivammathur/setup-php@v2 + with: + php-version: "8.2" + tools: composer:v2 + - name: Create stub CSS for embed + run: touch internal/http/static/assets/styles/app.css + - name: Live Canary Test + run: go test -tags=wporg_live -count=1 -timeout=5m -v ./internal/integration/... diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9a853c8..2edaec1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -97,3 +97,20 @@ jobs: run: make tailwind - name: Test run: go test -v ./... + + integration: + name: Integration Test + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - uses: actions/setup-go@v6 + with: + go-version-file: go.mod + - uses: shivammathur/setup-php@v2 + with: + php-version: "8.2" + tools: composer:v2 + - name: Create stub CSS for embed + run: touch internal/http/static/assets/styles/app.css + - name: Integration Test + run: go test -tags=integration -count=1 -timeout=5m -v ./internal/integration/... diff --git a/.github/workflows/smoke-test.yml b/.github/workflows/smoke-test.yml deleted file mode 100644 index f61bd8c..0000000 --- a/.github/workflows/smoke-test.yml +++ /dev/null @@ -1,45 +0,0 @@ -name: smoke-test - -on: - schedule: - - cron: "0 */6 * * *" - workflow_dispatch: - -jobs: - install: - name: Composer Install - runs-on: ubuntu-latest - steps: - - name: Set up PHP - uses: shivammathur/setup-php@v2 - with: - php-version: "8.2" - tools: composer:v2 - - - name: Create composer.json - run: | - cat > composer.json <<'EOF' - { - "repositories": [ - { - "name": "wp-composer", - "type": "composer", - "url": "https://repo.wp-composer.com", - "only": ["wp-plugin/*", "wp-theme/*"] - } - ], - "require": { - "composer/installers": "^2.2", - "wp-plugin/woocommerce": "*", - "wp-theme/twentytwentyfive": "*" - }, - "config": { - "allow-plugins": { - "composer/installers": true - } - } - } - EOF - - - name: Composer install - run: composer install --no-interaction --no-progress diff --git a/Makefile b/Makefile index 3cb106a..ff9c13c 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: build install dev test smoke lint clean tailwind +.PHONY: build install dev test integration lint clean tailwind TAILWIND ?= ./bin/tailwindcss @@ -38,9 +38,9 @@ dev: tailwind-install test: go test ./... -# End-to-end smoke test (requires composer, sqlite3) -smoke: build - ./test/smoke_test.sh +# Integration tests (requires composer) +integration: + go test -tags=integration -count=1 -timeout=5m -v ./internal/integration/... # Lint (matches CI: golangci-lint + gofmt + go vet + go mod tidy) lint: diff --git a/go.mod b/go.mod index a1a07ae..60a3419 100644 --- a/go.mod +++ b/go.mod @@ -9,6 +9,7 @@ require ( github.com/fogleman/gg v1.3.0 github.com/getsentry/sentry-go v0.43.0 github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 + github.com/johannesboyne/gofakes3 v0.0.0-20260208201424-4c385a1f6a73 github.com/pressly/goose/v3 v3.27.0 github.com/spf13/cobra v1.10.2 golang.org/x/crypto v0.49.0 @@ -36,12 +37,15 @@ require ( github.com/mfridman/interpolate v0.0.2 // indirect github.com/ncruces/go-strftime v1.0.0 // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect + github.com/ryszard/goskiplist v0.0.0-20150312221310-2dfbae5fcf46 // indirect github.com/sethvargo/go-retry v0.3.0 // indirect github.com/spf13/pflag v1.0.9 // indirect + go.shabbyrobe.org/gocovmerge v0.0.0-20230507111327-fa4f82cfbf4d // indirect go.uber.org/multierr v1.11.0 // indirect golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa // indirect golang.org/x/sys v0.42.0 // indirect golang.org/x/text v0.35.0 // indirect + golang.org/x/tools v0.42.0 // indirect modernc.org/libc v1.68.0 // indirect modernc.org/mathutil v1.7.1 // indirect modernc.org/memory v1.11.0 // indirect diff --git a/go.sum b/go.sum index bbfc120..0012ea6 100644 --- a/go.sum +++ b/go.sum @@ -4,6 +4,8 @@ github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.7 h1:3kGOqnh1pPeddVa/ github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.7/go.mod h1:lyw7GFp3qENLh7kwzf7iMzAxDn+NzjXEAGjKS2UOKqI= github.com/aws/aws-sdk-go-v2/credentials v1.19.12 h1:oqtA6v+y5fZg//tcTWahyN9PEn5eDU/Wpvc2+kJ4aY8= github.com/aws/aws-sdk-go-v2/credentials v1.19.12/go.mod h1:U3R1RtSHx6NB0DvEQFGyf/0sbrpJrluENHdPy1j/3TE= +github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.17.75 h1:S61/E3N01oral6B3y9hZ2E1iFDqCZPPOBoBQretCnBI= +github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.17.75/go.mod h1:bDMQbkI1vJbNjnvJYpPTSNYBkI/VIv18ngWb/K84tkk= github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.20 h1:CNXO7mvgThFGqOFgbNAP2nol2qAWBOGfqR/7tQlvLmc= github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.20/go.mod h1:oydPDJKcfMhgfcgBUZaG+toBbwy8yPWubJXBVERtI4o= github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.20 h1:tN6W/hg+pkM+tf9XDkWUbDEjGLb+raoBMFsTodcoYKw= @@ -22,6 +24,8 @@ github.com/aws/aws-sdk-go-v2/service/s3 v1.97.1 h1:csi9NLpFZXb9fxY7rS1xVzgPRGMt7 github.com/aws/aws-sdk-go-v2/service/s3 v1.97.1/go.mod h1:qXVal5H0ChqXP63t6jze5LmFalc7+ZE7wOdLtZ0LCP0= github.com/aws/smithy-go v1.24.2 h1:FzA3bu/nt/vDvmnkg+R8Xl46gmzEDam6mZ1hzmwXFng= github.com/aws/smithy-go v1.24.2/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc= +github.com/cevatbarisyilmaz/ara v0.0.4 h1:SGH10hXpBJhhTlObuZzTuFn1rrdmjQImITXnZVPSodc= +github.com/cevatbarisyilmaz/ara v0.0.4/go.mod h1:BfFOxnUd6Mj6xmcvRxHN3Sr21Z1T3U2MYkYOmoQe4Ts= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= @@ -46,6 +50,8 @@ github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/johannesboyne/gofakes3 v0.0.0-20260208201424-4c385a1f6a73 h1:0xkWp+RMC2ImuKacheMHEAtrbOTMOa0kYkxyzM1Z/II= +github.com/johannesboyne/gofakes3 v0.0.0-20260208201424-4c385a1f6a73/go.mod h1:S4S9jGBVlLri0OeqrSSbCGG5vsI6he06UJyuz1WT1EE= github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0= github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= @@ -69,14 +75,22 @@ github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qq github.com/rogpeppe/go-internal v1.8.0 h1:FCbCCtXNOY3UtUuHUYaghJg4y7Fd14rXifAYUAtL9R8= github.com/rogpeppe/go-internal v1.8.0/go.mod h1:WmiCO8CzOY8rg0OYDC4/i/2WRWAB6poM+XZ2dLUbcbE= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/ryszard/goskiplist v0.0.0-20150312221310-2dfbae5fcf46 h1:GHRpF1pTW19a8tTFrMLUcfWwyC0pnifVo2ClaLq+hP8= +github.com/ryszard/goskiplist v0.0.0-20150312221310-2dfbae5fcf46/go.mod h1:uAQ5PCi+MFsC7HjREoAz1BU+Mq60+05gifQSsHSDG/8= github.com/sethvargo/go-retry v0.3.0 h1:EEt31A35QhrcRZtrYFDTBg91cqZVnFL2navjDrah2SE= github.com/sethvargo/go-retry v0.3.0/go.mod h1:mNX17F0C/HguQMyMyJxcnU471gOZGxCLyYaFyAZraas= +github.com/spf13/afero v1.2.1 h1:qgMbHoJbPbw579P+1zVY+6n4nIFuIchaIjzZ/I/Yq8M= +github.com/spf13/afero v1.2.1/go.mod h1:9ZxEEn6pIJ8Rxe320qSDBk6AsU0r9pR7Q4OcevTdifk= github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +go.etcd.io/bbolt v1.3.5 h1:XAzx9gjCb0Rxj7EoqcClPD1d5ZBxZJk0jbuoPHenBt0= +go.etcd.io/bbolt v1.3.5/go.mod h1:G5EMThwa9y8QZGBClrRx5EY+Yw9kAhnjy3bSjsnlVTQ= +go.shabbyrobe.org/gocovmerge v0.0.0-20230507111327-fa4f82cfbf4d h1:Ns9kd1Rwzw7t0BR8XMphenji4SmIoNZPn8zhYmaVKP8= +go.shabbyrobe.org/gocovmerge v0.0.0-20230507111327-fa4f82cfbf4d/go.mod h1:92Uoe3l++MlthCm+koNi0tcUCX3anayogF0Pa/sp24k= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= @@ -102,6 +116,8 @@ golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0 gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/mgo.v2 v2.0.0-20180705113604-9856a29383ce h1:xcEWjVhvbDy+nHP67nPDDpbYrY+ILlfndk4bRioVHaU= +gopkg.in/mgo.v2 v2.0.0-20180705113604-9856a29383ce/go.mod h1:yeKp02qBN3iKW1OzL3MGk2IdtZzaj7SFntXj72NppTA= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= modernc.org/cc/v4 v4.27.1 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis= diff --git a/internal/deploy/r2.go b/internal/deploy/r2.go index a80317b..83e8a9c 100644 --- a/internal/deploy/r2.go +++ b/internal/deploy/r2.go @@ -548,6 +548,7 @@ func newS3Client(cfg config.R2Config) *s3.Client { "", ), BaseEndpoint: aws.String(cfg.Endpoint), + UsePathStyle: true, }) } diff --git a/internal/integration/helpers_test.go b/internal/integration/helpers_test.go new file mode 100644 index 0000000..11e7433 --- /dev/null +++ b/internal/integration/helpers_test.go @@ -0,0 +1,74 @@ +//go:build integration || wporg_live + +package integration + +import ( + "encoding/json" + "io" + "log/slog" + "net/http" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" +) + +func testLogger(t *testing.T) *slog.Logger { + t.Helper() + return slog.Default() +} + +func httpGet(t *testing.T, url string) string { + t.Helper() + resp, err := http.Get(url) + if err != nil { + t.Fatalf("GET %s: %v", url, err) + } + defer resp.Body.Close() + if resp.StatusCode != 200 { + t.Fatalf("GET %s: status %d", url, resp.StatusCode) + } + body, err := io.ReadAll(resp.Body) + if err != nil { + t.Fatalf("reading response from %s: %v", url, err) + } + return string(body) +} + +func writeComposerJSON(t *testing.T, dir, repoURL string, require map[string]string) { + t.Helper() + data := map[string]any{ + "repositories": []map[string]any{ + { + "type": "composer", + "url": repoURL, + }, + }, + "require": require, + "config": map[string]any{ + "allow-plugins": map[string]any{ + "composer/installers": true, + }, + "secure-http": false, + }, + } + jsonData, err := json.MarshalIndent(data, "", " ") + if err != nil { + t.Fatalf("marshaling composer.json: %v", err) + } + if err := os.WriteFile(filepath.Join(dir, "composer.json"), jsonData, 0644); err != nil { + t.Fatalf("writing composer.json: %v", err) + } +} + +func runComposer(t *testing.T, composerPath, dir string, args ...string) string { + t.Helper() + cmd := exec.Command(composerPath, args...) + cmd.Dir = dir + out, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("composer %s failed: %v\noutput: %s", strings.Join(args, " "), err, out) + } + return string(out) +} diff --git a/internal/integration/smoke_test.go b/internal/integration/smoke_test.go new file mode 100644 index 0000000..cd5cacf --- /dev/null +++ b/internal/integration/smoke_test.go @@ -0,0 +1,295 @@ +//go:build integration + +package integration + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "net/http/httptest" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + + "github.com/roots/wp-composer/internal/app" + "github.com/roots/wp-composer/internal/config" + apphttp "github.com/roots/wp-composer/internal/http" + "github.com/roots/wp-composer/internal/packagist" + "github.com/roots/wp-composer/internal/repository" + "github.com/roots/wp-composer/internal/testutil" + "github.com/roots/wp-composer/internal/wporg" +) + +func TestSmoke(t *testing.T) { + // --- Setup --- + fixtureDir := filepath.Join("..", "wporg", "testdata") + mock := wporg.NewMockServer(fixtureDir) + defer mock.Close() + + db := testutil.OpenTestDB(t) + testutil.SeedFromFixtures(t, db, mock.URL) + + // --- Build --- + buildDir := t.TempDir() + buildsDir := filepath.Join(buildDir, "builds") + repoDir := buildDir + + result, err := repository.Build(t.Context(), db, repository.BuildOpts{ + OutputDir: buildsDir, + AppURL: "http://test.local", + Force: true, + Logger: testLogger(t), + }) + if err != nil { + t.Fatalf("build failed: %v", err) + } + if result.PackagesTotal == 0 { + t.Fatal("build produced zero packages") + } + + // Symlink current -> build + currentLink := filepath.Join(repoDir, "current") + if err := os.Symlink(filepath.Join("builds", result.BuildID), currentLink); err != nil { + t.Fatalf("creating current symlink: %v", err) + } + + // --- Start HTTP server --- + cfg := &config.Config{ + AppURL: "http://test.local", + Env: "test", + } + a := &app.App{ + Config: cfg, + DB: db, + Logger: testLogger(t), + Packagist: packagist.NewStubCache(), + } + + // Serve repository files from the build directory + actualBuildDir := filepath.Join(buildsDir, result.BuildID) + repoServer := httptest.NewServer(http.FileServer(http.Dir(actualBuildDir))) + defer repoServer.Close() + + // Start app server + router := apphttp.NewRouter(a) + srv := httptest.NewServer(router) + defer srv.Close() + + // --- Composer metadata endpoints --- + t.Run("packages.json", func(t *testing.T) { + body := httpGet(t, repoServer.URL+"/packages.json") + var pkgJSON map[string]any + if err := json.Unmarshal([]byte(body), &pkgJSON); err != nil { + t.Fatalf("invalid packages.json: %v", err) + } + + // notify-batch URL + if nb, ok := pkgJSON["notify-batch"].(string); !ok || nb == "" { + t.Error("missing notify-batch URL") + } + + // metadata-url + if mu, ok := pkgJSON["metadata-url"].(string); !ok || mu != "/p2/%package%.json" { + t.Errorf("unexpected metadata-url: %v", pkgJSON["metadata-url"]) + } + + // providers-url + if pu, ok := pkgJSON["providers-url"].(string); !ok || !strings.Contains(pu, "/p/") { + t.Errorf("unexpected providers-url: %v", pkgJSON["providers-url"]) + } + + // provider-includes + pi, ok := pkgJSON["provider-includes"].(map[string]any) + if !ok || len(pi) == 0 { + t.Error("missing or empty provider-includes") + } + }) + + t.Run("p2 endpoint", func(t *testing.T) { + body := httpGet(t, repoServer.URL+"/p2/wp-plugin/akismet.json") + var data map[string]any + if err := json.Unmarshal([]byte(body), &data); err != nil { + t.Fatalf("invalid p2 response: %v", err) + } + + pkgs, ok := data["packages"].(map[string]any) + if !ok { + t.Fatal("missing 'packages' key in p2 response") + } + + akismet, ok := pkgs["wp-plugin/akismet"].(map[string]any) + if !ok { + t.Fatal("missing wp-plugin/akismet in packages") + } + + // Check at least one version entry + if len(akismet) == 0 { + t.Fatal("no version entries for akismet") + } + + // Verify version entry structure + for ver, entry := range akismet { + e, ok := entry.(map[string]any) + if !ok { + t.Fatalf("version %s is not an object", ver) + } + + // Required fields + for _, field := range []string{"name", "version", "type", "dist", "source", "require"} { + if _, ok := e[field]; !ok { + t.Errorf("version %s missing field: %s", ver, field) + } + } + + // dist URL should point to downloads.wordpress.org + if dist, ok := e["dist"].(map[string]any); ok { + if url, ok := dist["url"].(string); ok { + if !strings.Contains(url, "downloads.wordpress.org") { + t.Errorf("version %s dist URL unexpected: %s", ver, url) + } + } + } + + // type should be wordpress-plugin + if typ, ok := e["type"].(string); ok { + if typ != "wordpress-plugin" { + t.Errorf("version %s type: got %s, want wordpress-plugin", ver, typ) + } + } + break // checking one entry is sufficient + } + }) + + t.Run("p content-addressed endpoint", func(t *testing.T) { + // Extract hash from packages.json provider-includes + body := httpGet(t, repoServer.URL+"/packages.json") + var pkgJSON map[string]any + if err := json.Unmarshal([]byte(body), &pkgJSON); err != nil { + t.Fatalf("invalid packages.json: %v", err) + } + + pi := pkgJSON["provider-includes"].(map[string]any) + for providerPath := range pi { + // Fetch the provider file + providerBody := httpGet(t, repoServer.URL+"/"+providerPath) + var provider map[string]any + if err := json.Unmarshal([]byte(providerBody), &provider); err != nil { + t.Fatalf("invalid provider file %s: %v", providerPath, err) + } + + providers, ok := provider["providers"].(map[string]any) + if !ok { + continue + } + + // Find akismet and verify the content-addressed file exists + if akInfo, ok := providers["wp-plugin/akismet"]; ok { + info := akInfo.(map[string]any) + hash := info["sha256"].(string) + pPath := fmt.Sprintf("/p/wp-plugin/akismet$%s.json", hash) + pBody := httpGet(t, repoServer.URL+pPath) + + // Should match p2 content + p2Body := httpGet(t, repoServer.URL+"/p2/wp-plugin/akismet.json") + if pBody != p2Body { + t.Error("p/ content-addressed file does not match p2/ file") + } + return + } + } + t.Error("could not find akismet in any provider-includes") + }) + + t.Run("package detail page", func(t *testing.T) { + resp, err := http.Get(srv.URL + "/packages/wp-plugin/akismet") + if err != nil { + t.Fatalf("GET package detail: %v", err) + } + defer resp.Body.Close() + if resp.StatusCode != 200 { + t.Errorf("package detail status: got %d, want 200", resp.StatusCode) + } + body, _ := io.ReadAll(resp.Body) + if !strings.Contains(string(body), "composer require") { + t.Error("package detail page missing 'composer require'") + } + }) + + t.Run("package detail 404", func(t *testing.T) { + resp, err := http.Get(srv.URL + "/packages/wp-plugin/nonexistent") + if err != nil { + t.Fatalf("GET nonexistent package: %v", err) + } + defer resp.Body.Close() + if resp.StatusCode != 404 { + t.Errorf("nonexistent package status: got %d, want 404", resp.StatusCode) + } + }) + + // --- Composer install --- + t.Run("composer install", func(t *testing.T) { + composerPath, err := exec.LookPath("composer") + if err != nil { + t.Skip("composer not in PATH") + } + + dir := t.TempDir() + writeComposerJSON(t, dir, repoServer.URL, map[string]string{ + "wp-plugin/akismet": "*", + "wp-plugin/classic-editor": "*", + "wp-theme/astra": "*", + }) + out := runComposer(t, composerPath, dir, "install", "--no-interaction", "--no-progress") + for _, pkg := range []string{"wp-plugin/akismet", "wp-plugin/classic-editor", "wp-theme/astra"} { + if !strings.Contains(out, pkg) { + t.Errorf("composer install output missing %s", pkg) + } + } + }) + + t.Run("composer version pinning", func(t *testing.T) { + composerPath, err := exec.LookPath("composer") + if err != nil { + t.Skip("composer not in PATH") + } + + dir := t.TempDir() + writeComposerJSON(t, dir, repoServer.URL, map[string]string{ + "wp-plugin/akismet": "5.3.3", + }) + out := runComposer(t, composerPath, dir, "install", "--no-interaction", "--no-progress") + if !strings.Contains(out, "5.3.3") { + t.Errorf("composer install did not lock pinned version 5.3.3, output: %s", out) + } + }) + + // --- Build integrity --- + t.Run("build integrity", func(t *testing.T) { + manifestPath := filepath.Join(actualBuildDir, "manifest.json") + data, err := os.ReadFile(manifestPath) + if err != nil { + t.Fatalf("reading manifest: %v", err) + } + var manifest map[string]any + if err := json.Unmarshal(data, &manifest); err != nil { + t.Fatalf("invalid manifest: %v", err) + } + if _, ok := manifest["root_hash"]; !ok { + t.Error("manifest missing root_hash") + } + if count, ok := manifest["artifact_count"].(float64); !ok || count == 0 { + t.Error("manifest missing or zero artifact_count") + } + + // Run full integrity validation + errs := repository.ValidateIntegrity(actualBuildDir) + if len(errs) > 0 { + for _, e := range errs { + t.Errorf("integrity error: %s", e) + } + } + }) +} diff --git a/internal/integration/sync_test.go b/internal/integration/sync_test.go new file mode 100644 index 0000000..b2e250b --- /dev/null +++ b/internal/integration/sync_test.go @@ -0,0 +1,177 @@ +//go:build integration + +package integration + +import ( + "context" + "encoding/json" + "io" + "net/http/httptest" + "path/filepath" + "strings" + "testing" + + "github.com/aws/aws-sdk-go-v2/aws" + "github.com/aws/aws-sdk-go-v2/credentials" + "github.com/aws/aws-sdk-go-v2/service/s3" + "github.com/johannesboyne/gofakes3" + "github.com/johannesboyne/gofakes3/backend/s3mem" + "github.com/roots/wp-composer/internal/config" + "github.com/roots/wp-composer/internal/deploy" + "github.com/roots/wp-composer/internal/repository" + "github.com/roots/wp-composer/internal/testutil" + "github.com/roots/wp-composer/internal/wporg" +) + +func TestR2Sync(t *testing.T) { + ctx := context.Background() + + // 1. Seed DB from fixtures + fixtureDir := filepath.Join("..", "wporg", "testdata") + mock := wporg.NewMockServer(fixtureDir) + defer mock.Close() + + db := testutil.OpenTestDB(t) + testutil.SeedFromFixtures(t, db, mock.URL) + + // 2. Build artifacts + buildOutputDir := filepath.Join(t.TempDir(), "builds") + result, err := repository.Build(ctx, db, repository.BuildOpts{ + OutputDir: buildOutputDir, + AppURL: "http://test.local", + Force: true, + Logger: testLogger(t), + }) + if err != nil { + t.Fatalf("build failed: %v", err) + } + buildDir := filepath.Join(buildOutputDir, result.BuildID) + + // 3. Start gofakes3 in-process + backend := s3mem.New() + faker := gofakes3.New(backend) + ts := httptest.NewServer(faker.Server()) + defer ts.Close() + + // Create the bucket + s3Client := newTestS3Client(ts.URL) + _, err = s3Client.CreateBucket(ctx, &s3.CreateBucketInput{ + Bucket: aws.String("test-bucket"), + }) + if err != nil { + t.Fatalf("creating bucket: %v", err) + } + + r2Cfg := config.R2Config{ + AccessKeyID: "test", + SecretAccessKey: "test", + Bucket: "test-bucket", + Endpoint: ts.URL, + } + + // 4. First sync — all packages uploaded + err = deploy.SyncToR2(ctx, r2Cfg, buildDir, result.BuildID, "", testLogger(t)) + if err != nil { + t.Fatalf("first sync failed: %v", err) + } + + // Verify packages.json exists in bucket + rootObj, err := s3Client.GetObject(ctx, &s3.GetObjectInput{ + Bucket: aws.String("test-bucket"), + Key: aws.String("packages.json"), + }) + if err != nil { + t.Fatalf("packages.json not found after sync: %v", err) + } + rootData, _ := io.ReadAll(rootObj.Body) + _ = rootObj.Body.Close() + + var rootJSON map[string]any + if err := json.Unmarshal(rootData, &rootJSON); err != nil { + t.Fatalf("invalid packages.json: %v", err) + } + if rootJSON["build-id"] != result.BuildID { + t.Errorf("root packages.json build-id = %v, want %s", rootJSON["build-id"], result.BuildID) + } + + // Verify p2/ files exist + p2Key := "p2/wp-plugin/akismet.json" + p2Obj, err := s3Client.GetObject(ctx, &s3.GetObjectInput{ + Bucket: aws.String("test-bucket"), + Key: aws.String(p2Key), + }) + if err != nil { + t.Fatalf("p2 file %s not found: %v", p2Key, err) + } + _ = p2Obj.Body.Close() + + // Verify p/ content-addressed files exist + listResp, err := s3Client.ListObjectsV2(ctx, &s3.ListObjectsV2Input{ + Bucket: aws.String("test-bucket"), + Prefix: aws.String("p/"), + }) + if err != nil { + t.Fatalf("listing p/ objects: %v", err) + } + pFileCount := 0 + for _, obj := range listResp.Contents { + key := aws.ToString(obj.Key) + if strings.Contains(key, "$") { + pFileCount++ + } + } + if pFileCount == 0 { + t.Error("no content-addressed p/ files found after sync") + } + + // Verify release-prefixed index files exist + releaseKey := "releases/" + result.BuildID + "/packages.json" + relObj, err := s3Client.GetObject(ctx, &s3.GetObjectInput{ + Bucket: aws.String("test-bucket"), + Key: aws.String(releaseKey), + }) + if err != nil { + t.Fatalf("release packages.json not found: %v", err) + } + _ = relObj.Body.Close() + + // Count total uploaded objects for the idempotency check + allResp, err := s3Client.ListObjectsV2(ctx, &s3.ListObjectsV2Input{ + Bucket: aws.String("test-bucket"), + }) + if err != nil { + t.Fatalf("listing all objects: %v", err) + } + firstSyncCount := len(allResp.Contents) + t.Logf("first sync: %d objects in bucket", firstSyncCount) + + // 5. Second sync — same build, nothing should change + // (pass buildDir as previousBuildDir so unchanged files are skipped) + err = deploy.SyncToR2(ctx, r2Cfg, buildDir, result.BuildID, buildDir, testLogger(t)) + if err != nil { + t.Fatalf("second sync failed: %v", err) + } + + // Object count should be the same (idempotent) + allResp2, err := s3Client.ListObjectsV2(ctx, &s3.ListObjectsV2Input{ + Bucket: aws.String("test-bucket"), + }) + if err != nil { + t.Fatalf("listing all objects after second sync: %v", err) + } + secondSyncCount := len(allResp2.Contents) + if secondSyncCount != firstSyncCount { + t.Errorf("second sync changed object count: %d -> %d", firstSyncCount, secondSyncCount) + } +} + +func newTestS3Client(endpoint string) *s3.Client { + return s3.New(s3.Options{ + Region: "auto", + Credentials: credentials.NewStaticCredentialsProvider( + "test", "test", "", + ), + BaseEndpoint: aws.String(endpoint), + UsePathStyle: true, + }) +} diff --git a/internal/integration/wporg_live_test.go b/internal/integration/wporg_live_test.go new file mode 100644 index 0000000..1eb6601 --- /dev/null +++ b/internal/integration/wporg_live_test.go @@ -0,0 +1,196 @@ +//go:build wporg_live + +package integration + +import ( + "encoding/json" + "log/slog" + "net/http" + "net/http/httptest" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + + "github.com/roots/wp-composer/internal/app" + "github.com/roots/wp-composer/internal/config" + apphttp "github.com/roots/wp-composer/internal/http" + "github.com/roots/wp-composer/internal/packages" + "github.com/roots/wp-composer/internal/packagist" + "github.com/roots/wp-composer/internal/repository" + "github.com/roots/wp-composer/internal/testutil" + "github.com/roots/wp-composer/internal/wporg" +) + +// TestWpOrgLive tests the full pipeline against the real WordPress.org API. +// Run with: go test -tags=wporg_live -count=1 -timeout=5m ./internal/integration/... +func TestWpOrgLive(t *testing.T) { + ctx := t.Context() + db := testutil.OpenTestDB(t) + + cfg := config.DiscoveryConfig{ + APITimeoutS: 30, + MaxRetries: 3, + RetryDelayMs: 1000, + Concurrency: 5, + } + client := wporg.NewClient(cfg, slog.Default()) + + // Discover + update a small set of packages against real wp.org + seeds := []struct { + slug string + pkgType string + }{ + {"akismet", "plugin"}, + {"classic-editor", "plugin"}, + {"astra", "theme"}, + } + + for _, s := range seeds { + lastUpdated, err := client.FetchLastUpdated(ctx, s.pkgType, s.slug) + if err != nil { + t.Fatalf("discover %s: %v", s.slug, err) + } + if err := packages.UpsertShellPackage(ctx, db, s.pkgType, s.slug, lastUpdated); err != nil { + t.Fatalf("upsert shell %s: %v", s.slug, err) + } + } + + syncRun, err := packages.AllocateSyncRunID(ctx, db) + if err != nil { + t.Fatalf("allocate sync run: %v", err) + } + + pkgs, err := packages.GetPackagesNeedingUpdate(ctx, db, packages.UpdateQueryOpts{ + Force: true, + Type: "all", + }) + if err != nil { + t.Fatalf("get packages needing update: %v", err) + } + + for _, p := range pkgs { + var data map[string]any + var fetchErr error + if p.Type == "plugin" { + data, fetchErr = client.FetchPlugin(ctx, p.Name) + } else { + data, fetchErr = client.FetchTheme(ctx, p.Name) + } + if fetchErr != nil { + t.Fatalf("fetch %s/%s: %v", p.Type, p.Name, fetchErr) + } + + pkg := packages.PackageFromAPIData(data, p.Type) + pkg.ID = p.ID + if _, err := pkg.NormalizeAndStoreVersions(); err != nil { + t.Fatalf("normalize %s: %v", p.Name, err) + } + pkg.LastSyncRunID = &syncRun.RunID + + if err := packages.UpsertPackage(ctx, db, pkg); err != nil { + t.Fatalf("upsert %s: %v", p.Name, err) + } + } + + if err := packages.FinishSyncRun(ctx, db, syncRun.RowID, "completed", nil); err != nil { + t.Fatalf("finish sync run: %v", err) + } + + // Build + buildDir := t.TempDir() + buildsDir := filepath.Join(buildDir, "builds") + result, err := repository.Build(ctx, db, repository.BuildOpts{ + OutputDir: buildsDir, + AppURL: "http://test.local", + Force: true, + Logger: testLogger(t), + }) + if err != nil { + t.Fatalf("build: %v", err) + } + if result.PackagesTotal == 0 { + t.Fatal("build produced zero packages") + } + + // Symlink current + if err := os.Symlink(filepath.Join("builds", result.BuildID), filepath.Join(buildDir, "current")); err != nil { + t.Fatalf("symlink: %v", err) + } + + // Serve repository + actualBuildDir := filepath.Join(buildsDir, result.BuildID) + repoServer := httptest.NewServer(http.FileServer(http.Dir(actualBuildDir))) + defer repoServer.Close() + + // Start app server + appCfg := &config.Config{ + AppURL: "http://test.local", + Env: "test", + } + a := &app.App{ + Config: appCfg, + DB: db, + Logger: testLogger(t), + Packagist: packagist.NewStubCache(), + } + router := apphttp.NewRouter(a) + appSrv := httptest.NewServer(router) + defer appSrv.Close() + + // Verify packages.json + t.Run("packages.json", func(t *testing.T) { + body := httpGet(t, repoServer.URL+"/packages.json") + var pkgJSON map[string]any + if err := json.Unmarshal([]byte(body), &pkgJSON); err != nil { + t.Fatalf("invalid packages.json: %v", err) + } + if _, ok := pkgJSON["provider-includes"]; !ok { + t.Error("missing provider-includes") + } + }) + + // Verify p2 endpoint + t.Run("p2 endpoint", func(t *testing.T) { + body := httpGet(t, repoServer.URL+"/p2/wp-plugin/akismet.json") + var data map[string]any + if err := json.Unmarshal([]byte(body), &data); err != nil { + t.Fatalf("invalid p2 response: %v", err) + } + if _, ok := data["packages"]; !ok { + t.Error("missing packages key") + } + }) + + // Verify integrity + t.Run("build integrity", func(t *testing.T) { + errs := repository.ValidateIntegrity(actualBuildDir) + if len(errs) > 0 { + for _, e := range errs { + t.Errorf("integrity error: %s", e) + } + } + }) + + // Composer install + t.Run("composer install", func(t *testing.T) { + composerPath, err := exec.LookPath("composer") + if err != nil { + t.Skip("composer not in PATH") + } + + dir := t.TempDir() + writeComposerJSON(t, dir, repoServer.URL, map[string]string{ + "wp-plugin/akismet": "*", + "wp-plugin/classic-editor": "*", + "wp-theme/astra": "*", + }) + out := runComposer(t, composerPath, dir, "install", "--no-interaction", "--no-progress") + for _, pkg := range []string{"akismet", "classic-editor", "astra"} { + if !strings.Contains(out, pkg) { + t.Errorf("composer output missing %s", pkg) + } + } + }) +} diff --git a/internal/packagist/downloads.go b/internal/packagist/downloads.go index cb7d808..e0ad07e 100644 --- a/internal/packagist/downloads.go +++ b/internal/packagist/downloads.go @@ -23,6 +23,13 @@ func NewDownloadsCache(logger *slog.Logger) *DownloadsCache { return c } +// NewStubCache returns a DownloadsCache that never fetches, for use in tests. +func NewStubCache() *DownloadsCache { + c := &DownloadsCache{logger: slog.Default()} + c.value.Store(0) + return c +} + func (c *DownloadsCache) Total() int64 { return c.value.Load() } diff --git a/internal/testutil/testdb.go b/internal/testutil/testdb.go new file mode 100644 index 0000000..815c9f2 --- /dev/null +++ b/internal/testutil/testdb.go @@ -0,0 +1,113 @@ +package testutil + +import ( + "context" + "database/sql" + "log/slog" + "testing" + "time" + + wpcomposergo "github.com/roots/wp-composer" + "github.com/roots/wp-composer/internal/config" + "github.com/roots/wp-composer/internal/db" + "github.com/roots/wp-composer/internal/packages" + "github.com/roots/wp-composer/internal/wporg" +) + +// OpenTestDB opens an in-memory SQLite database and runs all migrations. +func OpenTestDB(t *testing.T) *sql.DB { + t.Helper() + database, err := db.Open(":memory:") + if err != nil { + t.Fatalf("opening test db: %v", err) + } + t.Cleanup(func() { _ = database.Close() }) + + if err := db.Migrate(database, wpcomposergo.Migrations); err != nil { + t.Fatalf("running migrations: %v", err) + } + return database +} + +// SeedFromFixtures runs the discover + update pipeline against a mock wp.org +// server, populating the database with package data derived from fixtures. +func SeedFromFixtures(t *testing.T, database *sql.DB, mockURL string) { + t.Helper() + ctx := context.Background() + + cfg := config.DiscoveryConfig{ + APITimeoutS: 5, + MaxRetries: 1, + RetryDelayMs: 10, + Concurrency: 2, + } + client := wporg.NewClient(cfg, slog.Default()) + client.SetBaseURL(mockURL) + + // Discover: fetch last_updated for each fixture slug and create shell records + type seed struct { + slug string + pkgType string + } + seeds := []seed{ + {"akismet", "plugin"}, + {"classic-editor", "plugin"}, + {"contact-form-7", "plugin"}, + {"astra", "theme"}, + {"twentytwentyfive", "theme"}, + } + + for _, s := range seeds { + lastUpdated, err := client.FetchLastUpdated(ctx, s.pkgType, s.slug) + if err != nil { + t.Fatalf("fetching last_updated for %s: %v", s.slug, err) + } + if err := packages.UpsertShellPackage(ctx, database, s.pkgType, s.slug, lastUpdated); err != nil { + t.Fatalf("upserting shell package %s: %v", s.slug, err) + } + } + + // Update: fetch full metadata and upsert + syncRun, err := packages.AllocateSyncRunID(ctx, database) + if err != nil { + t.Fatalf("allocating sync run: %v", err) + } + + pkgs, err := packages.GetPackagesNeedingUpdate(ctx, database, packages.UpdateQueryOpts{ + Force: true, + Type: "all", + }) + if err != nil { + t.Fatalf("getting packages needing update: %v", err) + } + + for _, p := range pkgs { + var data map[string]any + var fetchErr error + if p.Type == "plugin" { + data, fetchErr = client.FetchPlugin(ctx, p.Name) + } else { + data, fetchErr = client.FetchTheme(ctx, p.Name) + } + if fetchErr != nil { + t.Fatalf("fetching %s/%s: %v", p.Type, p.Name, fetchErr) + } + + pkg := packages.PackageFromAPIData(data, p.Type) + pkg.ID = p.ID + if _, err := pkg.NormalizeAndStoreVersions(); err != nil { + t.Fatalf("normalizing versions for %s: %v", p.Name, err) + } + now := time.Now().UTC() + pkg.LastSyncedAt = &now + pkg.LastSyncRunID = &syncRun.RunID + + if err := packages.UpsertPackage(ctx, database, pkg); err != nil { + t.Fatalf("upserting package %s: %v", p.Name, err) + } + } + + if err := packages.FinishSyncRun(ctx, database, syncRun.RowID, "completed", map[string]any{"updated": len(pkgs)}); err != nil { + t.Fatalf("finishing sync run: %v", err) + } +} diff --git a/internal/wporg/client.go b/internal/wporg/client.go index 6c02508..772f62b 100644 --- a/internal/wporg/client.go +++ b/internal/wporg/client.go @@ -25,6 +25,7 @@ type Client struct { logger *slog.Logger maxRetries int retryDelay time.Duration + baseURL string // override for testing; defaults to "https://api.wordpress.org" } func NewClient(cfg config.DiscoveryConfig, logger *slog.Logger) *Client { @@ -49,11 +50,17 @@ func NewClient(cfg config.DiscoveryConfig, logger *slog.Logger) *Client { logger: logger, maxRetries: cfg.MaxRetries, retryDelay: time.Duration(cfg.RetryDelayMs) * time.Millisecond, + baseURL: "https://api.wordpress.org", } } +// SetBaseURL overrides the WordPress.org API base URL (for testing). +func (c *Client) SetBaseURL(u string) { + c.baseURL = u +} + func (c *Client) FetchPlugin(ctx context.Context, slug string) (map[string]any, error) { - u := "https://api.wordpress.org/plugins/info/1.2/?action=plugin_information" + + u := c.baseURL + "/plugins/info/1.2/?action=plugin_information" + "&request%5Bslug%5D=" + url.QueryEscape(slug) + "&request%5Bfields%5D%5Bversions%5D=true" + "&request%5Bfields%5D%5Bdescription%5D=false" + @@ -81,7 +88,7 @@ func (c *Client) FetchPlugin(ctx context.Context, slug string) (map[string]any, } func (c *Client) FetchTheme(ctx context.Context, slug string) (map[string]any, error) { - u := "https://api.wordpress.org/themes/info/1.2/?action=theme_information" + + u := c.baseURL + "/themes/info/1.2/?action=theme_information" + "&request%5Bslug%5D=" + url.QueryEscape(slug) + "&request%5Bfields%5D%5Bversions%5D=true" + "&request%5Bfields%5D%5Bactive_installs%5D=true" + @@ -97,14 +104,14 @@ func (c *Client) FetchTheme(ctx context.Context, slug string) (map[string]any, e func (c *Client) FetchLastUpdated(ctx context.Context, pkgType, slug string) (*time.Time, error) { var u string if pkgType == "plugin" { - u = "https://api.wordpress.org/plugins/info/1.2/?action=plugin_information" + + u = c.baseURL + "/plugins/info/1.2/?action=plugin_information" + "&request%5Bslug%5D=" + url.QueryEscape(slug) + "&request%5Bfields%5D%5Blast_updated%5D=true" + "&request%5Bfields%5D%5Bdescription%5D=false" + "&request%5Bfields%5D%5Bsections%5D=false" + "&request%5Bfields%5D%5Bversions%5D=false" } else { - u = "https://api.wordpress.org/themes/info/1.2/?action=theme_information" + + u = c.baseURL + "/themes/info/1.2/?action=theme_information" + "&request%5Bslug%5D=" + url.QueryEscape(slug) + "&request%5Bfields%5D%5Blast_updated%5D=true" + "&request%5Bfields%5D%5Bversions%5D=false" diff --git a/internal/wporg/mock_server.go b/internal/wporg/mock_server.go new file mode 100644 index 0000000..eff6883 --- /dev/null +++ b/internal/wporg/mock_server.go @@ -0,0 +1,92 @@ +package wporg + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" +) + +// NewMockServer returns an httptest.Server that serves fixtures from fixtureDir. +// Routes: +// - GET /plugins/info/1.2/?...&request[slug]=X → testdata/plugins/X.json +// - GET /themes/info/1.2/?...&request[slug]=X → testdata/themes/X.json +// +// Returns 404 for unknown slugs. Handles both full info and last_updated-only requests. +func NewMockServer(fixtureDir string) *httptest.Server { + mux := http.NewServeMux() + + mux.HandleFunc("/plugins/info/1.2/", func(w http.ResponseWriter, r *http.Request) { + serveFixture(w, r, filepath.Join(fixtureDir, "plugins")) + }) + + mux.HandleFunc("/themes/info/1.2/", func(w http.ResponseWriter, r *http.Request) { + serveFixture(w, r, filepath.Join(fixtureDir, "themes")) + }) + + return httptest.NewServer(mux) +} + +func serveFixture(w http.ResponseWriter, r *http.Request, dir string) { + slug := extractSlug(r) + if slug == "" { + http.Error(w, `{"error":"missing slug"}`, http.StatusBadRequest) + return + } + + data, err := os.ReadFile(filepath.Join(dir, slug+".json")) + if err != nil { + w.WriteHeader(http.StatusNotFound) + _, _ = w.Write([]byte(`{"error":"Plugin not found.","slug":"` + slug + `"}`)) + return + } + + // If the request only asks for last_updated, return a minimal response + if isLastUpdatedOnly(r) { + var full map[string]any + if err := json.Unmarshal(data, &full); err == nil { + minimal := map[string]any{ + "last_updated": full["last_updated"], + "slug": full["slug"], + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(minimal) + return + } + } + + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write(data) +} + +func extractSlug(r *http.Request) string { + // Check query string (GET-style URLs used by our client) + q := r.URL.RawQuery + if slug := extractParam(q, "request%5Bslug%5D"); slug != "" { + return slug + } + if slug := extractParam(q, "request[slug]"); slug != "" { + return slug + } + return "" +} + +func extractParam(query, key string) string { + idx := strings.Index(query, key+"=") + if idx < 0 { + return "" + } + val := query[idx+len(key)+1:] + if end := strings.IndexByte(val, '&'); end >= 0 { + val = val[:end] + } + return val +} + +func isLastUpdatedOnly(r *http.Request) bool { + q := r.URL.RawQuery + // Check if versions=false is in the query (indicates a minimal last_updated-only request) + return strings.Contains(q, "versions%5D=false") || strings.Contains(q, "versions]=false") +} diff --git a/internal/wporg/testdata/plugins/akismet.json b/internal/wporg/testdata/plugins/akismet.json new file mode 100644 index 0000000..98c5018 --- /dev/null +++ b/internal/wporg/testdata/plugins/akismet.json @@ -0,0 +1,25 @@ +{ + "name": "Akismet Anti-spam: Spam Protection", + "slug": "akismet", + "version": "5.3.3", + "author": "Automattic - Anti-spam Team", + "requires": "5.8", + "tested": "6.7.2", + "requires_php": "5.6.20", + "download_link": "https://downloads.wordpress.org/plugin/akismet.5.3.3.zip", + "added": "2005-10-25", + "last_updated": "2024-10-03 14:31:00", + "homepage": "https://akismet.com/", + "short_description": "The best anti-spam protection to block spam comments and spam in a contact form.", + "downloaded": 312000000, + "active_installs": 5000000, + "rating": 94, + "num_ratings": 1100, + "versions": { + "5.3": "https://downloads.wordpress.org/plugin/akismet.5.3.zip", + "5.3.1": "https://downloads.wordpress.org/plugin/akismet.5.3.1.zip", + "5.3.2": "https://downloads.wordpress.org/plugin/akismet.5.3.2.zip", + "5.3.3": "https://downloads.wordpress.org/plugin/akismet.5.3.3.zip", + "dev-trunk": "https://downloads.wordpress.org/plugin/akismet.zip" + } +} diff --git a/internal/wporg/testdata/plugins/classic-editor.json b/internal/wporg/testdata/plugins/classic-editor.json new file mode 100644 index 0000000..0bbc48c --- /dev/null +++ b/internal/wporg/testdata/plugins/classic-editor.json @@ -0,0 +1,25 @@ +{ + "name": "Classic Editor", + "slug": "classic-editor", + "version": "1.6.6", + "author": "WordPress Contributors", + "requires": "4.9", + "tested": "6.7.2", + "requires_php": "5.2.4", + "download_link": "https://downloads.wordpress.org/plugin/classic-editor.1.6.6.zip", + "added": "2018-03-08", + "last_updated": "2024-11-11 20:45:00", + "homepage": "https://wordpress.org/plugins/classic-editor/", + "short_description": "Enables the previous \"classic\" editor and the old-style Edit Post screen.", + "downloaded": 68000000, + "active_installs": 5000000, + "rating": 98, + "num_ratings": 1200, + "versions": { + "1.6.3": "https://downloads.wordpress.org/plugin/classic-editor.1.6.3.zip", + "1.6.4": "https://downloads.wordpress.org/plugin/classic-editor.1.6.4.zip", + "1.6.5": "https://downloads.wordpress.org/plugin/classic-editor.1.6.5.zip", + "1.6.6": "https://downloads.wordpress.org/plugin/classic-editor.1.6.6.zip", + "dev-trunk": "https://downloads.wordpress.org/plugin/classic-editor.zip" + } +} diff --git a/internal/wporg/testdata/plugins/contact-form-7.json b/internal/wporg/testdata/plugins/contact-form-7.json new file mode 100644 index 0000000..62a223a --- /dev/null +++ b/internal/wporg/testdata/plugins/contact-form-7.json @@ -0,0 +1,24 @@ +{ + "name": "Contact Form 7", + "slug": "contact-form-7", + "version": "6.0.2", + "author": "Takayuki Miyoshi", + "requires": "6.2", + "tested": "6.7.2", + "requires_php": "7.4", + "download_link": "https://downloads.wordpress.org/plugin/contact-form-7.6.0.2.zip", + "added": "2007-11-11", + "last_updated": "2024-12-14 22:20:00", + "homepage": "https://contactform7.com/", + "short_description": "Just another contact form plugin. Simple but flexible.", + "downloaded": 400000000, + "active_installs": 5000000, + "rating": 88, + "num_ratings": 2200, + "versions": { + "6.0": "https://downloads.wordpress.org/plugin/contact-form-7.6.0.zip", + "6.0.1": "https://downloads.wordpress.org/plugin/contact-form-7.6.0.1.zip", + "6.0.2": "https://downloads.wordpress.org/plugin/contact-form-7.6.0.2.zip", + "dev-trunk": "https://downloads.wordpress.org/plugin/contact-form-7.zip" + } +} diff --git a/internal/wporg/testdata/themes/astra.json b/internal/wporg/testdata/themes/astra.json new file mode 100644 index 0000000..a9d7062 --- /dev/null +++ b/internal/wporg/testdata/themes/astra.json @@ -0,0 +1,20 @@ +{ + "name": "Astra", + "slug": "astra", + "version": "4.8.4", + "author": "Starter Templates", + "homepage": "https://wpastra.com/", + "last_updated": "2024-12-16 10:00:00", + "active_installs": 2000000, + "downloaded": 100000000, + "rating": 98, + "num_ratings": 5800, + "sections": { + "description": "Starter Templates lets you build beautiful WordPress websites with starter templates." + }, + "versions": { + "4.8.2": "https://downloads.wordpress.org/theme/astra.4.8.2.zip", + "4.8.3": "https://downloads.wordpress.org/theme/astra.4.8.3.zip", + "4.8.4": "https://downloads.wordpress.org/theme/astra.4.8.4.zip" + } +} diff --git a/internal/wporg/testdata/themes/twentytwentyfive.json b/internal/wporg/testdata/themes/twentytwentyfive.json new file mode 100644 index 0000000..2981f83 --- /dev/null +++ b/internal/wporg/testdata/themes/twentytwentyfive.json @@ -0,0 +1,19 @@ +{ + "name": "Twenty Twenty-Five", + "slug": "twentytwentyfive", + "version": "1.1", + "author": "WordPress team", + "homepage": "https://wordpress.org/themes/twentytwentyfive/", + "last_updated": "2025-01-15 12:00:00", + "active_installs": 3000000, + "downloaded": 50000000, + "rating": 72, + "num_ratings": 40, + "sections": { + "description": "Twenty Twenty-Five is a versatile theme designed for blogs and websites." + }, + "versions": { + "1.0": "https://downloads.wordpress.org/theme/twentytwentyfive.1.0.zip", + "1.1": "https://downloads.wordpress.org/theme/twentytwentyfive.1.1.zip" + } +} diff --git a/test/capture-fixtures.sh b/test/capture-fixtures.sh new file mode 100755 index 0000000..0392908 --- /dev/null +++ b/test/capture-fixtures.sh @@ -0,0 +1,31 @@ +#!/usr/bin/env bash +# +# Capture WordPress.org API fixtures for integration tests. +# Run once to snapshot, commit the results, re-run to refresh. +# +# Usage: ./test/capture-fixtures.sh +# +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +ROOT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" +FIXTURE_DIR="${ROOT_DIR}/internal/wporg/testdata" + +PLUGINS=(akismet classic-editor contact-form-7) +THEMES=(astra twentytwentyfive) + +mkdir -p "${FIXTURE_DIR}/plugins" "${FIXTURE_DIR}/themes" + +echo "Capturing plugin fixtures..." +for slug in "${PLUGINS[@]}"; do + echo " → ${slug}" + curl -sS "https://api.wordpress.org/plugins/info/1.2/?action=plugin_information&request%5Bslug%5D=${slug}&request%5Bfields%5D%5Bversions%5D=true&request%5Bfields%5D%5Bdescription%5D=false&request%5Bfields%5D%5Bsections%5D=false&request%5Bfields%5D%5Bcompatibility%5D=false&request%5Bfields%5D%5Breviews%5D=false&request%5Bfields%5D%5Bbanners%5D=false&request%5Bfields%5D%5Bicons%5D=false&request%5Bfields%5D%5Bdonate_link%5D=false&request%5Bfields%5D%5Bratings%5D=false&request%5Bfields%5D%5Bcontributors%5D=false&request%5Bfields%5D%5Btags%5D=false&request%5Bfields%5D%5Bactive_installs%5D=true&request%5Bfields%5D%5Brequires%5D=true&request%5Bfields%5D%5Btested%5D=true&request%5Bfields%5D%5Brequires_php%5D=true&request%5Bfields%5D%5Bauthor%5D=true&request%5Bfields%5D%5Bshort_description%5D=true&request%5Bfields%5D%5Bhomepage%5D=true&request%5Bfields%5D%5Blast_updated%5D=true&request%5Bfields%5D%5Badded%5D=true&request%5Bfields%5D%5Bdownload_link%5D=true" | python3 -m json.tool > "${FIXTURE_DIR}/plugins/${slug}.json" +done + +echo "Capturing theme fixtures..." +for slug in "${THEMES[@]}"; do + echo " → ${slug}" + curl -sS "https://api.wordpress.org/themes/info/1.2/?action=theme_information&request%5Bslug%5D=${slug}&request%5Bfields%5D%5Bversions%5D=true&request%5Bfields%5D%5Bactive_installs%5D=true&request%5Bfields%5D%5Bsections%5D=true&request%5Bfields%5D%5Bauthor%5D=true&request%5Bfields%5D%5Bhomepage%5D=true&request%5Bfields%5D%5Blast_updated%5D=true" | python3 -m json.tool > "${FIXTURE_DIR}/themes/${slug}.json" +done + +echo "Done. Fixtures written to ${FIXTURE_DIR}" diff --git a/test/smoke_test.sh b/test/smoke_test.sh deleted file mode 100755 index 859adb0..0000000 --- a/test/smoke_test.sh +++ /dev/null @@ -1,478 +0,0 @@ -#!/usr/bin/env bash -# -# End-to-end smoke test for WP Composer -# -# Boots the app, builds repository artifacts, then uses Composer to -# install real WordPress plugins/themes from the local repository. -# Verifies telemetry events are recorded via notify-batch. -# -# Prerequisites: go, composer, curl, sqlite3, jq -# -set -euo pipefail - -SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" -ROOT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" -TEST_DIR=$(mktemp -d) -APP_PORT=19876 -APP_URL="http://localhost:${APP_PORT}" -DB_PATH="${TEST_DIR}/wpcomposer.db" -BINARY="${ROOT_DIR}/wpcomposer" -PASSED=0 -FAILED=0 - -cleanup() { - if [ -n "${APP_PID:-}" ] && kill -0 "$APP_PID" 2>/dev/null; then - kill "$APP_PID" 2>/dev/null || true - wait "$APP_PID" 2>/dev/null || true - fi - rm -rf "$TEST_DIR" - rm -rf "${ROOT_DIR}/storage" - - echo "" - echo "================================" - echo "Results: ${PASSED} passed, ${FAILED} failed" - echo "================================" - - if [ "$FAILED" -gt 0 ]; then - exit 1 - fi -} -trap cleanup EXIT - -pass() { - echo " ✓ $1" - PASSED=$((PASSED + 1)) -} - -fail() { - echo " ✗ $1" - FAILED=$((FAILED + 1)) -} - -assert_eq() { - if [ "$1" = "$2" ]; then - pass "$3" - else - fail "$3 (expected '$2', got '$1')" - fi -} - -assert_contains() { - if echo "$1" | grep -q "$2"; then - pass "$3" - else - fail "$3 (expected to contain '$2')" - fi -} - -assert_gt() { - if [ "$1" -gt "$2" ]; then - pass "$3" - else - fail "$3 (expected > $2, got $1)" - fi -} - -echo "=== Building binary ===" -cd "$ROOT_DIR" -go build -o "$BINARY" ./cmd/wpcomposer - -echo "" -echo "=== Setting up database and repository ===" -"$BINARY" migrate --db "$DB_PATH" > /dev/null 2>&1 -"$BINARY" discover --source=config --type=plugin --limit=5 --db "$DB_PATH" > /dev/null 2>&1 -"$BINARY" discover --source=config --type=theme --limit=2 --db "$DB_PATH" > /dev/null 2>&1 -"$BINARY" update --force --db "$DB_PATH" > /dev/null 2>&1 -APP_URL="$APP_URL" "$BINARY" build --db "$DB_PATH" > /dev/null 2>&1 -"$BINARY" deploy --db "$DB_PATH" > /dev/null 2>&1 - -echo "" -echo "=== Starting server ===" -"$BINARY" serve --db "$DB_PATH" --addr ":${APP_PORT}" > /dev/null 2>&1 & -APP_PID=$! -sleep 2 - -if ! kill -0 "$APP_PID" 2>/dev/null; then - echo "Server failed to start" - exit 1 -fi - -# ─── Health check ─────────────────────────────────────────────────── - -echo "" -echo "--- Health & endpoints ---" - -HEALTH=$(curl -sf "${APP_URL}/health") -assert_contains "$HEALTH" '"status":"ok"' "GET /health returns ok" - -INDEX_STATUS=$(curl -sf -o /dev/null -w "%{http_code}" "${APP_URL}/") -assert_eq "$INDEX_STATUS" "200" "GET / returns 200" - -# ─── packages.json validation ────────────────────────────────────── - -echo "" -echo "--- packages.json ---" - -PACKAGES_JSON=$(curl -sf "${APP_URL}/packages.json") -NOTIFY_BATCH=$(echo "$PACKAGES_JSON" | python3 -c "import sys,json; print(json.load(sys.stdin).get('notify-batch',''))") -assert_eq "$NOTIFY_BATCH" "${APP_URL}/downloads" "notify-batch is absolute URL" - -PROVIDERS_URL=$(echo "$PACKAGES_JSON" | python3 -c "import sys,json; print(json.load(sys.stdin).get('providers-url',''))") -assert_eq "$PROVIDERS_URL" "/p/%package%\$%hash%.json" "providers-url is set" - -METADATA_URL=$(echo "$PACKAGES_JSON" | python3 -c "import sys,json; print(json.load(sys.stdin).get('metadata-url',''))") -assert_eq "$METADATA_URL" "/p2/%package%.json" "metadata-url is set" - -PROVIDER_COUNT=$(echo "$PACKAGES_JSON" | python3 -c "import sys,json; print(len(json.load(sys.stdin).get('provider-includes',{})))") -assert_gt "$PROVIDER_COUNT" "0" "provider-includes has entries" - -# ─── Composer install ────────────────────────────────────────────── - -echo "" -echo "--- Composer install ---" - -COMPOSER_DIR="${TEST_DIR}/composer-project" -mkdir -p "$COMPOSER_DIR" - -cat > "${COMPOSER_DIR}/composer.json" << COMPOSERJSON -{ - "repositories": [ - { - "type": "composer", - "url": "${APP_URL}", - "only": ["wp-plugin/*", "wp-theme/*"] - } - ], - "require": { - "composer/installers": "^2.2", - "wp-plugin/akismet": "*", - "wp-plugin/classic-editor": "*", - "wp-theme/astra": "*" - }, - "config": { - "secure-http": false, - "allow-plugins": { - "composer/installers": true - } - }, - "minimum-stability": "dev", - "prefer-stable": true -} -COMPOSERJSON - -cd "$COMPOSER_DIR" -COMPOSER_OUTPUT=$(composer install --no-interaction --no-progress 2>&1) || true - -# Check packages were installed (composer/installers puts them in vendor/ by default -# since we don't have the web/app directory structure) -if echo "$COMPOSER_OUTPUT" | grep -q "Installing wp-plugin/akismet"; then - pass "wp-plugin/akismet installed" -else - fail "wp-plugin/akismet not installed" - echo " Composer output:" - echo "$COMPOSER_OUTPUT" | head -20 | sed 's/^/ /' -fi - -if echo "$COMPOSER_OUTPUT" | grep -q "Installing wp-plugin/classic-editor"; then - pass "wp-plugin/classic-editor installed" -else - fail "wp-plugin/classic-editor not installed" -fi - -if echo "$COMPOSER_OUTPUT" | grep -q "Installing wp-theme/astra"; then - pass "wp-theme/astra installed" -else - fail "wp-theme/astra not installed" -fi - -# ─── Telemetry verification ──────────────────────────────────────── - -echo "" -echo "--- Telemetry ---" - -# Give the notify-batch POST a moment to complete -sleep 2 - -EVENT_COUNT=$(sqlite3 "$DB_PATH" "SELECT COUNT(*) FROM install_events;") -assert_gt "$EVENT_COUNT" "0" "install_events has records after composer install" - -# Check specific packages got events -AKISMET_EVENTS=$(sqlite3 "$DB_PATH" "SELECT COUNT(*) FROM install_events ie JOIN packages p ON p.id = ie.package_id WHERE p.name = 'akismet';") -assert_gt "$AKISMET_EVENTS" "0" "akismet has install events" - -# Aggregate and verify counters -cd "$ROOT_DIR" -"$BINARY" aggregate-installs --db "$DB_PATH" > /dev/null 2>&1 - -AKISMET_TOTAL=$(sqlite3 "$DB_PATH" "SELECT wp_composer_installs_total FROM packages WHERE name = 'akismet';") -assert_gt "$AKISMET_TOTAL" "0" "akismet wp_composer_installs_total > 0 after aggregation" - -AKISMET_30D=$(sqlite3 "$DB_PATH" "SELECT wp_composer_installs_30d FROM packages WHERE name = 'akismet';") -assert_gt "$AKISMET_30D" "0" "akismet wp_composer_installs_30d > 0 after aggregation" - -AKISMET_LAST=$(sqlite3 "$DB_PATH" "SELECT last_installed_at FROM packages WHERE name = 'akismet';") -if [ -n "$AKISMET_LAST" ] && [ "$AKISMET_LAST" != "" ]; then - pass "akismet last_installed_at is set" -else - fail "akismet last_installed_at is not set" -fi - -# ─── Admin access ────────────────────────────────────────────────── - -echo "" -echo "--- Admin access ---" - -# Login page should be accessible -LOGIN_STATUS=$(curl -sf -o /dev/null -w "%{http_code}" "${APP_URL}/admin/login") -assert_eq "$LOGIN_STATUS" "200" "GET /admin/login returns 200" - -# Admin dashboard should redirect to login (no session) -ADMIN_STATUS=$(curl -s -o /dev/null -w "%{http_code}" "${APP_URL}/admin/") -assert_eq "$ADMIN_STATUS" "303" "GET /admin/ without auth redirects (303)" - -# ─── Dedupe verification ────────────────────────────────────────── - -echo "" -echo "--- Dedupe ---" - -# POST the same install twice — second should be deduplicated -curl -sf -X POST "${APP_URL}/downloads" \ - -H 'Content-Type: application/json' \ - -d '{"downloads":[{"name":"wp-plugin/akismet","version":"5.6"}]}' > /dev/null -sleep 1 -AFTER_FIRST=$(sqlite3 "$DB_PATH" "SELECT COUNT(*) FROM install_events;") - -curl -sf -X POST "${APP_URL}/downloads" \ - -H 'Content-Type: application/json' \ - -d '{"downloads":[{"name":"wp-plugin/akismet","version":"5.6"}]}' > /dev/null -sleep 1 -AFTER_SECOND=$(sqlite3 "$DB_PATH" "SELECT COUNT(*) FROM install_events;") - -assert_eq "$AFTER_SECOND" "$AFTER_FIRST" "duplicate notify-batch is deduplicated" - -# ─── Version pinning ───────────────────────────────────────────── - -echo "" -echo "--- Version pinning ---" - -PINNED_DIR="${TEST_DIR}/composer-pinned" -mkdir -p "$PINNED_DIR" - -cat > "${PINNED_DIR}/composer.json" << COMPOSERJSON -{ - "repositories": [ - { - "type": "composer", - "url": "${APP_URL}", - "only": ["wp-plugin/*", "wp-theme/*"] - } - ], - "require": { - "composer/installers": "^2.2", - "wp-plugin/akismet": "5.3.3", - "wp-plugin/classic-editor": "1.6.6" - }, - "config": { - "secure-http": false, - "allow-plugins": { - "composer/installers": true - } - }, - "minimum-stability": "dev", - "prefer-stable": true -} -COMPOSERJSON - -cd "$PINNED_DIR" -PINNED_OUTPUT=$(composer install --no-interaction --no-progress 2>&1) || true - -if echo "$PINNED_OUTPUT" | grep -q "Locking wp-plugin/akismet (5.3.3)"; then - pass "wp-plugin/akismet pinned to 5.3.3" -else - fail "wp-plugin/akismet version pin failed" - echo " Output:" && echo "$PINNED_OUTPUT" | grep -i "akismet\|error\|lock" | head -5 | sed 's/^/ /' -fi - -if echo "$PINNED_OUTPUT" | grep -q "Locking wp-plugin/classic-editor (1.6.6)"; then - pass "wp-plugin/classic-editor pinned to 1.6.6" -else - fail "wp-plugin/classic-editor version pin failed" - echo " Output:" && echo "$PINNED_OUTPUT" | grep -i "classic\|error\|lock" | head -5 | sed 's/^/ /' -fi - -# ─── Package detail pages ──────────────────────────────────────── - -echo "" -echo "--- Package pages ---" - -DETAIL_STATUS=$(curl -sf -o /dev/null -w "%{http_code}" "${APP_URL}/packages/wp-plugin/akismet") -assert_eq "$DETAIL_STATUS" "200" "GET /packages/wp-plugin/akismet returns 200" - -DETAIL_BODY=$(curl -sf "${APP_URL}/packages/wp-plugin/akismet") -assert_contains "$DETAIL_BODY" "composer require wp-plugin/akismet" "detail page has install command" -assert_contains "$DETAIL_BODY" "Package Info" "detail page has Package Info sidebar" - -MISSING_STATUS=$(curl -s -o /dev/null -w "%{http_code}" "${APP_URL}/packages/wp-plugin/nonexistent-plugin-xyz") -assert_eq "$MISSING_STATUS" "404" "GET /packages/wp-plugin/nonexistent returns 404" - -# ─── Admin auth flow ───────────────────────────────────────────── - -echo "" -echo "--- Admin auth flow ---" - -cd "$ROOT_DIR" - -# Create test admin user -echo 'smoke-test-pass' | "$BINARY" admin create \ - --email smoke@test.com --name "Smoke Test" --password-stdin \ - --db "$DB_PATH" > /dev/null 2>&1 - -# Login with correct credentials -LOGIN_RESPONSE=$(curl -s -D - -o /dev/null -X POST "${APP_URL}/admin/login" \ - -d "email=smoke@test.com&password=smoke-test-pass" \ - -H "Content-Type: application/x-www-form-urlencoded" 2>&1) - -if echo "$LOGIN_RESPONSE" | grep -q "Set-Cookie: session="; then - pass "login sets session cookie" - SESSION_COOKIE=$(echo "$LOGIN_RESPONSE" | grep "Set-Cookie: session=" | sed 's/.*session=//;s/;.*//') -else - fail "login did not set session cookie" - SESSION_COOKIE="" -fi - -if echo "$LOGIN_RESPONSE" | grep -q "Location: /admin"; then - pass "login redirects to /admin" -else - fail "login did not redirect to /admin" -fi - -# Access admin with session cookie -if [ -n "$SESSION_COOKIE" ]; then - AUTHED_STATUS=$(curl -sf -o /dev/null -w "%{http_code}" \ - -b "session=${SESSION_COOKIE}" "${APP_URL}/admin/") - assert_eq "$AUTHED_STATUS" "200" "GET /admin/ with session returns 200" - - AUTHED_PACKAGES=$(curl -sf -o /dev/null -w "%{http_code}" \ - -b "session=${SESSION_COOKIE}" "${APP_URL}/admin/packages") - assert_eq "$AUTHED_PACKAGES" "200" "GET /admin/packages with session returns 200" - - AUTHED_BUILDS=$(curl -sf -o /dev/null -w "%{http_code}" \ - -b "session=${SESSION_COOKIE}" "${APP_URL}/admin/builds") - assert_eq "$AUTHED_BUILDS" "200" "GET /admin/builds with session returns 200" -fi - -# Login with wrong password -BAD_LOGIN=$(curl -s -o /dev/null -w "%{http_code}" -X POST "${APP_URL}/admin/login" \ - -d "email=smoke@test.com&password=wrong" \ - -H "Content-Type: application/x-www-form-urlencoded") -assert_eq "$BAD_LOGIN" "303" "login with wrong password redirects (303)" - -# Logout -if [ -n "$SESSION_COOKIE" ]; then - LOGOUT_RESPONSE=$(curl -s -D - -o /dev/null -X POST "${APP_URL}/admin/logout" \ - -b "session=${SESSION_COOKIE}" 2>&1) - if echo "$LOGOUT_RESPONSE" | grep -q "session=;"; then - pass "logout clears session cookie" - elif echo "$LOGOUT_RESPONSE" | grep -q "Max-Age=-1\|Max-Age=0"; then - pass "logout clears session cookie" - else - # Check if session is invalidated by trying to access admin - POST_LOGOUT=$(curl -s -o /dev/null -w "%{http_code}" \ - -b "session=${SESSION_COOKIE}" "${APP_URL}/admin/") - assert_eq "$POST_LOGOUT" "303" "admin inaccessible after logout" - fi -fi - -# ─── Golden fixture: p2 metadata structure ─────────────────────── - -echo "" -echo "--- Golden fixtures ---" - -P2_AKISMET=$(curl -sf "${APP_URL}/p2/wp-plugin/akismet.json") - -# Verify structure has packages key with wp-plugin/akismet -HAS_PACKAGES=$(echo "$P2_AKISMET" | python3 -c " -import sys, json -d = json.load(sys.stdin) -pkgs = d.get('packages', {}) -akismet = pkgs.get('wp-plugin/akismet', {}) -print(len(akismet)) -" 2>/dev/null || echo "0") -assert_gt "$HAS_PACKAGES" "0" "p2/wp-plugin/akismet.json has version entries" - -# Verify a version entry has required Composer fields -HAS_FIELDS=$(echo "$P2_AKISMET" | python3 -c " -import sys, json -d = json.load(sys.stdin) -versions = d.get('packages', {}).get('wp-plugin/akismet', {}) -v = next(iter(versions.values()), {}) -fields = ['name', 'version', 'type', 'dist', 'source', 'require'] -print(sum(1 for f in fields if f in v)) -" 2>/dev/null || echo "0") -assert_eq "$HAS_FIELDS" "6" "version entry has all required Composer fields (name, version, type, dist, source, require)" - -# Verify dist URL points to wordpress.org -DIST_URL=$(echo "$P2_AKISMET" | python3 -c " -import sys, json -d = json.load(sys.stdin) -versions = d.get('packages', {}).get('wp-plugin/akismet', {}) -v = next(iter(versions.values()), {}) -print(v.get('dist', {}).get('url', '')) -" 2>/dev/null || echo "") -assert_contains "$DIST_URL" "downloads.wordpress.org" "dist URL points to wordpress.org" - -# Verify type is wordpress-plugin -PKG_TYPE=$(echo "$P2_AKISMET" | python3 -c " -import sys, json -d = json.load(sys.stdin) -versions = d.get('packages', {}).get('wp-plugin/akismet', {}) -v = next(iter(versions.values()), {}) -print(v.get('type', '')) -" 2>/dev/null || echo "") -assert_eq "$PKG_TYPE" "wordpress-plugin" "package type is wordpress-plugin" - -# ─── Build integrity ───────────────────────────────────────────── - -echo "" -echo "--- Build integrity ---" - -MANIFEST=$(cat storage/repository/current/manifest.json) -ROOT_HASH=$(echo "$MANIFEST" | python3 -c "import sys,json; print(json.load(sys.stdin).get('root_hash',''))") -if [ -n "$ROOT_HASH" ] && [ "$ROOT_HASH" != "" ]; then - pass "manifest.json has root_hash" -else - fail "manifest.json missing root_hash" -fi - -ARTIFACT_COUNT=$(echo "$MANIFEST" | python3 -c "import sys,json; print(int(json.load(sys.stdin).get('artifact_count',0)))") -assert_gt "$ARTIFACT_COUNT" "0" "manifest reports artifact_count > 0" - -# ─── Rollback ──────────────────────────────────────────────────── - -echo "" -echo "--- Rollback ---" - -# Build again to get a second build -sleep 1 -APP_URL="$APP_URL" "$BINARY" build --db "$DB_PATH" > /dev/null 2>&1 -"$BINARY" deploy --db "$DB_PATH" > /dev/null 2>&1 - -BUILD_COUNT=$(ls storage/repository/builds/ | wc -l | tr -d ' ') -assert_gt "$BUILD_COUNT" "1" "multiple builds exist" - -CURRENT_BEFORE=$(readlink storage/repository/current | xargs basename) -"$BINARY" deploy --rollback --db "$DB_PATH" > /dev/null 2>&1 -CURRENT_AFTER=$(readlink storage/repository/current | xargs basename) - -if [ "$CURRENT_BEFORE" != "$CURRENT_AFTER" ]; then - pass "rollback changed current build" -else - fail "rollback did not change current build" -fi - -# Verify rolled-back build still serves valid packages.json -ROLLBACK_PJ=$(curl -sf "${APP_URL}/packages.json" | python3 -c "import sys,json; print(json.load(sys.stdin).get('notify-batch',''))" 2>/dev/null || echo "") -assert_eq "$ROLLBACK_PJ" "${APP_URL}/downloads" "packages.json valid after rollback" - -echo "" -echo "=== Smoke test complete ==="