Files
NianAIGC/backend/internal/identity/session_test.go

293 lines
10 KiB
Go

package identity
import (
"encoding/json"
"errors"
"os"
"path/filepath"
"reflect"
"strings"
"testing"
"time"
)
type sessionFixture struct {
Version int `json:"version"`
Cookie sessionCookieContract `json:"cookie"`
Secret string `json:"secret"`
RawJSON string `json:"rawJson"`
Payload string `json:"payload"`
Signature string `json:"signature"`
CookieValue string `json:"cookieValue"`
}
type sessionCookieContract struct {
Name string `json:"name"`
ChunkSize int `json:"chunkSize"`
MaxChunks int `json:"maxChunks"`
MaxValueLength int `json:"maxValueLength"`
ChunkNames []string `json:"chunkNames"`
WriteExample struct {
ValueCharacter string `json:"valueCharacter"`
ValueLength int `json:"valueLength"`
ChunkLengths []int `json:"chunkLengths"`
ExpiresAtUnix int64 `json:"expiresAtUnix"`
} `json:"writeExample"`
Attributes struct {
HTTPOnly bool `json:"httpOnly"`
SameSite string `json:"sameSite"`
Path string `json:"path"`
ProductionSecure bool `json:"productionSecure"`
} `json:"attributes"`
Clear struct {
Value string `json:"value"`
MaxAgeSeconds int `json:"maxAgeSeconds"`
} `json:"clear"`
SecureResolutionCases []struct {
Name string `json:"name"`
Explicit string `json:"explicit"`
PublicBaseURL string `json:"publicBaseUrl"`
RequestURL string `json:"requestUrl"`
Expected bool `json:"expected"`
} `json:"secureResolutionCases"`
}
func TestSignMatchesTypeScriptFixture(t *testing.T) {
fixture := loadSessionFixture(t)
const expectedSignature = "KTBRAoo5dytZJhvzX9qqd5JFnfVm7hEQd57TW1JSk4M"
if fixture.Signature != expectedSignature {
t.Fatalf("fixture signature = %q, want deterministic vector %q", fixture.Signature, expectedSignature)
}
got, err := Sign([]byte(fixture.RawJSON), fixture.Secret)
if err != nil {
t.Fatalf("Sign() error = %v", err)
}
if got != fixture.CookieValue {
t.Fatalf("Sign() = %q, want fixture cookie %q", got, fixture.CookieValue)
}
payload, signature, ok := strings.Cut(got, ".")
if !ok || payload != fixture.Payload || signature != expectedSignature {
t.Fatalf("Sign() parts = (%q, %q), want fixture payload and signature", payload, signature)
}
if strings.Contains(payload, "=") {
t.Fatal("Sign() emitted padded base64url")
}
}
func loadSessionFixture(t *testing.T) sessionFixture {
t.Helper()
path := filepath.Join("..", "..", "..", "contracts", "auth", "session-cookie-v1.json")
data, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read fixture: %v", err)
}
var fixture sessionFixture
if err := json.Unmarshal(data, &fixture); err != nil {
t.Fatalf("decode fixture: %v", err)
}
return fixture
}
func TestParseValidatesAndNormalizesSession(t *testing.T) {
fixture := loadSessionFixture(t)
session, err := Parse(fixture.CookieValue, fixture.Secret, time.Unix(150, 0))
if err != nil {
t.Fatalf("Parse() error = %v", err)
}
if session.AuthMode != AuthModeAdmin || session.AccessToken != "access-token-1" || session.TokenType != "bearer" {
t.Fatalf("Parse() session = %#v", session)
}
if session.User.ID != "auth:customPC:1" || session.User.DisplayName != "张三" {
t.Fatalf("Parse() user = %#v", session.User)
}
}
func TestParseRejectsTamperExpiryAndMalformedWireValues(t *testing.T) {
fixture := loadSessionFixture(t)
tampered := fixture.CookieValue[:len(fixture.CookieValue)-1] + "x"
tests := []struct {
name string
value string
now time.Time
}{
{name: "tampered", value: tampered, now: time.Unix(150, 0)},
{name: "exact expiry", value: fixture.CookieValue, now: time.Unix(200, 0)},
{name: "missing dot", value: fixture.Payload, now: time.Unix(150, 0)},
{name: "extra dot", value: fixture.CookieValue + ".extra", now: time.Unix(150, 0)},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if _, err := Parse(tt.value, fixture.Secret, tt.now); err == nil {
t.Fatal("Parse() error = nil, want rejection")
}
})
}
}
func TestParseNormalizesLegacyAndMissingCollections(t *testing.T) {
fixture := loadSessionFixture(t)
raw := []byte(`{"version":1,"issuedAt":100,"expiresAt":200,"user":{"id":"legacy","subject":"legacy","displayName":"Legacy","clientId":"customPC"}}`)
value, err := Sign(raw, fixture.Secret)
if err != nil {
t.Fatal(err)
}
session, err := Parse(value, fixture.Secret, time.Unix(150, 0))
if err != nil {
t.Fatalf("Parse() error = %v", err)
}
if session.AuthMode != AuthModeUser {
t.Fatalf("AuthMode = %q, want %q", session.AuthMode, AuthModeUser)
}
if session.User.Authorities == nil || len(session.User.Authorities) != 0 || session.User.Scope == nil || len(session.User.Scope) != 0 {
t.Fatalf("missing collections not normalized: %#v", session.User)
}
}
func TestParseRejectsInvalidProductionContract(t *testing.T) {
fixture := loadSessionFixture(t)
tests := []string{
`{"version":2,"expiresAt":200,"user":{"id":"user","clientId":"customPC"}}`,
`{"version":1,"expiresAt":200,"user":{"id":"","clientId":"customPC"}}`,
`{"version":1,"expiresAt":200,"user":{"id":"user","clientId":""}}`,
`{"version":1,"expiresAt":"200","user":{"id":"user","clientId":"customPC"}}`,
`{"version":1,"expiresAt":200,"accessToken":7,"user":{"id":"user","clientId":"customPC"}}`,
}
for _, raw := range tests {
value, err := Sign([]byte(raw), fixture.Secret)
if err != nil {
t.Fatal(err)
}
if _, err := Parse(value, fixture.Secret, time.Unix(150, 0)); err == nil {
t.Fatalf("Parse(%s) accepted invalid contract", raw)
}
}
}
func TestChunkAndReassembleUseLegacyCookieNamesAndLimits(t *testing.T) {
value := strings.Repeat("x", CookieChunkSize*2+1)
chunks, err := Chunk(SessionCookieName, value)
if err != nil {
t.Fatalf("Chunk() error = %v", err)
}
wantNames := []string{"zhinian_session", "zhinian_session.1", "zhinian_session.2"}
if len(chunks) != len(wantNames) {
t.Fatalf("len(Chunk()) = %d, want %d", len(chunks), len(wantNames))
}
values := make(map[string]string, len(chunks))
for index, chunk := range chunks {
if chunk.Name != wantNames[index] {
t.Errorf("chunk %d name = %q, want %q", index, chunk.Name, wantNames[index])
}
if len(chunk.Value) > CookieChunkSize {
t.Errorf("chunk %d length = %d", index, len(chunk.Value))
}
values[chunk.Name] = chunk.Value
}
got, ok := Reassemble(SessionCookieName, func(name string) (string, bool) {
value, found := values[name]
return value, found
})
if !ok || got != value {
t.Fatalf("Reassemble() = (%q, %v), want original", got, ok)
}
visited := make([]string, 0, CookieMaxChunks)
_, _ = Reassemble(SessionCookieName, func(name string) (string, bool) {
visited = append(visited, name)
return "x", true
})
if len(visited) != CookieMaxChunks || visited[len(visited)-1] != "zhinian_session.19" {
t.Fatalf("Reassemble visited %v", visited)
}
}
func TestCookieLifecycleMatchesSharedContract(t *testing.T) {
fixture := loadSessionFixture(t)
cookie := fixture.Cookie
if SessionCookieName != cookie.Name || CookieChunkSize != cookie.ChunkSize || CookieMaxChunks != cookie.MaxChunks || CookieMaxValueLength != cookie.MaxValueLength {
t.Fatalf("Go constants do not match shared cookie contract: %#v", cookie)
}
if got := CookieNames(); !reflect.DeepEqual(got, cookie.ChunkNames) {
t.Fatalf("CookieNames() = %v, want %v", got, cookie.ChunkNames)
}
value := strings.Repeat(cookie.WriteExample.ValueCharacter, cookie.WriteExample.ValueLength)
chunks, err := Chunk(cookie.Name, value)
if err != nil {
t.Fatalf("Chunk() error = %v", err)
}
gotLengths := make([]int, len(chunks))
for index, chunk := range chunks {
gotLengths[index] = len(chunk.Value)
}
if !reflect.DeepEqual(gotLengths, cookie.WriteExample.ChunkLengths) {
t.Fatalf("chunk lengths = %v, want %v", gotLengths, cookie.WriteExample.ChunkLengths)
}
expires := time.Unix(cookie.WriteExample.ExpiresAtUnix, 0).UTC()
writes, err := SetSessionCookies(value, expires, cookie.Attributes.ProductionSecure)
if err != nil {
t.Fatalf("SetSessionCookies() error = %v", err)
}
if len(writes) != cookie.MaxChunks {
t.Fatalf("len(SetSessionCookies()) = %d, want %d", len(writes), cookie.MaxChunks)
}
for index, write := range writes {
if write.Name != cookie.ChunkNames[index] {
t.Errorf("write %d name = %q, want %q", index, write.Name, cookie.ChunkNames[index])
}
assertCookieAttributes(t, write.Attributes, cookie, index < len(chunks), expires)
if index >= len(chunks) && write.Value != cookie.Clear.Value {
t.Errorf("stale clear %d value = %q, want %q", index, write.Value, cookie.Clear.Value)
}
}
clears := ClearSessionCookies(cookie.Attributes.ProductionSecure)
if len(clears) != cookie.MaxChunks {
t.Fatalf("len(ClearSessionCookies()) = %d, want %d", len(clears), cookie.MaxChunks)
}
for index, clear := range clears {
if clear.Name != cookie.ChunkNames[index] || clear.Value != cookie.Clear.Value {
t.Errorf("clear %d = %#v", index, clear)
}
assertCookieAttributes(t, clear.Attributes, cookie, false, expires)
}
}
func TestChunkRejectsValuesBeyondSharedReadCeiling(t *testing.T) {
fixture := loadSessionFixture(t)
_, err := Chunk(fixture.Cookie.Name, strings.Repeat("x", fixture.Cookie.MaxValueLength+1))
if !errors.Is(err, ErrSessionTooLarge) {
t.Fatalf("Chunk() error = %v, want ErrSessionTooLarge", err)
}
}
func TestSecureCookieResolutionMatchesSharedContract(t *testing.T) {
fixture := loadSessionFixture(t)
for _, testCase := range fixture.Cookie.SecureResolutionCases {
t.Run(testCase.Name, func(t *testing.T) {
got := ResolveSecureCookie(testCase.Explicit, testCase.PublicBaseURL, testCase.RequestURL)
if got != testCase.Expected {
t.Fatalf("ResolveSecureCookie() = %v, want %v", got, testCase.Expected)
}
})
}
}
func assertCookieAttributes(t *testing.T, got CookieAttributes, contract sessionCookieContract, write bool, expires time.Time) {
t.Helper()
if got.HTTPOnly != contract.Attributes.HTTPOnly || got.SameSite != contract.Attributes.SameSite || got.Secure != contract.Attributes.ProductionSecure || got.Path != contract.Attributes.Path {
t.Errorf("attributes = %#v, want shared base attributes", got)
}
if write {
if got.Expires == nil || !got.Expires.Equal(expires) || got.MaxAgeSeconds != nil {
t.Errorf("write attributes = %#v, want Expires=%v and no Max-Age", got, expires)
}
return
}
if got.Expires != nil || got.MaxAgeSeconds == nil || *got.MaxAgeSeconds != contract.Clear.MaxAgeSeconds {
t.Errorf("clear attributes = %#v, want Max-Age=%d and no Expires", got, contract.Clear.MaxAgeSeconds)
}
}