Merge PR #2: Phase 1 Foundation
Reviewed and merged Phase 1 Foundation PR. All tests pass, CI changes look good, linting config is comprehensive, and shared test utilities improve code quality. The merge preserves the existing CLI on main.
This commit is contained in:
commit
fbcb5a6dbd
23
.github/workflows/ci.yml
vendored
23
.github/workflows/ci.yml
vendored
@ -17,6 +17,14 @@ jobs:
|
|||||||
go-version: '1.25'
|
go-version: '1.25'
|
||||||
cache: true
|
cache: true
|
||||||
|
|
||||||
|
- name: Check formatting
|
||||||
|
run: |
|
||||||
|
if [ -n "$(gofmt -l .)" ]; then
|
||||||
|
echo "Files not formatted:"
|
||||||
|
gofmt -l .
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
- name: Vet
|
- name: Vet
|
||||||
run: go vet ./...
|
run: go vet ./...
|
||||||
|
|
||||||
@ -41,6 +49,21 @@ jobs:
|
|||||||
curl -fsS http://localhost:8080/healthz | grep -q 'ok'
|
curl -fsS http://localhost:8080/healthz | grep -q 'ok'
|
||||||
docker stop hatch-ci
|
docker stop hatch-ci
|
||||||
|
|
||||||
|
lint-go:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- uses: actions/setup-go@v5
|
||||||
|
with:
|
||||||
|
go-version: '1.25'
|
||||||
|
cache: true
|
||||||
|
|
||||||
|
- name: Run golangci-lint
|
||||||
|
uses: golangci/golangci-lint-action@v6
|
||||||
|
with:
|
||||||
|
version: latest
|
||||||
|
|
||||||
lint-docs:
|
lint-docs:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
|
|||||||
5
.gitignore
vendored
5
.gitignore
vendored
@ -12,6 +12,11 @@ go.work
|
|||||||
/bin
|
/bin
|
||||||
/dist
|
/dist
|
||||||
|
|
||||||
|
# Runtime data
|
||||||
|
data/
|
||||||
|
*.db
|
||||||
|
coverage.out
|
||||||
|
|
||||||
# IDE
|
# IDE
|
||||||
.idea/
|
.idea/
|
||||||
.vscode/
|
.vscode/
|
||||||
|
|||||||
57
.golangci.yml
Normal file
57
.golangci.yml
Normal file
@ -0,0 +1,57 @@
|
|||||||
|
linters:
|
||||||
|
enable:
|
||||||
|
- errcheck
|
||||||
|
- gosimple
|
||||||
|
- govet
|
||||||
|
- ineffassign
|
||||||
|
- staticcheck
|
||||||
|
- unused
|
||||||
|
- gofmt
|
||||||
|
- misspell
|
||||||
|
- unconvert
|
||||||
|
- unparam
|
||||||
|
- prealloc
|
||||||
|
- gocritic
|
||||||
|
- revive
|
||||||
|
|
||||||
|
linters-settings:
|
||||||
|
errcheck:
|
||||||
|
check-type-assertions: true
|
||||||
|
check-blank: true
|
||||||
|
govet:
|
||||||
|
check-shadowing: true
|
||||||
|
revive:
|
||||||
|
rules:
|
||||||
|
- name: blank-imports
|
||||||
|
- name: context-as-argument
|
||||||
|
- name: dot-imports
|
||||||
|
- name: error-return
|
||||||
|
- name: error-strings
|
||||||
|
- name: error-naming
|
||||||
|
- name: exported
|
||||||
|
- name: if-return
|
||||||
|
- name: increment-decrement
|
||||||
|
- name: var-naming
|
||||||
|
- name: var-declaration
|
||||||
|
- name: range
|
||||||
|
- name: receiver-naming
|
||||||
|
- name: indent-error-flow
|
||||||
|
- name: superfluous-else
|
||||||
|
- name: unreachable-code
|
||||||
|
- name: redefines-builtin-id
|
||||||
|
|
||||||
|
issues:
|
||||||
|
exclude-rules:
|
||||||
|
# Exclude some linters from running on test files
|
||||||
|
- path: _test\.go
|
||||||
|
linters:
|
||||||
|
- errcheck
|
||||||
|
- gocritic
|
||||||
|
- unparam
|
||||||
|
# Exclude known issues in test files
|
||||||
|
- path: _test\.go
|
||||||
|
text: "func name"
|
||||||
|
|
||||||
|
run:
|
||||||
|
timeout: 5m
|
||||||
|
modules-download-mode: readonly
|
||||||
34
Makefile
Normal file
34
Makefile
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
.PHONY: all build test lint fmt vet clean help
|
||||||
|
|
||||||
|
all: lint test build
|
||||||
|
|
||||||
|
## build: Build the hatch binary
|
||||||
|
build:
|
||||||
|
CGO_ENABLED=0 go build -o hatch ./cmd/hatch
|
||||||
|
|
||||||
|
## test: Run all tests with race detection and coverage
|
||||||
|
test:
|
||||||
|
go test ./... -v -race -coverprofile=coverage.out
|
||||||
|
|
||||||
|
## lint: Run golangci-lint
|
||||||
|
lint:
|
||||||
|
golangci-lint run ./...
|
||||||
|
|
||||||
|
## fmt: Format code with gofmt
|
||||||
|
fmt:
|
||||||
|
gofmt -s -w .
|
||||||
|
|
||||||
|
## vet: Run go vet
|
||||||
|
vet:
|
||||||
|
go vet ./...
|
||||||
|
|
||||||
|
## clean: Remove build artifacts
|
||||||
|
clean:
|
||||||
|
rm -f hatch coverage.out
|
||||||
|
|
||||||
|
## help: Show this help message
|
||||||
|
help:
|
||||||
|
@echo "Usage: make [target]"
|
||||||
|
@echo ""
|
||||||
|
@echo "Targets:"
|
||||||
|
@grep -E '^## ' $(MAKEFILE_LIST) | sed 's/## / /'
|
||||||
@ -8,10 +8,11 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/elfoundation/hatch/internal/store"
|
"github.com/elfoundation/hatch/internal/store"
|
||||||
|
"github.com/elfoundation/hatch/internal/testutil"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestV1CaptureRequest(t *testing.T) {
|
func TestV1CaptureRequest(t *testing.T) {
|
||||||
repo := newFakeRepo()
|
repo := testutil.NewFakeRepository()
|
||||||
r := testRouter(repo)
|
r := testRouter(repo)
|
||||||
|
|
||||||
body := `{"method":"POST","path":"/test","headers":{"X-Custom":"value"},"query":"foo=bar","body":"hello"}`
|
body := `{"method":"POST","path":"/test","headers":{"X-Custom":"value"},"query":"foo=bar","body":"hello"}`
|
||||||
@ -35,16 +36,16 @@ func TestV1CaptureRequest(t *testing.T) {
|
|||||||
t.Errorf("expected path /test, got %v", resp["path"])
|
t.Errorf("expected path /test, got %v", resp["path"])
|
||||||
}
|
}
|
||||||
// Verify request stored in repo.
|
// Verify request stored in repo.
|
||||||
if len(repo.requests) != 1 {
|
if len(repo.Requests) != 1 {
|
||||||
t.Fatalf("expected 1 request stored, got %d", len(repo.requests))
|
t.Fatalf("expected 1 request stored, got %d", len(repo.Requests))
|
||||||
}
|
}
|
||||||
if repo.requests[0].Headers != `{"X-Custom":"value"}` {
|
if repo.Requests[0].Headers != `{"X-Custom":"value"}` {
|
||||||
t.Errorf("expected headers JSON, got %q", repo.requests[0].Headers)
|
t.Errorf("expected headers JSON, got %q", repo.Requests[0].Headers)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestV1ListRequests(t *testing.T) {
|
func TestV1ListRequests(t *testing.T) {
|
||||||
repo := newFakeRepo()
|
repo := testutil.NewFakeRepository()
|
||||||
repo.CreateEndpoint(nil, "list-ep")
|
repo.CreateEndpoint(nil, "list-ep")
|
||||||
// Add a few requests.
|
// Add a few requests.
|
||||||
repo.AppendRequest(nil, "list-ep", &store.Request{Method: "GET", Path: "/a"})
|
repo.AppendRequest(nil, "list-ep", &store.Request{Method: "GET", Path: "/a"})
|
||||||
@ -77,7 +78,7 @@ func TestV1ListRequests(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestV1SearchRequests(t *testing.T) {
|
func TestV1SearchRequests(t *testing.T) {
|
||||||
repo := newFakeRepo()
|
repo := testutil.NewFakeRepository()
|
||||||
repo.CreateEndpoint(nil, "search-ep")
|
repo.CreateEndpoint(nil, "search-ep")
|
||||||
repo.AppendRequest(nil, "search-ep", &store.Request{Method: "GET", Path: "/users", Headers: `{"Accept":"json"}`})
|
repo.AppendRequest(nil, "search-ep", &store.Request{Method: "GET", Path: "/users", Headers: `{"Accept":"json"}`})
|
||||||
repo.AppendRequest(nil, "search-ep", &store.Request{Method: "POST", Path: "/orders", Body: []byte(`{"item":"apple"}`)})
|
repo.AppendRequest(nil, "search-ep", &store.Request{Method: "POST", Path: "/orders", Body: []byte(`{"item":"apple"}`)})
|
||||||
@ -111,7 +112,7 @@ func TestV1SearchRequests(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestV1MockSet(t *testing.T) {
|
func TestV1MockSet(t *testing.T) {
|
||||||
repo := newFakeRepo()
|
repo := testutil.NewFakeRepository()
|
||||||
repo.CreateEndpoint(nil, "mock-ep")
|
repo.CreateEndpoint(nil, "mock-ep")
|
||||||
r := testRouter(repo)
|
r := testRouter(repo)
|
||||||
|
|
||||||
@ -138,7 +139,7 @@ func TestV1MockSet(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestV1OpenAPI(t *testing.T) {
|
func TestV1OpenAPI(t *testing.T) {
|
||||||
repo := newFakeRepo()
|
repo := testutil.NewFakeRepository()
|
||||||
r := testRouter(repo)
|
r := testRouter(repo)
|
||||||
req := httptest.NewRequest(http.MethodGet, "/v1/endpoints/test/openapi.json", nil)
|
req := httptest.NewRequest(http.MethodGet, "/v1/endpoints/test/openapi.json", nil)
|
||||||
w := httptest.NewRecorder()
|
w := httptest.NewRecorder()
|
||||||
|
|||||||
@ -11,89 +11,10 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/elfoundation/hatch/internal/store"
|
"github.com/elfoundation/hatch/internal/store"
|
||||||
|
"github.com/elfoundation/hatch/internal/testutil"
|
||||||
"github.com/go-chi/chi/v5"
|
"github.com/go-chi/chi/v5"
|
||||||
)
|
)
|
||||||
|
|
||||||
// fakeRepo implements store.Repository for tests.
|
|
||||||
type fakeRepo struct {
|
|
||||||
endpoints map[string]*store.Endpoint
|
|
||||||
requests []*store.Request
|
|
||||||
mocks map[string]*store.MockConfig
|
|
||||||
}
|
|
||||||
|
|
||||||
func newFakeRepo() *fakeRepo {
|
|
||||||
return &fakeRepo{
|
|
||||||
endpoints: map[string]*store.Endpoint{},
|
|
||||||
mocks: map[string]*store.MockConfig{},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (f *fakeRepo) CreateEndpoint(_ context.Context, u string) (*store.Endpoint, error) {
|
|
||||||
e := &store.Endpoint{ID: u, URL: u, CreatedAt: "t", UpdatedAt: "t"}
|
|
||||||
f.endpoints[u] = e
|
|
||||||
return e, nil
|
|
||||||
}
|
|
||||||
func (f *fakeRepo) GetEndpoint(_ context.Context, id string) (*store.Endpoint, error) {
|
|
||||||
e, ok := f.endpoints[id]
|
|
||||||
if !ok {
|
|
||||||
return nil, errNotFound
|
|
||||||
}
|
|
||||||
return e, nil
|
|
||||||
}
|
|
||||||
func (f *fakeRepo) AppendRequest(_ context.Context, eid string, r *store.Request) error {
|
|
||||||
r.ID = "req-" + string(rune(len(f.requests)+'0'))
|
|
||||||
r.EndpointID = eid
|
|
||||||
f.requests = append(f.requests, r)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
func (f *fakeRepo) GetRequest(_ context.Context, id string) (*store.Request, error) {
|
|
||||||
for _, r := range f.requests {
|
|
||||||
if r.ID == id {
|
|
||||||
return r, nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil, errNotFound
|
|
||||||
}
|
|
||||||
func (f *fakeRepo) ListRequests(_ context.Context, _ string, _ int) ([]*store.Request, error) {
|
|
||||||
return f.requests, nil
|
|
||||||
}
|
|
||||||
func (f *fakeRepo) SearchRequests(_ context.Context, _ string, query string, limit int) ([]*store.Request, error) {
|
|
||||||
if query == "" {
|
|
||||||
return f.requests, nil
|
|
||||||
}
|
|
||||||
var matched []*store.Request
|
|
||||||
for _, r := range f.requests {
|
|
||||||
if strings.Contains(r.Method, query) ||
|
|
||||||
strings.Contains(r.Path, query) ||
|
|
||||||
strings.Contains(r.Headers, query) ||
|
|
||||||
strings.Contains(r.Query, query) ||
|
|
||||||
strings.Contains(string(r.Body), query) {
|
|
||||||
matched = append(matched, r)
|
|
||||||
if limit > 0 && len(matched) >= limit {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return matched, nil
|
|
||||||
}
|
|
||||||
func (f *fakeRepo) GetMock(_ context.Context, endpointID string) (*store.MockConfig, error) {
|
|
||||||
m, ok := f.mocks[endpointID]
|
|
||||||
if !ok {
|
|
||||||
return nil, errNotFound
|
|
||||||
}
|
|
||||||
return m, nil
|
|
||||||
}
|
|
||||||
func (f *fakeRepo) SetMock(_ context.Context, mock *store.MockConfig) error {
|
|
||||||
f.mocks[mock.EndpointID] = mock
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
func (f *fakeRepo) Close() error { return nil }
|
|
||||||
|
|
||||||
var errNotFound = &se{"nf"}
|
|
||||||
|
|
||||||
type se struct{ m string }
|
|
||||||
|
|
||||||
func (e *se) Error() string { return e.m }
|
|
||||||
|
|
||||||
// testRouter creates a chi router with all routes registered using a fake repo.
|
// testRouter creates a chi router with all routes registered using a fake repo.
|
||||||
func testRouter(repo store.Repository) chi.Router {
|
func testRouter(repo store.Repository) chi.Router {
|
||||||
@ -104,7 +25,7 @@ func testRouter(repo store.Repository) chi.Router {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestHealthz(t *testing.T) {
|
func TestHealthz(t *testing.T) {
|
||||||
r := testRouter(newFakeRepo())
|
r := testRouter(testutil.NewFakeRepository())
|
||||||
req := httptest.NewRequest(http.MethodGet, "/healthz", nil)
|
req := httptest.NewRequest(http.MethodGet, "/healthz", nil)
|
||||||
w := httptest.NewRecorder()
|
w := httptest.NewRecorder()
|
||||||
r.ServeHTTP(w, req)
|
r.ServeHTTP(w, req)
|
||||||
@ -121,7 +42,7 @@ func TestHealthz(t *testing.T) {
|
|||||||
func TestCaptureRecordsAllVerbs(t *testing.T) {
|
func TestCaptureRecordsAllVerbs(t *testing.T) {
|
||||||
methods := []string{"GET", "POST", "PUT", "PATCH", "DELETE"}
|
methods := []string{"GET", "POST", "PUT", "PATCH", "DELETE"}
|
||||||
for _, m := range methods {
|
for _, m := range methods {
|
||||||
repo := newFakeRepo()
|
repo := testutil.NewFakeRepository()
|
||||||
r := testRouter(repo)
|
r := testRouter(repo)
|
||||||
|
|
||||||
body := ""
|
body := ""
|
||||||
@ -138,11 +59,11 @@ func TestCaptureRecordsAllVerbs(t *testing.T) {
|
|||||||
if w.Code != 200 {
|
if w.Code != 200 {
|
||||||
t.Errorf("%s: expected 200, got %d", m, w.Code)
|
t.Errorf("%s: expected 200, got %d", m, w.Code)
|
||||||
}
|
}
|
||||||
if len(repo.requests) != 1 {
|
if len(repo.Requests) != 1 {
|
||||||
t.Errorf("%s: expected 1 request, got %d", m, len(repo.requests))
|
t.Errorf("%s: expected 1 request, got %d", m, len(repo.Requests))
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
reqCaptured := repo.requests[0]
|
reqCaptured := repo.Requests[0]
|
||||||
if reqCaptured.Method != m {
|
if reqCaptured.Method != m {
|
||||||
t.Errorf("%s: wrong method %s", m, reqCaptured.Method)
|
t.Errorf("%s: wrong method %s", m, reqCaptured.Method)
|
||||||
}
|
}
|
||||||
@ -150,7 +71,7 @@ func TestCaptureRecordsAllVerbs(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestSSEStreamReceivesEventOnCapture(t *testing.T) {
|
func TestSSEStreamReceivesEventOnCapture(t *testing.T) {
|
||||||
repo := newFakeRepo()
|
repo := testutil.NewFakeRepository()
|
||||||
srv := httptest.NewServer(testRouter(repo))
|
srv := httptest.NewServer(testRouter(repo))
|
||||||
defer srv.Close()
|
defer srv.Close()
|
||||||
|
|
||||||
@ -224,7 +145,7 @@ func TestSSEStreamReceivesEventOnCapture(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestInspectReturnsHTML(t *testing.T) {
|
func TestInspectReturnsHTML(t *testing.T) {
|
||||||
repo := newFakeRepo()
|
repo := testutil.NewFakeRepository()
|
||||||
repo.CreateEndpoint(nil, "ep")
|
repo.CreateEndpoint(nil, "ep")
|
||||||
repo.AppendRequest(nil, "ep", &store.Request{
|
repo.AppendRequest(nil, "ep", &store.Request{
|
||||||
Method: "POST",
|
Method: "POST",
|
||||||
@ -290,7 +211,7 @@ func TestInspectReturnsHTML(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestInspectEmptyState(t *testing.T) {
|
func TestInspectEmptyState(t *testing.T) {
|
||||||
repo := newFakeRepo()
|
repo := testutil.NewFakeRepository()
|
||||||
repo.CreateEndpoint(nil, "new-ep")
|
repo.CreateEndpoint(nil, "new-ep")
|
||||||
|
|
||||||
r := testRouter(repo)
|
r := testRouter(repo)
|
||||||
@ -320,7 +241,7 @@ func TestInspectEmptyState(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestInspectAutoCreatesEndpoint(t *testing.T) {
|
func TestInspectAutoCreatesEndpoint(t *testing.T) {
|
||||||
repo := newFakeRepo()
|
repo := testutil.NewFakeRepository()
|
||||||
|
|
||||||
// The endpoint doesn't exist yet.
|
// The endpoint doesn't exist yet.
|
||||||
r := testRouter(repo)
|
r := testRouter(repo)
|
||||||
@ -350,7 +271,7 @@ func TestInspectAutoCreatesEndpoint(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestCaptureReturnsJSON200(t *testing.T) {
|
func TestCaptureReturnsJSON200(t *testing.T) {
|
||||||
r := testRouter(newFakeRepo())
|
r := testRouter(testutil.NewFakeRepository())
|
||||||
w := httptest.NewRecorder()
|
w := httptest.NewRecorder()
|
||||||
r.ServeHTTP(w, httptest.NewRequest("GET", "/t", nil))
|
r.ServeHTTP(w, httptest.NewRequest("GET", "/t", nil))
|
||||||
|
|
||||||
@ -369,7 +290,7 @@ func TestCaptureReturnsJSON200(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestMockSetsAndConfiguresResponse(t *testing.T) {
|
func TestMockSetsAndConfiguresResponse(t *testing.T) {
|
||||||
repo := newFakeRepo()
|
repo := testutil.NewFakeRepository()
|
||||||
repo.CreateEndpoint(nil, "mock-ep")
|
repo.CreateEndpoint(nil, "mock-ep")
|
||||||
|
|
||||||
r := testRouter(repo)
|
r := testRouter(repo)
|
||||||
@ -399,7 +320,7 @@ func TestMockSetsAndConfiguresResponse(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestMockReturnsConfiguredResponseOnCapture(t *testing.T) {
|
func TestMockReturnsConfiguredResponseOnCapture(t *testing.T) {
|
||||||
repo := newFakeRepo()
|
repo := testutil.NewFakeRepository()
|
||||||
repo.CreateEndpoint(nil, "mock-cap")
|
repo.CreateEndpoint(nil, "mock-cap")
|
||||||
// Pre-set a mock.
|
// Pre-set a mock.
|
||||||
repo.SetMock(nil, &store.MockConfig{
|
repo.SetMock(nil, &store.MockConfig{
|
||||||
@ -427,16 +348,16 @@ func TestMockReturnsConfiguredResponseOnCapture(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Request should still be captured even when mock responds.
|
// Request should still be captured even when mock responds.
|
||||||
if len(repo.requests) != 1 {
|
if len(repo.Requests) != 1 {
|
||||||
t.Fatalf("expected 1 captured request, got %d", len(repo.requests))
|
t.Fatalf("expected 1 captured request, got %d", len(repo.Requests))
|
||||||
}
|
}
|
||||||
if repo.requests[0].Method != "POST" {
|
if repo.Requests[0].Method != "POST" {
|
||||||
t.Errorf("captured method: %s", repo.requests[0].Method)
|
t.Errorf("captured method: %s", repo.Requests[0].Method)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestMockAutoCreatesEndpointOnSet(t *testing.T) {
|
func TestMockAutoCreatesEndpointOnSet(t *testing.T) {
|
||||||
repo := newFakeRepo()
|
repo := testutil.NewFakeRepository()
|
||||||
r := testRouter(repo)
|
r := testRouter(repo)
|
||||||
|
|
||||||
// Set a mock on an endpoint that doesn't exist yet.
|
// Set a mock on an endpoint that doesn't exist yet.
|
||||||
|
|||||||
@ -8,13 +8,14 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/elfoundation/hatch/internal/store"
|
"github.com/elfoundation/hatch/internal/store"
|
||||||
|
"github.com/elfoundation/hatch/internal/testutil"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestReplayMissingTargetURL(t *testing.T) {
|
func TestReplayMissingTargetURL(t *testing.T) {
|
||||||
repo := newFakeRepo()
|
repo := testutil.NewFakeRepository()
|
||||||
repo.CreateEndpoint(nil, "ep")
|
repo.CreateEndpoint(nil, "ep")
|
||||||
repo.AppendRequest(nil, "ep", &store.Request{Method: "POST", Path: "/webhook", Headers: "{}"})
|
repo.AppendRequest(nil, "ep", &store.Request{Method: "POST", Path: "/webhook", Headers: "{}"})
|
||||||
reqID := repo.requests[0].ID
|
reqID := repo.Requests[0].ID
|
||||||
|
|
||||||
r := testRouter(repo)
|
r := testRouter(repo)
|
||||||
body := `{}`
|
body := `{}`
|
||||||
@ -34,10 +35,10 @@ func TestReplayMissingTargetURL(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestReplayInvalidScheme(t *testing.T) {
|
func TestReplayInvalidScheme(t *testing.T) {
|
||||||
repo := newFakeRepo()
|
repo := testutil.NewFakeRepository()
|
||||||
repo.CreateEndpoint(nil, "ep")
|
repo.CreateEndpoint(nil, "ep")
|
||||||
repo.AppendRequest(nil, "ep", &store.Request{Method: "GET", Path: "/", Headers: "{}"})
|
repo.AppendRequest(nil, "ep", &store.Request{Method: "GET", Path: "/", Headers: "{}"})
|
||||||
reqID := repo.requests[0].ID
|
reqID := repo.Requests[0].ID
|
||||||
|
|
||||||
r := testRouter(repo)
|
r := testRouter(repo)
|
||||||
body := `{"target_url": "ftp://bad.scheme/"}`
|
body := `{"target_url": "ftp://bad.scheme/"}`
|
||||||
@ -52,10 +53,10 @@ func TestReplayInvalidScheme(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestReplaySSRFBlocksLocalhost(t *testing.T) {
|
func TestReplaySSRFBlocksLocalhost(t *testing.T) {
|
||||||
repo := newFakeRepo()
|
repo := testutil.NewFakeRepository()
|
||||||
repo.CreateEndpoint(nil, "ep")
|
repo.CreateEndpoint(nil, "ep")
|
||||||
repo.AppendRequest(nil, "ep", &store.Request{Method: "GET", Path: "/", Headers: "{}"})
|
repo.AppendRequest(nil, "ep", &store.Request{Method: "GET", Path: "/", Headers: "{}"})
|
||||||
reqID := repo.requests[0].ID
|
reqID := repo.Requests[0].ID
|
||||||
|
|
||||||
r := testRouter(repo)
|
r := testRouter(repo)
|
||||||
body := `{"target_url": "http://localhost:8080/"}`
|
body := `{"target_url": "http://localhost:8080/"}`
|
||||||
@ -70,10 +71,10 @@ func TestReplaySSRFBlocksLocalhost(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestReplaySSRFBlocksPrivate(t *testing.T) {
|
func TestReplaySSRFBlocksPrivate(t *testing.T) {
|
||||||
repo := newFakeRepo()
|
repo := testutil.NewFakeRepository()
|
||||||
repo.CreateEndpoint(nil, "ep")
|
repo.CreateEndpoint(nil, "ep")
|
||||||
repo.AppendRequest(nil, "ep", &store.Request{Method: "GET", Path: "/", Headers: "{}"})
|
repo.AppendRequest(nil, "ep", &store.Request{Method: "GET", Path: "/", Headers: "{}"})
|
||||||
reqID := repo.requests[0].ID
|
reqID := repo.Requests[0].ID
|
||||||
|
|
||||||
r := testRouter(repo)
|
r := testRouter(repo)
|
||||||
for _, addr := range []string{
|
for _, addr := range []string{
|
||||||
@ -96,7 +97,7 @@ func TestReplaySSRFBlocksPrivate(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestReplayNotFound(t *testing.T) {
|
func TestReplayNotFound(t *testing.T) {
|
||||||
repo := newFakeRepo()
|
repo := testutil.NewFakeRepository()
|
||||||
r := testRouter(repo)
|
r := testRouter(repo)
|
||||||
body := `{"target_url": "https://example.com/"}`
|
body := `{"target_url": "https://example.com/"}`
|
||||||
req := httptest.NewRequest(http.MethodPost, "/e/ep/requests/nonexistent/replay", strings.NewReader(body))
|
req := httptest.NewRequest(http.MethodPost, "/e/ep/requests/nonexistent/replay", strings.NewReader(body))
|
||||||
@ -118,7 +119,7 @@ func TestReplaySuccess(t *testing.T) {
|
|||||||
}))
|
}))
|
||||||
defer sink.Close()
|
defer sink.Close()
|
||||||
|
|
||||||
repo := newFakeRepo()
|
repo := testutil.NewFakeRepository()
|
||||||
repo.CreateEndpoint(nil, "ep")
|
repo.CreateEndpoint(nil, "ep")
|
||||||
repo.AppendRequest(nil, "ep", &store.Request{
|
repo.AppendRequest(nil, "ep", &store.Request{
|
||||||
Method: "POST",
|
Method: "POST",
|
||||||
@ -127,7 +128,7 @@ func TestReplaySuccess(t *testing.T) {
|
|||||||
Query: "foo=bar",
|
Query: "foo=bar",
|
||||||
Body: []byte(`{"msg":"hello"}`),
|
Body: []byte(`{"msg":"hello"}`),
|
||||||
})
|
})
|
||||||
reqID := repo.requests[0].ID
|
reqID := repo.Requests[0].ID
|
||||||
|
|
||||||
// Set env to allow private replay since httptest server is on loopback.
|
// Set env to allow private replay since httptest server is on loopback.
|
||||||
t.Setenv("HATCH_ALLOW_PRIVATE_REPLAY", "true")
|
t.Setenv("HATCH_ALLOW_PRIVATE_REPLAY", "true")
|
||||||
@ -175,7 +176,7 @@ func TestReplayE2ECaptureThenReplay(t *testing.T) {
|
|||||||
defer sink.Close()
|
defer sink.Close()
|
||||||
|
|
||||||
// Step 2: Capture a request.
|
// Step 2: Capture a request.
|
||||||
repo := newFakeRepo()
|
repo := testutil.NewFakeRepository()
|
||||||
rt := testRouter(repo)
|
rt := testRouter(repo)
|
||||||
|
|
||||||
captureReq := httptest.NewRequest("POST", "/e2e-test", strings.NewReader(`{"order":"1"}`))
|
captureReq := httptest.NewRequest("POST", "/e2e-test", strings.NewReader(`{"order":"1"}`))
|
||||||
@ -183,10 +184,10 @@ func TestReplayE2ECaptureThenReplay(t *testing.T) {
|
|||||||
captureW := httptest.NewRecorder()
|
captureW := httptest.NewRecorder()
|
||||||
rt.ServeHTTP(captureW, captureReq)
|
rt.ServeHTTP(captureW, captureReq)
|
||||||
|
|
||||||
if len(repo.requests) != 1 {
|
if len(repo.Requests) != 1 {
|
||||||
t.Fatalf("expected 1 captured request, got %d", len(repo.requests))
|
t.Fatalf("expected 1 captured request, got %d", len(repo.Requests))
|
||||||
}
|
}
|
||||||
captured := repo.requests[0]
|
captured := repo.Requests[0]
|
||||||
if captured.Method != "POST" {
|
if captured.Method != "POST" {
|
||||||
t.Errorf("captured method: %s", captured.Method)
|
t.Errorf("captured method: %s", captured.Method)
|
||||||
}
|
}
|
||||||
|
|||||||
98
internal/testutil/fake_repo.go
Normal file
98
internal/testutil/fake_repo.go
Normal file
@ -0,0 +1,98 @@
|
|||||||
|
package testutil
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/elfoundation/hatch/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
// FakeRepository implements store.Repository for tests.
|
||||||
|
type FakeRepository struct {
|
||||||
|
Endpoints map[string]*store.Endpoint
|
||||||
|
Requests []*store.Request
|
||||||
|
Mocks map[string]*store.MockConfig
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewFakeRepository creates a new FakeRepository.
|
||||||
|
func NewFakeRepository() *FakeRepository {
|
||||||
|
return &FakeRepository{
|
||||||
|
Endpoints: map[string]*store.Endpoint{},
|
||||||
|
Mocks: map[string]*store.MockConfig{},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *FakeRepository) CreateEndpoint(_ context.Context, url string) (*store.Endpoint, error) {
|
||||||
|
e := &store.Endpoint{ID: url, URL: url, CreatedAt: "t", UpdatedAt: "t"}
|
||||||
|
f.Endpoints[url] = e
|
||||||
|
return e, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *FakeRepository) GetEndpoint(_ context.Context, id string) (*store.Endpoint, error) {
|
||||||
|
e, ok := f.Endpoints[id]
|
||||||
|
if !ok {
|
||||||
|
return nil, errNotFound
|
||||||
|
}
|
||||||
|
return e, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *FakeRepository) AppendRequest(_ context.Context, eid string, r *store.Request) error {
|
||||||
|
r.ID = "req-" + string(rune(len(f.Requests)+'0'))
|
||||||
|
r.EndpointID = eid
|
||||||
|
f.Requests = append(f.Requests, r)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *FakeRepository) GetRequest(_ context.Context, id string) (*store.Request, error) {
|
||||||
|
for _, r := range f.Requests {
|
||||||
|
if r.ID == id {
|
||||||
|
return r, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil, errNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *FakeRepository) ListRequests(_ context.Context, _ string, _ int) ([]*store.Request, error) {
|
||||||
|
return f.Requests, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *FakeRepository) SearchRequests(_ context.Context, _ string, query string, limit int) ([]*store.Request, error) {
|
||||||
|
if query == "" {
|
||||||
|
return f.Requests, nil
|
||||||
|
}
|
||||||
|
var matched []*store.Request
|
||||||
|
for _, r := range f.Requests {
|
||||||
|
if strings.Contains(r.Method, query) ||
|
||||||
|
strings.Contains(r.Path, query) ||
|
||||||
|
strings.Contains(r.Headers, query) ||
|
||||||
|
strings.Contains(r.Query, query) ||
|
||||||
|
strings.Contains(string(r.Body), query) {
|
||||||
|
matched = append(matched, r)
|
||||||
|
if limit > 0 && len(matched) >= limit {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return matched, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *FakeRepository) GetMock(_ context.Context, endpointID string) (*store.MockConfig, error) {
|
||||||
|
m, ok := f.Mocks[endpointID]
|
||||||
|
if !ok {
|
||||||
|
return nil, errNotFound
|
||||||
|
}
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *FakeRepository) SetMock(_ context.Context, mock *store.MockConfig) error {
|
||||||
|
f.Mocks[mock.EndpointID] = mock
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *FakeRepository) Close() error { return nil }
|
||||||
|
|
||||||
|
type notFoundError struct{}
|
||||||
|
|
||||||
|
func (e *notFoundError) Error() string { return "not found" }
|
||||||
|
|
||||||
|
var errNotFound = ¬FoundError{}
|
||||||
Loading…
Reference in New Issue
Block a user