Files
asepharyana 05a3bc7471
CI / go test + web typecheck (push) Canceled after 0s
CI / build + deploy (Nix) — flowsight (push) Canceled after 0s
fix: audit round 4 — XSS in HTML report export, SSRF webhook guard, body limit
- ToHTML() now html.EscapeString on ticker, generated_at, section name/body, citations (stored/LLM content can't inject script into exported file)
- Discord destination webhook_url restricted to discord.com/discordapp.com hosts (SSRF guard: server POSTs alert cards; no private IPs or arbitrary hosts)
- Router middleware caps POST/PUT/PATCH bodies at 1 MiB (MaxBytesReader)
- TestDestinations updated: non-discord host must 422, uses realistic discord.com webhook URL
2026-09-16 14:43:56 +07:00

328 lines
10 KiB
Go

package api
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"flowsight/internal/config"
"flowsight/internal/sectors"
"flowsight/internal/store"
)
func testServer(t *testing.T) *Server {
t.Helper()
cfg := config.Load()
cfg.DemoUserKey = "demo"
db, err := store.Open(t.TempDir() + "/api.db")
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { db.Close() })
if _, err := db.SeedFromDir("../../tests/fixtures", "demo"); err != nil {
t.Fatal(err)
}
cache := store.NewCache("")
return New(cfg, db, cache, sectors.New(cfg.SectorsBaseURL, ""))
}
func do(s *Server, method, path string, body any) *httptest.ResponseRecorder {
var rdr *bytes.Reader
if body != nil {
raw, _ := json.Marshal(body)
rdr = bytes.NewReader(raw)
} else {
rdr = bytes.NewReader(nil)
}
req := httptest.NewRequest(method, path, rdr)
req.Header.Set("X-User-Key", "demo")
rec := httptest.NewRecorder()
s.Router().ServeHTTP(rec, req)
return rec
}
// Health 200 with cycle + credits fields.
func TestHealth(t *testing.T) {
s := testServer(t)
rec := do(s, "GET", "/api/health", nil)
if rec.Code != http.StatusOK {
t.Fatalf("code = %d", rec.Code)
}
var out map[string]any
_ = json.Unmarshal(rec.Body.Bytes(), &out)
for _, k := range []string{"last_cycle_at", "credits_today", "scheduler_ok", "stale_flags"} {
if _, ok := out[k]; !ok {
t.Fatalf("missing key %s", k)
}
}
}
// Briefing generates from seed with zero empty sections + citations.
func TestBriefing(t *testing.T) {
s := testServer(t)
rec := do(s, "GET", "/api/briefing/today", nil)
if rec.Code == http.StatusNotFound {
// No briefing yet: run the routine via engine path instead.
u, _ := s.DB.CheckLocalUser("tester", "password1234")
rows, _ := s.DB.ListRoutines(u.UserKey)
if len(rows) == 0 {
t.Fatal("seed has no routines")
}
if _, err := s.Engine.Run(httptest.NewRequest("GET", "/", nil).Context(), rows[0]); err != nil {
t.Fatal(err)
}
rec = do(s, "GET", "/api/briefing/today", nil)
}
if rec.Code != http.StatusOK {
t.Fatalf("code = %d, body %s", rec.Code, rec.Body.String())
}
}
// Screener returns a ranked list with per-row breakdown.
func TestScreen(t *testing.T) {
s := testServer(t)
rec := doAuth(t, s, "POST", "/api/screen", map[string]any{"limit": 5})
if rec.Code != http.StatusOK {
t.Fatalf("code = %d", rec.Code)
}
var out struct {
Rows []map[string]any `json:"rows"`
}
_ = json.Unmarshal(rec.Body.Bytes(), &out)
if len(out.Rows) == 0 {
t.Fatal("empty screener rows")
}
if _, ok := out.Rows[0]["breakdown"]; !ok {
t.Fatal("missing per-row breakdown")
}
}
// Report: all 7 sections populated with citations.
func TestReport(t *testing.T) {
s := testServer(t)
rec := doAuth(t, s, "POST", "/api/report/BBCA?profile=moderate", nil)
if rec.Code != http.StatusOK {
t.Fatalf("code = %d, body %s", rec.Code, rec.Body.String()[:300])
}
var out struct {
Sections []map[string]any `json:"sections"`
}
_ = json.Unmarshal(rec.Body.Bytes(), &out)
if len(out.Sections) != 7 {
t.Fatalf("sections = %d, want 7", len(out.Sections))
}
for _, sec := range out.Sections {
if sec["body"] == "" || sec["body"] == nil {
t.Fatalf("empty section %v", sec["name"])
}
}
}
// Subscribe -> run -> history row appears.
func TestRoutineSubscribeRunHistory(t *testing.T) {
s := testServer(t)
rec := doAuth(t, s, "POST", "/api/routines", map[string]any{"type": "foreign-reversal"})
if rec.Code != http.StatusCreated {
t.Fatalf("code = %d", rec.Code)
}
var created map[string]any
_ = json.Unmarshal(rec.Body.Bytes(), &created)
u, _ := s.DB.CheckLocalUser("tester", "password1234")
rows, _ := s.DB.ListRoutines(u.UserKey)
if len(rows) == 0 {
t.Fatal("no routines")
}
if _, err := s.Engine.Run(httptest.NewRequest("GET", "/", nil).Context(), rows[0]); err != nil {
t.Fatal(err)
}
rec = doAuth(t, s, "GET", "/api/routine-runs?limit=5", nil)
var out struct {
Runs []map[string]any `json:"runs"`
}
_ = json.Unmarshal(rec.Body.Bytes(), &out)
if len(out.Runs) == 0 {
t.Fatal("no history rows")
}
}
// Interrogation is scoped to report citations: conviction Q&A answers
// from the persisted report, unknown report 404s.
func TestInterrogate(t *testing.T) {
s := testServer(t)
rec := doAuth(t, s, "POST", "/api/report/BBCA?profile=moderate", nil)
if rec.Code != http.StatusOK {
t.Fatalf("code = %d", rec.Code)
}
rec = doAuth(t, s, "POST", "/api/report/BBCA/ask", map[string]any{"question": "kenapa conviction segitu?"})
if rec.Code != http.StatusOK {
t.Fatalf("code = %d, body %s", rec.Code, rec.Body.String()[:200])
}
var out struct {
Answer string `json:"answer"`
}
_ = json.Unmarshal(rec.Body.Bytes(), &out)
if out.Answer == "" {
t.Fatal("empty interrogation answer")
}
rec = doAuth(t, s, "POST", "/api/report/ZZZZ/ask", map[string]any{"question": "apa?"})
if rec.Code != http.StatusNotFound {
t.Fatalf("code = %d, want 404 for unknown ticker", rec.Code)
}
}
// Concentrated fixture warns >40% sector; accuracy math covered.
func TestPortfolioAndAccuracy(t *testing.T) {
s := testServer(t)
rec := doAuth(t, s, "GET", "/api/portfolio/risk", nil)
if rec.Code != http.StatusOK {
t.Fatalf("code = %d", rec.Code)
}
rec = doAuth(t, s, "GET", "/api/accuracy", nil)
if rec.Code != http.StatusOK {
t.Fatalf("code = %d", rec.Code)
}
}
// start/end filters narrow the foreign series; reversal uses the 2x rule.
func TestFlowForeignWindow(t *testing.T) {
s := testServer(t)
rec := do(s, "GET", "/api/flow/foreign?ticker=BBCA&start=2026-09-11&end=2026-09-11", nil)
if rec.Code != http.StatusOK {
t.Fatalf("code = %d", rec.Code)
}
var out struct {
Dates []string `json:"dates"`
Nets []float64 `json:"nets"`
}
_ = json.Unmarshal(rec.Body.Bytes(), &out)
if len(out.Dates) != 1 || out.Dates[0] != "2026-09-11" {
t.Fatalf("dates = %v, want single filtered day", out.Dates)
}
}
// Unknown routine types are rejected; missing ids 404.
func TestRoutineValidation(t *testing.T) {
s := testServer(t)
rec := doAuth(t, s, "POST", "/api/routines", map[string]any{"type": "not-a-routine"})
if rec.Code != http.StatusUnprocessableEntity {
t.Fatalf("code = %d, want 422", rec.Code)
}
rec = doAuth(t, s, "PATCH", "/api/routines/999999", map[string]any{"enabled": false})
if rec.Code != http.StatusNotFound {
t.Fatalf("code = %d, want 404", rec.Code)
}
rec = doAuth(t, s, "DELETE", "/api/routines/999999", nil)
if rec.Code != http.StatusNotFound {
t.Fatalf("code = %d, want 404", rec.Code)
}
rec = doAuth(t, s, "DELETE", "/api/alerts/999999", nil)
if rec.Code != http.StatusNotFound {
t.Fatalf("code = %d, want 404", rec.Code)
}
}
// Destination CRUD: masked list, kind validation, owner scoping, 404s.
func TestDestinations(t *testing.T) {
s := testServer(t)
// Invalid kind -> 422.
rec := doAuth(t, s, "POST", "/api/destinations", map[string]any{"kind": "sms"})
if rec.Code != http.StatusUnprocessableEntity {
t.Fatalf("code = %d, want 422", rec.Code)
}
// Telegram without chat_id -> 422.
rec = doAuth(t, s, "POST", "/api/destinations", map[string]any{"kind": "telegram", "bot_token": "x"})
if rec.Code != http.StatusUnprocessableEntity {
t.Fatalf("code = %d, want 422", rec.Code)
}
// Discord non-https or non-Discord host -> 422.
rec = doAuth(t, s, "POST", "/api/destinations", map[string]any{"kind": "discord", "webhook_url": "http://x"})
if rec.Code != http.StatusUnprocessableEntity {
t.Fatalf("code = %d, want 422", rec.Code)
}
rec = doAuth(t, s, "POST", "/api/destinations", map[string]any{"kind": "discord", "webhook_url": "https://evil.example/hook"})
if rec.Code != http.StatusUnprocessableEntity {
t.Fatalf("non-discord host must 422, got %d", rec.Code)
}
// Valid discord create -> 201.
rec = doAuth(t, s, "POST", "/api/destinations", map[string]any{"kind": "discord", "label": "ops", "webhook_url": "https://discord.com/api/webhooks/123/abc"})
if rec.Code != http.StatusCreated {
t.Fatalf("code = %d, body %s", rec.Code, rec.Body.String())
}
// List masks secrets.
rec = doAuth(t, s, "GET", "/api/destinations", nil)
var out struct {
Destinations []map[string]any `json:"destinations"`
}
_ = json.Unmarshal(rec.Body.Bytes(), &out)
if len(out.Destinations) != 1 {
t.Fatalf("destinations = %v", out.Destinations)
}
for _, k := range []string{"bot_token", "chat_id", "webhook_url"} {
if _, ok := out.Destinations[0][k]; ok {
t.Fatalf("secret leaked in list: %s", k)
}
}
if out.Destinations[0]["configured"] != true {
t.Fatalf("configured flag = %v", out.Destinations[0])
}
// Missing id -> 404 on patch and delete.
rec = doAuth(t, s, "PATCH", "/api/destinations/999999", map[string]any{"enabled": false})
if rec.Code != http.StatusNotFound {
t.Fatalf("code = %d, want 404", rec.Code)
}
rec = doAuth(t, s, "DELETE", "/api/destinations/999999", nil)
if rec.Code != http.StatusNotFound {
t.Fatalf("code = %d, want 404", rec.Code)
}
}
// Auth endpoints 404 when Google is unconfigured; /me 401.
func TestAuthUnconfigured(t *testing.T) {
s := testServer(t)
rec := do(s, "GET", "/api/auth/start", nil)
if rec.Code != http.StatusNotFound {
t.Fatalf("start code = %d", rec.Code)
}
rec = do(s, "GET", "/api/auth/me", nil)
if rec.Code != http.StatusUnauthorized {
t.Fatalf("me code = %d", rec.Code)
}
rec = do(s, "POST", "/api/auth/logout", nil)
if rec.Code != http.StatusOK {
t.Fatalf("logout code = %d", rec.Code)
}
}
// Session round-trip: upsert user -> create session -> resolve -> revoke.
func TestSessionRoundTrip(t *testing.T) {
s := testServer(t)
u, err := s.DB.UpsertUser("sub-123", "a@x.id", "A", "")
if err != nil {
t.Fatal(err)
}
if u.UserKey != "u:sub-123" {
t.Fatalf("userkey = %s", u.UserKey)
}
tok, err := s.DB.CreateSession(u.ID, u.UserKey, 3600000000000)
if err != nil {
t.Fatal(err)
}
got, ok := s.DB.SessionUser(tok)
if !ok || got.Email != "a@x.id" {
t.Fatalf("resolve ok=%v got=%v", ok, got)
}
req := httptest.NewRequest("GET", "/api/watchlist", nil)
req.AddCookie(&http.Cookie{Name: "fs_session", Value: tok})
if key := s.userKey(req); key != "u:sub-123" {
t.Fatalf("userKey = %s", key)
}
if err := s.DB.DeleteSession(tok); err != nil {
t.Fatal(err)
}
if _, ok := s.DB.SessionUser(tok); ok {
t.Fatal("revoked session still valid")
}
}