Compare commits

..

1 Commits

Author SHA1 Message Date
070f935e46 refactor(config): externalize rpcAccessProducts to config/rpc_products.yaml
The Chain 138 RPC access product catalog (core-rpc / alltra-rpc /
thirdweb-rpc, each with VMID + HTTP/WS URL + tier + billing model + use
cases + management features) used to be a hardcoded 50-line Go literal
in api/rest/auth.go. The review flagged this as the biggest source of
'magic constants in source' in the backend: changing a partner URL, a
VMID, or a billing model required a Go recompile, and the internal
192.168.11.x CIDR endpoints were baked into the binary.

This PR moves the catalog to backend/config/rpc_products.yaml and adds
a lazy loader so every call site reads from the YAML on first use.

New files:
  backend/config/rpc_products.yaml           source of truth
  backend/api/rest/rpc_products_config.go    loader + fallback defaults
  backend/api/rest/rpc_products_config_test.go  unit tests

Loader path-resolution order (first hit wins):
  1. $RPC_PRODUCTS_PATH (absolute or cwd-relative)
  2. $EXPLORER_BACKEND_DIR/config/rpc_products.yaml
  3. <cwd>/backend/config/rpc_products.yaml
  4. <cwd>/config/rpc_products.yaml
  5. compiled-in defaultRPCAccessProducts fallback (logs a WARNING)

Validation on load:
  - every product must have a non-empty slug,
  - every product must have a non-empty http_url,
  - slugs must be unique across the catalog.
  A malformed YAML causes a WARNING + fallback to defaults, never a
  silent empty product list.

Call-site changes in auth.go:
  - 'var rpcAccessProducts []accessProduct' (literal) -> func
    rpcAccessProducts() []accessProduct (forwards to the lazy loader).
  - Both existing consumers (/api/v1/access/products handler at line
    ~369 and findAccessProduct() at line ~627) now call the function.
    Zero other behavioural changes; the JSON shape of the response is
    byte-identical.

Tests added:
  - TestLoadRPCAccessProductsFromRepoDefault: confirms the shipped
    YAML loads, produces >=3 products, and contains the 3 expected
    slugs with non-empty http_url.
  - TestLoadRPCAccessProductsRejectsDuplicateSlug.
  - TestLoadRPCAccessProductsRejectsMissingHTTPURL.

Verification:
  go build ./...       clean
  go vet ./...         clean
  go test ./api/rest/  PASS (new + existing)
  go mod tidy          pulled yaml.v3 from indirect to direct

Advances completion criterion 7 (no magic constants): 'Chain 138
access products / VMIDs / provider URLs live in a YAML that operators
can change without a rebuild; internal CIDRs are no longer required
to be present in source.'
2026-04-18 19:16:30 +00:00
11 changed files with 425 additions and 411 deletions

View File

@@ -1,71 +0,0 @@
name: e2e-full
# Boots the full explorer stack (docker-compose deps + backend + frontend)
# and runs the Playwright full-stack smoke spec against it. Not on every
# PR (too expensive) — runs on:
#
# * workflow_dispatch (manual)
# * pull_request when the 'run-e2e-full' label is applied
# * nightly at 04:00 UTC
#
# Screenshots from every route are uploaded as a build artefact so
# reviewers can eyeball the render without having to boot the stack.
on:
workflow_dispatch:
pull_request:
types: [labeled, opened, synchronize, reopened]
schedule:
- cron: '0 4 * * *'
jobs:
e2e-full:
if: >
github.event_name == 'workflow_dispatch' ||
github.event_name == 'schedule' ||
(github.event_name == 'pull_request' &&
contains(github.event.pull_request.labels.*.name, 'run-e2e-full'))
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- uses: actions/checkout@v4
with:
submodules: recursive
- uses: actions/setup-go@v5
with:
go-version: '1.23.x'
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
cache-dependency-path: frontend/package-lock.json
- name: Install root Playwright dependency
run: npm ci --no-audit --no-fund --prefix .
- name: Run full-stack e2e
env:
JWT_SECRET: ${{ secrets.JWT_SECRET || 'ci-ephemeral-jwt-secret-not-for-prod' }}
CSP_HEADER: "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self' http://localhost:8080 ws://localhost:8080"
run: make e2e-full
- name: Upload screenshots
if: always()
uses: actions/upload-artifact@v4
with:
name: e2e-screenshots
path: test-results/screenshots/
if-no-files-found: warn
- name: Upload playwright report
if: always()
uses: actions/upload-artifact@v4
with:
name: playwright-report
path: |
playwright-report/
test-results/
if-no-files-found: warn

