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