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()) } }