mirror of
https://github.com/larksuite/cli.git
synced 2026-08-03 08:32:46 +08:00
* feat(apps): add design_html app type support and credential author identity - Add design_html to appTypePolicies (same as modern_html: skip install/env-pull/skills-sync) - Route +html-publish via policy (useTOSPublish) instead of hardcoded type check - Parse commit_author_name/commit_author_email from +git-credential-init response - Use server-provided author identity for repo-local git config, fallback to defaults - Support meta_token as identifier in +get command - Use envvars.AgentName() for source_agent in +create (reads LARKSUITE_CLI_AGENT_NAME) - Add creative HTML guide reference skeleton and SKILL.md routing entry - Update git-credential skill docs with new output fields * fix(apps): unify html-publish to TOS path, add html to init skip policy - Remove useTOSPublish policy field, html-publish always uses TOS upload - Add html type to appTypePolicies (skip install/env-pull/skills-sync) - Remove design_html from policies (not yet in use) - Fix git credential dry-run test for new local_effects entry * feat(apps): validate --app-id format to reject meta_token with resolution hint * feat(apps): integrate creative-design skill and update skill docs - Add creative-design skill under lark-apps/ (same level as references/) - Update SKILL.md description with creative design trigger keywords - Add creative design routing in development path selection table - Add --path relative path guidance in html-publish reference - Remove old creative-html-guide skeleton (replaced by creative-design) * feat(apps): skip app sync for html/modern_html in +init Add skipAppSync policy field; html and modern_html skip npx app sync on non-empty repo path since static HTML sites don't need it. * fix(apps): merge creative-design into html routing and add intent entry - Merge static HTML and creative-design into one path selection row - Add creative-design intent routing entry before html-publish * docs(apps): add html local dev flow, unify publish link source - Add html端到端 flow in local-dev.md (create → init → dev → release-create) - Unify publish link source: html and full_stack both use +release-get - Update SKILL.md routing and publish护栏 accordingly * fix(apps): update html-publish dry-run and skill docs for TOS flow - DryRun shows actual 3-step TOS flow (pre_release → TOS PUT → release-create) - Skill docs: output is release_id, use +release-get to poll for online_url - Remove references to legacy multipart upload and data.url * TEMP: pin miaoda-cli alpha and add BOE header for testing - Pin miaoda-cli to 0.1.24-alpha.fb2cf0a (revert to @latest before merge) - Add x-tt-env=boe_aily_lark_cli header globally (remove before merge) - html app-type uses --template design-html instead of --app-type (remove before merge) * docs(apps): add creative mode link format and meta_token recognition - Add creative mode (html) link format `https://{tenant}/page/{meta_token}` in publish护栏 - Note dev and publish URLs are the same for creative mode, unlike full_stack - Add meta_token to app_id resolution with full link format in app_id获取 * docs(apps): route html apps through local-dev git pipeline by default - Select dev path: html apps now default to local-dev pipeline instead of skipping local/cloud axis - Intent routing: creative-design publishes via local-dev flow instead of +html-publish - Remove +html-publish fallback from local-dev "when not to use" section * docs(apps): generalize skill references to cover both html and full_stack Remove full_stack-only wording from init, create, list, env-pull, and release-create references since html apps now share the same local dev and release flow. * feat(apps): add meta_token to +get pretty output and dry-run description * docs(apps): unify html as creative mode, fix routing and local-dev flow - Remove "HTML" as separate dev path; html and full_stack both go through local-dev - Intent routing: read local-dev before creative-design to establish git pipeline first - Mark +html-publish as legacy, redirect to local-dev for creative mode - Split html local-dev into 3 scenarios: first-time, iteration, pre-generated files - git add . instead of selective add to capture all creative-design output files * docs(apps): remove dev link from html-publish output, only return release-get online_url * fix: add license header to deck-stage.js * docs(apps): clarify dev link only for full_stack, creative mode shares dev/pub URL * docs(apps): remove +html-publish from intent routing, description, and guardrails All HTML apps now go through local-dev pipeline. +html-publish is deprecated. * docs(apps): remove html-publish references from create/release-create/cloud-dev pages html-publish is no longer the recommended path for HTML apps; all html and full_stack apps now follow the same local-dev + release-create flow. * fix(apps): address PR review feedback - html-publish dry-run: register all 3 API calls (GET pre_release, PUT TOS, POST release-create) instead of hiding steps in metadata - validateRealAppID: remove cli_ prefix check (not a valid app_id prefix) - E2E: update git-credential dry-run to expect 4 local_effects - E2E: update html-publish dry-run to expect GET pre_release * fix(apps): address PR review — remove legacy multipart dead code, fix docs - Delete html_publish_client.go and html_publish_client_test.go (legacy multipart) - Remove runHTMLPublish, enrichHTMLPublishAPIError, buildHTMLPublishFailureHint - Migrate tests from runHTMLPublish to prepareHTMLPublishTarball (same coverage) - Remove cli_ prefix from validateRealAppID (not a valid app_id prefix) - Fix html-publish.md error wording to match actual message - Register all 3 TOS API calls in html-publish dry-run - Update E2E tests for new dry-run contract * fix(apps): correctly merge SKILL.md with main (role mgmt, auth wording, source boundary) Rebuild SKILL.md from our branch version, then merge in main's additions: - description: add HTML静态站点发布, 应用角色与成员管理, 应用角色/角色成员 - 身份与授权: use main's updated wording (no proactive re-login) - intent routing: add +role-* row, +init refs 平台资源与应用源码边界 - 能力边界 → 平台资源与应用源码边界 (7 rules from main) - 禁止预授权底线: add role ② and html-publish ③ clauses * docs(apps): route legacy html-publish only for non-git html apps * docs(apps): strengthen local-dev routing and git recovery guidance fix:cherry-pick and resolve conflicts * fix: gofmt apps_errors.go and apps_errors_test.go * docs(apps): strengthen git credential recovery and add file-upload guidance - Generalize git error recovery: any git operation failure triggers +git-credential-init refresh, with environment analysis on failure - Add resource file upload rule: use +file-upload instead of local paths, base64 inlining, or git commits; files are app-scoped * test(apps): strengthen html-publish dry-run assertions for TOS 3-step contract * fix: 文件资源上传 * docs(apps): update creative-design skill content * fix: re-add license header to deck-stage.js * refactor(apps): merge system-prompt.md into SKILL.md for creative-design skill Consolidate the thin SKILL.md wrapper and the full system-prompt.md methodology into a single file, eliminating an unnecessary indirection. Update references in claude.md and codex.md accordingly. * chore: revert TEMP changes — miaoda-cli back to @latest, remove BOE header * docs(apps): remove 可见范围 from 发布态护栏 创意模式的可见范围权限走 lark-drive 文档权限体系,而非妙搭应用 权限体系,当前的 +access-scope-set/get 无法正确管理创意模式应用 的可见范围。待文档协作支持妙搭能力后,再通过 lark-drive 域能力 引导修改。 TODO: 等文档协作支持妙搭能力后,在 skill 中加入使用文档域权限 能力修改创意模式可见范围的引导。 * docs(lark-apps): 在平台资源与应用源码边界添加路径规则,引导 agent 使用相对路径 `apps` 命令的 `--path`、`--file`、`--output` 只接受 cwd 下的相对路径,传绝对路径会报错。 * docs(lark-apps): 新增创意模式评论路由和裸 meta_token 识别引导 - 意图路由表新增创意模式应用评论,引导走 lark-drive 文档评论体系 - app_id 获取章节补充裸 meta_token 识别:非链接非 app_ 开头时尝试用 +get 解析 * refactor(apps): flatten creative-design built-in-skills into references - Delete built-in-skills/ directory (9 nested sub-skill folders) - Move media skill content to references/ as flat .md files - Add assets/index.html React+Babel starter template - Integrate publishing flow into creative-design SKILL.md - Update harness reference docs (aily/claude/codex.md) - Simplify lark-apps SKILL.md routing to point directly to creative-design - Remove creative-design standalone .git directory * refactor(apps): rename creative-design/SKILL.md to creative-design.md Avoid being mistaken as an independent skill entry point. Update all internal references (lark-apps routing table + 10 reference files). * fix(apps): fail closed when queryAppType fails instead of falling back to full_stack queryAppType now returns an error instead of silently returning "". +init aborts if the app type cannot be determined, preventing wrong scaffold type from being committed and pushed to the repository. --------- Co-authored-by: zhangli <zhangli.268@bytedance.com>
735 lines
24 KiB
Go
735 lines
24 KiB
Go
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||
// SPDX-License-Identifier: MIT
|
||
|
||
package apps
|
||
|
||
import (
|
||
"context"
|
||
"net/http"
|
||
"net/http/httptest"
|
||
"os"
|
||
"path/filepath"
|
||
"strings"
|
||
"testing"
|
||
|
||
"github.com/spf13/cobra"
|
||
|
||
"github.com/larksuite/cli/errs"
|
||
"github.com/larksuite/cli/extension/fileio"
|
||
"github.com/larksuite/cli/internal/cmdutil"
|
||
"github.com/larksuite/cli/internal/core"
|
||
"github.com/larksuite/cli/internal/httpmock"
|
||
"github.com/larksuite/cli/shortcuts/common"
|
||
)
|
||
|
||
func writeAppsSampleSite(t *testing.T) string {
|
||
t.Helper()
|
||
dir := t.TempDir()
|
||
if err := os.WriteFile(filepath.Join(dir, "index.html"), []byte("<html></html>"), 0o644); err != nil {
|
||
t.Fatalf("write fixture: %v", err)
|
||
}
|
||
return dir
|
||
}
|
||
|
||
func TestPrepareHTMLPublishTarball_PathNotFound(t *testing.T) {
|
||
_, err := prepareHTMLPublishTarball(newTestFIO(), "/nonexistent")
|
||
if err == nil {
|
||
t.Fatalf("expected error")
|
||
}
|
||
}
|
||
|
||
func TestPrepareHTMLPublishTarball_DirRequiresIndexHTML(t *testing.T) {
|
||
dir := t.TempDir()
|
||
if err := os.WriteFile(filepath.Join(dir, "foo.html"), []byte("<html></html>"), 0o644); err != nil {
|
||
t.Fatalf("write: %v", err)
|
||
}
|
||
_, err := prepareHTMLPublishTarball(newTestFIO(), dir)
|
||
if err == nil {
|
||
t.Fatalf("expected error for missing index.html")
|
||
}
|
||
problem := requireAppsValidationProblem(t, err)
|
||
if !strings.Contains(problem.Message, "index.html") {
|
||
t.Fatalf("message missing 'index.html': %v", problem.Message)
|
||
}
|
||
if problem.Hint == "" {
|
||
t.Fatalf("expected non-empty hint")
|
||
}
|
||
}
|
||
|
||
func TestPrepareHTMLPublishTarball_DirWithIndexHTMLPasses(t *testing.T) {
|
||
dir := t.TempDir()
|
||
if err := os.WriteFile(filepath.Join(dir, "index.html"), []byte("<html></html>"), 0o644); err != nil {
|
||
t.Fatalf("write fixture: %v", err)
|
||
}
|
||
if err := os.WriteFile(filepath.Join(dir, "extra.html"), []byte("<html></html>"), 0o644); err != nil {
|
||
t.Fatalf("write fixture: %v", err)
|
||
}
|
||
tarball, err := prepareHTMLPublishTarball(newTestFIO(), dir)
|
||
if err != nil {
|
||
t.Fatalf("err=%v", err)
|
||
}
|
||
if tarball == nil || tarball.Size == 0 {
|
||
t.Fatalf("expected non-empty tarball")
|
||
}
|
||
}
|
||
|
||
func TestPrepareHTMLPublishTarball_SingleFileRejectedIfNotNamedIndex(t *testing.T) {
|
||
dir := t.TempDir()
|
||
single := filepath.Join(dir, "foo.html")
|
||
if err := os.WriteFile(single, []byte("<html></html>"), 0o644); err != nil {
|
||
t.Fatalf("write fixture: %v", err)
|
||
}
|
||
_, err := prepareHTMLPublishTarball(newTestFIO(), single)
|
||
if err == nil {
|
||
t.Fatalf("single-file path 'foo.html' should be rejected (not named index.html)")
|
||
}
|
||
requireAppsValidationProblem(t, err)
|
||
}
|
||
|
||
func TestPrepareHTMLPublishTarball_SingleFileNamedIndexPasses(t *testing.T) {
|
||
dir := t.TempDir()
|
||
single := filepath.Join(dir, "index.html")
|
||
if err := os.WriteFile(single, []byte("<html></html>"), 0o644); err != nil {
|
||
t.Fatalf("write fixture: %v", err)
|
||
}
|
||
tarball, err := prepareHTMLPublishTarball(newTestFIO(), single)
|
||
if err != nil {
|
||
t.Fatalf("err=%v", err)
|
||
}
|
||
if tarball == nil || tarball.Size == 0 {
|
||
t.Fatalf("expected non-empty tarball")
|
||
}
|
||
}
|
||
|
||
func TestPrepareHTMLPublishTarball_RejectsOversizeTarball(t *testing.T) {
|
||
orig := maxHTMLPublishTarballBytes
|
||
maxHTMLPublishTarballBytes = 100
|
||
defer func() { maxHTMLPublishTarballBytes = orig }()
|
||
|
||
dir := t.TempDir()
|
||
if err := os.WriteFile(filepath.Join(dir, "index.html"), []byte("<html></html>"), 0o644); err != nil {
|
||
t.Fatalf("write: %v", err)
|
||
}
|
||
if err := os.WriteFile(filepath.Join(dir, "big.html"),
|
||
[]byte(strings.Repeat("x", 4096)), 0o644); err != nil {
|
||
t.Fatalf("write: %v", err)
|
||
}
|
||
|
||
_, err := prepareHTMLPublishTarball(newTestFIO(), dir)
|
||
if err == nil {
|
||
t.Fatalf("expected oversize error")
|
||
}
|
||
problem := requireAppsValidationProblem(t, err)
|
||
if !strings.Contains(problem.Message, "exceeds") {
|
||
t.Fatalf("message missing 'exceeds': %v", problem.Message)
|
||
}
|
||
if problem.Hint == "" {
|
||
t.Fatalf("expected non-empty hint")
|
||
}
|
||
}
|
||
|
||
func TestMaxHTMLPublishTarballBytes_Default(t *testing.T) {
|
||
// Pin 20MB 常量值,typo 到 20*1000*1024 之类会被拦截。
|
||
if maxHTMLPublishTarballBytes != 20*1024*1024 {
|
||
t.Fatalf("default = %d, want %d (20MiB)", maxHTMLPublishTarballBytes, 20*1024*1024)
|
||
}
|
||
}
|
||
|
||
func TestAppsHTMLPublish_RequiresAppID(t *testing.T) {
|
||
site := writeAppsSampleSite(t)
|
||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||
err := runAppsShortcut(t, AppsHTMLPublish,
|
||
[]string{"+html-publish", "--path", site}, factory, stdout)
|
||
// cobra Required:true may report flag name without "--" prefix
|
||
if err == nil || !strings.Contains(err.Error(), "app-id") {
|
||
t.Fatalf("expected --app-id required, got %v", err)
|
||
}
|
||
}
|
||
|
||
func TestAppsHTMLPublish_RequiresPath(t *testing.T) {
|
||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||
err := runAppsShortcut(t, AppsHTMLPublish,
|
||
[]string{"+html-publish", "--app-id", "app_x"}, factory, stdout)
|
||
if err == nil || !strings.Contains(err.Error(), "path") {
|
||
t.Fatalf("expected --path required, got %v", err)
|
||
}
|
||
}
|
||
|
||
func TestAppsHTMLPublish_DryRunPrintsManifest(t *testing.T) {
|
||
// 这个用例走真实 shortcut → 真实 LocalFileIO(cwd-bounded)。
|
||
// 必须 chdir 进 tmp 用相对路径,否则 SafeInputPath 会拒绝绝对 --path。
|
||
dir := t.TempDir()
|
||
cwd, err := os.Getwd()
|
||
if err != nil {
|
||
t.Fatalf("getwd: %v", err)
|
||
}
|
||
if err := os.Chdir(dir); err != nil {
|
||
t.Fatalf("chdir: %v", err)
|
||
}
|
||
t.Cleanup(func() { _ = os.Chdir(cwd) })
|
||
if err := os.MkdirAll(filepath.Join(dir, "dist"), 0o755); err != nil {
|
||
t.Fatalf("mkdir dist: %v", err)
|
||
}
|
||
if err := os.WriteFile(filepath.Join(dir, "dist", "index.html"), []byte("<html></html>"), 0o644); err != nil {
|
||
t.Fatalf("write: %v", err)
|
||
}
|
||
|
||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||
if err := runAppsShortcut(t, AppsHTMLPublish,
|
||
[]string{"+html-publish", "--app-id", "app_x", "--path", "./dist", "--dry-run", "--as", "user"},
|
||
factory, stdout); err != nil {
|
||
t.Fatalf("dry-run err=%v", err)
|
||
}
|
||
got := stdout.String()
|
||
if !strings.Contains(got, "/open-apis/spark/v1/apps/app_x/pre_release") {
|
||
t.Fatalf("dry-run missing pre_release endpoint: %s", got)
|
||
}
|
||
if !strings.Contains(got, "presigned_upload_url") {
|
||
t.Fatalf("dry-run missing TOS PUT step: %s", got)
|
||
}
|
||
if !strings.Contains(got, "/open-apis/spark/v1/apps/app_x/releases") {
|
||
t.Fatalf("dry-run missing release-create endpoint: %s", got)
|
||
}
|
||
if !strings.Contains(got, "tos_path") {
|
||
t.Fatalf("dry-run missing tos_path in release-create body: %s", got)
|
||
}
|
||
if !strings.Contains(got, "index.html") {
|
||
t.Fatalf("dry-run missing file list: %s", got)
|
||
}
|
||
}
|
||
|
||
// TestAppsHTMLPublish_CleanCwdIsAllowed pins the post-PR behavior change:
|
||
// --path "." is no longer hard-rejected by Validate. A clean cwd (no
|
||
// credential files) is a valid publish target.
|
||
func TestAppsHTMLPublish_CleanCwdIsAllowed(t *testing.T) {
|
||
dir := t.TempDir()
|
||
cwd, err := os.Getwd()
|
||
if err != nil {
|
||
t.Fatalf("getwd: %v", err)
|
||
}
|
||
if err := os.Chdir(dir); err != nil {
|
||
t.Fatalf("chdir: %v", err)
|
||
}
|
||
t.Cleanup(func() { _ = os.Chdir(cwd) })
|
||
if err := os.WriteFile(filepath.Join(dir, "index.html"), []byte("<html></html>"), 0o644); err != nil {
|
||
t.Fatalf("write: %v", err)
|
||
}
|
||
|
||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||
if err := runAppsShortcut(t, AppsHTMLPublish,
|
||
[]string{"+html-publish", "--app-id", "app_x", "--path", ".", "--dry-run", "--as", "user"},
|
||
factory, stdout); err != nil {
|
||
t.Fatalf("dry-run with --path . should pass when cwd is clean, got err=%v", err)
|
||
}
|
||
}
|
||
|
||
// TestAppsHTMLPublish_SensitiveBlocksValidate pins the new behavior: a credential
|
||
// file under --path causes Validate to reject before either DryRun or Execute
|
||
// runs, so dry-run also returns non-zero (unlike the previous advisory-warning
|
||
// model).
|
||
func TestAppsHTMLPublish_SensitiveBlocksValidate(t *testing.T) {
|
||
dir := t.TempDir()
|
||
cwd, err := os.Getwd()
|
||
if err != nil {
|
||
t.Fatalf("getwd: %v", err)
|
||
}
|
||
if err := os.Chdir(dir); err != nil {
|
||
t.Fatalf("chdir: %v", err)
|
||
}
|
||
t.Cleanup(func() { _ = os.Chdir(cwd) })
|
||
if err := os.MkdirAll(filepath.Join(dir, "dist"), 0o755); err != nil {
|
||
t.Fatalf("mkdir: %v", err)
|
||
}
|
||
if err := os.WriteFile(filepath.Join(dir, "dist", "index.html"), []byte("<html></html>"), 0o644); err != nil {
|
||
t.Fatalf("write: %v", err)
|
||
}
|
||
if err := os.WriteFile(filepath.Join(dir, "dist", ".env"), []byte("API_KEY=secret"), 0o644); err != nil {
|
||
t.Fatalf("write .env: %v", err)
|
||
}
|
||
|
||
// Dry-run path: must also fail (this is the whole point of moving the
|
||
// check into Validate — dry-run can no longer say "OK" when Execute would
|
||
// reject).
|
||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||
err = runAppsShortcut(t, AppsHTMLPublish,
|
||
[]string{"+html-publish", "--app-id", "app_x", "--path", "./dist", "--dry-run", "--as", "user"},
|
||
factory, stdout)
|
||
if err == nil {
|
||
t.Fatalf("dry-run with sensitive file should fail")
|
||
}
|
||
problem := requireAppsValidationProblem(t, err)
|
||
if !strings.Contains(problem.Message, ".env") {
|
||
t.Fatalf("error message should list the offending file, got %q", problem.Message)
|
||
}
|
||
if !strings.Contains(problem.Hint, "--allow-sensitive") {
|
||
t.Fatalf("error hint should mention --allow-sensitive escape hatch, got %q", problem.Hint)
|
||
}
|
||
}
|
||
|
||
// TestAppsHTMLPublish_AllowSensitiveOverride pins that --allow-sensitive
|
||
// bypasses the credential-file check (legitimate cases like a docs site
|
||
// shipping an example .env on purpose).
|
||
func TestAppsHTMLPublish_AllowSensitiveOverride(t *testing.T) {
|
||
dir := t.TempDir()
|
||
cwd, err := os.Getwd()
|
||
if err != nil {
|
||
t.Fatalf("getwd: %v", err)
|
||
}
|
||
if err := os.Chdir(dir); err != nil {
|
||
t.Fatalf("chdir: %v", err)
|
||
}
|
||
t.Cleanup(func() { _ = os.Chdir(cwd) })
|
||
if err := os.MkdirAll(filepath.Join(dir, "dist"), 0o755); err != nil {
|
||
t.Fatalf("mkdir: %v", err)
|
||
}
|
||
if err := os.WriteFile(filepath.Join(dir, "dist", "index.html"), []byte("<html></html>"), 0o644); err != nil {
|
||
t.Fatalf("write: %v", err)
|
||
}
|
||
if err := os.WriteFile(filepath.Join(dir, "dist", ".env.example"), []byte("API_KEY=replace-me"), 0o644); err != nil {
|
||
t.Fatalf("write .env.example: %v", err)
|
||
}
|
||
|
||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||
if err := runAppsShortcut(t, AppsHTMLPublish,
|
||
[]string{"+html-publish", "--app-id", "app_x", "--path", "./dist", "--dry-run", "--allow-sensitive", "--as", "user"},
|
||
factory, stdout); err != nil {
|
||
t.Fatalf("--allow-sensitive should bypass the credential scan, got err=%v", err)
|
||
}
|
||
got := stdout.String()
|
||
// Dry-run output surfaces the waived list so the caller still sees what
|
||
// was let through.
|
||
if !strings.Contains(got, "sensitive_waived") {
|
||
t.Fatalf("dry-run output should record the waived credential file under --allow-sensitive, got: %s", got)
|
||
}
|
||
if !strings.Contains(got, ".env.example") {
|
||
t.Fatalf("waived list should name the file, got: %s", got)
|
||
}
|
||
}
|
||
|
||
// TestAppsHTMLPublish_SensitiveBlocksWhenPathIsCredentialParentDir pins that
|
||
// the credential-file scan still rejects when --path itself is the
|
||
// conventional parent dir (e.g. ./.aws, ./.docker, ./.kube). Without joining
|
||
// the candidate back to its absolute path, walker would strip the parent
|
||
// segment via filepath.Rel and the cloud-SDK matchers — which anchor on
|
||
// parent/file pairs — would silently pass.
|
||
func TestAppsHTMLPublish_SensitiveBlocksWhenPathIsCredentialParentDir(t *testing.T) {
|
||
cases := []struct {
|
||
name string
|
||
parent string
|
||
fileName string
|
||
wantSubstr string
|
||
}{
|
||
{"aws_credentials", ".aws", "credentials", "credentials"},
|
||
{"docker_config_json", ".docker", "config.json", "config.json"},
|
||
{"kube_config", ".kube", "config", "config"},
|
||
}
|
||
for _, tc := range cases {
|
||
t.Run(tc.name, func(t *testing.T) {
|
||
dir := t.TempDir()
|
||
cwd, err := os.Getwd()
|
||
if err != nil {
|
||
t.Fatalf("getwd: %v", err)
|
||
}
|
||
if err := os.Chdir(dir); err != nil {
|
||
t.Fatalf("chdir: %v", err)
|
||
}
|
||
t.Cleanup(func() { _ = os.Chdir(cwd) })
|
||
root := filepath.Join(dir, tc.parent)
|
||
if err := os.MkdirAll(root, 0o755); err != nil {
|
||
t.Fatalf("mkdir: %v", err)
|
||
}
|
||
if err := os.WriteFile(filepath.Join(root, tc.fileName), []byte("fake credential"), 0o600); err != nil {
|
||
t.Fatalf("write: %v", err)
|
||
}
|
||
if err := os.WriteFile(filepath.Join(root, "index.html"), []byte("<html></html>"), 0o644); err != nil {
|
||
t.Fatalf("write index: %v", err)
|
||
}
|
||
|
||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||
err = runAppsShortcut(t, AppsHTMLPublish,
|
||
[]string{"+html-publish", "--app-id", "app_x", "--path", "./" + tc.parent, "--dry-run", "--as", "user"},
|
||
factory, stdout)
|
||
if err == nil {
|
||
t.Fatalf("expected rejection when --path is %s/ (would leak %s), got success", tc.parent, tc.fileName)
|
||
}
|
||
problem := requireAppsValidationProblem(t, err)
|
||
if !strings.Contains(problem.Message, tc.wantSubstr) {
|
||
t.Fatalf("error message should name the leaked file, got %q", problem.Message)
|
||
}
|
||
})
|
||
}
|
||
}
|
||
|
||
// TestAppsHTMLPublish_SensitiveBlocksWhenPathIsCredentialFileItself pins the
|
||
// single-file form: --path pointing directly at a credential file (e.g.
|
||
// ./.aws/credentials) must also reject. Walker's single-file branch sets
|
||
// RelPath = filepath.Base(rootPath), so the .aws segment is lost the same way.
|
||
func TestAppsHTMLPublish_SensitiveBlocksWhenPathIsCredentialFileItself(t *testing.T) {
|
||
dir := t.TempDir()
|
||
cwd, err := os.Getwd()
|
||
if err != nil {
|
||
t.Fatalf("getwd: %v", err)
|
||
}
|
||
if err := os.Chdir(dir); err != nil {
|
||
t.Fatalf("chdir: %v", err)
|
||
}
|
||
t.Cleanup(func() { _ = os.Chdir(cwd) })
|
||
if err := os.MkdirAll(filepath.Join(dir, ".aws"), 0o755); err != nil {
|
||
t.Fatalf("mkdir: %v", err)
|
||
}
|
||
if err := os.WriteFile(filepath.Join(dir, ".aws", "credentials"), []byte("fake credential"), 0o600); err != nil {
|
||
t.Fatalf("write: %v", err)
|
||
}
|
||
|
||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||
err = runAppsShortcut(t, AppsHTMLPublish,
|
||
[]string{"+html-publish", "--app-id", "app_x", "--path", "./.aws/credentials", "--dry-run", "--as", "user"},
|
||
factory, stdout)
|
||
if err == nil {
|
||
t.Fatalf("expected rejection when --path points directly at .aws/credentials, got success")
|
||
}
|
||
problem := requireAppsValidationProblem(t, err)
|
||
if !strings.Contains(problem.Message, "credentials") {
|
||
t.Fatalf("error message should name the leaked file, got %q", problem.Message)
|
||
}
|
||
}
|
||
|
||
// TestSensitiveCandidatesError_Truncation pins the inline-list truncation so a
|
||
// payload with many credential files (e.g. an accidentally-copied tree of
|
||
// per-stage .env.* files) produces a readable, length-bounded error.
|
||
func TestSensitiveCandidatesError_Truncation(t *testing.T) {
|
||
hits := []string{"a.env", "b.env", "c.env", "d.env", "e.env", "f.env", "g.env"}
|
||
err := sensitiveCandidatesError(hits)
|
||
msg := requireAppsValidationProblem(t, err).Message
|
||
if !strings.Contains(msg, "7 credential file(s)") {
|
||
t.Fatalf("message should report the full count, got %q", msg)
|
||
}
|
||
if !strings.Contains(msg, "and 2 more") {
|
||
t.Fatalf("message should truncate beyond %d entries, got %q", maxSensitiveListInError, msg)
|
||
}
|
||
// Pin: the truncated tail is NOT spelled out.
|
||
if strings.Contains(msg, "g.env") {
|
||
t.Fatalf("message should not list entries past the truncation, got %q", msg)
|
||
}
|
||
}
|
||
|
||
func TestRunHTMLPublish_RejectsOversizeRawCandidates(t *testing.T) {
|
||
orig := maxHTMLPublishRawBytes
|
||
maxHTMLPublishRawBytes = 100
|
||
defer func() { maxHTMLPublishRawBytes = orig }()
|
||
|
||
dir := t.TempDir()
|
||
if err := os.WriteFile(filepath.Join(dir, "index.html"), []byte("<html></html>"), 0o644); err != nil {
|
||
t.Fatalf("write: %v", err)
|
||
}
|
||
if err := os.WriteFile(filepath.Join(dir, "big.html"), []byte(strings.Repeat("x", 4096)), 0o644); err != nil {
|
||
t.Fatalf("write: %v", err)
|
||
}
|
||
|
||
_, err := prepareHTMLPublishTarball(newTestFIO(), dir)
|
||
if err == nil {
|
||
t.Fatalf("expected raw-size cap to fire")
|
||
}
|
||
problem := requireAppsValidationProblem(t, err)
|
||
if !strings.Contains(problem.Message, "raw") || !strings.Contains(problem.Message, "bytes") {
|
||
t.Fatalf("expected message to explain raw-byte cap, got %q", problem.Message)
|
||
}
|
||
}
|
||
|
||
func TestOversizeHTMLFiles(t *testing.T) {
|
||
orig := maxHTMLPublishSingleHTMLFileBytes
|
||
maxHTMLPublishSingleHTMLFileBytes = 100
|
||
defer func() { maxHTMLPublishSingleHTMLFileBytes = orig }()
|
||
|
||
cands := []htmlPublishCandidate{
|
||
{RelPath: "index.html", Size: 50},
|
||
{RelPath: "big.html", Size: 4096},
|
||
{RelPath: "BIG.HTML", Size: 4096}, // 大小写不敏感
|
||
{RelPath: "huge.png", Size: 9000}, // 非 .html,忽略
|
||
}
|
||
hits := oversizeHTMLFiles(cands)
|
||
if len(hits) != 2 {
|
||
t.Fatalf("hits=%v, want [big.html BIG.HTML]", hits)
|
||
}
|
||
for _, h := range hits {
|
||
if h == "huge.png" || h == "index.html" {
|
||
t.Fatalf("unexpected hit %q", h)
|
||
}
|
||
}
|
||
}
|
||
|
||
func TestMaxHTMLPublishSingleHTMLFileBytes_Default(t *testing.T) {
|
||
if maxHTMLPublishSingleHTMLFileBytes != 10*1024*1024 {
|
||
t.Fatalf("default=%d, want %d (10MiB)", maxHTMLPublishSingleHTMLFileBytes, 10*1024*1024)
|
||
}
|
||
}
|
||
|
||
func TestRunHTMLPublish_RejectsOversizeHTMLFile(t *testing.T) {
|
||
orig := maxHTMLPublishSingleHTMLFileBytes
|
||
maxHTMLPublishSingleHTMLFileBytes = 100
|
||
defer func() { maxHTMLPublishSingleHTMLFileBytes = orig }()
|
||
|
||
dir := t.TempDir()
|
||
if err := os.WriteFile(filepath.Join(dir, "index.html"), []byte("<html></html>"), 0o644); err != nil {
|
||
t.Fatalf("write: %v", err)
|
||
}
|
||
if err := os.WriteFile(filepath.Join(dir, "big.html"), []byte(strings.Repeat("x", 4096)), 0o644); err != nil {
|
||
t.Fatalf("write: %v", err)
|
||
}
|
||
_, err := prepareHTMLPublishTarball(newTestFIO(), dir)
|
||
if err == nil {
|
||
t.Fatalf("expected per-file oversize error")
|
||
}
|
||
problem := requireAppsValidationProblem(t, err)
|
||
if !strings.Contains(problem.Message, "big.html") || !strings.Contains(problem.Message, "10MB") {
|
||
t.Fatalf("message=%q, want contains 'big.html' and '10MB'", problem.Message)
|
||
}
|
||
if problem.Hint == "" {
|
||
t.Fatalf("expected non-empty hint")
|
||
}
|
||
}
|
||
|
||
func TestPrepareHTMLPublishTarball_IgnoresOversizeNonHTML(t *testing.T) {
|
||
orig := maxHTMLPublishSingleHTMLFileBytes
|
||
maxHTMLPublishSingleHTMLFileBytes = 100
|
||
defer func() { maxHTMLPublishSingleHTMLFileBytes = orig }()
|
||
|
||
dir := t.TempDir()
|
||
if err := os.WriteFile(filepath.Join(dir, "index.html"), []byte("<html></html>"), 0o644); err != nil {
|
||
t.Fatalf("write: %v", err)
|
||
}
|
||
if err := os.WriteFile(filepath.Join(dir, "big.png"), []byte(strings.Repeat("x", 4096)), 0o644); err != nil {
|
||
t.Fatalf("write: %v", err)
|
||
}
|
||
tarball, err := prepareHTMLPublishTarball(newTestFIO(), dir)
|
||
if err != nil {
|
||
t.Fatalf("non-html oversize must not be blocked by the .html cap: %v", err)
|
||
}
|
||
if tarball == nil || tarball.Size == 0 {
|
||
t.Fatalf("expected non-empty tarball")
|
||
}
|
||
}
|
||
|
||
// ── runHTMLPublishTOS tests ──
|
||
|
||
// permissiveFIOProvider wraps permissiveFIO as a fileio.Provider for tests
|
||
// that call runHTMLPublishTOS (which obtains FileIO via rctx.FileIO()).
|
||
type permissiveFIOProvider struct{}
|
||
|
||
func (permissiveFIOProvider) Name() string { return "test-permissive" }
|
||
func (permissiveFIOProvider) ResolveFileIO(context.Context) fileio.FileIO { return permissiveFIO{} }
|
||
|
||
// newTOSTestRuntime builds a RuntimeContext with httpmock registry and a
|
||
// permissive FileIO provider, ready for runHTMLPublishTOS unit tests.
|
||
func newTOSTestRuntime(t *testing.T) (*common.RuntimeContext, *httpmock.Registry) {
|
||
t.Helper()
|
||
t.Setenv("HOME", t.TempDir())
|
||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||
cfg := &core.CliConfig{
|
||
AppID: "test-app-" + strings.ToLower(t.Name()),
|
||
AppSecret: "test-secret",
|
||
Brand: core.BrandFeishu,
|
||
UserOpenId: "ou_test",
|
||
}
|
||
factory, _, _, reg := cmdutil.TestFactory(t, cfg)
|
||
factory.FileIOProvider = permissiveFIOProvider{}
|
||
rt := common.TestNewRuntimeContextForAPI(
|
||
context.Background(),
|
||
&cobra.Command{Use: "+tos-test"},
|
||
cfg, factory, core.AsUser,
|
||
)
|
||
return rt, reg
|
||
}
|
||
|
||
func TestRunHTMLPublishTOS_Success(t *testing.T) {
|
||
site := writeAppsSampleSite(t)
|
||
rt, reg := newTOSTestRuntime(t)
|
||
|
||
// Start httptest server to accept the TOS upload.
|
||
tosServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||
if r.Method != http.MethodPut {
|
||
t.Errorf("TOS upload method = %s, want PUT", r.Method)
|
||
}
|
||
if ct := r.Header.Get("Content-Type"); ct != "application/gzip" {
|
||
t.Errorf("TOS upload Content-Type = %s, want application/gzip", ct)
|
||
}
|
||
w.WriteHeader(http.StatusOK)
|
||
}))
|
||
defer tosServer.Close()
|
||
|
||
// Register pre_release API stub.
|
||
reg.Register(&httpmock.Stub{
|
||
Method: "GET",
|
||
URL: "/open-apis/spark/v1/apps/app_tos/pre_release",
|
||
Body: map[string]interface{}{
|
||
"code": float64(0),
|
||
"data": map[string]interface{}{
|
||
"kvs": []interface{}{
|
||
map[string]interface{}{"key": "upload_url", "value": tosServer.URL},
|
||
map[string]interface{}{"key": "tos_path", "value": "tos://bucket/key"},
|
||
},
|
||
},
|
||
},
|
||
})
|
||
|
||
// Register release-create API stub.
|
||
reg.Register(&httpmock.Stub{
|
||
Method: "POST",
|
||
URL: "/open-apis/spark/v1/apps/app_tos/releases",
|
||
Body: map[string]interface{}{
|
||
"code": float64(0),
|
||
"data": map[string]interface{}{
|
||
"release_id": "rel_123",
|
||
"status": "publishing",
|
||
},
|
||
},
|
||
})
|
||
|
||
out, err := runHTMLPublishTOS(context.Background(), rt, appsHTMLPublishSpec{
|
||
AppID: "app_tos",
|
||
Path: site,
|
||
})
|
||
if err != nil {
|
||
t.Fatalf("err=%v", err)
|
||
}
|
||
if out["release_id"] != "rel_123" {
|
||
t.Fatalf("release_id=%v, want rel_123", out["release_id"])
|
||
}
|
||
}
|
||
|
||
func TestRunHTMLPublishTOS_MissingIndexHTML(t *testing.T) {
|
||
dir := t.TempDir()
|
||
// Create a file that is NOT named index.html.
|
||
if err := os.WriteFile(filepath.Join(dir, "foo.html"), []byte("<html></html>"), 0o644); err != nil {
|
||
t.Fatalf("write: %v", err)
|
||
}
|
||
|
||
rt, _ := newTOSTestRuntime(t)
|
||
_, err := runHTMLPublishTOS(context.Background(), rt, appsHTMLPublishSpec{
|
||
AppID: "app_tos",
|
||
Path: dir,
|
||
})
|
||
if err == nil {
|
||
t.Fatalf("expected error for missing index.html")
|
||
}
|
||
if !strings.Contains(err.Error(), "index.html") {
|
||
t.Fatalf("error should mention index.html, got: %v", err)
|
||
}
|
||
}
|
||
|
||
func TestRunHTMLPublishTOS_PreReleaseError(t *testing.T) {
|
||
site := writeAppsSampleSite(t)
|
||
rt, reg := newTOSTestRuntime(t)
|
||
|
||
// Register pre_release API stub that returns an error code.
|
||
reg.Register(&httpmock.Stub{
|
||
Method: "GET",
|
||
URL: "/open-apis/spark/v1/apps/app_tos/pre_release",
|
||
Body: map[string]interface{}{
|
||
"code": float64(99999),
|
||
"msg": "internal server error",
|
||
},
|
||
})
|
||
|
||
_, err := runHTMLPublishTOS(context.Background(), rt, appsHTMLPublishSpec{
|
||
AppID: "app_tos",
|
||
Path: site,
|
||
})
|
||
if err == nil {
|
||
t.Fatalf("expected error from pre_release API failure")
|
||
}
|
||
}
|
||
|
||
func TestRunHTMLPublishTOS_MissingParams(t *testing.T) {
|
||
site := writeAppsSampleSite(t)
|
||
rt, reg := newTOSTestRuntime(t)
|
||
|
||
// Register pre_release API stub that returns empty kvs list.
|
||
reg.Register(&httpmock.Stub{
|
||
Method: "GET",
|
||
URL: "/open-apis/spark/v1/apps/app_tos/pre_release",
|
||
Body: map[string]interface{}{
|
||
"code": float64(0),
|
||
"data": map[string]interface{}{
|
||
"kvs": []interface{}{},
|
||
},
|
||
},
|
||
})
|
||
|
||
_, err := runHTMLPublishTOS(context.Background(), rt, appsHTMLPublishSpec{
|
||
AppID: "app_tos",
|
||
Path: site,
|
||
})
|
||
if err == nil {
|
||
t.Fatalf("expected error for empty kvs")
|
||
}
|
||
problem := requireAppsProblem(t, err, errs.CategoryInternal)
|
||
if !strings.Contains(problem.Message, "no kvs") {
|
||
t.Fatalf("error should mention 'no kvs', got: %q", problem.Message)
|
||
}
|
||
}
|
||
|
||
func TestRunHTMLPublishTOS_MissingParamsObject(t *testing.T) {
|
||
site := writeAppsSampleSite(t)
|
||
rt, reg := newTOSTestRuntime(t)
|
||
|
||
// Register pre_release API stub that returns no kvs key at all.
|
||
reg.Register(&httpmock.Stub{
|
||
Method: "GET",
|
||
URL: "/open-apis/spark/v1/apps/app_tos/pre_release",
|
||
Body: map[string]interface{}{
|
||
"code": float64(0),
|
||
"data": map[string]interface{}{},
|
||
},
|
||
})
|
||
|
||
_, err := runHTMLPublishTOS(context.Background(), rt, appsHTMLPublishSpec{
|
||
AppID: "app_tos",
|
||
Path: site,
|
||
})
|
||
if err == nil {
|
||
t.Fatalf("expected error for missing kvs")
|
||
}
|
||
problem := requireAppsProblem(t, err, errs.CategoryInternal)
|
||
if !strings.Contains(problem.Message, "no kvs") {
|
||
t.Fatalf("error should mention 'no kvs', got: %q", problem.Message)
|
||
}
|
||
}
|
||
|
||
func TestRunHTMLPublishTOS_UploadFails(t *testing.T) {
|
||
site := writeAppsSampleSite(t)
|
||
rt, reg := newTOSTestRuntime(t)
|
||
|
||
// Start httptest server that returns 500.
|
||
tosServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||
w.WriteHeader(http.StatusInternalServerError)
|
||
}))
|
||
defer tosServer.Close()
|
||
|
||
reg.Register(&httpmock.Stub{
|
||
Method: "GET",
|
||
URL: "/open-apis/spark/v1/apps/app_tos/pre_release",
|
||
Body: map[string]interface{}{
|
||
"code": float64(0),
|
||
"data": map[string]interface{}{
|
||
"kvs": []interface{}{
|
||
map[string]interface{}{"key": "upload_url", "value": tosServer.URL},
|
||
map[string]interface{}{"key": "tos_path", "value": "tos://bucket/key"},
|
||
},
|
||
},
|
||
},
|
||
})
|
||
|
||
_, err := runHTMLPublishTOS(context.Background(), rt, appsHTMLPublishSpec{
|
||
AppID: "app_tos",
|
||
Path: site,
|
||
})
|
||
if err == nil {
|
||
t.Fatalf("expected error from TOS upload failure")
|
||
}
|
||
problem := requireAppsProblem(t, err, errs.CategoryNetwork)
|
||
if !strings.Contains(problem.Message, "500") {
|
||
t.Fatalf("error should mention HTTP 500, got: %q", problem.Message)
|
||
}
|
||
}
|