diff --git a/.github/workflows/testing.yml b/.github/workflows/testing.yml index 8b4ace76..b40762a5 100644 --- a/.github/workflows/testing.yml +++ b/.github/workflows/testing.yml @@ -19,7 +19,7 @@ jobs: run: make test connectivity-tests: - name: Backend Connectivity Tests + name: Connectivity Tests runs-on: [self-hosted, Linux, X64] needs: unit-tests if: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository }} @@ -70,4 +70,4 @@ jobs: vars_json: ${{ toJSON(vars) }} - name: Run e2e tests - run: make e2e ARGS="-v" + run: make test-e2e ARGS="-v" diff --git a/Makefile b/Makefile index fdef737f..20e13157 100644 --- a/Makefile +++ b/Makefile @@ -12,7 +12,7 @@ BB_RPC_ENV := $(shell env | awk -F= '/^BB_RPC_(URL_HTTP|URL_WS|BIND_HOST|ALLOW_I TARGETS=$(subst .json,, $(shell ls configs/coins)) -.PHONY: build build-debug test test-connectivity test-integration test-e2e e2e test-all deb +.PHONY: build build-debug test test-connectivity test-integration test-e2e test-all deb build: .bin-image docker run -t --rm -e PACKAGER=$(PACKAGER) $(BB_RPC_ENV) -v "$(CURDIR):/src" -v "$(CURDIR)/build:/out" $(BIN_IMAGE) make build ARGS="$(ARGS)" @@ -29,8 +29,6 @@ test-integration: .bin-image test-e2e: .bin-image docker run -t --rm -e PACKAGER=$(PACKAGER) $(BB_RPC_ENV) -v "$(CURDIR):/src" --network="host" $(BIN_IMAGE) make test-e2e ARGS="$(ARGS)" -e2e: test-e2e - test-connectivity: .bin-image docker run -t --rm -e PACKAGER=$(PACKAGER) $(BB_RPC_ENV) -v "$(CURDIR):/src" --network="host" $(BIN_IMAGE) make test-connectivity ARGS="$(ARGS)" diff --git a/build/docker/bin/Makefile b/build/docker/bin/Makefile index 3849a298..02f86053 100644 --- a/build/docker/bin/Makefile +++ b/build/docker/bin/Makefile @@ -32,10 +32,8 @@ test-integration: prepare-sources test-e2e: prepare-sources cd $(BLOCKBOOK_SRC) && go test -tags 'integration' `go list github.com/trezor/blockbook/tests/...` -run 'TestIntegration/.*/api' -timeout 30m $(ARGS) -e2e: test-e2e - test-connectivity: prepare-sources - cd $(BLOCKBOOK_SRC) && go test -tags 'integration' `go list github.com/trezor/blockbook/tests/...` -run 'TestIntegration/.*/connectivity' -timeout 30m $(ARGS) + cd $(BLOCKBOOK_SRC) && go test -tags 'integration' github.com/trezor/blockbook/tests -run 'TestIntegration/.*/connectivity' -timeout 30m $(ARGS) test-all: prepare-sources cd $(BLOCKBOOK_SRC) && go test -tags 'unittest integration' `go list ./... | grep -v '^github.com/trezor/blockbook/contrib'` -timeout 30m $(ARGS) diff --git a/docs/testing.md b/docs/testing.md index 6d7d134d..39e7e544 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -86,8 +86,15 @@ Example: } ``` -HTTP connectivity for UTXO chains calls `getblockchaininfo`. For EVM chains it calls `web3_clientVersion`. WebSocket -connectivity validates `web3_clientVersion` and opens a `newHeads` subscription. +HTTP connectivity verifies both back-end and Blockbook accessibility: + +* back-end: UTXO chains call `getblockchaininfo`, EVM chains call `web3_clientVersion` +* Blockbook: calls `GET /api/status` (resolved from `BB_API_URL_HTTP_` or local `ports.blockbook_public`) + +WebSocket connectivity also verifies both surfaces: + +* back-end: validates `web3_clientVersion` and opens a `newHeads` subscription +* Blockbook: connects to `/websocket` (or `BB_API_URL_WS_`) and calls `getInfo` ### Blockbook API end-to-end tests diff --git a/tests/api/endpoint_resolution.go b/tests/api/endpoint_resolution.go index ed1d9d8d..4ff5c074 100644 --- a/tests/api/endpoint_resolution.go +++ b/tests/api/endpoint_resolution.go @@ -14,6 +14,16 @@ import ( "strings" ) +// ResolveEndpoints resolves Blockbook API endpoints for a coin alias using +// BB_API_URL_* overrides first and coin config fallbacks. +func ResolveEndpoints(coin string) (string, string, error) { + ep, err := resolveAPIEndpoints(coin) + if err != nil { + return "", "", err + } + return ep.HTTP, ep.WS, nil +} + func resolveAPIEndpoints(coin string) (*apiEndpoints, error) { cfg, err := loadCoinConfig(coin) if err != nil { diff --git a/tests/connectivity/blockbook_connectivity.go b/tests/connectivity/blockbook_connectivity.go new file mode 100644 index 00000000..48be292f --- /dev/null +++ b/tests/connectivity/blockbook_connectivity.go @@ -0,0 +1,247 @@ +//go:build integration + +package connectivity + +import ( + "crypto/tls" + "encoding/json" + "io" + "net/http" + "net/url" + "strings" + "testing" + "time" + + "github.com/gorilla/websocket" + "github.com/trezor/blockbook/bchain" + apitests "github.com/trezor/blockbook/tests/api" +) + +type blockbookStatusEnvelope struct { + Blockbook json.RawMessage `json:"blockbook"` + Backend json.RawMessage `json:"backend"` +} + +type blockbookWSRequest struct { + ID string `json:"id"` + Method string `json:"method"` + Params interface{} `json:"params"` +} + +type blockbookWSResponse struct { + ID string `json:"id"` + Data json.RawMessage `json:"data"` +} + +type blockbookWSInfo struct { + BestHeight int `json:"bestHeight"` + BestHash string `json:"bestHash"` +} + +func BlockbookHTTPIntegrationTest(t *testing.T, coin string, _ bchain.BlockChain, _ bchain.Mempool, _ json.RawMessage) { + t.Helper() + + httpBase, _, err := apitests.ResolveEndpoints(coin) + if err != nil { + t.Fatalf("resolve Blockbook endpoints for %s: %v", coin, err) + } + + client := &http.Client{ + Timeout: connectivityTimeout, + Transport: &http.Transport{ + Proxy: http.ProxyFromEnvironment, + TLSClientConfig: &tls.Config{ + InsecureSkipVerify: true, + }, + }, + } + + status, body, err := blockbookHTTPGet(client, httpBase, "/api/status") + if err != nil { + t.Fatalf("GET %s/api/status: %v", httpBase, err) + } + if shouldUpgradeToHTTPS(status, body, httpBase) { + if upgraded, ok := upgradeHTTPBaseToHTTPS(httpBase); ok { + httpBase = upgraded + status, body, err = blockbookHTTPGet(client, httpBase, "/api/status") + if err != nil { + t.Fatalf("GET %s/api/status: %v", httpBase, err) + } + } + } + + if status != http.StatusOK { + t.Fatalf("GET %s/api/status returned HTTP %d: %s", httpBase, status, previewBody(body)) + } + + var envelope blockbookStatusEnvelope + if err := json.Unmarshal(body, &envelope); err != nil { + t.Fatalf("decode %s/api/status: %v", httpBase, err) + } + if !hasNonEmptyJSON(envelope.Blockbook) { + t.Fatalf("status response missing non-empty blockbook object") + } + if !hasNonEmptyJSON(envelope.Backend) { + t.Fatalf("status response missing non-empty backend object") + } +} + +func BlockbookWSIntegrationTest(t *testing.T, coin string, _ bchain.BlockChain, _ bchain.Mempool, _ json.RawMessage) { + t.Helper() + + _, wsURL, err := apitests.ResolveEndpoints(coin) + if err != nil { + t.Fatalf("resolve Blockbook endpoints for %s: %v", coin, err) + } + + dialer := websocket.Dialer{ + HandshakeTimeout: connectivityTimeout, + TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, + } + + conn, _, err := dialer.Dial(wsURL, nil) + if err != nil { + if upgraded, ok := upgradeWSBaseToWSS(wsURL); ok { + conn, _, err = dialer.Dial(upgraded, nil) + if err == nil { + wsURL = upgraded + } + } + } + if err != nil { + t.Fatalf("websocket dial %s: %v", wsURL, err) + } + defer conn.Close() + + reqID := "connectivity-getinfo" + req := blockbookWSRequest{ + ID: reqID, + Method: "getInfo", + Params: map[string]interface{}{}, + } + + conn.SetWriteDeadline(time.Now().Add(connectivityTimeout)) + if err := conn.WriteJSON(&req); err != nil { + t.Fatalf("websocket write getInfo: %v", err) + } + + for i := 0; i < 5; i++ { + conn.SetReadDeadline(time.Now().Add(connectivityTimeout)) + _, payload, err := conn.ReadMessage() + if err != nil { + t.Fatalf("websocket read getInfo: %v", err) + } + + var resp blockbookWSResponse + if err := json.Unmarshal(payload, &resp); err != nil { + t.Fatalf("decode websocket response: %v", err) + } + if resp.ID != reqID { + continue + } + if msg, hasError := blockbookWebsocketError(resp.Data); hasError { + t.Fatalf("websocket getInfo returned error: %s", msg) + } + + var info blockbookWSInfo + if err := json.Unmarshal(resp.Data, &info); err != nil { + t.Fatalf("decode websocket getInfo payload: %v", err) + } + if info.BestHeight < 0 { + t.Fatalf("invalid websocket bestHeight: %d", info.BestHeight) + } + if strings.TrimSpace(info.BestHash) == "" { + t.Fatalf("empty websocket bestHash") + } + return + } + + t.Fatalf("missing websocket getInfo response for request id %s", reqID) +} + +func blockbookHTTPGet(client *http.Client, baseURL, path string) (int, []byte, error) { + req, err := http.NewRequest(http.MethodGet, resolveHTTPURL(baseURL, path), nil) + if err != nil { + return 0, nil, err + } + + resp, err := client.Do(req) + if err != nil { + return 0, nil, err + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return 0, nil, err + } + return resp.StatusCode, body, nil +} + +func resolveHTTPURL(baseURL, path string) string { + if strings.HasPrefix(path, "/") { + return baseURL + path + } + return baseURL + "/" + path +} + +func shouldUpgradeToHTTPS(status int, body []byte, baseURL string) bool { + if status != http.StatusBadRequest { + return false + } + if !strings.Contains(strings.ToLower(string(body)), "http request to an https server") { + return false + } + parsed, err := url.Parse(baseURL) + if err != nil { + return false + } + return parsed.Scheme == "http" +} + +func upgradeHTTPBaseToHTTPS(raw string) (string, bool) { + u, err := url.Parse(raw) + if err != nil || u.Scheme != "http" { + return "", false + } + u.Scheme = "https" + return strings.TrimRight(u.String(), "/"), true +} + +func upgradeWSBaseToWSS(raw string) (string, bool) { + u, err := url.Parse(raw) + if err != nil || u.Scheme != "ws" { + return "", false + } + u.Scheme = "wss" + return u.String(), true +} + +func blockbookWebsocketError(data json.RawMessage) (string, bool) { + var e struct { + Error *struct { + Message string `json:"message"` + } `json:"error"` + } + if err := json.Unmarshal(data, &e); err != nil { + return "", false + } + if e.Error == nil { + return "", false + } + return e.Error.Message, true +} + +func hasNonEmptyJSON(raw json.RawMessage) bool { + v := strings.TrimSpace(string(raw)) + return v != "" && v != "null" && v != "{}" +} + +func previewBody(body []byte) string { + const max = 256 + s := strings.TrimSpace(string(body)) + if len(s) <= max { + return s + } + return s[:max] + "..." +} diff --git a/tests/connectivity/connectivity.go b/tests/connectivity/connectivity.go index e310fcd0..e8103b80 100644 --- a/tests/connectivity/connectivity.go +++ b/tests/connectivity/connectivity.go @@ -25,8 +25,8 @@ type connectivityCfg struct { } // IntegrationTest runs connectivity checks for the requested modes (e.g., ["http","ws"]). -// HTTP checks verify the backend responds (UTXO uses getblockchaininfo, EVM uses web3_clientVersion). -// WS checks verify web3_clientVersion and a newHeads subscription over the WS endpoint. +// HTTP mode verifies both backend RPC and Blockbook HTTP API accessibility. +// WS mode verifies both backend WS RPC and Blockbook websocket accessibility. func IntegrationTest(t *testing.T, coin string, _ bchain.BlockChain, _ bchain.Mempool, testConfig json.RawMessage) { t.Helper() @@ -39,8 +39,10 @@ func IntegrationTest(t *testing.T, coin string, _ bchain.BlockChain, _ bchain.Me switch mode { case "http": HTTPIntegrationTest(t, coin, nil, nil, nil) + BlockbookHTTPIntegrationTest(t, coin, nil, nil, nil) case "ws": WSIntegrationTest(t, coin, nil, nil, nil) + BlockbookWSIntegrationTest(t, coin, nil, nil, nil) default: t.Fatalf("unsupported connectivity mode %q for %s", mode, coin) }