dsh-config-manager 0.1.12 → 0.1.13
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +30 -0
- package/README.zh-CN.md +30 -0
- package/lib/adapters/test-helpers.d.ts +4 -2
- package/lib/adapters/test-helpers.js +11 -3
- package/lib/adapters/test-helpers.js.map +1 -1
- package/lib/cli/index.d.ts +44 -0
- package/lib/cli/index.js +230 -0
- package/lib/cli/index.js.map +1 -0
- package/lib/client.d.ts +260 -5
- package/lib/client.js +2519 -820
- package/lib/client.js.map +1 -1
- package/lib/core/analyzer.d.ts +18 -1
- package/lib/core/analyzer.js +29 -0
- package/lib/core/analyzer.js.map +1 -1
- package/lib/core/backup.d.ts +3 -1
- package/lib/core/backup.js +70 -0
- package/lib/core/backup.js.map +1 -1
- package/lib/core/exporter.d.ts +11 -0
- package/lib/core/exporter.js +11 -0
- package/lib/core/exporter.js.map +1 -1
- package/lib/core/importer.d.ts +3 -0
- package/lib/core/importer.js +1 -0
- package/lib/core/importer.js.map +1 -1
- package/lib/core/index.d.ts +1 -0
- package/lib/core/index.js +1 -0
- package/lib/core/index.js.map +1 -1
- package/lib/core/restore.d.ts +92 -0
- package/lib/core/restore.js +505 -0
- package/lib/core/restore.js.map +1 -0
- package/lib/core/run-registry.d.ts +65 -0
- package/lib/core/run-registry.js +120 -0
- package/lib/core/run-registry.js.map +1 -0
- package/lib/core/types.d.ts +20 -0
- package/lib/core/types.js.map +1 -1
- package/lib/index.d.ts +45 -0
- package/lib/index.js +442 -8
- package/lib/index.js.map +1 -1
- package/lib/sync/fs.d.ts +18 -0
- package/lib/sync/fs.js +47 -0
- package/lib/sync/fs.js.map +1 -0
- package/lib/sync/git/git-transport.d.ts +87 -0
- package/lib/sync/git/git-transport.js +333 -0
- package/lib/sync/git/git-transport.js.map +1 -0
- package/lib/sync/layout.d.ts +29 -0
- package/lib/sync/layout.js +166 -0
- package/lib/sync/layout.js.map +1 -0
- package/lib/sync/sync-config.d.ts +16 -0
- package/lib/sync/sync-config.js +67 -0
- package/lib/sync/sync-config.js.map +1 -0
- package/lib/sync/sync-engine.d.ts +96 -0
- package/lib/sync/sync-engine.js +236 -0
- package/lib/sync/sync-engine.js.map +1 -0
- package/lib/sync/sync-state.d.ts +31 -0
- package/lib/sync/sync-state.js +103 -0
- package/lib/sync/sync-state.js.map +1 -0
- package/lib/sync/transport.d.ts +50 -0
- package/lib/sync/transport.js +22 -0
- package/lib/sync/transport.js.map +1 -0
- package/package.json +4 -1
- package/src/adapters/test-helpers.ts +10 -3
- package/src/cli/index.ts +278 -0
- package/src/client/ConfigManagerSection.tsx +49 -14
- package/src/client/api.ts +53 -5
- package/src/client/client-types.ts +0 -6
- package/src/client/common/ProgressBar.tsx +31 -19
- package/src/client/common/progress-view-edge.test.ts +60 -0
- package/src/client/common/progress-view.test.ts +79 -0
- package/src/client/common/progress-view.ts +94 -0
- package/src/client/config-manager.module.css +99 -20
- package/src/client/export/ExportView.tsx +63 -49
- package/src/client/import/ImportWizardView.tsx +107 -66
- package/src/client/index.ts +22 -0
- package/src/client/locales.ts +53 -0
- package/src/client/run-store.test.ts +500 -0
- package/src/client/run-store.ts +772 -0
- package/src/client/snapshots/SnapshotsPanel.tsx +240 -0
- package/src/client/sync/SyncSettingsView.tsx +261 -0
- package/src/client/sync/sync-api.test.ts +117 -0
- package/src/client/sync/sync-api.ts +132 -0
- package/src/client/sync/sync-locales.ts +77 -0
- package/src/client/sync/sync-view.test.ts +211 -0
- package/src/client/sync/sync-view.ts +202 -0
- package/src/core/analyzer.ts +50 -1
- package/src/core/backup.ts +78 -2
- package/src/core/exporter.ts +21 -0
- package/src/core/importer.ts +4 -0
- package/src/core/index.ts +5 -0
- package/src/core/restore.ts +587 -0
- package/src/core/run-progress.test.ts +237 -0
- package/src/core/run-registry.test.ts +139 -0
- package/src/core/run-registry.ts +166 -0
- package/src/core/smoke.test.ts +5 -0
- package/src/core/types.ts +22 -0
- package/src/index.ts +476 -8
- package/src/security/security.test.ts +5 -0
- package/src/sync/fs.ts +46 -0
- package/src/sync/git/git-transport.test.ts +456 -0
- package/src/sync/git/git-transport.ts +390 -0
- package/src/sync/layout.test.ts +226 -0
- package/src/sync/layout.ts +183 -0
- package/src/sync/sync-config.ts +72 -0
- package/src/sync/sync-engine.test.ts +299 -0
- package/src/sync/sync-engine.ts +318 -0
- package/src/sync/sync-state.test.ts +118 -0
- package/src/sync/sync-state.ts +123 -0
- package/src/sync/transport.test.ts +110 -0
- package/src/sync/transport.ts +73 -0
|
@@ -0,0 +1,456 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* m-git-channel:Git 私有仓库通道测试(TDD:先红后绿)。
|
|
3
|
+
* - mock exec 单元测试:命令序列 / token 不泄漏(argv、commit message、错误消息、credential 文件生命周期)
|
|
4
|
+
* - 真实 git 集成测试:本地 bare repo 模拟远端(无真实网络),端到端 list/upload/download/delete
|
|
5
|
+
*/
|
|
6
|
+
import test from 'node:test';
|
|
7
|
+
import assert from 'node:assert/strict';
|
|
8
|
+
import fs from 'node:fs/promises';
|
|
9
|
+
import os from 'node:os';
|
|
10
|
+
import path from 'node:path';
|
|
11
|
+
import { execFile } from 'node:child_process';
|
|
12
|
+
|
|
13
|
+
import { GitTransport, GitTransportError } from './git-transport.ts';
|
|
14
|
+
import type { GitExecFn, GitExecResult, GitTransportOptions } from './git-transport.ts';
|
|
15
|
+
import { computeSnapshotMeta } from '../transport.ts';
|
|
16
|
+
import type { SyncSnapshot } from '../transport.ts';
|
|
17
|
+
|
|
18
|
+
/* ---------------- helpers ---------------- */
|
|
19
|
+
|
|
20
|
+
const TEST_TOKEN = 'secret-token-xyz-123';
|
|
21
|
+
|
|
22
|
+
function sampleSnapshot(overrides: Partial<SyncSnapshot> = {}): SyncSnapshot {
|
|
23
|
+
return {
|
|
24
|
+
id: 'snap-001',
|
|
25
|
+
createdAt: '2026-08-16T12:00:00.000Z',
|
|
26
|
+
manifest: {
|
|
27
|
+
schemaVersion: 1,
|
|
28
|
+
dshVersion: '1.2.3',
|
|
29
|
+
platform: 'win32',
|
|
30
|
+
sectionIds: ['settings', 'providers'],
|
|
31
|
+
containsSecrets: false,
|
|
32
|
+
},
|
|
33
|
+
sections: {
|
|
34
|
+
settings: { version: 1, namespaces: { general: { value: { theme: 'dark' }, revision: 1, secrets: [] } } },
|
|
35
|
+
providers: { version: 1, providers: { deepseek: { route: '/v1' } } },
|
|
36
|
+
},
|
|
37
|
+
...overrides,
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** 记录每次 git 调用的 mock exec:默认全部成功;按需特判 */
|
|
42
|
+
interface CallRecord { cmd: string; args: string[]; }
|
|
43
|
+
function mockExec(
|
|
44
|
+
calls: CallRecord[],
|
|
45
|
+
rules: {
|
|
46
|
+
/** args.join(' ') 包含该子串时返回 code(用于 rev-parse/diff/ls-remote 特判) */
|
|
47
|
+
codeBy?: Record<string, number>;
|
|
48
|
+
/** 模拟失败:返回 code + stderr(可含 token 变体,验证 sanitize) */
|
|
49
|
+
failOn?: { match: string; code: number; stderr: string };
|
|
50
|
+
} = {},
|
|
51
|
+
): GitExecFn {
|
|
52
|
+
return async (_cmd, args): Promise<GitExecResult> => {
|
|
53
|
+
calls.push({ cmd: 'git', args });
|
|
54
|
+
const joined = args.join(' ');
|
|
55
|
+
if (rules.failOn && joined.includes(rules.failOn.match)) {
|
|
56
|
+
return { stdout: '', stderr: rules.failOn.stderr, code: rules.failOn.code };
|
|
57
|
+
}
|
|
58
|
+
for (const [sub, code] of Object.entries(rules.codeBy ?? {})) {
|
|
59
|
+
if (joined.includes(sub)) return { stdout: '', stderr: '', code };
|
|
60
|
+
}
|
|
61
|
+
// 默认:diff --cached --quiet = 有变更(code 1);其余成功
|
|
62
|
+
if (joined.includes('diff') && joined.includes('--quiet')) return { stdout: '', stderr: '', code: 1 };
|
|
63
|
+
return { stdout: '', stderr: '', code: 0 };
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
async function makeTempDir(t: test.TestContext): Promise<string> {
|
|
68
|
+
const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'dsh-git-test-'));
|
|
69
|
+
t.after(async () => { await fs.rm(dir, { recursive: true, force: true }); });
|
|
70
|
+
return dir;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** 真实 git:初始化一个非空 git 工作树(.git 存在,使 ensureRepo 走已 clone 分支) */
|
|
74
|
+
async function makeGitWorkDir(t: test.TestContext): Promise<string> {
|
|
75
|
+
const dir = await makeTempDir(t);
|
|
76
|
+
await runRealGit(['init'], dir);
|
|
77
|
+
await runRealGit(['config', 'user.name', 'test'], dir);
|
|
78
|
+
await runRealGit(['config', 'user.email', 'test@local'], dir);
|
|
79
|
+
return dir;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function runRealGit(args: string[], cwd: string): Promise<{ stdout: string; stderr: string; code: number }> {
|
|
83
|
+
return new Promise((resolve) => {
|
|
84
|
+
execFile('git', args, { cwd, encoding: 'utf8', maxBuffer: 16 * 1024 * 1024 }, (err, stdout, stderr) => {
|
|
85
|
+
if (err) {
|
|
86
|
+
const e = err as { code?: number };
|
|
87
|
+
resolve({ stdout: String(stdout), stderr: String(stderr), code: e.code ?? 1 });
|
|
88
|
+
} else {
|
|
89
|
+
resolve({ stdout: String(stdout), stderr: String(stderr), code: 0 });
|
|
90
|
+
}
|
|
91
|
+
});
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** 本地 bare 仓库(模拟远端,无认证需求) */
|
|
96
|
+
async function makeBareRepo(t: test.TestContext): Promise<string> {
|
|
97
|
+
const dir = await makeTempDir(t);
|
|
98
|
+
const bare = path.join(dir, 'remote.git');
|
|
99
|
+
const r = await runRealGit(['init', '--bare', bare], dir);
|
|
100
|
+
assert.equal(r.code, 0, `git init --bare 失败: ${r.stderr}`);
|
|
101
|
+
return bare;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function makeOptions(overrides: Partial<GitTransportOptions> = {}): GitTransportOptions {
|
|
105
|
+
return {
|
|
106
|
+
repoUrl: 'https://github.com/example/private-config.git',
|
|
107
|
+
workDir: 'UNSET',
|
|
108
|
+
credentials: { getToken: async () => TEST_TOKEN },
|
|
109
|
+
...overrides,
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function joinedArgs(calls: CallRecord[], needle: string): string[] {
|
|
114
|
+
return calls.filter((c) => c.args.join(' ').includes(needle)).map((c) => c.args.join(' '));
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/* ---------------- 单元测试(mock exec) ---------------- */
|
|
118
|
+
|
|
119
|
+
test('非 git 仓库:workDir 非空且无 .git → 报错清晰(含 workDir 路径)', async (t) => {
|
|
120
|
+
const dir = await makeTempDir(t);
|
|
121
|
+
await fs.writeFile(path.join(dir, 'stray.txt'), 'not a repo');
|
|
122
|
+
const calls: CallRecord[] = [];
|
|
123
|
+
const transport = new GitTransport(makeOptions({ workDir: dir, exec: mockExec(calls) }));
|
|
124
|
+
await assert.rejects(
|
|
125
|
+
transport.list(),
|
|
126
|
+
(err: unknown) => {
|
|
127
|
+
assert.ok(err instanceof GitTransportError, '必须是 GitTransportError');
|
|
128
|
+
assert.match(err.message, /不是 git 仓库/);
|
|
129
|
+
assert.ok(err.message.includes(dir), '错误消息应含 workDir 路径');
|
|
130
|
+
return true;
|
|
131
|
+
},
|
|
132
|
+
);
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
test('首次使用(空目录无 .git)→ 触发 clone(credential helper 注入,argv 不含 token)', async (t) => {
|
|
136
|
+
const dir = await makeTempDir(t); // 空目录
|
|
137
|
+
const calls: CallRecord[] = [];
|
|
138
|
+
const transport = new GitTransport(makeOptions({ workDir: dir, exec: mockExec(calls) }));
|
|
139
|
+
await transport.list();
|
|
140
|
+
const clones = joinedArgs(calls, 'clone');
|
|
141
|
+
assert.equal(clones.length, 1, '应恰好调用一次 clone');
|
|
142
|
+
const cloneArgs = calls.find((c) => c.args.includes('clone'))!.args;
|
|
143
|
+
// credential helper 注入到 clone 参数
|
|
144
|
+
assert.ok(cloneArgs.some((a) => a.startsWith('credential.helper=') && a.includes('store --file=')), 'clone 应带 store credential helper');
|
|
145
|
+
// argv 不含 token
|
|
146
|
+
for (const arg of cloneArgs) assert.ok(!arg.includes(TEST_TOKEN), `argv 泄漏 token: ${arg}`);
|
|
147
|
+
// clone 的 URL 参数不带 token
|
|
148
|
+
const urlArg = cloneArgs[cloneArgs.indexOf('clone') + 1]!;
|
|
149
|
+
assert.ok(!urlArg.includes(TEST_TOKEN), 'clone URL 不得含 token');
|
|
150
|
+
assert.ok(!urlArg.includes('oauth2:'), 'clone URL 不得含内嵌凭据');
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
test('token 不泄漏:credential 文件在 git 调用期间存在、用后删除、内容含 token 但 argv 不含', async (t) => {
|
|
154
|
+
const dir = await makeTempDir(t);
|
|
155
|
+
const calls: CallRecord[] = [];
|
|
156
|
+
const credFileContents: string[] = [];
|
|
157
|
+
const exec: GitExecFn = async (_cmd, args) => {
|
|
158
|
+
calls.push({ cmd: 'git', args });
|
|
159
|
+
const helper = args.find((a) => a.startsWith('credential.helper=') && a.includes('store --file='));
|
|
160
|
+
if (helper) {
|
|
161
|
+
const file = helper.slice(helper.indexOf('--file=') + '--file='.length).replace(/^"|"$/g, '');
|
|
162
|
+
credFileContents.push(await fs.readFile(file, 'utf8'));
|
|
163
|
+
}
|
|
164
|
+
return { stdout: '', stderr: '', code: 0 };
|
|
165
|
+
};
|
|
166
|
+
const transport = new GitTransport(makeOptions({ workDir: dir, exec }));
|
|
167
|
+
await transport.list(); // 触发 clone(带 credential)
|
|
168
|
+
// credential 文件在调用期间存在(被读到内容)
|
|
169
|
+
assert.ok(credFileContents.length >= 1, 'credential 文件应被创建并被 git 调用读取');
|
|
170
|
+
assert.ok(credFileContents.some((c) => c.includes(TEST_TOKEN)), 'credential 文件应含 token');
|
|
171
|
+
// 调用后无残留:credential 临时目录已删除(找不到任何含 token 的文件)
|
|
172
|
+
const tmpRoot = path.join(os.tmpdir());
|
|
173
|
+
const leftovers = (await fs.readdir(tmpRoot)).filter((n) => n.startsWith('dsh-git-cred-'));
|
|
174
|
+
for (const n of leftovers) {
|
|
175
|
+
const p = path.join(tmpRoot, n);
|
|
176
|
+
try {
|
|
177
|
+
const stat = await fs.stat(p);
|
|
178
|
+
if (stat.isDirectory()) {
|
|
179
|
+
const files = await fs.readdir(p);
|
|
180
|
+
assert.equal(files.length, 0, `credential 临时目录应有残留: ${n}`);
|
|
181
|
+
}
|
|
182
|
+
} catch { /* 已删除 */ }
|
|
183
|
+
}
|
|
184
|
+
// argv 不含 token
|
|
185
|
+
for (const c of calls) for (const arg of c.args) assert.ok(!arg.includes(TEST_TOKEN), `argv 泄漏 token: ${arg}`);
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
test('upload 契约:写快照目录 + add + commit(add) + push,返回 computeSnapshotMeta', async (t) => {
|
|
189
|
+
const dir = await makeGitWorkDir(t);
|
|
190
|
+
const calls: CallRecord[] = [];
|
|
191
|
+
const transport = new GitTransport(makeOptions({ workDir: dir, exec: mockExec(calls) }));
|
|
192
|
+
const snap = sampleSnapshot();
|
|
193
|
+
const meta = await transport.upload(snap);
|
|
194
|
+
assert.deepEqual(meta, computeSnapshotMeta(snap));
|
|
195
|
+
// 命令序列:add snapshots/<id> → diff --cached --quiet → commit -m add → push
|
|
196
|
+
const adds = joinedArgs(calls, 'add');
|
|
197
|
+
assert.ok(adds.some((a) => a.includes('snapshots/snap-001')), `应 add 快照目录: ${JSON.stringify(adds)}`);
|
|
198
|
+
assert.equal(joinedArgs(calls, 'diff --cached --quiet').length, 1);
|
|
199
|
+
const commits = joinedArgs(calls, 'commit');
|
|
200
|
+
assert.equal(commits.length, 1);
|
|
201
|
+
assert.match(commits[0]!, /-m sync: add snapshot snap-001/);
|
|
202
|
+
assert.equal(joinedArgs(calls, 'push').length, 1, 'upload 应 push');
|
|
203
|
+
// commit message 不含 token
|
|
204
|
+
for (const c of commits) assert.ok(!c.includes(TEST_TOKEN));
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
test('upload 幂等:工作树内容与 HEAD 一致 → 不 commit 不 push,仍返回 meta', async (t) => {
|
|
208
|
+
const dir = await makeGitWorkDir(t);
|
|
209
|
+
const calls: CallRecord[] = [];
|
|
210
|
+
const transport = new GitTransport(makeOptions({
|
|
211
|
+
workDir: dir,
|
|
212
|
+
exec: mockExec(calls, { codeBy: { 'diff --cached --quiet': 0 } }),
|
|
213
|
+
}));
|
|
214
|
+
const meta = await transport.upload(sampleSnapshot());
|
|
215
|
+
assert.equal(meta.id, 'snap-001');
|
|
216
|
+
assert.equal(joinedArgs(calls, 'commit').length, 0, '无变更不得 commit');
|
|
217
|
+
assert.equal(joinedArgs(calls, 'push').length, 0, '无变更不得 push');
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
test('upload 覆盖:同 id 再次上传 → commit message 为 update', async (t) => {
|
|
221
|
+
const dir = await makeGitWorkDir(t);
|
|
222
|
+
const calls: CallRecord[] = [];
|
|
223
|
+
const transport = new GitTransport(makeOptions({ workDir: dir, exec: mockExec(calls) }));
|
|
224
|
+
await transport.upload(sampleSnapshot());
|
|
225
|
+
calls.length = 0; // 清空,模拟第二次 upload(同 id 覆盖)
|
|
226
|
+
await transport.upload(sampleSnapshot({ createdAt: '2026-08-16T13:00:00.000Z' }));
|
|
227
|
+
const commits = joinedArgs(calls, 'commit');
|
|
228
|
+
assert.equal(commits.length, 1);
|
|
229
|
+
assert.match(commits[0]!, /-m sync: update snapshot snap-001/);
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
test('download 契约:不存在的 id → 抛错(消息含 id)', async (t) => {
|
|
233
|
+
const dir = await makeGitWorkDir(t);
|
|
234
|
+
const calls: CallRecord[] = [];
|
|
235
|
+
const transport = new GitTransport(makeOptions({ workDir: dir, exec: mockExec(calls) }));
|
|
236
|
+
await assert.rejects(
|
|
237
|
+
transport.download('missing-001'),
|
|
238
|
+
(err: unknown) => {
|
|
239
|
+
assert.ok(err instanceof GitTransportError);
|
|
240
|
+
assert.match(err.message, /missing-001/);
|
|
241
|
+
assert.match(err.message, /不存在/);
|
|
242
|
+
return true;
|
|
243
|
+
},
|
|
244
|
+
);
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
test('delete 契约:存在 → 删除目录 + add -A + commit(delete) + push;不存在 → 静默成功', async (t) => {
|
|
248
|
+
const dir = await makeGitWorkDir(t);
|
|
249
|
+
const calls: CallRecord[] = [];
|
|
250
|
+
const transport = new GitTransport(makeOptions({ workDir: dir, exec: mockExec(calls) }));
|
|
251
|
+
// 准备一个已存在快照目录
|
|
252
|
+
await fs.mkdir(path.join(dir, 'snapshots', 'snap-001'), { recursive: true });
|
|
253
|
+
await fs.writeFile(path.join(dir, 'snapshots', 'snap-001', 'manifest.json'), '{}');
|
|
254
|
+
await transport.delete('snap-001');
|
|
255
|
+
const adds = joinedArgs(calls, 'add -A');
|
|
256
|
+
assert.ok(adds.some((a) => a.includes('snapshots/snap-001')), 'delete 应 stage 删除');
|
|
257
|
+
assert.equal(joinedArgs(calls, 'commit').length, 1);
|
|
258
|
+
assert.match(joinedArgs(calls, 'commit')[0]!, /-m sync: delete snapshot snap-001/);
|
|
259
|
+
assert.equal(joinedArgs(calls, 'push').length, 1);
|
|
260
|
+
// 不存在 → 成功且不产生 git 写操作
|
|
261
|
+
calls.length = 0;
|
|
262
|
+
await transport.delete('never-existed');
|
|
263
|
+
assert.equal(joinedArgs(calls, 'commit').length, 0);
|
|
264
|
+
assert.equal(joinedArgs(calls, 'push').length, 0);
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
test('错误消息 sanitize:git 失败 stderr 含 token → 抛出消息被替换为 [REDACTED]', async (t) => {
|
|
268
|
+
const dir = await makeGitWorkDir(t);
|
|
269
|
+
const calls: CallRecord[] = [];
|
|
270
|
+
const transport = new GitTransport(makeOptions({
|
|
271
|
+
workDir: dir,
|
|
272
|
+
exec: mockExec(calls, {
|
|
273
|
+
failOn: { match: 'push', code: 128, stderr: `fatal: unable to access 'https://oauth2:${TEST_TOKEN}@github.com/example/private-config.git/': authentication failed` },
|
|
274
|
+
}),
|
|
275
|
+
}));
|
|
276
|
+
await assert.rejects(
|
|
277
|
+
transport.upload(sampleSnapshot()),
|
|
278
|
+
(err: unknown) => {
|
|
279
|
+
assert.ok(err instanceof GitTransportError);
|
|
280
|
+
assert.ok(!err.message.includes(TEST_TOKEN), `错误消息泄漏 token: ${err.message}`);
|
|
281
|
+
assert.match(err.message, /\[REDACTED\]/);
|
|
282
|
+
return true;
|
|
283
|
+
},
|
|
284
|
+
);
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
test('checkIsPrivate:匿名可达 → false;匿名失败+认证可达 → true;都失败 → 抛错', async (t) => {
|
|
288
|
+
// 匿名成功
|
|
289
|
+
const c1: CallRecord[] = [];
|
|
290
|
+
const t1 = new GitTransport(makeOptions({ workDir: await makeTempDir(t), exec: mockExec(c1) }));
|
|
291
|
+
assert.equal(await t1.checkIsPrivate(), false);
|
|
292
|
+
assert.equal(t1.isPrivateHint, false);
|
|
293
|
+
|
|
294
|
+
// 匿名失败(404),带 credential 成功
|
|
295
|
+
const c2: CallRecord[] = [];
|
|
296
|
+
const exec2: GitExecFn = async (_cmd, args) => {
|
|
297
|
+
c2.push({ cmd: 'git', args });
|
|
298
|
+
if (args.join(' ').includes('ls-remote')) {
|
|
299
|
+
const authed = args.some((a) => a.includes('credential.helper='));
|
|
300
|
+
return authed
|
|
301
|
+
? { stdout: '', stderr: '', code: 0 }
|
|
302
|
+
: { stdout: '', stderr: 'fatal: could not read Username', code: 128 };
|
|
303
|
+
}
|
|
304
|
+
return { stdout: '', stderr: '', code: 0 };
|
|
305
|
+
};
|
|
306
|
+
const t2 = new GitTransport(makeOptions({ workDir: await makeTempDir(t), exec: exec2 }));
|
|
307
|
+
assert.equal(await t2.checkIsPrivate(), true);
|
|
308
|
+
assert.equal(t2.isPrivateHint, true);
|
|
309
|
+
// 第二次调用走缓存,不重复探测
|
|
310
|
+
const lsBefore = joinedArgs(c2, 'ls-remote').length;
|
|
311
|
+
await t2.checkIsPrivate();
|
|
312
|
+
assert.equal(joinedArgs(c2, 'ls-remote').length, lsBefore);
|
|
313
|
+
|
|
314
|
+
// 匿名与认证都失败
|
|
315
|
+
const c3: CallRecord[] = [];
|
|
316
|
+
const t3 = new GitTransport(makeOptions({
|
|
317
|
+
workDir: await makeTempDir(t),
|
|
318
|
+
exec: mockExec(c3, { codeBy: { 'ls-remote': 128 }, failOn: { match: 'credential.helper=', code: 128, stderr: 'fatal: could not read Username' } }),
|
|
319
|
+
}));
|
|
320
|
+
await assert.rejects(t3.checkIsPrivate(), /不可达|认证失败/);
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
test('快照 id 安全:非法 id(路径穿越/特殊字符)→ upload/download/delete 均拒绝', async (t) => {
|
|
324
|
+
const dir = await makeGitWorkDir(t);
|
|
325
|
+
const calls: CallRecord[] = [];
|
|
326
|
+
const transport = new GitTransport(makeOptions({ workDir: dir, exec: mockExec(calls) }));
|
|
327
|
+
for (const bad of ['../evil', 'a/b', 'a\\b', '.', '..', 'snap\ninject']) {
|
|
328
|
+
await assert.rejects(transport.upload(sampleSnapshot({ id: bad })), /非法快照 id/);
|
|
329
|
+
await assert.rejects(transport.download(bad), /非法快照 id/);
|
|
330
|
+
await assert.rejects(transport.delete(bad), /非法快照 id/);
|
|
331
|
+
}
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
/* ---------------- 集成测试(真实 git,本地 bare repo) ---------------- */
|
|
335
|
+
|
|
336
|
+
test('集成:upload → list → download → delete 端到端(真实 git + 本地 bare repo)', async (t) => {
|
|
337
|
+
const bare = await makeBareRepo(t);
|
|
338
|
+
const workDir = await makeTempDir(t);
|
|
339
|
+
let tokenReads = 0;
|
|
340
|
+
const transport = new GitTransport({
|
|
341
|
+
repoUrl: bare,
|
|
342
|
+
workDir,
|
|
343
|
+
credentials: { getToken: async () => { tokenReads += 1; return TEST_TOKEN; } },
|
|
344
|
+
});
|
|
345
|
+
|
|
346
|
+
// upload
|
|
347
|
+
const snap = sampleSnapshot();
|
|
348
|
+
const meta = await transport.upload(snap);
|
|
349
|
+
assert.equal(meta.id, 'snap-001');
|
|
350
|
+
// 快照散文件目录落在工作副本
|
|
351
|
+
const manifestAbs = path.join(workDir, 'snapshots', 'snap-001', 'manifest.json');
|
|
352
|
+
const manifestRaw = JSON.parse(await fs.readFile(manifestAbs, 'utf8'));
|
|
353
|
+
assert.equal(manifestRaw.id, 'snap-001');
|
|
354
|
+
assert.equal(manifestRaw.createdAt, snap.createdAt);
|
|
355
|
+
// 分区文件落盘
|
|
356
|
+
assert.ok(await fs.stat(path.join(workDir, 'snapshots', 'snap-001', 'config', 'settings.json')));
|
|
357
|
+
|
|
358
|
+
// 提交已 push 到远端 bare repo
|
|
359
|
+
const log = await runRealGit(['log', '--oneline', '--all'], bare);
|
|
360
|
+
assert.equal(log.code, 0, `git log 失败: ${log.stderr}`);
|
|
361
|
+
assert.match(log.stdout, /sync: add snapshot snap-001/);
|
|
362
|
+
|
|
363
|
+
// list:远端 + 工作副本 → 1 条,createdAt 升序
|
|
364
|
+
const listed = await transport.list();
|
|
365
|
+
assert.equal(listed.length, 1);
|
|
366
|
+
assert.equal(listed[0]!.id, 'snap-001');
|
|
367
|
+
assert.equal(listed[0]!.createdAt, snap.createdAt);
|
|
368
|
+
assert.deepEqual(listed[0]!.manifest, snap.manifest);
|
|
369
|
+
|
|
370
|
+
// download:读回完整载荷,与上传一致
|
|
371
|
+
const roundtrip = await transport.download('snap-001');
|
|
372
|
+
assert.deepEqual(roundtrip, snap);
|
|
373
|
+
|
|
374
|
+
// 覆盖:同 id 上传新内容 → update commit,目录唯一
|
|
375
|
+
const snap2 = sampleSnapshot({ createdAt: '2026-08-16T14:00:00.000Z' });
|
|
376
|
+
snap2.sections.settings = { version: 1, namespaces: { general: { value: { theme: 'light' }, revision: 2, secrets: [] } } };
|
|
377
|
+
await transport.upload(snap2);
|
|
378
|
+
const dirs = await fs.readdir(path.join(workDir, 'snapshots'));
|
|
379
|
+
assert.deepEqual(dirs, ['snap-001']);
|
|
380
|
+
const log2 = await runRealGit(['log', '--oneline', '--all'], bare);
|
|
381
|
+
assert.match(log2.stdout, /sync: update snapshot snap-001/);
|
|
382
|
+
const roundtrip2 = await transport.download('snap-001');
|
|
383
|
+
assert.deepEqual(roundtrip2, snap2);
|
|
384
|
+
|
|
385
|
+
// delete:存在 → 移除;再 delete 不存在 id → 静默成功
|
|
386
|
+
await transport.delete('snap-001');
|
|
387
|
+
assert.equal((await transport.list()).length, 0);
|
|
388
|
+
const log3 = await runRealGit(['log', '--oneline', '--all'], bare);
|
|
389
|
+
assert.match(log3.stdout, /sync: delete snapshot snap-001/);
|
|
390
|
+
await transport.delete('snap-001'); // 不存在视为成功
|
|
391
|
+
await assert.rejects(transport.download('snap-001'), /不存在/);
|
|
392
|
+
|
|
393
|
+
// 本地路径仓库不触发认证读取(token 提供者不被调用)
|
|
394
|
+
assert.equal(tokenReads, 0, '本地仓库无需 token,getToken 不应被调用');
|
|
395
|
+
});
|
|
396
|
+
|
|
397
|
+
test('集成:token 不泄漏到快照文件内容与 commit message(真实 git 全链路)', async (t) => {
|
|
398
|
+
const bare = await makeBareRepo(t);
|
|
399
|
+
const workDir = await makeTempDir(t);
|
|
400
|
+
const transport = new GitTransport({
|
|
401
|
+
repoUrl: bare,
|
|
402
|
+
workDir,
|
|
403
|
+
credentials: { getToken: async () => TEST_TOKEN },
|
|
404
|
+
});
|
|
405
|
+
await transport.upload(sampleSnapshot());
|
|
406
|
+
// 工作副本 snapshots 下所有文件内容不得含 token
|
|
407
|
+
const files: string[] = [];
|
|
408
|
+
const walk = async (p: string): Promise<void> => {
|
|
409
|
+
for (const name of await fs.readdir(p)) {
|
|
410
|
+
const full = path.join(p, name);
|
|
411
|
+
const stat = await fs.stat(full);
|
|
412
|
+
if (stat.isDirectory()) await walk(full);
|
|
413
|
+
else files.push(full);
|
|
414
|
+
}
|
|
415
|
+
};
|
|
416
|
+
await walk(path.join(workDir, 'snapshots'));
|
|
417
|
+
for (const f of files) {
|
|
418
|
+
const content = await fs.readFile(f, 'utf8');
|
|
419
|
+
assert.ok(!content.includes(TEST_TOKEN), `文件内容泄漏 token: ${f}`);
|
|
420
|
+
}
|
|
421
|
+
// commit message 不含 token
|
|
422
|
+
const log = await runRealGit(['log', '--format=%s', '--all'], bare);
|
|
423
|
+
assert.ok(!log.stdout.includes(TEST_TOKEN), `commit message 泄漏 token: ${log.stdout}`);
|
|
424
|
+
// 工作副本根目录无 credential 残留文件
|
|
425
|
+
const rootEntries = await fs.readdir(workDir);
|
|
426
|
+
assert.ok(!rootEntries.some((n) => n.includes('cred') || n.includes('dsh-git-cred')), `credential 残留: ${rootEntries.join(', ')}`);
|
|
427
|
+
});
|
|
428
|
+
|
|
429
|
+
test('集成:非 git 仓库报错清晰(真实 git 验证消息)', async (t) => {
|
|
430
|
+
const workDir = await makeTempDir(t);
|
|
431
|
+
await fs.writeFile(path.join(workDir, 'placeholder.txt'), 'x');
|
|
432
|
+
const transport = new GitTransport({
|
|
433
|
+
repoUrl: 'https://github.com/example/private-config.git',
|
|
434
|
+
workDir,
|
|
435
|
+
credentials: { getToken: async () => TEST_TOKEN },
|
|
436
|
+
});
|
|
437
|
+
await assert.rejects(
|
|
438
|
+
transport.list(),
|
|
439
|
+
(err: unknown) => {
|
|
440
|
+
assert.ok(err instanceof GitTransportError);
|
|
441
|
+
assert.match(err.message, /不是 git 仓库/);
|
|
442
|
+
return true;
|
|
443
|
+
},
|
|
444
|
+
);
|
|
445
|
+
});
|
|
446
|
+
|
|
447
|
+
test('集成:list 按 createdAt 升序(真实 git 多快照)', async (t) => {
|
|
448
|
+
const bare = await makeBareRepo(t);
|
|
449
|
+
const workDir = await makeTempDir(t);
|
|
450
|
+
const transport = new GitTransport({ repoUrl: bare, workDir, credentials: { getToken: async () => TEST_TOKEN } });
|
|
451
|
+
await transport.upload(sampleSnapshot({ id: 'snap-b', createdAt: '2026-08-16T11:00:00.000Z' }));
|
|
452
|
+
await transport.upload(sampleSnapshot({ id: 'snap-a', createdAt: '2026-08-16T09:00:00.000Z' }));
|
|
453
|
+
await transport.upload(sampleSnapshot({ id: 'snap-c', createdAt: '2026-08-16T12:00:00.000Z' }));
|
|
454
|
+
const listed = await transport.list();
|
|
455
|
+
assert.deepEqual(listed.map((m) => m.id), ['snap-a', 'snap-b', 'snap-c']);
|
|
456
|
+
});
|