mirror of
https://github.com/larksuite/cli.git
synced 2026-08-03 08:32:46 +08:00
Compare commits
4 Commits
refactor/o
...
fix/text_o
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
44be8c986f | ||
|
|
a849ac3449 | ||
|
|
47a46658c8 | ||
|
|
11f34b3e85 |
@@ -40,23 +40,15 @@ 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 {
|
||||
return nil, err
|
||||
}
|
||||
if jsonStr == "" {
|
||||
return nil, nil
|
||||
if err != nil || jsonStr == "" {
|
||||
return nil
|
||||
}
|
||||
var token StoredUAToken
|
||||
if err := json.Unmarshal([]byte(jsonStr), &token); err != nil {
|
||||
return nil, err
|
||||
return nil
|
||||
}
|
||||
return &token, nil
|
||||
return &token
|
||||
}
|
||||
|
||||
// SetStoredToken persists a UAT.
|
||||
@@ -74,54 +66,6 @@ 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()
|
||||
|
||||
@@ -4,18 +4,17 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptrace"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/gofrs/flock"
|
||||
@@ -82,7 +81,7 @@ func GetValidAccessToken(httpClient *http.Client, opts UATCallOptions) (string,
|
||||
}
|
||||
|
||||
if status == "needs_refresh" {
|
||||
refreshed, err := refreshWithLock(httpClient, opts)
|
||||
refreshed, err := refreshWithLock(httpClient, opts, stored)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
@@ -104,7 +103,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) (*StoredUAToken, error) {
|
||||
func refreshWithLock(httpClient *http.Client, opts UATCallOptions, stored *StoredUAToken) (*StoredUAToken, error) {
|
||||
key := fmt.Sprintf("%s:%s", opts.AppId, opts.UserOpenId)
|
||||
|
||||
// 1. Process-level lock (prevents multiple goroutines in the same process)
|
||||
@@ -126,9 +125,12 @@ func refreshWithLock(httpClient *http.Client, opts UATCallOptions) (*StoredUATok
|
||||
refreshLocks.Delete(key)
|
||||
}()
|
||||
|
||||
// 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")
|
||||
// 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")
|
||||
if err := vfs.MkdirAll(lockDir, 0700); err != nil {
|
||||
return nil, fmt.Errorf("failed to create lock directory: %w", err)
|
||||
}
|
||||
@@ -151,91 +153,21 @@ func refreshWithLock(httpClient *http.Client, opts UATCallOptions) (*StoredUATok
|
||||
}
|
||||
defer fileLock.Unlock()
|
||||
|
||||
// 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")
|
||||
// 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
|
||||
}
|
||||
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, 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
|
||||
return doRefreshToken(httpClient, opts, stored)
|
||||
}
|
||||
|
||||
// doRefreshToken performs the actual HTTP request to refresh the token.
|
||||
@@ -245,318 +177,141 @@ func doRefreshToken(httpClient *http.Client, opts UATCallOptions, stored *Stored
|
||||
errOut = os.Stderr
|
||||
}
|
||||
|
||||
if time.Now().UnixMilli() >= stored.RefreshExpiresAt {
|
||||
now := time.Now().UnixMilli()
|
||||
if now >= stored.RefreshExpiresAt {
|
||||
fmt.Fprintf(errOut, "[lark-cli] uat-client: refresh_token expired for %s, clearing\n", opts.UserOpenId)
|
||||
retained, removed, err := removeStoredTokenIfCurrent(stored)
|
||||
if err != nil {
|
||||
if err := RemoveStoredToken(opts.AppId, opts.UserOpenId); 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
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
endpoints := ResolveOAuthEndpoints(opts.Domain)
|
||||
|
||||
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)
|
||||
}
|
||||
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)
|
||||
|
||||
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)
|
||||
req, err := http.NewRequest("POST", endpoints.Token, strings.NewReader(form.Encode()))
|
||||
if err != nil {
|
||||
fmt.Fprintf(errOut, "[lark-cli] [WARN] uat-client: failed to remove token: %v\n", err)
|
||||
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)
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
|
||||
resp, err := httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
fmt.Fprintf(errOut,
|
||||
"[lark-cli] [WARN] uat-client: refresh failed for %s, token cleared: %v\n",
|
||||
opts.UserOpenId, result.err)
|
||||
return nil, result.err
|
||||
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
|
||||
}
|
||||
|
||||
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,
|
||||
})
|
||||
data, err := callEndpoint()
|
||||
if err != nil {
|
||||
return refreshResult{
|
||||
action: refreshStopAndPreserve,
|
||||
err: errs.NewInternalError(errs.SubtypeSDKError,
|
||||
"failed to encode token refresh request: %v", err).
|
||||
WithCause(err),
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
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")
|
||||
errStr := getStr(data, "error")
|
||||
|
||||
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"`
|
||||
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
|
||||
}
|
||||
_ = 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,
|
||||
},
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
// Retry succeeded, fall through to parse token below.
|
||||
} else {
|
||||
now := time.Now().UnixMilli()
|
||||
*parsed.RefreshTokenExpiresIn = (stored.RefreshExpiresAt - now) / 1000
|
||||
// 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
|
||||
}
|
||||
}
|
||||
|
||||
return refreshResult{action: refreshSaveResponse, response: parsed}
|
||||
}
|
||||
|
||||
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
|
||||
accessToken := getStr(data, "access_token")
|
||||
if accessToken == "" {
|
||||
return nil, fmt.Errorf("Token refresh returned no access_token")
|
||||
}
|
||||
}
|
||||
|
||||
func saveRefreshResponse(opts UATCallOptions, stored *StoredUAToken, response refreshResponse) (*StoredUAToken, error) {
|
||||
now := time.Now().UnixMilli()
|
||||
refreshToken := getStr(data, "refresh_token")
|
||||
if refreshToken == "" {
|
||||
refreshToken = stored.RefreshToken
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
updated := &StoredUAToken{
|
||||
UserOpenId: stored.UserOpenId,
|
||||
AppId: opts.AppId,
|
||||
AccessToken: response.AccessToken,
|
||||
RefreshToken: response.RefreshToken,
|
||||
ExpiresAt: now + *response.ExpiresIn*1000,
|
||||
RefreshExpiresAt: now + *response.RefreshTokenExpiresIn*1000,
|
||||
Scope: response.Scope,
|
||||
AccessToken: accessToken,
|
||||
RefreshToken: refreshToken,
|
||||
ExpiresAt: now + int64(expiresIn)*1000,
|
||||
RefreshExpiresAt: refreshExpiresAt,
|
||||
Scope: scope,
|
||||
GrantedAt: stored.GrantedAt,
|
||||
}
|
||||
current, saved, err := setStoredTokenIfCurrent(stored, updated)
|
||||
if err != nil {
|
||||
|
||||
if err := SetStoredToken(updated); 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
|
||||
}
|
||||
|
||||
@@ -38,13 +38,11 @@ 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
|
||||
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
|
||||
20026: {Category: errs.CategoryAuthentication, Subtype: errs.SubtypeRefreshTokenInvalid}, // refresh_token v1 legacy format
|
||||
20037: {Category: errs.CategoryAuthentication, Subtype: errs.SubtypeRefreshTokenExpired}, // refresh_token expired
|
||||
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
|
||||
20050: {Category: errs.CategoryAuthentication, Subtype: errs.SubtypeRefreshServerError, Retryable: true}, // refresh endpoint transient error
|
||||
|
||||
// CategoryAuthorization
|
||||
99991672: {Category: errs.CategoryAuthorization, Subtype: errs.SubtypeAppScopeNotApplied},
|
||||
@@ -53,13 +51,6 @@ 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},
|
||||
@@ -71,17 +62,10 @@ 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},
|
||||
|
||||
@@ -23,27 +23,11 @@ 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},
|
||||
{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},
|
||||
{20050, errs.CategoryAuthentication, errs.SubtypeRefreshServerError, true},
|
||||
}
|
||||
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
@@ -1004,9 +1004,17 @@ class XmlTextOverlapLintGeometryTest(unittest.TestCase):
|
||||
"""
|
||||
)
|
||||
overlap_pairs = {tuple(issue["elements"]) for issue in result["slides"][0]["issues"]}
|
||||
self.assertEqual(result["summary"]["error_count"], 2)
|
||||
# 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.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(
|
||||
@@ -1119,6 +1127,42 @@ 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(
|
||||
"""
|
||||
@@ -1203,6 +1247,28 @@ 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(
|
||||
"""
|
||||
@@ -1223,6 +1289,97 @@ 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 ("2020–2023")
|
||||
# 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>2020–2023</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(
|
||||
"""
|
||||
@@ -1716,10 +1873,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="40" topLeftY="80" width="180" height="80">
|
||||
<shape type="text" topLeftX="300" topLeftY="80" width="180" height="80">
|
||||
<content textType="title" fontSize="44"><p>Title</p></content>
|
||||
</shape>
|
||||
<shape type="text" topLeftX="40" topLeftY="120" width="180" height="40">
|
||||
<shape type="text" topLeftX="300" topLeftY="170" width="180" height="40">
|
||||
<content textType="sub-headline" fontSize="20"><p>Subtitle</p></content>
|
||||
</shape>
|
||||
</data>
|
||||
@@ -1893,6 +2050,32 @@ 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(
|
||||
"""
|
||||
@@ -2321,6 +2504,266 @@ 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(
|
||||
"""
|
||||
@@ -2459,6 +2902,79 @@ 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:
|
||||
|
||||
Reference in New Issue
Block a user