跳到正文
DEV Community· EME GUG·· 2 小時前AI 評分61

API 客戶端知道得太多:如何改用 Bruno 和 Hurl 進行本地優先、以 Git 管理版本的 API 測試

Your API Client Knows Too Much: Moving to Local-First, Git-Versioned API Testing with Bruno and Hurl

AI 導讀

作者分享將團隊 API 測試遷移至 Bruno 和 Hurl 的做法,把 collection 存入 Git、在執行時透過環境變數注入 secrets,並讓測試在本機和 CI 執行。

正文

Tuần này HN có một thread hơn 800 điểm: một API client rất phổ biến tự cài AI plugin mà không hỏi người dùng, rồi cố gửi dữ liệu API lên cloud. Đọc xong mình mở máy ra kiểm tra thì thấy collection của team đang chứa token production, base URL nội bộ và cả response mẫu có dữ liệu khách hàng. Tất cả đều được sync lên server của bên thứ ba. Chẳng ai cố ý làm vậy, chỉ là tiện thôi. Bài này kể lại cách mình chuyển team sang API testing kiểu local-first: collection nằm trong Git, secret không bao giờ rời máy, và cùng một bộ test chạy được cả trên laptop lẫn trên CI.

Vấn đề thật sự không nằm ở cái plugin AI

Plugin AI chỉ làm lộ ra một vấn đề có sẵn từ lâu: API client của bạn đã trở thành một kho dữ liệu nhạy cảm, mà bạn không kiểm soát được nó. Thử nghĩ xem trong workspace của bạn có những gì:

  • Bearer token, API key, cookie session (thường là của production vì "test nhanh cái")
  • Endpoint nội bộ, có khi kèm IP private
  • Response đã lưu, chứa PII của user thật
  • Pre-request script, đôi khi hardcode luôn client secret

Khi mọi thứ này được sync lên cloud, mức độ an toàn của chúng phụ thuộc vào quyết định sản phẩm của một công ty khác. Hôm nay là plugin AI, mai có thể là một đợt thay đổi pricing khóa workspace, hoặc một vụ breach.

Hướng đi của mình gồm ba nguyên tắc:

  1. Collection là code: dùng file text, nằm trong repo, đi qua review bằng PR.
  2. Secret là runtime: inject qua biến môi trường, không bao giờ commit.
  3. Không cần sync cloud: Git đã làm việc đồng bộ rồi.
flowchart LR
    A[Developer] -->|edit .bru / .hurl| B[Git repo]
    C[.env local<br/>gitignored] -->|inject secrets| D[Bruno / Hurl runner]
    B --> D
    D --> E[API staging]
    B -->|push| F[CI pipeline]
    G[CI secrets store] -->|env vars| F
    F --> E

Bước 1: Audit xem bạn đang lộ gì

Trước khi migrate, export toàn bộ collection hiện tại ra JSON (format Collection v2.1) rồi quét một lượt. Dưới đây là script Python mình dùng, chỉ cần standard library:

#!/usr/bin/env python3
# audit_collection.py - quét secret trong collection export
import json, re, sys
from pathlib import Path

PATTERNS = {
    "bearer": re.compile(r"Bearer\s+[A-Za-z0-9\-_\.]{20,}"),
    "jwt": re.compile(r"eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+"),
    "aws_key": re.compile(r"AKIA[0-9A-Z]{16}"),
    "private_ip": re.compile(r"\b(10|192\.168|172\.(1[6-9]|2\d|3[01]))\.\d+\.\d+(\.\d+)?\b"),
    "email": re.compile(r"[\w.+-]+@[\w-]+\.[\w.]+"),
}

def walk(node, path="$"):
    if isinstance(node, dict):
        for k, v in node.items():
            yield from walk(v, f"{path}.{k}")
    elif isinstance(node, list):
        for i, v in enumerate(node):
            yield from walk(v, f"{path}[{i}]")
    elif isinstance(node, str):
        yield path, node

def main(file):
    data = json.loads(Path(file).read_text())
    hits = 0
    for path, value in walk(data):
        if "{{" in value:  # đã dùng biến, bỏ qua
            continue
        for name, pat in PATTERNS.items():
            if pat.search(value):
                hits += 1
                print(f"[{name}] {path}: {value[:60]}...")
    print(f"\nTổng: {hits} vị trí nghi ngờ")
    sys.exit(1 if hits else 0)

if __name__ == "__main__":
    main(sys.argv[1])

Chạy với python3 audit_collection.py my-collection.json. Lần đầu chạy trên collection của team, mình nhận về 47 hit, trong đó có 3 JWT production còn hạn. Gặp trường hợp này thì rotate token ngay, đừng đợi migrate xong mới làm.

Nếu muốn quét kỹ hơn, gitleaks (v8.19 trở lên) có lệnh gitleaks dir ./exports để quét thư mục không phải Git repo.

Bước 2: Bruno cho team dùng GUI

