Compare commits

..

2 Commits

Author SHA1 Message Date
kiraWangRuilong
844c6eb30f refactor: enhance file lock and token file directory writable checking logic 2026-07-31 18:02:38 +08:00
kiraWangRuilong
de28420edb feat: optimize refresh token error handling 2026-07-31 18:02:38 +08:00
6 changed files with 527 additions and 1413 deletions

View File

@@ -40,15 +40,23 @@ func MaskToken(token string) string {
// GetStoredToken reads the stored UAT for a given (appId, userOpenId) pair.
func GetStoredToken(appId, userOpenId string) *StoredUAToken {
token, _ := readStoredToken(appId, userOpenId)
return token
}
func readStoredToken(appId, userOpenId string) (*StoredUAToken, error) {
jsonStr, err := keychain.Get(keychain.LarkCliService, accountKey(appId, userOpenId))
if err != nil || jsonStr == "" {
return nil
if err != nil {
return nil, err
}
if jsonStr == "" {
return nil, nil
}
var token StoredUAToken
if err := json.Unmarshal([]byte(jsonStr), &token); err != nil {
return nil
return nil, err
}
return &token
return &token, nil
}
// SetStoredToken persists a UAT.
@@ -66,6 +74,54 @@ func RemoveStoredToken(appId, userOpenId string) error {
return keychain.Remove(keychain.LarkCliService, accountKey(appId, userOpenId))
}
// sameStoredTokenGeneration reports whether two snapshots represent the same
// refresh-token generation. Access tokens are used only for case that does not
// contain a refresh token.
func isSameStoredTokenGeneration(current, expected *StoredUAToken) bool {
if current == nil || expected == nil ||
current.AppId != expected.AppId ||
current.UserOpenId != expected.UserOpenId {
return false
}
if current.RefreshToken != "" || expected.RefreshToken != "" {
return current.RefreshToken == expected.RefreshToken
}
return current.AccessToken == expected.AccessToken
}
// setStoredTokenIfCurrent stores updated only when expected is still the
// current token generation. It returns the token present after the check and
// whether the update was applied.
func setStoredTokenIfCurrent(expected, updated *StoredUAToken) (*StoredUAToken, bool, error) {
current, err := readStoredToken(expected.AppId, expected.UserOpenId)
if err != nil {
return nil, false, err
}
if !isSameStoredTokenGeneration(current, expected) {
return current, false, nil
}
if err := SetStoredToken(updated); err != nil {
return current, false, err
}
return updated, true, nil
}
// removeStoredTokenIfCurrent removes expected only when it is still the
// current token generation. It returns the token retained on a mismatch.
func removeStoredTokenIfCurrent(expected *StoredUAToken) (*StoredUAToken, bool, error) {
current, err := readStoredToken(expected.AppId, expected.UserOpenId)
if err != nil {
return nil, false, err
}
if !isSameStoredTokenGeneration(current, expected) {
return current, false, nil
}
if err := RemoveStoredToken(expected.AppId, expected.UserOpenId); err != nil {
return current, false, err
}
return nil, true, nil
}
// TokenStatus determines the freshness of a stored token.
func TokenStatus(token *StoredUAToken) string {
now := time.Now().UnixMilli()

View File

@@ -4,17 +4,18 @@
package auth
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"net/http/httptrace"
"os"
"path/filepath"
"regexp"
"strings"
"sync"
"sync/atomic"
"time"
"github.com/gofrs/flock"
@@ -81,7 +82,7 @@ func GetValidAccessToken(httpClient *http.Client, opts UATCallOptions) (string,
}
if status == "needs_refresh" {
refreshed, err := refreshWithLock(httpClient, opts, stored)
refreshed, err := refreshWithLock(httpClient, opts)
if err != nil {
return "", err
}
@@ -103,7 +104,7 @@ func GetValidAccessToken(httpClient *http.Client, opts UATCallOptions) (string,
}
// refreshWithLock acquires a file lock before attempting to refresh the token.
func refreshWithLock(httpClient *http.Client, opts UATCallOptions, stored *StoredUAToken) (*StoredUAToken, error) {
func refreshWithLock(httpClient *http.Client, opts UATCallOptions) (*StoredUAToken, error) {
key := fmt.Sprintf("%s:%s", opts.AppId, opts.UserOpenId)
// 1. Process-level lock (prevents multiple goroutines in the same process)
@@ -125,12 +126,9 @@ func refreshWithLock(httpClient *http.Client, opts UATCallOptions, stored *Store
refreshLocks.Delete(key)
}()
// 2. Cross-process lock using flock
// We use the same underlying storage directory resolution as keychain_other.go
// to ensure locks are isolated properly alongside other sensitive data.
configDir := core.GetConfigDir()
lockDir := filepath.Join(configDir, "locks")
// 2. Cross-process lock using the global config directory so all
// workspaces sharing the same token also share the same lock.
lockDir := filepath.Join(core.GetBaseConfigDir(), "locks")
if err := vfs.MkdirAll(lockDir, 0700); err != nil {
return nil, fmt.Errorf("failed to create lock directory: %w", err)
}
@@ -153,21 +151,91 @@ func refreshWithLock(httpClient *http.Client, opts UATCallOptions, stored *Store
}
defer fileLock.Unlock()
// 3. Double-checked locking: Check if another process has already refreshed the token
freshStored := GetStoredToken(opts.AppId, opts.UserOpenId)
if freshStored != nil {
status := TokenStatus(freshStored)
if status == "valid" {
// Another process refreshed it, we can just use the new token
if opts.ErrOut != nil {
fmt.Fprintf(opts.ErrOut, "[lark-cli] uat-client: token already refreshed by another process\n")
}
return freshStored, nil
// 3. Re-read under the global lock and use only the current generation.
freshStored, err := readStoredToken(opts.AppId, opts.UserOpenId)
if err != nil {
return nil, err
}
if freshStored == nil {
return nil, nil
}
switch TokenStatus(freshStored) {
case "valid":
if opts.ErrOut != nil {
fmt.Fprintf(opts.ErrOut, "[lark-cli] uat-client: token already refreshed by another process\n")
}
return freshStored, nil
case "expired":
retained, removed, err := removeStoredTokenIfCurrent(freshStored)
if err != nil {
return nil, err
}
if !removed {
return storedTokenAfterGenerationChange(retained, opts.UserOpenId)
}
if opts.ErrOut != nil {
fmt.Fprintf(opts.ErrOut, "[lark-cli] uat-client: refresh_token expired for %s, clearing\n", opts.UserOpenId)
}
return nil, nil
}
if err := ensureDirWritable(lockDir, "tmp_writetest-*"); err != nil {
if opts.ErrOut != nil {
fmt.Fprintf(opts.ErrOut,
"[lark-cli] [WARN] uat-client: refresh lock directory is not writable while refreshing: %v\n",
err)
}
return nil, err
}
// 4. Actually perform the refresh
return doRefreshToken(httpClient, opts, stored)
return doRefreshToken(httpClient, opts, freshStored)
}
const refreshMaxAttempts = 2
type refreshRequest struct {
GrantType string `json:"grant_type"`
RefreshToken string `json:"refresh_token"`
ClientID string `json:"client_id"`
ClientSecret string `json:"client_secret"`
}
// refreshResponse contains only fields documented by the OAuth token endpoint.
// Pointers distinguish an omitted numeric field from a real zero value.
type refreshResponse struct {
Code *int `json:"code"`
AccessToken string `json:"access_token"`
ExpiresIn *int64 `json:"expires_in"`
RefreshToken string `json:"refresh_token"`
RefreshTokenExpiresIn *int64 `json:"refresh_token_expires_in"`
TokenType string `json:"token_type"`
Scope string `json:"scope"`
Error string `json:"error"`
ErrorDescription string `json:"error_description"`
}
// refreshAction describes both retry behavior and local token disposition.
type refreshAction uint8
const (
// refreshSaveResponse saves a successful response.
refreshSaveResponse refreshAction = iota
// refreshRetryAndPreserve retries, preserving the stored token if retry fails.
refreshRetryAndPreserve
// refreshRetryAndClear retries, clearing the stored token if retry fails.
refreshRetryAndClear
// refreshStopAndPreserve stops without clearing the stored token.
refreshStopAndPreserve
// refreshStopAndClear stops and clears the stored token.
refreshStopAndClear
)
type refreshResult struct {
action refreshAction
response refreshResponse
err error
}
// doRefreshToken performs the actual HTTP request to refresh the token.
@@ -177,141 +245,318 @@ func doRefreshToken(httpClient *http.Client, opts UATCallOptions, stored *Stored
errOut = os.Stderr
}
now := time.Now().UnixMilli()
if now >= stored.RefreshExpiresAt {
if time.Now().UnixMilli() >= stored.RefreshExpiresAt {
fmt.Fprintf(errOut, "[lark-cli] uat-client: refresh_token expired for %s, clearing\n", opts.UserOpenId)
if err := RemoveStoredToken(opts.AppId, opts.UserOpenId); err != nil {
retained, removed, err := removeStoredTokenIfCurrent(stored)
if err != nil {
fmt.Fprintf(errOut, "[lark-cli] [WARN] uat-client: failed to remove expired token: %v\n", err)
return nil, err
}
if !removed {
return storedTokenAfterGenerationChange(retained, opts.UserOpenId)
}
return nil, nil
}
endpoints := ResolveOAuthEndpoints(opts.Domain)
endpoint := ResolveOAuthEndpoints(opts.Domain).Token
uncertain := false
for attempt := 1; attempt <= refreshMaxAttempts; attempt++ {
result := refreshOnce(httpClient, endpoint, opts, stored)
if result.action == refreshSaveResponse {
return saveRefreshResponse(opts, stored, result.response)
}
callEndpoint := func() (map[string]interface{}, error) {
form := url.Values{}
form.Set("grant_type", "refresh_token")
form.Set("refresh_token", stored.RefreshToken)
form.Set("client_id", opts.AppId)
form.Set("client_secret", opts.AppSecret)
switch result.action {
case refreshRetryAndPreserve, refreshRetryAndClear:
if result.action == refreshRetryAndClear {
uncertain = true
}
if attempt < refreshMaxAttempts {
fmt.Fprintf(errOut,
"[lark-cli] [WARN] uat-client: refresh attempt %d/%d failed for %s: %v; retrying\n",
attempt, refreshMaxAttempts, opts.UserOpenId, result.err)
continue
}
case refreshStopAndPreserve, refreshStopAndClear:
default:
return nil, errs.NewInternalError(errs.SubtypeUnknown,
"unrecognized token refresh action %d", result.action)
}
req, err := http.NewRequest("POST", endpoints.Token, strings.NewReader(form.Encode()))
clearToken := result.action == refreshStopAndClear ||
result.action == refreshRetryAndClear ||
(result.action == refreshRetryAndPreserve && uncertain)
if !clearToken {
fmt.Fprintf(errOut,
"[lark-cli] [WARN] uat-client: refresh failed for %s, preserving token: %v\n",
opts.UserOpenId, result.err)
return nil, result.err
}
if problem, ok := errs.ProblemOf(result.err); ok {
problem.Retryable = false
}
retained, removed, err := removeStoredTokenIfCurrent(stored)
if err != nil {
fmt.Fprintf(errOut, "[lark-cli] [WARN] uat-client: failed to remove token: %v\n", err)
return nil, err
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
resp, err := httpClient.Do(req)
if err != nil {
return nil, err
if !removed {
fmt.Fprintf(errOut,
"[lark-cli] [WARN] uat-client: stored token changed during refresh for %s, preserving current token\n",
opts.UserOpenId)
return storedTokenAfterGenerationChange(retained, opts.UserOpenId)
}
defer resp.Body.Close()
logHTTPResponse(resp)
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("token refresh read error: %v", err)
}
var data map[string]interface{}
if err := json.Unmarshal(body, &data); err != nil {
return nil, fmt.Errorf("token refresh parse error: %w", err)
}
return data, nil
fmt.Fprintf(errOut,
"[lark-cli] [WARN] uat-client: refresh failed for %s, token cleared: %v\n",
opts.UserOpenId, result.err)
return nil, result.err
}
data, err := callEndpoint()
return nil, errs.NewInternalError(errs.SubtypeUnknown,
"token refresh exhausted attempts without a result")
}
func refreshOnce(httpClient *http.Client, endpoint string, opts UATCallOptions, stored *StoredUAToken) refreshResult {
payload, err := json.Marshal(refreshRequest{
GrantType: "refresh_token",
RefreshToken: stored.RefreshToken,
ClientID: opts.AppId,
ClientSecret: opts.AppSecret,
})
if err != nil {
return nil, err
}
code := getInt(data, "code", -1)
meta, metaOK := errclass.LookupCodeMeta(code)
if metaOK && meta.Category == errs.CategoryPolicy {
challengeUrl := getStr(data, "challenge_url")
cliHint := getStr(data, "cli_hint")
msg := getStr(data, "error_description")
return nil, &errs.SecurityPolicyError{
Problem: errs.Problem{
Category: errs.CategoryPolicy,
Subtype: meta.Subtype,
Code: code,
Message: msg,
Hint: cliHint,
},
ChallengeURL: challengeUrl,
return refreshResult{
action: refreshStopAndPreserve,
err: errs.NewInternalError(errs.SubtypeSDKError,
"failed to encode token refresh request: %v", err).
WithCause(err),
}
}
errStr := getStr(data, "error")
var wroteRequest atomic.Bool
trace := &httptrace.ClientTrace{
WroteRequest: func(httptrace.WroteRequestInfo) {
wroteRequest.Store(true)
},
}
ctx := httptrace.WithClientTrace(context.Background(), trace)
req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(payload))
if err != nil {
return refreshResult{
action: refreshStopAndPreserve,
err: errs.NewInternalError(errs.SubtypeSDKError,
"failed to create token refresh request: %v", err).
WithCause(err),
}
}
req.Header.Set("Content-Type", "application/json; charset=utf-8")
if (code != -1 && code != 0) || errStr != "" {
// Retryable server error: retry once, then clear token on second failure.
if metaOK && meta.Category == errs.CategoryAuthentication && meta.Retryable {
fmt.Fprintf(errOut, "[lark-cli] [WARN] uat-client: refresh transient error (code=%d) for %s, retrying once\n", code, opts.UserOpenId)
data, err = callEndpoint()
if err != nil {
fmt.Fprintf(errOut, "[lark-cli] [WARN] uat-client: refresh retry network error for %s, clearing token\n", opts.UserOpenId)
if err := RemoveStoredToken(opts.AppId, opts.UserOpenId); err != nil {
fmt.Fprintf(errOut, "[lark-cli] [WARN] uat-client: failed to remove token: %v\n", err)
}
return nil, nil
resp, err := httpClient.Do(req)
if err != nil {
action := refreshRetryAndPreserve
if wroteRequest.Load() {
action = refreshRetryAndClear
}
return refreshResult{action: action, err: err}
}
defer resp.Body.Close()
logHTTPResponse(resp)
body, err := io.ReadAll(resp.Body)
if err != nil {
return refreshResult{
action: refreshRetryAndClear,
err: errs.NewNetworkError(errs.SubtypeNetworkTransport,
"token refresh response read failed: %v", err).
WithRetryable().
WithCause(err),
}
}
var parsed refreshResponse
if err := json.Unmarshal(body, &parsed); err != nil {
return refreshResult{
action: refreshRetryAndClear,
err: errs.NewInternalError(errs.SubtypeInvalidResponse,
"token refresh returned invalid JSON: %v", err).
WithRetryable().
WithCause(err),
}
}
if parsed.Code == nil {
return refreshResult{
action: refreshRetryAndClear,
err: errs.NewInternalError(errs.SubtypeInvalidResponse,
"token refresh response is missing required field code").
WithRetryable(),
}
}
code := *parsed.Code
if code != 0 {
if meta, ok := errclass.LookupCodeMeta(code); ok && meta.Category == errs.CategoryPolicy {
var policyFields struct {
ChallengeURL string `json:"challenge_url"`
CLIHint string `json:"cli_hint"`
}
code = getInt(data, "code", -1)
errStr = getStr(data, "error")
if (code != -1 && code != 0) || errStr != "" {
fmt.Fprintf(errOut, "[lark-cli] [WARN] uat-client: refresh failed after retry (code=%d) for %s, clearing token\n", code, opts.UserOpenId)
if err := RemoveStoredToken(opts.AppId, opts.UserOpenId); err != nil {
fmt.Fprintf(errOut, "[lark-cli] [WARN] uat-client: failed to remove token: %v\n", err)
}
return nil, nil
_ = json.Unmarshal(body, &policyFields)
return refreshResult{
action: refreshStopAndPreserve,
err: &errs.SecurityPolicyError{
Problem: errs.Problem{
Category: errs.CategoryPolicy,
Subtype: meta.Subtype,
Code: code,
Message: parsed.ErrorDescription,
Hint: policyFields.CLIHint,
},
ChallengeURL: policyFields.ChallengeURL,
},
}
// Retry succeeded, fall through to parse token below.
}
message := parsed.ErrorDescription
if message == "" {
message = parsed.Error
}
// BuildAPIError accepts the common OpenAPI message key; OAuth names
// the same value error_description.
apiErr := errclass.BuildAPIError(map[string]any{
"code": code,
"msg": message,
}, errclass.ClassifyContext{
Brand: string(opts.Domain),
AppID: opts.AppId,
Identity: "user",
})
if authErr, ok := apiErr.(*errs.AuthenticationError); ok {
authErr.UserOpenID = opts.UserOpenId
}
return refreshResult{action: refreshActionForCode(code), err: apiErr}
}
if parsed.RefreshToken == "" {
parsed.RefreshToken = stored.RefreshToken
}
if parsed.AccessToken == "" {
return refreshResult{
action: refreshStopAndPreserve,
err: errs.NewInternalError(errs.SubtypeInvalidResponse,
"token refresh response is missing required field access_token").
WithRetryable(),
}
}
if parsed.ExpiresIn == nil || *parsed.ExpiresIn <= 0 {
parsed.ExpiresIn = new(int64)
*parsed.ExpiresIn = 7200 // 2 hours
}
if parsed.RefreshTokenExpiresIn == nil || *parsed.RefreshTokenExpiresIn <= 0 {
parsed.RefreshTokenExpiresIn = new(int64)
if stored.RefreshExpiresAt <= 0 {
*parsed.RefreshTokenExpiresIn = 2592000 // 30 days
} else {
// All other errors: clear token, require re-authorization.
fmt.Fprintf(errOut, "[lark-cli] [WARN] uat-client: refresh failed (code=%d), clearing token for %s\n", code, opts.UserOpenId)
if err := RemoveStoredToken(opts.AppId, opts.UserOpenId); err != nil {
fmt.Fprintf(errOut, "[lark-cli] [WARN] uat-client: failed to remove token: %v\n", err)
}
return nil, nil
now := time.Now().UnixMilli()
*parsed.RefreshTokenExpiresIn = (stored.RefreshExpiresAt - now) / 1000
}
}
accessToken := getStr(data, "access_token")
if accessToken == "" {
return nil, fmt.Errorf("Token refresh returned no access_token")
}
return refreshResult{action: refreshSaveResponse, response: parsed}
}
refreshToken := getStr(data, "refresh_token")
if refreshToken == "" {
refreshToken = stored.RefreshToken
func refreshActionForCode(code int) refreshAction {
meta, ok := errclass.LookupCodeMeta(code)
switch {
case !ok:
return refreshRetryAndClear
case meta.Category == errs.CategoryPolicy:
return refreshStopAndPreserve
case meta.Retryable:
return refreshRetryAndPreserve
default:
return refreshStopAndClear
}
}
expiresIn := getInt(data, "expires_in", 7200)
refreshExpiresIn := getInt(data, "refresh_token_expires_in", 0)
refreshExpiresAt := stored.RefreshExpiresAt
if refreshExpiresIn > 0 {
refreshExpiresAt = now + int64(refreshExpiresIn)*1000
}
scope := getStr(data, "scope")
if scope == "" {
scope = stored.Scope
}
func saveRefreshResponse(opts UATCallOptions, stored *StoredUAToken, response refreshResponse) (*StoredUAToken, error) {
now := time.Now().UnixMilli()
updated := &StoredUAToken{
UserOpenId: stored.UserOpenId,
AppId: opts.AppId,
AccessToken: accessToken,
RefreshToken: refreshToken,
ExpiresAt: now + int64(expiresIn)*1000,
RefreshExpiresAt: refreshExpiresAt,
Scope: scope,
AccessToken: response.AccessToken,
RefreshToken: response.RefreshToken,
ExpiresAt: now + *response.ExpiresIn*1000,
RefreshExpiresAt: now + *response.RefreshTokenExpiresIn*1000,
Scope: response.Scope,
GrantedAt: stored.GrantedAt,
}
if err := SetStoredToken(updated); err != nil {
current, saved, err := setStoredTokenIfCurrent(stored, updated)
if err != nil {
return nil, err
}
if !saved {
if opts.ErrOut != nil {
fmt.Fprintf(opts.ErrOut,
"[lark-cli] [WARN] uat-client: stored token changed during refresh for %s, preserving current token\n",
opts.UserOpenId)
}
return storedTokenAfterGenerationChange(current, opts.UserOpenId)
}
return updated, nil
}
func storedTokenAfterGenerationChange(current *StoredUAToken, userOpenId string) (*StoredUAToken, error) {
if current == nil {
return nil, nil
}
if TokenStatus(current) == "valid" {
return current, nil
}
return nil, errs.NewInternalError(errs.SubtypeStorage,
"stored refresh token changed while refreshing user %q", userOpenId).
WithRetryable().
WithHint("retry the command")
}
func ensureDirWritable(dir, tempPrefix string) error {
if dir == "" {
return nil
}
if err := vfs.MkdirAll(dir, 0700); err != nil {
return errs.NewInternalError(errs.SubtypeFileIO,
"failed to access refresh lock directory %q", dir).
WithCause(err).
WithHint("If running in a sandbox or read-only workspace, grant write access for this directory and retry.")
}
tmp, err := vfs.CreateTemp(dir, tempPrefix)
if err != nil {
return errs.NewInternalError(errs.SubtypeFileIO,
"failed to create temporary file in refresh lock directory %q", dir).
WithCause(err).
WithHint("If running in a sandbox or read-only workspace, grant write access for this directory and retry.")
}
tmpName := tmp.Name()
closeErr := tmp.Close()
if removeErr := vfs.Remove(tmpName); removeErr != nil {
err := fmt.Errorf("%v", removeErr)
if closeErr != nil {
err = fmt.Errorf("%v; also failed to close temp file: %v", removeErr, closeErr)
}
return errs.NewInternalError(errs.SubtypeFileIO,
"failed to clean up refresh lock write-check file %q", tmpName).
WithCause(err)
}
if closeErr != nil {
return errs.NewInternalError(errs.SubtypeFileIO,
"failed to close refresh lock write-check file %q", tmpName).
WithCause(closeErr)
}
return nil
}

View File

@@ -38,11 +38,13 @@ var codeMeta = map[int]CodeMeta{
99991668: {Category: errs.CategoryAuthentication, Subtype: errs.SubtypeTokenInvalid}, // UAT invalid/expired (server does not distinguish)
99991663: {Category: errs.CategoryAuthentication, Subtype: errs.SubtypeTokenInvalid}, // access_token invalid
99991677: {Category: errs.CategoryAuthentication, Subtype: errs.SubtypeTokenExpired}, // UAT expired
20026: {Category: errs.CategoryAuthentication, Subtype: errs.SubtypeRefreshTokenInvalid}, // refresh_token v1 legacy format
20024: {Category: errs.CategoryAuthentication, Subtype: errs.SubtypeRefreshTokenInvalid}, // authorization code or refresh_token does not match client_id
20026: {Category: errs.CategoryAuthentication, Subtype: errs.SubtypeRefreshTokenInvalid}, // refresh_token is invalid or v1 legacy format
20037: {Category: errs.CategoryAuthentication, Subtype: errs.SubtypeRefreshTokenExpired}, // refresh_token expired
20064: {Category: errs.CategoryAuthentication, Subtype: errs.SubtypeRefreshTokenRevoked}, // refresh_token revoked
20073: {Category: errs.CategoryAuthentication, Subtype: errs.SubtypeRefreshTokenReused}, // refresh_token already used
20050: {Category: errs.CategoryAuthentication, Subtype: errs.SubtypeRefreshServerError, Retryable: true}, // refresh endpoint transient error
20064: {Category: errs.CategoryAuthentication, Subtype: errs.SubtypeRefreshTokenRevoked}, // refresh_token revoked
20072: {Category: errs.CategoryAuthentication, Subtype: errs.SubtypeRefreshServerError}, // refresh endpoint temporarily unavailable
20073: {Category: errs.CategoryAuthentication, Subtype: errs.SubtypeRefreshTokenReused}, // refresh_token already used
// CategoryAuthorization
99991672: {Category: errs.CategoryAuthorization, Subtype: errs.SubtypeAppScopeNotApplied},
@@ -51,6 +53,13 @@ var codeMeta = map[int]CodeMeta{
230027: {Category: errs.CategoryAuthorization, Subtype: errs.SubtypeUserUnauthorized}, // user never authorized the app
99991673: {Category: errs.CategoryAuthorization, Subtype: errs.SubtypeAppUnavailable}, // app status unavailable
99991662: {Category: errs.CategoryAuthorization, Subtype: errs.SubtypeAppDisabled}, // app currently disabled in tenant
20008: {Category: errs.CategoryAuthorization, Subtype: errs.SubtypeUserUnauthorized}, // user does not exist
20009: {Category: errs.CategoryAuthorization, Subtype: errs.SubtypeAppUnavailable}, // app specified is not installed
20010: {Category: errs.CategoryAuthorization, Subtype: errs.SubtypeUserUnauthorized}, // user does not have permission to use this app
20048: {Category: errs.CategoryAuthorization, Subtype: errs.SubtypeAppUnavailable}, // app specified is not exist
20066: {Category: errs.CategoryAuthorization, Subtype: errs.SubtypeUserUnauthorized}, // user staus is not normal
20069: {Category: errs.CategoryAuthorization, Subtype: errs.SubtypeAppDisabled}, // app specified is disabled
20074: {Category: errs.CategoryAuthorization, Subtype: errs.SubtypeAppUnavailable}, // app specified not allows for refresh token
// CategoryAPI
99991400: {Category: errs.CategoryAPI, Subtype: errs.SubtypeRateLimit, Retryable: true},
@@ -62,10 +71,17 @@ var codeMeta = map[int]CodeMeta{
1063006: {Category: errs.CategoryAPI, Subtype: errs.SubtypeRateLimit}, // drive perm-apply quota; 5/day, not short-term retryable
1063007: {Category: errs.CategoryAPI, Subtype: errs.SubtypeInvalidParameters},
231205: {Category: errs.CategoryAPI, Subtype: errs.SubtypeOwnershipMismatch},
20001: {Category: errs.CategoryAPI, Subtype: errs.SubtypeInvalidParameters}, // request missing required parameter
20036: {Category: errs.CategoryAPI, Subtype: errs.SubtypeInvalidParameters}, // grant_type not supported
20063: {Category: errs.CategoryAPI, Subtype: errs.SubtypeInvalidParameters}, // request format error
20067: {Category: errs.CategoryAPI, Subtype: errs.SubtypeInvalidParameters}, // scope list contains duplicated items
20068: {Category: errs.CategoryAPI, Subtype: errs.SubtypeInvalidParameters}, // scope list contains forbidden permissions
20070: {Category: errs.CategoryAPI, Subtype: errs.SubtypeInvalidParameters}, // request provide multiple authorization methods
// CategoryConfig
99991543: {Category: errs.CategoryConfig, Subtype: errs.SubtypeInvalidClient}, // RFC 6749 §5.2 — app_id / app_secret incorrect (Open API)
10014: {Category: errs.CategoryConfig, Subtype: errs.SubtypeInvalidClient}, // legacy TAT endpoint — "app secret invalid" (pre-v3 variant of 99991543; CLI now reports invalid_client)
20002: {Category: errs.CategoryConfig, Subtype: errs.SubtypeInvalidClient}, // client secret invalid
// CategoryPolicy
21000: {Category: errs.CategoryPolicy, Subtype: errs.SubtypeChallengeRequired},

View File

@@ -23,11 +23,27 @@ func TestLookupCodeMeta_CredentialCodes(t *testing.T) {
{99991668, errs.CategoryAuthentication, errs.SubtypeTokenInvalid, false},
{99991663, errs.CategoryAuthentication, errs.SubtypeTokenInvalid, false},
{99991677, errs.CategoryAuthentication, errs.SubtypeTokenExpired, false},
{20024, errs.CategoryAuthentication, errs.SubtypeRefreshTokenInvalid, false},
{20026, errs.CategoryAuthentication, errs.SubtypeRefreshTokenInvalid, false},
{20037, errs.CategoryAuthentication, errs.SubtypeRefreshTokenExpired, false},
{20064, errs.CategoryAuthentication, errs.SubtypeRefreshTokenRevoked, false},
{20073, errs.CategoryAuthentication, errs.SubtypeRefreshTokenReused, false},
{20050, errs.CategoryAuthentication, errs.SubtypeRefreshServerError, true},
{20064, errs.CategoryAuthentication, errs.SubtypeRefreshTokenRevoked, false},
{20072, errs.CategoryAuthentication, errs.SubtypeRefreshServerError, false},
{20073, errs.CategoryAuthentication, errs.SubtypeRefreshTokenReused, false},
{20008, errs.CategoryAuthorization, errs.SubtypeUserUnauthorized, false},
{20009, errs.CategoryAuthorization, errs.SubtypeAppUnavailable, false},
{20010, errs.CategoryAuthorization, errs.SubtypeUserUnauthorized, false},
{20048, errs.CategoryAuthorization, errs.SubtypeAppUnavailable, false},
{20066, errs.CategoryAuthorization, errs.SubtypeUserUnauthorized, false},
{20069, errs.CategoryAuthorization, errs.SubtypeAppDisabled, false},
{20074, errs.CategoryAuthorization, errs.SubtypeAppUnavailable, false},
{20001, errs.CategoryAPI, errs.SubtypeInvalidParameters, false},
{20036, errs.CategoryAPI, errs.SubtypeInvalidParameters, false},
{20063, errs.CategoryAPI, errs.SubtypeInvalidParameters, false},
{20067, errs.CategoryAPI, errs.SubtypeInvalidParameters, false},
{20068, errs.CategoryAPI, errs.SubtypeInvalidParameters, false},
{20070, errs.CategoryAPI, errs.SubtypeInvalidParameters, false},
{20002, errs.CategoryConfig, errs.SubtypeInvalidClient, false},
}
for _, tc := range cases {
t.Run(fmt.Sprintf("%d", tc.code), func(t *testing.T) {

File diff suppressed because it is too large Load Diff

View File

@@ -1004,17 +1004,9 @@ class XmlTextOverlapLintGeometryTest(unittest.TestCase):
"""
)
overlap_pairs = {tuple(issue["elements"]) for issue in result["slides"][0]["issues"]}
# Two caption/label pairs overlap; blV also trips the width-wrap rule (its 15-char
# caption renders wider than its 150px box), which is an intentional error here.
self.assertEqual(result["summary"]["error_count"], 3)
self.assertEqual(result["summary"]["error_count"], 2)
self.assertIn(("blY", "blV"), overlap_pairs)
self.assertIn(("blQ", "blS"), overlap_pairs)
wrap_ids = {
issue["elements"][0]
for issue in result["slides"][0]["issues"]
if issue.get("overflow_axis") == "width"
}
self.assertEqual(wrap_ids, {"blV"})
def test_lint_xml_detects_horizontal_text_overflow_across_declared_box_gap(self) -> None:
result = xml_text_overlap_lint.lint_xml(
@@ -1127,42 +1119,6 @@ class XmlTextOverlapLintGeometryTest(unittest.TestCase):
self.assertEqual(overflowing_issue["overflow"], 30)
self.assertIn('wrap="true" autoFit="normal-auto-fit"', overflowing_issue["message"])
def test_lint_xml_detects_short_label_that_wraps_by_width(self) -> None:
result = xml_text_overlap_lint.lint_xml(
"""
<slide xmlns="http://www.larkoffice.com/sml/2.0">
<data>
<shape id="near-fit" type="text" topLeftX="40" topLeftY="40" width="176" height="96">
<content textType="sub-headline" fontSize="32" fontFamily="思源黑体" bold="true"><p>Slides 87% </p></content>
</shape>
<shape id="under-measured" type="text" topLeftX="40" topLeftY="160" width="136" height="90">
<content fontSize="30" fontFamily="黑体"><p>Docs 99%</p></content>
</shape>
<shape id="auto-fit-spaced" type="text" topLeftX="300" topLeftY="40" width="227" height="96">
<content textType="sub-headline" fontSize="32" fontFamily="思源黑体" bold="true" autoFit="shape-auto-fit"><p>autofix 87% </p></content>
</shape>
<shape id="no-wrap-label" type="text" topLeftX="300" topLeftY="160" width="136" height="90">
<content fontSize="30" fontFamily="黑体" wrap="false"><p>Docs 99%</p></content>
</shape>
<shape id="comfortable" type="text" topLeftX="600" topLeftY="40" width="300" height="60">
<content fontSize="24" fontFamily="思源黑体"><p>OK</p></content>
</shape>
</data>
</slide>
"""
)
wrap_issues = [
issue for issue in result["slides"][0]["issues"] if issue.get("overflow_axis") == "width"
]
wrap_ids = {issue["elements"][0] for issue in wrap_issues}
# The three real false-negatives are caught, independent of autoFit and collapsed spaces.
self.assertEqual(wrap_ids, {"near-fit", "under-measured", "auto-fit-spaced"})
self.assertTrue(all(issue["level"] == "error" for issue in wrap_issues))
self.assertTrue(all(issue["code"] == "text_may_overflow_shape" for issue in wrap_issues))
# wrap="false" opts a run out; a label that comfortably fits is not flagged.
self.assertNotIn("no-wrap-label", wrap_ids)
self.assertNotIn("comfortable", wrap_ids)
def test_lint_xml_uses_fixed_line_spacing_for_text_height_warning(self) -> None:
result = xml_text_overlap_lint.lint_xml(
"""
@@ -1247,28 +1203,6 @@ class XmlTextOverlapLintGeometryTest(unittest.TestCase):
]
self.assertEqual(overflow_issues, [])
def test_lint_xml_reports_labeled_short_metric_when_it_wraps(self) -> None:
result = xml_text_overlap_lint.lint_xml(
"""
<slide xmlns="http://www.larkoffice.com/sml/2.0">
<data>
<shape id="sheet-success" type="text" topLeftX="520" topLeftY="385" width="180" height="50">
<content textType="headline" fontSize="32" bold="true" autoFit="no-auto-fit">
<p>Sheet 98.5%</p>
</content>
</shape>
</data>
</slide>
"""
)
overflow_issues = [
issue
for issue in result["slides"][0]["issues"]
if issue["code"] == "text_may_overflow_shape"
]
self.assertEqual(len(overflow_issues), 1)
self.assertEqual(overflow_issues[0]["elements"], ["sheet-success"])
def test_lint_xml_reports_plain_short_metric_when_it_wraps(self) -> None:
result = xml_text_overlap_lint.lint_xml(
"""
@@ -1289,97 +1223,6 @@ class XmlTextOverlapLintGeometryTest(unittest.TestCase):
self.assertEqual(len(overflow_issues), 1)
self.assertEqual(overflow_issues[0]["elements"], ["plain-age"])
def test_lint_xml_reports_cjk_credit_with_em_dashes_wrapping_narrow_box(self) -> None:
# "—— 李白" in a tight author-credit box wraps in the renderer because the two em-dashes render
# full-width inside a CJK run (slides p1: bMW). unicodedata marks em-dash as ambiguous width, so
# a naive Latin-punctuation estimate under-reports the line and misses the wrap. The width check
# must treat ambiguous glyphs as full-width in CJK context (Bucket A4).
result = xml_text_overlap_lint.lint_xml(
"""
<slide xmlns="http://www.larkoffice.com/sml/2.0">
<data>
<shape id="credit" type="text" topLeftX="66" topLeftY="124" width="46" height="18">
<content fontSize="12"><p>—— 李白</p></content>
</shape>
</data>
</slide>
"""
)
wrap_issues = [
issue for issue in result["slides"][0]["issues"]
if issue["code"] == "text_may_overflow_shape" and issue["elements"] == ["credit"]
]
# Promoting the em-dashes to full-width makes the run too wide for the box; the renderer then
# wraps it to two lines that also overflow the 18px height, so either the width or the height
# detector may surface it first -- the contract is that the credit is flagged, not which axis.
self.assertEqual(len(wrap_issues), 1)
self.assertIn(wrap_issues[0]["overflow_axis"], {"width", "height"})
def test_lint_xml_keeps_latin_en_dash_range_narrow(self) -> None:
# The ambiguous-width promotion is context-gated: an en-dash in a pure-Latin run ("20202023")
# stays half-width, so a comfortably-sized box must not be reported. Guards A4 from over-firing
# by inflating every dash to full-width regardless of surrounding script.
result = xml_text_overlap_lint.lint_xml(
"""
<slide xmlns="http://www.larkoffice.com/sml/2.0">
<data>
<shape id="range" type="text" topLeftX="80" topLeftY="80" width="140" height="30">
<content fontSize="14"><p>20202023</p></content>
</shape>
</data>
</slide>
"""
)
wrap_issues = [
issue for issue in result["slides"][0]["issues"]
if issue["code"] == "text_may_overflow_shape"
]
self.assertEqual(wrap_issues, [])
def test_lint_xml_reports_percent_heavy_run_overflowing_by_full_width_glyph(self) -> None:
# "%" is Unicode half-width (Na) but renders near full-width, so a percentage-heavy run wraps to
# more lines than a naive punct-coefficient estimate and overflows its box height (slides p3:
# bhU "Docs 99%Docs 99%Docs 99%%1"). Measuring "%" at its true advance is what surfaces this.
result = xml_text_overlap_lint.lint_xml(
"""
<slide xmlns="http://www.larkoffice.com/sml/2.0">
<data>
<shape id="metrics" type="text" topLeftX="587" topLeftY="60" width="227" height="100">
<content fontSize="30" autoFit="no-auto-fit"><p>Docs 99%Docs 99%Docs 99%%1</p></content>
</shape>
</data>
</slide>
"""
)
overflow = [
issue for issue in result["slides"][0]["errors"]
if issue["code"] == "text_may_overflow_shape" and issue["elements"] == ["metrics"]
]
self.assertEqual(len(overflow), 1)
def test_lint_xml_marginal_height_warning_does_not_mask_width_error(self) -> None:
# A short "Slides 87%" label sized so its wrapped two lines graze the box height by <1px yields a
# height *warning*, while the same run is genuinely too wide -> a width *error*. The width error
# must still surface: a marginal height warning must not suppress it via already_flagged_ids
# (slides p3: bMP/bhd). The run is reported once, at error level.
result = xml_text_overlap_lint.lint_xml(
"""
<slide xmlns="http://www.larkoffice.com/sml/2.0">
<data>
<shape id="label" type="text" topLeftX="244" topLeftY="120" width="176" height="79">
<content fontSize="32" bold="true" autoFit="no-auto-fit"><p>Slides 87%</p></content>
</shape>
</data>
</slide>
"""
)
reports = [
issue for issue in result["slides"][0]["issues"]
if issue["code"] == "text_may_overflow_shape" and issue["elements"] == ["label"]
]
self.assertEqual(len(reports), 1)
self.assertEqual(reports[0]["level"], "error")
def test_lint_xml_allows_centered_short_label_near_fit_as_single_line(self) -> None:
result = xml_text_overlap_lint.lint_xml(
"""
@@ -1873,10 +1716,10 @@ class XmlTextOverlapLintGeometryTest(unittest.TestCase):
<slide xmlns="http://www.larkoffice.com/sml/2.0">
<data>
<img src="tok" topLeftX="-120" topLeftY="20" width="360" height="360"/>
<shape type="text" topLeftX="300" topLeftY="80" width="180" height="80">
<shape type="text" topLeftX="40" topLeftY="80" width="180" height="80">
<content textType="title" fontSize="44"><p>Title</p></content>
</shape>
<shape type="text" topLeftX="300" topLeftY="170" width="180" height="40">
<shape type="text" topLeftX="40" topLeftY="120" width="180" height="40">
<content textType="sub-headline" fontSize="20"><p>Subtitle</p></content>
</shape>
</data>
@@ -2050,32 +1893,6 @@ class XmlTextOverlapLintGeometryTest(unittest.TestCase):
]
self.assertEqual(len(crossing), 1)
def test_lint_xml_reports_horizontal_line_inside_wide_line_spacing_span(self) -> None:
# 3 lines of fontSize 20 at multiple:1.8 give a real 92px glyph span, but the flat
# font_size*1.2 approximation is only 72px. Both boxes centre in the 200px shape, so the flat
# eroded box is ~[246,314] while the spacing-aware eroded box is ~[236,324]. A rule at y=240
# lands in that top margin -- inside the real glyph rows yet outside the flat box -- so it only
# reports once the line-crossing path uses the spacing-aware height (Bucket C, slides p8/p10).
result = xml_text_overlap_lint.lint_xml(
"""
<slide xmlns="http://www.larkoffice.com/sml/2.0">
<data>
<shape id="poem" type="text" topLeftX="80" topLeftY="180" width="360" height="200">
<content fontSize="20" lineSpacing="multiple:1.8"><p>第一行诗句文字</p><p>第二行诗句文字</p><p>第三行诗句文字</p></content>
</shape>
<line id="rule" startX="80" startY="240" endX="220" endY="240">
<border color="rgb(0, 0, 0)" width="3"/>
</line>
</data>
</slide>
"""
)
crossing = [
issue for issue in result["slides"][0]["errors"] if set(issue["elements"]) == {"rule", "poem"}
]
self.assertEqual(len(crossing), 1)
self.assertEqual(crossing[0]["code"], "bbox_overlap")
def test_lint_xml_reports_diagonal_line_crossing_text_block(self) -> None:
result = xml_text_overlap_lint.lint_xml(
"""
@@ -2504,266 +2321,6 @@ class XmlTextOverlapLintGeometryTest(unittest.TestCase):
self.assertEqual(result["summary"]["warning_count"], 0)
self.assertEqual(result["slides"][0]["issues"], [])
def test_lint_xml_reports_rotated_text_colliding_with_horizontal_text(self) -> None:
# A 270-rotated label sweeps a vertical footprint that overlaps a nearby horizontal label. With
# rotation-aware glyph boxes the collision is detectable, and because the runs are not parallel
# the overlap ratio is tiny so the absolute-area fallback must flag it (slides p6, Bucket D+E).
result = xml_text_overlap_lint.lint_xml(
"""
<slide xmlns="http://www.larkoffice.com/sml/2.0">
<data>
<shape id="flat" type="text" topLeftX="240" topLeftY="200" width="64" height="24">
<content fontSize="16"><p>文字碰撞</p></content>
</shape>
<shape id="spun" type="text" topLeftX="272" topLeftY="232" width="64" height="24" rotation="270">
<content fontSize="16"><p>文字碰撞</p></content>
</shape>
</data>
</slide>
"""
)
collisions = [
issue for issue in result["slides"][0]["errors"]
if issue["code"] == "bbox_overlap" and set(issue["elements"]) == {"flat", "spun"}
]
self.assertEqual(len(collisions), 1)
def test_lint_xml_still_suppresses_coincident_shadow_text_overlay(self) -> None:
# A drop-shadow duplicate offset by a pixel is an intentional overlay; the coincidence check
# must keep suppressing it even though the text is identical (guards the E1 tightening).
result = xml_text_overlap_lint.lint_xml(
"""
<slide xmlns="http://www.larkoffice.com/sml/2.0">
<data>
<shape id="shadow" type="text" topLeftX="200" topLeftY="200" width="200" height="40">
<content fontSize="20"><p>标题文字</p></content>
</shape>
<shape id="fill" type="text" topLeftX="202" topLeftY="202" width="200" height="40">
<content fontSize="20"><p>标题文字</p></content>
</shape>
</data>
</slide>
"""
)
collisions = [
issue for issue in result["slides"][0]["errors"]
if issue["code"] == "bbox_overlap" and set(issue["elements"]) == {"shadow", "fill"}
]
self.assertEqual(collisions, [])
def test_lint_xml_reports_text_overflowing_background_container(self) -> None:
# Text anchored inside a background card whose glyph box spills past the card's bottom edge has
# outgrown the box the author sized for it (slides p7). The card is drawn first (lower z-order),
# so it is the container; the text must surface as text_overflows_container (Bucket B).
result = xml_text_overlap_lint.lint_xml(
"""
<slide xmlns="http://www.larkoffice.com/sml/2.0">
<data>
<shape id="card" type="rect" topLeftX="200" topLeftY="200" width="120" height="40">
<fill><fillColor color="rgba(230,230,230,1)"/></fill>
</shape>
<shape id="body" type="text" topLeftX="205" topLeftY="205" width="110" height="120">
<content fontSize="16"><p>第一行</p><p>第二行</p><p>第三行</p></content>
</shape>
</data>
</slide>
"""
)
overflow = [
issue for issue in result["slides"][0]["errors"]
if issue["code"] == "text_overflows_container" and set(issue["elements"]) == {"body", "card"}
]
self.assertEqual(len(overflow), 1)
self.assertGreater(overflow[0]["overflow"]["bottom"], 4)
def test_lint_xml_ignores_text_fitting_inside_background_container(self) -> None:
# Text whose glyph box stays inside its background card is fine; the container rule must stay
# silent so tightly-fitted-but-valid cards are not falsely reported.
result = xml_text_overlap_lint.lint_xml(
"""
<slide xmlns="http://www.larkoffice.com/sml/2.0">
<data>
<shape id="card" type="rect" topLeftX="200" topLeftY="200" width="200" height="120">
<fill><fillColor color="rgba(230,230,230,1)"/></fill>
</shape>
<shape id="body" type="text" topLeftX="210" topLeftY="210" width="180" height="40">
<content fontSize="14"><p>短文本</p></content>
</shape>
</data>
</slide>
"""
)
codes = [issue["code"] for issue in result["slides"][0]["issues"]]
self.assertNotIn("text_overflows_container", codes)
def test_lint_xml_reports_free_text_shape_overlapping_table_grid(self) -> None:
# A free-floating text shape whose glyph box lands on top of a sibling table occludes the cell
# contents (slides p4). The table renders its own text; a stray shape over the grid is an
# accidental overlay, so it must surface as table_covers_text (Bucket B).
result = xml_text_overlap_lint.lint_xml(
"""
<presentation xmlns="http://www.larkoffice.com/sml/2.0" width="960" height="540">
<slide xmlns="http://www.larkoffice.com/sml/2.0">
<data>
<table id="grid" topLeftX="200" topLeftY="200" width="400" height="150">
<tr><td><content><p>A</p></content></td></tr>
</table>
<shape id="stray" type="text" topLeftX="260" topLeftY="240" width="120" height="30">
<content fontSize="16"><p>覆盖表格</p></content>
</shape>
</data>
</slide>
</presentation>
"""
)
occlusions = [
issue for issue in result["slides"][0]["errors"]
if issue["code"] == "table_covers_text" and set(issue["elements"]) == {"grid", "stray"}
]
self.assertEqual(len(occlusions), 1)
def test_lint_xml_ignores_table_with_only_cell_text(self) -> None:
# Cell text is part of the table's own layout and is never extracted as a standalone shape, so
# a table alone must not self-report table_covers_text (guards against a runaway detector).
result = xml_text_overlap_lint.lint_xml(
"""
<presentation xmlns="http://www.larkoffice.com/sml/2.0" width="960" height="540">
<slide xmlns="http://www.larkoffice.com/sml/2.0">
<data>
<table id="solo" topLeftX="200" topLeftY="200" width="400" height="150">
<tr><td><content><p>Score</p></content></td></tr>
</table>
</data>
</slide>
</presentation>
"""
)
codes = [issue["code"] for issue in result["slides"][0]["issues"]]
self.assertNotIn("table_covers_text", codes)
def test_lint_xml_reports_free_text_shape_overlapping_chart(self) -> None:
# A free-floating text shape whose glyph box lands on top of a sibling chart occludes the chart's
# generated labels and legend (slides p5: a headline dropped onto a pie chart's ring). The chart
# renders its own text; a stray shape over the plot area is an accidental overlay, so it must
# surface as chart_covers_text (Bucket B3).
result = xml_text_overlap_lint.lint_xml(
"""
<presentation xmlns="http://www.larkoffice.com/sml/2.0" width="960" height="540">
<slide xmlns="http://www.larkoffice.com/sml/2.0">
<data>
<chart id="pie" topLeftX="200" topLeftY="60" width="420" height="420">
<chartData><dim1><chartField name="p">A,B</chartField></dim1></chartData>
</chart>
<shape id="stray" type="text" topLeftX="360" topLeftY="120" width="120" height="40">
<content fontSize="32"><p>abc 99%</p></content>
</shape>
</data>
</slide>
</presentation>
"""
)
occlusions = [
issue for issue in result["slides"][0]["errors"]
if issue["code"] == "chart_covers_text" and set(issue["elements"]) == {"pie", "stray"}
]
self.assertEqual(len(occlusions), 1)
def test_lint_xml_ignores_chart_not_overlapping_text(self) -> None:
# A chart and a text shape that sit side by side without their glyph boxes touching must not
# report chart_covers_text (guards the detector from firing on mere co-existence).
result = xml_text_overlap_lint.lint_xml(
"""
<presentation xmlns="http://www.larkoffice.com/sml/2.0" width="960" height="540">
<slide xmlns="http://www.larkoffice.com/sml/2.0">
<data>
<chart id="pie" topLeftX="40" topLeftY="60" width="300" height="300">
<chartData><dim1><chartField name="p">A,B</chartField></dim1></chartData>
</chart>
<shape id="caption" type="text" topLeftX="600" topLeftY="80" width="200" height="40">
<content fontSize="16"><p>Sales breakdown</p></content>
</shape>
</data>
</slide>
</presentation>
"""
)
codes = [issue["code"] for issue in result["slides"][0]["issues"]]
self.assertNotIn("chart_covers_text", codes)
def test_lint_xml_reports_auto_fit_title_growing_onto_body_below(self) -> None:
# A shape-auto-fit title sized for one line wraps to two, growing downward past its authored box
# onto the body text beneath it (slides p9). shape-auto-fit only means the box grows to fit, so
# the grown glyph height -- not the authored height -- is what collides. The body is a tall
# multi-line block so the overlap covers <30% of it: the generic text-text check cannot catch
# this, only the dedicated auto-fit growth detector can (Bucket A3 / auto-fit growth).
result = xml_text_overlap_lint.lint_xml(
"""
<slide xmlns="http://www.larkoffice.com/sml/2.0">
<data>
<shape id="title" type="text" topLeftX="80" topLeftY="20" width="480" height="36">
<content fontSize="24" autoFit="shape-auto-fit"><p>02. | Literature Review - International Research</p></content>
</shape>
<shape id="body" type="text" topLeftX="80" topLeftY="60" width="480" height="200">
<content fontSize="15" verticalAlign="top"><p>1. Marxist Perspective</p><p>Line two of body copy</p><p>Line three of body copy</p><p>Line four of body copy</p><p>Line five of body copy</p><p>Line six of body copy</p></content>
</shape>
</data>
</slide>
"""
)
collisions = [
issue for issue in result["slides"][0]["errors"]
if issue["code"] == "bbox_overlap" and set(issue["elements"]) == {"title", "body"}
]
self.assertEqual(len(collisions), 1)
def test_lint_xml_ignores_auto_fit_title_with_space_below(self) -> None:
# An identical wrapping auto-fit title with an empty gap below it grows harmlessly; the check
# must stay silent so ordinary auto-fit growth is not flagged (guards the grown-region area gate).
result = xml_text_overlap_lint.lint_xml(
"""
<slide xmlns="http://www.larkoffice.com/sml/2.0">
<data>
<shape id="title" type="text" topLeftX="80" topLeftY="20" width="480" height="36">
<content fontSize="24" autoFit="shape-auto-fit"><p>02. | Literature Review - International Research</p></content>
</shape>
<shape id="body" type="text" topLeftX="80" topLeftY="300" width="480" height="200">
<content fontSize="15"><p>1. Marxist Perspective</p></content>
</shape>
</data>
</slide>
"""
)
collisions = [
issue for issue in result["slides"][0]["issues"]
if issue["code"] == "bbox_overlap" and set(issue["elements"]) == {"title", "body"}
]
self.assertEqual(collisions, [])
def test_lint_xml_does_not_treat_divider_rule_as_text_background_container(self) -> None:
# A thin horizontal rule under a title is a divider, not a container. Owning a title's grown
# glyph box to a 3px rule and reporting it as text_overflows_container is a false positive
# (slides p9); the line-like guard must keep the divider out of the container candidate set.
result = xml_text_overlap_lint.lint_xml(
"""
<slide xmlns="http://www.larkoffice.com/sml/2.0">
<data>
<shape id="rule" type="rect" topLeftX="40" topLeftY="60" width="880" height="3">
<fill><fillColor color="rgba(40,60,120,1)"/></fill>
</shape>
<shape id="title" type="text" topLeftX="80" topLeftY="20" width="480" height="36">
<content fontSize="24" autoFit="shape-auto-fit"><p>02. | Literature Review - International Research</p></content>
</shape>
</data>
</slide>
"""
)
container_hits = [
issue for issue in result["slides"][0]["issues"]
if issue["code"] == "text_overflows_container" and "rule" in issue["elements"]
]
self.assertEqual(container_hits, [])
def test_lint_xml_keeps_resolved_table_sizes_positive_when_target_is_too_small(self) -> None:
result = xml_text_overlap_lint.lint_xml(
"""
@@ -2902,79 +2459,6 @@ class XmlTextOverlapLintGeometryTest(unittest.TestCase):
self.assertEqual(result["summary"]["error_count"], 0)
self.assertEqual(result["summary"]["info_count"], 1)
def test_lint_xml_reports_image_text_overlap_even_when_image_precedes_text_in_xml_order(self) -> None:
result = xml_text_overlap_lint.lint_xml(
"""
<slide xmlns="http://www.larkoffice.com/sml/2.0"><data>
<img id="image" src="token" topLeftX="120" topLeftY="120" width="120" height="60"/>
<shape id="text" type="text" topLeftX="100" topLeftY="100" width="220" height="90">
<content fontSize="28" lineSpacing="fixed:34" wrap="false"><p>Quarterly Plan</p></content>
</shape>
</data></slide>
"""
)
issue = next(issue for issue in result["slides"][0]["issues"] if issue["code"] == "image_covers_text")
self.assertEqual(issue["elements"], ["image", "text"])
self.assertIn("no longer overlaps the text glyph area", issue["hint"])
self.assertEqual(result["summary"]["error_count"], 1)
def test_lint_xml_exempts_full_canvas_background_image_behind_text(self) -> None:
# A full-bleed image at the bottom of the z-order is the slide backdrop; text rendered on top of
# it is never occluded (slides p9: bBo fills the whole canvas under the content). It must not be
# reported as image_covers_text (Bucket B5 background-image false positive).
result = xml_text_overlap_lint.lint_xml(
"""
<presentation xmlns="http://www.larkoffice.com/sml/2.0" width="960" height="540">
<slide xmlns="http://www.larkoffice.com/sml/2.0"><data>
<img id="backdrop" src="token" topLeftX="0" topLeftY="0" width="960" height="540"/>
<shape id="text" type="text" topLeftX="100" topLeftY="100" width="400" height="60">
<content fontSize="28"><p>On the backdrop</p></content>
</shape>
</data></slide>
</presentation>
"""
)
codes = [
issue for issue in result["slides"][0]["issues"]
if issue["code"] == "image_covers_text" and "backdrop" in issue["elements"]
]
self.assertEqual(codes, [])
def test_lint_xml_reports_full_canvas_image_drawn_above_text(self) -> None:
# The exemption is z-order aware: a full-canvas image drawn *after* (above) the text really does
# cover it, so it must still be flagged. Guards the backdrop exemption from swallowing real
# occlusions where the image is on top.
result = xml_text_overlap_lint.lint_xml(
"""
<presentation xmlns="http://www.larkoffice.com/sml/2.0" width="960" height="540">
<slide xmlns="http://www.larkoffice.com/sml/2.0"><data>
<shape id="text" type="text" topLeftX="100" topLeftY="100" width="400" height="60">
<content fontSize="28"><p>Under the cover</p></content>
</shape>
<img id="cover" src="token" topLeftX="0" topLeftY="0" width="960" height="540"/>
</data></slide>
</presentation>
"""
)
codes = [
issue for issue in result["slides"][0]["issues"]
if issue["code"] == "image_covers_text" and set(issue["elements"]) == {"cover", "text"}
]
self.assertEqual(len(codes), 1)
def test_stacking_helpers_agree_on_paint_order(self) -> None:
lower = {"order": 1}
upper = {"order": 3}
same = {"order": 3}
# is_drawn_behind and is_drawn_in_front_of are strict and mutually exclusive inverses.
self.assertTrue(xml_text_overlap_lint.is_drawn_behind(lower, upper))
self.assertFalse(xml_text_overlap_lint.is_drawn_in_front_of(lower, upper))
self.assertTrue(xml_text_overlap_lint.is_drawn_in_front_of(upper, lower))
self.assertFalse(xml_text_overlap_lint.is_drawn_behind(upper, lower))
# Equal order is neither behind nor in front, so an equal-order sibling never occludes.
self.assertFalse(xml_text_overlap_lint.is_drawn_behind(same, upper))
self.assertFalse(xml_text_overlap_lint.is_drawn_in_front_of(same, upper))
class XmlTextOverlapLintDensityTest(unittest.TestCase):
def test_lint_xml_blocks_blank_slide(self) -> None: