package usersession import ( "errors" "net/http" "net/http/httptest" "strings" "testing" ) // testKey 是測試用的 32 byte HMAC key。 var testKey = []byte("test-key-test-key-test-key-1234!") // 32 bytes // ───────────────────────────────────────────────────────── // Encode / Decode roundtrip // ───────────────────────────────────────────────────────── func TestEncodeDecode_Roundtrip(t *testing.T) { sid := "abc123XYZ_-test" encoded := EncodeCookieValue(sid, testKey) if !strings.Contains(encoded, ".") { t.Fatalf("encoded should contain separator '.', got %q", encoded) } got, err := DecodeCookieValue(encoded, testKey) if err != nil { t.Fatalf("Decode: %v", err) } if got != sid { t.Fatalf("roundtrip mismatch: got %q want %q", got, sid) } } func TestDecode_TamperedSessionID(t *testing.T) { encoded := EncodeCookieValue("realsid", testKey) parts := strings.SplitN(encoded, ".", 2) tampered := "fakesid." + parts[1] _, err := DecodeCookieValue(tampered, testKey) if !errors.Is(err, ErrSignatureMismatch) { t.Fatalf("expected ErrSignatureMismatch when sessionID tampered, got %v", err) } } func TestDecode_TamperedSignature(t *testing.T) { encoded := EncodeCookieValue("realsid", testKey) parts := strings.SplitN(encoded, ".", 2) // 換個簽章(不同長度的 base64url 也會失敗) tampered := parts[0] + ".AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" _, err := DecodeCookieValue(tampered, testKey) if !errors.Is(err, ErrSignatureMismatch) { t.Fatalf("expected ErrSignatureMismatch, got %v", err) } } func TestDecode_DifferentKeyFails(t *testing.T) { encoded := EncodeCookieValue("sid", testKey) other := []byte("other-key-other-key-other-key-1!") _, err := DecodeCookieValue(encoded, other) if !errors.Is(err, ErrSignatureMismatch) { t.Fatalf("expected ErrSignatureMismatch with different key, got %v", err) } } func TestDecode_MalformedValues(t *testing.T) { cases := map[string]string{ "empty": "", "no separator": "noseparator", "only sep": ".", "empty sid": ".sigonly", "empty sig": "sidonly.", } for name, val := range cases { t.Run(name, func(t *testing.T) { _, err := DecodeCookieValue(val, testKey) if !errors.Is(err, ErrInvalidCookie) { t.Fatalf("%s: expected ErrInvalidCookie, got %v", name, err) } }) } } // ───────────────────────────────────────────────────────── // WriteCookie / ReadCookie / ClearCookie via httptest // ───────────────────────────────────────────────────────── func newCookieCfg() CookieConfig { return CookieConfig{ Name: DefaultCookieName, Path: "/", HTTPOnly: true, Secure: false, SameSite: http.SameSiteLaxMode, MaxAge: 86400, SigningKey: testKey, } } func TestWriteAndReadCookie_Roundtrip(t *testing.T) { cfg := newCookieCfg() sid := "session-abc-123" w := httptest.NewRecorder() if err := WriteCookie(w, cfg, sid); err != nil { t.Fatalf("WriteCookie: %v", err) } // 從 response 取出 Set-Cookie,做成 request cookie 模擬 browser 回傳 resp := w.Result() cookies := resp.Cookies() if len(cookies) != 1 { t.Fatalf("expected 1 Set-Cookie, got %d", len(cookies)) } c := cookies[0] if c.Name != DefaultCookieName { t.Fatalf("cookie name: got %q want %q", c.Name, DefaultCookieName) } if !c.HttpOnly { t.Fatalf("HttpOnly should be true") } if c.SameSite != http.SameSiteLaxMode { t.Fatalf("SameSite should be Lax") } if c.MaxAge != 86400 { t.Fatalf("MaxAge: got %d want 86400", c.MaxAge) } r := httptest.NewRequest(http.MethodGet, "/", nil) r.AddCookie(c) got, ok := ReadCookie(r, cfg) if !ok { t.Fatalf("ReadCookie: ok=false") } if got != sid { t.Fatalf("ReadCookie: got %q want %q", got, sid) } } func TestReadCookie_NoCookie(t *testing.T) { cfg := newCookieCfg() r := httptest.NewRequest(http.MethodGet, "/", nil) if _, ok := ReadCookie(r, cfg); ok { t.Fatalf("ReadCookie should return ok=false when no cookie present") } } func TestReadCookie_TamperedValue(t *testing.T) { cfg := newCookieCfg() r := httptest.NewRequest(http.MethodGet, "/", nil) // 故意放一個簽章錯的 cookie r.AddCookie(&http.Cookie{Name: cfg.Name, Value: "tampered.sig"}) if _, ok := ReadCookie(r, cfg); ok { t.Fatalf("ReadCookie should return ok=false for tampered cookie") } } func TestClearCookie_SetsExpiration(t *testing.T) { cfg := newCookieCfg() w := httptest.NewRecorder() ClearCookie(w, cfg) cookies := w.Result().Cookies() if len(cookies) != 1 { t.Fatalf("expected 1 Set-Cookie, got %d", len(cookies)) } c := cookies[0] if c.MaxAge >= 0 { t.Fatalf("ClearCookie MaxAge should be < 0, got %d", c.MaxAge) } if c.Value != "" { t.Fatalf("ClearCookie value should be empty, got %q", c.Value) } if c.Name != cfg.Name || c.Path != cfg.Path { t.Fatalf("ClearCookie must use same Name/Path; got name=%q path=%q", c.Name, c.Path) } } func TestClearCookie_BrowserCannotRead(t *testing.T) { cfg := newCookieCfg() // 1. WriteCookie → 得到一個 cookie w1 := httptest.NewRecorder() if err := WriteCookie(w1, cfg, "sid-X"); err != nil { t.Fatalf("WriteCookie: %v", err) } original := w1.Result().Cookies()[0] // 2. ClearCookie → 得到一個 expiration cookie w2 := httptest.NewRecorder() ClearCookie(w2, cfg) expirationCookie := w2.Result().Cookies()[0] // 3. 模擬 browser 在收到 expiration cookie 後立刻發 request — 此時應該沒有 cookie // (這裡無法直接模擬 browser 的 cookie jar 邏輯,但能驗證 expiration cookie 內容是空的、 // 若 browser 真的把它存下來,後續 ReadCookie 會失敗)。 r := httptest.NewRequest(http.MethodGet, "/", nil) r.AddCookie(&http.Cookie{Name: expirationCookie.Name, Value: expirationCookie.Value}) if _, ok := ReadCookie(r, cfg); ok { t.Fatalf("after ClearCookie, ReadCookie of cleared value must fail") } // sanity check:原本的 cookie 仍然能讀(驗證 ReadCookie 本身沒壞) r2 := httptest.NewRequest(http.MethodGet, "/", nil) r2.AddCookie(original) if _, ok := ReadCookie(r2, cfg); !ok { t.Fatalf("baseline: original cookie should still read OK") } } // ───────────────────────────────────────────────────────── // CookieConfig validation // ───────────────────────────────────────────────────────── func TestWriteCookie_MissingSigningKey(t *testing.T) { cfg := CookieConfig{} // SigningKey 為 nil w := httptest.NewRecorder() err := WriteCookie(w, cfg, "sid") if !errors.Is(err, ErrInvalidConfig) { t.Fatalf("expected ErrInvalidConfig, got %v", err) } } func TestWriteCookie_EmptySessionID(t *testing.T) { cfg := newCookieCfg() w := httptest.NewRecorder() err := WriteCookie(w, cfg, "") if !errors.Is(err, ErrInvalidCookie) { t.Fatalf("expected ErrInvalidCookie, got %v", err) } } func TestReadCookie_MissingSigningKey(t *testing.T) { cfg := CookieConfig{} // SigningKey 為 nil r := httptest.NewRequest(http.MethodGet, "/", nil) r.AddCookie(&http.Cookie{Name: DefaultCookieName, Value: "anything.thing"}) if _, ok := ReadCookie(r, cfg); ok { t.Fatalf("ReadCookie should fail when SigningKey missing") } } func TestCookieConfig_Defaults(t *testing.T) { cfg := CookieConfig{SigningKey: testKey} // 其他欄位都用預設 if cfg.resolvedName() != DefaultCookieName { t.Fatalf("resolvedName default mismatch") } if cfg.resolvedPath() != "/" { t.Fatalf("resolvedPath default should be '/'") } if cfg.resolvedSameSite() != http.SameSiteLaxMode { t.Fatalf("resolvedSameSite default should be Lax") } }