Bruno (mình dùng bản 2.x) là API client open-source. Nó lưu mỗi request thành một file .bru dạng plain text ngay trong filesystem và không có cloud sync. Bruno có sẵn tính năng import collection v2.1 nên việc migrate khá nhẹ nhàng.

Cấu trúc thư mục đặt luôn trong repo backend:

api-tests/
├── bruno.json
├── .env              # gitignored
├── environments/
│   ├── local.bru
│   └── staging.bru
└── users/
    ├── get-user.bru
    └── create-user.bru

File environment chỉ tham chiếu biến môi trường, không chứa giá trị thật:

# environments/staging.bru
vars {
  baseUrl: https://staging-api.example.com
  token: {{process.env.STAGING_API_TOKEN}}
}

Thêm .env vào .gitignore, rồi commit một file .env.example chỉ chứa tên biến để người mới biết cần điền gì. Chạy từ terminal bằng CLI:

npm install -g @usebruno/cli
cd api-tests
bru run users --env staging --reporter-junit results.xml

Điểm mình thích nhất là khi review PR, diff của file .bru đọc được như code bình thường. Ai đổi assertion, ai thêm header đều hiện rõ ràng.

Bước 3: Hurl cho CI và những người sống trong terminal

Bruno hợp với người cần GUI. Còn trên CI, mình dùng Hurl (6.x). Đây là một binary viết bằng Rust, chạy trên libcurl, khởi động gần như tức thì và không cần Node:

# users.hurl
POST {{base_url}}/auth/login
Content-Type: application/json
{
  "email": "{{test_email}}",
  "password": "{{test_password}}"
}
HTTP 200
[Captures]
access_token: jsonpath "$.access_token"

GET {{base_url}}/users/me
Authorization: Bearer {{access_token}}
HTTP 200
[Asserts]
jsonpath "$.email" == "{{test_email}}"
jsonpath "$.roles" includes "member"
duration < 500

Token được capture ngay trong lúc chạy, nên không có token nào nằm cố định trong file. Trên CI, ví dụ GitHub Actions:

hurl --test \
  --variable base_url="$BASE_URL" \
  --variable test_email="$TEST_EMAIL" \
  --secret test_password="$TEST_PASSWORD" \
  --report-junit hurl-report.xml \
  tests/*.hurl

Nên dùng --secret thay cho --variable với các giá trị nhạy cảm, vì Hurl sẽ redact chúng khỏi log. Nhiều team chỉ để ý chuyện secret có bị commit hay không mà quên mất CI log cũng là một chỗ hay bị lộ.

sequenceDiagram
    participant CI as CI Runner
    participant H as Hurl
    participant API as Staging API
    CI->>H: inject secrets (env, redacted)
    H->>API: POST /auth/login
    API-->>H: access_token
    H->>API: GET /users/me (Bearer token)
    API-->>H: 200 + JSON
    H-->>CI: JUnit report, exit code

Mấy bài học xương máu khi migrate

  • Đừng migrate response đã lưu. Example response trong collection cũ thường chứa dữ liệu thật. Bỏ hết, nếu cần thì viết lại bằng dữ liệu giả.
  • Tách test account riêng cho CI, phân quyền tối thiểu và rotate định kỳ. Đừng dùng account của dev.
  • Thêm pre-commit hook: gitleaks git --staged chạy chưa tới một giây mà chặn được kha khá tai nạn.
  • Kiểm tra egress của các tool desktop. Trên macOS, chạy lsof -i -nP | grep -i <tên-app> sẽ thấy app đang kết nối tới đâu. Nếu một API client gọi tới domain bạn không biết trong khi bạn chưa gửi request nào, đó là dấu hiệu cần để ý.
  • Một nguồn sự thật duy nhất. Đừng để Bruno và Hurl test hai bộ hành vi khác nhau. Team mình quy ước: Bruno dùng để khám phá và debug, còn Hurl là contract test chính thức chạy trên CI.

Kết luận

Câu chuyện plugin AI chỉ là phần nổi. Vấn đề gốc là chúng ta đã để dữ liệu nhạy cảm nằm trong một tool mình không kiểm soát. Dưới đây là những việc làm được ngay trong tuần này:

  1. Hôm nay: export collection hiện tại, chạy script audit ở trên, rotate mọi token bị lộ.
  2. Tuần này: tạo thư mục api-tests/ trong repo, import vào Bruno, chuyển toàn bộ secret sang {{process.env.X}}.
  3. Sprint tới: viết 5 đến 10 file .hurl cho các luồng quan trọng nhất (auth, CRUD chính), đưa vào CI với --secret và --test.
  4. Luôn luôn: gitleaks ở pre-commit, .env trong .gitignore, review collection như review code.

Khi API test là code thì nó được version, được review và bạn biết chính xác nó nằm ở đâu. Đó cũng là cách đơn giản nhất để tool không biết nhiều hơn mức cần thiết.

來源:DEV Community · dev.to