Files
larksuite-cli/shortcuts/apps/gitcred/gitcred_test.go
raistlin042 2c703f2fce feat: apps support multi dev modes (#1175)
* feat: add fullstack app-type and --message to apps +create (#1)

* feat: accept fullstack app-type and require --message for it

* feat: inject message into fullstack create request body

* refactor: align fullstack message injection with existing body-build style

* docs: document fullstack app-type and --message for apps +create

* docs: keep scene numbering consistent in lark-apps-create reference

* docs: add HTML/fullstack intent routing to lark-apps SKILL.md

* docs: cover fullstack in lark-apps skill description and clarify HTML flow step

* test: assert fullstack in allow-list error and reject wrong-cased fullstack

* feat: drop --message from apps +create (#4)

* feat: drop --message from apps +create

* docs: drop --message and document agent-generated name/description for apps +create

* feat: add apps local key-value file storage (#5)

* feat: add Miaoda app git credential support (#9)

* fix: remove APIError detail field dependency

* docs(apps): expand lark-apps skill for local-dev & cloud-chat workflows (#3)

Reframe lark-apps from an HTML-publish skill into a full Miaoda app dev
tool covering three paths: local fullstack dev, HTML hosting, and cloud
session dev. Builds on the fullstack create change already on this branch.

- SKILL.md: 3-path routing table; mental models (code via native git,
  develop/main branch model, DB via +db-* through Miaoda, env auto-pulled
  by `npm dev run`, auto-managed credentials); command index for the new
  verbs; ambiguous-input fallback (infer app type from need, ask local vs
  cloud instead of assuming; default HTML when no signal)
- add local-dev and cloud-dev playbooks
- create: keep HTML/fullstack + required --message; add local/cloud scene
  routing and --enable-multi-env-db
- list: usable by agents with --filter; app_id resolution order
  (user-provided / .spark/meta.json / +list --filter)

Co-authored-by: wangjiangwen-gif <286006750+wangjiangwen-gif@users.noreply.github.com>
Co-authored-by: raistlin042 <lvxinsheng@bytedance.com>

* feat(apps): add 4 db CLI commands (table-list / table-schema / sql / dev-init)

妙搭 data CLI 4 条命令,复用存量 OpenAPI URL + 1 个新增 dev-init:
- +db-table-list  → GET /apps/{id}/tables(游标分页,AppTable 含预估行数/占用空间)
- +db-table-schema → GET /apps/{id}/tables/{name}(默认结构化 schema;--format pretty 出建表 DDL)
- +db-sql         → POST /apps/{id}/sql_commands(?transactional=false DBA 模式)
- +db-dev-init    → POST /apps/{id}/db_dev_init(单库→online/dev,不可逆,high-risk-write)

要点:
- sql result 兼容两种 wire 形态(结构化 [{sql_type,data,record_count}] 与 legacy ["rows-json"])
- 多语句失败:server 返 code:0 + ERROR 哨兵,CLI 升级成 typed api_error(exit 非 0),
  detail 带 statement_index/completed/rolled_back,防止 agent 误判 ok:true 假成功
- pretty 渲染对齐 miaoda:列间两空格、CJK 双宽、size 友好格式(KB/MB/GB)
- 单测 + e2e dry-run 全覆盖;BOE 真机 e2e 验证通过(25 PASS)
- SKILL.md 注册 4 条命令 + 4 篇 reference

注:内含的 BOE 联调专用 env 覆盖(LARK_CLI_OPEN_API_BASE / LARK_CLI_X_TT_ENV,
internal/cmdutil + internal/envvars)未包含在本次提交,仅本地联调用。

Change-Id: I0fe4458086708a93941e2dee852fa6a10b53bd4a

* docs(lark-apps): db 能力补进 SKILL.md description 的 WHEN 段

按 skill 质量规范(description 三段式 WHAT+WHEN+NOT,加载前唯一可见信息),
原 WHEN 仅"连数据库调试"含糊覆盖 db。补成「查看或操作应用数据库(看表结构 /
跑 SQL / 初始化 dev 环境)」,让 +db-table-schema / +db-sql / +db-dev-init
类查询能精确触发,净增 ~12 字无膨胀。

Change-Id: Id52819fa7d6b8ed0c1f174bf5946d55da7b893d7

* Feat/apps env pull (#11)

* feat: add apps env-pull shortcut

* fix: support array env_vars response in apps env-pull

* fix(apps): improve env-pull merge and expiry output

* feat: add keyword/scope/app-type query to apps +list and unhide it (#8)

* feat: switch apps +create --app-type enum to lowercase html/full_stack

* feat: add keyword/scope/app-type query to apps +list and unhide it

* docs: document apps +list query params and lowercase app_type enum

* test: update apps cli_e2e dry-run tests for lowercase app_type and +list filters

* docs: trim redundant app_type case-sensitivity note in create skill

* docs: single-source apps +list usage contract to SKILL.md

* feat: add apps publish shortcuts (publish/status/history/error-log) (#12)

* feat: add apps publish shared guard and NodeStatus mapping

* test: cover json.Number path in injectStatusName

* feat: add apps +publish shortcut

Implements the `apps +publish` command with dry-run preview (upstream
PSM path shown) and an Execute gated by ensurePublishWired() per the
not-yet-deployed OpenAPI gateway constraint (publishAPIWired=false).

* refactor: make apps publish path placeholders var to satisfy go vet

Declare the four publishXxxPath constants as var instead of const so
go vet's printf analyzer skips them while they are empty placeholders.
Revert the Execute path-build in apps_publish.go from strings.Replace
back to fmt.Sprintf (now safe because the format string is a var).

* feat: add apps +publish-history shortcut

* feat: add apps +publish-status shortcut

* feat: add apps +publish-error-log shortcut

* feat: register apps publish shortcuts

Add AppsPublish, AppsPublishHistory, AppsPublishStatus, AppsPublishErrorLog
to Shortcuts() and update count test from 6 → 10.

* docs: add skill references for apps publish shortcuts

* docs: surface apps publish shortcuts in lark-apps SKILL.md

* docs: clarify publish instance id is not an approval instance

* docs: nudge agent to run apps +publish --dry-run for release requests

* feat: update apps publish shortcuts to v1.0.381 release protocol

Rename concept instance→release across all 4 publish shortcuts and their
tests: NodeStatus→ReleaseStatus enum, --instance-id→--release-id flag,
pipelineTaskID→releaseID response field, errorJobs→errorLogs, and
upstream HTTP path consts→RPC method name consts (PSM lark.apaas.devops
v1.0.381). Dry-run now shows psm+rpc_method instead of an HTTP path.

* docs: update apps publish skill docs to v1.0.381 release protocol

* fix: soften apps publish unavailable hint to user-facing language

* feat: update apps publish to v1.0.385 string status + --status filter

- Remove obsolete int-enum machinery (releaseStatusName/toInt/injectStatusName)
  and their encoding/json + fmt imports from apps_publish_common.go
- +publish Execute now returns status string alongside release_id
- +publish-history gains --status Enum flag (publishing/finished/failed);
  buildHistoryBody gains status param, table column status_name→status
- +publish-status Execute drops injectStatusName, pretty prints out["status"]
- +publish-error-log shapeErrorLog is string passthrough (no status_name)
- Unit tests updated: delete 3 obsolete common tests, update history/error-log

* docs: update apps publish docs to v1.0.385 string status + --status filter

* feat: wire apps publish shortcuts to final gateway paths (guard stays until deploy)

Replace RPC-name placeholders with real OpenAPI paths (publishCreate/Get/ErrorLog/ListPath consts). Switch DryRun to idiomatic HTTP form (POST/GET + real URL + body/params). Fix body/query placement: publish body has no app_id (path-only); history switches from POST body to GET query with snake page_token. Fix Execute response reads to snake_case fields (release_id, created_at, updated_at, error_logs). publishAPIWired stays false; 1-line flip activates live calls.

* docs: update apps publish docs to final gateway paths

Replace RPC/PSM dry-run example with real HTTP form (POST/GET /open-apis/spark/v1/apps/:app_id/releases[/:release_id[/error_logs]]).
Fix all response field names to snake_case (release_id, created_at, updated_at, error_log).
Note --status/--limit/--page-token as HTTP query params in publish-history.

* feat: enable apps publish gateway calls (remove not-deployed guard)

* docs: remove not-deployed transition notes from apps publish docs

* feat: use spark:app:publish scope for apps +publish

* feat(apps): add +init shortcut to initialize Miaoda app repo (#6)

* feat(apps): add command runner and credential redaction for +init

* fix(apps): make credential redaction scheme matching case-insensitive

* feat(apps): add +init shortcut declaration, validation, and dry-run

* feat(apps): implement +init orchestration (credential-init, clone, checkout, conditional push)

* fix(apps): redact full userinfo when repo URL contains literal @

* docs(apps): add +init skill reference

* fix(apps): declare explicit empty Scopes on +init shortcut

* fix(apps): consume repository_url from +git-credential-init in +init

* feat(apps): add +init template flag and absolute-path dir resolution

* refactor(apps): use shared charcheck for +init --dir validation

* feat(apps): add meta.json, steering, and empty-repo helpers for +init

* feat(apps): add +init npx scaffold orchestration (init/upgrade branches)

* feat(apps): wire +init scaffold, already-initialized short-circuit, npx dep check

* docs(apps): document +init npx scaffold, --template, --dir, already-initialized

* docs(apps): correct stale +git-credential-init unreleased note in +init ref

* fix(apps): reject all control chars in +init --dir

* feat(apps): add +init progress logging and optional --template resolver

* refactor(apps): inline constant in +init scaffold progress log

* docs(apps): document +init optional --template and stderr progress contract

* feat(apps): treat README-only repo as empty and commit with --no-verify in +init

* docs(apps): explain README-seed match and --no-verify rationale in +init

* docs(apps): document README-seed empty detection and commit --no-verify

* feat(apps): add session conversation lifecycle shortcuts (#13)

* feat(apps): add +session-create shortcut

* fix(apps): remove unused sessionPath helper, assert empty +session-create body

* feat(apps): add +session-list shortcut

* feat(apps): add +session-read shortcut

* feat(apps): add +session-stop shortcut

* feat(apps): add +chat shortcut

* feat(apps): register session lifecycle shortcuts

* docs(apps): add session conversation skill reference

* docs(apps): clarify fullstack session_id source and fallback

* style(apps): gofmt apps_session_create.go

* docs(apps): add conversation/session triggers to skill routing description

* docs(apps): add conversation flow guidance (when to reuse vs new session, per-step user prompts)

* docs(apps): slim session reference per skill quality standard (4047->1726 tok)

* docs(apps): tighten session additions in SKILL.md (4394->4145 tok)

* fix(apps): align +chat with v7.8 contract (async, no turn_id in response)

* fix(apps): update +chat path to .../sessions/{id}/chat (backend endpoint change)

* docs(apps): align SKILL.md session command shape with v7.8 contract

* style(apps): gofmt apps_db_table_schema_dryrun_test.go

Go 1.19+ gofmt 文档注释列表缩进新规则(普通缩进 → tab 对齐),
修复 fast-gate CI 的 gofmt 卡点。

Change-Id: Ic246a659e016d9d6216182199ef300ae6f00ef9d

* feat(apps): split +init commit, plainer wording, align skill branches (#14)

* refactor(apps): plainer +init progress/help wording, keep scaffold key

* refactor(apps): add porcelain change classifier for +init commit split

* feat(apps): split +init empty-repo commit into code + config, reword subjects

* refactor(apps): scaffold-kind constants and pathspec assertions for +init split

* docs(apps): use +init in Path A; align app-repo branch to sprint/default

* docs(apps): align local-dev playbook to sprint/default + origin remote

* docs(apps): document +init two-commit split and plainer init wording

* docs(apps): require asking clone dir before +init, no assumed path

* fix(apps): stage +init commits by exact paths to avoid gitignore error

* refactor(apps): lowercase miaoda in +init commit subjects

* test(apps): cover +init upgrade path with real git

* fix: harden app git credential handling (#16)

* fix: harden git credential refresh fallback (#18)

* fix(apps): validate env-pull key names before writing to .env.local (#17)

* fix(apps): validate env-pull key names before writing to .env.local

S2 (medium-low) from security review: env-pull wrote server-returned
env KEYs to .env.local without validation. A compromised or MITM'd
backend could inject arbitrary lines via keys containing newlines.

- Add envKeyPattern regex to validate keys match [A-Za-z_][A-Za-z0-9_]*
- extractEnvPullVars now returns skippedKeys for invalid key names
- Invalid keys are skipped (not hard-fail) so remaining valid keys
  are still pulled
- writeEnvPullPretty prints a warning listing skipped keys

* fix(skills): correct npm script syntax from 'npm dev run' to 'npm run dev'

* fix(skills): align env-pull guidance with implementation

🤖 Generated with [Aiden x Claude Code]

* test(apps): cover storage/git-credential error paths and fix tz-flaky env-pull tests (#19)

The coverage and unit-test CI jobs failed on two timezone-dependent
assertions in apps_env_pull_test.go: the code renders the database
expiry via time.Local() while the tests hard-coded a CST literal, so
they failed under CI's UTC. Compute the expected string from the same
timestamp with Local() instead, making the assertions timezone-agnostic.

Also add unit tests for the error branches codecov flagged as uncovered,
taking storage.go and git_credential.go to 100%:
- storage Read/Write/Delete/List filesystem-error paths
- +git-credential-remove ConfigWarning output (pretty and JSON)
- gitCredentialLocalError nil passthrough

* fix(apps): silence +init forbidigo, npx app sync -y --prefer-online (#20)

* fix(apps): add Subtype to env-pull error literals (#21)

typed_error_completeness lint requires all errs.XxxError literals to
set Problem.Subtype. Add the missing field to 11 error constructions:
- ValidationError (user input checks): SubtypeInvalidArgument
- ValidationError (API response parsing): SubtypeInvalidResponse
- InternalError (filesystem ops): SubtypeUnknown

* feat(apps): inject FORCE_DB_BRANCH=dev in env-pull output (#23)

* feat(apps): inject FORCE_DB_BRANCH=dev in env-pull output

Always write FORCE_DB_BRANCH="dev" into the resolved .env.local after
extracting upstream env_vars, so downstream tooling pinning the dev
database branch does not need a separate manual edit. Existing local
values are overwritten in place via the canonical merge path.

* docs(skills): document apps +env-pull in lark-apps skill

Add the env-pull entry to the lark-apps SKILL index and ship the
matching reference doc covering args, merge semantics, return shape,
error envelope subtypes, and dry-run behavior so AI agents can route
to it without reading the Go source.

* feat(apps): surface is_published and online_url in +list pretty view (#22)

* docs: refactor lark-apps skill per quality spec (#24)

Slim SKILL.md and references against the lark-cli skill quality spec
while preserving domain knowledge and safety guardrails.

- Compress SKILL.md (drop the MUST-read prelude, full command-index
  tables, and content already owned by lark-shared: auth, scope,
  exit-10, risk policy, _notice); add version field; zero CRITICAL
  markers.
- Defer flag enumeration in references to `--help`; convert
  narration-inducing prohibitions into positive defaults; de-duplicate
  the per-file error.hint relay into a single resident SKILL.md rule.
- Fix stale facts found against shortcuts/apps source: drop the
  non-existent +create --message and --enable-multi-env-db flags,
  +list --filter (now --keyword), +db-multi-env-init (now
  +db-dev-init), and the removed html-publish cwd hard-reject.
- Keep all safety guardrails: db-dev-init irreversibility/exit-10,
  db-sql non-transactional multi-statement, git-credential token
  handling, html-publish credential scan, access-scope confirmation.
- Restore intent lost during slimming: release_id is not an approval
  instance (do not route to lark-approval); resolve access-scope
  targets via contact/im; ask the user before publishing as a
  side-effect; distinguish developing an existing app locally
  (+init) from creating a new one (+create).

* test(apps): supplement shortcuts/apps unit-test coverage to 88% (#25)

* test(apps): cover db-table-list numeric/byte formatting helpers

* test(apps): cover db-sql cell/code/dml/error render helpers

* test(apps): cover env-pull newline/expiry/extract-vars helpers

* test(apps): cover db-sql render branches and env-pull expiry edge case

* test(apps): cover init empty-dir/meta/ls-files error branches

* test(apps): cover env-pull target/read/parent-dir error branches

* test(apps): cover stage-and-commit and commit-push error branches

* test(apps): cover access-scope target split and JSON validation

* test(apps): cover html-publish decode error and scaffold sync failure

* test(apps): cover apps-update body field combinations

* test(apps): cover access-scope body build branches

* feat(apps): pass --local to npx skills sync in +init (#26)

* feat(apps): pass --local to all npx miaoda-cli calls in +init

* feat(apps): pass --local only to npx skills sync in +init

* docs(apps): surface +publish and +init dir-choice in local-dev flow (#27)

* docs(apps): surface +publish as deploy action in skill routing

* docs(apps): add explicit deploy-after-local-edit section to local-dev

* docs(apps): promote +init dir-choice instruction to a domain rule

* docs(apps): make dev-method a signal-driven entry gate before routing (#28)

* docs(apps): restore three-path overview line in apps skill intro (#29)

* feat(apps): add executable Examples to shortcut --help and error hints (#30)

* test(apps): guard every shortcut has a help Example and no PII

* feat(apps): add help Examples to all 24 apps shortcuts

* feat(apps): add actionable hints to high-impact error paths

* test(apps): cover withAppsHint set-if-empty hint behavior

* feat(apps): use concrete enum value in access-scope-set Example

* docs(apps): clarify db-sql/db-table-list json default output behavior

两处仅补充注释,不改逻辑:
- +db-sql: data.results 在 json 默认路径原样透出全部行,CLI 不二次截断;
  server 对单条 SELECT 有 1000 行硬上限、超出直接返报错,非无界 token 黑洞。
- +db-table-list: json 默认透出含每表完整 columns[] 系产品设计(list 接口本就
  返回列定义,json 消费方一次拿全量、免逐表再调 +db-table-schema),pretty 仅摘计数。

Change-Id: I1a49de8defc4428bfe1e774e4fd7adb45e59e3af

* feat(apps): command-layer AI-friendliness governance (P0+P1) (#32)

* fix(apps): normalize --app-type case to align with server

* refactor(apps): migrate CallAPI to CallAPITyped for typed errors and retryable

* feat(apps): trim icon_url and created_at from +list default output

* feat(apps): add actionable hints to high-impact error paths

* feat(apps): add 2-3 help Examples to +chat and +access-scope-set

* docs(apps): add --jq filter tips to list/db commands

* docs(apps): sync +list reference with trimmed output fields

* test(apps): assert error hints and messages carry no secrets or PII

* fix(apps): prefix --jq tips with .data. so they run against the response envelope

* test(apps): expect --app-type uppercase normalization in create dry-run E2E (#33)

* fix(apps): scaffold via @latest miaoda-cli instead of @alpha (#34)

* feat(apps): rework lark-apps triggering, routing & confirm policy (#35)

* feat(apps): results-oriented triggering, pre-auth floors, terminal URL

Widen description WHEN to cover app-building openers (CRM/审批/HTML page)
with no Miaoda signal word, WHAT still anchored to 妙搭应用开发与托管.
Add a pre-authorization rule (auth words skip confirm) with two non-exempt
floors: destructive DDL (DROP/TRUNCATE/ALTER drop|modify column) dry-run,
and first public-URL publish (+publish/+html-publish) when no auth word.
Exempt html app_type from the local-vs-cloud dev-method gate, and scope
that gate to new-app creation only (existing-app ops route directly).
Require an accessible URL as the end-to-end terminal step.

* feat(apps): apply eval-fix behavior contracts across reference docs

init/local-dev: end-to-end default-directory escape hatch; end-to-end
new-build starts with +create. db-sql: additive DDL direct-exec when
authorized, destructive DDL stays dry-run. local-dev/publish-status:
return online_url via +list as the full_stack publish terminal step.
cloud-dev: generation != shareable URL, +publish handoff, background
until-poll snippet (sleep N && cmd intercepted; deprecate ScheduleWakeup),
multi-turn publish precondition. publish/publish-error-log: transient
failure (EAI_AGAIN/ETIMEDOUT/registry) discrimination, retry cap 2,
honest receipt. env-pull: first-launch fallback. local-dev/db-dev-init:
new full_stack ships dual DB, skip +db-dev-init.

* refactor(apps): apply review feedback — semantic criteria, drop overfit/unverified content

Per line-by-line review of the eval-fix changes:
- Entry routing reframed to objective/semantic criteria (new-vs-existing =
  'can an existing app be identified'; dev-method = who-writes-code
  preference), replacing keyword/example matching.
- db-sql DDL gate restated by effect (data-loss / reversibility), not a
  keyword list.
- Pre-authorization judged by expressed intent (not a word list); single
  non-exempt floor (destructive/irreversible DB dry-run); confirm policy in
  its own section, error.hint in 'failure handling'.
- init.md slimmed to command facts (directory choice owned by local-dev,
  no init<->local-dev cycle); local-dev defers new-vs-existing to the entry.
- Reverted unverified/redundant/runtime-coupled additions: cloud-dev
  session-read preview-URL claim + background-poll snippet + queued_count
  precondition; publish transient-retry/ScheduleWakeup; env-pull first-launch;
  db-dev-init positive restatement; SKILL terminal-URL mandate.
- Fixed dangling section references after the rename.

* fix(apps): scope pre-authorization to hands-off intent, not 'wants a result' (#36)

Follow-up to #35. The merged pre-authorization rule treated 'wanting the
final result' as authorization, so '先在本地跑起来让我看看' was read as
pre-authorized and the agent silently picked a clone directory without
asking. Re-state the criterion as the user's hands-off intent (explicit
waiver, or an end-to-end directive), judged uniformly across the flow
(directory/clone, publish) — not a per-decision carve-out. Merely wanting
a result or asking to review is not authorization.

* docs: clarify apps cloud dev publish state

* fix(apps): require commit+push before publish, clarify deploy flow (#38)

* fix(apps): require committing changes before publish in local-dev flow

* fix(apps): make commit+push mandatory before publish in agent rules

* fix(apps): scope selective-add caveat to incremental deploy, not new-app flow

* fix(apps): make pre-publish commit conditional on local changes

* fix(apps): tighten pre-publish commit wording in agent rules

* fix(apps): cloud-dev does not auto-deploy, add explicit publish step

* docs(apps): document +chat init vs incremental turn cost (#39)

First +chat on a not-initialized app runs full design+gen server-side
(~20-50 min); chat on an already-initialized app is incremental and
finishes in minutes. Surface this in the +chat Go comment as a pointer
and put the init-state check + matching polling cadence (5-10s vs
60-120s) in the lark-apps cloud-dev skill reference as the canonical
source. Cloud-side init check uses +session-read committed-version
info or +list is_published:true.

* docs(apps): document +chat init vs incremental turn cost (#40)

First +chat on a not-initialized app runs full design+gen server-side
(~20-50 min); chat on an already-initialized app is incremental and
finishes in minutes. Surface this in the +chat Go comment as a pointer
and put the init-state check + matching polling cadence (5-10s vs
60-120s) in the lark-apps cloud-dev skill reference as the canonical
source. Cloud-side init check uses +session-read committed-version
info or +list is_published:true.

* feat(apps): surface online_url/error_logs in +publish-status output (#41)

* refactor(apps): extract shared release error-log table helper

* fix(apps): keep error-log table byte-identical for null error_logs

* feat(apps): surface online_url/error_logs in +publish-status output

* docs(apps): read online_url/error_logs from +publish-status in publish flow

* docs(apps): align local/cloud dev publish flow with +publish-status fields

* refactor(apps): rename +db-dev-init→+db-env-create, trim db-table-list columns

- +db-env-create(原 +db-dev-init):新增 --env 参数(调用方传入,目前只支持 dev),
  --sync-data 改为 true/false 取值;服务端 URL 仍走 db_dev_init。
- +db-table-list:json 默认用白名单投影(dbTableListItem)只输出产品要求字段,
  每表 columns[] 折算成 column_count、不再透出完整列定义(与 +db-table-schema 重复且放大
  token);要完整列定义/索引/约束用 +db-table-schema。
- 同步对齐 db 相关 skill 文档(命令名、column_count、env-create 参数)。
- 单测 + cli_e2e dry-run 全绿。

Change-Id: I116ab11807679f8f06ed18221f705bab426d015c

* refactor(apps): rename +db-table-schema → +db-table-get

动词对齐 +db-table-list(list/get)。仅命令名 + 标识符 + 文档改名,行为/输出/URL 不变:
- AppsDBTableSchema→AppsDBTableGet,文件/测试/cli_e2e test 重命名
- buildDBTableSchemaParams→buildDBTableGetParams
- +db-sql / +db-table-list 里的交叉引用 hint、skill 文档同步

Change-Id: I36dfb8fd0d2613492a57dc7815bc58414c145480

* feat: auto-pull env vars after apps +init (#42)

* test: route apps +env-pull to its own fake-runner key

* feat(apps): add +env-pull envelope parsers for +init

* feat(apps): add pullEnv helper invoking sibling +env-pull

* feat(apps): +init auto-runs +env-pull after push (non-fatal)

* docs(apps): clarify db-sql --query @path is relative-only, use stdin for absolute paths

@path 受 lark-cli 全局文件安全策略约束,只接受 cwd 内相对路径;绝对路径 / cwd 不固定
场景改用 stdin(--query - < /abs/file.sql),无需先 cd。

Change-Id: Ib3453810cfc9303d72b4facf3493ad9688eeffd3

* docs(apps): refine db-sql --query path guidance wording

以 agent 视角重写:@ 仅接受工作目录内相对路径,绝对路径/越界路径被拒(CLI 文件访问统一约束);
工作目录外的文件经 stdin 传入。

Change-Id: Ic7db00934b3571368eb704451f4ce1776463806d

* feat(apps): make +db-sql high-risk-write (require --yes)

+db-sql 可含 DML/DDL,统一升级为 high-risk-write:框架对所有执行强制 --yes 确认关卡
(--dry-run 预览豁免),无 --yes 返 confirmation_required / exit 10。
- Risk: write → high-risk-write(去掉自定义门禁,直接用框架机制)
- skill 文档:命令骨架标注 --yes 要求;Agent 规则改为「执行需 --yes,只读可直接带、
  破坏性先 dry-run 确认再带」
- 单测所有执行调用补 --yes

Change-Id: I57e78832b35fa170a485774e6fb7289109d678c3

* docs(apps): clarify app_ (Miaoda) vs cli_ (Feishu) app id (#46)

* 优化云端开发skill,明确执行模型,参数解释 (#44)

Co-authored-by: fushengdong.1 <fushengdong.1@bytedance.com>

* refactor: rename apps publish commands to release and session-get (#45)

* refactor(apps): drop +publish-error-log, rename release path constants

* refactor(apps): rename +publish to +release-create

* refactor(apps): rename +publish-history to +release-list, unify pagination to --page-size

* refactor(apps): rename +publish-status to +release-get

Renames apps +publish-status → +release-get (AppsPublishStatus → AppsReleaseGet),
updates --release-id desc to reference +release-create, and fixes the Execute
error hint to point at +release-list instead of +publish-history.

* refactor(apps): rename +session-read to +session-get

* docs(apps): rename publish references to release, +session-read to +session-get

* refactor(apps): clean up residual publish/session-read references

Fix six leftover references missed in Tasks 1-6: +publish-history in
jq-tip test wantCmds map and common_test hint fixture (×3), +session-read
in apps_chat.go comment+output string (×2), apps_session_stop.go flag
desc (×1), apps_chat_test.go comment (×1), and +publish-status in
lark-apps-list.md agent rule prose (×1).

* docs(apps): clarify release-get link contract and session-get vs session-list

* docs(apps): generalize release-list page-size rule to N records

* feat(apps): rename +list --scope flag to --ownership (#47)

* feat(apps): rename +list --scope flag to --ownership

* test(apps): update +list cli_e2e dry-run for --ownership rename

* docs(apps): document +list --ownership flag

* feat(apps): align +release commands with new release API format (#48)

* feat(apps): align +release-create scope to spark:app:write

* feat(apps): raise +release-list --page-size documented max to 500

* feat(apps): show commit_id in +release-get pretty output

* docs(apps): update release reference docs for page-size 500 and commit_id

* test(apps): cover empty commit_id in +release-get pretty output

* docs: align lark apps cloud dev release flow

* feat(apps): redesign +db-sql → +db-execute (--sql/--file, default env dev)

按 db 子域命令最终设计重做执行入口:
- 命令 +db-sql → +db-execute(动词收尾,对齐 +db-table-list/-get)
- --query 拆为 --sql(内联/stdin)与 --file(.sql 文件路径),二选一互斥;
  --file 在 Validate 阶段读出归一化到 --sql
- 默认 --env online → dev(打生产库需显式 --env online)
- 文件/标识符/注册/测试/cli_e2e/skill 文档全部对齐重命名
- 新增测试:--sql/--file 互斥、--file 读取、默认 env=dev

不在本次范围:--transaction/--no-transaction(服务端 transactional 实为路径切换、
非真事务,需 dataloom 侧先支持真事务开关)、--max-rows/--timeout 等后续项。

Change-Id: I50c06faf83527471446e2a6651ccb51f6eedd6ff

* docs(apps): clearer --env online wording for +db-execute

把口语化的「打生产库需显式」改为「需要操作线上环境数据库时,显式指定 --env online」;
flag desc 同步去掉 hit production 措辞。

Change-Id: Iee82fccf17e08bddb4b760c3970a416746b10c4c

* docs(apps): drop 'ad-hoc' jargon from +db-execute description

中文文档/英文 description 去掉术语 ad-hoc;SELECT/DML/DDL 已表意,含义不丢。

Change-Id: Ie2cccc5fc3491fe5f57190a87b93ecd70405b156

* docs(apps): trim +db-execute when-to-use and --file path wording

- 何时用去掉「(查询 / 临时数据修复 / 应急 DDL)」枚举
- --file 路径说明去掉 .. /符号链接/统一约束 的技术化描述,改为「相对路径,
  否则用 --sql - < 文件路径」的产品化口吻

Change-Id: Ie70e57895c78650230b6942b03d90a2d95c937f2

* docs(apps): note --file rejects absolute/cwd-escaping paths

简短补回 --file 的路径约束(绝对路径 / 经 ..、符号链接越界会被拒),去掉冗余评注。

Change-Id: I549893c82cafbe97529e08dcbc3ee5496927da18

* fix(apps): replace t.Chdir with os.Chdir in db-execute test (Go 1.23 compat)

t.Chdir 是 Go 1.24 API,但 go.mod 为 go 1.23.0,CI(Go 1.23)报
"t.Chdir undefined"。改用 os.Chdir + t.Cleanup 还原,1.23 兼容。

Change-Id: I550611773e5088275be1c4344d4f8269610ce74a

* feat(apps): refine +init description and refresh env on re-init

* fix(apps): treat accessible-link requests as publish intent (#53)

* refactor(apps): +db-env-create --sync-data string-enum → Type:bool

原实现用 string + Enum["true","false"] + == "true" 模拟 bool,啰嗦且非惯用。
改为 Type:bool(rctx.Bool):传 --sync-data 即开启、省略为 false。
同步更新测试、cli_e2e dry-run、skill 文档。

Change-Id: I3068e0577fa20a7cbaf414ca9af3d197f6ae8049

* fix(apps): declare --app-type as strict lowercase enum (#55)

* docs(apps): front-load routing, dedupe, and trim lark-apps skill (#56)

* docs(apps): front-load intent-routing table and dedupe skill body

* docs(apps): dedupe publish guardrail and polling rules in cloud-dev

* docs(apps): trim env-pull implementation detail to behavior contract

* docs(apps): add +env-pull routing entry in SKILL.md

* docs(apps): fix create.md cross-ref to actual SKILL.md section name

* feat(apps): add error.hint to command failures and a consistency gate (#57)

* feat(apps): add appIDListHint const and wrap 4 pure app-id command failure paths

Adds shared `appIDListHint` recovery hint to common.go and wraps the
CallAPITyped failure branch of session-create, session-list, update, and
release-list to surface an actionable next-step hint on 4xx errors.
Includes httpmock unit tests in apps_hints_more_test.go (TDD: red→green).

* feat(apps): add sessionStopHint and createHint for session-stop and create commands

Adds per-command recovery hints with specific guidance: sessionStopHint
points at +session-list and +session-get; createHint explains valid
--app-type values and permission failure. Wraps the CallAPITyped failure
branch in both commands.

* feat(apps): add recovery hints for db-env-create, db-table-get, db-table-list

Adds dbEnvCreateHint, dbTableGetHint, and dbTableListHint with actionable
cross-command guidance (e.g. pointing at +db-table-list for env conflicts,
+db-env-create for missing dev env). Wraps only the CallAPITyped failure
branch; requireAppID validation errors are left untouched.

* refactor(apps): make session-stop hint runnable and align hint test names

* test(apps): guard withAppsHint upstream-wins contract and new hint leak safety

* test(apps): add help-skill command consistency gate

---------

Co-authored-by: linchao5102 <linchao.5102@bytedance.com>
Co-authored-by: Wang <wangjiangwen@bytedance.com>
Co-authored-by: wangjiangwen-gif <286006750+wangjiangwen-gif@users.noreply.github.com>
Co-authored-by: 陈兴炀 <chenxingyang.1019@bytedance.com>
Co-authored-by: aihao-git <aihao.0331@bytedance.com>
Co-authored-by: bali <bali@bytedance.com>
Co-authored-by: hunnnnngry <chenxi.xichen@bytedance.com>
Co-authored-by: shengdongyc <1135978761fsd@gmail.com>
Co-authored-by: fushengdong.1 <fushengdong.1@bytedance.com>
2026-06-10 21:45:45 +08:00

2382 lines
86 KiB
Go

// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package gitcred
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/url"
"os"
"path/filepath"
"slices"
"strings"
"testing"
"time"
"github.com/larksuite/cli/errs"
)
func TestMain(m *testing.M) {
dir, err := os.MkdirTemp("", "gitcred-test-config-*")
if err != nil {
panic(err)
}
_ = os.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
code := m.Run()
_ = os.RemoveAll(dir)
os.Exit(code)
}
type fakeKeychain struct {
values map[string]string
removed []string
services []string
getErr error
setErr error
removeErr error
onGet func(account string)
onSet func(account, value string)
}
func newFakeKeychain() *fakeKeychain {
return &fakeKeychain{values: map[string]string{}}
}
func (f *fakeKeychain) Get(service, account string) (string, error) {
if f.getErr != nil {
return "", f.getErr
}
f.services = append(f.services, "get:"+service)
if f.onGet != nil {
f.onGet(account)
}
return f.values[account], nil
}
func (f *fakeKeychain) Set(service, account, value string) error {
if f.setErr != nil {
return f.setErr
}
f.services = append(f.services, "set:"+service)
f.values[account] = value
if f.onSet != nil {
f.onSet(account, value)
}
return nil
}
func (f *fakeKeychain) Remove(service, account string) error {
if f.removeErr != nil {
return f.removeErr
}
f.services = append(f.services, "remove:"+service)
delete(f.values, account)
f.removed = append(f.removed, account)
return nil
}
type fakeIssuer struct {
calls int
next *IssuedCredential
err error
onIssue func()
}
func (f *fakeIssuer) Issue(ctx context.Context, appID string, profile ProfileContext) (*IssuedCredential, error) {
f.calls++
if f.onIssue != nil {
f.onIssue()
}
if f.err != nil {
return nil, f.err
}
out := *f.next
if out.AppID == "" {
out.AppID = appID
}
return &out, nil
}
type fakeGitConfig struct {
set []string
unset []string
err error
}
func (f *fakeGitConfig) SetHelper(ctx context.Context, gitHTTPURL, appID string) error {
f.set = append(f.set, gitHTTPURL+" "+appID)
return f.err
}
func (f *fakeGitConfig) UnsetHelper(ctx context.Context, gitHTTPURL string) error {
f.unset = append(f.unset, gitHTTPURL)
return f.err
}
type splitFakeGitConfig struct {
setErr error
unsetErr error
}
func (f splitFakeGitConfig) SetHelper(ctx context.Context, gitHTTPURL, appID string) error {
return f.setErr
}
func (f splitFakeGitConfig) UnsetHelper(ctx context.Context, gitHTTPURL string) error {
return f.unsetErr
}
type fakeAppStorage struct {
values map[string][]byte
err error
}
func newFakeAppStorage() *fakeAppStorage {
return &fakeAppStorage{values: map[string][]byte{}}
}
func (s *fakeAppStorage) Read(appID, key string) ([]byte, error) {
if s.err != nil {
return nil, s.err
}
data := s.values[appID+"/"+key]
if data == nil {
return nil, nil
}
return append([]byte(nil), data...), nil
}
func (s *fakeAppStorage) Write(appID, key string, data []byte) error {
if s.err != nil {
return s.err
}
s.values[appID+"/"+key] = append([]byte(nil), data...)
return nil
}
func (s *fakeAppStorage) Delete(appID, key string) error {
if s.err != nil {
return s.err
}
delete(s.values, appID+"/"+key)
return nil
}
type sequenceAppStorage struct {
reads [][]byte
}
func (s *sequenceAppStorage) Read(appID, key string) ([]byte, error) {
if len(s.reads) == 0 {
return nil, nil
}
data := s.reads[0]
s.reads = s.reads[1:]
return append([]byte(nil), data...), nil
}
func (s *sequenceAppStorage) Write(appID, key string, data []byte) error {
return nil
}
func (s *sequenceAppStorage) Delete(appID, key string) error {
return nil
}
func TestNormalizeGitHTTPURL(t *testing.T) {
got, err := NormalizeGitHTTPURL("HTTPS://Example.COM:443//git/u_abc/app.git/?x=1#frag")
if err != nil {
t.Fatalf("NormalizeGitHTTPURL returned error: %v", err)
}
want := "https://example.com/git/u_abc/app.git"
if got != want {
t.Fatalf("NormalizeGitHTTPURL() = %q, want %q", got, want)
}
}
func TestManagerInitStoresPATThroughInternalKeychainAndMetadataOnly(t *testing.T) {
now := time.Unix(1780000000, 0)
kc := newFakeKeychain()
gitConfig := &fakeGitConfig{}
issuer := &fakeIssuer{next: &IssuedCredential{
GitHTTPURL: "https://example.com/git/u/app.git",
Username: "x-access-token",
PAT: "secret-pat",
ExpiresAt: now.Add(24 * time.Hour).Unix(),
}}
manager := NewManager(NewStoreAt(filepath.Join(t.TempDir(), MetadataFilename)), NewSecretStore(kc), gitConfig, issuer)
manager.Now = func() time.Time { return now }
result, err := manager.Init(context.Background(), testProfile(), "app_xxx")
if err != nil {
t.Fatalf("Init returned error: %v", err)
}
if result.GitHTTPURL != "https://example.com/git/u/app.git" {
t.Fatalf("GitHTTPURL = %q", result.GitHTTPURL)
}
record, err := manager.Store.FindByURL(result.GitHTTPURL)
if err != nil {
t.Fatalf("FindByURL returned error: %v", err)
}
if record == nil || record.Status != StatusConfirmed {
t.Fatalf("record = %#v, want confirmed", record)
}
if bytes.Contains(mustReadMetadata(t, manager), []byte("secret-pat")) {
t.Fatalf("metadata must not contain PAT")
}
if got := kc.values[record.PATRef]; got != "secret-pat" {
t.Fatalf("keychain PAT = %q, want secret-pat", got)
}
if !slices.Contains(kc.services, "set:"+KeychainService) {
t.Fatalf("keychain services = %#v, want Set through %q", kc.services, KeychainService)
}
if len(gitConfig.set) != 1 || gitConfig.set[0] != result.GitHTTPURL+" app_xxx" {
t.Fatalf("git config set = %#v", gitConfig.set)
}
}
func TestManagerInitFailsWhenKeychainUnavailable(t *testing.T) {
now := time.Unix(1780000000, 0)
issuer := &fakeIssuer{next: &IssuedCredential{
GitHTTPURL: "https://example.com/git/u/app.git",
PAT: "secret-pat",
ExpiresAt: now.Add(24 * time.Hour).Unix(),
}}
manager := NewManager(NewStoreAt(filepath.Join(t.TempDir(), MetadataFilename)), NewSecretStore(nil), nil, issuer)
manager.Now = func() time.Time { return now }
_, err := manager.Init(context.Background(), testProfile(), "app_xxx")
if err == nil {
t.Fatal("Init returned nil error, want keychain unavailable error")
}
record, findErr := manager.Store.FindByURL("https://example.com/git/u/app.git")
if findErr != nil {
t.Fatalf("FindByURL returned error: %v", findErr)
}
if record != nil {
t.Fatalf("record after failed init = %#v, want nil", record)
}
}
func TestManagerInitRestoresExistingRecordWhenKeychainSetFails(t *testing.T) {
now := time.Unix(1780000000, 0)
kc := newFakeKeychain()
issuer := &fakeIssuer{next: &IssuedCredential{
GitHTTPURL: "https://example.com/git/u/app.git",
PAT: "old-pat",
ExpiresAt: now.Add(24 * time.Hour).Unix(),
}}
manager := NewManager(NewStoreAt(filepath.Join(t.TempDir(), MetadataFilename)), NewSecretStore(kc), nil, issuer)
manager.Now = func() time.Time { return now }
if _, err := manager.Init(context.Background(), testProfile(), "app_xxx"); err != nil {
t.Fatalf("initial Init returned error: %v", err)
}
before, err := manager.Store.FindByURL("https://example.com/git/u/app.git")
if err != nil {
t.Fatalf("FindByURL returned error: %v", err)
}
kc.setErr = errors.New("keychain locked")
issuer.next = &IssuedCredential{
GitHTTPURL: "https://example.com/git/u/app.git",
PAT: "new-pat",
ExpiresAt: now.Add(48 * time.Hour).Unix(),
}
if _, err := manager.Init(context.Background(), testProfile(), "app_xxx"); err == nil {
t.Fatal("second Init returned nil error, want keychain error")
}
after, err := manager.Store.FindByURL("https://example.com/git/u/app.git")
if err != nil {
t.Fatalf("FindByURL returned error: %v", err)
}
if after == nil || after.ExpiresAt != before.ExpiresAt || after.Status != StatusConfirmed {
t.Fatalf("record after failed refresh init = %#v, want original %#v", after, before)
}
if got := kc.values[before.PATRef]; got != "old-pat" {
t.Fatalf("keychain PAT after failed refresh init = %q, want old-pat", got)
}
}
func TestManagerInitCleansOldURLHelperAfterRepositoryURLChanges(t *testing.T) {
now := time.Unix(1780000000, 0)
kc := newFakeKeychain()
gitConfig := &fakeGitConfig{}
issuer := &fakeIssuer{next: &IssuedCredential{
GitHTTPURL: "https://example.com/git/u/old.git",
PAT: "old-pat",
ExpiresAt: now.Add(24 * time.Hour).Unix(),
}}
manager := NewManager(NewStoreAt(filepath.Join(t.TempDir(), MetadataFilename)), NewSecretStore(kc), gitConfig, issuer)
manager.Now = func() time.Time { return now }
if _, err := manager.Init(context.Background(), testProfile(), "app_xxx"); err != nil {
t.Fatalf("initial Init returned error: %v", err)
}
issuer.next = &IssuedCredential{
GitHTTPURL: "https://example.com/git/u/new.git",
PAT: "new-pat",
ExpiresAt: now.Add(48 * time.Hour).Unix(),
}
if _, err := manager.Init(context.Background(), testProfile(), "app_xxx"); err != nil {
t.Fatalf("second Init returned error: %v", err)
}
if len(gitConfig.unset) != 1 || gitConfig.unset[0] != "https://example.com/git/u/old.git" {
t.Fatalf("git config unset = %#v, want old URL cleanup", gitConfig.unset)
}
}
func TestManagerInitReportsOldURLCleanupWarning(t *testing.T) {
now := time.Unix(1780000000, 0)
issuer := &fakeIssuer{next: &IssuedCredential{
GitHTTPURL: "https://example.com/git/u/old.git",
PAT: "old-pat",
ExpiresAt: now.Add(24 * time.Hour).Unix(),
}}
manager := NewManager(NewStoreAt(filepath.Join(t.TempDir(), MetadataFilename)), NewSecretStore(newFakeKeychain()), splitFakeGitConfig{unsetErr: errors.New("unset failed")}, issuer)
manager.Now = func() time.Time { return now }
if _, err := manager.Init(context.Background(), testProfile(), "app_xxx"); err != nil {
t.Fatalf("initial Init returned error: %v", err)
}
issuer.next = &IssuedCredential{
GitHTTPURL: "https://example.com/git/u/new.git",
PAT: "new-pat",
ExpiresAt: now.Add(48 * time.Hour).Unix(),
}
result, err := manager.Init(context.Background(), testProfile(), "app_xxx")
if err != nil {
t.Fatalf("second Init returned error: %v", err)
}
if !strings.Contains(result.ConfigWarning, "unset failed") {
t.Fatalf("ConfigWarning = %q, want unset warning", result.ConfigWarning)
}
}
func TestManagerInitRemovesPreviousPATRefAfterLoginChanges(t *testing.T) {
now := time.Unix(1780000000, 0)
kc := newFakeKeychain()
issuer := &fakeIssuer{next: &IssuedCredential{
GitHTTPURL: "https://example.com/git/u/app.git",
PAT: "old-pat",
ExpiresAt: now.Add(24 * time.Hour).Unix(),
}}
manager := NewManager(NewStoreAt(filepath.Join(t.TempDir(), MetadataFilename)), NewSecretStore(kc), nil, issuer)
manager.Now = func() time.Time { return now }
oldProfile := testProfile()
if _, err := manager.Init(context.Background(), oldProfile, "app_xxx"); err != nil {
t.Fatalf("initial Init returned error: %v", err)
}
oldRef := BuildPATRef(oldProfile, "app_xxx")
newProfile := ProfileContext{Profile: "default", ProfileAppID: "cli_xxx", UserOpenID: "ou_new"}
issuer.next = &IssuedCredential{
GitHTTPURL: "https://example.com/git/u/app.git",
PAT: "new-pat",
ExpiresAt: now.Add(48 * time.Hour).Unix(),
}
if _, err := manager.Init(context.Background(), newProfile, "app_xxx"); err != nil {
t.Fatalf("second Init returned error: %v", err)
}
newRef := BuildPATRef(newProfile, "app_xxx")
if got := kc.values[oldRef]; got != "" {
t.Fatalf("old keychain PAT = %q, want removed", got)
}
if got := kc.values[newRef]; got != "new-pat" {
t.Fatalf("new keychain PAT = %q, want new-pat", got)
}
}
func TestManagerInitDoesNotTreatOtherAppRecordAsRefresh(t *testing.T) {
now := time.Unix(1780000000, 0)
kc := newFakeKeychain()
storage := newFakeAppStorage()
otherStore := NewAppStore("app_other", storage)
otherRecord := CredentialRecord{
AppID: "app_other",
GitHTTPURL: "https://example.com/git/u/other.git",
PATRef: "other-ref",
Status: StatusConfirmed,
ExpiresAt: now.Add(24 * time.Hour).Unix(),
}
if err := otherStore.Upsert(otherRecord); err != nil {
t.Fatalf("seed other app record: %v", err)
}
kc.values[otherRecord.PATRef] = "other-pat"
manager := NewManager(NewAppStore("app_xxx", storage), NewSecretStore(kc), nil, &fakeIssuer{next: &IssuedCredential{
GitHTTPURL: "https://example.com/git/u/app.git",
PAT: "app-pat",
ExpiresAt: now.Add(48 * time.Hour).Unix(),
}})
manager.Now = func() time.Time { return now }
result, err := manager.Init(context.Background(), testProfile(), "app_xxx")
if err != nil {
t.Fatalf("Init returned error: %v", err)
}
if result.Refreshed {
t.Fatalf("Init marked refreshed with only another app record present")
}
if got := kc.values[otherRecord.PATRef]; got != "other-pat" {
t.Fatalf("other app PAT = %q, want untouched", got)
}
if record, err := otherStore.FindByURL(otherRecord.GitHTTPURL); err != nil || record == nil {
t.Fatalf("other app record = %#v, %v; want untouched", record, err)
}
}
func TestManagerGetRefreshesWithinTenMinutes(t *testing.T) {
now := time.Unix(1780000000, 0)
kc := newFakeKeychain()
issuer := &fakeIssuer{next: &IssuedCredential{
GitHTTPURL: "https://example.com/git/u/app.git",
Username: "x-access-token",
PAT: "old-pat",
ExpiresAt: now.Add(9 * time.Minute).Unix(),
}}
manager := NewManager(NewStoreAt(filepath.Join(t.TempDir(), MetadataFilename)), NewSecretStore(kc), nil, issuer)
manager.Now = func() time.Time { return now }
if _, err := manager.Init(context.Background(), testProfile(), "app_xxx"); err != nil {
t.Fatalf("Init returned error: %v", err)
}
issuer.next = &IssuedCredential{
GitHTTPURL: "https://example.com/git/u/app.git",
Username: "x-access-token",
PAT: "new-pat",
ExpiresAt: now.Add(24 * time.Hour).Unix(),
}
var out bytes.Buffer
if err := manager.Get(context.Background(), CredentialInput{Protocol: "https", Host: "example.com", Path: "/git/u/app.git"}, testProfile(), &out, &bytes.Buffer{}); err != nil {
t.Fatalf("Get returned error: %v", err)
}
if got := out.String(); got != "username=x-access-token\npassword=new-pat\n\n" {
t.Fatalf("credential output = %q", got)
}
if issuer.calls != 2 {
t.Fatalf("issuer calls = %d, want 2", issuer.calls)
}
}
func TestManagerGetDoesNotReuseUnusableRecordWhenRefreshReturnsOlderExpiry(t *testing.T) {
now := time.Unix(1780000000, 0)
kc := newFakeKeychain()
issuer := &fakeIssuer{next: &IssuedCredential{
GitHTTPURL: "https://example.com/git/u/app.git",
Username: "x-access-token",
PAT: "old-pat",
ExpiresAt: now.Add(5 * time.Minute).Unix(),
}}
manager := NewManager(NewStoreAt(filepath.Join(t.TempDir(), MetadataFilename)), NewSecretStore(kc), nil, issuer)
manager.Now = func() time.Time { return now }
if _, err := manager.Init(context.Background(), testProfile(), "app_xxx"); err != nil {
t.Fatalf("Init returned error: %v", err)
}
issuer.next = &IssuedCredential{
GitHTTPURL: "https://example.com/git/u/app.git",
Username: "x-access-token",
PAT: "older-pat",
ExpiresAt: now.Add(time.Minute).Unix(),
}
var out bytes.Buffer
if err := manager.Get(context.Background(), CredentialInput{Protocol: "https", Host: "example.com", Path: "/git/u/app.git"}, testProfile(), &out, &bytes.Buffer{}); err != nil {
t.Fatalf("Get returned error: %v", err)
}
if out.Len() != 0 {
t.Fatalf("stdout = %q, want empty because reread record is still not usable", out.String())
}
if issuer.calls != 2 {
t.Fatalf("issuer calls = %d, want 2", issuer.calls)
}
}
func TestManagerGetUsesValidPATWithoutRefresh(t *testing.T) {
now := time.Unix(1780000000, 0)
kc := newFakeKeychain()
issuer := &fakeIssuer{next: &IssuedCredential{
GitHTTPURL: "https://example.com/git/u/app.git",
Username: "x-access-token",
PAT: "valid-pat",
ExpiresAt: now.Add(24 * time.Hour).Unix(),
}}
manager := NewManager(NewStoreAt(filepath.Join(t.TempDir(), MetadataFilename)), NewSecretStore(kc), nil, issuer)
manager.Now = func() time.Time { return now }
if _, err := manager.Init(context.Background(), testProfile(), "app_xxx"); err != nil {
t.Fatalf("Init returned error: %v", err)
}
var out bytes.Buffer
if err := manager.Get(context.Background(), CredentialInput{Protocol: "https", Host: "example.com", Path: "/git/u/app.git"}, testProfile(), &out, &bytes.Buffer{}); err != nil {
t.Fatalf("Get returned error: %v", err)
}
if got := out.String(); got != "username=x-access-token\npassword=valid-pat\n\n" {
t.Fatalf("credential output = %q", got)
}
if issuer.calls != 1 {
t.Fatalf("issuer calls = %d, want 1", issuer.calls)
}
}
func TestManagerGetKeepsStdoutEmptyWhenRefreshFails(t *testing.T) {
now := time.Unix(1780000000, 0)
kc := newFakeKeychain()
issuer := &fakeIssuer{next: &IssuedCredential{
GitHTTPURL: "https://example.com/git/u/app.git",
PAT: "old-pat",
ExpiresAt: now.Add(24 * time.Hour).Unix(),
}}
manager := NewManager(NewStoreAt(filepath.Join(t.TempDir(), MetadataFilename)), NewSecretStore(kc), nil, issuer)
manager.Now = func() time.Time { return now }
if _, err := manager.Init(context.Background(), testProfile(), "app_xxx"); err != nil {
t.Fatalf("Init returned error: %v", err)
}
record, err := manager.Store.FindByURL("https://example.com/git/u/app.git")
if err != nil {
t.Fatalf("FindByURL returned error: %v", err)
}
record.ExpiresAt = now.Add(-time.Minute).Unix()
if err := manager.Store.Upsert(*record); err != nil {
t.Fatalf("Upsert expired record returned error: %v", err)
}
issuer.err = errors.New("permission denied")
var out bytes.Buffer
var errOut bytes.Buffer
if err := manager.Get(context.Background(), CredentialInput{Protocol: "https", Host: "example.com", Path: "/git/u/app.git"}, testProfile(), &out, &errOut); err != nil {
t.Fatalf("Get returned error: %v", err)
}
if out.Len() != 0 {
t.Fatalf("stdout = %q, want empty", out.String())
}
if !bytes.Contains(errOut.Bytes(), []byte("lark-cli apps +git-credential-init --app-id app_xxx")) {
t.Fatalf("stderr missing actionable hint: %q", errOut.String())
}
}
func TestManagerGetKeepsStdoutEmptyOnLoginMismatch(t *testing.T) {
now := time.Unix(1780000000, 0)
kc := newFakeKeychain()
issuer := &fakeIssuer{next: &IssuedCredential{
GitHTTPURL: "https://example.com/git/u/app.git",
PAT: "pat",
ExpiresAt: now.Add(24 * time.Hour).Unix(),
}}
manager := NewManager(NewStoreAt(filepath.Join(t.TempDir(), MetadataFilename)), NewSecretStore(kc), nil, issuer)
manager.Now = func() time.Time { return now }
if _, err := manager.Init(context.Background(), testProfile(), "app_xxx"); err != nil {
t.Fatalf("Init returned error: %v", err)
}
var out bytes.Buffer
var errOut bytes.Buffer
other := ProfileContext{Profile: "work", ProfileAppID: "cli_other", UserOpenID: "ou_other"}
if err := manager.Get(context.Background(), CredentialInput{Protocol: "https", Host: "example.com", Path: "/git/u/app.git"}, other, &out, &errOut); err != nil {
t.Fatalf("Get returned error: %v", err)
}
if out.Len() != 0 {
t.Fatalf("stdout = %q, want empty", out.String())
}
if !bytes.Contains(errOut.Bytes(), []byte("current login does not match")) {
t.Fatalf("stderr missing login mismatch: %q", errOut.String())
}
}
func TestManagerGetAllowsProfileRenameForSameLogin(t *testing.T) {
now := time.Unix(1780000000, 0)
kc := newFakeKeychain()
manager := NewManager(NewStoreAt(filepath.Join(t.TempDir(), MetadataFilename)), NewSecretStore(kc), nil, &fakeIssuer{next: &IssuedCredential{
GitHTTPURL: "https://example.com/git/u/app.git",
Username: "x-access-token",
PAT: "pat",
ExpiresAt: now.Add(24 * time.Hour).Unix(),
}})
manager.Now = func() time.Time { return now }
if _, err := manager.Init(context.Background(), testProfile(), "app_xxx"); err != nil {
t.Fatalf("Init returned error: %v", err)
}
renamed := testProfile()
renamed.Profile = "renamed-profile"
var out bytes.Buffer
var errOut bytes.Buffer
if err := manager.Get(context.Background(), CredentialInput{Protocol: "https", Host: "example.com", Path: "/git/u/app.git"}, renamed, &out, &errOut); err != nil {
t.Fatalf("Get returned error: %v", err)
}
if got := out.String(); got != "username=x-access-token\npassword=pat\n\n" {
t.Fatalf("credential output = %q", got)
}
if errOut.Len() != 0 {
t.Fatalf("stderr = %q, want empty", errOut.String())
}
}
func TestEraseMarksInvalidatedWithCooldown(t *testing.T) {
now := time.Unix(1780000000, 0)
kc := newFakeKeychain()
issuer := &fakeIssuer{next: &IssuedCredential{
GitHTTPURL: "https://example.com/git/u/app.git",
PAT: "pat",
ExpiresAt: now.Add(24 * time.Hour).Unix(),
}}
manager := NewManager(NewStoreAt(filepath.Join(t.TempDir(), MetadataFilename)), NewSecretStore(kc), nil, issuer)
manager.Now = func() time.Time { return now }
if _, err := manager.Init(context.Background(), testProfile(), "app_xxx"); err != nil {
t.Fatalf("Init returned error: %v", err)
}
input := "protocol=https\nhost=example.com\npath=/git/u/app.git\n\n"
if err := manager.Erase(bytes.NewBufferString(input)); err != nil {
t.Fatalf("Erase returned error: %v", err)
}
if err := manager.Erase(bytes.NewBufferString(input)); err != nil {
t.Fatalf("second Erase returned error: %v", err)
}
if len(kc.removed) != 1 {
t.Fatalf("removed count = %d, want 1", len(kc.removed))
}
record, err := manager.Store.FindByURL("https://example.com/git/u/app.git")
if err != nil {
t.Fatalf("FindByURL returned error: %v", err)
}
if record.InvalidatedAt == 0 || record.LastEraseAt == 0 {
t.Fatalf("record was not invalidated: %#v", record)
}
}
func TestEraseLockAndSecondReadBranches(t *testing.T) {
now := time.Unix(1780000000, 0)
kc := newFakeKeychain()
manager := NewManager(NewStoreAt(filepath.Join(t.TempDir(), MetadataFilename)), NewSecretStore(kc), nil, &fakeIssuer{next: &IssuedCredential{
GitHTTPURL: "https://example.com/git/u/app.git",
PAT: "pat",
ExpiresAt: now.Add(24 * time.Hour).Unix(),
}})
manager.Now = func() time.Time { return now }
if _, err := manager.Init(context.Background(), testProfile(), "app_xxx"); err != nil {
t.Fatalf("Init returned error: %v", err)
}
blocker := filepath.Join(t.TempDir(), "config-blocker")
if err := os.WriteFile(blocker, []byte("file"), 0600); err != nil {
t.Fatalf("write config blocker: %v", err)
}
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", blocker)
input := "protocol=https\nhost=example.com\npath=/git/u/app.git\n\n"
if err := manager.Erase(bytes.NewBufferString(input)); err == nil || !strings.Contains(err.Error(), "create Git credential lock dir") {
t.Fatalf("Erase lock error = %v", err)
}
}
func TestEraseSecondReadMissingReturnsNil(t *testing.T) {
now := time.Unix(1780000000, 0)
kc := newFakeKeychain()
record := CredentialRecord{
AppID: "app_xxx",
GitHTTPURL: "https://example.com/git/u/app.git",
Profile: "default",
ProfileAppID: "cli_xxx",
UserOpenID: "ou_xxx",
Username: "x-access-token",
PATRef: "ref",
Status: StatusConfirmed,
ExpiresAt: now.Add(24 * time.Hour).Unix(),
}
data, err := json.Marshal(CredentialFile{Version: CurrentCredentialVersion, CredentialRecord: record})
if err != nil {
t.Fatalf("marshal credential file: %v", err)
}
manager := NewManager(NewAppStore("app_xxx", &sequenceAppStorage{reads: [][]byte{data, nil}}), NewSecretStore(kc), nil, nil)
manager.Now = func() time.Time { return now }
input := "protocol=https\nhost=example.com\npath=/git/u/app.git\n\n"
if err := manager.Erase(bytes.NewBufferString(input)); err != nil {
t.Fatalf("Erase second read missing returned error: %v", err)
}
}
func TestStoreCredentialDrainsStdin(t *testing.T) {
manager := NewManager(NewStoreAt(filepath.Join(t.TempDir(), MetadataFilename)), NewSecretStore(newFakeKeychain()), nil, nil)
if err := manager.StoreCredential(bytes.NewBufferString("protocol=https\nhost=example.com\n\n")); err != nil {
t.Fatalf("StoreCredential returned error: %v", err)
}
}
func TestRemoveDeletesMetadataSecretAndGitConfig(t *testing.T) {
now := time.Unix(1780000000, 0)
kc := newFakeKeychain()
gitConfig := &fakeGitConfig{}
issuer := &fakeIssuer{next: &IssuedCredential{
GitHTTPURL: "https://example.com/git/u/app.git",
PAT: "pat",
ExpiresAt: now.Add(24 * time.Hour).Unix(),
}}
manager := NewManager(NewStoreAt(filepath.Join(t.TempDir(), MetadataFilename)), NewSecretStore(kc), gitConfig, issuer)
manager.Now = func() time.Time { return now }
if _, err := manager.Init(context.Background(), testProfile(), "app_xxx"); err != nil {
t.Fatalf("Init returned error: %v", err)
}
result, err := manager.Remove(context.Background(), testProfile(), "app_xxx")
if err != nil {
t.Fatalf("Remove returned error: %v", err)
}
if !result.Removed || len(result.Records) != 1 {
t.Fatalf("remove result = %#v", result)
}
if got := kc.values[result.Records[0].PATRef]; got != "" {
t.Fatalf("keychain PAT after remove = %q, want empty", got)
}
if len(gitConfig.unset) != 1 || gitConfig.unset[0] != "https://example.com/git/u/app.git" {
t.Fatalf("git config unset = %#v", gitConfig.unset)
}
record, err := manager.Store.FindByURL("https://example.com/git/u/app.git")
if err != nil {
t.Fatalf("FindByURL returned error: %v", err)
}
if record != nil {
t.Fatalf("record after remove = %#v, want nil", record)
}
}
func TestInitWorksAfterRemove(t *testing.T) {
now := time.Unix(1780000000, 0)
kc := newFakeKeychain()
gitConfig := &fakeGitConfig{}
issuer := &fakeIssuer{next: &IssuedCredential{
GitHTTPURL: "https://example.com/git/u/app.git",
PAT: "first-pat",
ExpiresAt: now.Add(24 * time.Hour).Unix(),
}}
manager := NewManager(NewStoreAt(filepath.Join(t.TempDir(), MetadataFilename)), NewSecretStore(kc), gitConfig, issuer)
manager.Now = func() time.Time { return now }
if _, err := manager.Init(context.Background(), testProfile(), "app_xxx"); err != nil {
t.Fatalf("initial Init returned error: %v", err)
}
if _, err := manager.Remove(context.Background(), testProfile(), "app_xxx"); err != nil {
t.Fatalf("Remove returned error: %v", err)
}
issuer.next = &IssuedCredential{
GitHTTPURL: "https://example.com/git/u/app.git",
PAT: "second-pat",
ExpiresAt: now.Add(48 * time.Hour).Unix(),
}
result, err := manager.Init(context.Background(), testProfile(), "app_xxx")
if err != nil {
t.Fatalf("Init after Remove returned error: %v", err)
}
if result.Refreshed {
t.Fatalf("Init after Remove marked refreshed, want fresh init")
}
record, err := manager.Store.FindByURL("https://example.com/git/u/app.git")
if err != nil {
t.Fatalf("FindByURL returned error: %v", err)
}
if record == nil || record.Status != StatusConfirmed {
t.Fatalf("record after re-init = %#v, want confirmed", record)
}
if got := kc.values[record.PATRef]; got != "second-pat" {
t.Fatalf("PAT after re-init = %q, want second-pat", got)
}
if len(gitConfig.set) != 2 {
t.Fatalf("git config set calls = %#v, want initial and re-init", gitConfig.set)
}
if len(gitConfig.unset) != 1 {
t.Fatalf("git config unset calls = %#v, want remove cleanup", gitConfig.unset)
}
}
func TestRemoveIgnoresCurrentProfileMismatch(t *testing.T) {
now := time.Unix(1780000000, 0)
kc := newFakeKeychain()
gitConfig := &fakeGitConfig{}
manager := NewManager(NewStoreAt(filepath.Join(t.TempDir(), MetadataFilename)), NewSecretStore(kc), gitConfig, &fakeIssuer{next: &IssuedCredential{
GitHTTPURL: "https://example.com/git/u/app.git",
PAT: "pat",
ExpiresAt: now.Add(24 * time.Hour).Unix(),
}})
manager.Now = func() time.Time { return now }
if _, err := manager.Init(context.Background(), testProfile(), "app_xxx"); err != nil {
t.Fatalf("Init returned error: %v", err)
}
other := ProfileContext{Profile: "work", ProfileAppID: "other_cli", UserOpenID: "ou_other"}
result, err := manager.Remove(context.Background(), other, "app_xxx")
if err != nil {
t.Fatalf("Remove with profile mismatch returned error: %v", err)
}
if !result.Removed {
t.Fatalf("Remove with profile mismatch did not remove: %#v", result)
}
}
func TestRemoveWithoutRecordDoesNotTouchKeychainOrGitConfig(t *testing.T) {
kc := newFakeKeychain()
gitConfig := &fakeGitConfig{}
manager := NewManager(NewStoreAt(filepath.Join(t.TempDir(), MetadataFilename)), NewSecretStore(kc), gitConfig, nil)
result, err := manager.Remove(context.Background(), testProfile(), "app_xxx")
if err != nil {
t.Fatalf("Remove without record returned error: %v", err)
}
if result.Removed {
t.Fatalf("Remove without record marked removed: %#v", result)
}
if len(kc.removed) != 0 {
t.Fatalf("keychain removals = %#v, want none", kc.removed)
}
if len(gitConfig.unset) != 0 {
t.Fatalf("git config unsets = %#v, want none", gitConfig.unset)
}
}
func TestListReportsCredentialStatuses(t *testing.T) {
now := time.Unix(1780000000, 0)
for _, tc := range []struct {
name string
mutate func(*CredentialRecord, *fakeKeychain)
want string
expired bool
}{
{
name: "valid",
want: ListStatusValid,
},
{
name: "expired",
mutate: func(record *CredentialRecord, kc *fakeKeychain) {
record.ExpiresAt = now.Add(-time.Minute).Unix()
},
want: ListStatusExpired,
expired: true,
},
{
name: "invalidated",
mutate: func(record *CredentialRecord, kc *fakeKeychain) {
record.InvalidatedAt = now.Unix()
},
want: ListStatusInvalidated,
},
{
name: "missing-secret",
mutate: func(record *CredentialRecord, kc *fakeKeychain) {
delete(kc.values, record.PATRef)
},
want: ListStatusMissingSecret,
},
{
name: "incomplete",
mutate: func(record *CredentialRecord, kc *fakeKeychain) {
record.Status = StatusPending
record.PATRef = ""
},
want: ListStatusIncomplete,
},
} {
t.Run(tc.name, func(t *testing.T) {
kc := newFakeKeychain()
manager := NewManager(NewStoreAt(filepath.Join(t.TempDir(), MetadataFilename)), NewSecretStore(kc), nil, nil)
manager.Now = func() time.Time { return now }
record := CredentialRecord{
AppID: "app_xxx",
GitHTTPURL: "https://example.com/git/u/app.git",
Profile: "default",
ProfileAppID: "cli_xxx",
UserOpenID: "ou_xxx",
Username: "x-access-token",
PATRef: "ref",
Status: StatusConfirmed,
ExpiresAt: now.Add(time.Hour).Unix(),
UpdatedAt: now.Unix(),
}
kc.values[record.PATRef] = "pat"
if tc.mutate != nil {
tc.mutate(&record, kc)
}
if err := manager.Store.Upsert(record); err != nil {
t.Fatalf("Upsert returned error: %v", err)
}
result, err := manager.List()
if err != nil {
t.Fatalf("List returned error: %v", err)
}
if len(result.Records) != 1 {
t.Fatalf("records = %#v, want one", result.Records)
}
got := result.Records[0]
if got.Status != tc.want || got.Expired != tc.expired {
t.Fatalf("list record = %#v, want status=%s expired=%v", got, tc.want, tc.expired)
}
if got.AppID != record.AppID || got.GitHTTPURL != record.GitHTTPURL || got.ProfileAppID != record.ProfileAppID || got.UserOpenID != record.UserOpenID {
t.Fatalf("list record lost metadata: %#v", got)
}
})
}
}
func TestListReturnsStoreError(t *testing.T) {
manager := NewManager(NewStoreAt(filepath.Join(t.TempDir(), MetadataFilename)), NewSecretStore(newFakeKeychain()), nil, nil)
if err := os.WriteFile(manager.Store.Path(), []byte("{bad json"), 0600); err != nil {
t.Fatalf("write invalid metadata: %v", err)
}
if _, err := manager.List(); err == nil || !strings.Contains(err.Error(), "invalid git.json") {
t.Fatalf("List store error = %v", err)
}
}
func TestGlobalGitConfigSetAndUnsetHelper(t *testing.T) {
logPath := installFakeGit(t, 0)
cfg := GlobalGitConfig{HelperCommand: "!custom-helper"}
ctx := context.Background()
if err := cfg.SetHelper(ctx, "https://example.com/git/u/app.git", "app_xxx"); err != nil {
t.Fatalf("SetHelper returned error: %v", err)
}
if err := cfg.UnsetHelper(ctx, "https://example.com/git/u/app.git"); err != nil {
t.Fatalf("UnsetHelper returned error: %v", err)
}
log := readFileString(t, logPath)
for _, want := range []string{
"config --global credential.https://example.com/git/u/app.git.helper !custom-helper",
"config --global credential.https://example.com/git/u/app.git.useHttpPath true",
"config --global --unset credential.https://example.com/git/u/app.git.helper",
"config --global --unset credential.https://example.com/git/u/app.git.useHttpPath",
} {
if !strings.Contains(log, want) {
t.Fatalf("git log missing %q in:\n%s", want, log)
}
}
}
func TestGlobalGitConfigNormalizesCredentialKeyURL(t *testing.T) {
logPath := installFakeGit(t, 0)
cfg := GlobalGitConfig{HelperCommand: "!custom-helper"}
rawURL := "HTTPS://[2001:DB8::1]:443//repo.git?x=1"
if err := cfg.SetHelper(context.Background(), rawURL, "app_xxx"); err != nil {
t.Fatalf("SetHelper returned error: %v", err)
}
if err := cfg.UnsetHelper(context.Background(), rawURL); err != nil {
t.Fatalf("UnsetHelper returned error: %v", err)
}
log := readFileString(t, logPath)
for _, want := range []string{
"config --global credential.https://[2001:db8::1]/repo.git.helper !custom-helper",
"config --global credential.https://[2001:db8::1]/repo.git.useHttpPath true",
"config --global --unset credential.https://[2001:db8::1]/repo.git.helper",
"config --global --unset credential.https://[2001:db8::1]/repo.git.useHttpPath",
} {
if !strings.Contains(log, want) {
t.Fatalf("git log missing normalized key %q in:\n%s", want, log)
}
}
}
func TestGlobalGitConfigRollsBackHelperWhenUseHttpPathFails(t *testing.T) {
logPath := installFakeGit(t, 7)
err := (GlobalGitConfig{}).SetHelper(context.Background(), "https://example.com/git/u/app.git", "app_xxx")
if err == nil {
t.Fatal("SetHelper returned nil error, want git failure")
}
log := readFileString(t, logPath)
if !strings.Contains(log, "config --global --unset credential.https://example.com/git/u/app.git.helper") {
t.Fatalf("git log missing rollback unset:\n%s", log)
}
}
func TestGlobalGitConfigQuotesDefaultHelperAppID(t *testing.T) {
logPath := installFakeGit(t, 0)
appID := "app_xxx; touch /tmp/pwned"
if err := (GlobalGitConfig{}).SetHelper(context.Background(), "https://example.com/git/u/app.git", appID); err != nil {
t.Fatalf("SetHelper returned error: %v", err)
}
log := readFileString(t, logPath)
want := "helper !lark-cli apps git-credential-helper --app-id 'app_xxx; touch /tmp/pwned'"
if !strings.Contains(log, want) {
t.Fatalf("git log missing quoted helper %q in:\n%s", want, log)
}
}
func TestGlobalGitConfigReturnsFirstGitCommandError(t *testing.T) {
installAlwaysFailingGit(t)
err := (GlobalGitConfig{}).SetHelper(context.Background(), "https://example.com/git/u/app.git", "app_xxx")
if err == nil {
t.Fatal("SetHelper returned nil error, want first git command failure")
}
}
func TestGlobalGitConfigUnsetReportsUnexpectedErrors(t *testing.T) {
installAlwaysFailingGit(t)
err := (GlobalGitConfig{}).UnsetHelper(context.Background(), "https://example.com/git/u/app.git")
if err == nil || !strings.Contains(err.Error(), "get credential.https://example.com/git/u/app.git.helper") {
t.Fatalf("UnsetHelper error = %v", err)
}
}
func TestGlobalGitConfigDoesNotOverwriteOrUnsetNonLarkHelper(t *testing.T) {
logPath := installFakeGitWithGet(t, "!other-helper")
cfg := GlobalGitConfig{}
err := cfg.SetHelper(context.Background(), "https://example.com/git/u/app.git", "app_xxx")
if err == nil || !strings.Contains(err.Error(), "refusing to overwrite non-lark helper") {
t.Fatalf("SetHelper error = %v", err)
}
if err := cfg.UnsetHelper(context.Background(), "https://example.com/git/u/app.git"); err != nil {
t.Fatalf("UnsetHelper returned error: %v", err)
}
log := readFileString(t, logPath)
for _, unwanted := range []string{
"credential.https://example.com/git/u/app.git.helper !lark-cli",
"--unset credential.https://example.com/git/u/app.git.helper",
"--unset credential.https://example.com/git/u/app.git.useHttpPath",
} {
if strings.Contains(log, unwanted) {
t.Fatalf("git log contains unwanted %q in:\n%s", unwanted, log)
}
}
}
func TestGlobalGitConfigUnsetIgnoresMissingManagedKeys(t *testing.T) {
logPath := installFakeGitWithGetAndUnsetExit(t, "!lark-cli apps git-credential-helper --app-id app_xxx", 5)
if err := (GlobalGitConfig{}).UnsetHelper(context.Background(), "https://example.com/git/u/app.git"); err != nil {
t.Fatalf("UnsetHelper returned error: %v", err)
}
log := readFileString(t, logPath)
for _, want := range []string{
"config --global --unset credential.https://example.com/git/u/app.git.helper",
"config --global --unset credential.https://example.com/git/u/app.git.useHttpPath",
} {
if !strings.Contains(log, want) {
t.Fatalf("git log missing %q in:\n%s", want, log)
}
}
}
func TestGlobalGitConfigAdditionalBranches(t *testing.T) {
if err := (GlobalGitConfig{}).SetHelper(context.Background(), "ssh://example.com/git/u/app.git", "app_xxx"); err == nil {
t.Fatal("SetHelper invalid URL returned nil error")
}
if err := (GlobalGitConfig{}).UnsetHelper(context.Background(), "ssh://example.com/git/u/app.git"); err == nil {
t.Fatal("UnsetHelper invalid URL returned nil error")
}
if err := (GlobalGitConfig{}).SetHelper(context.Background(), "https://example.com/git/u/app.git", "../bad"); err == nil {
t.Fatal("SetHelper invalid appID returned nil error")
}
installFakeGitSetFails(t)
if err := (GlobalGitConfig{}).SetHelper(context.Background(), "https://example.com/git/u/app.git", "app_xxx"); err == nil {
t.Fatal("SetHelper set failure returned nil error")
}
logPath := installFakeGitWithGetAndUseHTTPPathFailure(t, "!lark-cli apps git-credential-helper --app-id old_app", 7)
if err := (GlobalGitConfig{}).SetHelper(context.Background(), "https://example.com/git/u/app.git", "app_xxx"); err == nil {
t.Fatal("SetHelper useHttpPath failure returned nil error")
}
log := readFileString(t, logPath)
if !strings.Contains(log, "config --global credential.https://example.com/git/u/app.git.helper !lark-cli apps git-credential-helper --app-id old_app") {
t.Fatalf("git log missing previous helper restore:\n%s", log)
}
installFakeGitWithGetAndUnsetExit(t, "!lark-cli apps git-credential-helper --app-id app_xxx", 9)
if err := (GlobalGitConfig{}).UnsetHelper(context.Background(), "https://example.com/git/u/app.git"); err == nil || !strings.Contains(err.Error(), "unset credential.https://example.com/git/u/app.git.helper") {
t.Fatalf("UnsetHelper helper unset error = %v", err)
}
logPath = installFakeGitWithGetAndSecondUnsetFails(t, "!lark-cli apps git-credential-helper --app-id app_xxx")
if err := (GlobalGitConfig{}).UnsetHelper(context.Background(), "https://example.com/git/u/app.git"); err == nil || !strings.Contains(err.Error(), "unset credential.https://example.com/git/u/app.git.useHttpPath") {
t.Fatalf("UnsetHelper useHttpPath unset error = %v", err)
}
if !strings.Contains(readFileString(t, logPath), "--unset credential.https://example.com/git/u/app.git.useHttpPath") {
t.Fatalf("git log missing useHttpPath unset:\n%s", readFileString(t, logPath))
}
cfg := GlobalGitConfig{HelperCommand: "!custom-helper"}
if !cfg.isManagedHelper(" !custom-helper ") {
t.Fatal("custom helper should be managed")
}
if cfg.isManagedHelper("!other-helper") {
t.Fatal("other helper should not be managed")
}
if isGitConfigUnsetMissing(errors.New("plain error")) {
t.Fatal("plain error must not be treated as missing git config")
}
}
func TestStoreLoadSaveAndQueryBranches(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, MetadataFilename)
store := NewStoreAt(path)
if store.Path() != path {
t.Fatalf("Path() = %q", store.Path())
}
empty, err := store.Load()
if err != nil {
t.Fatalf("Load missing file returned error: %v", err)
}
if empty.Version != CurrentCredentialVersion || empty.GitHTTPURL != "" {
t.Fatalf("empty file = %#v", empty)
}
if err := store.Save(nil); err != nil {
t.Fatalf("Save(nil) returned error: %v", err)
}
file, err := store.Load()
if err != nil {
t.Fatalf("Load after Save(nil) returned error: %v", err)
}
if file.Version != CurrentCredentialVersion {
t.Fatalf("Version after Save(nil) = %d", file.Version)
}
if err := os.WriteFile(path, []byte{}, 0600); err != nil {
t.Fatalf("write empty metadata: %v", err)
}
empty, err = store.Load()
if err != nil {
t.Fatalf("Load empty file returned error: %v", err)
}
if empty.Version != CurrentCredentialVersion {
t.Fatalf("empty file version = %d", empty.Version)
}
emptyRecords, err := store.Records()
if err != nil {
t.Fatalf("Records empty file returned error: %v", err)
}
if len(emptyRecords) != 0 {
t.Fatalf("empty records = %#v, want none", emptyRecords)
}
recordB := CredentialRecord{AppID: "app_a", GitHTTPURL: "https://example.com/git/a.git", Profile: "default", ProfileAppID: "cli", UserOpenID: "ou", Status: StatusConfirmed}
recordC := CredentialRecord{AppID: "app_a", GitHTTPURL: "https://example.com/git/c.git", Profile: "default", ProfileAppID: "cli", UserOpenID: "ou", Status: StatusConfirmed}
if err := store.Upsert(recordB); err != nil {
t.Fatalf("Upsert B returned error: %v", err)
}
if err := store.Upsert(recordC); err != nil {
t.Fatalf("Upsert C returned error: %v", err)
}
records, err := store.Records()
if err != nil {
t.Fatalf("Records returned error: %v", err)
}
if len(records) != 1 || records[0].GitHTTPURL != recordC.GitHTTPURL {
t.Fatalf("records = %#v, want latest app-scoped record", records)
}
matches, err := store.FindByAppID("app_a", ProfileContext{Profile: "default", ProfileAppID: "cli", UserOpenID: "ou"})
if err != nil {
t.Fatalf("FindByAppID returned error: %v", err)
}
if len(matches) != 1 || matches[0].GitHTTPURL != recordC.GitHTTPURL {
t.Fatalf("matches = %#v", matches)
}
matches, err = store.FindByAppID("app_a", ProfileContext{Profile: "work"})
if err != nil {
t.Fatalf("FindByAppID with profile mismatch returned error: %v", err)
}
if len(matches) != 0 {
t.Fatalf("profile mismatch matches = %#v, want empty", matches)
}
matches, err = store.FindByAppID("app_other", ProfileContext{})
if err != nil {
t.Fatalf("FindByAppID app mismatch returned error: %v", err)
}
if len(matches) != 0 {
t.Fatalf("app mismatch matches = %#v, want empty", matches)
}
for _, profile := range []ProfileContext{
{Profile: "default", ProfileAppID: "other", UserOpenID: "ou"},
{Profile: "default", ProfileAppID: "cli", UserOpenID: "other"},
} {
matches, err = store.FindByAppID("app_a", profile)
if err != nil {
t.Fatalf("FindByAppID mismatch returned error: %v", err)
}
if len(matches) != 0 {
t.Fatalf("FindByAppID mismatch %#v returned %#v, want empty", profile, matches)
}
}
deleted, err := store.DeleteByURL("https://example.com/git/missing.git")
if err != nil || deleted != nil {
t.Fatalf("DeleteByURL missing = %#v, %v; want nil, nil", deleted, err)
}
deleted, err = store.DeleteByURL(recordC.GitHTTPURL)
if err != nil {
t.Fatalf("DeleteByURL returned error: %v", err)
}
if deleted == nil || deleted.AppID != recordC.AppID {
t.Fatalf("deleted = %#v", deleted)
}
if _, err := store.Records(); err != nil {
t.Fatalf("Records after delete returned error: %v", err)
}
if err := os.WriteFile(path, []byte("{bad json"), 0600); err != nil {
t.Fatalf("write invalid metadata: %v", err)
}
if _, err := store.Records(); err == nil {
t.Fatal("Records invalid metadata returned nil error")
}
if _, err := store.FindByAppID("app_a", ProfileContext{}); err == nil {
t.Fatal("FindByAppID invalid metadata returned nil error")
}
}
func TestAppStoreUsesAppScopedStorage(t *testing.T) {
storage := newFakeAppStorage()
store := NewAppStore("app_xxx", storage)
if got := store.Path(); got != "apps:app_xxx/"+MetadataFilename {
t.Fatalf("Path() = %q, want app-scoped path", got)
}
empty, err := store.Load()
if err != nil {
t.Fatalf("Load missing app storage returned error: %v", err)
}
if empty.Version != CurrentCredentialVersion {
t.Fatalf("empty version = %d, want %d", empty.Version, CurrentCredentialVersion)
}
record := CredentialRecord{AppID: "app_xxx", GitHTTPURL: "https://example.com/git/u/app.git", Profile: "default", ProfileAppID: "cli_xxx", UserOpenID: "ou_xxx", Status: StatusConfirmed}
if err := store.Upsert(record); err != nil {
t.Fatalf("Upsert app storage returned error: %v", err)
}
if storage.values["app_xxx/"+MetadataFilename] == nil {
t.Fatalf("app storage missing metadata key")
}
records, err := store.Records()
if err != nil {
t.Fatalf("Records app storage returned error: %v", err)
}
if len(records) != 1 || records[0].GitHTTPURL != record.GitHTTPURL {
t.Fatalf("records = %#v, want stored record", records)
}
deleted, err := store.DeleteByURL(record.GitHTTPURL)
if err != nil {
t.Fatalf("DeleteByURL app storage returned error: %v", err)
}
if deleted == nil || deleted.GitHTTPURL != record.GitHTTPURL {
t.Fatalf("deleted = %#v, want stored record", deleted)
}
if storage.values["app_xxx/"+MetadataFilename] != nil {
t.Fatalf("app storage metadata still present after delete")
}
}
func TestNewStoreUsesConfigDir(t *testing.T) {
configDir := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", configDir)
if got := NewStore().Path(); got != filepath.Join(configDir, MetadataFilename) {
t.Fatalf("NewStore path = %q", got)
}
}
func TestStoreLoadRejectsInvalidAndNewerVersions(t *testing.T) {
path := filepath.Join(t.TempDir(), MetadataFilename)
if err := os.WriteFile(path, []byte("{bad json"), 0600); err != nil {
t.Fatalf("write invalid json: %v", err)
}
if _, err := NewStoreAt(path).Load(); err == nil {
t.Fatal("Load invalid json returned nil error")
} else {
var cfgErr *errs.ConfigError
if !errors.As(err, &cfgErr) {
t.Fatalf("Load invalid json error = %T %v, want ConfigError", err, err)
}
}
if err := os.WriteFile(path, []byte(`{"version":99,"credentials":{}}`), 0600); err != nil {
t.Fatalf("write newer version: %v", err)
}
if _, err := NewStoreAt(path).Load(); err == nil {
t.Fatal("Load newer version returned nil error")
}
if err := os.WriteFile(path, []byte(`{"credentials":null}`), 0600); err != nil {
t.Fatalf("write version 0: %v", err)
}
file, err := NewStoreAt(path).Load()
if err != nil {
t.Fatalf("Load version 0 returned error: %v", err)
}
if file.Version != CurrentCredentialVersion {
t.Fatalf("version 0 upgrade = %#v", file)
}
if _, err := NewStoreAt(t.TempDir()).Load(); err == nil {
t.Fatal("Load directory path returned nil error")
}
if err := NewStoreAt(path).Upsert(CredentialRecord{GitHTTPURL: "https://example.com/repo.git"}); err != nil {
t.Fatalf("Upsert after version 0 returned error: %v", err)
}
if err := os.WriteFile(path, []byte("{bad json"), 0600); err != nil {
t.Fatalf("rewrite invalid json: %v", err)
}
if err := NewStoreAt(path).Upsert(CredentialRecord{GitHTTPURL: "https://example.com/repo.git"}); err == nil {
t.Fatal("Upsert invalid json returned nil error")
}
if _, err := NewStoreAt(path).DeleteByURL("https://example.com/repo.git"); err == nil {
t.Fatal("DeleteByURL invalid json returned nil error")
}
}
func TestStoreSaveReturnsMkdirError(t *testing.T) {
blocker := filepath.Join(t.TempDir(), "blocker")
if err := os.WriteFile(blocker, []byte("file"), 0600); err != nil {
t.Fatalf("write blocker: %v", err)
}
store := NewStoreAt(filepath.Join(blocker, MetadataFilename))
if err := store.Save(&CredentialFile{}); err == nil {
t.Fatal("Save returned nil error, want mkdir error")
}
}
func TestNormalizeGitHTTPURLBranches(t *testing.T) {
tests := []struct {
name string
raw string
want string
wantErr bool
}{
{name: "empty", raw: " ", wantErr: true},
{name: "bad parse", raw: "https://%zz", wantErr: true},
{name: "unsupported", raw: "ssh://example.com/repo.git", wantErr: true},
{name: "empty host", raw: "https:///repo.git", wantErr: true},
{name: "http default port", raw: "http://EXAMPLE.com:80/repo.git/", want: "http://example.com/repo.git"},
{name: "custom port", raw: "https://Example.com:8443//repo.git?x=1", want: "https://example.com:8443/repo.git"},
{name: "ipv6 default port", raw: "HTTPS://[2001:DB8::1]:443//repo.git", want: "https://[2001:db8::1]/repo.git"},
{name: "ipv6 custom port", raw: "https://[2001:db8::1]:8443/repo.git", want: "https://[2001:db8::1]:8443/repo.git"},
{name: "root path", raw: "https://Example.com", want: "https://example.com/"},
}
for _, tt := range tests {
got, err := NormalizeGitHTTPURL(tt.raw)
if tt.wantErr {
if err == nil {
t.Fatalf("%s: NormalizeGitHTTPURL returned nil error", tt.name)
}
continue
}
if err != nil {
t.Fatalf("%s: NormalizeGitHTTPURL returned error: %v", tt.name, err)
}
if got != tt.want {
t.Fatalf("%s: got %q, want %q", tt.name, got, tt.want)
}
}
if got := cleanURLPath("relative/path"); got != "/relative/path" {
t.Fatalf("cleanURLPath(relative/path) = %q", got)
}
if got := cleanURLPath("/%zz"); got != "/%zz" {
t.Fatalf("cleanURLPath(/%%zz) = %q", got)
}
if got := normalizeHostname("[example.com]"); got != "[example.com]" {
t.Fatalf("normalizeHostname([example.com]) = %q", got)
}
if got := normalizeHostname("[2001:db8::1]"); got != "[2001:db8::1]" {
t.Fatalf("normalizeHostname([2001:db8::1]) = %q", got)
}
got, err := normalizeParsedURL(&url.URL{Scheme: "https", Host: "example.com", Path: ".."})
if err != nil {
t.Fatalf("normalizeParsedURL dot path returned error: %v", err)
}
if got != "https://example.com/" {
t.Fatalf("normalizeParsedURL dot path = %q", got)
}
}
func TestNormalizeCredentialInputRequiresProtocolAndHost(t *testing.T) {
if _, err := NormalizeCredentialInput(CredentialInput{Protocol: "https"}); err == nil {
t.Fatal("NormalizeCredentialInput returned nil error for missing host")
}
}
func TestSecretStoreBranches(t *testing.T) {
if got, err := (*SecretStore)(nil).Get("ref"); err != nil || got != "" {
t.Fatalf("nil SecretStore Get = %q, %v", got, err)
}
if err := (*SecretStore)(nil).Remove("ref"); err != nil {
t.Fatalf("nil SecretStore Remove returned error: %v", err)
}
kc := newFakeKeychain()
if got, err := NewSecretStore(kc).Get(""); err != nil || got != "" {
t.Fatalf("empty SecretStore Get = %q, %v", got, err)
}
if err := NewSecretStore(kc).Remove(""); err != nil {
t.Fatalf("empty SecretStore Remove returned error: %v", err)
}
if len(kc.removed) != 0 {
t.Fatalf("keychain removals for empty ref = %#v, want none", kc.removed)
}
if err := NewSecretStore(nil).Remove("ref"); err == nil {
t.Fatal("nil keychain SecretStore Remove returned nil error")
}
if got, err := NewSecretStore(nil).Get("ref"); err != nil || got != "" {
t.Fatalf("nil keychain SecretStore Get = %q, %v", got, err)
}
if err := NewSecretStore(newFakeKeychain()).Set("", "pat"); err == nil {
t.Fatal("SecretStore.Set empty ref returned nil error")
}
kc.removeErr = errors.New("keychain remove failed")
var cfgErr *errs.ConfigError
if err := NewSecretStore(kc).Remove("ref"); err == nil || !errors.As(err, &cfgErr) {
t.Fatalf("SecretStore.Remove keychain error = %T %v, want ConfigError", err, err)
}
}
func TestManagerInitValidationAndIssuerErrors(t *testing.T) {
manager := NewManager(NewStoreAt(filepath.Join(t.TempDir(), MetadataFilename)), NewSecretStore(newFakeKeychain()), nil, nil)
if _, err := manager.Init(context.Background(), testProfile(), " "); err == nil {
t.Fatal("Init empty appID returned nil error")
}
if _, err := manager.Init(context.Background(), testProfile(), "../bad"); err == nil {
t.Fatal("Init invalid appID returned nil error")
}
if _, err := manager.Init(context.Background(), ProfileContext{}, "app_xxx"); err == nil {
t.Fatal("Init without login returned nil error")
}
if _, err := manager.Init(context.Background(), testProfile(), "app_xxx"); err == nil {
t.Fatal("Init without issuer returned nil error")
}
issuer := &fakeIssuer{err: errors.New("api down")}
manager.Issuer = issuer
if _, err := manager.Init(context.Background(), testProfile(), "app_xxx"); err == nil {
t.Fatal("Init with issuer error returned nil error")
}
issuer.err = nil
issuer.next = &IssuedCredential{GitHTTPURL: "ssh://example.com/repo.git", PAT: "pat"}
if _, err := manager.Init(context.Background(), testProfile(), "app_xxx"); err == nil {
t.Fatal("Init invalid URL returned nil error")
}
issuer.next = &IssuedCredential{AppID: "app_other", GitHTTPURL: "https://example.com/repo.git", PAT: "pat", ExpiresAt: time.Now().Add(time.Hour).Unix()}
if _, err := manager.Init(context.Background(), testProfile(), "app_xxx"); err == nil {
t.Fatal("Init mismatched response app_id returned nil error")
}
issuer.next = &IssuedCredential{GitHTTPURL: "https://example.com/repo.git", PAT: "pat", ExpiresAt: time.Now().Add(-time.Hour).Unix()}
if _, err := manager.Init(context.Background(), testProfile(), "app_xxx"); err == nil {
t.Fatal("Init expired credential response returned nil error")
}
issuer.next = &IssuedCredential{GitHTTPURL: "https://example.com/repo.git", ExpiresAt: time.Now().Add(time.Hour).Unix()}
if _, err := manager.Init(context.Background(), testProfile(), "app_xxx"); err == nil || !strings.Contains(err.Error(), "response missing token") {
t.Fatalf("Init empty PAT response error = %v", err)
}
if err := validateIssuedCredential("app_xxx", "", &IssuedCredential{GitHTTPURL: "https://example.com/repo.git", PAT: "pat", ExpiresAt: time.Now().Add(time.Hour).Unix()}, time.Now().Unix()); err == nil {
t.Fatal("validateIssuedCredential missing normalized URL returned nil")
}
if err := validateIssuedCredential("app_xxx", "https://example.com/repo.git", nil, time.Now().Unix()); err == nil {
t.Fatal("validateIssuedCredential nil issued returned nil")
}
}
func TestManagerInitAndRemoveLockFailures(t *testing.T) {
blocker := filepath.Join(t.TempDir(), "config-blocker")
if err := os.WriteFile(blocker, []byte("file"), 0600); err != nil {
t.Fatalf("write config blocker: %v", err)
}
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", blocker)
now := time.Unix(1780000000, 0)
manager := NewManager(NewStoreAt(filepath.Join(t.TempDir(), MetadataFilename)), NewSecretStore(newFakeKeychain()), nil, &fakeIssuer{next: &IssuedCredential{
GitHTTPURL: "https://example.com/git/u/app.git",
PAT: "pat",
ExpiresAt: now.Add(24 * time.Hour).Unix(),
}})
manager.Now = func() time.Time { return now }
if _, err := manager.Init(context.Background(), testProfile(), "app_xxx"); err == nil || !strings.Contains(err.Error(), "create Git credential lock dir") {
t.Fatalf("Init lock error = %v", err)
}
if _, err := manager.Remove(context.Background(), testProfile(), "app_xxx"); err == nil || !strings.Contains(err.Error(), "create Git credential lock dir") {
t.Fatalf("Remove lock error = %v", err)
}
}
func TestLockAppHeldTimesOut(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
unlock, err := lockApp("held/app")
if err != nil {
t.Fatalf("initial lockApp returned error: %v", err)
}
defer unlock()
if _, err := lockApp("held/app"); err == nil {
t.Fatal("second lockApp returned nil error, want held lock timeout")
}
}
func TestManagerInitStoreAndSecretReadErrors(t *testing.T) {
now := time.Unix(1780000000, 0)
path := filepath.Join(t.TempDir(), MetadataFilename)
if err := os.WriteFile(path, []byte("{bad json"), 0600); err != nil {
t.Fatalf("write invalid metadata: %v", err)
}
manager := NewManager(NewStoreAt(path), NewSecretStore(newFakeKeychain()), nil, &fakeIssuer{next: &IssuedCredential{
GitHTTPURL: "https://example.com/git/u/app.git",
PAT: "pat",
ExpiresAt: now.Add(24 * time.Hour).Unix(),
}})
manager.Now = func() time.Time { return now }
if _, err := manager.Init(context.Background(), testProfile(), "app_xxx"); err == nil {
t.Fatal("Init with unreadable metadata returned nil error")
}
kc := newFakeKeychain()
manager = NewManager(NewStoreAt(filepath.Join(t.TempDir(), MetadataFilename)), NewSecretStore(kc), nil, &fakeIssuer{next: &IssuedCredential{
GitHTTPURL: "https://example.com/git/u/app.git",
PAT: "old-pat",
ExpiresAt: now.Add(24 * time.Hour).Unix(),
}})
manager.Now = func() time.Time { return now }
if _, err := manager.Init(context.Background(), testProfile(), "app_xxx"); err != nil {
t.Fatalf("initial Init returned error: %v", err)
}
kc.getErr = errors.New("keychain get failed")
if _, err := manager.Init(context.Background(), testProfile(), "app_xxx"); err != nil {
t.Fatalf("Init should repair existing credential when old secret cannot be read, got %v", err)
}
}
func TestManagerInitPendingWriteError(t *testing.T) {
now := time.Unix(1780000000, 0)
dir := t.TempDir()
path := filepath.Join(dir, MetadataFilename)
if err := NewStoreAt(path).Save(&CredentialFile{}); err != nil {
t.Fatalf("Save seed metadata returned error: %v", err)
}
makeDirReadOnly(t, dir)
manager := NewManager(NewStoreAt(path), NewSecretStore(newFakeKeychain()), nil, &fakeIssuer{next: &IssuedCredential{
GitHTTPURL: "https://example.com/git/u/app.git",
PAT: "pat",
ExpiresAt: now.Add(24 * time.Hour).Unix(),
}})
manager.Now = func() time.Time { return now }
if _, err := manager.Init(context.Background(), testProfile(), "app_xxx"); err == nil {
t.Fatal("Init with pending metadata write error returned nil")
}
}
func TestManagerInitConfirmedWriteErrorRollsBackSecret(t *testing.T) {
now := time.Unix(1780000000, 0)
dir := t.TempDir()
path := filepath.Join(dir, MetadataFilename)
kc := newFakeKeychain()
kc.onSet = func(account, value string) {
if value == "new-pat" {
makeDirReadOnly(t, dir)
}
}
manager := NewManager(NewStoreAt(path), NewSecretStore(kc), nil, &fakeIssuer{next: &IssuedCredential{
GitHTTPURL: "https://example.com/git/u/app.git",
PAT: "new-pat",
ExpiresAt: now.Add(24 * time.Hour).Unix(),
}})
manager.Now = func() time.Time { return now }
if _, err := manager.Init(context.Background(), testProfile(), "app_xxx"); err == nil {
t.Fatal("Init with confirmed metadata write error returned nil")
}
if len(kc.removed) != 1 {
t.Fatalf("removed PAT refs = %#v, want rollback removal", kc.removed)
}
}
func TestManagerInitConfirmedWriteErrorRestoresExistingSecret(t *testing.T) {
now := time.Unix(1780000000, 0)
dir := t.TempDir()
path := filepath.Join(dir, MetadataFilename)
kc := newFakeKeychain()
issuer := &fakeIssuer{next: &IssuedCredential{
GitHTTPURL: "https://example.com/git/u/app.git",
PAT: "old-pat",
ExpiresAt: now.Add(24 * time.Hour).Unix(),
}}
manager := NewManager(NewStoreAt(path), NewSecretStore(kc), nil, issuer)
manager.Now = func() time.Time { return now }
if _, err := manager.Init(context.Background(), testProfile(), "app_xxx"); err != nil {
t.Fatalf("initial Init returned error: %v", err)
}
record, err := manager.Store.FindByURL("https://example.com/git/u/app.git")
if err != nil {
t.Fatalf("FindByURL returned error: %v", err)
}
kc.onSet = func(account, value string) {
if value == "new-pat" {
makeDirReadOnly(t, dir)
}
}
issuer.next = &IssuedCredential{
GitHTTPURL: "https://example.com/git/u/app.git",
PAT: "new-pat",
ExpiresAt: now.Add(48 * time.Hour).Unix(),
}
if _, err := manager.Init(context.Background(), testProfile(), "app_xxx"); err == nil {
t.Fatal("refresh Init with confirmed metadata write error returned nil")
}
if got := kc.values[record.PATRef]; got != "old-pat" {
t.Fatalf("restored PAT = %q, want old-pat", got)
}
}
func TestManagerRemoveValidationNoMatchAndErrors(t *testing.T) {
manager := NewManager(NewStoreAt(filepath.Join(t.TempDir(), MetadataFilename)), NewSecretStore(newFakeKeychain()), nil, nil)
if _, err := manager.Remove(context.Background(), testProfile(), " "); err == nil {
t.Fatal("Remove empty appID returned nil error")
}
if _, err := manager.Remove(context.Background(), testProfile(), "../bad"); err == nil {
t.Fatal("Remove invalid appID returned nil error")
}
result, err := manager.Remove(context.Background(), testProfile(), "app_missing")
if err != nil {
t.Fatalf("Remove missing returned error: %v", err)
}
if result.Removed || len(result.Records) != 0 {
t.Fatalf("Remove missing result = %#v", result)
}
now := time.Unix(1780000000, 0)
kc := newFakeKeychain()
gitConfig := &fakeGitConfig{err: errors.New("git config locked")}
manager = NewManager(NewStoreAt(filepath.Join(t.TempDir(), MetadataFilename)), NewSecretStore(kc), gitConfig, &fakeIssuer{next: &IssuedCredential{
GitHTTPURL: "https://example.com/git/u/app.git",
PAT: "pat",
ExpiresAt: now.Add(24 * time.Hour).Unix(),
}})
manager.Now = func() time.Time { return now }
if _, err := manager.Init(context.Background(), testProfile(), "app_xxx"); err != nil {
t.Fatalf("Init returned error: %v", err)
}
result, err = manager.Remove(context.Background(), testProfile(), "app_xxx")
if err != nil {
t.Fatalf("Remove with git config warning returned error: %v", err)
}
if result == nil || !result.Removed || !strings.Contains(result.ConfigWarning, "git config locked") {
t.Fatalf("Remove result = %#v, want removed with config warning", result)
}
record, err := manager.Store.Current()
if err != nil {
t.Fatalf("Current after remove with config warning returned error: %v", err)
}
if record != nil {
t.Fatalf("metadata should be removed despite git config cleanup warning, got %#v", record)
}
kc = newFakeKeychain()
kc.removeErr = errors.New("keychain remove failed")
manager = NewManager(NewStoreAt(filepath.Join(t.TempDir(), MetadataFilename)), NewSecretStore(kc), nil, &fakeIssuer{next: &IssuedCredential{
GitHTTPURL: "https://example.com/git/u/app.git",
PAT: "pat",
ExpiresAt: now.Add(24 * time.Hour).Unix(),
}})
manager.Now = func() time.Time { return now }
if _, err := manager.Init(context.Background(), testProfile(), "app_xxx"); err != nil {
t.Fatalf("Init returned error: %v", err)
}
if _, err := manager.Remove(context.Background(), testProfile(), "app_xxx"); err == nil {
t.Fatal("Remove with keychain error returned nil error")
}
record, err = manager.Store.Current()
if err != nil {
t.Fatalf("Current after keychain remove error returned error: %v", err)
}
if record == nil {
t.Fatalf("metadata should stay after keychain remove error")
}
}
func TestManagerRemoveStoreErrors(t *testing.T) {
path := filepath.Join(t.TempDir(), MetadataFilename)
if err := os.WriteFile(path, []byte("{bad json"), 0600); err != nil {
t.Fatalf("write invalid metadata: %v", err)
}
manager := NewManager(NewStoreAt(path), NewSecretStore(newFakeKeychain()), nil, nil)
if _, err := manager.Remove(context.Background(), testProfile(), "app_xxx"); err == nil {
t.Fatal("Remove with invalid metadata returned nil error")
}
now := time.Unix(1780000000, 0)
dir := t.TempDir()
path = filepath.Join(dir, MetadataFilename)
manager = NewManager(NewStoreAt(path), NewSecretStore(newFakeKeychain()), nil, &fakeIssuer{next: &IssuedCredential{
GitHTTPURL: "https://example.com/git/u/app.git",
PAT: "pat",
ExpiresAt: now.Add(24 * time.Hour).Unix(),
}})
manager.Now = func() time.Time { return now }
if _, err := manager.Init(context.Background(), testProfile(), "app_xxx"); err != nil {
t.Fatalf("Init returned error: %v", err)
}
makeDirReadOnly(t, dir)
if _, err := manager.Remove(context.Background(), testProfile(), "app_xxx"); err == nil {
t.Fatal("Remove with delete save error returned nil error")
}
}
func TestManagerGetBranches(t *testing.T) {
now := time.Unix(1780000000, 0)
manager := NewManager(NewStoreAt(filepath.Join(t.TempDir(), MetadataFilename)), NewSecretStore(newFakeKeychain()), nil, nil)
manager.Now = func() time.Time { return now }
var out bytes.Buffer
var errOut bytes.Buffer
if err := manager.Get(context.Background(), CredentialInput{Protocol: "https"}, testProfile(), &out, &errOut); err != nil {
t.Fatalf("Get invalid input returned error: %v", err)
}
if !strings.Contains(errOut.String(), "protocol and host") {
t.Fatalf("stderr = %q, want protocol/host validation", errOut.String())
}
out.Reset()
errOut.Reset()
if err := manager.Get(context.Background(), CredentialInput{Protocol: "https", Host: "example.com", Path: "/missing.git"}, testProfile(), &out, &errOut); err != nil {
t.Fatalf("Get missing record returned error: %v", err)
}
if out.Len() != 0 || errOut.Len() != 0 {
t.Fatalf("missing record stdout=%q stderr=%q, want both empty", out.String(), errOut.String())
}
kc := newFakeKeychain()
issuer := &fakeIssuer{next: &IssuedCredential{
GitHTTPURL: "https://example.com/git/u/app.git",
PAT: "old-pat",
ExpiresAt: now.Add(5 * time.Minute).Unix(),
}}
manager = NewManager(NewStoreAt(filepath.Join(t.TempDir(), MetadataFilename)), NewSecretStore(kc), nil, issuer)
manager.Now = func() time.Time { return now }
if _, err := manager.Init(context.Background(), testProfile(), "app_xxx"); err != nil {
t.Fatalf("Init returned error: %v", err)
}
manager.Issuer = nil
errOut.Reset()
if err := manager.Get(context.Background(), CredentialInput{Protocol: "https", Host: "example.com", Path: "/git/u/app.git"}, testProfile(), &out, &errOut); err != nil {
t.Fatalf("Get without issuer returned error: %v", err)
}
if !strings.Contains(errOut.String(), "issuer is not configured") {
t.Fatalf("stderr = %q, want issuer error", errOut.String())
}
issuer.next = &IssuedCredential{
GitHTTPURL: "https://example.com/git/u/app.git",
PAT: "older-pat",
ExpiresAt: now.Add(time.Minute).Unix(),
}
manager.Issuer = issuer
out.Reset()
errOut.Reset()
if err := manager.Get(context.Background(), CredentialInput{Protocol: "https", Host: "example.com", Path: "/git/u/app.git"}, testProfile(), &out, &errOut); err != nil {
t.Fatalf("Get stale refresh returned error: %v", err)
}
if got := out.String(); got != "" {
t.Fatalf("stale refresh output = %q, want empty", got)
}
kc.setErr = errors.New("keychain locked")
issuer.next = &IssuedCredential{
GitHTTPURL: "https://example.com/git/u/app.git",
PAT: "new-pat",
ExpiresAt: now.Add(24 * time.Hour).Unix(),
}
out.Reset()
errOut.Reset()
if err := manager.Get(context.Background(), CredentialInput{Protocol: "https", Host: "example.com", Path: "/git/u/app.git"}, testProfile(), &out, &errOut); err != nil {
t.Fatalf("Get keychain set error returned error: %v", err)
}
if !strings.Contains(errOut.String(), "keychain locked") {
t.Fatalf("stderr = %q, want keychain error", errOut.String())
}
kc.setErr = nil
issuer.next = &IssuedCredential{
GitHTTPURL: "https://example.com/git/u/other.git",
PAT: "other-pat",
ExpiresAt: now.Add(48 * time.Hour).Unix(),
}
out.Reset()
errOut.Reset()
if err := manager.Get(context.Background(), CredentialInput{Protocol: "https", Host: "example.com", Path: "/git/u/app.git"}, testProfile(), &out, &errOut); err != nil {
t.Fatalf("Get URL mismatch returned error: %v", err)
}
if !strings.Contains(errOut.String(), "does not match initialized URL") {
t.Fatalf("stderr = %q, want URL mismatch", errOut.String())
}
issuer.next = &IssuedCredential{
GitHTTPURL: "ssh://example.com/git/u/app.git",
PAT: "pat",
ExpiresAt: now.Add(48 * time.Hour).Unix(),
}
out.Reset()
errOut.Reset()
if err := manager.Get(context.Background(), CredentialInput{Protocol: "https", Host: "example.com", Path: "/git/u/app.git"}, testProfile(), &out, &errOut); err != nil {
t.Fatalf("Get invalid issued URL returned error: %v", err)
}
if !strings.Contains(errOut.String(), "only supports http/https") {
t.Fatalf("stderr = %q, want invalid issued URL", errOut.String())
}
issuer.next = &IssuedCredential{
GitHTTPURL: "https://example.com/git/u/app.git",
ExpiresAt: now.Add(48 * time.Hour).Unix(),
}
out.Reset()
errOut.Reset()
if err := manager.Get(context.Background(), CredentialInput{Protocol: "https", Host: "example.com", Path: "/git/u/app.git"}, testProfile(), &out, &errOut); err != nil {
t.Fatalf("Get invalid issued credential returned error: %v", err)
}
if !strings.Contains(errOut.String(), "response missing token") {
t.Fatalf("stderr = %q, want missing token", errOut.String())
}
kc = newFakeKeychain()
issuer = &fakeIssuer{next: &IssuedCredential{
GitHTTPURL: "https://example.com/git/u/app.git",
PAT: "old-pat",
ExpiresAt: now.Add(5 * time.Minute).Unix(),
}}
manager = NewManager(NewStoreAt(filepath.Join(t.TempDir(), MetadataFilename)), NewSecretStore(kc), nil, issuer)
manager.Now = func() time.Time { return now }
if _, err := manager.Init(context.Background(), testProfile(), "app_xxx"); err != nil {
t.Fatalf("Init for lock failure returned error: %v", err)
}
blocker := filepath.Join(t.TempDir(), "config-blocker")
if err := os.WriteFile(blocker, []byte("file"), 0600); err != nil {
t.Fatalf("write config blocker: %v", err)
}
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", blocker)
errOut.Reset()
if err := manager.Get(context.Background(), CredentialInput{Protocol: "https", Host: "example.com", Path: "/git/u/app.git"}, testProfile(), &bytes.Buffer{}, &errOut); err != nil {
t.Fatalf("Get lock failure returned error: %v", err)
}
if !strings.Contains(errOut.String(), "create Git credential lock dir") {
t.Fatalf("stderr = %q, want lock error", errOut.String())
}
}
func TestManagerGetSecondReadBranches(t *testing.T) {
now := time.Unix(1780000000, 0)
kc := newFakeKeychain()
issuer := &fakeIssuer{next: &IssuedCredential{
GitHTTPURL: "https://example.com/git/u/app.git",
PAT: "old-pat",
ExpiresAt: now.Add(5 * time.Minute).Unix(),
}}
manager := NewManager(NewStoreAt(filepath.Join(t.TempDir(), MetadataFilename)), NewSecretStore(kc), nil, issuer)
manager.Now = func() time.Time { return now }
if _, err := manager.Init(context.Background(), testProfile(), "app_xxx"); err != nil {
t.Fatalf("Init returned error: %v", err)
}
var calls int
kc.onGet = func(account string) {
calls++
if calls == 1 {
kc.values[account] = ""
record, err := manager.Store.FindByURL("https://example.com/git/u/app.git")
if err != nil {
t.Fatalf("FindByURL in onGet returned error: %v", err)
}
record.ExpiresAt = now.Add(24 * time.Hour).Unix()
if err := manager.Store.Upsert(*record); err != nil {
t.Fatalf("Upsert in onGet returned error: %v", err)
}
return
}
kc.values[account] = "restored-pat"
}
var out bytes.Buffer
if err := manager.Get(context.Background(), CredentialInput{Protocol: "https", Host: "example.com", Path: "/git/u/app.git"}, testProfile(), &out, &bytes.Buffer{}); err != nil {
t.Fatalf("Get second usable branch returned error: %v", err)
}
if got := out.String(); got != "username=x-access-token\npassword=restored-pat\n\n" {
t.Fatalf("second usable output = %q", got)
}
kc = newFakeKeychain()
dir := t.TempDir()
manager = NewManager(NewStoreAt(filepath.Join(dir, MetadataFilename)), NewSecretStore(kc), nil, issuer)
manager.Now = func() time.Time { return now }
issuer.next = &IssuedCredential{
GitHTTPURL: "https://example.com/git/u/app.git",
PAT: "old-pat",
ExpiresAt: now.Add(5 * time.Minute).Unix(),
}
if _, err := manager.Init(context.Background(), testProfile(), "app_xxx"); err != nil {
t.Fatalf("Init returned error: %v", err)
}
deleted := false
kc.onGet = func(account string) {
if !deleted {
deleted = true
if err := os.Remove(filepath.Join(dir, MetadataFilename)); err != nil {
t.Fatalf("remove metadata: %v", err)
}
}
}
out.Reset()
if err := manager.Get(context.Background(), CredentialInput{Protocol: "https", Host: "example.com", Path: "/git/u/app.git"}, testProfile(), &out, &bytes.Buffer{}); err != nil {
t.Fatalf("Get second read missing returned error: %v", err)
}
if out.Len() != 0 {
t.Fatalf("second read missing stdout = %q, want empty", out.String())
}
kc = newFakeKeychain()
dir = t.TempDir()
path := filepath.Join(dir, MetadataFilename)
manager = NewManager(NewStoreAt(path), NewSecretStore(kc), nil, issuer)
manager.Now = func() time.Time { return now }
issuer.next = &IssuedCredential{
GitHTTPURL: "https://example.com/git/u/app.git",
PAT: "old-pat",
ExpiresAt: now.Add(5 * time.Minute).Unix(),
}
if _, err := manager.Init(context.Background(), testProfile(), "app_xxx"); err != nil {
t.Fatalf("Init returned error: %v", err)
}
wroteBadMetadata := false
kc.onGet = func(account string) {
if !wroteBadMetadata {
wroteBadMetadata = true
kc.values[account] = ""
if err := os.WriteFile(path, []byte("{bad json"), 0600); err != nil {
t.Fatalf("write invalid metadata: %v", err)
}
}
}
var errOut bytes.Buffer
if err := manager.Get(context.Background(), CredentialInput{Protocol: "https", Host: "example.com", Path: "/git/u/app.git"}, testProfile(), &bytes.Buffer{}, &errOut); err != nil {
t.Fatalf("Get second read error returned error: %v", err)
}
if !strings.Contains(errOut.String(), "invalid git.json") {
t.Fatalf("stderr = %q, want second read error", errOut.String())
}
}
func TestManagerGetRefreshReadAndWriteErrors(t *testing.T) {
now := time.Unix(1780000000, 0)
dir := t.TempDir()
path := filepath.Join(dir, MetadataFilename)
kc := newFakeKeychain()
issuer := &fakeIssuer{next: &IssuedCredential{
GitHTTPURL: "https://example.com/git/u/app.git",
PAT: "old-pat",
ExpiresAt: now.Add(5 * time.Minute).Unix(),
}}
manager := NewManager(NewStoreAt(path), NewSecretStore(kc), nil, issuer)
manager.Now = func() time.Time { return now }
if _, err := manager.Init(context.Background(), testProfile(), "app_xxx"); err != nil {
t.Fatalf("Init returned error: %v", err)
}
issuer.next = &IssuedCredential{
GitHTTPURL: "https://example.com/git/u/app.git",
PAT: "older-pat",
ExpiresAt: now.Add(time.Minute).Unix(),
}
issuer.onIssue = func() {
if err := os.WriteFile(path, []byte("{bad json"), 0600); err != nil {
t.Fatalf("write invalid metadata: %v", err)
}
}
var errOut bytes.Buffer
if err := manager.Get(context.Background(), CredentialInput{Protocol: "https", Host: "example.com", Path: "/git/u/app.git"}, testProfile(), &bytes.Buffer{}, &errOut); err != nil {
t.Fatalf("Get refresh read error returned error: %v", err)
}
if !strings.Contains(errOut.String(), "invalid git.json") {
t.Fatalf("stderr = %q, want read error", errOut.String())
}
dir = t.TempDir()
path = filepath.Join(dir, MetadataFilename)
kc = newFakeKeychain()
issuer = &fakeIssuer{next: &IssuedCredential{
GitHTTPURL: "https://example.com/git/u/app.git",
PAT: "old-pat",
ExpiresAt: now.Add(5 * time.Minute).Unix(),
}}
manager = NewManager(NewStoreAt(path), NewSecretStore(kc), nil, issuer)
manager.Now = func() time.Time { return now }
if _, err := manager.Init(context.Background(), testProfile(), "app_xxx"); err != nil {
t.Fatalf("Init returned error: %v", err)
}
issuer.next = &IssuedCredential{
GitHTTPURL: "https://example.com/git/u/app.git",
PAT: "new-pat",
ExpiresAt: now.Add(24 * time.Hour).Unix(),
}
issuer.onIssue = func() { makeDirReadOnly(t, dir) }
errOut.Reset()
if err := manager.Get(context.Background(), CredentialInput{Protocol: "https", Host: "example.com", Path: "/git/u/app.git"}, testProfile(), &bytes.Buffer{}, &errOut); err != nil {
t.Fatalf("Get refresh write error returned error: %v", err)
}
if !strings.Contains(errOut.String(), "Git credential refresh failed") {
t.Fatalf("stderr = %q, want refresh write error", errOut.String())
}
record, err := manager.Store.FindByURL("https://example.com/git/u/app.git")
if err != nil {
t.Fatalf("FindByURL returned error: %v", err)
}
if got := kc.values[record.PATRef]; got != "old-pat" {
t.Fatalf("PAT after failed refresh = %q, want old-pat", got)
}
dir = t.TempDir()
path = filepath.Join(dir, MetadataFilename)
kc = newFakeKeychain()
issuer = &fakeIssuer{next: &IssuedCredential{
GitHTTPURL: "https://example.com/git/u/app.git",
PAT: "old-pat",
ExpiresAt: now.Add(5 * time.Minute).Unix(),
}}
manager = NewManager(NewStoreAt(path), NewSecretStore(kc), nil, issuer)
manager.Now = func() time.Time { return now }
if _, err := manager.Init(context.Background(), testProfile(), "app_xxx"); err != nil {
t.Fatalf("Init returned error: %v", err)
}
issuer.next = &IssuedCredential{
GitHTTPURL: "https://example.com/git/u/app.git",
PAT: "older-pat",
ExpiresAt: now.Add(time.Minute).Unix(),
}
issuer.onIssue = func() {
if err := os.Remove(path); err != nil {
t.Fatalf("remove metadata: %v", err)
}
}
errOut.Reset()
if err := manager.Get(context.Background(), CredentialInput{Protocol: "https", Host: "example.com", Path: "/git/u/app.git"}, testProfile(), &bytes.Buffer{}, &errOut); err != nil {
t.Fatalf("Get stale refresh missing record returned error: %v", err)
}
if errOut.Len() != 0 {
t.Fatalf("stderr = %q, want empty on stale missing record", errOut.String())
}
}
func TestManagerGetReadErrorsStayOnStderr(t *testing.T) {
path := filepath.Join(t.TempDir(), MetadataFilename)
if err := os.WriteFile(path, []byte("{bad json"), 0600); err != nil {
t.Fatalf("write invalid metadata: %v", err)
}
manager := NewManager(NewStoreAt(path), NewSecretStore(newFakeKeychain()), nil, nil)
var errOut bytes.Buffer
if err := manager.Get(context.Background(), CredentialInput{Protocol: "https", Host: "example.com", Path: "/repo.git"}, testProfile(), &bytes.Buffer{}, &errOut); err != nil {
t.Fatalf("Get returned error: %v", err)
}
if !strings.Contains(errOut.String(), "invalid git.json") {
t.Fatalf("stderr = %q, want config parse error", errOut.String())
}
}
func TestManagerGetSecretReadErrorStaysOnStderr(t *testing.T) {
now := time.Unix(1780000000, 0)
kc := newFakeKeychain()
manager := NewManager(NewStoreAt(filepath.Join(t.TempDir(), MetadataFilename)), NewSecretStore(kc), nil, &fakeIssuer{next: &IssuedCredential{
GitHTTPURL: "https://example.com/git/u/app.git",
PAT: "pat",
ExpiresAt: now.Add(24 * time.Hour).Unix(),
}})
manager.Now = func() time.Time { return now }
if _, err := manager.Init(context.Background(), testProfile(), "app_xxx"); err != nil {
t.Fatalf("Init returned error: %v", err)
}
kc.getErr = errors.New("keychain read failed")
manager.Issuer = nil
var errOut bytes.Buffer
if err := manager.Get(context.Background(), CredentialInput{Protocol: "https", Host: "example.com", Path: "/git/u/app.git"}, testProfile(), &bytes.Buffer{}, &errOut); err != nil {
t.Fatalf("Get returned error: %v", err)
}
if !strings.Contains(errOut.String(), "issuer is not configured") {
t.Fatalf("stderr = %q, want refresh path after secret read failure", errOut.String())
}
}
func TestEraseBranches(t *testing.T) {
manager := NewManager(NewStoreAt(filepath.Join(t.TempDir(), MetadataFilename)), NewSecretStore(newFakeKeychain()), nil, nil)
if err := manager.Erase(errorReader{}); err == nil {
t.Fatal("Erase reader error returned nil error")
}
if err := manager.Erase(bytes.NewBufferString("protocol=ssh\nhost=example.com\n\n")); err == nil {
t.Fatal("Erase invalid URL returned nil error")
}
if err := manager.Erase(bytes.NewBufferString("protocol=https\nhost=example.com\npath=/missing.git\n\n")); err != nil {
t.Fatalf("Erase missing record returned error: %v", err)
}
path := filepath.Join(t.TempDir(), MetadataFilename)
if err := os.WriteFile(path, []byte("{bad json"), 0600); err != nil {
t.Fatalf("write invalid metadata: %v", err)
}
manager = NewManager(NewStoreAt(path), NewSecretStore(newFakeKeychain()), nil, nil)
if err := manager.Erase(bytes.NewBufferString("protocol=https\nhost=example.com\npath=/repo.git\n\n")); err == nil {
t.Fatal("Erase invalid store returned nil error")
}
now := time.Unix(1780000000, 0)
dir := t.TempDir()
path = filepath.Join(dir, MetadataFilename)
manager = NewManager(NewStoreAt(path), NewSecretStore(newFakeKeychain()), nil, &fakeIssuer{next: &IssuedCredential{
GitHTTPURL: "https://example.com/git/u/app.git",
PAT: "pat",
ExpiresAt: now.Add(24 * time.Hour).Unix(),
}})
manager.Now = func() time.Time { return now }
if _, err := manager.Init(context.Background(), testProfile(), "app_xxx"); err != nil {
t.Fatalf("Init returned error: %v", err)
}
makeDirReadOnly(t, dir)
if err := manager.Erase(bytes.NewBufferString("protocol=https\nhost=example.com\npath=/git/u/app.git\n\n")); err == nil {
t.Fatal("Erase with metadata write error returned nil")
}
}
func TestParseCredentialInputURLAndErrors(t *testing.T) {
if _, err := ParseCredentialInput(bytes.NewBufferString("ignored-line\nprotocol=https\nhost=example.com\n\n")); err != nil {
t.Fatalf("ParseCredentialInput ignored line returned error: %v", err)
}
input, err := ParseCredentialInput(bytes.NewBufferString("url=https://example.com/git/u/app.git?x=1\n\n"))
if err != nil {
t.Fatalf("ParseCredentialInput returned error: %v", err)
}
if input.Protocol != "https" || input.Host != "example.com" || input.Path != "/git/u/app.git" {
t.Fatalf("input = %#v", input)
}
input, err = parseNormalizedForInput("https://example.com")
if err != nil {
t.Fatalf("parseNormalizedForInput no slash returned error: %v", err)
}
if input.Path != "/" {
t.Fatalf("no slash path = %q, want /", input.Path)
}
if _, err := parseNormalizedForInput("not-a-url"); err == nil {
t.Fatal("parseNormalizedForInput invalid returned nil error")
}
if _, err := ParseCredentialInput(errorReader{}); err == nil {
t.Fatal("ParseCredentialInput reader error returned nil error")
}
}
func TestWriteGitCredentialBranches(t *testing.T) {
var out bytes.Buffer
if err := writeGitCredential(&out, "", "pat"); err != nil {
t.Fatalf("writeGitCredential empty username returned error: %v", err)
}
if out.Len() != 0 {
t.Fatalf("empty username output = %q", out.String())
}
for _, failAt := range []int{1, 2, 3} {
err := writeGitCredential(&failWriter{failAt: failAt}, "user", "pat")
if err == nil {
t.Fatalf("writeGitCredential failAt=%d returned nil error", failAt)
}
}
}
func TestNilManagerUsesTimeNow(t *testing.T) {
var manager *Manager
if manager.now().IsZero() {
t.Fatal("nil manager now() returned zero time")
}
}
func testProfile() ProfileContext {
return ProfileContext{Profile: "default", ProfileAppID: "cli_xxx", UserOpenID: "ou_xxx"}
}
type errorReader struct{}
func (errorReader) Read(p []byte) (int, error) {
return 0, errors.New("read failed")
}
type failWriter struct {
failAt int
writes int
}
func (w *failWriter) Write(p []byte) (int, error) {
w.writes++
if w.writes >= w.failAt {
return 0, fmt.Errorf("write %d failed", w.writes)
}
return len(p), nil
}
func installFakeGit(t *testing.T, failUseHTTPPathExit int) string {
t.Helper()
dir := t.TempDir()
logPath := filepath.Join(dir, "git.log")
gitPath := filepath.Join(dir, "git")
script := fmt.Sprintf(`#!/bin/sh
printf '%%s\n' "$*" >> "$GIT_FAKE_LOG"
case "$*" in
*"--get"*) exit 1 ;;
esac
case "$*" in
*useHttpPath*) exit %d ;;
esac
exit 0
`, failUseHTTPPathExit)
if failUseHTTPPathExit == 0 {
script = `#!/bin/sh
printf '%s\n' "$*" >> "$GIT_FAKE_LOG"
case "$*" in
*"--get"*) exit 1 ;;
esac
exit 0
`
}
if err := os.WriteFile(gitPath, []byte(script), 0700); err != nil {
t.Fatalf("write fake git: %v", err)
}
t.Setenv("GIT_FAKE_LOG", logPath)
t.Setenv("PATH", dir+string(os.PathListSeparator)+os.Getenv("PATH"))
return logPath
}
func installFakeGitWithGet(t *testing.T, value string) string {
t.Helper()
dir := t.TempDir()
logPath := filepath.Join(dir, "git.log")
gitPath := filepath.Join(dir, "git")
script := fmt.Sprintf(`#!/bin/sh
printf '%%s\n' "$*" >> "$GIT_FAKE_LOG"
case "$*" in
*"--get"*) printf '%%s\n' %s; exit 0 ;;
esac
exit 0
`, shellQuoteArg(value))
if err := os.WriteFile(gitPath, []byte(script), 0700); err != nil {
t.Fatalf("write fake git: %v", err)
}
t.Setenv("GIT_FAKE_LOG", logPath)
t.Setenv("PATH", dir+string(os.PathListSeparator)+os.Getenv("PATH"))
return logPath
}
func installFakeGitWithGetAndUnsetExit(t *testing.T, value string, unsetExit int) string {
t.Helper()
dir := t.TempDir()
logPath := filepath.Join(dir, "git.log")
gitPath := filepath.Join(dir, "git")
script := fmt.Sprintf(`#!/bin/sh
printf '%%s\n' "$*" >> "$GIT_FAKE_LOG"
case "$*" in
*"--get"*) printf '%%s\n' %s; exit 0 ;;
*"--unset"*) exit %d ;;
esac
exit 0
`, shellQuoteArg(value), unsetExit)
if err := os.WriteFile(gitPath, []byte(script), 0700); err != nil {
t.Fatalf("write fake git: %v", err)
}
t.Setenv("GIT_FAKE_LOG", logPath)
t.Setenv("PATH", dir+string(os.PathListSeparator)+os.Getenv("PATH"))
return logPath
}
func installFakeGitSetFails(t *testing.T) string {
t.Helper()
dir := t.TempDir()
logPath := filepath.Join(dir, "git.log")
gitPath := filepath.Join(dir, "git")
script := `#!/bin/sh
printf '%s\n' "$*" >> "$GIT_FAKE_LOG"
case "$*" in
*"--get"*) exit 1 ;;
*".helper "*) exit 8 ;;
esac
exit 0
`
if err := os.WriteFile(gitPath, []byte(script), 0700); err != nil {
t.Fatalf("write fake git: %v", err)
}
t.Setenv("GIT_FAKE_LOG", logPath)
t.Setenv("PATH", dir+string(os.PathListSeparator)+os.Getenv("PATH"))
return logPath
}
func installFakeGitWithGetAndUseHTTPPathFailure(t *testing.T, value string, useHTTPPathExit int) string {
t.Helper()
dir := t.TempDir()
logPath := filepath.Join(dir, "git.log")
gitPath := filepath.Join(dir, "git")
script := fmt.Sprintf(`#!/bin/sh
printf '%%s\n' "$*" >> "$GIT_FAKE_LOG"
case "$*" in
*"--get"*) printf '%%s\n' %s; exit 0 ;;
*"useHttpPath true"*) exit %d ;;
esac
exit 0
`, shellQuoteArg(value), useHTTPPathExit)
if err := os.WriteFile(gitPath, []byte(script), 0700); err != nil {
t.Fatalf("write fake git: %v", err)
}
t.Setenv("GIT_FAKE_LOG", logPath)
t.Setenv("PATH", dir+string(os.PathListSeparator)+os.Getenv("PATH"))
return logPath
}
func installFakeGitWithGetAndSecondUnsetFails(t *testing.T, value string) string {
t.Helper()
dir := t.TempDir()
logPath := filepath.Join(dir, "git.log")
gitPath := filepath.Join(dir, "git")
script := fmt.Sprintf(`#!/bin/sh
printf '%%s\n' "$*" >> "$GIT_FAKE_LOG"
case "$*" in
*"--get"*) printf '%%s\n' %s; exit 0 ;;
*"--unset"*"useHttpPath"*) exit 9 ;;
*"--unset"*) exit 0 ;;
esac
exit 0
`, shellQuoteArg(value))
if err := os.WriteFile(gitPath, []byte(script), 0700); err != nil {
t.Fatalf("write fake git: %v", err)
}
t.Setenv("GIT_FAKE_LOG", logPath)
t.Setenv("PATH", dir+string(os.PathListSeparator)+os.Getenv("PATH"))
return logPath
}
func installAlwaysFailingGit(t *testing.T) string {
t.Helper()
dir := t.TempDir()
logPath := filepath.Join(dir, "git.log")
gitPath := filepath.Join(dir, "git")
script := `#!/bin/sh
printf '%s\n' "$*" >> "$GIT_FAKE_LOG"
exit 9
`
if err := os.WriteFile(gitPath, []byte(script), 0700); err != nil {
t.Fatalf("write fake git: %v", err)
}
t.Setenv("GIT_FAKE_LOG", logPath)
t.Setenv("PATH", dir+string(os.PathListSeparator)+os.Getenv("PATH"))
return logPath
}
func makeDirReadOnly(t *testing.T, dir string) {
t.Helper()
if err := os.Chmod(dir, 0500); err != nil {
t.Fatalf("chmod readonly %s: %v", dir, err)
}
t.Cleanup(func() {
_ = os.Chmod(dir, 0700)
})
}
func readFileString(t *testing.T, path string) string {
t.Helper()
data, err := os.ReadFile(path)
if err != nil && !errors.Is(err, os.ErrNotExist) {
t.Fatalf("read %s: %v", path, err)
}
return string(data)
}
var _ io.Reader = errorReader{}
func mustReadMetadata(t *testing.T, manager *Manager) []byte {
t.Helper()
data, err := manager.Store.Load()
if err != nil {
t.Fatalf("Load returned error: %v", err)
}
raw, err := jsonMarshal(data)
if err != nil {
t.Fatalf("jsonMarshal returned error: %v", err)
}
return raw
}
func jsonMarshal(v interface{}) ([]byte, error) {
return json.Marshal(v)
}