ci: bb connectivity suite

This commit is contained in:
pragmaxim
2026-03-03 08:11:01 +01:00
parent d33d606995
commit 73b3f2a2a1
7 changed files with 274 additions and 12 deletions

View File

@@ -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"

View File

@@ -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)"

View File

@@ -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)

View File

@@ -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_<coin alias>` 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_<coin alias>`) and calls `getInfo`
### Blockbook API end-to-end tests

View File

@@ -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 {

View File

@@ -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] + "..."
}

View File

@@ -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)
}