- 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
169 lines
6.0 KiB
Go
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())
|
|
}
|
|
}
|