mirror of
https://github.com/larksuite/cli.git
synced 2026-09-14 18:42:53 +08:00
a6f3e635d4
* feat(docs): add local authoring and resource workflows Add docs +script workflows for isolated draft initialization and tolerant XML/Markdown profiling. Support local and remote document resources across create and update flows with safe, bounded-concurrency uploads, binding verification, and cleanup. Synchronize shared credential-source selection during concurrent uploads, refresh lark-doc guidance, and expand unit, dry-run, and live E2E coverage. * docs(lark-doc): clarify genre reference paths * fix(docs): address PR validation feedback * docs(lark-doc): clarify remote image handling * feat: streamline docs draft workflow * fix(docs): clarify script input and resource cleanup * fix(docs): align script dry-run test with auth flow * fix(docs): authenticate local script e2e test * fix(docs): refine script diagnostics and image preflight * fix(docs): align draft workspace cleanup with VFS * fix(docs): route workspace cleanup through FileIO * docs(lark-doc): simplify profile check guidance
2081 lines
86 KiB
Go
2081 lines
86 KiB
Go
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
|
// SPDX-License-Identifier: MIT
|
|
|
|
package doc
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"image"
|
|
"image/png"
|
|
"io"
|
|
"net/http"
|
|
"net/url"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"sync"
|
|
"sync/atomic"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/spf13/cobra"
|
|
|
|
"github.com/larksuite/cli/errs"
|
|
"github.com/larksuite/cli/internal/cmdutil"
|
|
"github.com/larksuite/cli/internal/core"
|
|
"github.com/larksuite/cli/internal/credential"
|
|
"github.com/larksuite/cli/internal/httpmock"
|
|
internaltransport "github.com/larksuite/cli/internal/transport"
|
|
"github.com/larksuite/cli/shortcuts/common"
|
|
)
|
|
|
|
func TestPrepareLocalDocResourcesXMLImageAndSource(t *testing.T) {
|
|
runtime := newLocalDocResourceTestRuntime(t, map[string]string{
|
|
"diagram.png": localDocResourcePNG(t, 100, 80),
|
|
"report.pdf": "pdf-data",
|
|
})
|
|
content := `<p>before</p><img path="@diagram.png" alt="diagram" width="50" height="40" align="right" scale="0.5"/><source path="@report.pdf" name="report.pdf"></source>`
|
|
|
|
got, resources, err := prepareLocalDocResources(runtime, "xml", content)
|
|
if err != nil {
|
|
t.Fatalf("prepareLocalDocResources() error: %v", err)
|
|
}
|
|
if len(resources) != 2 {
|
|
t.Fatalf("resources len = %d, want 2: %#v", len(resources), resources)
|
|
}
|
|
if resources[0].Kind != localDocResourceImage || resources[0].Path != "diagram.png" {
|
|
t.Fatalf("image resource = %#v", resources[0])
|
|
}
|
|
if resources[1].Kind != localDocResourceFile || resources[1].Path != "report.pdf" {
|
|
t.Fatalf("file resource = %#v", resources[1])
|
|
}
|
|
if strings.Contains(got, "@diagram.png") || strings.Contains(got, "@report.pdf") {
|
|
t.Fatalf("rewritten content leaks local path: %s", got)
|
|
}
|
|
for _, want := range []string{resources[0].Marker, resources[1].Marker, `width="100"`, `height="80"`, `align="right"`, `scale="0.500000"`} {
|
|
if !strings.Contains(got, want) {
|
|
t.Fatalf("rewritten content missing %q: %s", want, got)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestPrepareLocalDocResourcesRejectsUndecodableLocalImage(t *testing.T) {
|
|
runtime := newLocalDocResourceTestRuntime(t, map[string]string{"fake.png": "<html>not an image</html>"})
|
|
_, _, err := prepareLocalDocResources(runtime, "xml", `<img path="@fake.png"/>`)
|
|
problem, ok := errs.ProblemOf(err)
|
|
var validationErr *errs.ValidationError
|
|
if !ok || problem.Category != errs.CategoryValidation || problem.Subtype != errs.SubtypeInvalidArgument ||
|
|
!errors.As(err, &validationErr) || validationErr.Param != "path" {
|
|
t.Fatalf("error=%T %v problem=%#v validation=%#v", err, err, problem, validationErr)
|
|
}
|
|
}
|
|
|
|
func TestPrepareLocalDocResourcesXMLRemoteImage(t *testing.T) {
|
|
runtime := newLocalDocResourceTestRuntime(t, nil)
|
|
got, resources, err := prepareLocalDocResources(runtime, "xml", `<p>before</p><img href="http://93.184.216.34/photo.png" alt="remote" width="50"/>`)
|
|
if err != nil {
|
|
t.Fatalf("prepareLocalDocResources() error: %v", err)
|
|
}
|
|
if len(resources) != 1 {
|
|
t.Fatalf("resources = %d, want 1", len(resources))
|
|
}
|
|
resource := resources[0]
|
|
if resource.RemoteURL != "http://93.184.216.34/photo.png" {
|
|
t.Fatalf("RemoteURL = %q", resource.RemoteURL)
|
|
}
|
|
if resource.Path != "" || resource.Size != 0 {
|
|
t.Fatalf("remote resource materialized during preparation: %#v", resource)
|
|
}
|
|
if strings.Contains(got, `href=`) || !strings.Contains(got, `path="`+resource.Marker+`"`) || !strings.Contains(got, `caption="remote"`) {
|
|
t.Fatalf("prepared content = %q", got)
|
|
}
|
|
}
|
|
|
|
func TestPrepareLocalDocResourcesXMLRemoteImageAcceptsBareAmpersandsInHref(t *testing.T) {
|
|
runtime := newLocalDocResourceTestRuntime(t, nil)
|
|
content := `<img href="https://93.184.216.34/photo.png?x=1&image_size=large&token=a%26b" caption="remote"/>`
|
|
got, resources, err := prepareLocalDocResources(runtime, "xml", content)
|
|
if err != nil {
|
|
t.Fatalf("prepareLocalDocResources() error: %v", err)
|
|
}
|
|
if len(resources) != 1 {
|
|
t.Fatalf("resources = %d, want 1", len(resources))
|
|
}
|
|
if want := "https://93.184.216.34/photo.png?x=1&image_size=large&token=a%26b"; resources[0].RemoteURL != want {
|
|
t.Fatalf("RemoteURL = %q, want %q", resources[0].RemoteURL, want)
|
|
}
|
|
if !strings.Contains(got, `path="`+resources[0].Marker+`"`) || strings.Contains(got, `href=`) {
|
|
t.Fatalf("prepared content = %q", got)
|
|
}
|
|
}
|
|
|
|
func TestPrepareLocalDocResourcesXMLRemoteImagePreservesValidEntities(t *testing.T) {
|
|
runtime := newLocalDocResourceTestRuntime(t, nil)
|
|
got, resources, err := prepareLocalDocResources(
|
|
runtime,
|
|
"xml",
|
|
`<img href="https://93.184.216.34/photo.png?x=1&y=2&z=3" caption="A & B"/>`,
|
|
)
|
|
if err != nil {
|
|
t.Fatalf("prepareLocalDocResources() error: %v", err)
|
|
}
|
|
if len(resources) != 1 {
|
|
t.Fatalf("resources = %d, want 1", len(resources))
|
|
}
|
|
if want := "https://93.184.216.34/photo.png?x=1&y=2&z=3"; resources[0].RemoteURL != want {
|
|
t.Fatalf("RemoteURL = %q, want %q", resources[0].RemoteURL, want)
|
|
}
|
|
if !strings.Contains(got, `caption="A & B"`) {
|
|
t.Fatalf("prepared content = %q", got)
|
|
}
|
|
}
|
|
|
|
func TestPrepareLocalDocResourcesXMLRemoteImageDoesNotRelaxOtherAttributes(t *testing.T) {
|
|
runtime := newLocalDocResourceTestRuntime(t, nil)
|
|
_, _, err := prepareLocalDocResources(runtime, "xml", `<img href="https://93.184.216.34/photo.png?x=1&y=2" caption="A & B"/>`)
|
|
if err == nil {
|
|
t.Fatal("prepareLocalDocResources() error = nil")
|
|
}
|
|
if !strings.Contains(err.Error(), "invalid character entity") {
|
|
t.Fatalf("prepareLocalDocResources() error = %v", err)
|
|
}
|
|
}
|
|
|
|
func TestPrepareLocalDocResourcesRemoteImageRejectsConflicts(t *testing.T) {
|
|
runtime := newLocalDocResourceTestRuntime(t, nil)
|
|
for _, content := range []string{
|
|
`<img href="https://93.184.216.34/photo.png" src="token"/>`,
|
|
`<img href="file:///tmp/photo.png"/>`,
|
|
`<img href="https://user:secret@93.184.216.34/photo.png"/>`,
|
|
} {
|
|
if _, _, err := prepareLocalDocResources(runtime, "xml", content); err == nil {
|
|
t.Fatalf("prepareLocalDocResources(%q) error = nil", content)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestUploadRemoteDocImagesRecordsIndividualDownloadFailure(t *testing.T) {
|
|
dir := t.TempDir()
|
|
cmdutil.TestChdir(t, dir)
|
|
factory, _, _, reg := cmdutil.TestFactory(t, docsTestConfigWithAppID("remote-image-partial-download"))
|
|
runtime := common.TestNewRuntimeContextForAPI(context.Background(), &cobra.Command{Use: "test"}, docsTestConfigWithAppID("remote-image-partial-download"), factory, core.AsUser)
|
|
_, resources, err := prepareLocalDocResources(
|
|
runtime,
|
|
"xml",
|
|
`<img href="https://93.184.216.34/one.png"/><img href="https://93.184.216.34/two.png"/>`,
|
|
)
|
|
if err != nil {
|
|
t.Fatalf("prepareLocalDocResources: %v", err)
|
|
}
|
|
|
|
originalDownload := downloadRemoteDocImage
|
|
t.Cleanup(func() { downloadRemoteDocImage = originalDownload })
|
|
var downloadMu sync.Mutex
|
|
downloadCalls := make(map[int]int)
|
|
downloadRemoteDocImage = func(_ *common.RuntimeContext, _ string, occurrence int) (remoteDocImageDownload, error) {
|
|
downloadMu.Lock()
|
|
downloadCalls[occurrence]++
|
|
downloadMu.Unlock()
|
|
if occurrence == 2 {
|
|
return remoteDocImageDownload{}, errors.New("second download failed")
|
|
}
|
|
payload := []byte(localDocResourcePNG(t, 20, 10))
|
|
return remoteDocImageDownload{Content: payload, FileName: "image.png", Width: 20, Height: 10}, nil
|
|
}
|
|
reg.Register(&httpmock.Stub{
|
|
Method: "POST",
|
|
URL: "/open-apis/drive/v1/medias/upload_all",
|
|
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"file_token": "file_one"}},
|
|
})
|
|
outcomes := []*localDocResourceOutcome{
|
|
{Resource: resources[0], BlockID: "blk_one", Status: "pending"},
|
|
{Resource: resources[1], BlockID: "blk_two", Status: "pending"},
|
|
}
|
|
uploadLocalDocResources(runtime, "doxcn_partial_download", outcomes)
|
|
if outcomes[0].Status != "uploaded" || outcomes[0].FileToken != "file_one" {
|
|
t.Fatalf("first outcome = %#v", outcomes[0])
|
|
}
|
|
if outcomes[1].Status != "upload_failed" || outcomes[1].Err == nil || !strings.Contains(outcomes[1].Err.Error(), "second download failed") {
|
|
t.Fatalf("second outcome = %#v", outcomes[1])
|
|
}
|
|
downloadMu.Lock()
|
|
secondDownloadCalls := downloadCalls[2]
|
|
downloadMu.Unlock()
|
|
if secondDownloadCalls != 1 {
|
|
t.Fatalf("non-retryable second download calls = %d, want 1", secondDownloadCalls)
|
|
}
|
|
}
|
|
|
|
func TestUploadRemoteDocImageRetriesRetryableDownload(t *testing.T) {
|
|
factory, _, _, reg := cmdutil.TestFactory(t, docsTestConfigWithAppID("remote-image-download-retry"))
|
|
runtime := common.TestNewRuntimeContextForAPI(context.Background(), &cobra.Command{Use: "test"}, docsTestConfigWithAppID("remote-image-download-retry"), factory, core.AsUser)
|
|
payload := []byte(localDocResourcePNG(t, 20, 10))
|
|
originalDownload := downloadRemoteDocImage
|
|
downloadCalls := 0
|
|
downloadRemoteDocImage = func(_ *common.RuntimeContext, _ string, _ int) (remoteDocImageDownload, error) {
|
|
downloadCalls++
|
|
if downloadCalls < remoteDocImageDownloadAttempts {
|
|
return remoteDocImageDownload{}, errs.NewNetworkError(errs.SubtypeNetworkTransport, "temporary download failure").WithRetryable()
|
|
}
|
|
return remoteDocImageDownload{Content: payload, FileName: "image.png", Width: 20, Height: 10}, nil
|
|
}
|
|
t.Cleanup(func() { downloadRemoteDocImage = originalDownload })
|
|
|
|
reg.Register(&httpmock.Stub{
|
|
Method: "POST",
|
|
URL: "/open-apis/drive/v1/medias/upload_all",
|
|
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"file_token": "file_after_download_retry"}},
|
|
})
|
|
var waits []time.Duration
|
|
originalWait := waitLocalDocResourceRequest
|
|
waitLocalDocResourceRequest = func(_ context.Context, delay time.Duration) error {
|
|
waits = append(waits, delay)
|
|
return nil
|
|
}
|
|
t.Cleanup(func() { waitLocalDocResourceRequest = originalWait })
|
|
|
|
outcome := &localDocResourceOutcome{
|
|
Resource: localDocResource{Occurrence: 1, Kind: localDocResourceImage, RemoteURL: "https://93.184.216.34/retry.png"},
|
|
BlockID: "blk_download_retry",
|
|
Status: "pending",
|
|
}
|
|
uploadLocalDocResources(runtime, "doxcn_download_retry", []*localDocResourceOutcome{outcome})
|
|
if outcome.Status != "uploaded" || outcome.FileToken != "file_after_download_retry" {
|
|
t.Fatalf("outcome = %#v", outcome)
|
|
}
|
|
if downloadCalls != remoteDocImageDownloadAttempts {
|
|
t.Fatalf("download calls = %d, want %d", downloadCalls, remoteDocImageDownloadAttempts)
|
|
}
|
|
assertLocalDocResourceRetryWaits(t, waits, 2)
|
|
}
|
|
|
|
func TestUploadRemoteDocImageRetriesRetryableUploadWithoutRedownloading(t *testing.T) {
|
|
factory, _, _, reg := cmdutil.TestFactory(t, docsTestConfigWithAppID("remote-image-upload-retry"))
|
|
runtime := common.TestNewRuntimeContextForAPI(context.Background(), &cobra.Command{Use: "test"}, docsTestConfigWithAppID("remote-image-upload-retry"), factory, core.AsUser)
|
|
payload := []byte(localDocResourcePNG(t, 20, 10))
|
|
originalDownload := downloadRemoteDocImage
|
|
downloadCalls := 0
|
|
downloadRemoteDocImage = func(_ *common.RuntimeContext, _ string, _ int) (remoteDocImageDownload, error) {
|
|
downloadCalls++
|
|
return remoteDocImageDownload{Content: payload, FileName: "image.png", Width: 20, Height: 10}, nil
|
|
}
|
|
t.Cleanup(func() { downloadRemoteDocImage = originalDownload })
|
|
|
|
reg.Register(&httpmock.Stub{
|
|
Method: "POST",
|
|
URL: "/open-apis/drive/v1/medias/upload_all",
|
|
Status: http.StatusServiceUnavailable,
|
|
Body: map[string]interface{}{"code": 0, "msg": "temporary upstream failure"},
|
|
})
|
|
reg.Register(&httpmock.Stub{
|
|
Method: "POST",
|
|
URL: "/open-apis/drive/v1/medias/upload_all",
|
|
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"file_token": "file_after_upload_retry"}},
|
|
})
|
|
var waits []time.Duration
|
|
originalWait := waitLocalDocResourceRequest
|
|
waitLocalDocResourceRequest = func(_ context.Context, delay time.Duration) error {
|
|
waits = append(waits, delay)
|
|
return nil
|
|
}
|
|
t.Cleanup(func() { waitLocalDocResourceRequest = originalWait })
|
|
|
|
outcome := &localDocResourceOutcome{
|
|
Resource: localDocResource{Occurrence: 1, Kind: localDocResourceImage, RemoteURL: "https://93.184.216.34/retry.png"},
|
|
BlockID: "blk_upload_retry",
|
|
Status: "pending",
|
|
}
|
|
uploadLocalDocResources(runtime, "doxcn_upload_retry", []*localDocResourceOutcome{outcome})
|
|
if outcome.Status != "uploaded" || outcome.FileToken != "file_after_upload_retry" {
|
|
t.Fatalf("outcome = %#v", outcome)
|
|
}
|
|
if downloadCalls != 1 {
|
|
t.Fatalf("download calls = %d, want 1 when only upload is retried", downloadCalls)
|
|
}
|
|
assertLocalDocResourceRetryWaits(t, waits, 1)
|
|
}
|
|
|
|
func TestApplyRemoteDocImageDownloadNormalizesPresentationWithoutRetainingContent(t *testing.T) {
|
|
runtime := newLocalDocResourceTestRuntime(t, nil)
|
|
content, resources, err := prepareLocalDocResources(runtime, "xml", `<img href="https://93.184.216.34/photo.png" width="50" align="right"/>`)
|
|
if err != nil {
|
|
t.Fatalf("prepareLocalDocResources: %v", err)
|
|
}
|
|
payload := []byte(localDocResourcePNG(t, 200, 100))
|
|
resource := resources[0]
|
|
if err := applyRemoteDocImageDownload(&resource, remoteDocImageDownload{Content: payload, FileName: "image.png", Width: 200, Height: 100}); err != nil {
|
|
t.Fatalf("applyRemoteDocImageDownload: %v", err)
|
|
}
|
|
if resource.ImageWidth != 200 || resource.ImageHeight != 100 || !resource.HasScale || resource.ImageScale != 0.25 {
|
|
t.Fatalf("downloaded resource = %#v", resource)
|
|
}
|
|
if resource.FileName != "image.png" || resource.Size != int64(len(payload)) || len(resource.Content) != 0 {
|
|
t.Fatalf("downloaded resource metadata = %#v", resource)
|
|
}
|
|
if !strings.Contains(content, resource.Marker) || strings.Contains(content, `width=`) || strings.Contains(content, `align=`) {
|
|
t.Fatalf("placeholder content = %q, want presentation deferred until upload", content)
|
|
}
|
|
}
|
|
|
|
func TestDownloadRemoteDocImageContentBuffersSupportedImage(t *testing.T) {
|
|
runtime := newLocalDocResourceTestRuntime(t, nil)
|
|
payload := []byte(localDocResourcePNG(t, 30, 20))
|
|
|
|
originalDo := doRemoteDocImageRequest
|
|
t.Cleanup(func() { doRemoteDocImageRequest = originalDo })
|
|
doRemoteDocImageRequest = func(_ remoteDocImageHTTPDoer, req *http.Request) (*http.Response, error) {
|
|
return &http.Response{
|
|
StatusCode: http.StatusOK,
|
|
Header: http.Header{"Content-Type": []string{"image/png"}},
|
|
Body: io.NopCloser(bytes.NewReader(payload)),
|
|
ContentLength: int64(len(payload)),
|
|
Request: req,
|
|
}, nil
|
|
}
|
|
|
|
download, err := downloadRemoteDocImageContent(runtime, "https://93.184.216.34/photo.png", 1)
|
|
if err != nil {
|
|
t.Fatalf("downloadRemoteDocImageContent: %v", err)
|
|
}
|
|
if download.FileName != "image.png" || download.Width != 30 || download.Height != 20 || !bytes.Equal(download.Content, payload) {
|
|
t.Fatalf("download result = %#v", download)
|
|
}
|
|
}
|
|
|
|
type remoteDocImageProbeBody struct {
|
|
reads int
|
|
closed bool
|
|
}
|
|
|
|
func (b *remoteDocImageProbeBody) Read([]byte) (int, error) {
|
|
b.reads++
|
|
return 0, io.EOF
|
|
}
|
|
|
|
func (b *remoteDocImageProbeBody) Close() error {
|
|
b.closed = true
|
|
return nil
|
|
}
|
|
|
|
func TestProbeRemoteDocImageDownloadDoesNotReadImageBody(t *testing.T) {
|
|
runtime := newLocalDocResourceTestRuntime(t, nil)
|
|
originalDo := doRemoteDocImageRequest
|
|
t.Cleanup(func() { doRemoteDocImageRequest = originalDo })
|
|
body := &remoteDocImageProbeBody{}
|
|
method := ""
|
|
requestRange := ""
|
|
doRemoteDocImageRequest = func(_ remoteDocImageHTTPDoer, req *http.Request) (*http.Response, error) {
|
|
method = req.Method
|
|
requestRange = req.Header.Get("Range")
|
|
return &http.Response{
|
|
StatusCode: http.StatusOK,
|
|
ContentLength: 1024,
|
|
Header: http.Header{"Content-Type": []string{"image/png"}},
|
|
Body: body,
|
|
}, nil
|
|
}
|
|
|
|
if err := probeRemoteDocImageDownload(runtime, "https://93.184.216.34/image.png", 1); err != nil {
|
|
t.Fatalf("probeRemoteDocImageDownload: %v", err)
|
|
}
|
|
if method != http.MethodGet || requestRange != "bytes=0-0" || body.reads != 0 || !body.closed {
|
|
t.Fatalf("probe method=%q range=%q reads=%d closed=%v, want ranged GET with closed unread body", method, requestRange, body.reads, body.closed)
|
|
}
|
|
}
|
|
|
|
func TestDownloadRemoteDocImageContentRejectsNonImage(t *testing.T) {
|
|
runtime := newLocalDocResourceTestRuntime(t, nil)
|
|
|
|
originalDo := doRemoteDocImageRequest
|
|
t.Cleanup(func() { doRemoteDocImageRequest = originalDo })
|
|
doRemoteDocImageRequest = func(_ remoteDocImageHTTPDoer, req *http.Request) (*http.Response, error) {
|
|
return &http.Response{
|
|
StatusCode: http.StatusOK,
|
|
Header: http.Header{"Content-Type": []string{"text/plain"}},
|
|
Body: io.NopCloser(strings.NewReader("not an image")),
|
|
Request: req,
|
|
}, nil
|
|
}
|
|
|
|
_, err := downloadRemoteDocImageContent(runtime, "http://93.184.216.34/not-image", 1)
|
|
if err == nil {
|
|
t.Fatal("downloadRemoteDocImageContent() error = nil")
|
|
}
|
|
problem, ok := errs.ProblemOf(err)
|
|
var validationErr *errs.ValidationError
|
|
if !ok || problem.Category != errs.CategoryValidation || problem.Subtype != errs.SubtypeInvalidArgument ||
|
|
!errors.As(err, &validationErr) || validationErr.Param != "href" {
|
|
t.Fatalf("problem = %#v, validation = %#v, %v", problem, validationErr, ok)
|
|
}
|
|
}
|
|
|
|
func TestDownloadRemoteDocImageContentRejectsDeclaredOversize(t *testing.T) {
|
|
runtime := newLocalDocResourceTestRuntime(t, nil)
|
|
|
|
originalDo := doRemoteDocImageRequest
|
|
t.Cleanup(func() { doRemoteDocImageRequest = originalDo })
|
|
doRemoteDocImageRequest = func(_ remoteDocImageHTTPDoer, req *http.Request) (*http.Response, error) {
|
|
return &http.Response{
|
|
StatusCode: http.StatusOK,
|
|
Header: http.Header{"Content-Type": []string{"image/png"}},
|
|
Body: io.NopCloser(strings.NewReader("")),
|
|
ContentLength: remoteDocImageMaxBytes + 1,
|
|
Request: req,
|
|
}, nil
|
|
}
|
|
|
|
_, err := downloadRemoteDocImageContent(runtime, "https://93.184.216.34/oversize.png", 1)
|
|
if err == nil || !strings.Contains(err.Error(), "20MiB") {
|
|
t.Fatalf("downloadRemoteDocImageContent() error = %v", err)
|
|
}
|
|
problem, ok := errs.ProblemOf(err)
|
|
var validationErr *errs.ValidationError
|
|
if !ok || problem.Category != errs.CategoryValidation || problem.Subtype != errs.SubtypeInvalidArgument ||
|
|
!errors.As(err, &validationErr) || validationErr.Param != "href" {
|
|
t.Fatalf("problem = %#v, validation = %#v, ok = %v", problem, validationErr, ok)
|
|
}
|
|
}
|
|
|
|
func TestDownloadRemoteDocImageHTTPStatusRetryMetadata(t *testing.T) {
|
|
tests := []struct {
|
|
status int
|
|
wantSubtype errs.Subtype
|
|
wantRetryable bool
|
|
}{
|
|
{status: http.StatusTooManyRequests, wantSubtype: errs.SubtypeRateLimit, wantRetryable: true},
|
|
{status: http.StatusServiceUnavailable, wantSubtype: errs.SubtypeNetworkServer, wantRetryable: true},
|
|
{status: http.StatusNotFound, wantSubtype: errs.SubtypeNetworkTransport},
|
|
}
|
|
for _, test := range tests {
|
|
t.Run(http.StatusText(test.status), func(t *testing.T) {
|
|
runtime := newLocalDocResourceTestRuntime(t, nil)
|
|
|
|
originalDo := doRemoteDocImageRequest
|
|
t.Cleanup(func() { doRemoteDocImageRequest = originalDo })
|
|
doRemoteDocImageRequest = func(_ remoteDocImageHTTPDoer, req *http.Request) (*http.Response, error) {
|
|
return &http.Response{
|
|
StatusCode: test.status,
|
|
Header: make(http.Header),
|
|
Body: io.NopCloser(strings.NewReader("temporary")),
|
|
Request: req,
|
|
}, nil
|
|
}
|
|
|
|
_, err := downloadRemoteDocImageContent(runtime, "https://93.184.216.34/image.png", 1)
|
|
problem, ok := errs.ProblemOf(err)
|
|
if !ok || problem.Category != errs.CategoryNetwork || problem.Subtype != test.wantSubtype ||
|
|
problem.Code != test.status || problem.Retryable != test.wantRetryable {
|
|
t.Fatalf("error=%T %v problem=%#v", err, err, problem)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
type cannedRemoteImageTransport struct {
|
|
base http.RoundTripper
|
|
payload []byte
|
|
}
|
|
|
|
func (t *cannedRemoteImageTransport) BaseRoundTripper() http.RoundTripper {
|
|
return t.base
|
|
}
|
|
|
|
func (t *cannedRemoteImageTransport) WithBaseRoundTripper(base http.RoundTripper) http.RoundTripper {
|
|
return &cannedRemoteImageTransport{base: base, payload: t.payload}
|
|
}
|
|
|
|
func (t *cannedRemoteImageTransport) RoundTrip(req *http.Request) (*http.Response, error) {
|
|
return &http.Response{
|
|
StatusCode: http.StatusOK,
|
|
Header: http.Header{"Content-Type": []string{"image/png"}},
|
|
Body: io.NopCloser(bytes.NewReader(t.payload)),
|
|
ContentLength: int64(len(t.payload)),
|
|
Request: req,
|
|
}, nil
|
|
}
|
|
|
|
func TestDownloadRemoteDocImageContentUsesExternalPolicyBranch(t *testing.T) {
|
|
runtime := newLocalDocResourceTestRuntime(t, nil)
|
|
runtime.Factory.HttpClient = func() (*http.Client, error) {
|
|
external := &cannedRemoteImageTransport{base: http.DefaultTransport, payload: []byte(localDocResourcePNG(t, 2, 1))}
|
|
return &http.Client{Transport: internaltransport.NewHTTPPolicyRouter(http.DefaultTransport, external)}, nil
|
|
}
|
|
download, err := downloadRemoteDocImageContent(runtime, "https://93.184.216.34/image.png", 1)
|
|
if err != nil {
|
|
t.Fatalf("downloadRemoteDocImageContent() error = %v", err)
|
|
}
|
|
if download.FileName != "image.png" || len(download.Content) <= 3 {
|
|
t.Fatalf("download result = %#v", download)
|
|
}
|
|
}
|
|
|
|
func TestDownloadRemoteDocImageContentRejectsInvalidImageBody(t *testing.T) {
|
|
runtime := newLocalDocResourceTestRuntime(t, nil)
|
|
|
|
originalDo := doRemoteDocImageRequest
|
|
t.Cleanup(func() { doRemoteDocImageRequest = originalDo })
|
|
doRemoteDocImageRequest = func(_ remoteDocImageHTTPDoer, req *http.Request) (*http.Response, error) {
|
|
return &http.Response{
|
|
StatusCode: http.StatusOK,
|
|
Header: http.Header{"Content-Type": []string{"image/png"}},
|
|
Body: io.NopCloser(strings.NewReader("<html>not an image</html>")),
|
|
ContentLength: 25,
|
|
Request: req,
|
|
}, nil
|
|
}
|
|
|
|
_, err := downloadRemoteDocImageContent(runtime, "https://93.184.216.34/not-really.png", 1)
|
|
problem, ok := errs.ProblemOf(err)
|
|
var validationErr *errs.ValidationError
|
|
if !ok || problem.Category != errs.CategoryValidation || problem.Subtype != errs.SubtypeInvalidArgument ||
|
|
!errors.As(err, &validationErr) || validationErr.Param != "href" {
|
|
t.Fatalf("error = %T %v, problem=%#v validation=%#v", err, err, problem, validationErr)
|
|
}
|
|
}
|
|
|
|
func TestDownloadRemoteDocImageContentSupportsWebPDimensions(t *testing.T) {
|
|
const encodedWebP = "UklGRrIBAABXRUJQVlA4TKUBAAAvSsAYAA8w//M///MfeJAkbXvaSG7m8Q3GfYSBJekwQztm/IcZlgwnmWImn2BK7aFmBtnVir6q//8VOkFE/xm4baTIu8c48ArEo6+B3zFKYln3pqClSCKX0begFTAXFOLXHSyF8cCNcZEG4OywuA4KVVfJCiArU7GAgJI8+lJP/OKMT/fBAjevg1cYB7YVkFuWga2lyPi5I0HFy5YTpWIHg0RZpkniRVW9odHAKOwosWuOGdxIyn2OvaCDvhg/we6TwadPBPbqBV58MsLmMJ8yZnOWk8SRz4N+QoyPL+MnamzMvcE1rHNEr91F9GKZPVUcS9w7PhhH36suB9qPeYb/oLk6cuTiJ0wOK3m5h1cKjW6EVZCYMK7dxcKCBdgP9HkKr9gkAO2P8GKZGWVdIAatQa+1IDpt6qyorVwdy01xdW8Jkfk6xjEXmVQQ+HQdFr6OKhIN34dXWq0+0qr6EJSCeeVLH9+gvGTLyqM65PQ44ihzlTXxQKjKbAvshXgir7Lil9w4L2bvMycmjQcqXaMCO6BlY28i+FOLzbfI1vEqxAhotocAAA=="
|
|
payload, err := base64.StdEncoding.DecodeString(encodedWebP)
|
|
if err != nil {
|
|
t.Fatalf("decode WebP fixture: %v", err)
|
|
}
|
|
runtime := newLocalDocResourceTestRuntime(t, nil)
|
|
|
|
originalDo := doRemoteDocImageRequest
|
|
t.Cleanup(func() { doRemoteDocImageRequest = originalDo })
|
|
doRemoteDocImageRequest = func(_ remoteDocImageHTTPDoer, req *http.Request) (*http.Response, error) {
|
|
return &http.Response{
|
|
StatusCode: http.StatusOK,
|
|
Header: http.Header{"Content-Type": []string{"image/webp"}},
|
|
Body: io.NopCloser(bytes.NewReader(payload)),
|
|
ContentLength: int64(len(payload)),
|
|
Request: req,
|
|
}, nil
|
|
}
|
|
|
|
download, err := downloadRemoteDocImageContent(runtime, "https://93.184.216.34/image.webp", 1)
|
|
if err != nil {
|
|
t.Fatalf("downloadRemoteDocImageContent() error = %v", err)
|
|
}
|
|
if download.Width <= 0 || download.Height <= 0 || download.FileName != "image.webp" || !bytes.Equal(download.Content, payload) {
|
|
t.Fatalf("WebP download = %#v", download)
|
|
}
|
|
}
|
|
|
|
func TestDownloadRemoteDocImageContentSupportsBMPAndTIFFDimensions(t *testing.T) {
|
|
runtime := newLocalDocResourceTestRuntime(t, nil)
|
|
|
|
originalDo := doRemoteDocImageRequest
|
|
t.Cleanup(func() { doRemoteDocImageRequest = originalDo })
|
|
|
|
tests := []struct {
|
|
name string
|
|
contentType string
|
|
payload []byte
|
|
width int
|
|
height int
|
|
ext string
|
|
}{
|
|
{name: "BMP", contentType: "image/bmp", payload: testBMPImage(2, 3), width: 2, height: 3, ext: ".bmp"},
|
|
{name: "TIFF", contentType: "image/tiff", payload: testTIFFImage(4, 5), width: 4, height: 5, ext: ".tiff"},
|
|
}
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
doRemoteDocImageRequest = func(_ remoteDocImageHTTPDoer, req *http.Request) (*http.Response, error) {
|
|
return &http.Response{
|
|
StatusCode: http.StatusOK,
|
|
Header: http.Header{"Content-Type": []string{tt.contentType}},
|
|
Body: io.NopCloser(bytes.NewReader(tt.payload)),
|
|
ContentLength: int64(len(tt.payload)),
|
|
Request: req,
|
|
}, nil
|
|
}
|
|
|
|
download, err := downloadRemoteDocImageContent(runtime, "https://93.184.216.34/image"+tt.ext, 1)
|
|
if err != nil {
|
|
t.Fatalf("downloadRemoteDocImageContent() error = %v", err)
|
|
}
|
|
if download.Width != tt.width || download.Height != tt.height || download.FileName != "image"+tt.ext || !bytes.Equal(download.Content, tt.payload) {
|
|
t.Fatalf("download = %#v", download)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
type remoteImageErrorReader struct{ err error }
|
|
|
|
func (r remoteImageErrorReader) Read([]byte) (int, error) { return 0, r.err }
|
|
|
|
func TestDownloadRemoteDocImageContentClassifiesResponseReadFailureAsNetwork(t *testing.T) {
|
|
readErr := errors.New("connection reset while streaming")
|
|
runtime := newLocalDocResourceTestRuntime(t, nil)
|
|
|
|
originalDo := doRemoteDocImageRequest
|
|
t.Cleanup(func() { doRemoteDocImageRequest = originalDo })
|
|
doRemoteDocImageRequest = func(_ remoteDocImageHTTPDoer, req *http.Request) (*http.Response, error) {
|
|
return &http.Response{
|
|
StatusCode: http.StatusOK,
|
|
Header: http.Header{"Content-Type": []string{"image/png"}},
|
|
Body: io.NopCloser(remoteImageErrorReader{err: readErr}),
|
|
ContentLength: -1,
|
|
Request: req,
|
|
}, nil
|
|
}
|
|
|
|
_, err := downloadRemoteDocImageContent(runtime, "https://93.184.216.34/image.png", 1)
|
|
problem, ok := errs.ProblemOf(err)
|
|
if !ok || problem.Category != errs.CategoryNetwork || problem.Subtype != errs.SubtypeNetworkTransport || !problem.Retryable || !errors.Is(err, readErr) {
|
|
t.Fatalf("error = %T %v, problem=%#v", err, err, problem)
|
|
}
|
|
var validationErr *errs.ValidationError
|
|
if errors.As(err, &validationErr) {
|
|
t.Fatalf("response read failure was classified as validation: %#v", validationErr)
|
|
}
|
|
}
|
|
|
|
func TestRemoteDocImageNetworkErrorDoesNotRetryCanceledContext(t *testing.T) {
|
|
err := remoteDocImageNetworkError(context.Canceled, 1, "was interrupted")
|
|
problem, ok := errs.ProblemOf(err)
|
|
if !ok || problem.Category != errs.CategoryNetwork || problem.Retryable || !errors.Is(err, context.Canceled) {
|
|
t.Fatalf("error = %T %v, problem=%#v", err, err, problem)
|
|
}
|
|
}
|
|
|
|
func TestRemoteImageErrorsAndDryRunRedactURLCredentials(t *testing.T) {
|
|
const rawURL = "https://alice-sensitive:password@93.184.216.34/image.png?token=secret-value#frag-sensitive"
|
|
requestErr := &url.Error{Op: "Get", URL: rawURL, Err: errors.New("dial failed")}
|
|
got := remoteDocImageNetworkError(requestErr, 1, "request failed")
|
|
for _, secret := range []string{"alice-sensitive", "password", "secret-value", "frag-sensitive"} {
|
|
if strings.Contains(got.Error(), secret) {
|
|
t.Errorf("network error leaks %q: %v", secret, got)
|
|
}
|
|
}
|
|
|
|
dry := appendRemoteDocImageDownloadsDryRun(common.NewDryRunAPI(), []localDocResource{{Occurrence: 1, RemoteURL: rawURL}})
|
|
raw, err := json.Marshal(dry)
|
|
if err != nil {
|
|
t.Fatalf("marshal dry run: %v", err)
|
|
}
|
|
for _, secret := range []string{"alice-sensitive", "password", "secret-value", "frag-sensitive"} {
|
|
if strings.Contains(string(raw), secret) {
|
|
t.Errorf("dry run leaks %q: %s", secret, raw)
|
|
}
|
|
}
|
|
if !strings.Contains(string(raw), `https://93.184.216.34/image.png`) {
|
|
t.Fatalf("dry run lost redacted endpoint identity: %s", raw)
|
|
}
|
|
}
|
|
|
|
func TestPrepareLocalDocResourcesNormalizesImageDimensionsAndScale(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
nativeWidth int
|
|
nativeHeight int
|
|
attrs string
|
|
wantWidth int
|
|
wantHeight int
|
|
wantScale float64
|
|
wantHasScale bool
|
|
wantScaleText string
|
|
}{
|
|
{
|
|
name: "intrinsic dimensions without model display size",
|
|
nativeWidth: 100,
|
|
nativeHeight: 80,
|
|
wantWidth: 100,
|
|
wantHeight: 80,
|
|
},
|
|
{
|
|
name: "model width becomes scale",
|
|
nativeWidth: 100,
|
|
nativeHeight: 80,
|
|
attrs: ` width="50"`,
|
|
wantWidth: 100,
|
|
wantHeight: 80,
|
|
wantScale: 0.5,
|
|
wantHasScale: true,
|
|
wantScaleText: `scale="0.500000"`,
|
|
},
|
|
{
|
|
name: "model height becomes scale",
|
|
nativeWidth: 100,
|
|
nativeHeight: 80,
|
|
attrs: ` height="20"`,
|
|
wantWidth: 100,
|
|
wantHeight: 80,
|
|
wantScale: 0.25,
|
|
wantHasScale: true,
|
|
wantScaleText: `scale="0.250000"`,
|
|
},
|
|
{
|
|
name: "model width wins when both dimensions exist",
|
|
nativeWidth: 100,
|
|
nativeHeight: 80,
|
|
attrs: ` width="25" height="70"`,
|
|
wantWidth: 100,
|
|
wantHeight: 80,
|
|
wantScale: 0.25,
|
|
wantHasScale: true,
|
|
wantScaleText: `scale="0.250000"`,
|
|
},
|
|
{
|
|
name: "explicit scale wins over model dimensions",
|
|
nativeWidth: 100,
|
|
nativeHeight: 80,
|
|
attrs: ` width="50" scale="0.75"`,
|
|
wantWidth: 100,
|
|
wantHeight: 80,
|
|
wantScale: 0.75,
|
|
wantHasScale: true,
|
|
wantScaleText: `scale="0.750000"`,
|
|
},
|
|
{
|
|
name: "percentage width becomes scale",
|
|
nativeWidth: 100,
|
|
nativeHeight: 80,
|
|
attrs: ` width="80%"`,
|
|
wantWidth: 100,
|
|
wantHeight: 80,
|
|
wantScale: 0.8,
|
|
wantHasScale: true,
|
|
wantScaleText: `scale="0.800000"`,
|
|
},
|
|
{
|
|
name: "invalid model dimensions are ignored",
|
|
nativeWidth: 100,
|
|
nativeHeight: 80,
|
|
attrs: ` width="invalid" height="0" scale="-1"`,
|
|
wantWidth: 100,
|
|
wantHeight: 80,
|
|
},
|
|
{
|
|
name: "wide image is capped below page width",
|
|
nativeWidth: 1200,
|
|
nativeHeight: 800,
|
|
wantWidth: 1200,
|
|
wantHeight: 800,
|
|
wantScale: 0.849999,
|
|
wantHasScale: true,
|
|
wantScaleText: `scale="0.849999"`,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
runtime := newLocalDocResourceTestRuntime(t, map[string]string{
|
|
"diagram.png": localDocResourcePNG(t, tt.nativeWidth, tt.nativeHeight),
|
|
})
|
|
content := `<img path="@diagram.png"` + tt.attrs + `/>`
|
|
|
|
got, resources, err := prepareLocalDocResources(runtime, "xml", content)
|
|
if err != nil {
|
|
t.Fatalf("prepareLocalDocResources() error: %v", err)
|
|
}
|
|
if len(resources) != 1 {
|
|
t.Fatalf("resources len = %d, want 1: %#v", len(resources), resources)
|
|
}
|
|
resource := resources[0]
|
|
if resource.ImageWidth != tt.wantWidth || resource.ImageHeight != tt.wantHeight {
|
|
t.Fatalf("intrinsic dimensions = %dx%d, want %dx%d; content=%s", resource.ImageWidth, resource.ImageHeight, tt.wantWidth, tt.wantHeight, got)
|
|
}
|
|
if resource.HasScale != tt.wantHasScale || resource.ImageScale != tt.wantScale {
|
|
t.Fatalf("scale = %v (present=%v), want %v (present=%v); content=%s", resource.ImageScale, resource.HasScale, tt.wantScale, tt.wantHasScale, got)
|
|
}
|
|
for _, want := range []string{
|
|
fmt.Sprintf(`width="%d"`, tt.wantWidth),
|
|
fmt.Sprintf(`height="%d"`, tt.wantHeight),
|
|
} {
|
|
if !strings.Contains(got, want) {
|
|
t.Fatalf("rewritten content missing %q: %s", want, got)
|
|
}
|
|
}
|
|
if tt.wantHasScale {
|
|
if !strings.Contains(got, tt.wantScaleText) {
|
|
t.Fatalf("rewritten content missing %q: %s", tt.wantScaleText, got)
|
|
}
|
|
} else if strings.Contains(got, ` scale=`) {
|
|
t.Fatalf("rewritten content unexpectedly contains scale: %s", got)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// BUG_MAP #1: Markdown alt is persisted by docx_engine through caption, then
|
|
// exported back as Markdown alt. Sending only the SDK-only alt attribute loses
|
|
// the text when the placeholder reaches the engine.
|
|
func TestPrepareLocalDocResourcesMarkdownAltUsesCaption(t *testing.T) {
|
|
runtime := newLocalDocResourceTestRuntime(t, map[string]string{"diagram.png": localDocResourcePNG(t, 2, 1)})
|
|
|
|
got, resources, err := prepareLocalDocResources(runtime, "markdown", ``)
|
|
if err != nil {
|
|
t.Fatalf("prepareLocalDocResources() error: %v", err)
|
|
}
|
|
if len(resources) != 1 {
|
|
t.Fatalf("resources len = %d, want 1", len(resources))
|
|
}
|
|
if !strings.Contains(got, `caption="architecture diagram"`) {
|
|
t.Fatalf("Markdown alt must be mapped to engine caption, got: %s", got)
|
|
}
|
|
if strings.Contains(got, ` alt=`) {
|
|
t.Fatalf("rewritten Markdown image must not rely on non-persisted alt: %s", got)
|
|
}
|
|
}
|
|
|
|
func TestPrepareLocalDocResourcesMarkdownTitleConsumesQuotedClosingParen(t *testing.T) {
|
|
runtime := newLocalDocResourceTestRuntime(t, map[string]string{"diagram.png": localDocResourcePNG(t, 2, 1)})
|
|
content := `before ") after`
|
|
|
|
got, resources, err := prepareLocalDocResources(runtime, "markdown", content)
|
|
if err != nil {
|
|
t.Fatalf("prepareLocalDocResources() error: %v", err)
|
|
}
|
|
if len(resources) != 1 {
|
|
t.Fatalf("resources len = %d, want 1", len(resources))
|
|
}
|
|
want := `before <img path="` + resources[0].Marker + `" caption="architecture diagram" title="v2 (final)"/> after`
|
|
if got != want {
|
|
t.Fatalf("rewritten Markdown image = %q, want %q", got, want)
|
|
}
|
|
}
|
|
|
|
func TestParseMarkdownImageDestinationRejectsUnclosedTitle(t *testing.T) {
|
|
content := `) trailing`
|
|
if image, ok := parseMarkdownImageAt(content, 0); ok {
|
|
t.Fatalf("parseMarkdownImageAt() = %#v, true; want invalid unclosed title", image)
|
|
}
|
|
}
|
|
|
|
// BUG_MAP #2: image-looking text in inert Markdown contexts must remain text;
|
|
// otherwise the CLI plans an upload for a block the Markdown parser never
|
|
// creates and reports a partial failure after the document write succeeded.
|
|
func TestPrepareLocalDocResourcesMarkdownIgnoresInertContexts(t *testing.T) {
|
|
runtime := newLocalDocResourceTestRuntime(t, map[string]string{"diagram.png": localDocResourcePNG(t, 2, 1)})
|
|
content := "<!--  -->\n \n"
|
|
|
|
got, resources, err := prepareLocalDocResources(runtime, "markdown", content)
|
|
if err != nil {
|
|
t.Fatalf("prepareLocalDocResources() error: %v", err)
|
|
}
|
|
if got != content {
|
|
t.Fatalf("inert Markdown was rewritten:\n got: %q\nwant: %q", got, content)
|
|
}
|
|
if len(resources) != 0 {
|
|
t.Fatalf("inert Markdown planned %d resources, want 0: %#v", len(resources), resources)
|
|
}
|
|
}
|
|
|
|
func TestPrepareLocalDocResourcesMarkdownListIndentIsRelativeToList(t *testing.T) {
|
|
runtime := newLocalDocResourceTestRuntime(t, map[string]string{"diagram.png": localDocResourcePNG(t, 2, 1)})
|
|
content := "- report:\n\n \n"
|
|
|
|
got, resources, err := prepareLocalDocResources(runtime, "markdown", content)
|
|
if err != nil {
|
|
t.Fatalf("prepareLocalDocResources() error: %v", err)
|
|
}
|
|
if len(resources) != 1 || !strings.Contains(got, resources[0].Marker) {
|
|
t.Fatalf("four-space list content was not rewritten: got=%q resources=%#v", got, resources)
|
|
}
|
|
if strings.Contains(got, "@diagram.png") {
|
|
t.Fatalf("rewritten list content leaked local path: %q", got)
|
|
}
|
|
}
|
|
|
|
func TestPrepareLocalDocResourcesMarkdownListIndentedCodeRemainsInert(t *testing.T) {
|
|
runtime := newLocalDocResourceTestRuntime(t, map[string]string{"diagram.png": localDocResourcePNG(t, 2, 1)})
|
|
content := "- report:\n\n \n"
|
|
|
|
got, resources, err := prepareLocalDocResources(runtime, "markdown", content)
|
|
if err != nil {
|
|
t.Fatalf("prepareLocalDocResources() error: %v", err)
|
|
}
|
|
if got != content || len(resources) != 0 {
|
|
t.Fatalf("six-space list code was rewritten: got=%q resources=%#v", got, resources)
|
|
}
|
|
}
|
|
|
|
func TestPrepareLocalDocResourcesMarkdownThematicBreakDoesNotOpenList(t *testing.T) {
|
|
for _, thematicBreak := range []string{"* * *", "- - -"} {
|
|
t.Run(thematicBreak, func(t *testing.T) {
|
|
runtime := newLocalDocResourceTestRuntime(t, map[string]string{"diagram.png": localDocResourcePNG(t, 2, 1)})
|
|
content := thematicBreak + "\n\n \n"
|
|
|
|
got, resources, err := prepareLocalDocResources(runtime, "markdown", content)
|
|
if err != nil {
|
|
t.Fatalf("prepareLocalDocResources() error: %v", err)
|
|
}
|
|
if got != content || len(resources) != 0 {
|
|
t.Fatalf("indented code after thematic break was rewritten: got=%q resources=%#v", got, resources)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// BUG_MAP #3: internal correlation markers are an implementation detail and
|
|
// must never escape in a partial-failure result, even if no document ID was
|
|
// returned and cleanup therefore cannot run.
|
|
func TestFinalizeLocalDocResourcesScrubsMarkerWhenDocumentIDMissing(t *testing.T) {
|
|
marker := "@lcli_img_0123456789abcdef0123456789abcdef"
|
|
factory, stdout, _, _ := cmdutil.TestFactory(t, docsTestConfigWithAppID("local-resource-marker-scrub"))
|
|
runtime := common.TestNewRuntimeContextForAPI(
|
|
context.Background(),
|
|
&cobra.Command{Use: "docs +create"},
|
|
docsTestConfigWithAppID("local-resource-marker-scrub"),
|
|
factory,
|
|
core.AsUser,
|
|
)
|
|
block := map[string]interface{}{
|
|
"block_id": "blk_placeholder",
|
|
"block_type": "image",
|
|
"block_token": marker,
|
|
}
|
|
data := map[string]interface{}{
|
|
"document": map[string]interface{}{
|
|
"new_blocks": []interface{}{block},
|
|
},
|
|
}
|
|
|
|
err := finalizeLocalDocResources(runtime, "", data, []localDocResource{{
|
|
Occurrence: 1,
|
|
Kind: localDocResourceImage,
|
|
Marker: marker,
|
|
}})
|
|
if err == nil {
|
|
t.Fatal("finalizeLocalDocResources() error = nil, want partial failure")
|
|
}
|
|
if token := common.GetString(block, "block_token"); token != "" {
|
|
t.Fatalf("partial-failure response leaked marker %q", token)
|
|
}
|
|
var envelope struct {
|
|
OK bool `json:"ok"`
|
|
Data map[string]interface{} `json:"data"`
|
|
}
|
|
if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil {
|
|
t.Fatalf("unmarshal partial-failure response: %v\nstdout: %s", err, stdout.String())
|
|
}
|
|
if envelope.OK {
|
|
t.Fatalf("partial failure reported ok:true: %s", stdout.String())
|
|
}
|
|
for _, internalField := range []string{"summary", "items"} {
|
|
if _, exposed := envelope.Data[internalField]; exposed {
|
|
t.Fatalf("partial-failure response exposed internal field %q: %s", internalField, stdout.String())
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestNewLocalDocResourceRejectsTraversalAndSymlinkEscape(t *testing.T) {
|
|
root := t.TempDir()
|
|
work := filepath.Join(root, "work")
|
|
outside := filepath.Join(root, "outside")
|
|
if err := os.MkdirAll(work, 0o700); err != nil {
|
|
t.Fatalf("MkdirAll(work): %v", err)
|
|
}
|
|
if err := os.MkdirAll(outside, 0o700); err != nil {
|
|
t.Fatalf("MkdirAll(outside): %v", err)
|
|
}
|
|
if err := os.WriteFile(filepath.Join(outside, "secret.png"), []byte("secret"), 0o600); err != nil {
|
|
t.Fatalf("WriteFile(secret): %v", err)
|
|
}
|
|
if err := os.Symlink(filepath.Join(outside, "secret.png"), filepath.Join(work, "escape.png")); err != nil {
|
|
t.Fatalf("Symlink: %v", err)
|
|
}
|
|
cmdutil.TestChdir(t, work)
|
|
factory, _, _, _ := cmdutil.TestFactory(t, docsTestConfigWithAppID("local-resource-path"))
|
|
runtime := common.TestNewRuntimeContextForAPI(context.Background(), &cobra.Command{Use: "test"}, docsTestConfigWithAppID("local-resource-path"), factory, core.AsUser)
|
|
|
|
for _, path := range []string{"@../outside/secret.png", "@escape.png"} {
|
|
if _, err := newLocalDocResource(runtime, localDocResourceImage, path, 1); err == nil {
|
|
t.Fatalf("newLocalDocResource(%q) error = nil, want unsafe path rejection", path)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestCorrelateLocalDocResourcesMatchesTypeAndBlockID(t *testing.T) {
|
|
marker := "@lcli_file_0123456789abcdef0123456789abcdef"
|
|
block := map[string]interface{}{
|
|
"block_id": "blk_file",
|
|
"block_type": "file",
|
|
"block_token": marker,
|
|
}
|
|
data := map[string]interface{}{
|
|
"document": map[string]interface{}{"new_blocks": []interface{}{block}},
|
|
}
|
|
|
|
outcomes := correlateLocalDocResources(data, []localDocResource{{Occurrence: 1, Kind: localDocResourceFile, Marker: marker}})
|
|
if len(outcomes) != 1 {
|
|
t.Fatalf("outcomes len = %d, want 1", len(outcomes))
|
|
}
|
|
if outcomes[0].Status != "pending" || outcomes[0].BlockID != "blk_file" || !outcomes[0].SafeToCleanup {
|
|
t.Fatalf("outcome = %#v", outcomes[0])
|
|
}
|
|
}
|
|
|
|
func TestCorrelateLocalDocResourcesTypeMismatchIsNotSafeToCleanup(t *testing.T) {
|
|
marker := "@lcli_img_0123456789abcdef0123456789abcdef"
|
|
outcomes := correlateLocalDocResources(map[string]interface{}{
|
|
"document": map[string]interface{}{"new_blocks": []interface{}{
|
|
map[string]interface{}{"block_id": "blk_mismatch", "block_type": "file", "block_token": marker},
|
|
}},
|
|
}, []localDocResource{{Occurrence: 1, Kind: localDocResourceImage, Marker: marker}})
|
|
|
|
if len(outcomes) != 1 || outcomes[0].Status != "correlation_failed" || outcomes[0].SafeToCleanup {
|
|
t.Fatalf("mismatched block outcome = %#v", outcomes)
|
|
}
|
|
if len(outcomes[0].CleanupBlockIDs) != 0 || outcomes[0].CleanupStatus != "skipped_ambiguous" {
|
|
t.Fatalf("mismatched block was scheduled for cleanup: %#v", outcomes[0])
|
|
}
|
|
}
|
|
|
|
func TestCorrelateLocalDocResourcesMissingMarkerIsSkippedAmbiguous(t *testing.T) {
|
|
marker := "@lcli_img_0123456789abcdef0123456789abcdef"
|
|
outcomes := correlateLocalDocResources(map[string]interface{}{
|
|
"document": map[string]interface{}{"new_blocks": []interface{}{}},
|
|
}, []localDocResource{{Occurrence: 1, Kind: localDocResourceImage, Marker: marker}})
|
|
|
|
if len(outcomes) != 1 || outcomes[0].Status != "correlation_failed" || outcomes[0].SafeToCleanup {
|
|
t.Fatalf("missing marker outcome = %#v", outcomes)
|
|
}
|
|
if outcomes[0].CleanupStatus != "skipped_ambiguous" {
|
|
t.Fatalf("missing marker cleanup status = %q, want skipped_ambiguous", outcomes[0].CleanupStatus)
|
|
}
|
|
data := map[string]interface{}{}
|
|
appendLocalDocResourceFailures(data, outcomes)
|
|
failures, ok := data["local_resource_failures"].([]interface{})
|
|
if !ok || len(failures) != 1 {
|
|
t.Fatalf("failure payload = %#v, want one failure", data["local_resource_failures"])
|
|
}
|
|
failure, ok := failures[0].(map[string]interface{})
|
|
if !ok || failure["cleanup_status"] != "skipped_ambiguous" {
|
|
t.Fatalf("failure payload = %#v, want cleanup_status=skipped_ambiguous", failures[0])
|
|
}
|
|
}
|
|
|
|
func TestCorrelateLocalDocResourcesUnknownMarkerDisablesCleanup(t *testing.T) {
|
|
expectedMarker := "@lcli_img_0123456789abcdef0123456789abcdef"
|
|
unknownMarker := "@lcli_file_fedcba9876543210fedcba9876543210"
|
|
outcomes := correlateLocalDocResources(map[string]interface{}{
|
|
"document": map[string]interface{}{"new_blocks": []interface{}{
|
|
map[string]interface{}{"block_id": "blk_expected", "block_type": "image", "block_token": expectedMarker},
|
|
map[string]interface{}{"block_id": "blk_unknown", "block_type": "file", "block_token": unknownMarker},
|
|
}},
|
|
}, []localDocResource{{Occurrence: 1, Kind: localDocResourceImage, Marker: expectedMarker}})
|
|
|
|
if len(outcomes) != 1 || outcomes[0].Status != "correlation_failed" || outcomes[0].SafeToCleanup {
|
|
t.Fatalf("unknown marker outcome = %#v", outcomes)
|
|
}
|
|
if outcomes[0].CleanupStatus != "skipped_ambiguous" {
|
|
t.Fatalf("unknown marker cleanup status = %q, want skipped_ambiguous", outcomes[0].CleanupStatus)
|
|
}
|
|
for _, blockID := range outcomes[0].CleanupBlockIDs {
|
|
if blockID == "blk_unknown" {
|
|
t.Fatalf("unknown marker block was scheduled for cleanup: %#v", outcomes[0])
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestBuildLocalDocResourceBatchUpdatePreservesImagePresentation(t *testing.T) {
|
|
image := &localDocResourceOutcome{
|
|
Resource: localDocResource{
|
|
Kind: localDocResourceImage,
|
|
ImageWidth: 640,
|
|
ImageHeight: 480,
|
|
ImageAlign: "right",
|
|
ImageScale: 0.5,
|
|
HasScale: true,
|
|
},
|
|
BlockID: "blk_image",
|
|
FileToken: "file_image",
|
|
}
|
|
file := &localDocResourceOutcome{
|
|
Resource: localDocResource{Kind: localDocResourceFile},
|
|
BlockID: "blk_file",
|
|
FileToken: "file_attachment",
|
|
}
|
|
|
|
body := buildLocalDocResourceBatchUpdate([]*localDocResourceOutcome{image, file})
|
|
requests, _ := body["requests"].([]interface{})
|
|
if len(requests) != 2 {
|
|
t.Fatalf("requests len = %d, want 2: %#v", len(requests), body)
|
|
}
|
|
imageReq, _ := requests[0].(map[string]interface{})
|
|
replaceImage, _ := imageReq["replace_image"].(map[string]interface{})
|
|
for key, want := range map[string]interface{}{
|
|
"token": "file_image",
|
|
"width": 640,
|
|
"height": 480,
|
|
"align": alignMap["right"],
|
|
"scale": 0.5,
|
|
} {
|
|
if got := replaceImage[key]; got != want {
|
|
t.Fatalf("replace_image[%s] = %#v, want %#v; body=%#v", key, got, want, body)
|
|
}
|
|
}
|
|
fileReq, _ := requests[1].(map[string]interface{})
|
|
replaceFile, _ := fileReq["replace_file"].(map[string]interface{})
|
|
if got := replaceFile["token"]; got != "file_attachment" {
|
|
t.Fatalf("replace_file token = %#v, want file_attachment", got)
|
|
}
|
|
}
|
|
|
|
func TestPrepareLocalDocResourcesSourceUsesExplicitUploadName(t *testing.T) {
|
|
runtime := newLocalDocResourceTestRuntime(t, map[string]string{"report.pdf": "pdf-data"})
|
|
|
|
got, resources, err := prepareLocalDocResources(runtime, "xml", `<source path="@report.pdf" name=" 自定义报告.pdf "/>`)
|
|
if err != nil {
|
|
t.Fatalf("prepareLocalDocResources() error: %v", err)
|
|
}
|
|
if len(resources) != 1 || resources[0].FileName != "自定义报告.pdf" {
|
|
t.Fatalf("resources = %#v, want trimmed explicit file name", resources)
|
|
}
|
|
if !strings.Contains(got, `name="自定义报告.pdf"`) {
|
|
t.Fatalf("rewritten source did not preserve trimmed name: %s", got)
|
|
}
|
|
}
|
|
|
|
func TestPrepareLocalDocResourcesRejectsInvalidSourceName(t *testing.T) {
|
|
for _, name := range []string{" ", "../secret.pdf", `folder\secret.pdf`} {
|
|
t.Run(strings.ReplaceAll(name, "/", "_"), func(t *testing.T) {
|
|
runtime := newLocalDocResourceTestRuntime(t, map[string]string{"report.pdf": "pdf-data"})
|
|
_, _, err := prepareLocalDocResources(runtime, "xml", `<source path="@report.pdf" name="`+name+`"/>`)
|
|
if err == nil {
|
|
t.Fatalf("source name %q was accepted", name)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestUploadLocalDocResourceUsesExplicitSourceName(t *testing.T) {
|
|
dir := t.TempDir()
|
|
cmdutil.TestChdir(t, dir)
|
|
if err := os.WriteFile("report.pdf", []byte("pdf-data"), 0o600); err != nil {
|
|
t.Fatalf("WriteFile: %v", err)
|
|
}
|
|
factory, _, _, reg := cmdutil.TestFactory(t, docsTestConfigWithAppID("local-source-name"))
|
|
runtime := common.TestNewRuntimeContextForAPI(context.Background(), &cobra.Command{Use: "test"}, docsTestConfigWithAppID("local-source-name"), factory, core.AsUser)
|
|
_, resources, err := prepareLocalDocResources(runtime, "xml", `<source path="@report.pdf" name="自定义报告.pdf"/>`)
|
|
if err != nil {
|
|
t.Fatalf("prepareLocalDocResources: %v", err)
|
|
}
|
|
upload := &httpmock.Stub{
|
|
Method: "POST",
|
|
URL: "/open-apis/drive/v1/medias/upload_all",
|
|
Body: map[string]interface{}{
|
|
"code": 0,
|
|
"data": map[string]interface{}{"file_token": "file_custom_name"},
|
|
},
|
|
}
|
|
reg.Register(upload)
|
|
outcome := &localDocResourceOutcome{Resource: resources[0], BlockID: "blk_file", Status: "pending"}
|
|
uploadLocalDocResources(runtime, "doxcn_source_name", []*localDocResourceOutcome{outcome})
|
|
if outcome.Status != "uploaded" || outcome.FileToken != "file_custom_name" {
|
|
t.Fatalf("outcome = %#v", outcome)
|
|
}
|
|
body := string(upload.CapturedBody)
|
|
if !strings.Contains(body, "自定义报告.pdf") || strings.Contains(body, "\r\n\r\nreport.pdf\r\n") {
|
|
t.Fatalf("upload body did not use explicit source name: %s", body)
|
|
}
|
|
}
|
|
|
|
func TestUploadRemoteDocImagesUsesBoundedConcurrency(t *testing.T) {
|
|
config := docsTestConfigWithAppID("remote-image-concurrent-upload")
|
|
factory, _, _, reg := cmdutil.TestFactory(t, config)
|
|
var credentialReady atomic.Bool
|
|
factory.Credential = credential.NewCredentialProvider(
|
|
nil,
|
|
&remoteImageTestAccountResolver{config: config},
|
|
&remoteImageTestTokenResolver{resolved: &credentialReady},
|
|
nil,
|
|
)
|
|
runtime := common.TestNewRuntimeContextForAPI(context.Background(), &cobra.Command{Use: "test"}, config, factory, core.AsUser)
|
|
payload := []byte(localDocResourcePNG(t, 20, 10))
|
|
originalDownload := downloadRemoteDocImage
|
|
t.Cleanup(func() { downloadRemoteDocImage = originalDownload })
|
|
var downloadMu sync.Mutex
|
|
downloads := 0
|
|
var downloadBeforeCredential atomic.Bool
|
|
downloadRemoteDocImage = func(_ *common.RuntimeContext, _ string, _ int) (remoteDocImageDownload, error) {
|
|
if !credentialReady.Load() {
|
|
downloadBeforeCredential.Store(true)
|
|
}
|
|
downloadMu.Lock()
|
|
downloads++
|
|
downloadMu.Unlock()
|
|
return remoteDocImageDownload{Content: payload, FileName: "remote.png", Width: 20, Height: 10}, nil
|
|
}
|
|
|
|
releaseUploads := make(chan struct{})
|
|
reachedConcurrentUpload := make(chan struct{})
|
|
var concurrentOnce sync.Once
|
|
var uploadMu sync.Mutex
|
|
activeUploads := 0
|
|
maxActiveUploads := 0
|
|
upload := &httpmock.Stub{
|
|
Method: "POST",
|
|
URL: "/open-apis/drive/v1/medias/upload_all",
|
|
Reusable: true,
|
|
OnMatch: func(*http.Request) {
|
|
uploadMu.Lock()
|
|
activeUploads++
|
|
if activeUploads > maxActiveUploads {
|
|
maxActiveUploads = activeUploads
|
|
}
|
|
if activeUploads >= 2 {
|
|
concurrentOnce.Do(func() { close(reachedConcurrentUpload) })
|
|
}
|
|
uploadMu.Unlock()
|
|
<-releaseUploads
|
|
uploadMu.Lock()
|
|
activeUploads--
|
|
uploadMu.Unlock()
|
|
},
|
|
Body: map[string]interface{}{
|
|
"code": 0,
|
|
"data": map[string]interface{}{"file_token": "file_remote_image"},
|
|
},
|
|
}
|
|
reg.Register(upload)
|
|
outcomes := make([]*localDocResourceOutcome, remoteDocImageUploadConcurrency+2)
|
|
for i := range outcomes {
|
|
occurrence := i + 1
|
|
outcomes[i] = &localDocResourceOutcome{
|
|
Resource: localDocResource{
|
|
Occurrence: occurrence,
|
|
Kind: localDocResourceImage,
|
|
RemoteURL: fmt.Sprintf("https://93.184.216.34/%d.png", occurrence),
|
|
Content: payload,
|
|
},
|
|
BlockID: fmt.Sprintf("blk_remote_image_%d", occurrence),
|
|
Status: "pending",
|
|
}
|
|
}
|
|
|
|
done := make(chan struct{})
|
|
go func() {
|
|
uploadLocalDocResources(runtime, "doxcn_remote_image", outcomes)
|
|
close(done)
|
|
}()
|
|
select {
|
|
case <-reachedConcurrentUpload:
|
|
close(releaseUploads)
|
|
case <-time.After(2 * time.Second):
|
|
close(releaseUploads)
|
|
<-done
|
|
t.Fatalf("remote image uploads did not overlap; max active uploads = %d", maxActiveUploads)
|
|
}
|
|
<-done
|
|
downloadMu.Lock()
|
|
gotDownloads := downloads
|
|
downloadMu.Unlock()
|
|
if gotDownloads != len(outcomes) {
|
|
t.Fatalf("remote image downloads = %d, want %d", gotDownloads, len(outcomes))
|
|
}
|
|
if downloadBeforeCredential.Load() {
|
|
t.Fatal("remote image worker started before credentials were resolved")
|
|
}
|
|
if maxActiveUploads < 2 {
|
|
t.Fatalf("max active remote image uploads = %d, want at least 2", maxActiveUploads)
|
|
}
|
|
if maxActiveUploads > remoteDocImageUploadConcurrency {
|
|
t.Fatalf("max active remote image uploads = %d, concurrency limit = %d", maxActiveUploads, remoteDocImageUploadConcurrency)
|
|
}
|
|
for _, outcome := range outcomes {
|
|
if outcome.Status != "uploaded" || outcome.FileToken != "file_remote_image" {
|
|
t.Fatalf("outcome = %#v", outcome)
|
|
}
|
|
if len(outcome.Resource.Content) != 0 {
|
|
t.Fatalf("remote image #%d retained %d buffered bytes after upload", outcome.Resource.Occurrence, len(outcome.Resource.Content))
|
|
}
|
|
}
|
|
}
|
|
|
|
type remoteImageTestAccountResolver struct {
|
|
config *core.CliConfig
|
|
}
|
|
|
|
func (r *remoteImageTestAccountResolver) ResolveAccount(context.Context) (*credential.Account, error) {
|
|
return credential.AccountFromCliConfig(r.config), nil
|
|
}
|
|
|
|
type remoteImageTestTokenResolver struct {
|
|
resolved *atomic.Bool
|
|
}
|
|
|
|
func (r *remoteImageTestTokenResolver) ResolveToken(context.Context, credential.TokenSpec) (*credential.TokenResult, error) {
|
|
r.resolved.Store(true)
|
|
return &credential.TokenResult{Token: "test-token"}, nil
|
|
}
|
|
|
|
func TestUploadLocalDocResourcesRetriesConflictAndSerializes(t *testing.T) {
|
|
dir := t.TempDir()
|
|
cmdutil.TestChdir(t, dir)
|
|
for _, name := range []string{"first.png", "second.png"} {
|
|
if err := os.WriteFile(name, []byte(name), 0o600); err != nil {
|
|
t.Fatalf("WriteFile(%s): %v", name, err)
|
|
}
|
|
}
|
|
factory, _, _, reg := cmdutil.TestFactory(t, docsTestConfigWithAppID("local-upload-serial"))
|
|
runtime := common.TestNewRuntimeContextForAPI(context.Background(), &cobra.Command{Use: "test"}, docsTestConfigWithAppID("local-upload-serial"), factory, core.AsUser)
|
|
reg.Register(&httpmock.Stub{Method: "POST", URL: "/open-apis/drive/v1/medias/upload_all", Body: map[string]interface{}{"code": localDocResourceUploadConflictCode, "msg": "material transaction conflict"}})
|
|
reg.Register(&httpmock.Stub{Method: "POST", URL: "/open-apis/drive/v1/medias/upload_all", Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"file_token": "file_first"}}})
|
|
reg.Register(&httpmock.Stub{Method: "POST", URL: "/open-apis/drive/v1/medias/upload_all", Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"file_token": "file_second"}}})
|
|
outcomes := []*localDocResourceOutcome{
|
|
{Resource: localDocResource{Occurrence: 1, Kind: localDocResourceImage, Path: "first.png", FileName: "first.png", Size: int64(len("first.png"))}, BlockID: "blk_first", Status: "pending"},
|
|
{Resource: localDocResource{Occurrence: 2, Kind: localDocResourceImage, Path: "second.png", FileName: "second.png", Size: int64(len("second.png"))}, BlockID: "blk_second", Status: "pending"},
|
|
}
|
|
var waits []time.Duration
|
|
originalWait := waitLocalDocResourceRequest
|
|
waitLocalDocResourceRequest = func(_ context.Context, delay time.Duration) error {
|
|
waits = append(waits, delay)
|
|
return nil
|
|
}
|
|
t.Cleanup(func() { waitLocalDocResourceRequest = originalWait })
|
|
uploadLocalDocResources(runtime, "doxcn_upload_serial", outcomes)
|
|
if outcomes[0].FileToken != "file_first" || outcomes[1].FileToken != "file_second" {
|
|
t.Fatalf("outcomes = %#v", outcomes)
|
|
}
|
|
if len(waits) != 2 {
|
|
t.Fatalf("upload pacing waits = %#v", waits)
|
|
}
|
|
retryWaits := waits[:1]
|
|
assertLocalDocResourceRetryWaits(t, retryWaits, 1)
|
|
if waits[1] != localDocResourceUploadInterval {
|
|
t.Fatalf("serial upload pacing wait = %v, want %v", waits[1], localDocResourceUploadInterval)
|
|
}
|
|
}
|
|
|
|
func assertLocalDocResourceRetryWaits(t *testing.T, waits []time.Duration, want int) {
|
|
t.Helper()
|
|
if len(waits) != want {
|
|
t.Fatalf("retry waits = %#v, want %d", waits, want)
|
|
}
|
|
for attempt, got := range waits {
|
|
base := localDocResourceUploadInterval * time.Duration(1<<attempt)
|
|
max := base + base/4
|
|
if got < base || got > max {
|
|
t.Fatalf("retry wait[%d] = %v, want in [%v, %v]", attempt, got, base, max)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestPrepareLocalDocResourcesRejectsDuplicateAttributes(t *testing.T) {
|
|
runtime := newLocalDocResourceTestRuntime(t, map[string]string{
|
|
"diagram.png": localDocResourcePNG(t, 2, 1),
|
|
"secret.png": localDocResourcePNG(t, 3, 1),
|
|
})
|
|
_, resources, err := prepareLocalDocResources(runtime, "xml", `<img path="@diagram.png" PATH="@secret.png"/>`)
|
|
if err == nil {
|
|
t.Fatal("duplicate path attributes were accepted")
|
|
}
|
|
if len(resources) != 0 {
|
|
t.Fatalf("duplicate attributes planned resources: %#v", resources)
|
|
}
|
|
if strings.Contains(err.Error(), "secret.png") {
|
|
t.Fatalf("duplicate-attribute error leaked the second local path: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestCorrelateLocalDocResourcesRejectsDuplicateBlockID(t *testing.T) {
|
|
markers := []string{
|
|
"@lcli_img_0123456789abcdef0123456789abcdef",
|
|
"@lcli_img_fedcba9876543210fedcba9876543210",
|
|
}
|
|
blocks := []interface{}{
|
|
map[string]interface{}{"block_id": "blk_shared", "block_type": "image", "block_token": markers[0]},
|
|
map[string]interface{}{"block_id": "blk_shared", "block_type": "image", "block_token": markers[1]},
|
|
}
|
|
outcomes := correlateLocalDocResources(map[string]interface{}{
|
|
"document": map[string]interface{}{"new_blocks": blocks},
|
|
}, []localDocResource{
|
|
{Occurrence: 1, Kind: localDocResourceImage, Marker: markers[0]},
|
|
{Occurrence: 2, Kind: localDocResourceImage, Marker: markers[1]},
|
|
})
|
|
cleanupCount := 0
|
|
for _, outcome := range outcomes {
|
|
if outcome.Status != "correlation_failed" {
|
|
t.Fatalf("duplicate block outcome = %#v", outcome)
|
|
}
|
|
cleanupCount += len(outcome.CleanupBlockIDs)
|
|
}
|
|
if cleanupCount != 1 {
|
|
t.Fatalf("cleanup block IDs = %d, want one deduplicated delete", cleanupCount)
|
|
}
|
|
}
|
|
|
|
func TestPrepareLocalDocResourcesMarkdownIgnoresNestedFencesAndRawHTML(t *testing.T) {
|
|
runtime := newLocalDocResourceTestRuntime(t, map[string]string{"diagram.png": localDocResourcePNG(t, 2, 1)})
|
|
content := strings.Join([]string{
|
|
"> ```xml",
|
|
"> <img path=\"@diagram.png\"/>",
|
|
"> ```",
|
|
"- ```markdown",
|
|
" ",
|
|
" ```",
|
|
"<pre>",
|
|
"```",
|
|
"<img path=\"@diagram.png\"/>",
|
|
"```",
|
|
"</pre>",
|
|
"<script>const sample = '<img path=\"@diagram.png\"/>';</script>",
|
|
"<style>/*  */</style>",
|
|
"<textarea><source path=\"@diagram.png\"/></textarea>",
|
|
`\<img path="@diagram.png"/>`,
|
|
}, "\n")
|
|
|
|
got, resources, err := prepareLocalDocResources(runtime, "markdown", content)
|
|
if err != nil {
|
|
t.Fatalf("prepareLocalDocResources: %v", err)
|
|
}
|
|
if got != content || len(resources) != 0 {
|
|
t.Fatalf("inert Markdown changed: resources=%#v\n got=%q\nwant=%q", resources, got, content)
|
|
}
|
|
}
|
|
|
|
func TestPrepareLocalDocResourcesXMLBackticksAreNotInert(t *testing.T) {
|
|
runtime := newLocalDocResourceTestRuntime(t, map[string]string{"diagram.png": localDocResourcePNG(t, 2, 1)})
|
|
got, resources, err := prepareLocalDocResources(runtime, "xml", "`<img path=\"@diagram.png\"/>`")
|
|
if err != nil {
|
|
t.Fatalf("prepareLocalDocResources: %v", err)
|
|
}
|
|
if len(resources) != 1 || !strings.Contains(got, resources[0].Marker) {
|
|
t.Fatalf("XML backticks incorrectly hid resource: got=%q resources=%#v", got, resources)
|
|
}
|
|
}
|
|
|
|
func TestMarkdownUnescapePreservesNonPunctuationBackslashes(t *testing.T) {
|
|
if got, want := unescapeMarkdownText(`C:\temp\photo \*draft\* \\`), `C:\temp\photo *draft* \`; got != want {
|
|
t.Fatalf("unescapeMarkdownText() = %q, want %q", got, want)
|
|
}
|
|
image, ok := parseMarkdownImageAt(``, 0)
|
|
if !ok || image.Destination != `@images\photo.png` {
|
|
t.Fatalf("parsed destination = %q, ok=%v", image.Destination, ok)
|
|
}
|
|
}
|
|
|
|
func TestBindLocalDocResourcesBatchesTwentyAndPaces(t *testing.T) {
|
|
factory, _, _, reg := cmdutil.TestFactory(t, docsTestConfigWithAppID("local-bind-batch"))
|
|
runtime := common.TestNewRuntimeContextForAPI(context.Background(), &cobra.Command{Use: "test"}, docsTestConfigWithAppID("local-bind-batch"), factory, core.AsUser)
|
|
first := &httpmock.Stub{Method: "PATCH", URL: "/open-apis/docx/v1/documents/doxcn_batch/blocks/batch_update", Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"document_revision_id": 1}}}
|
|
second := &httpmock.Stub{Method: "PATCH", URL: "/open-apis/docx/v1/documents/doxcn_batch/blocks/batch_update", Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"document_revision_id": 2}}}
|
|
reg.Register(first)
|
|
reg.Register(second)
|
|
outcomes := make([]*localDocResourceOutcome, 21)
|
|
for i := range outcomes {
|
|
outcomes[i] = &localDocResourceOutcome{
|
|
Resource: localDocResource{Occurrence: i + 1, Kind: localDocResourceImage},
|
|
BlockID: fmt.Sprintf("blk_%d", i),
|
|
FileToken: fmt.Sprintf("file_%d", i),
|
|
Status: "uploaded",
|
|
}
|
|
}
|
|
var waits []time.Duration
|
|
originalWait := waitLocalDocResourceRequest
|
|
waitLocalDocResourceRequest = func(_ context.Context, delay time.Duration) error {
|
|
waits = append(waits, delay)
|
|
return nil
|
|
}
|
|
t.Cleanup(func() { waitLocalDocResourceRequest = originalWait })
|
|
|
|
revision, revisionKnown := bindLocalDocResources(runtime, "doxcn_batch", outcomes)
|
|
if !revisionKnown || revision != int64(2) {
|
|
t.Fatalf("revision = %#v, want 2", revision)
|
|
}
|
|
if got := requestCountFromLocalDocBatchBody(t, first.CapturedBody); got != 20 {
|
|
t.Fatalf("first batch size = %d, want 20", got)
|
|
}
|
|
if got := requestCountFromLocalDocBatchBody(t, second.CapturedBody); got != 1 {
|
|
t.Fatalf("second batch size = %d, want 1", got)
|
|
}
|
|
if len(waits) != 1 || waits[0] != localDocResourceBindInterval {
|
|
t.Fatalf("bind pacing waits = %#v", waits)
|
|
}
|
|
}
|
|
|
|
func TestNormalizeLocalDocResourceRevision(t *testing.T) {
|
|
for _, tt := range []struct {
|
|
name string
|
|
value interface{}
|
|
want interface{}
|
|
}{
|
|
{name: "integer", value: 7, want: int64(7)},
|
|
{name: "float integer", value: float64(8), want: int64(8)},
|
|
{name: "json number", value: json.Number("9"), want: int64(9)},
|
|
{name: "numeric string", value: "10", want: int64(10)},
|
|
{name: "negative sentinel", value: -1},
|
|
{name: "fraction", value: 1.5},
|
|
{name: "invalid string", value: "latest"},
|
|
} {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
if got := normalizeLocalDocResourceRevision(tt.value); got != tt.want {
|
|
t.Fatalf("normalizeLocalDocResourceRevision(%#v) = %#v, want %#v", tt.value, got, tt.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestBindLocalDocResourceRetryUsesStableTokenAndBackoff(t *testing.T) {
|
|
factory, _, _, reg := cmdutil.TestFactory(t, docsTestConfigWithAppID("local-bind-retry"))
|
|
runtime := common.TestNewRuntimeContextForAPI(context.Background(), &cobra.Command{Use: "test"}, docsTestConfigWithAppID("local-bind-retry"), factory, core.AsUser)
|
|
var clientTokens []string
|
|
for _, stub := range []*httpmock.Stub{
|
|
{Method: "PATCH", URL: "/open-apis/docx/v1/documents/doxcn_retry/blocks/batch_update", Body: map[string]interface{}{"code": localDocResourceUploadRateLimitCode, "msg": "rate limited"}, OnMatch: func(req *http.Request) { clientTokens = append(clientTokens, req.URL.Query().Get("client_token")) }},
|
|
{Method: "GET", URL: "/open-apis/docx/v1/documents/doxcn_retry/blocks/blk_retry", Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"block": map[string]interface{}{"image": map[string]interface{}{"token": ""}}}}},
|
|
{Method: "PATCH", URL: "/open-apis/docx/v1/documents/doxcn_retry/blocks/batch_update", Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{}}, OnMatch: func(req *http.Request) { clientTokens = append(clientTokens, req.URL.Query().Get("client_token")) }},
|
|
} {
|
|
reg.Register(stub)
|
|
}
|
|
var waits []time.Duration
|
|
originalWait := waitLocalDocResourceRequest
|
|
waitLocalDocResourceRequest = func(_ context.Context, delay time.Duration) error {
|
|
waits = append(waits, delay)
|
|
return nil
|
|
}
|
|
t.Cleanup(func() { waitLocalDocResourceRequest = originalWait })
|
|
outcome := &localDocResourceOutcome{Resource: localDocResource{Kind: localDocResourceImage}, BlockID: "blk_retry", FileToken: "file_retry", Status: "uploaded"}
|
|
_, _ = bindLocalDocResourceChunk(runtime, "doxcn_retry", []*localDocResourceOutcome{outcome})
|
|
if outcome.Status != "bound" || len(clientTokens) != 2 || clientTokens[0] == "" || clientTokens[0] != clientTokens[1] {
|
|
t.Fatalf("outcome=%#v client_tokens=%#v", outcome, clientTokens)
|
|
}
|
|
if len(waits) != 1 || waits[0] != localDocResourceBindInterval {
|
|
t.Fatalf("retry waits = %#v", waits)
|
|
}
|
|
}
|
|
|
|
func TestBindLocalDocResourceAmbiguousSuccessDropsStaleRevision(t *testing.T) {
|
|
factory, _, _, reg := cmdutil.TestFactory(t, docsTestConfigWithAppID("local-bind-ambiguous-success"))
|
|
runtime := common.TestNewRuntimeContextForAPI(context.Background(), &cobra.Command{Use: "test"}, docsTestConfigWithAppID("local-bind-ambiguous-success"), factory, core.AsUser)
|
|
reg.Register(&httpmock.Stub{Method: "PATCH", URL: "/open-apis/docx/v1/documents/doxcn_ambiguous/blocks/batch_update", Body: map[string]interface{}{"code": localDocResourceUploadRateLimitCode, "msg": "rate limited"}})
|
|
reg.Register(&httpmock.Stub{Method: "GET", URL: "/open-apis/docx/v1/documents/doxcn_ambiguous/blocks/blk_ambiguous", Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"block": map[string]interface{}{"image": map[string]interface{}{"token": "file_ambiguous"}}}}})
|
|
outcome := &localDocResourceOutcome{Resource: localDocResource{Kind: localDocResourceImage}, BlockID: "blk_ambiguous", FileToken: "file_ambiguous", Status: "uploaded"}
|
|
revision, revisionKnown := bindLocalDocResourceChunk(runtime, "doxcn_ambiguous", []*localDocResourceOutcome{outcome})
|
|
if revision != nil || revisionKnown || outcome.Status != "bound" {
|
|
t.Fatalf("revision=%#v known=%v outcome=%#v", revision, revisionKnown, outcome)
|
|
}
|
|
}
|
|
|
|
func TestCleanupLocalDocResourcePlaceholdersRevalidatesToken(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
getStatus int
|
|
block map[string]interface{}
|
|
wantDelete bool
|
|
wantStatus string
|
|
wantCleanup string
|
|
wantKnown bool
|
|
wantRevision interface{}
|
|
}{
|
|
{name: "empty", block: map[string]interface{}{"block_type": 27, "image": map[string]interface{}{"token": ""}}, wantDelete: true, wantStatus: "bind_failed", wantCleanup: "succeeded", wantKnown: true, wantRevision: int64(8)},
|
|
{name: "ours", block: map[string]interface{}{"block_type": 27, "image": map[string]interface{}{"token": "file_ours"}}, wantStatus: "bound", wantCleanup: "not_needed"},
|
|
{name: "other", block: map[string]interface{}{"block_type": 27, "image": map[string]interface{}{"token": "file_other"}}, wantStatus: "bind_conflict", wantCleanup: "skipped_conflict"},
|
|
{name: "other_kind_token", block: map[string]interface{}{"block_type": 27, "image": map[string]interface{}{"token": ""}, "file": map[string]interface{}{"token": "file_hidden"}}, wantStatus: "bind_conflict", wantCleanup: "skipped_conflict"},
|
|
{name: "type_mismatch", block: map[string]interface{}{"block_type": 23, "file": map[string]interface{}{"token": ""}}, wantStatus: "bind_ambiguous", wantCleanup: "skipped_ambiguous"},
|
|
{name: "type_unknown", block: map[string]interface{}{"block_type": 999, "image": map[string]interface{}{"token": ""}}, wantStatus: "bind_ambiguous", wantCleanup: "skipped_ambiguous"},
|
|
{name: "get_fail", getStatus: 503, wantStatus: "bind_ambiguous", wantCleanup: "skipped_ambiguous"},
|
|
}
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
factory, _, _, reg := cmdutil.TestFactory(t, docsTestConfigWithAppID("local-cleanup-"+tt.name))
|
|
runtime := common.TestNewRuntimeContextForAPI(context.Background(), &cobra.Command{Use: "test"}, docsTestConfigWithAppID("local-cleanup-"+tt.name), factory, core.AsUser)
|
|
getStub := &httpmock.Stub{Method: "GET", URL: "/open-apis/docx/v1/documents/doxcn_cleanup/blocks/blk_cleanup"}
|
|
if tt.getStatus != 0 {
|
|
getStub.Status = tt.getStatus
|
|
getStub.RawBody = []byte("temporary")
|
|
} else {
|
|
getStub.Body = map[string]interface{}{"code": 0, "data": map[string]interface{}{"block": tt.block}}
|
|
}
|
|
reg.Register(getStub)
|
|
deleteCalls := 0
|
|
var deleteStub *httpmock.Stub
|
|
if tt.wantDelete {
|
|
deleteStub = &httpmock.Stub{Method: "PUT", URL: "/open-apis/docs_ai/v1/documents/doxcn_cleanup", Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"document": map[string]interface{}{"revision_id": 8}}}, OnMatch: func(*http.Request) { deleteCalls++ }}
|
|
reg.Register(deleteStub)
|
|
}
|
|
outcome := &localDocResourceOutcome{
|
|
Resource: localDocResource{Occurrence: 1, Kind: localDocResourceImage},
|
|
BlockID: "blk_cleanup",
|
|
CleanupBlockIDs: []string{"blk_cleanup"},
|
|
FileToken: "file_ours",
|
|
Status: "bind_failed",
|
|
CleanupStatus: "pending",
|
|
SafeToCleanup: true,
|
|
}
|
|
revision, revisionKnown := cleanupLocalDocResourcePlaceholders(runtime, "doxcn_cleanup", []*localDocResourceOutcome{outcome}, float64(7))
|
|
if got := deleteCalls > 0; got != tt.wantDelete {
|
|
t.Fatalf("delete called=%v, want %v", got, tt.wantDelete)
|
|
}
|
|
if revisionKnown != tt.wantKnown || revision != tt.wantRevision {
|
|
t.Fatalf("revision=%#v known=%v, want revision=%#v known=%v", revision, revisionKnown, tt.wantRevision, tt.wantKnown)
|
|
}
|
|
if outcome.Status != tt.wantStatus || outcome.CleanupStatus != tt.wantCleanup || outcome.SafeToCleanup {
|
|
t.Fatalf("outcome=%#v, want status=%s cleanup=%s safe=false", outcome, tt.wantStatus, tt.wantCleanup)
|
|
}
|
|
if tt.wantDelete {
|
|
var body map[string]interface{}
|
|
if err := json.Unmarshal(deleteStub.CapturedBody, &body); err != nil {
|
|
t.Fatalf("decode cleanup body: %v", err)
|
|
}
|
|
if body["revision_id"] != float64(7) {
|
|
t.Fatalf("cleanup revision_id=%#v, want 7", body["revision_id"])
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestCleanupLocalDocResourcePlaceholdersRequiresKnownRevision(t *testing.T) {
|
|
factory, _, _, _ := cmdutil.TestFactory(t, docsTestConfigWithAppID("local-cleanup-no-revision"))
|
|
runtime := common.TestNewRuntimeContextForAPI(context.Background(), &cobra.Command{Use: "test"}, docsTestConfigWithAppID("local-cleanup-no-revision"), factory, core.AsUser)
|
|
outcome := &localDocResourceOutcome{Resource: localDocResource{Kind: localDocResourceImage}, CleanupBlockIDs: []string{"blk_cleanup"}, Status: "upload_failed", CleanupStatus: "pending", SafeToCleanup: true}
|
|
revision, revisionKnown := cleanupLocalDocResourcePlaceholders(runtime, "doxcn_cleanup", []*localDocResourceOutcome{outcome}, nil)
|
|
if revision != nil || revisionKnown || outcome.CleanupStatus != "skipped_ambiguous" || outcome.SafeToCleanup {
|
|
t.Fatalf("revision=%#v known=%v outcome=%#v", revision, revisionKnown, outcome)
|
|
}
|
|
}
|
|
|
|
func TestCleanupLocalDocResourcePlaceholdersPreservesServiceFailure(t *testing.T) {
|
|
factory, _, _, reg := cmdutil.TestFactory(t, docsTestConfigWithAppID("local-cleanup-service-failure"))
|
|
runtime := common.TestNewRuntimeContextForAPI(context.Background(), &cobra.Command{Use: "test"}, docsTestConfigWithAppID("local-cleanup-service-failure"), factory, core.AsUser)
|
|
reg.Register(&httpmock.Stub{
|
|
Method: "GET",
|
|
URL: "/open-apis/docx/v1/documents/doxcn_cleanup/blocks/blk_cleanup",
|
|
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"block": map[string]interface{}{
|
|
"block_type": 27,
|
|
"image": map[string]interface{}{"token": ""},
|
|
}}},
|
|
})
|
|
reg.Register(&httpmock.Stub{
|
|
Method: "PUT",
|
|
URL: "/open-apis/docs_ai/v1/documents/doxcn_cleanup",
|
|
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{
|
|
"result": "failed",
|
|
"warnings": []interface{}{"target block cannot be deleted"},
|
|
}},
|
|
})
|
|
outcome := &localDocResourceOutcome{
|
|
Resource: localDocResource{Occurrence: 1, Kind: localDocResourceImage},
|
|
BlockID: "blk_cleanup",
|
|
CleanupBlockIDs: []string{"blk_cleanup"},
|
|
Status: "upload_failed",
|
|
CleanupStatus: "pending",
|
|
SafeToCleanup: true,
|
|
}
|
|
|
|
revision, revisionKnown := cleanupLocalDocResourcePlaceholders(runtime, "doxcn_cleanup", []*localDocResourceOutcome{outcome}, float64(7))
|
|
|
|
if revision != nil || revisionKnown || outcome.CleanupStatus != "failed" || outcome.SafeToCleanup {
|
|
t.Fatalf("revision=%#v known=%v outcome=%#v", revision, revisionKnown, outcome)
|
|
}
|
|
if len(outcome.ServerWarnings) != 1 || outcome.ServerWarnings[0] != "target block cannot be deleted" {
|
|
t.Fatalf("server warnings = %#v", outcome.ServerWarnings)
|
|
}
|
|
problem, ok := errs.ProblemOf(outcome.Err)
|
|
if !ok || problem.Category != errs.CategoryAPI {
|
|
t.Fatalf("cleanup error = %T %v, problem=%#v", outcome.Err, outcome.Err, problem)
|
|
}
|
|
}
|
|
|
|
func TestCleanupLocalDocResourceFileDeletesFigureParent(t *testing.T) {
|
|
factory, _, _, reg := cmdutil.TestFactory(t, docsTestConfigWithAppID("local-cleanup-file-parent"))
|
|
runtime := common.TestNewRuntimeContextForAPI(context.Background(), &cobra.Command{Use: "test"}, docsTestConfigWithAppID("local-cleanup-file-parent"), factory, core.AsUser)
|
|
reg.Register(&httpmock.Stub{
|
|
Method: "GET",
|
|
URL: "/open-apis/docx/v1/documents/doxcn_cleanup/blocks/blk_file",
|
|
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"block": map[string]interface{}{
|
|
"block_id": "blk_file",
|
|
"block_type": 23,
|
|
"parent_id": "blk_figure",
|
|
"file": map[string]interface{}{"token": ""},
|
|
}}},
|
|
})
|
|
reg.Register(&httpmock.Stub{
|
|
Method: "GET",
|
|
URL: "/open-apis/docx/v1/documents/doxcn_cleanup/blocks/blk_figure",
|
|
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"block": map[string]interface{}{
|
|
"block_id": "blk_figure",
|
|
"block_type": 33,
|
|
"children": []interface{}{"blk_file"},
|
|
"view": map[string]interface{}{"view_type": 2},
|
|
}}},
|
|
})
|
|
deleteStub := &httpmock.Stub{
|
|
Method: "PUT",
|
|
URL: "/open-apis/docs_ai/v1/documents/doxcn_cleanup",
|
|
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"document": map[string]interface{}{"revision_id": 8}}},
|
|
}
|
|
reg.Register(deleteStub)
|
|
outcome := &localDocResourceOutcome{
|
|
Resource: localDocResource{Occurrence: 1, Kind: localDocResourceFile},
|
|
BlockID: "blk_file",
|
|
CleanupBlockIDs: []string{"blk_file"},
|
|
Status: "upload_failed",
|
|
CleanupStatus: "pending",
|
|
SafeToCleanup: true,
|
|
}
|
|
|
|
revision, revisionKnown := cleanupLocalDocResourcePlaceholders(runtime, "doxcn_cleanup", []*localDocResourceOutcome{outcome}, float64(7))
|
|
|
|
if !revisionKnown || revision != int64(8) || outcome.CleanupStatus != "succeeded" {
|
|
t.Fatalf("revision=%#v known=%v outcome=%#v", revision, revisionKnown, outcome)
|
|
}
|
|
var body map[string]interface{}
|
|
if err := json.Unmarshal(deleteStub.CapturedBody, &body); err != nil {
|
|
t.Fatalf("decode cleanup body: %v", err)
|
|
}
|
|
if body["block_id"] != "blk_figure" {
|
|
t.Fatalf("cleanup block_id=%#v, want figure parent", body["block_id"])
|
|
}
|
|
}
|
|
|
|
func TestCleanupLocalDocResourceFileDoesNotDeleteInlineParent(t *testing.T) {
|
|
factory, _, _, reg := cmdutil.TestFactory(t, docsTestConfigWithAppID("local-cleanup-inline-file"))
|
|
runtime := common.TestNewRuntimeContextForAPI(context.Background(), &cobra.Command{Use: "test"}, docsTestConfigWithAppID("local-cleanup-inline-file"), factory, core.AsUser)
|
|
reg.Register(&httpmock.Stub{
|
|
Method: "GET",
|
|
URL: "/open-apis/docx/v1/documents/doxcn_cleanup/blocks/blk_file",
|
|
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"block": map[string]interface{}{
|
|
"block_id": "blk_file",
|
|
"block_type": 23,
|
|
"parent_id": "blk_paragraph",
|
|
"file": map[string]interface{}{"token": ""},
|
|
}}},
|
|
})
|
|
reg.Register(&httpmock.Stub{
|
|
Method: "GET",
|
|
URL: "/open-apis/docx/v1/documents/doxcn_cleanup/blocks/blk_paragraph",
|
|
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"block": map[string]interface{}{
|
|
"block_id": "blk_paragraph",
|
|
"block_type": 2,
|
|
"children": []interface{}{"blk_file"},
|
|
"text": map[string]interface{}{"elements": []interface{}{map[string]interface{}{"text_run": map[string]interface{}{"content": "keep me"}}}},
|
|
}}},
|
|
})
|
|
outcome := &localDocResourceOutcome{
|
|
Resource: localDocResource{Occurrence: 1, Kind: localDocResourceFile},
|
|
BlockID: "blk_file",
|
|
CleanupBlockIDs: []string{"blk_file"},
|
|
Status: "upload_failed",
|
|
CleanupStatus: "pending",
|
|
SafeToCleanup: true,
|
|
}
|
|
|
|
revision, revisionKnown := cleanupLocalDocResourcePlaceholders(runtime, "doxcn_cleanup", []*localDocResourceOutcome{outcome}, float64(7))
|
|
|
|
if revision != nil || revisionKnown || outcome.Status != "bind_ambiguous" || outcome.CleanupStatus != "skipped_ambiguous" || outcome.SafeToCleanup {
|
|
t.Fatalf("revision=%#v known=%v outcome=%#v", revision, revisionKnown, outcome)
|
|
}
|
|
if outcome.Err == nil || !strings.Contains(outcome.Err.Error(), "sole-source figure") {
|
|
t.Fatalf("outcome error = %v, want parent verification failure", outcome.Err)
|
|
}
|
|
}
|
|
|
|
func TestCleanupLocalDocResourceFileWithoutFigureParentIsPreserved(t *testing.T) {
|
|
factory, _, _, reg := cmdutil.TestFactory(t, docsTestConfigWithAppID("local-cleanup-file-no-parent"))
|
|
runtime := common.TestNewRuntimeContextForAPI(context.Background(), &cobra.Command{Use: "test"}, docsTestConfigWithAppID("local-cleanup-file-no-parent"), factory, core.AsUser)
|
|
reg.Register(&httpmock.Stub{
|
|
Method: "GET",
|
|
URL: "/open-apis/docx/v1/documents/doxcn_cleanup/blocks/blk_file",
|
|
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"block": map[string]interface{}{
|
|
"block_id": "blk_file",
|
|
"block_type": 23,
|
|
"file": map[string]interface{}{"token": ""},
|
|
}}},
|
|
})
|
|
outcome := &localDocResourceOutcome{
|
|
Resource: localDocResource{Occurrence: 1, Kind: localDocResourceFile},
|
|
BlockID: "blk_file",
|
|
CleanupBlockIDs: []string{"blk_file"},
|
|
Status: "upload_failed",
|
|
CleanupStatus: "pending",
|
|
SafeToCleanup: true,
|
|
}
|
|
|
|
revision, revisionKnown := cleanupLocalDocResourcePlaceholders(runtime, "doxcn_cleanup", []*localDocResourceOutcome{outcome}, float64(7))
|
|
|
|
if revision != nil || revisionKnown || outcome.Status != "bind_ambiguous" || outcome.CleanupStatus != "skipped_ambiguous" || outcome.SafeToCleanup {
|
|
t.Fatalf("revision=%#v known=%v outcome=%#v", revision, revisionKnown, outcome)
|
|
}
|
|
}
|
|
|
|
func TestDocAPINullDataIsTypedInvalidResponse(t *testing.T) {
|
|
factory, _, _, reg := cmdutil.TestFactory(t, docsTestConfigWithAppID("local-null-data"))
|
|
runtime := common.TestNewRuntimeContextForAPI(context.Background(), &cobra.Command{Use: "test"}, docsTestConfigWithAppID("local-null-data"), factory, core.AsUser)
|
|
reg.Register(&httpmock.Stub{Method: "PUT", URL: "/open-apis/docs_ai/v1/documents/doxcn_null", Body: map[string]interface{}{"code": 0, "data": nil}})
|
|
_, err := doDocAPI(runtime, "PUT", "/open-apis/docs_ai/v1/documents/doxcn_null", map[string]interface{}{})
|
|
problem, ok := errs.ProblemOf(err)
|
|
if !ok || problem.Subtype != errs.SubtypeInvalidResponse {
|
|
t.Fatalf("error = %T %v, want typed invalid_response", err, err)
|
|
}
|
|
}
|
|
|
|
func TestFinalizeLocalDocResourcesTOCTOUErrorDoesNotLeakCWD(t *testing.T) {
|
|
dir := t.TempDir()
|
|
cmdutil.TestChdir(t, dir)
|
|
if err := os.WriteFile("vanished.png", []byte(localDocResourcePNG(t, 2, 1)), 0o600); err != nil {
|
|
t.Fatalf("WriteFile: %v", err)
|
|
}
|
|
factory, stdout, _, reg := cmdutil.TestFactory(t, docsTestConfigWithAppID("local-toctou"))
|
|
runtime := common.TestNewRuntimeContextForAPI(context.Background(), &cobra.Command{Use: "test"}, docsTestConfigWithAppID("local-toctou"), factory, core.AsUser)
|
|
_, resources, err := prepareLocalDocResources(runtime, "xml", `<img path="@vanished.png"/>`)
|
|
if err != nil {
|
|
t.Fatalf("prepareLocalDocResources: %v", err)
|
|
}
|
|
if err := os.Remove("vanished.png"); err != nil {
|
|
t.Fatalf("Remove: %v", err)
|
|
}
|
|
block := map[string]interface{}{"block_id": "blk_vanished", "block_type": "image", "block_token": resources[0].Marker}
|
|
data := map[string]interface{}{"document": map[string]interface{}{"revision_id": 1, "new_blocks": []interface{}{block}}}
|
|
reg.Register(&httpmock.Stub{Method: "GET", URL: "/open-apis/docx/v1/documents/doxcn_toctou/blocks/blk_vanished", Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"block": map[string]interface{}{"block_type": 27, "image": map[string]interface{}{"token": ""}}}}})
|
|
reg.Register(&httpmock.Stub{Method: "PUT", URL: "/open-apis/docs_ai/v1/documents/doxcn_toctou", Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"document": map[string]interface{}{"revision_id": 2}}}})
|
|
if err := finalizeLocalDocResources(runtime, "doxcn_toctou", data, resources); err == nil {
|
|
t.Fatal("finalizeLocalDocResources error = nil, want partial failure")
|
|
}
|
|
public, _ := json.Marshal(data)
|
|
public = append(public, stdout.Bytes()...)
|
|
for _, secret := range []string{dir, "vanished.png", resources[0].Marker} {
|
|
if strings.Contains(string(public), secret) {
|
|
t.Fatalf("partial response leaked %q: %s", secret, public)
|
|
}
|
|
}
|
|
var envelope struct {
|
|
Data struct {
|
|
Failures []struct {
|
|
Occurrence int `json:"occurrence"`
|
|
Kind string `json:"kind"`
|
|
Status string `json:"status"`
|
|
Cleanup string `json:"cleanup_status"`
|
|
Error struct {
|
|
Type string `json:"type"`
|
|
Subtype string `json:"subtype"`
|
|
} `json:"error"`
|
|
} `json:"local_resource_failures"`
|
|
} `json:"data"`
|
|
}
|
|
if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil {
|
|
t.Fatalf("decode partial failure: %v", err)
|
|
}
|
|
if len(envelope.Data.Failures) != 1 || envelope.Data.Failures[0].Occurrence != 1 ||
|
|
envelope.Data.Failures[0].Kind != "image" || envelope.Data.Failures[0].Status != "upload_failed" ||
|
|
envelope.Data.Failures[0].Error.Type == "" || envelope.Data.Failures[0].Error.Subtype == "" {
|
|
t.Fatalf("failure details = %#v", envelope.Data.Failures)
|
|
}
|
|
}
|
|
|
|
func TestLocalDocResourceDryRunIncludesRouteExtraPartSizeAndTwentyBatch(t *testing.T) {
|
|
resources := make([]localDocResource, 21)
|
|
for i := range resources {
|
|
resources[i] = localDocResource{Occurrence: i + 1, Kind: localDocResourceImage, Size: common.MaxDriveMediaUploadSinglePartSize + 1}
|
|
}
|
|
dry := appendLocalDocResourcesDryRun(common.NewDryRunAPI(), "doc/with space", resources)
|
|
decoded := decodeDocDryRun(t, dry)
|
|
patches := 0
|
|
for _, api := range decoded.API {
|
|
switch {
|
|
case api.URL == "/open-apis/drive/v1/medias/upload_prepare":
|
|
if !strings.Contains(fmt.Sprint(api.Body["extra"]), "drive_route_token") || api.Body["size"] == nil {
|
|
t.Fatalf("upload_prepare body = %#v", api.Body)
|
|
}
|
|
case api.URL == "/open-apis/drive/v1/medias/upload_part":
|
|
if api.Body["size"] == nil {
|
|
t.Fatalf("upload_part body missing size: %#v", api.Body)
|
|
}
|
|
case strings.Contains(api.URL, "/blocks/batch_update"):
|
|
patches++
|
|
if !strings.Contains(api.URL, "doc%2Fwith%20space") {
|
|
t.Fatalf("batch URL is not encoded: %s", api.URL)
|
|
}
|
|
}
|
|
}
|
|
if patches != 2 {
|
|
t.Fatalf("batch PATCH count = %d, want 2", patches)
|
|
}
|
|
}
|
|
|
|
func TestDocsUpdateLocalResourceWikiDryRunResolvesDocxFirst(t *testing.T) {
|
|
dir := t.TempDir()
|
|
cmdutil.TestChdir(t, dir)
|
|
if err := os.WriteFile("diagram.png", []byte(localDocResourcePNG(t, 2, 1)), 0o600); err != nil {
|
|
t.Fatalf("WriteFile: %v", err)
|
|
}
|
|
runtime := newUpdateShortcutTestRuntime(t, "", map[string]string{
|
|
"doc": "https://example.larksuite.com/wiki/wikcn_local",
|
|
"command": "append",
|
|
"content": `<img path="@diagram.png"/>`,
|
|
})
|
|
dry := decodeDocDryRun(t, dryRunUpdateV2(context.Background(), runtime))
|
|
if len(dry.API) < 2 || dry.API[0].URL != "/open-apis/wiki/v2/spaces/get_node" {
|
|
t.Fatalf("dry-run must resolve wiki first: %#v", dry.API)
|
|
}
|
|
if got := dry.API[1].URL; got != "/open-apis/docs_ai/v1/documents/%3Cresolved_docx_token%3E" {
|
|
t.Fatalf("docs_ai URL = %q", got)
|
|
}
|
|
for _, api := range dry.API[2:] {
|
|
if strings.Contains(api.URL, "wikcn_local") {
|
|
t.Fatalf("post-resolve API still uses wiki node token: %s", api.URL)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestDocsUpdateLocalResourceRejectsLegacyDocBeforeWrite(t *testing.T) {
|
|
dir := t.TempDir()
|
|
cmdutil.TestChdir(t, dir)
|
|
if err := os.WriteFile("diagram.png", []byte(localDocResourcePNG(t, 2, 1)), 0o600); err != nil {
|
|
t.Fatalf("WriteFile: %v", err)
|
|
}
|
|
runtime := newUpdateShortcutTestRuntime(t, "", map[string]string{
|
|
"doc": "https://example.larksuite.com/doc/docc_legacy",
|
|
"command": "append",
|
|
"content": `<img path="@diagram.png"/>`,
|
|
})
|
|
err := validateUpdateV2(context.Background(), runtime)
|
|
problem, ok := errs.ProblemOf(err)
|
|
var validationErr *errs.ValidationError
|
|
if !ok || problem.Subtype != errs.SubtypeInvalidArgument ||
|
|
!errors.As(err, &validationErr) || validationErr.Param != "--doc" {
|
|
t.Fatalf("error=%T %v problem=%#v validation=%#v", err, err, problem, validationErr)
|
|
}
|
|
}
|
|
|
|
func TestDocsUpdateRemoteImageDryRunDownloadsAfterDocumentWrite(t *testing.T) {
|
|
runtime := newUpdateShortcutTestRuntime(t, "", map[string]string{
|
|
"doc": "doxcn_remote_image",
|
|
"command": "append",
|
|
"content": `<img href="https://93.184.216.34/photo.png"/>`,
|
|
})
|
|
dry := decodeDocDryRun(t, dryRunUpdateV2(context.Background(), runtime))
|
|
if len(dry.API) != 6 {
|
|
t.Fatalf("dry-run API calls = %d, want 6: %#v", len(dry.API), dry.API)
|
|
}
|
|
if got := dry.API[0].URL; got != "/open-apis/docs_ai/v1/documents/doxcn_remote_image" {
|
|
t.Fatalf("document update URL = %q", got)
|
|
}
|
|
if got := dry.API[1].URL; got != "https://93.184.216.34/photo.png" {
|
|
t.Fatalf("download URL = %q", got)
|
|
}
|
|
if got := dry.API[2].URL; got != "/open-apis/drive/v1/medias/upload_all" {
|
|
t.Fatalf("upload URL = %q", got)
|
|
}
|
|
preparedContent := fmt.Sprint(dry.API[0].Body["content"])
|
|
if strings.Contains(preparedContent, `href=`) || !strings.Contains(preparedContent, "@lcli_img_") {
|
|
t.Fatalf("prepared content = %q", preparedContent)
|
|
}
|
|
}
|
|
|
|
func TestDocsUpdateV2RejectsBlockedRemoteImageBeforeDocumentWrite(t *testing.T) {
|
|
f, stdout, _, reg := cmdutil.TestFactory(t, docsTestConfigWithAppID("docs-update-blocked-remote-image"))
|
|
updateStub := &httpmock.Stub{
|
|
Method: "PUT",
|
|
URL: "/open-apis/docs_ai/v1/documents/doxcn_blocked_remote_image",
|
|
Optional: true,
|
|
Body: map[string]interface{}{
|
|
"code": 0,
|
|
"msg": "ok",
|
|
"data": map[string]interface{}{},
|
|
},
|
|
}
|
|
reg.Register(updateStub)
|
|
|
|
err := mountAndRunDocs(t, DocsUpdate, []string{
|
|
"+update",
|
|
"--doc", "doxcn_blocked_remote_image",
|
|
"--command", "append",
|
|
"--content", `<p>text that must not be written</p><img href="http://127.0.0.1/image.png"/>`,
|
|
"--as", "bot",
|
|
}, f, stdout)
|
|
assertValidationContract(t, err, errs.SubtypeInvalidArgument, "href")
|
|
if len(updateStub.CapturedBodies) != 0 {
|
|
t.Fatalf("document update was called before remote image validation: %s", updateStub.CapturedBody)
|
|
}
|
|
}
|
|
|
|
func TestLocalDocResourceUpdateCommands(t *testing.T) {
|
|
resources := []localDocResource{{Kind: localDocResourceImage}}
|
|
for _, command := range []string{"str_replace"} {
|
|
if err := validateLocalDocResourceUpdateCommand(command, resources); err == nil {
|
|
t.Fatalf("command %s accepted local resources", command)
|
|
}
|
|
}
|
|
for _, command := range []string{"append", "block_insert_after", "block_replace", "overwrite"} {
|
|
if err := validateLocalDocResourceUpdateCommand(command, resources); err != nil {
|
|
t.Fatalf("command %s rejected: %v", command, err)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestDocsUpdateLocalResourceBlockReplaceDryRun(t *testing.T) {
|
|
dir := t.TempDir()
|
|
cmdutil.TestChdir(t, dir)
|
|
if err := os.WriteFile("replacement.png", []byte(localDocResourcePNG(t, 2, 1)), 0o600); err != nil {
|
|
t.Fatalf("WriteFile: %v", err)
|
|
}
|
|
runtime := newUpdateShortcutTestRuntime(t, "", map[string]string{
|
|
"doc": "doxcn_block_replace",
|
|
"command": "block_replace",
|
|
"block-id": "blk_target",
|
|
"content": `<img path="@replacement.png" caption="replacement"/>`,
|
|
})
|
|
|
|
dry := decodeDocDryRun(t, dryRunUpdateV2(context.Background(), runtime))
|
|
if len(dry.API) != 5 {
|
|
t.Fatalf("dry-run API calls = %d, want 5: %#v", len(dry.API), dry.API)
|
|
}
|
|
update := dry.API[0]
|
|
if got := update.Body["command"]; got != "block_replace" {
|
|
t.Fatalf("update command = %#v, want block_replace", got)
|
|
}
|
|
if got := update.Body["block_id"]; got != "blk_target" {
|
|
t.Fatalf("update block_id = %#v, want blk_target", got)
|
|
}
|
|
preparedContent := fmt.Sprint(update.Body["content"])
|
|
if strings.Contains(preparedContent, "@replacement.png") || !strings.Contains(preparedContent, "@lcli_img_") {
|
|
t.Fatalf("prepared content = %q, want private marker without local path", preparedContent)
|
|
}
|
|
if got := dry.API[1].URL; got != "/open-apis/drive/v1/medias/upload_all" {
|
|
t.Fatalf("upload URL = %q", got)
|
|
}
|
|
if got := dry.API[2].URL; got != "/open-apis/docx/v1/documents/doxcn_block_replace/blocks/batch_update" {
|
|
t.Fatalf("bind URL = %q", got)
|
|
}
|
|
}
|
|
|
|
func requestCountFromLocalDocBatchBody(t *testing.T, raw []byte) int {
|
|
t.Helper()
|
|
var body struct {
|
|
Requests []interface{} `json:"requests"`
|
|
}
|
|
if err := json.Unmarshal(raw, &body); err != nil {
|
|
t.Fatalf("decode batch body: %v; %s", err, raw)
|
|
}
|
|
return len(body.Requests)
|
|
}
|
|
|
|
func newLocalDocResourceTestRuntime(t *testing.T, files map[string]string) *common.RuntimeContext {
|
|
t.Helper()
|
|
dir := t.TempDir()
|
|
cmdutil.TestChdir(t, dir)
|
|
for name, content := range files {
|
|
if err := os.WriteFile(name, []byte(content), 0o600); err != nil {
|
|
t.Fatalf("WriteFile(%s): %v", name, err)
|
|
}
|
|
}
|
|
factory, _, _, _ := cmdutil.TestFactory(t, docsTestConfigWithAppID("local-resource-test"))
|
|
return common.TestNewRuntimeContextForAPI(
|
|
context.Background(),
|
|
&cobra.Command{Use: "docs local-resource-test"},
|
|
docsTestConfigWithAppID("local-resource-test"),
|
|
factory,
|
|
core.AsUser,
|
|
)
|
|
}
|
|
|
|
func localDocResourcePNG(t *testing.T, width, height int) string {
|
|
t.Helper()
|
|
var buf bytes.Buffer
|
|
if err := png.Encode(&buf, image.NewRGBA(image.Rect(0, 0, width, height))); err != nil {
|
|
t.Fatalf("encode %dx%d PNG: %v", width, height, err)
|
|
}
|
|
return buf.String()
|
|
}
|