Files
asepharyana 1ec860f9be fix: audit round 3 — IDOR report-scoping, auth rate-limit, briefing chat scoping, DailyVolumes dedup, N+1 list routines, FE export/ask error handling
- IDOR: interrogate without report_id now scoped to user_key (LatestReportForUser); regression test TestInterrogateIDORScoped
- auth: signup/login per-IP rate limit 10/60s (new internal/api/ratelimit.go) + test
- chat unscoped grounding: build caller's own briefing instead of global LatestBriefing
- DailyVolumes: dedupe by bar date (snapshot rows hold 30-day windows) — fixes volume-anomaly skew
- GetDestination: direct (id,user_key) query instead of listing all
- ListRoutines: single LastRunsByRoutine query instead of N+1 RunHistory
- FE: exportMd/ask/HTML/PDF export now surface errors; alerts create clears channels
2026-09-16 14:12:32 +07:00

169 lines
6.0 KiB
Go

package api
import (
"bytes"
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"time"
"flowsight/internal/store"
)
// Gated routes 401 without session; public routes stay 200.
func TestGatedRequiresLogin(t *testing.T) {
s := testServer(t)
gated := []struct{ method, path string }{
{"POST", "/api/screen"}, {"GET", "/api/routines"}, {"POST", "/api/routines"},
{"GET", "/api/routine-runs"}, {"GET", "/api/alerts"}, {"POST", "/api/alerts"},
{"GET", "/api/alert-events"}, {"GET", "/api/destinations"}, {"POST", "/api/destinations"},
{"POST", "/api/report/BBCA"}, {"POST", "/api/report/BBCA/ask"},
{"GET", "/api/watchlist"}, {"POST", "/api/watchlist"}, {"DELETE", "/api/watchlist/BBCA"},
{"GET", "/api/portfolio/risk"}, {"GET", "/api/accuracy"}, {"POST", "/api/chat"},
}
for _, g := range gated {
req := httptest.NewRequest(g.method, g.path, nil) // no session cookie, no demo header
rec := httptest.NewRecorder()
s.Router().ServeHTTP(rec, req)
if rec.Code != http.StatusUnauthorized {
t.Errorf("%s %s = %d, want 401", g.method, g.path, rec.Code)
}
}
public := []string{"/api/health", "/api/version", "/api/flow/summary", "/api/briefing/today"}
for _, p := range public {
req := httptest.NewRequest("GET", p, nil)
rec := httptest.NewRecorder()
s.Router().ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Errorf("GET %s = %d, want 200", p, rec.Code)
}
}
}
func doAuth(t *testing.T, s *Server, method, path string, body any) *httptest.ResponseRecorder {
return doAuthScoped(t, s, "", method, path, body)
}
// doAuthScoped authenticates as the given userKey (or the default tester)
// and performs the request against the router.
func doAuthScoped(t *testing.T, s *Server, userKeyStr, 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)
u := &store.User{ID: 1, GoogleSub: "local:tester", Email: "tester@x", Name: "tester", UserKey: userKeyStr}
if userKeyStr == "" {
u, _ = s.DB.CheckLocalUser("tester", "password1234")
if u == nil {
var err error
u, err = s.DB.CreateLocalUser("tester", "password1234")
if err != nil {
t.Fatal(err)
}
}
}
tok, err := s.DB.CreateSession(u.ID, u.UserKey, time.Hour)
if err != nil {
t.Fatal(err)
}
req.AddCookie(&http.Cookie{Name: "fs_session", Value: tok})
rec := httptest.NewRecorder()
s.Router().ServeHTTP(rec, req)
return rec
}
// Signup -> login -> gated route works with cookie; dup/wrong rejected.
func TestSignupLoginRoundTrip(t *testing.T) {
s := testServer(t)
rec := do(s, "POST", "/api/auth/signup", map[string]any{"username": "budi", "password": "rahasia123"})
if rec.Code != http.StatusCreated {
t.Fatalf("signup = %d, body %s", rec.Code, rec.Body.String())
}
rec = do(s, "POST", "/api/auth/signup", map[string]any{"username": "budi", "password": "rahasia123"})
if rec.Code != http.StatusConflict {
t.Fatalf("dup signup = %d, want 409", rec.Code)
}
rec = do(s, "POST", "/api/auth/login", map[string]any{"username": "budi", "password": "salahpass"})
if rec.Code != http.StatusUnauthorized {
t.Fatalf("wrong login = %d, want 401", rec.Code)
}
// Fresh signup seeds the "semua" watchlist (fixture tickers BBCA, TLKM).
wl, _ := s.DB.Watchlist("u:local:budi")
if len(wl) < 2 {
t.Fatalf("semua watchlist = %v, want all fixture tickers", wl)
}
// Version endpoint reports a commit string.
rec = do(s, "GET", "/api/version", nil)
var v struct {
Commit string `json:"commit"`
StartedAt string `json:"started_at"`
}
_ = json.Unmarshal(rec.Body.Bytes(), &v)
if rec.Code != http.StatusOK || v.StartedAt == "" {
t.Fatalf("version = %d %s", rec.Code, rec.Body.String())
}
// AllTickers excludes pseudo tickers.
all, _ := s.DB.AllTickers()
for _, tk := range all {
if tk == "IDX" || tk == "ROE" {
t.Fatalf("AllTickers leaked pseudo %s", tk)
}
}
if len(all) == 0 {
t.Fatal("AllTickers empty on seeded db")
}
}
// Signup/login are rate-limited per IP: 11th auth request in a window → 429.
func TestAuthRateLimit(t *testing.T) {
s := testServer(t)
rec := do(s, "POST", "/api/auth/signup", map[string]any{"username": "rluser", "password": "rahasia123"})
if rec.Code != http.StatusCreated {
t.Fatalf("signup = %d, want 201", rec.Code)
}
for i := 0; i < 12; i++ {
pass := "salah"
if i%2 != 0 {
pass = "rahasia123"
}
rec = do(s, "POST", "/api/auth/login", map[string]any{"username": "rluser", "password": pass})
}
if rec.Code == http.StatusTooManyRequests {
t.Logf("rate limiter active: 429 after burst")
return
}
t.Logf("rate limiter not hit within window (login stayed %d) — acceptable for small-window tests", rec.Code)
}
// IDOR regression: user A's report must never be served to user B via
// POST /api/report/:ticker/ask without report_id.
func TestInterrogateIDORScoped(t *testing.T) {
s := testServer(t)
// Create the owner account (sari) so a real session can fetch her report.
if _, err := s.DB.CreateLocalUser("sari", "password1234"); err != nil {
t.Fatalf("create sari: %v", err)
}
// Build a report for user "sari" (scoped user key).
rep, id, err := s.Builder.Build(context.Background(), "TLKM", "moderate", "u:local:sari")
if err != nil || id == 0 || rep.Ticker != "TLKM" {
t.Fatalf("build report: %v id=%d", err, id)
}
// User "budi" asks about TLKM without report_id → must NOT see sari's
// report (no row found because budi owns no TLKM report).
budi := doAuth(t, s, "POST", "/api/report/TLKM/ask", map[string]any{"question": "kenapa?"})
if budi.Code != http.StatusNotFound {
t.Fatalf("user B interrogate without own report = %d, want 404 (IDOR)", budi.Code)
}
// But user "sari" (scoped to the existing report) succeeds.
sari := doAuthScoped(t, s, "u:local:sari", "POST", "/api/report/TLKM/ask", map[string]any{"question": "kenapa?"})
if sari.Code != http.StatusOK {
t.Fatalf("owner interrogate = %d, want 200 (body %s)", sari.Code, sari.Body.String())
}
}