diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 56695f7f..eb8c6d72 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -17,6 +17,14 @@ jobs: go-version: '1.25' cache: true + - name: Check formatting + run: | + if [ -n "$(gofmt -l .)" ]; then + echo "Files not formatted:" + gofmt -l . + exit 1 + fi + - name: Vet run: go vet ./... @@ -41,6 +49,21 @@ jobs: curl -fsS http://localhost:8080/healthz | grep -q 'ok' 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: runs-on: ubuntu-latest steps: diff --git a/.gitignore b/.gitignore index 3747f69c..84c3330e 100644 --- a/.gitignore +++ b/.gitignore @@ -12,6 +12,11 @@ go.work /bin /dist +# Runtime data +data/ +*.db +coverage.out + # IDE .idea/ .vscode/ diff --git a/.golangci.yml b/.golangci.yml new file mode 100644 index 00000000..b6c09e86 --- /dev/null +++ b/.golangci.yml @@ -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 \ No newline at end of file diff --git a/Makefile b/Makefile new file mode 100644 index 00000000..06eda28f --- /dev/null +++ b/Makefile @@ -0,0 +1,35 @@ +.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 and goimports +fmt: + gofmt -s -w . + goimports -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/## / /' diff --git a/internal/handler/api_v1_test.go b/internal/handler/api_v1_test.go index 6978f080..d2fbbcb7 100644 --- a/internal/handler/api_v1_test.go +++ b/internal/handler/api_v1_test.go @@ -8,10 +8,11 @@ import ( "testing" "github.com/elfoundation/hatch/internal/store" + "github.com/elfoundation/hatch/internal/testutil" ) func TestV1CaptureRequest(t *testing.T) { - repo := newFakeRepo() + repo := testutil.NewFakeRepository() r := testRouter(repo) 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"]) } // Verify request stored in repo. - if len(repo.requests) != 1 { - t.Fatalf("expected 1 request stored, got %d", len(repo.requests)) + if len(repo.Requests) != 1 { + t.Fatalf("expected 1 request stored, got %d", len(repo.Requests)) } - if repo.requests[0].Headers != `{"X-Custom":"value"}` { - t.Errorf("expected headers JSON, got %q", repo.requests[0].Headers) + if repo.Requests[0].Headers != `{"X-Custom":"value"}` { + t.Errorf("expected headers JSON, got %q", repo.Requests[0].Headers) } } func TestV1ListRequests(t *testing.T) { - repo := newFakeRepo() + repo := testutil.NewFakeRepository() repo.CreateEndpoint(nil, "list-ep") // Add a few requests. 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) { - repo := newFakeRepo() + repo := testutil.NewFakeRepository() 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: "POST", Path: "/orders", Body: []byte(`{"item":"apple"}`)}) @@ -111,7 +112,7 @@ func TestV1SearchRequests(t *testing.T) { } func TestV1MockSet(t *testing.T) { - repo := newFakeRepo() + repo := testutil.NewFakeRepository() repo.CreateEndpoint(nil, "mock-ep") r := testRouter(repo) @@ -138,7 +139,7 @@ func TestV1MockSet(t *testing.T) { } func TestV1OpenAPI(t *testing.T) { - repo := newFakeRepo() + repo := testutil.NewFakeRepository() r := testRouter(repo) req := httptest.NewRequest(http.MethodGet, "/v1/endpoints/test/openapi.json", nil) w := httptest.NewRecorder() diff --git a/internal/handler/handler_test.go b/internal/handler/handler_test.go index 5bbe533c..08c97064 100644 --- a/internal/handler/handler_test.go +++ b/internal/handler/handler_test.go @@ -11,89 +11,10 @@ import ( "time" "github.com/elfoundation/hatch/internal/store" + "github.com/elfoundation/hatch/internal/testutil" "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. func testRouter(repo store.Repository) chi.Router { @@ -104,7 +25,7 @@ func testRouter(repo store.Repository) chi.Router { } func TestHealthz(t *testing.T) { - r := testRouter(newFakeRepo()) + r := testRouter(testutil.NewFakeRepository()) req := httptest.NewRequest(http.MethodGet, "/healthz", nil) w := httptest.NewRecorder() r.ServeHTTP(w, req) @@ -121,7 +42,7 @@ func TestHealthz(t *testing.T) { func TestCaptureRecordsAllVerbs(t *testing.T) { methods := []string{"GET", "POST", "PUT", "PATCH", "DELETE"} for _, m := range methods { - repo := newFakeRepo() + repo := testutil.NewFakeRepository() r := testRouter(repo) body := "" @@ -138,11 +59,11 @@ func TestCaptureRecordsAllVerbs(t *testing.T) { if w.Code != 200 { t.Errorf("%s: expected 200, got %d", m, w.Code) } - if len(repo.requests) != 1 { - t.Errorf("%s: expected 1 request, got %d", m, len(repo.requests)) + if len(repo.Requests) != 1 { + t.Errorf("%s: expected 1 request, got %d", m, len(repo.Requests)) continue } - reqCaptured := repo.requests[0] + reqCaptured := repo.Requests[0] if reqCaptured.Method != m { t.Errorf("%s: wrong method %s", m, reqCaptured.Method) } @@ -150,7 +71,7 @@ func TestCaptureRecordsAllVerbs(t *testing.T) { } func TestSSEStreamReceivesEventOnCapture(t *testing.T) { - repo := newFakeRepo() + repo := testutil.NewFakeRepository() srv := httptest.NewServer(testRouter(repo)) defer srv.Close() @@ -224,7 +145,7 @@ func TestSSEStreamReceivesEventOnCapture(t *testing.T) { } func TestInspectReturnsHTML(t *testing.T) { - repo := newFakeRepo() + repo := testutil.NewFakeRepository() repo.CreateEndpoint(nil, "ep") repo.AppendRequest(nil, "ep", &store.Request{ Method: "POST", @@ -290,7 +211,7 @@ func TestInspectReturnsHTML(t *testing.T) { } func TestInspectEmptyState(t *testing.T) { - repo := newFakeRepo() + repo := testutil.NewFakeRepository() repo.CreateEndpoint(nil, "new-ep") r := testRouter(repo) @@ -320,7 +241,7 @@ func TestInspectEmptyState(t *testing.T) { } func TestInspectAutoCreatesEndpoint(t *testing.T) { - repo := newFakeRepo() + repo := testutil.NewFakeRepository() // The endpoint doesn't exist yet. r := testRouter(repo) @@ -350,7 +271,7 @@ func TestInspectAutoCreatesEndpoint(t *testing.T) { } func TestCaptureReturnsJSON200(t *testing.T) { - r := testRouter(newFakeRepo()) + r := testRouter(testutil.NewFakeRepository()) w := httptest.NewRecorder() r.ServeHTTP(w, httptest.NewRequest("GET", "/t", nil)) @@ -369,7 +290,7 @@ func TestCaptureReturnsJSON200(t *testing.T) { } func TestMockSetsAndConfiguresResponse(t *testing.T) { - repo := newFakeRepo() + repo := testutil.NewFakeRepository() repo.CreateEndpoint(nil, "mock-ep") r := testRouter(repo) @@ -399,7 +320,7 @@ func TestMockSetsAndConfiguresResponse(t *testing.T) { } func TestMockReturnsConfiguredResponseOnCapture(t *testing.T) { - repo := newFakeRepo() + repo := testutil.NewFakeRepository() repo.CreateEndpoint(nil, "mock-cap") // Pre-set a mock. repo.SetMock(nil, &store.MockConfig{ @@ -427,16 +348,16 @@ func TestMockReturnsConfiguredResponseOnCapture(t *testing.T) { } // Request should still be captured even when mock responds. - if len(repo.requests) != 1 { - t.Fatalf("expected 1 captured request, got %d", len(repo.requests)) + if len(repo.Requests) != 1 { + t.Fatalf("expected 1 captured request, got %d", len(repo.Requests)) } - if repo.requests[0].Method != "POST" { - t.Errorf("captured method: %s", repo.requests[0].Method) + if repo.Requests[0].Method != "POST" { + t.Errorf("captured method: %s", repo.Requests[0].Method) } } func TestMockAutoCreatesEndpointOnSet(t *testing.T) { - repo := newFakeRepo() + repo := testutil.NewFakeRepository() r := testRouter(repo) // Set a mock on an endpoint that doesn't exist yet. diff --git a/internal/handler/replay_test.go b/internal/handler/replay_test.go index 93e13112..0aca80c4 100644 --- a/internal/handler/replay_test.go +++ b/internal/handler/replay_test.go @@ -8,13 +8,14 @@ import ( "testing" "github.com/elfoundation/hatch/internal/store" + "github.com/elfoundation/hatch/internal/testutil" ) func TestReplayMissingTargetURL(t *testing.T) { - repo := newFakeRepo() + repo := testutil.NewFakeRepository() repo.CreateEndpoint(nil, "ep") repo.AppendRequest(nil, "ep", &store.Request{Method: "POST", Path: "/webhook", Headers: "{}"}) - reqID := repo.requests[0].ID + reqID := repo.Requests[0].ID r := testRouter(repo) body := `{}` @@ -34,10 +35,10 @@ func TestReplayMissingTargetURL(t *testing.T) { } func TestReplayInvalidScheme(t *testing.T) { - repo := newFakeRepo() + repo := testutil.NewFakeRepository() repo.CreateEndpoint(nil, "ep") repo.AppendRequest(nil, "ep", &store.Request{Method: "GET", Path: "/", Headers: "{}"}) - reqID := repo.requests[0].ID + reqID := repo.Requests[0].ID r := testRouter(repo) body := `{"target_url": "ftp://bad.scheme/"}` @@ -52,10 +53,10 @@ func TestReplayInvalidScheme(t *testing.T) { } func TestReplaySSRFBlocksLocalhost(t *testing.T) { - repo := newFakeRepo() + repo := testutil.NewFakeRepository() repo.CreateEndpoint(nil, "ep") repo.AppendRequest(nil, "ep", &store.Request{Method: "GET", Path: "/", Headers: "{}"}) - reqID := repo.requests[0].ID + reqID := repo.Requests[0].ID r := testRouter(repo) body := `{"target_url": "http://localhost:8080/"}` @@ -70,10 +71,10 @@ func TestReplaySSRFBlocksLocalhost(t *testing.T) { } func TestReplaySSRFBlocksPrivate(t *testing.T) { - repo := newFakeRepo() + repo := testutil.NewFakeRepository() repo.CreateEndpoint(nil, "ep") repo.AppendRequest(nil, "ep", &store.Request{Method: "GET", Path: "/", Headers: "{}"}) - reqID := repo.requests[0].ID + reqID := repo.Requests[0].ID r := testRouter(repo) for _, addr := range []string{ @@ -96,7 +97,7 @@ func TestReplaySSRFBlocksPrivate(t *testing.T) { } func TestReplayNotFound(t *testing.T) { - repo := newFakeRepo() + repo := testutil.NewFakeRepository() r := testRouter(repo) body := `{"target_url": "https://example.com/"}` 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() - repo := newFakeRepo() + repo := testutil.NewFakeRepository() repo.CreateEndpoint(nil, "ep") repo.AppendRequest(nil, "ep", &store.Request{ Method: "POST", @@ -127,7 +128,7 @@ func TestReplaySuccess(t *testing.T) { Query: "foo=bar", 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. t.Setenv("HATCH_ALLOW_PRIVATE_REPLAY", "true") @@ -175,7 +176,7 @@ func TestReplayE2ECaptureThenReplay(t *testing.T) { defer sink.Close() // Step 2: Capture a request. - repo := newFakeRepo() + repo := testutil.NewFakeRepository() rt := testRouter(repo) captureReq := httptest.NewRequest("POST", "/e2e-test", strings.NewReader(`{"order":"1"}`)) @@ -183,10 +184,10 @@ func TestReplayE2ECaptureThenReplay(t *testing.T) { captureW := httptest.NewRecorder() rt.ServeHTTP(captureW, captureReq) - if len(repo.requests) != 1 { - t.Fatalf("expected 1 captured request, got %d", len(repo.requests)) + if len(repo.Requests) != 1 { + t.Fatalf("expected 1 captured request, got %d", len(repo.Requests)) } - captured := repo.requests[0] + captured := repo.Requests[0] if captured.Method != "POST" { t.Errorf("captured method: %s", captured.Method) } diff --git a/internal/testutil/fake_repo.go b/internal/testutil/fake_repo.go new file mode 100644 index 00000000..b9a48f7e --- /dev/null +++ b/internal/testutil/fake_repo.go @@ -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{} \ No newline at end of file