kushi-agents 6.0.0 → 6.0.1
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/bin/cli.mjs +16 -23
- package/package.json +2 -2
- package/plugin/runners/pull-email.mjs +15 -1
- package/plugin/runners/refresh.mjs +49 -0
- package/src/resolve-alias.mjs +60 -0
- package/src/resolve-alias.test.mjs +91 -0
package/bin/cli.mjs
CHANGED
|
@@ -24,30 +24,21 @@ if (args.length > 0 && VERB_ALIASES[args[0]]) {
|
|
|
24
24
|
args[0] = VERB_ALIASES[args[0]];
|
|
25
25
|
}
|
|
26
26
|
|
|
27
|
-
// ── alias resolution (v5.9.7+)
|
|
28
|
-
// Mirrors `instructions/identity-resolution.instructions.md
|
|
29
|
-
//
|
|
30
|
-
//
|
|
27
|
+
// ── alias resolution (v5.9.7+ / v6.0.1 deterministic auto) ─────────────────
|
|
28
|
+
// Mirrors `instructions/identity-resolution.instructions.md`. Precedence:
|
|
29
|
+
// 1. KUSHI_ALIAS env var (cron / CI override)
|
|
30
|
+
// 2. <cwd>/.kushi/config/user/project-evidence.yml -> alias: field
|
|
31
|
+
// (skipped if value is a placeholder like `<auto>` / `<...>` / `your-alias`)
|
|
32
|
+
// 3. m365-auth.json -> _meta.owner UPN — mailNickname before '@'.
|
|
33
|
+
// 4. os.userInfo().username — sanitized, mirrors seed-config.applyDerivedDefaults().
|
|
34
|
+
// 5. only if all of those fail → return null and print the 4-fix error.
|
|
35
|
+
//
|
|
36
|
+
// v6.0.0 incorrectly stopped at step 2 and treated `<auto>` as a hard error,
|
|
37
|
+
// which contradicted the project-evidence.yml comment that promises auto-detection.
|
|
38
|
+
// Implementation lives in src/resolve-alias.mjs so it's unit-testable.
|
|
31
39
|
async function resolveAlias() {
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
}
|
|
35
|
-
const fs = await import('node:fs');
|
|
36
|
-
const path = await import('node:path');
|
|
37
|
-
const cfgPath = path.resolve(process.cwd(), '.kushi', 'config', 'user', 'project-evidence.yml');
|
|
38
|
-
if (!fs.existsSync(cfgPath)) return { alias: null, source: 'missing-config' };
|
|
39
|
-
try {
|
|
40
|
-
const txt = fs.readFileSync(cfgPath, 'utf-8');
|
|
41
|
-
const m = txt.match(/^\s*alias:\s*([^\s#]+)/m);
|
|
42
|
-
if (!m) return { alias: null, source: 'no-alias-key' };
|
|
43
|
-
const v = m[1].trim();
|
|
44
|
-
if (!v || v === '<auto>' || v.startsWith('<') || v === 'your-alias') {
|
|
45
|
-
return { alias: null, source: 'placeholder' };
|
|
46
|
-
}
|
|
47
|
-
return { alias: v, source: 'project-evidence.yml' };
|
|
48
|
-
} catch {
|
|
49
|
-
return { alias: null, source: 'read-error' };
|
|
50
|
-
}
|
|
40
|
+
const { resolveAliasFor } = await import('../src/resolve-alias.mjs');
|
|
41
|
+
return resolveAliasFor(process.cwd());
|
|
51
42
|
}
|
|
52
43
|
|
|
53
44
|
function printAliasError(verb, project) {
|
|
@@ -313,6 +304,8 @@ if (args.length > 0 && ['refresh', 'bootstrap', 'discover', 'references'].includ
|
|
|
313
304
|
aliasArgs = ['--alias', alias];
|
|
314
305
|
if (source === 'env') {
|
|
315
306
|
process.stderr.write(`\n Using alias='${alias}' from KUSHI_ALIAS env var.\n`);
|
|
307
|
+
} else if (source === 'm365-auth-upn' || source === 'os-username') {
|
|
308
|
+
process.stderr.write(`\n Using alias='${alias}' (auto-derived from ${source === 'm365-auth-upn' ? 'm365-auth.json owner UPN' : 'OS username'}). Override via 'alias:' in .kushi/config/user/project-evidence.yml or KUSHI_ALIAS env var.\n`);
|
|
316
309
|
}
|
|
317
310
|
}
|
|
318
311
|
const r = spawnSync(process.execPath, [runner, '--project', project, ...aliasArgs, ...passthrough], { stdio: 'inherit' });
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "kushi-agents",
|
|
3
|
-
"version": "6.0.
|
|
3
|
+
"version": "6.0.1",
|
|
4
4
|
"description": "Install Kushi — multi-source project evidence agent with Comprehensive Structured Capture (CSC) into weekly-only files across Email, Teams, OneNote, Loop, SharePoint, Meetings, CRM, ADO. Meetings retain a sibling verbatim/ audit folder. WorkIQ-only for M365 sources (Graph / m365_* FORBIDDEN as fallbacks; user-paste is first-class). Host-agnostic.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -44,7 +44,7 @@
|
|
|
44
44
|
},
|
|
45
45
|
"license": "MIT",
|
|
46
46
|
"scripts": {
|
|
47
|
-
"test": "node --test src/check-workiq.test.mjs src/seed-config.test.mjs src/sanitize-workiq-input.test.mjs src/detect-vertex-repo.test.mjs src/vertex-validate.test.mjs src/emit-vertex.e2e.test.mjs src/config-root-resolve.test.mjs src/forbidden-workiq-phrasings.test.mjs src/multi-host-install.test.mjs src/eval-aggregator.test.mjs src/eval-runner.test.mjs src/hooks-dispatcher.test.mjs src/parallel-refresh.test.mjs src/otel-emit.test.mjs src/doctor.test.mjs src/setup-wizard.test.mjs src/cli-no-args.test.mjs src/cli-no-args-tty.test.mjs src/per-user-files.test.mjs src/layout-portable.test.mjs src/profile-coverage.test.mjs src/get-kushi-config.test.mjs src/seed-config-derived.test.mjs plugin/runners/test/unit/*.test.mjs",
|
|
47
|
+
"test": "node --test src/check-workiq.test.mjs src/seed-config.test.mjs src/sanitize-workiq-input.test.mjs src/detect-vertex-repo.test.mjs src/vertex-validate.test.mjs src/emit-vertex.e2e.test.mjs src/config-root-resolve.test.mjs src/forbidden-workiq-phrasings.test.mjs src/multi-host-install.test.mjs src/eval-aggregator.test.mjs src/eval-runner.test.mjs src/hooks-dispatcher.test.mjs src/parallel-refresh.test.mjs src/otel-emit.test.mjs src/doctor.test.mjs src/setup-wizard.test.mjs src/cli-no-args.test.mjs src/cli-no-args-tty.test.mjs src/per-user-files.test.mjs src/layout-portable.test.mjs src/profile-coverage.test.mjs src/get-kushi-config.test.mjs src/seed-config-derived.test.mjs src/resolve-alias.test.mjs plugin/runners/test/unit/*.test.mjs",
|
|
48
48
|
"test:runners": "node --test plugin/runners/test/unit/*.test.mjs",
|
|
49
49
|
"test:runners:integration": "node --test plugin/runners/test/integration/*.test.mjs",
|
|
50
50
|
"test:integration:bootstrap": "node src/bootstrap-dryrun.integration.test.mjs",
|
|
@@ -117,7 +117,19 @@ async function main() {
|
|
|
117
117
|
const client = await buildClient({ mailbox: args.mailbox, fixture: args.fixture });
|
|
118
118
|
const startedAt = new Date().toISOString();
|
|
119
119
|
|
|
120
|
-
|
|
120
|
+
let folder;
|
|
121
|
+
try {
|
|
122
|
+
folder = await client.findFolder(args.entity);
|
|
123
|
+
} catch (e) {
|
|
124
|
+
const retryable = !e.status || [429, 502, 503, 504].includes(e.status);
|
|
125
|
+
const status = retryable ? 'deferred' : 'failed';
|
|
126
|
+
await updateCell(projectRoot, args.alias, SOURCE, args.entity, weekStart, { last_status: status, last_error: `findFolder: ${e.message}` });
|
|
127
|
+
if (retryable && !args.dryRun) await enqueue(projectRoot, args.alias, { source: SOURCE, entity: args.entity, weekStart, signature: 'find-folder-failed', reason: e.message });
|
|
128
|
+
if (!retryable && !args.dryRun) await emitLearningCandidate({ projectRoot, alias: args.alias, source: SOURCE, entity: args.entity, week: weekStart, error: { signature: 'find-folder-failed', message: e.message, status: e.status }, context: { runner: 'pull-email' } });
|
|
129
|
+
if (!args.dryRun) await appendRunLog(projectRoot, { runner: 'pull-email', alias: args.alias, entity: args.entity, week: weekStart, status, error: e.message, http_status: e.status ?? null });
|
|
130
|
+
emit({ source: SOURCE, entity: args.entity, week: weekStart, status, errors: [{ signature: 'find-folder-failed', message: e.message, status: e.status }] });
|
|
131
|
+
return retryable ? 1 : 0;
|
|
132
|
+
}
|
|
121
133
|
if (!folder) {
|
|
122
134
|
await updateCell(projectRoot, args.alias, SOURCE, args.entity, weekStart, {
|
|
123
135
|
last_status: 'failed', last_error: `folder not found: ${args.entity}`,
|
|
@@ -134,12 +146,14 @@ async function main() {
|
|
|
134
146
|
await updateCell(projectRoot, args.alias, SOURCE, args.entity, weekStart, { last_status: retryable ? 'deferred' : 'failed', last_error: e.message });
|
|
135
147
|
if (retryable && !args.dryRun) await enqueue(projectRoot, args.alias, { source: SOURCE, entity: args.entity, weekStart, signature: 'fetch-failed', reason: e.message });
|
|
136
148
|
if (!retryable && !args.dryRun) await emitLearningCandidate({ projectRoot, alias: args.alias, source: SOURCE, entity: args.entity, week: weekStart, error: { signature: 'fetch-failed', message: e.message, status: e.status }, context: { runner: 'pull-email' } });
|
|
149
|
+
if (!args.dryRun) await appendRunLog(projectRoot, { runner: 'pull-email', alias: args.alias, entity: args.entity, week: weekStart, status: retryable ? 'deferred' : 'failed', error: e.message, http_status: e.status ?? null });
|
|
137
150
|
emit({ source: SOURCE, entity: args.entity, week: weekStart, status: retryable ? 'deferred' : 'failed', errors: [{ message: e.message, status: e.status }] });
|
|
138
151
|
return retryable ? 1 : 0;
|
|
139
152
|
}
|
|
140
153
|
|
|
141
154
|
if (messages.length === 0) {
|
|
142
155
|
await updateCell(projectRoot, args.alias, SOURCE, args.entity, weekStart, { last_status: 'no-activity', items_pulled: 0, folder_id: folder.id, folder_name: folder.displayName });
|
|
156
|
+
if (!args.dryRun) await appendRunLog(projectRoot, { runner: 'pull-email', alias: args.alias, entity: args.entity, week: weekStart, status: 'no-activity', items_pulled: 0 });
|
|
143
157
|
emit({ source: SOURCE, entity: args.entity, week: weekStart, status: 'no-activity', items_pulled: 0, files_written: [] });
|
|
144
158
|
return 0;
|
|
145
159
|
}
|
|
@@ -24,6 +24,7 @@ import { loadConfig, assertProject } from './lib/config.mjs';
|
|
|
24
24
|
import { readLedger, needsPull } from './lib/ledger.mjs';
|
|
25
25
|
import { currentIsoMonday, ymd } from './lib/weeks.mjs';
|
|
26
26
|
import { readCandidateCount } from './lib/learnings.mjs';
|
|
27
|
+
import { writeRefreshReport, appendRunLog } from './lib/runlog.mjs';
|
|
27
28
|
|
|
28
29
|
const HERE = path.dirname(fileURLToPath(import.meta.url));
|
|
29
30
|
|
|
@@ -237,6 +238,54 @@ async function main() {
|
|
|
237
238
|
|
|
238
239
|
const learning_candidates_total = args.dryRun ? 0 : await readCandidateCount(args.project);
|
|
239
240
|
|
|
241
|
+
// v6.0.1: orchestrator-level diagnostics — write a refresh report and append
|
|
242
|
+
// run-log entries for EVERY result (captured / no-activity / partial /
|
|
243
|
+
// deferred / failed). Per-runner appendRunLog calls only fired on success
|
|
244
|
+
// before, so failures left no audit trail beyond the ephemeral stdout JSON.
|
|
245
|
+
const counts = { captured: 0, 'no-activity': 0, partial: 0, deferred: 0, failed: 0, other: 0 };
|
|
246
|
+
for (const r of results) {
|
|
247
|
+
const status = r?.parsed?.status || (r?.dry_run ? 'dry-run' : 'unknown');
|
|
248
|
+
if (counts[status] !== undefined) counts[status]++; else counts.other++;
|
|
249
|
+
}
|
|
250
|
+
if (!args.dryRun) {
|
|
251
|
+
try {
|
|
252
|
+
await writeRefreshReport(args.project, args.alias, {
|
|
253
|
+
type: args.mode,
|
|
254
|
+
summary: `${args.mode} ${weekStart}: planned=${planned.length} skipped=${skipped.length} captured=${counts.captured} no-activity=${counts['no-activity']} partial=${counts.partial} deferred=${counts.deferred} failed=${counts.failed}`,
|
|
255
|
+
details: {
|
|
256
|
+
week: weekStart,
|
|
257
|
+
mode: args.mode,
|
|
258
|
+
planned: planned.length,
|
|
259
|
+
skipped: skipped.length,
|
|
260
|
+
counts,
|
|
261
|
+
results: results.map(r => ({
|
|
262
|
+
source: r.source,
|
|
263
|
+
entity: r.entity,
|
|
264
|
+
status: r?.parsed?.status,
|
|
265
|
+
exit_code: r.exit_code,
|
|
266
|
+
errors: r?.parsed?.errors,
|
|
267
|
+
})),
|
|
268
|
+
},
|
|
269
|
+
});
|
|
270
|
+
} catch (e) { /* refresh-report is diagnostics-only, never block */ }
|
|
271
|
+
|
|
272
|
+
for (const r of results) {
|
|
273
|
+
const status = r?.parsed?.status;
|
|
274
|
+
if (!status || status === 'captured') continue; // captured already logged by per-runner
|
|
275
|
+
try {
|
|
276
|
+
await appendRunLog(args.project, {
|
|
277
|
+
runner: `pull-${r.source}`,
|
|
278
|
+
alias: args.alias,
|
|
279
|
+
entity: r.entity,
|
|
280
|
+
week: weekStart,
|
|
281
|
+
status,
|
|
282
|
+
via: 'refresh-orchestrator',
|
|
283
|
+
errors: r?.parsed?.errors,
|
|
284
|
+
});
|
|
285
|
+
} catch (e) { /* run-log is append-only diagnostics */ }
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
240
289
|
emit({
|
|
241
290
|
status: 'ok',
|
|
242
291
|
project: args.project,
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
// src/resolve-alias.mjs
|
|
2
|
+
// Deterministic alias resolution for the CLI runner-dispatch path.
|
|
3
|
+
// Extracted from bin/cli.mjs so it's unit-testable.
|
|
4
|
+
//
|
|
5
|
+
// Precedence:
|
|
6
|
+
// 1. KUSHI_ALIAS env var (cron / CI override)
|
|
7
|
+
// 2. <cwd>/.kushi/config/user/project-evidence.yml -> alias: field
|
|
8
|
+
// (skipped if value is a placeholder like `<auto>` / `<...>` / `your-alias`)
|
|
9
|
+
// 3. <cwd>/.kushi/config/user/m365-auth.json -> _meta.owner UPN
|
|
10
|
+
// (mailNickname before '@'; sanitized)
|
|
11
|
+
// 4. os.userInfo().username — sanitized (mirrors seed-config.applyDerivedDefaults)
|
|
12
|
+
// 5. else null with source 'placeholder'
|
|
13
|
+
|
|
14
|
+
import fs from 'node:fs';
|
|
15
|
+
import path from 'node:path';
|
|
16
|
+
import os from 'node:os';
|
|
17
|
+
|
|
18
|
+
function sanitize(raw) {
|
|
19
|
+
return String(raw || '').toLowerCase().replace(/[^a-z0-9._-]/g, '');
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function resolveAliasFor(cwd, env = process.env, userInfo = () => os.userInfo()) {
|
|
23
|
+
if (env.KUSHI_ALIAS && env.KUSHI_ALIAS.trim()) {
|
|
24
|
+
return { alias: env.KUSHI_ALIAS.trim(), source: 'env' };
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const cfgPath = path.resolve(cwd, '.kushi', 'config', 'user', 'project-evidence.yml');
|
|
28
|
+
const cfgExists = fs.existsSync(cfgPath);
|
|
29
|
+
|
|
30
|
+
if (cfgExists) {
|
|
31
|
+
try {
|
|
32
|
+
const txt = fs.readFileSync(cfgPath, 'utf-8');
|
|
33
|
+
const m = txt.match(/^\s*alias:\s*([^\s#]+)/m);
|
|
34
|
+
if (m) {
|
|
35
|
+
const v = m[1].trim();
|
|
36
|
+
const isPlaceholder = !v || v === '<auto>' || v.startsWith('<') || v === 'your-alias';
|
|
37
|
+
if (!isPlaceholder) return { alias: v, source: 'project-evidence.yml' };
|
|
38
|
+
}
|
|
39
|
+
} catch { /* fall through */ }
|
|
40
|
+
|
|
41
|
+
const authPath = path.resolve(cwd, '.kushi', 'config', 'user', 'm365-auth.json');
|
|
42
|
+
if (fs.existsSync(authPath)) {
|
|
43
|
+
try {
|
|
44
|
+
const obj = JSON.parse(fs.readFileSync(authPath, 'utf-8'));
|
|
45
|
+
const upn = obj && obj._meta && typeof obj._meta.owner === 'string' ? obj._meta.owner : '';
|
|
46
|
+
const local = sanitize(upn.split('@')[0]);
|
|
47
|
+
if (local && !local.startsWith('<')) {
|
|
48
|
+
return { alias: local, source: 'm365-auth-upn' };
|
|
49
|
+
}
|
|
50
|
+
} catch { /* fall through */ }
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
try {
|
|
55
|
+
const u = sanitize(userInfo().username);
|
|
56
|
+
if (u) return { alias: u, source: 'os-username' };
|
|
57
|
+
} catch { /* fall through */ }
|
|
58
|
+
|
|
59
|
+
return { alias: null, source: 'placeholder' };
|
|
60
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
// src/resolve-alias.test.mjs
|
|
2
|
+
import test from 'node:test';
|
|
3
|
+
import assert from 'node:assert/strict';
|
|
4
|
+
import fs from 'node:fs';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import os from 'node:os';
|
|
7
|
+
import { resolveAliasFor } from './resolve-alias.mjs';
|
|
8
|
+
|
|
9
|
+
function tmp() {
|
|
10
|
+
const d = fs.mkdtempSync(path.join(os.tmpdir(), 'kushi-alias-'));
|
|
11
|
+
return d;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function seedEvidenceYml(dir, value) {
|
|
15
|
+
const p = path.join(dir, '.kushi', 'config', 'user');
|
|
16
|
+
fs.mkdirSync(p, { recursive: true });
|
|
17
|
+
fs.writeFileSync(path.join(p, 'project-evidence.yml'), `alias: ${value}\nfoo: bar\n`);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function seedM365Auth(dir, owner) {
|
|
21
|
+
const p = path.join(dir, '.kushi', 'config', 'user');
|
|
22
|
+
fs.mkdirSync(p, { recursive: true });
|
|
23
|
+
fs.writeFileSync(path.join(p, 'm365-auth.json'), JSON.stringify({ _meta: { owner } }));
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
test('resolveAlias: env var wins over yml + m365-auth', () => {
|
|
27
|
+
const d = tmp();
|
|
28
|
+
seedEvidenceYml(d, 'fromyml');
|
|
29
|
+
seedM365Auth(d, 'fromupn@contoso.com');
|
|
30
|
+
const r = resolveAliasFor(d, { KUSHI_ALIAS: 'fromenv' }, () => ({ username: 'fromos' }));
|
|
31
|
+
assert.equal(r.source, 'env');
|
|
32
|
+
assert.equal(r.alias, 'fromenv');
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test('resolveAlias: real value in project-evidence.yml is used', () => {
|
|
36
|
+
const d = tmp();
|
|
37
|
+
seedEvidenceYml(d, 'realalias');
|
|
38
|
+
const r = resolveAliasFor(d, {}, () => ({ username: 'fromos' }));
|
|
39
|
+
assert.equal(r.source, 'project-evidence.yml');
|
|
40
|
+
assert.equal(r.alias, 'realalias');
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test('resolveAlias: <auto> placeholder falls through to m365-auth UPN', () => {
|
|
44
|
+
const d = tmp();
|
|
45
|
+
seedEvidenceYml(d, '<auto>');
|
|
46
|
+
seedM365Auth(d, 'jane.doe@microsoft.com');
|
|
47
|
+
const r = resolveAliasFor(d, {}, () => ({ username: 'shouldnotuse' }));
|
|
48
|
+
assert.equal(r.source, 'm365-auth-upn');
|
|
49
|
+
assert.equal(r.alias, 'jane.doe');
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test('resolveAlias: <auto> + no m365-auth falls through to OS username', () => {
|
|
53
|
+
const d = tmp();
|
|
54
|
+
seedEvidenceYml(d, '<auto>');
|
|
55
|
+
const r = resolveAliasFor(d, {}, () => ({ username: 'OS_User' }));
|
|
56
|
+
assert.equal(r.source, 'os-username');
|
|
57
|
+
assert.equal(r.alias, 'os_user');
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test('resolveAlias: <foo> generic placeholder also falls through', () => {
|
|
61
|
+
const d = tmp();
|
|
62
|
+
seedEvidenceYml(d, '<your-alias>');
|
|
63
|
+
const r = resolveAliasFor(d, {}, () => ({ username: 'osuser' }));
|
|
64
|
+
assert.equal(r.source, 'os-username');
|
|
65
|
+
assert.equal(r.alias, 'osuser');
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test('resolveAlias: literal "your-alias" treated as placeholder', () => {
|
|
69
|
+
const d = tmp();
|
|
70
|
+
seedEvidenceYml(d, 'your-alias');
|
|
71
|
+
const r = resolveAliasFor(d, {}, () => ({ username: 'osuser' }));
|
|
72
|
+
assert.equal(r.source, 'os-username');
|
|
73
|
+
assert.equal(r.alias, 'osuser');
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test('resolveAlias: missing .kushi/ falls through to OS username (deterministic last resort)', () => {
|
|
77
|
+
const d = tmp();
|
|
78
|
+
const r = resolveAliasFor(d, {}, () => ({ username: 'osuser' }));
|
|
79
|
+
assert.equal(r.source, 'os-username');
|
|
80
|
+
assert.equal(r.alias, 'osuser');
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test('resolveAlias: m365-auth UPN sanitizes uppercase + special chars', () => {
|
|
84
|
+
const d = tmp();
|
|
85
|
+
seedEvidenceYml(d, '<auto>');
|
|
86
|
+
seedM365Auth(d, 'Bob+Tag@microsoft.com');
|
|
87
|
+
const r = resolveAliasFor(d, {}, () => ({ username: 'osuser' }));
|
|
88
|
+
assert.equal(r.source, 'm365-auth-upn');
|
|
89
|
+
// '+' is stripped, lowercased
|
|
90
|
+
assert.equal(r.alias, 'bobtag');
|
|
91
|
+
});
|