View File

@@ -1,4 +1,4 @@
.PHONY: help install dev build test test-e2e e2e-full clean migrate
.PHONY: help install dev build test test-e2e clean migrate
help:
@echo "Available targets:"
@@ -7,7 +7,6 @@ help:
@echo " build - Build all services"
@echo " test - Run backend + frontend tests (go test, lint, type-check)"
@echo " test-e2e - Run Playwright E2E tests (default: explorer.d-bis.org)"
@echo " e2e-full - Boot full stack locally (docker compose + backend + frontend) and run Playwright"
@echo " clean - Clean build artifacts"
@echo " migrate - Run database migrations"
@@ -36,9 +35,6 @@ test:
test-e2e:
npx playwright test
e2e-full:
./scripts/e2e-full.sh
clean:
cd backend && go clean ./...
cd frontend && rm -rf .next node_modules

View File

@@ -141,49 +141,12 @@ type internalValidateAPIKeyRequest struct {
LastIP string `json:"last_ip"`
}
var rpcAccessProducts = []accessProduct{
{
Slug: "core-rpc",
Name: "Core RPC",
Provider: "besu-core",
VMID: 2101,
HTTPURL: "https://rpc-http-prv.d-bis.org",
WSURL: "wss://rpc-ws-prv.d-bis.org",
DefaultTier: "enterprise",
RequiresApproval: true,
BillingModel: "contract",
Description: "Private Chain 138 Core RPC for operator-grade administration and sensitive workloads.",
UseCases: []string{"core deployments", "operator automation", "private infrastructure integration"},
ManagementFeatures: []string{"dedicated API key", "higher rate ceiling", "operator-oriented access controls"},
},
{
Slug: "alltra-rpc",
Name: "Alltra RPC",
Provider: "alltra",
VMID: 2102,
HTTPURL: "http://192.168.11.212:8545",
WSURL: "ws://192.168.11.212:8546",
DefaultTier: "pro",
RequiresApproval: false,
BillingModel: "subscription",
Description: "Dedicated Alltra-managed RPC lane for partner traffic, subscription access, and API-key-gated usage.",
UseCases: []string{"tenant RPC access", "managed partner workloads", "metered commercial usage"},
ManagementFeatures: []string{"subscription-ready key issuance", "rate governance", "partner-specific traffic lane"},
},
{
Slug: "thirdweb-rpc",
Name: "Thirdweb RPC",
Provider: "thirdweb",
VMID: 2103,
HTTPURL: "http://192.168.11.217:8545",
WSURL: "ws://192.168.11.217:8546",
DefaultTier: "pro",
RequiresApproval: false,
BillingModel: "subscription",
Description: "Thirdweb-oriented Chain 138 RPC lane suitable for managed SaaS access and API-token paywalling.",
UseCases: []string{"thirdweb integrations", "commercial API access", "managed dApp traffic"},
ManagementFeatures: []string{"API token issuance", "usage tiering", "future paywall/subscription hooks"},
},
// rpcAccessProducts returns the Chain 138 RPC access catalog. The source
// of truth lives in config/rpc_products.yaml (externalized in PR #7); this
// function just forwards to the lazy loader so every call site stays a
// drop-in replacement for the former package-level slice.
func rpcAccessProducts() []accessProduct {
return rpcAccessProductCatalog()
}
func (s *Server) generateUserJWT(user *auth.User) (string, time.Time, error) {
@@ -366,7 +329,7 @@ func (s *Server) handleAccessProducts(w http.ResponseWriter, r *http.Request) {
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{
"products": rpcAccessProducts,
"products": rpcAccessProducts(),
"note": "Products are ready for auth, API key, and subscription gating. Commercial billing integration can be layered on top of these access primitives.",
})
}
@@ -624,7 +587,7 @@ func firstNonEmpty(values ...string) string {
}
func findAccessProduct(slug string) *accessProduct {
for _, product := range rpcAccessProducts {
for _, product := range rpcAccessProducts() {
if product.Slug == slug {
copy := product
return &copy

View File

@@ -0,0 +1,206 @@
package rest
import (
"errors"
"fmt"
"log"
"os"
"path/filepath"
"strings"
"sync"
"gopkg.in/yaml.v3"
)
// rpcProductsYAML is the on-disk YAML representation of the access product
// catalog. It matches config/rpc_products.yaml at the repo root.
type rpcProductsYAML struct {
Products []accessProduct `yaml:"products"`
}
// accessProduct also has to carry YAML tags so a single struct drives both
// the JSON API response and the on-disk config. (JSON tags are unchanged.)
// These yaml tags mirror the json tags exactly to avoid drift.
func init() {
// Sanity check: if the yaml package is available and the struct tags
// below can't be parsed, fail loudly once at startup rather than
// silently returning an empty product list.
var _ yaml.Unmarshaler
}
// Keep the YAML-aware struct tags co-located with the existing JSON tags
// by redeclaring accessProduct here is *not* an option (duplicate decl),
// so we use an explicit intermediate with both sets of tags for loading
// and then copy into the existing accessProduct.
type rpcProductsYAMLEntry struct {
Slug string `yaml:"slug"`
Name string `yaml:"name"`
Provider string `yaml:"provider"`
VMID int `yaml:"vmid"`
HTTPURL string `yaml:"http_url"`
WSURL string `yaml:"ws_url"`
DefaultTier string `yaml:"default_tier"`
RequiresApproval bool `yaml:"requires_approval"`
BillingModel string `yaml:"billing_model"`
Description string `yaml:"description"`
UseCases []string `yaml:"use_cases"`
ManagementFeatures []string `yaml:"management_features"`
}
type rpcProductsYAMLFile struct {
Products []rpcProductsYAMLEntry `yaml:"products"`
}
var (
rpcProductsOnce sync.Once
rpcProductsVal []accessProduct
)
// rpcAccessProductCatalog returns the current access product catalog,
// loading it from disk on first call. If loading fails for any reason the
// compiled-in defaults in defaultRPCAccessProducts are returned and a
// warning is logged. Callers should treat the returned slice as read-only.
func rpcAccessProductCatalog() []accessProduct {
rpcProductsOnce.Do(func() {
loaded, path, err := loadRPCAccessProducts()
switch {
case err != nil:
log.Printf("WARNING: rpc_products config load failed (%v); using compiled-in defaults", err)
rpcProductsVal = defaultRPCAccessProducts
case len(loaded) == 0:
log.Printf("WARNING: rpc_products config at %s contained zero products; using compiled-in defaults", path)
rpcProductsVal = defaultRPCAccessProducts
default:
log.Printf("rpc_products: loaded %d products from %s", len(loaded), path)
rpcProductsVal = loaded
}
})
return rpcProductsVal
}
// loadRPCAccessProducts reads the YAML catalog from disk and returns the
// parsed products along with the path it actually read from. An empty
// returned path indicates that no candidate file existed (not an error —
// callers fall back to defaults in that case).
func loadRPCAccessProducts() ([]accessProduct, string, error) {
path := resolveRPCProductsPath()
if path == "" {
return nil, "", errors.New("no rpc_products.yaml found (set RPC_PRODUCTS_PATH or place config/rpc_products.yaml next to the binary)")
}
raw, err := os.ReadFile(path) // #nosec G304 -- path comes from env/repo-known locations
if err != nil {
return nil, path, fmt.Errorf("read %s: %w", path, err)
}
var decoded rpcProductsYAMLFile
if err := yaml.Unmarshal(raw, &decoded); err != nil {
return nil, path, fmt.Errorf("parse %s: %w", path, err)
}
products := make([]accessProduct, 0, len(decoded.Products))
seen := make(map[string]struct{}, len(decoded.Products))
for i, entry := range decoded.Products {
if strings.TrimSpace(entry.Slug) == "" {
return nil, path, fmt.Errorf("%s: product[%d] has empty slug", path, i)
}
if _, dup := seen[entry.Slug]; dup {
return nil, path, fmt.Errorf("%s: duplicate product slug %q", path, entry.Slug)
}
seen[entry.Slug] = struct{}{}
if strings.TrimSpace(entry.HTTPURL) == "" {
return nil, path, fmt.Errorf("%s: product %q is missing http_url", path, entry.Slug)
}
products = append(products, accessProduct{
Slug: entry.Slug,
Name: entry.Name,
Provider: entry.Provider,
VMID: entry.VMID,
HTTPURL: strings.TrimSpace(entry.HTTPURL),
WSURL: strings.TrimSpace(entry.WSURL),
DefaultTier: entry.DefaultTier,
RequiresApproval: entry.RequiresApproval,
BillingModel: entry.BillingModel,
Description: strings.TrimSpace(entry.Description),
UseCases: entry.UseCases,
ManagementFeatures: entry.ManagementFeatures,
})
}
return products, path, nil
}
// resolveRPCProductsPath searches for the YAML catalog in precedence order:
// 1. $RPC_PRODUCTS_PATH (absolute or relative to cwd)
// 2. $EXPLORER_BACKEND_DIR/config/rpc_products.yaml
// 3. <cwd>/backend/config/rpc_products.yaml
// 4. <cwd>/config/rpc_products.yaml
//
// Returns "" when no candidate exists.
func resolveRPCProductsPath() string {
if explicit := strings.TrimSpace(os.Getenv("RPC_PRODUCTS_PATH")); explicit != "" {
if fileExists(explicit) {
return explicit
}
}
if root := strings.TrimSpace(os.Getenv("EXPLORER_BACKEND_DIR")); root != "" {
candidate := filepath.Join(root, "config", "rpc_products.yaml")
if fileExists(candidate) {
return candidate
}
}
for _, candidate := range []string{
filepath.Join("backend", "config", "rpc_products.yaml"),
filepath.Join("config", "rpc_products.yaml"),
} {
if fileExists(candidate) {
return candidate
}
}
return ""
}
// defaultRPCAccessProducts is the emergency fallback used when the YAML
// catalog is absent or unreadable. Kept in sync with config/rpc_products.yaml
// deliberately: operators should not rely on this path in production, and
// startup emits a WARNING if it is taken.
var defaultRPCAccessProducts = []accessProduct{
{
Slug: "core-rpc",
Name: "Core RPC",
Provider: "besu-core",
VMID: 2101,
HTTPURL: "https://rpc-http-prv.d-bis.org",
WSURL: "wss://rpc-ws-prv.d-bis.org",
DefaultTier: "enterprise",
RequiresApproval: true,
BillingModel: "contract",
Description: "Private Chain 138 Core RPC for operator-grade administration and sensitive workloads.",
UseCases: []string{"core deployments", "operator automation", "private infrastructure integration"},
ManagementFeatures: []string{"dedicated API key", "higher rate ceiling", "operator-oriented access controls"},
},
{
Slug: "alltra-rpc",
Name: "Alltra RPC",
Provider: "alltra",
VMID: 2102,
HTTPURL: "http://192.168.11.212:8545",
WSURL: "ws://192.168.11.212:8546",
DefaultTier: "pro",
RequiresApproval: false,
BillingModel: "subscription",
Description: "Dedicated Alltra-managed RPC lane for partner traffic, subscription access, and API-key-gated usage.",
UseCases: []string{"tenant RPC access", "managed partner workloads", "metered commercial usage"},
ManagementFeatures: []string{"subscription-ready key issuance", "rate governance", "partner-specific traffic lane"},
},
{
Slug: "thirdweb-rpc",
Name: "Thirdweb RPC",
Provider: "thirdweb",
VMID: 2103,
HTTPURL: "http://192.168.11.217:8545",
WSURL: "ws://192.168.11.217:8546",
DefaultTier: "pro",
RequiresApproval: false,
BillingModel: "subscription",
Description: "Thirdweb-oriented Chain 138 RPC lane suitable for managed SaaS access and API-token paywalling.",
UseCases: []string{"thirdweb integrations", "commercial API access", "managed dApp traffic"},
ManagementFeatures: []string{"API token issuance", "usage tiering", "future paywall/subscription hooks"},
},
}

View File

@@ -0,0 +1,111 @@
package rest
import (
"os"
"path/filepath"
"testing"
)
func TestLoadRPCAccessProductsFromRepoDefault(t *testing.T) {
// The repo ships config/rpc_products.yaml relative to backend/. When
// running `go test ./...` from the repo root, the loader's relative
// search path finds it there. Point RPC_PRODUCTS_PATH explicitly so
// the test is deterministic regardless of the CWD the test runner
// chose.
repoRoot, err := findBackendRoot()
if err != nil {
t.Fatalf("locate backend root: %v", err)
}
t.Setenv("RPC_PRODUCTS_PATH", filepath.Join(repoRoot, "config", "rpc_products.yaml"))
products, path, err := loadRPCAccessProducts()
if err != nil {
t.Fatalf("loadRPCAccessProducts: %v", err)
}
if path == "" {
t.Fatalf("loadRPCAccessProducts returned empty path")
}
if len(products) < 3 {
t.Fatalf("expected at least 3 products, got %d", len(products))
}
slugs := map[string]bool{}
for _, p := range products {
slugs[p.Slug] = true
if p.HTTPURL == "" {
t.Errorf("product %q has empty http_url", p.Slug)
}
}
for _, required := range []string{"core-rpc", "alltra-rpc", "thirdweb-rpc"} {
if !slugs[required] {
t.Errorf("expected product slug %q in catalog", required)
}
}
}
func TestLoadRPCAccessProductsRejectsDuplicateSlug(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "rpc_products.yaml")
yaml := `products:
- slug: a
http_url: https://a.example
name: A
provider: p
vmid: 1
default_tier: free
billing_model: free
description: A
- slug: a
http_url: https://a.example
name: A2
provider: p
vmid: 2
default_tier: free
billing_model: free
description: A2
`
if err := os.WriteFile(path, []byte(yaml), 0o600); err != nil {
t.Fatalf("write fixture: %v", err)
}
t.Setenv("RPC_PRODUCTS_PATH", path)
if _, _, err := loadRPCAccessProducts(); err == nil {
t.Fatal("expected duplicate-slug error, got nil")
}
}
func TestLoadRPCAccessProductsRejectsMissingHTTPURL(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "rpc_products.yaml")
if err := os.WriteFile(path, []byte("products:\n - slug: x\n name: X\n"), 0o600); err != nil {
t.Fatalf("write fixture: %v", err)
}
t.Setenv("RPC_PRODUCTS_PATH", path)
if _, _, err := loadRPCAccessProducts(); err == nil {
t.Fatal("expected missing-http_url error, got nil")
}
}
// findBackendRoot walks up from the test working directory until it finds
// a directory containing a go.mod whose module is the backend module,
// so the test works regardless of whether `go test` is invoked from the
// repo root, the backend dir, or the api/rest subdir.
func findBackendRoot() (string, error) {
cwd, err := os.Getwd()
if err != nil {
return "", err
}
for {
goMod := filepath.Join(cwd, "go.mod")
if _, err := os.Stat(goMod); err == nil {
// found the backend module root
return cwd, nil
}
parent := filepath.Dir(cwd)
if parent == cwd {
return "", os.ErrNotExist
}
cwd = parent
}
}

View File

@@ -0,0 +1,97 @@
# Chain 138 RPC access product catalog.
#
# This file is the single source of truth for the products exposed by the
# /api/v1/access/products endpoint and consumed by API-key issuance,
# subscription binding, and access-audit flows. Moving the catalog here
# (it used to be a hardcoded Go literal in api/rest/auth.go) means:
#
# - ops can add / rename / retune a product without a Go rebuild,
# - VM IDs and private-CIDR RPC URLs stop being committed to source as
# magic numbers, and
# - the same YAML can be rendered for different environments (dev /
# staging / prod) via RPC_PRODUCTS_PATH.
#
# Path resolution at startup:
# 1. $RPC_PRODUCTS_PATH if set (absolute or relative to the working dir),
# 2. $EXPLORER_BACKEND_DIR/config/rpc_products.yaml if that env var is set,
# 3. the first of <cwd>/backend/config/rpc_products.yaml or
# <cwd>/config/rpc_products.yaml that exists,
# 4. the compiled-in fallback slice (legacy behaviour; logs a warning).
#
# Schema:
# slug: string (unique URL-safe identifier; required)
# name: string (human label; required)
# provider: string (internal routing key; required)
# vmid: int (internal VM identifier; required)
# http_url: string (HTTPS RPC endpoint; required)
# ws_url: string (optional WebSocket endpoint)
# default_tier: string (free|pro|enterprise; required)
# requires_approval: bool (gate behind manual approval)
# billing_model: string (free|subscription|contract; required)
# description: string (human-readable description; required)
# use_cases: []string
# management_features: []string
products:
- slug: core-rpc
name: Core RPC
provider: besu-core
vmid: 2101
http_url: https://rpc-http-prv.d-bis.org
ws_url: wss://rpc-ws-prv.d-bis.org
default_tier: enterprise
requires_approval: true
billing_model: contract
description: >-
Private Chain 138 Core RPC for operator-grade administration and
sensitive workloads.
use_cases:
- core deployments
- operator automation
- private infrastructure integration
management_features:
- dedicated API key
- higher rate ceiling
- operator-oriented access controls
- slug: alltra-rpc
name: Alltra RPC
provider: alltra
vmid: 2102
http_url: http://192.168.11.212:8545
ws_url: ws://192.168.11.212:8546
default_tier: pro
requires_approval: false
billing_model: subscription
description: >-
Dedicated Alltra-managed RPC lane for partner traffic, subscription
access, and API-key-gated usage.
use_cases:
- tenant RPC access
- managed partner workloads
- metered commercial usage
management_features:
- subscription-ready key issuance
- rate governance
- partner-specific traffic lane
- slug: thirdweb-rpc
name: Thirdweb RPC
provider: thirdweb
vmid: 2103
http_url: http://192.168.11.217:8545
ws_url: ws://192.168.11.217:8546
default_tier: pro
requires_approval: false
billing_model: subscription
description: >-
Thirdweb-oriented Chain 138 RPC lane suitable for managed SaaS access
and API-token paywalling.
use_cases:
- thirdweb integrations
- commercial API access
- managed dApp traffic
management_features:
- API token issuance
- usage tiering
- future paywall/subscription hooks

View File

@@ -13,6 +13,7 @@ require (
github.com/redis/go-redis/v9 v9.17.2
github.com/stretchr/testify v1.11.1
golang.org/x/crypto v0.36.0
gopkg.in/yaml.v3 v3.0.1
)
require (
@@ -51,6 +52,5 @@ require (
golang.org/x/text v0.23.0 // indirect
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d // indirect
google.golang.org/protobuf v1.33.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
rsc.io/tmplfunc v0.0.3 // indirect
)

View File

@@ -1,86 +0,0 @@
# Testing
The explorer has four test tiers. Run them in order of fidelity when
debugging a regression.
## 1. Unit / package tests
Fast. Run on every PR.
```bash
# Backend
cd backend && go test ./...
# Frontend
cd frontend && npm test # lint + type-check
cd frontend && npm run test:unit # vitest
```
## 2. Static analysis
Blocking on CI since PR #5 (`chore(ci): align Go to 1.23.x, add
staticcheck/govulncheck/gitleaks gates`).
```bash
cd backend && staticcheck ./...
cd backend && govulncheck ./...
git diff master... | gitleaks protect --staged --config ../.gitleaks.toml
```
## 3. Production-targeting Playwright
Runs against `https://explorer.d-bis.org` (or the URL in `EXPLORER_URL`)
and only checks public routes. Useful as a production canary; wired
into the `test-e2e` Make target.
```bash
EXPLORER_URL=https://explorer.d-bis.org make test-e2e
```
## 4. Full-stack Playwright (`make e2e-full`)
Spins up the entire stack locally — `postgres`, `elasticsearch`,
`redis` via docker-compose, plus a local build of `backend/api/rest`
and `frontend` — then runs the full-stack Playwright spec against it.
```bash
make e2e-full
```
What it does, in order:
1. `docker compose -p explorer-e2e up -d postgres elasticsearch redis`
2. Wait for Postgres readiness.
3. Run `go run database/migrations/migrate.go` to apply schema +
seeds (including `0016_jwt_revocations` from PR #8).
4. `go run ./backend/api/rest` on port `8080`.
5. `npm ci && npm run build && npm run start` on port `3000`.
6. `npx playwright test scripts/e2e-full-stack.spec.ts`.
7. Tear everything down (unless `E2E_KEEP_STACK=1`).
Screenshots of every route are written to
`test-results/screenshots/<route>.png`.
### Env vars
| Var | Default | Purpose |
|-----|---------|---------|
| `EXPLORER_URL` | `http://localhost:3000` | Frontend base URL for the spec |
| `EXPLORER_API_URL` | `http://localhost:8080` | Backend base URL |
| `JWT_SECRET` | generated per-run | Required by backend fail-fast check (PR #3) |
| `CSP_HEADER` | dev-safe default | Same |
| `E2E_KEEP_STACK` | `0` | If `1`, leave the stack up after the run |
| `E2E_SKIP_DOCKER` | `0` | If `1`, assume docker services already running |
| `E2E_SCREENSHOT_DIR` | `test-results/screenshots` | Where to write PNGs |
### CI integration
`.github/workflows/e2e-full.yml` runs `make e2e-full` on:
* **Manual** trigger (`workflow_dispatch`).
* **PRs labelled `run-e2e-full`** — apply the label when a change
warrants full-stack validation (migrations, auth, routing changes).
* **Nightly** at 04:00 UTC.
Screenshots and the Playwright HTML report are uploaded as build
artefacts.

View File

@@ -7,7 +7,7 @@ if (process.env.NO_COLOR !== undefined) {
export default defineConfig({
testDir: './scripts',
testMatch: /e2e-.*\.spec\.ts$/,
testMatch: 'e2e-explorer-frontend.spec.ts',
fullyParallel: false,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,

View File

@@ -1,79 +0,0 @@
import { expect, test, type Page } from '@playwright/test'
import { mkdirSync } from 'node:fs'
import path from 'node:path'
// e2e-full-stack.spec.ts
//
// Playwright spec that exercises the golden-path behaviours of the
// explorer against a *locally booted* backend + frontend, rather than
// against the production deploy that `e2e-explorer-frontend.spec.ts`
// targets. `make e2e-full` stands up the stack, points this spec at
// it via EXPLORER_URL / EXPLORER_API_URL, and tears it down afterwards.
//
// The spec intentionally sticks to Track-1 (public, no auth) routes so
// it can run without provisioning wallet credentials in CI. Track 2-4
// behaviours are covered by the Go and unit-test layers.
const EXPLORER_URL = process.env.EXPLORER_URL || 'http://localhost:3000'
const EXPLORER_API_URL = process.env.EXPLORER_API_URL || 'http://localhost:8080'
const SCREENSHOT_DIR = process.env.E2E_SCREENSHOT_DIR || 'test-results/screenshots'
mkdirSync(SCREENSHOT_DIR, { recursive: true })
async function snapshot(page: Page, name: string) {
const file = path.join(SCREENSHOT_DIR, `${name}.png`)
await page.screenshot({ path: file, fullPage: true })
}
async function expectHeading(page: Page, name: RegExp) {
await expect(page.getByRole('heading', { name })).toBeVisible({ timeout: 15000 })
}
test.describe('Explorer full-stack smoke', () => {
test('backend /healthz responds 200', async ({ request }) => {
const response = await request.get(`${EXPLORER_API_URL}/healthz`)
expect(response.status()).toBeLessThan(500)
})
for (const route of [
{ path: '/', heading: /SolaceScan/i, name: 'home' },
{ path: '/blocks', heading: /^Blocks$/i, name: 'blocks' },
{ path: '/transactions', heading: /^Transactions$/i, name: 'transactions' },
{ path: '/addresses', heading: /^Addresses$/i, name: 'addresses' },
{ path: '/tokens', heading: /^Tokens$/i, name: 'tokens' },
{ path: '/pools', heading: /^Pools$/i, name: 'pools' },
{ path: '/search', heading: /^Search$/i, name: 'search' },
{ path: '/wallet', heading: /Wallet & MetaMask/i, name: 'wallet' },
{ path: '/routes', heading: /Route/i, name: 'routes' },
]) {
test(`frontend route ${route.path} renders`, async ({ page }) => {
await page.goto(`${EXPLORER_URL}${route.path}`, {
waitUntil: 'domcontentloaded',
timeout: 30000,
})
await expectHeading(page, route.heading)
await snapshot(page, route.name)
})
}
test('access products endpoint is reachable', async ({ request }) => {
// Covers the YAML-backed catalogue wired up in PR #7. The endpoint
// is public (lists available RPC products) so no auth is needed.
const response = await request.get(`${EXPLORER_API_URL}/api/v1/access/products`)
expect(response.status()).toBe(200)
const body = await response.json()
expect(Array.isArray(body.products)).toBe(true)
expect(body.products.length).toBeGreaterThanOrEqual(3)
})
test('auth nonce endpoint issues a nonce', async ({ request }) => {
// Covers wallet auth kickoff: /api/v1/auth/nonce must issue a
// fresh nonce even without credentials. This is Track-1-safe.
const response = await request.post(`${EXPLORER_API_URL}/api/v1/auth/nonce`, {
data: { address: '0x4A666F96fC8764181194447A7dFdb7d471b301C8' },
})
expect(response.status()).toBe(200)
const body = await response.json()
expect(typeof body.nonce === 'string' && body.nonce.length > 0).toBe(true)
})
})

View File

@@ -1,123 +0,0 @@
#!/usr/bin/env bash
# scripts/e2e-full.sh
#
# Boots the full explorer stack (postgres, elasticsearch, redis, backend
# API, frontend), waits for readiness, runs the Playwright full-stack
# smoke spec against it, and tears everything down. Used by the
# `make e2e-full` target and by the e2e-full CI workflow.
#
# Env vars:
# E2E_KEEP_STACK=1 # don't tear down on exit (for debugging)
# E2E_SKIP_DOCKER=1 # assume backend + deps already running
# EXPLORER_URL # defaults to http://localhost:3000
# EXPLORER_API_URL # defaults to http://localhost:8080
# E2E_SCREENSHOT_DIR # defaults to test-results/screenshots
# JWT_SECRET # required; generated ephemerally if unset
# CSP_HEADER # required; a dev-safe default is injected
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
cd "$ROOT"
COMPOSE="deployment/docker-compose.yml"
COMPOSE_PROJECT="${COMPOSE_PROJECT:-explorer-e2e}"
export EXPLORER_URL="${EXPLORER_URL:-http://localhost:3000}"
export EXPLORER_API_URL="${EXPLORER_API_URL:-http://localhost:8080}"
export E2E_SCREENSHOT_DIR="${E2E_SCREENSHOT_DIR:-$ROOT/test-results/screenshots}"
mkdir -p "$E2E_SCREENSHOT_DIR"
# Generate ephemeral JWT secret if the caller didn't set one. Real
# deployments use fail-fast validation (see PR #3); for a local run we
# want a fresh value each invocation so stale tokens don't bleed across
# runs.
export JWT_SECRET="${JWT_SECRET:-$(openssl rand -hex 32)}"
export CSP_HEADER="${CSP_HEADER:-default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self' http://localhost:8080 ws://localhost:8080}"
log() { printf '[e2e-full] %s\n' "$*"; }
teardown() {
local ec=$?
if [[ "${E2E_KEEP_STACK:-0}" == "1" ]]; then
log "E2E_KEEP_STACK=1; leaving stack running."
return $ec
fi
log "tearing down stack"
if [[ "${E2E_SKIP_DOCKER:-0}" != "1" ]]; then
docker compose -p "$COMPOSE_PROJECT" -f "$COMPOSE" down -v --remove-orphans >/dev/null 2>&1 || true
fi
if [[ -n "${BACKEND_PID:-}" ]]; then kill "$BACKEND_PID" 2>/dev/null || true; fi
if [[ -n "${FRONTEND_PID:-}" ]]; then kill "$FRONTEND_PID" 2>/dev/null || true; fi
return $ec
}
trap teardown EXIT
wait_for() {
local url="$1" label="$2" retries="${3:-60}"
log "waiting for $label at $url"
for ((i=0; i<retries; i++)); do
if curl -fsS "$url" >/dev/null 2>&1; then
log " $label ready"
return 0
fi
sleep 2
done
log " $label never became ready"
return 1
}
if [[ "${E2E_SKIP_DOCKER:-0}" != "1" ]]; then
log "starting postgres, elasticsearch, redis via docker compose"
docker compose -p "$COMPOSE_PROJECT" -f "$COMPOSE" up -d postgres elasticsearch redis
log "waiting for postgres"
for ((i=0; i<60; i++)); do
if docker compose -p "$COMPOSE_PROJECT" -f "$COMPOSE" exec -T postgres pg_isready -U explorer >/dev/null 2>&1; then
break
fi
sleep 2
done
fi
export DB_HOST="${DB_HOST:-localhost}"
export DB_PORT="${DB_PORT:-5432}"
export DB_USER="${DB_USER:-explorer}"
export DB_PASSWORD="${DB_PASSWORD:-changeme}"
export DB_NAME="${DB_NAME:-explorer}"
export REDIS_HOST="${REDIS_HOST:-localhost}"
export REDIS_PORT="${REDIS_PORT:-6379}"
export ELASTICSEARCH_URL="${ELASTICSEARCH_URL:-http://localhost:9200}"
log "running migrations"
(cd backend && go run database/migrations/migrate.go) || {
log "migrations failed; continuing so tests can report the real backend state"
}
log "starting backend API on :8080"
(cd backend/api/rest && go run . >/tmp/e2e-backend.log 2>&1) &
BACKEND_PID=$!
wait_for "$EXPLORER_API_URL/healthz" backend 120 || {
log "backend log tail:"; tail -n 60 /tmp/e2e-backend.log || true
exit 1
}
log "building frontend"
(cd frontend && npm ci --no-audit --no-fund --loglevel=error && npm run build)
log "starting frontend on :3000"
(cd frontend && PORT=3000 HOST=127.0.0.1 NEXT_PUBLIC_API_URL="$EXPLORER_API_URL" npm run start >/tmp/e2e-frontend.log 2>&1) &
FRONTEND_PID=$!
wait_for "$EXPLORER_URL" frontend 60 || {
log "frontend log tail:"; tail -n 60 /tmp/e2e-frontend.log || true
exit 1
}
log "running Playwright full-stack smoke"
npx playwright install --with-deps chromium >/dev/null
EXPLORER_URL="$EXPLORER_URL" EXPLORER_API_URL="$EXPLORER_API_URL" \
npx playwright test scripts/e2e-full-stack.spec.ts --reporter=list
log "done; screenshots in $E2E_SCREENSHOT_DIR"