package api import ( "bytes" "git.misaka.ren/m1saka/dsh-web/internal/store" "golang.org/x/crypto/bcrypt" "net/http" "net/http/httptest" "os" "path/filepath" "strings" "sync" "testing" "time" ) func authFixture(t *testing.T) (*API, func(string, string, string, string, []*http.Cookie) *httptest.ResponseRecorder) { t.Helper() s, e := store.Open(filepath.Join(t.TempDir(), "db")) if e != nil { t.Fatal(e) } t.Cleanup(s.Close) h, _ := bcrypt.GenerateFromPassword([]byte("password-123"), bcrypt.MinCost) for _, id := range []string{"alice", "bob"} { if e = s.CreateUser(store.User{ID: id, Username: id}, string(h)); e != nil { t.Fatal(e) } } a := New(s, t.TempDir()) a.origin = "https://panel.test" return a, func(method, path, body, csrf string, cookies []*http.Cookie) *httptest.ResponseRecorder { r := httptest.NewRequest(method, path, strings.NewReader(body)) r.Header.Set("Origin", a.origin) r.Header.Set("X-CSRF-Token", csrf) for _, c := range cookies { r.AddCookie(c) } w := httptest.NewRecorder() a.Handler().ServeHTTP(w, r) return w } } func cookieValue(cs []*http.Cookie, name string) string { for _, c := range cs { if c.Name == name { return c.Value } } return "" } func TestRefreshSessionLookupFailureIsServerError(t *testing.T) { a, req := authFixture(t) w := req("POST", "/api/login", `{"username":"alice","password":"password-123"}`, "", nil) cookies := w.Result().Cookies() a.s.Close() r := httptest.NewRequest("POST", "/api/refresh", nil) for _, c := range cookies { r.AddCookie(c) } w = httptest.NewRecorder() a.refresh(w, r) if w.Code != 500 || len(w.Result().Cookies()) != 0 { t.Fatal(w.Code, w.Header(), w.Body.String()) } } func TestAccessRefreshLifecycle(t *testing.T) { a, req := authFixture(t) w := req("POST", "/api/login", `{"username":"alice","password":"password-123"}`, "", nil) if w.Code != 200 { t.Fatal(w.Code, w.Body.String()) } cs := w.Result().Cookies() sid := cookieValue(cs, "dsh_session") csrf := cookieValue(cs, "dsh_csrf") for _, name := range []string{"dsh_access", "dsh_refresh"} { v := cookieValue(cs, name) if v == "" || strings.Contains(w.Body.String(), v) { t.Fatal("missing/leaked", name) } } for _, c := range cs { if !c.Secure || (c.Name != "dsh_csrf" && !c.HttpOnly) { t.Fatal("unsafe cookie", c.Name) } } if w = req("GET", "/api/me", "", "", []*http.Cookie{{Name: "dsh_session", Value: sid}}); w.Code != 401 { t.Fatal("missing access accepted", w.Code) } expired := append([]*http.Cookie{}, cs...) for i, c := range expired { if c.Name == "dsh_access" { expired[i] = &http.Cookie{Name: c.Name, Value: a.accessJWT(sid, "alice", time.Now().Add(-time.Minute))} } } if w = req("GET", "/api/me", "", "", expired); w.Code != 401 { t.Fatal("expired access accepted") } if w = req("POST", "/api/refresh", "", "", expired); w.Code != 403 { t.Fatal("refresh bypassed csrf", w.Code) } w = req("POST", "/api/refresh", "", csrf, expired) if w.Code != 200 { t.Fatal(w.Code, w.Body.String()) } fresh := w.Result().Cookies() if cookieValue(fresh, "dsh_refresh") == cookieValue(cs, "dsh_refresh") { t.Fatal("not rotated") } if w = req("POST", "/api/refresh", "", csrf, cs); w.Code != 200 { t.Fatal("retry rejected", w.Code) } if cookieValue(w.Result().Cookies(), "dsh_refresh") != cookieValue(fresh, "dsh_refresh") { t.Fatal("retry must return the same successor") } if w = req("GET", "/api/me", "", "", fresh); w.Code != 200 { t.Fatal(w.Code) } if w = req("POST", "/api/logout", "", csrf, fresh); w.Code != 200 { t.Fatal(w.Code) } if w = req("GET", "/api/me", "", "", fresh); w.Code != 401 { t.Fatal("logout access valid") } if w = req("POST", "/api/refresh", "", csrf, fresh); w.Code != 403 { t.Fatal("logout refresh valid") } } func TestPersistentCookiesRecoverAfterBrowserRestart(t *testing.T) { a, req := authFixture(t) w := req("POST", "/api/login", `{"username":"alice","password":"password-123"}`, "", nil) if w.Code != 200 { t.Fatal(w.Code) } cookies := w.Result().Cookies() sid := cookieValue(cookies, "dsh_session") var expires int64 if err := a.s.DB.QueryRow("SELECT expires FROM sessions WHERE id=?", sid).Scan(&expires); err != nil { t.Fatal(err) } var restored []*http.Cookie for _, c := range cookies { if c.MaxAge <= 0 || c.Expires.IsZero() { t.Fatalf("nonpersistent cookie: %s", c.Name) } if c.Name == "dsh_access" { continue } // Access has expired while backgrounded. if c.Expires.Unix() != expires { t.Fatalf("wrong expiry: %s", c.Name) } restored = append(restored, c) } if w = req("GET", "/api/me", "", "", restored); w.Code != 401 { t.Fatal(w.Code) } // Simulate an older session without sleeping; rotation must not reset its TTL. expires = time.Now().Add(time.Hour).Unix() if _, err := a.s.DB.Exec("UPDATE sessions SET expires=? WHERE id=?", expires, sid); err != nil { t.Fatal(err) } w = req("POST", "/api/refresh", "", cookieValue(restored, "dsh_csrf"), restored) if w.Code != 200 { t.Fatal(w.Code, w.Body.String()) } fresh := w.Result().Cookies() for _, c := range fresh { if c.Name == "dsh_access" { if c.MaxAge != int(accessLifetime.Seconds()) { t.Fatal("access lifetime changed") } continue } if c.Expires.Unix() != expires || c.MaxAge <= 0 || c.MaxAge > 3600 { t.Fatalf("session extended: %s", c.Name) } } if w = req("GET", "/api/me", "", "", fresh); w.Code != 200 { t.Fatal(w.Code) } w = req("POST", "/api/logout", "", cookieValue(fresh, "dsh_csrf"), fresh) if w.Code != 200 { t.Fatal(w.Code) } for _, c := range w.Result().Cookies() { if c.MaxAge >= 0 { t.Fatalf("cookie not cleared: %s", c.Name) } } if w = req("GET", "/api/me", "", "", fresh); w.Code != 401 { t.Fatal("revoked session accepted") } } func TestAccountLockAndPersistentKey(t *testing.T) { a, req := authFixture(t) for i := 0; i < 5; i++ { if w := req("POST", "/api/login", `{"username":"alice","password":"wrong"}`, "", nil); w.Code != 401 { t.Fatal(w.Code) } } if w := req("POST", "/api/login", `{"username":"alice","password":"password-123"}`, "", nil); w.Code != 429 { t.Fatal("not locked", w.Code) } if w := req("POST", "/api/login", `{"username":"bob","password":"password-123"}`, "", nil); w.Code != 200 { t.Fatal("other account locked", w.Code) } b := New(a.s, a.root) if !bytes.Equal(a.authKey, b.authKey) || b.authErr != nil { t.Fatal("key not persistent", b.authErr) } fi, e := os.Stat(filepath.Join(a.root, "auth-signing.key")) if e != nil || fi.Mode().Perm() != 0600 { t.Fatal("unsafe key") } if locked, e := b.accountLocked("alice"); e != nil || !locked { t.Fatal("lock not persistent", e) } if _, e = a.s.DB.Exec("UPDATE auth_failures SET locked_until=? WHERE username='alice'", time.Now().Add(-time.Second).Unix()); e != nil { t.Fatal(e) } if w := req("POST", "/api/login", `{"username":"alice","password":"password-123"}`, "", nil); w.Code != 200 { t.Fatal("lock did not expire", w.Code) } } func TestRefreshOriginConcurrentRotationAndJWTBinding(t *testing.T) { a, req := authFixture(t) w := req("POST", "/api/login", `{"username":"alice","password":"password-123"}`, "", nil) cs := w.Result().Cookies() sid := cookieValue(cs, "dsh_session") csrf := cookieValue(cs, "dsh_csrf") var stored string if err := a.s.DB.QueryRow("SELECT token_hash FROM auth_refresh WHERE sid=?", sid).Scan(&stored); err != nil || stored != refreshHash(cookieValue(cs, "dsh_refresh")) { t.Fatal("refresh not hashed", err) } for _, token := range []string{cookieValue(cs, "dsh_access") + "x", a.accessJWT("other", "alice", time.Now().Add(accessLifetime)), a.accessJWT(sid, "bob", time.Now().Add(accessLifetime)), "eyJhbGciOiJub25lIn0.e30."} { if w = req("GET", "/api/me", "", "", []*http.Cookie{{Name: "dsh_session", Value: sid}, {Name: "dsh_access", Value: token}}); w.Code != 401 { t.Fatal("invalid JWT accepted") } } for _, origin := range []string{"", "https://evil.test"} { r := httptest.NewRequest("POST", "/api/refresh", nil) r.Header.Set("Origin", origin) r.Header.Set("X-CSRF-Token", csrf) for _, c := range cs { r.AddCookie(c) } w = httptest.NewRecorder() a.Handler().ServeHTTP(w, r) if w.Code != 403 { t.Fatal("origin accepted", w.Code) } } var wg sync.WaitGroup codes := make(chan int, 2) for i := 0; i < 2; i++ { wg.Add(1) go func() { defer wg.Done(); codes <- req("POST", "/api/refresh", "", csrf, cs).Code }() } wg.Wait() close(codes) counts := map[int]int{} for code := range codes { counts[code]++ } if counts[200] != 2 { t.Fatal(counts) } b := New(a.s, a.root) r := httptest.NewRequest("GET", "/api/me", nil) for _, c := range cs { r.AddCookie(c) } w = httptest.NewRecorder() b.Handler().ServeHTTP(w, r) if w.Code != 200 { t.Fatal("restart rejected access", w.Code) } a.s.DeleteSession(sid) if w = req("GET", "/api/me", "", "", cs); w.Code != 401 { t.Fatal("deleted binding accepted") } } func TestUnsafeAuthKeyFailsClosed(t *testing.T) { a, req := authFixture(t) if err := os.Chmod(filepath.Join(a.root, "auth-signing.key"), 0644); err != nil { t.Fatal(err) } b := New(a.s, a.root) if b.authErr == nil { t.Fatal("unsafe key accepted") } a.authErr = b.authErr if w := req("POST", "/api/login", `{"username":"alice","password":"password-123"}`, "", nil); w.Code != 503 { t.Fatal(w.Code) } } func TestSessionCleanupRemovesExpiredRows(t *testing.T) { a, _ := authFixture(t) expired, _, e := a.s.Session("alice") if e != nil { t.Fatal(e) } current, _, e := a.s.Session("alice") if e != nil { t.Fatal(e) } past := time.Now().Add(-time.Minute).Unix() if _, e = a.s.DB.Exec("UPDATE sessions SET expires=? WHERE id=?", past, expired); e != nil { t.Fatal(e) } token, e := refreshToken() if e != nil { t.Fatal(e) } if _, e = a.s.DB.Exec("INSERT INTO auth_refresh(sid,token_hash) VALUES(?,?)", expired, refreshHash(token)); e != nil { t.Fatal(e) } future := time.Now().Add(time.Hour).Unix() if _, e = a.s.DB.Exec("INSERT INTO auth_failures(username,failures,locked_until) VALUES('expired-lock',5,?)", past); e != nil { t.Fatal(e) } if _, e = a.s.DB.Exec("INSERT INTO auth_failures(username,failures,locked_until) VALUES('active-lock',5,?)", future); e != nil { t.Fatal(e) } a.cleanupSessions(time.Now().Unix()) var n int for _, q := range []string{ "SELECT count(*) FROM sessions WHERE id='" + expired + "'", "SELECT count(*) FROM auth_refresh WHERE sid='" + expired + "'", "SELECT count(*) FROM auth_failures WHERE username='expired-lock'", } { if err := a.s.DB.QueryRow(q).Scan(&n); err != nil || n != 0 { t.Fatalf("expired row retained: %s (%d, %v)", q, n, err) } } for _, q := range []string{ "SELECT count(*) FROM sessions WHERE id='" + current + "'", "SELECT count(*) FROM auth_failures WHERE username='active-lock'", } { if err := a.s.DB.QueryRow(q).Scan(&n); err != nil || n != 1 { t.Fatalf("live row removed: %s (%d, %v)", q, n, err) } } }