Files
Calcium-Ion 128089fa7d feat(i18n): translate web console messages in the frontend and AI-client errors in the backend (#7712)
* feat(i18n): translate web console messages in the frontend and AI-client errors in the backend

Chinese text was hard-coded in API messages, stored log content, emails and
server logs, so it reached users of every interface language.

Text is now translated where its reader is:

- Web console APIs return the English source text as message_key with
  message_params, and the web console translates it in all seven languages.
  message keeps the English text for other API callers.
- Errors returned to AI clients, emails and notifications are translated by
  the backend (en, zh-CN, zh-TW) in the language saved on the account, then
  the language of the request's Accept-Language header.
- A reader who states no language still gets the original Chinese text of the
  messages that were Chinese before, word for word. Gateways that use another
  new-api as their upstream match that text, for example to disable a channel
  whose account ran out of quota.
- Usage, top-up and system log content is stored as content_parts (key and
  params) and rendered in the viewer's language. Task fail reasons and refund
  reasons written by the gateway are English keys. Rows written earlier are
  shown as before.
- Server logs are English.

Also:

- The saved interface language zhTW, and zh-HK, zh-MO and zh-Hant, now select
  Traditional Chinese in the backend instead of Simplified Chinese.
- The built-in group descriptions are translated; descriptions an
  administrator saved are shown as written.
- .agents/rules/i18n.md records the conventions. Tests in i18n/ fail on a
  non-English string literal, on a web console message without translations
  and on a locale key without a constant.

* feat(i18n): print server logs in Chinese when LOG_LANGUAGE asks for it

Server log lines are English by default. With LOG_LANGUAGE=zh-CN, the lines
that were Chinese before print their original Chinese text.

common.LogText formats a log line from its English format, which is also the
key of its Chinese text in common/log_text.zh-CN.json. A line without an entry
prints in English. go vet checks the arguments of every call, and
TestServerLogTexts checks that each entry still matches a call and has the
same verbs as its key.

* docs: list LOG_LANGUAGE in the docker compose example

* feat(i18n): translate every server log line and add DEFAULT_LANGUAGE

Every line the gateway prints goes through common.LogText. With
DEFAULT_LANGUAGE=zh-CN, the lines that have a Chinese text in
common/log_text.zh-CN.json print it and the others print English.

DEFAULT_LANGUAGE replaces LOG_LANGUAGE. It also sets the language of
backend messages, emails and notifications for a reader who states no
language: en, zh-CN or zh-TW, English when unset. The list of messages
that stayed Chinese for such readers is removed.

Review fixes:
- Channel test logs store the token name "Model test"; the console shows
  it in the interface language.
- Task fail reasons written by the gateway are fixed sentences the console
  translates. The HTTP status and the upstream detail go to the server log
  when a polled task is failed.
- The auto-disable reason of a slow channel is written in DEFAULT_LANGUAGE.

Also:
- Drop the repository-scanning lint tests, the i18n rules file and its
  read gate. AGENTS.md keeps the conventions in four lines.
- Payment error toasts keep their previous text and only add the
  translated message key.
- Remove guards and helpers that no caller needs.

* fix(i18n): address review on language lookup, notices and error responses

- Relay requests no longer load the user a second time to find the
  language: the settings TokenAuth put into the request are used as they
  are, also when they hold no language.
- Upstream model update notices are rendered in each recipient's saved
  language, DEFAULT_LANGUAGE when none.
- /api/usage/token and /api/log/token answer with backend-translated
  messages again, with the keys and status codes used before this PR.
  Their callers are scripts, not the web console.
- Webhook notifications fill {{value}} placeholders like the other
  senders. Running the rendered text through Sprintf appended
  %!(EXTRA ...) and broke %2F in links.
- RootUserLanguage reads the setting of the root user it already loaded.
- zh_TW and zh_HK are read as Traditional Chinese.
- Add common.ApiErrorStatus for error responses under another HTTP status
  and use it where those responses were built by hand.
- Remove middleware.GetLanguage, which had no callers.
2026-10-09 21:39:51 +08:00

537 lines
22 KiB
Go

package middleware
import (
"crypto/hmac"
"crypto/sha256"
"errors"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/i18n"
"github.com/QuantumNous/new-api/model"
"github.com/QuantumNous/new-api/service"
"github.com/QuantumNous/new-api/service/authz"
"github.com/gin-gonic/gin"
"github.com/glebarez/sqlite"
"github.com/golang-jwt/jwt/v5"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gorm.io/gorm"
)
func setupDashboardAuthMiddlewareTest(t *testing.T) {
t.Helper()
previousDB := model.DB
previousLogDB := model.LOG_DB
previousType := common.MainDatabaseType()
previousRedis := common.RedisEnabled
previousSecret := common.SessionSecret
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
require.NoError(t, err)
require.NoError(t, db.AutoMigrate(&model.User{}, &model.UserSession{}, &model.AuditLog{}, &model.UserAccessToken{}, &model.Option{}))
model.DB = db
model.LOG_DB = db
common.SetMainDatabaseType(common.DatabaseTypeSQLite)
common.RedisEnabled = false
common.SessionSecret = "middleware-auth-test-secret"
require.NoError(t, model.EnsureLegacyAccessTokenRetireAt(time.Now().Unix()))
t.Cleanup(func() {
model.DB = previousDB
model.LOG_DB = previousLogDB
common.SetMainDatabaseType(previousType)
common.RedisEnabled = previousRedis
common.SessionSecret = previousSecret
})
}
func issueExpiredDashboardAccessToken(t *testing.T, identity service.AuthIdentity) string {
t.Helper()
claims := jwt.MapClaims{
"iss": "new-api",
"aud": []string{"new-api-dashboard"},
"sub": fmt.Sprintf("%d", identity.UserID),
"token_use": "access",
"sid": identity.SessionID,
"uv": identity.UserAuthVersion,
"sv": identity.SessionVersion,
"exp": time.Now().Add(-time.Minute).Unix(),
"nbf": time.Now().Add(-2 * time.Minute).Unix(),
"iat": time.Now().Add(-2 * time.Minute).Unix(),
}
mac := hmac.New(sha256.New, []byte(common.SessionSecret))
_, err := mac.Write([]byte("new-api/auth/access/v1"))
require.NoError(t, err)
token, err := jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString(mac.Sum(nil))
require.NoError(t, err)
return token
}
func tamperDashboardToken(token string) string {
tamperAt := len(token) - 2
replacement := "x"
if token[tamperAt] == 'x' {
replacement = "y"
}
return token[:tamperAt] + replacement + token[tamperAt+1:]
}
func createMiddlewarePATUser(t *testing.T, username, token string) *model.User {
t.Helper()
user := &model.User{
Username: username, Password: "password-placeholder", Role: common.RoleCommonUser,
Status: common.UserStatusEnabled, Group: "default", AccessToken: &token, AuthVersion: 1,
AffCode: "middleware-aff-" + username,
}
require.NoError(t, model.DB.Create(user).Error)
return user
}
// Read-only API key routes (/api/usage/token, /api/log/token) are called by
// scripts, not the web console, so their errors are translated by the backend
// in the language the request asks for.
func TestTokenAuthReadOnlyTranslatesErrors(t *testing.T) {
require.NoError(t, i18n.Init())
for _, tc := range []struct {
acceptLanguage string
want string
}{
{"zh-CN", "未提供令牌"},
{"en-US", "Token not provided"},
} {
recorder := httptest.NewRecorder()
c, _ := gin.CreateTestContext(recorder)
c.Request = httptest.NewRequest(http.MethodGet, "/api/log/token", nil)
c.Request.Header.Set("Accept-Language", tc.acceptLanguage)
TokenAuthReadOnly()(c)
assert.True(t, c.IsAborted(), tc.acceptLanguage)
assert.Equal(t, http.StatusUnauthorized, recorder.Code, tc.acceptLanguage)
assert.JSONEq(t, `{"success":false,"message":"`+tc.want+`"}`, recorder.Body.String(), tc.acceptLanguage)
}
}
func TestUserAuthAllowsOpaqueDottedPAT(t *testing.T) {
setupDashboardAuthMiddlewareTest(t)
user := createMiddlewarePATUser(t, "dotted-pat-user", "opaque.key.with-dots")
router := gin.New()
router.GET("/protected", UserAuth(), func(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"id": c.GetInt("id")})
})
request := httptest.NewRequest(http.MethodGet, "/protected", nil)
request.Header.Set("Authorization", "Bearer opaque.key.with-dots")
response := httptest.NewRecorder()
router.ServeHTTP(response, request)
assert.Equal(t, http.StatusOK, response.Code)
var body struct {
ID int `json:"id"`
}
require.NoError(t, common.Unmarshal(response.Body.Bytes(), &body))
assert.Equal(t, user.Id, body.ID)
}
func TestUserAuthNeverFallsBackForRecognizedInvalidInternalJWT(t *testing.T) {
setupDashboardAuthMiddlewareTest(t)
identity := service.AuthIdentity{UserID: 42, SessionID: "session-42", UserAuthVersion: 1, SessionVersion: 1}
token, _, err := service.IssueAccessToken(identity)
require.NoError(t, err)
tampered := tamperDashboardToken(token)
createMiddlewarePATUser(t, "jwt-fallback-user", tampered)
router := gin.New()
router.GET("/protected", UserAuth(), func(c *gin.Context) {
c.Status(http.StatusNoContent)
})
request := httptest.NewRequest(http.MethodGet, "/protected", nil)
request.Header.Set("Authorization", "Bearer "+tampered)
response := httptest.NewRecorder()
router.ServeHTTP(response, request)
assert.Equal(t, http.StatusUnauthorized, response.Code)
assert.Contains(t, response.Body.String(), "AUTH_UNAUTHORIZED")
}
func TestTryUserAuthCredentialClassification(t *testing.T) {
setupDashboardAuthMiddlewareTest(t)
gin.SetMode(gin.TestMode)
patUser := createMiddlewarePATUser(t, "optional-pat-user", "optional.pat.with-dots")
internalUser := createMiddlewarePATUser(t, "optional-session-user", "unrelated-pat")
scopedPAT, _ := createMiddlewareScopedToken(t, patUser.Id, 0, "profile:read")
now := time.Now().Unix()
session := &model.UserSession{
SID: "optional-auth-session",
UserID: internalUser.Id,
Version: 1,
UserAuthVersion: internalUser.AuthVersion,
Status: model.UserSessionStatusActive,
RefreshHash: "refresh-hash",
LoginMethod: "password",
LastActiveAt: now,
ExpiresAt: now + 3600,
}
require.NoError(t, model.CreateUserSession(session))
identity := service.AuthIdentity{
UserID: internalUser.Id,
SessionID: session.SID,
UserAuthVersion: session.UserAuthVersion,
SessionVersion: session.Version,
}
accessToken, _, err := service.IssueAccessToken(identity)
require.NoError(t, err)
require.NoError(t, model.DB.AutoMigrate(&model.AuthFlow{}))
binding, err := service.BindVerificationOperation(service.VerificationOperation{Scope: "channel.key.read", Context: []byte(`{"channel_id":123}`)})
require.NoError(t, err)
securityProof, _, err := service.IssueSecurityProof(identity, "2fa", binding)
require.NoError(t, err)
externalToken, err := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{
"iss": "external-issuer",
"aud": "external-audience",
"exp": time.Now().Add(time.Minute).Unix(),
}).SignedString([]byte("external-secret"))
require.NoError(t, err)
router := gin.New()
router.GET("/optional", TryUserAuth(), func(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{
"id": c.GetInt("id"),
"use_access_token": c.GetBool("use_access_token"),
})
})
tests := []struct {
name string
token string
wantStatus int
wantUserID int
wantPAT bool
wantErrorCode string
}{
{name: "no authorization header", wantStatus: http.StatusOK},
{name: "opaque unmatched credential", token: "opaque-relay-key", wantStatus: http.StatusOK},
{name: "dotted unmatched credential", token: "ordinary.key.with-dots", wantStatus: http.StatusOK},
{name: "third party jwt", token: externalToken, wantStatus: http.StatusOK},
{name: "valid pat", token: "optional.pat.with-dots", wantStatus: http.StatusOK, wantUserID: patUser.Id, wantPAT: true},
{name: "scoped pat outside its grant stays anonymous", token: scopedPAT, wantStatus: http.StatusOK},
{name: "valid internal access jwt", token: accessToken, wantStatus: http.StatusOK, wantUserID: internalUser.Id},
{name: "expired internal access jwt", token: issueExpiredDashboardAccessToken(t, identity), wantStatus: http.StatusUnauthorized, wantErrorCode: "AUTH_TOKEN_EXPIRED"},
{name: "tampered internal access jwt", token: tamperDashboardToken(accessToken), wantStatus: http.StatusUnauthorized, wantErrorCode: "AUTH_UNAUTHORIZED"},
{name: "security proof used as access", token: securityProof, wantStatus: http.StatusUnauthorized, wantErrorCode: "AUTH_UNAUTHORIZED"},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
request := httptest.NewRequest(http.MethodGet, "/optional", nil)
if test.token != "" {
request.Header.Set("Authorization", "Bearer "+test.token)
}
response := httptest.NewRecorder()
router.ServeHTTP(response, request)
assert.Equal(t, test.wantStatus, response.Code)
if test.wantErrorCode != "" {
assert.Contains(t, response.Body.String(), test.wantErrorCode)
return
}
var body struct {
ID int `json:"id"`
UseAccessToken bool `json:"use_access_token"`
}
require.NoError(t, common.Unmarshal(response.Body.Bytes(), &body))
assert.Equal(t, test.wantUserID, body.ID)
assert.Equal(t, test.wantPAT, body.UseAccessToken)
})
}
requiredRouter := gin.New()
requiredRouter.GET("/required", UserAuth(), func(c *gin.Context) { c.Status(http.StatusNoContent) })
requiredRequest := httptest.NewRequest(http.MethodGet, "/required", nil)
requiredRequest.Header.Set("Authorization", "Bearer ordinary-unmatched-key")
requiredResponse := httptest.NewRecorder()
requiredRouter.ServeHTTP(requiredResponse, requiredRequest)
assert.Equal(t, http.StatusUnauthorized, requiredResponse.Code, "required dashboard authentication must not adopt optional-auth fallback semantics")
var patUserQueries int
forcedCacheError := errors.New("forced PAT user cache lookup failure")
const callbackName = "test:optional-auth-pat-user-cache-failure"
require.NoError(t, model.DB.Callback().Query().Before("gorm:query").Register(callbackName, func(tx *gorm.DB) {
if tx.Statement.Table != "users" {
return
}
patUserQueries++
if patUserQueries == 2 {
tx.AddError(forcedCacheError)
}
}))
cacheFailureRequest := httptest.NewRequest(http.MethodGet, "/optional", nil)
cacheFailureRequest.Header.Set("Authorization", "Bearer optional.pat.with-dots")
cacheFailureResponse := httptest.NewRecorder()
router.ServeHTTP(cacheFailureResponse, cacheFailureRequest)
model.DB.Callback().Query().Remove(callbackName)
assert.Equal(t, http.StatusInternalServerError, cacheFailureResponse.Code)
assert.Contains(t, cacheFailureResponse.Body.String(), "AUTH_INTERNAL_ERROR")
sqlDB, err := model.DB.DB()
require.NoError(t, err)
require.NoError(t, sqlDB.Close())
databaseFailureRequest := httptest.NewRequest(http.MethodGet, "/optional", nil)
databaseFailureRequest.Header.Set("Authorization", "Bearer database-failure-key")
databaseFailureResponse := httptest.NewRecorder()
router.ServeHTTP(databaseFailureResponse, databaseFailureRequest)
assert.Equal(t, http.StatusInternalServerError, databaseFailureResponse.Code)
assert.Contains(t, databaseFailureResponse.Body.String(), "AUTH_INTERNAL_ERROR")
}
func createMiddlewareScopedToken(t *testing.T, userID int, expiresAt int64, scopes ...string) (string, *model.UserAccessToken) {
t.Helper()
suffix, err := common.GenerateRandomCharsKey(43)
require.NoError(t, err)
raw := model.AccessTokenPrefix + suffix
token := &model.UserAccessToken{Name: "middleware token", TokenHash: model.AccessTokenFingerprint(raw), TokenHint: model.AccessTokenHint(raw), ExpiresAt: expiresAt}
require.NoError(t, token.SetScopes(scopes))
require.NoError(t, model.CreateUserAccessToken(userID, token, service.AccessTokenMaxPerUser))
return raw, token
}
func middlewareBearerRequest(router *gin.Engine, path, token string) *httptest.ResponseRecorder {
request := httptest.NewRequest(http.MethodGet, path, nil)
request.Header.Set("Authorization", "Bearer "+token)
response := httptest.NewRecorder()
router.ServeHTTP(response, request)
return response
}
func TestUserAuthAppliesAccessTokenRouteRules(t *testing.T) {
setupDashboardAuthMiddlewareTest(t)
gin.SetMode(gin.TestMode)
wasMaster := common.IsMasterNode
common.IsMasterNode = true
t.Cleanup(func() { common.IsMasterNode = wasMaster })
require.NoError(t, model.DB.AutoMigrate(&model.CasbinRule{}, &model.AuthzRole{}))
require.NoError(t, authz.Init(model.DB))
// The channel router declares this rule when it registers the route.
DeclareAccessTokenPermissionRoute(http.MethodGet, "/api/channel/", authz.ChannelRead)
legacy := "middleware-legacy-token"
admin := createMiddlewarePATUser(t, "route-rule-admin", legacy)
require.NoError(t, model.DB.Model(admin).Update("role", common.RoleAdminUser).Error)
profile, _ := createMiddlewareScopedToken(t, admin.Id, 0, "profile:read")
channel, _ := createMiddlewareScopedToken(t, admin.Id, time.Now().Unix()+3600, "channel:read")
expired, _ := createMiddlewareScopedToken(t, admin.Id, time.Now().Unix()-1, "profile:read")
ok := func(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"id": c.GetInt("id")}) }
router := gin.New()
router.GET("/api/user/self", UserAuth(), ok)
router.GET("/api/user/access_tokens", UserAuth(), ok)
router.GET("/api/undeclared", UserAuth(), ok)
router.GET("/api/channel/", AdminAuth(), RequirePermission(authz.ChannelRead), ok)
router.GET("/api/pricing", TryUserAuth(), ok)
for _, test := range []struct {
name, path, token, code, reason string
status int
}{
{name: "granted scope on a never expiring token", path: "/api/user/self", token: profile, status: http.StatusOK},
{name: "missing scope", path: "/api/user/self", token: channel, status: http.StatusForbidden, code: "ACCESS_TOKEN_SCOPE_DENIED", reason: "scope_denied"},
{name: "undeclared route", path: "/api/undeclared", token: profile, status: http.StatusForbidden, code: "ACCESS_TOKEN_ROUTE_UNDECLARED", reason: "route_undeclared"},
{name: "session route", path: "/api/user/access_tokens", token: profile, status: http.StatusForbidden, code: "AUTH_SESSION_REQUIRED", reason: "session_required"},
{name: "permission route with its scope", path: "/api/channel/", token: channel, status: http.StatusOK},
{name: "permission route without its scope", path: "/api/channel/", token: profile, status: http.StatusForbidden, code: "ACCESS_TOKEN_SCOPE_DENIED", reason: "scope_denied"},
{name: "expired token", path: "/api/user/self", token: expired, status: http.StatusUnauthorized, code: "ACCESS_TOKEN_EXPIRED", reason: "expired"},
{name: "legacy token before the deadline", path: "/api/user/self", token: legacy, status: http.StatusOK},
{name: "legacy token on a session route", path: "/api/user/access_tokens", token: legacy, status: http.StatusForbidden, code: "AUTH_SESSION_REQUIRED", reason: "session_required"},
} {
t.Run(test.name, func(t *testing.T) {
response := middlewareBearerRequest(router, test.path, test.token)
assert.Equal(t, test.status, response.Code, response.Body.String())
if test.code != "" {
assert.Contains(t, response.Body.String(), `"code":"`+test.code+`"`)
}
var audit model.AuditLog
require.NoError(t, model.LOG_DB.Where("category = ?", model.AuditCategoryAccessToken).Order("id desc").First(&audit).Error)
assert.Equal(t, model.AccessTokenFingerprint(test.token), audit.TokenRef)
assert.Equal(t, test.status, audit.Status)
if test.reason == "" {
return
}
require.NotNil(t, audit.Other.Op)
params, err := common.Marshal(audit.Other.Op.Params)
require.NoError(t, err)
assert.Contains(t, string(params), `"failure_reason":"`+test.reason+`"`)
if test.code == "ACCESS_TOKEN_SCOPE_DENIED" {
assert.Contains(t, string(params), `"required_scope":"`)
}
})
}
// The token grant never widens the account: revoking the Casbin permission
// rejects a token that still carries the scope.
require.NoError(t, authz.SetUserPermissions(admin.Id, authz.PermissionsMap{authz.ResourceChannel: {authz.ActionRead: false}}))
response := middlewareBearerRequest(router, "/api/channel/", channel)
assert.Equal(t, http.StatusForbidden, response.Code)
assert.NotContains(t, response.Body.String(), "ACCESS_TOKEN_SCOPE_DENIED")
require.NoError(t, model.DB.Model(admin).Update("status", common.UserStatusDisabled).Error)
response = middlewareBearerRequest(router, "/api/user/self", profile)
assert.Equal(t, http.StatusUnauthorized, response.Code)
assert.Contains(t, response.Body.String(), `"code":"AUTH_USER_DISABLED"`)
require.NoError(t, model.DB.Model(admin).Update("status", common.UserStatusEnabled).Error)
assert.Equal(t, http.StatusOK, middlewareBearerRequest(router, "/api/user/self", profile).Code, "disabling a user keeps the token")
require.NoError(t, model.DB.Model(&model.Option{}).Where(&model.Option{Key: "LegacyAccessTokenRetireAt"}).Update("value", fmt.Sprint(time.Now().Unix()-1)).Error)
require.NoError(t, model.EnsureLegacyAccessTokenRetireAt(time.Now().Unix()))
response = middlewareBearerRequest(router, "/api/user/self", legacy)
assert.Equal(t, http.StatusUnauthorized, response.Code)
assert.Contains(t, response.Body.String(), `"code":"ACCESS_TOKEN_LEGACY_RETIRED"`)
response = middlewareBearerRequest(router, "/api/pricing", legacy)
assert.Equal(t, http.StatusOK, response.Code)
assert.JSONEq(t, `{"id":0}`, response.Body.String(), "optional authentication treats a retired token as anonymous")
assert.Equal(t, http.StatusOK, middlewareBearerRequest(router, "/api/user/self", profile).Code, "the deadline only retires legacy tokens")
}
func TestAccessTokenIdentityAndSingleLookup(t *testing.T) {
setupDashboardAuthMiddlewareTest(t)
gin.SetMode(gin.TestMode)
legacy := "middleware-identity-legacy"
user := createMiddlewarePATUser(t, "identity-user", legacy)
scoped, token := createMiddlewareScopedToken(t, user.Id, 0, "profile:read")
router := gin.New()
router.Use(AccessTokenAudit())
router.GET("/api/user/self", UserAuth(), func(c *gin.Context) {
_, session := GetSessionAuthIdentity(c)
identity, stepUp := GetStepUpIdentity(c)
c.JSON(http.StatusOK, gin.H{"session": session, "step_up": stepUp, "identity": identity})
})
var queries []string
const callbackName = "test:access-token-lookup-count"
require.NoError(t, model.DB.Callback().Query().After("gorm:query").Register(callbackName, func(tx *gorm.DB) {
queries = append(queries, tx.Statement.Table+" "+tx.Statement.SQL.String())
}))
t.Cleanup(func() { model.DB.Callback().Query().Remove(callbackName) })
countQueries := func(table, fragment string) int {
count := 0
for _, query := range queries {
if strings.HasPrefix(query, table+" ") && strings.Contains(query, fragment) {
count++
}
}
return count
}
for _, test := range []struct {
name, token string
stepUp bool
tokenTable int
legacyQuery int
}{
{name: "scoped token", token: scoped, stepUp: true, tokenTable: 1},
{name: "legacy token", token: legacy, legacyQuery: 1},
} {
t.Run(test.name, func(t *testing.T) {
queries = nil
response := middlewareBearerRequest(router, "/api/user/self", test.token)
require.Equal(t, http.StatusOK, response.Code, response.Body.String())
var body struct {
Session bool `json:"session"`
StepUp bool `json:"step_up"`
Identity service.AuthIdentity `json:"identity"`
}
require.NoError(t, common.Unmarshal(response.Body.Bytes(), &body))
assert.False(t, body.Session, "an access token is never a browser session")
assert.Equal(t, test.stepUp, body.StepUp)
if test.stepUp {
assert.Equal(t, service.AuthIdentity{UserID: user.Id, SessionID: model.AccessTokenSessionID(token.Id), UserAuthVersion: user.AuthVersion, SessionVersion: model.AccessTokenSessionVersion}, body.Identity)
}
// The access audit and authentication share one lookup: the
// credential query plus the owner's user row.
assert.Len(t, queries, 2)
assert.Equal(t, test.tokenTable, countQueries("user_access_tokens", "token_hash"))
assert.Equal(t, test.legacyQuery, countQueries("users", "access_token = "))
})
}
}
func TestAPIKeyFromWebSocketSubprotocol(t *testing.T) {
tests := []struct {
name string
protocols string
wantKey string
wantOK bool
}{
{
name: "responses protocol only",
protocols: "responses",
wantOK: false,
},
{
name: "realtime protocol only",
protocols: "realtime",
wantOK: false,
},
{
name: "responses with insecure key",
protocols: "responses, openai-insecure-api-key.sk-test",
wantKey: "sk-test",
wantOK: true,
},
{
name: "realtime with beta and insecure key",
protocols: "realtime, openai-insecure-api-key.sk-realtime, openai-beta.realtime-v1",
wantKey: "sk-realtime",
wantOK: true,
},
{
name: "empty insecure key",
protocols: "responses, openai-insecure-api-key.",
wantOK: false,
},
{
name: "bare insecure marker is not a key",
protocols: "openai-insecure-api-key",
wantOK: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
gotKey, gotOK := apiKeyFromWebSocketSubprotocol(tt.protocols)
assert.Equal(t, tt.wantOK, gotOK)
assert.Equal(t, tt.wantKey, gotKey)
})
}
}
func TestApplyWebSocketSubprotocolAuthorizationDoesNotOverrideProtocolOnly(t *testing.T) {
header := http.Header{}
header.Set("Authorization", "Bearer sk-original")
header.Set("Sec-WebSocket-Protocol", "responses")
header.Add("Sec-WebSocket-Protocol", "openai-beta.realtime-v1")
assert.False(t, applyWebSocketSubprotocolAuthorization(header))
assert.Equal(t, "Bearer sk-original", header.Get("Authorization"))
}
func TestApplyWebSocketSubprotocolAuthorizationOverridesWithInsecureKey(t *testing.T) {
header := http.Header{}
header.Set("Authorization", "Bearer sk-original")
header.Set("Sec-WebSocket-Protocol", "responses, openai-insecure-api-key.sk-from-protocol")
assert.True(t, applyWebSocketSubprotocolAuthorization(header))
assert.Equal(t, "Bearer sk-from-protocol", header.Get("Authorization"))
}
func TestApplyWebSocketSubprotocolAuthorizationReadsRepeatedHeaders(t *testing.T) {
header := http.Header{}
header.Set("Authorization", "Bearer sk-original")
header.Add("Sec-WebSocket-Protocol", "responses")
header.Add("Sec-WebSocket-Protocol", "openai-insecure-api-key.sk-later-field")
assert.True(t, applyWebSocketSubprotocolAuthorization(header))
assert.Equal(t, "Bearer sk-later-field", header.Get("Authorization"))
}