feat(18-02): REST API handlers for /api/v1/* endpoints

- Stats, keys, providers, scan, recon, dorks, config endpoints
- JSON response wrappers with proper tags for all entities
- Filtering, pagination, 404/204/202 status codes
- SSE hub stub (full impl in task 2)
- Resolved merge conflict in schema.sql
- 16 passing tests covering all endpoints
This commit is contained in:
salvacybersec
2026-04-06 18:05:39 +03:00
parent 17c17944aa
commit 76601b11b5
7 changed files with 962 additions and 19 deletions

329
pkg/web/api_test.go Normal file
View File

@@ -0,0 +1,329 @@
package web
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/go-chi/chi/v5"
"github.com/salvacybersec/keyhunter/pkg/dorks"
"github.com/salvacybersec/keyhunter/pkg/providers"
"github.com/salvacybersec/keyhunter/pkg/recon"
"github.com/salvacybersec/keyhunter/pkg/storage"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// testServer creates a Server with in-memory DB and minimal registries for testing.
func testServer(t *testing.T) (*Server, []byte) {
t.Helper()
db, err := storage.Open(":memory:")
require.NoError(t, err)
t.Cleanup(func() { db.Close() })
encKey := []byte("0123456789abcdef0123456789abcdef") // 32-byte test key
provReg := providers.NewRegistryFromProviders([]providers.Provider{
{Name: "openai", DisplayName: "OpenAI", Tier: 1, Keywords: []string{"sk-"}},
{Name: "anthropic", DisplayName: "Anthropic", Tier: 1, Keywords: []string{"sk-ant-"}},
})
dorkReg := dorks.NewRegistryFromDorks([]dorks.Dork{
{ID: "gh-openai-1", Name: "OpenAI GitHub", Source: "github", Category: "frontier", Query: "sk-proj- in:file", Description: "Find OpenAI keys on GitHub"},
})
reconEng := recon.NewEngine()
s := NewServer(ServerConfig{
DB: db,
EncKey: encKey,
Providers: provReg,
Dorks: dorkReg,
ReconEngine: reconEng,
})
return s, encKey
}
// seedFinding inserts a test finding and returns its ID.
func seedFinding(t *testing.T, db *storage.DB, encKey []byte, provider string) int64 {
t.Helper()
id, err := db.SaveFinding(storage.Finding{
ProviderName: provider,
KeyValue: "sk-test1234567890abcdefghijklmnop",
KeyMasked: "sk-test1...mnop",
Confidence: "high",
SourcePath: "/tmp/test.py",
SourceType: "file",
LineNumber: 42,
}, encKey)
require.NoError(t, err)
return id
}
func TestAPIStats(t *testing.T) {
s, encKey := testServer(t)
seedFinding(t, s.cfg.DB, encKey, "openai")
r := chi.NewRouter()
s.mountAPI(r)
req := httptest.NewRequest(http.MethodGet, "/api/v1/stats", nil)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
assert.Equal(t, "application/json", w.Header().Get("Content-Type"))
var body map[string]interface{}
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &body))
assert.Contains(t, body, "totalKeys")
assert.Contains(t, body, "totalProviders")
assert.Contains(t, body, "reconSources")
}
func TestAPIListKeys(t *testing.T) {
s, encKey := testServer(t)
seedFinding(t, s.cfg.DB, encKey, "openai")
seedFinding(t, s.cfg.DB, encKey, "anthropic")
r := chi.NewRouter()
s.mountAPI(r)
req := httptest.NewRequest(http.MethodGet, "/api/v1/keys", nil)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
var keys []map[string]interface{}
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &keys))
assert.Len(t, keys, 2)
// Keys should be masked (no raw key value exposed)
for _, k := range keys {
val, ok := k["keyValue"]
assert.True(t, ok)
assert.Equal(t, "", val, "API must not expose raw key values")
}
}
func TestAPIListKeysFilterByProvider(t *testing.T) {
s, encKey := testServer(t)
seedFinding(t, s.cfg.DB, encKey, "openai")
seedFinding(t, s.cfg.DB, encKey, "anthropic")
r := chi.NewRouter()
s.mountAPI(r)
req := httptest.NewRequest(http.MethodGet, "/api/v1/keys?provider=openai", nil)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
var keys []map[string]interface{}
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &keys))
assert.Len(t, keys, 1)
}
func TestAPIGetKey(t *testing.T) {
s, encKey := testServer(t)
id := seedFinding(t, s.cfg.DB, encKey, "openai")
r := chi.NewRouter()
s.mountAPI(r)
req := httptest.NewRequest(http.MethodGet, "/api/v1/keys/"+itoa(id), nil)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
var body map[string]interface{}
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &body))
assert.Equal(t, "openai", body["providerName"])
}
func TestAPIGetKeyNotFound(t *testing.T) {
s, _ := testServer(t)
r := chi.NewRouter()
s.mountAPI(r)
req := httptest.NewRequest(http.MethodGet, "/api/v1/keys/99999", nil)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusNotFound, w.Code)
}
func TestAPIDeleteKey(t *testing.T) {
s, encKey := testServer(t)
id := seedFinding(t, s.cfg.DB, encKey, "openai")
r := chi.NewRouter()
s.mountAPI(r)
req := httptest.NewRequest(http.MethodDelete, "/api/v1/keys/"+itoa(id), nil)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusNoContent, w.Code)
}
func TestAPIDeleteKeyNotFound(t *testing.T) {
s, _ := testServer(t)
r := chi.NewRouter()
s.mountAPI(r)
req := httptest.NewRequest(http.MethodDelete, "/api/v1/keys/99999", nil)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusNotFound, w.Code)
}
func TestAPIListProviders(t *testing.T) {
s, _ := testServer(t)
r := chi.NewRouter()
s.mountAPI(r)
req := httptest.NewRequest(http.MethodGet, "/api/v1/providers", nil)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
var provs []map[string]interface{}
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &provs))
assert.Len(t, provs, 2)
}
func TestAPIGetProvider(t *testing.T) {
s, _ := testServer(t)
r := chi.NewRouter()
s.mountAPI(r)
req := httptest.NewRequest(http.MethodGet, "/api/v1/providers/openai", nil)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
var body map[string]interface{}
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &body))
assert.Equal(t, "openai", body["name"])
}
func TestAPIGetProviderNotFound(t *testing.T) {
s, _ := testServer(t)
r := chi.NewRouter()
s.mountAPI(r)
req := httptest.NewRequest(http.MethodGet, "/api/v1/providers/nonexistent", nil)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusNotFound, w.Code)
}
func TestAPIScan(t *testing.T) {
s, _ := testServer(t)
r := chi.NewRouter()
s.mountAPI(r)
body := `{"path":"/tmp/test","verify":false,"workers":2}`
req := httptest.NewRequest(http.MethodPost, "/api/v1/scan", bytes.NewBufferString(body))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusAccepted, w.Code)
var resp map[string]interface{}
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp))
assert.Equal(t, "started", resp["status"])
}
func TestAPIRecon(t *testing.T) {
s, _ := testServer(t)
r := chi.NewRouter()
s.mountAPI(r)
body := `{"query":"openai","sources":["github"],"stealth":false}`
req := httptest.NewRequest(http.MethodPost, "/api/v1/recon", bytes.NewBufferString(body))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusAccepted, w.Code)
var resp map[string]interface{}
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp))
assert.Equal(t, "started", resp["status"])
}
func TestAPIListDorks(t *testing.T) {
s, _ := testServer(t)
r := chi.NewRouter()
s.mountAPI(r)
req := httptest.NewRequest(http.MethodGet, "/api/v1/dorks", nil)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
var d []map[string]interface{}
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &d))
assert.Len(t, d, 1)
}
func TestAPIAddDork(t *testing.T) {
s, _ := testServer(t)
r := chi.NewRouter()
s.mountAPI(r)
body := `{"dorkId":"custom-1","name":"Custom Dork","source":"github","category":"custom","query":"custom query","description":"test"}`
req := httptest.NewRequest(http.MethodPost, "/api/v1/dorks", bytes.NewBufferString(body))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusCreated, w.Code)
var resp map[string]interface{}
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp))
assert.Contains(t, resp, "id")
}
func TestAPIGetConfig(t *testing.T) {
s, _ := testServer(t)
r := chi.NewRouter()
s.mountAPI(r)
req := httptest.NewRequest(http.MethodGet, "/api/v1/config", nil)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
assert.Equal(t, "application/json", w.Header().Get("Content-Type"))
}
func TestAPIUpdateConfig(t *testing.T) {
s, _ := testServer(t)
r := chi.NewRouter()
s.mountAPI(r)
body := `{"scan.workers":"8"}`
req := httptest.NewRequest(http.MethodPut, "/api/v1/config", bytes.NewBufferString(body))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
}