job-application-agent 3.5.0 → 3.6.0
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 +21 -0
- package/installer/src/cli.mjs +8 -1
- package/installer/src/installer.mjs +8 -0
- package/job-application-agent/SKILL.md +21 -0
- package/job-application-agent/capabilities.json +2 -1
- package/job-application-agent/references/AUTONOMY.md +2 -0
- package/job-application-agent/references/FREE_AI.md +39 -0
- package/job-application-agent/references/OUTREACH.md +210 -0
- package/job-application-agent/references/RUNS.md +29 -0
- package/job-application-agent/references/SCHEMAS.md +8 -2
- package/job-application-agent/references/agent-box/README.md +77 -0
- package/job-application-agent/references/agent-box/novnc.service.example +16 -0
- package/job-application-agent/scripts/ats/answer-inject.mjs +203 -0
- package/job-application-agent/scripts/ats/submit-adapters.mjs +194 -0
- package/job-application-agent/scripts/attention-questions.mjs +111 -0
- package/job-application-agent/scripts/attention-resume-submit.mjs +450 -0
- package/job-application-agent/scripts/attention-runner-poll.mjs +328 -0
- package/job-application-agent/scripts/captcha-vendor.mjs +328 -0
- package/job-application-agent/scripts/cloud-state-client.mjs +5 -1
- package/job-application-agent/scripts/job-application.mjs +117 -3
- package/job-application-agent/scripts/novnc-display-guard.mjs +300 -0
- package/job-application-agent/scripts/outreach-cli.mjs +95 -0
- package/job-application-agent/scripts/outreach-domain.mjs +287 -0
- package/job-application-agent/scripts/outreach-store.mjs +72 -0
- package/job-application-agent/scripts/session-binding.mjs +474 -0
- package/job-application-agent/scripts/version.mjs +1 -1
- package/job-application-agent/tests/answer-inject-captcha.test.mjs +169 -0
- package/job-application-agent/tests/attention-resume-submit.test.mjs +119 -0
- package/job-application-agent/tests/attention-runner-poll.test.mjs +135 -0
- package/job-application-agent/tests/fixtures/outreach.mjs +22 -0
- package/job-application-agent/tests/novnc-display-guard.test.mjs +50 -0
- package/job-application-agent/tests/outreach-cli.test.mjs +73 -0
- package/job-application-agent/tests/outreach.test.mjs +178 -0
- package/job-application-agent/tests/privacy-audit.test.mjs +2 -0
- package/job-application-agent/tests/session-binding.test.mjs +102 -0
- package/job-application-agent/tests/skill-contract.test.mjs +25 -0
- package/job-application-agent/tests/workflow-state.test.mjs +12 -1
- package/package.json +6 -3
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import test from "node:test";
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
ASHBY_ADAPTER,
|
|
6
|
+
buildSubmitProbePlan,
|
|
7
|
+
detectAbsoluteBlockers,
|
|
8
|
+
detectConfirmation,
|
|
9
|
+
resolveAtsAdapter,
|
|
10
|
+
} from "../scripts/ats/submit-adapters.mjs";
|
|
11
|
+
import {
|
|
12
|
+
RESUME_EXIT,
|
|
13
|
+
decideResumeSubmit,
|
|
14
|
+
resumeSubmitChecklist,
|
|
15
|
+
} from "../scripts/attention-resume-submit.mjs";
|
|
16
|
+
|
|
17
|
+
const binding = {
|
|
18
|
+
version: 1,
|
|
19
|
+
attentionId: "attention-1",
|
|
20
|
+
jobUrl: "https://jobs.ashbyhq.com/livekit/application",
|
|
21
|
+
browserProfilePath: "/tmp/jaa-chrome",
|
|
22
|
+
display: ":99",
|
|
23
|
+
vncPort: 5900,
|
|
24
|
+
createdAt: "2026-09-16T00:00:00.000Z",
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
test("resolveAtsAdapter picks Ashby for ashbyhq hosts", () => {
|
|
28
|
+
assert.equal(resolveAtsAdapter("https://jobs.ashbyhq.com/x/application").id, "ashby");
|
|
29
|
+
assert.equal(resolveAtsAdapter("https://boards.greenhouse.io/x").id, "generic");
|
|
30
|
+
assert.ok(ASHBY_ADAPTER.submitSelectors.length >= 2);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test("detectAbsoluteBlockers and confirmation helpers", () => {
|
|
34
|
+
assert.equal(detectAbsoluteBlockers({ pageText: "Please complete the reCAPTCHA" }).blocked, true);
|
|
35
|
+
assert.equal(detectConfirmation({
|
|
36
|
+
pageUrl: "https://jobs.ashbyhq.com/x/application-submitted",
|
|
37
|
+
}).confirmed, true);
|
|
38
|
+
assert.equal(detectConfirmation({
|
|
39
|
+
pageUrl: "https://jobs.ashbyhq.com/x/application",
|
|
40
|
+
pageText: "Thank you for applying",
|
|
41
|
+
}).confirmed, true);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test("decideResumeSubmit: ready to submit when clear", () => {
|
|
45
|
+
const decision = decideResumeSubmit({
|
|
46
|
+
binding,
|
|
47
|
+
snapshot: {
|
|
48
|
+
pageUrl: "https://jobs.ashbyhq.com/livekit/application",
|
|
49
|
+
submitEnabled: true,
|
|
50
|
+
leaseHeld: true,
|
|
51
|
+
},
|
|
52
|
+
});
|
|
53
|
+
assert.equal(decision.action, "ready_to_submit");
|
|
54
|
+
assert.equal(decision.exitCode, RESUME_EXIT.READY_TO_SUBMIT);
|
|
55
|
+
assert.match(decision.message, /submit if possible/i);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test("decideResumeSubmit: still blocked on captcha", () => {
|
|
59
|
+
const decision = decideResumeSubmit({
|
|
60
|
+
binding,
|
|
61
|
+
snapshot: {
|
|
62
|
+
pageUrl: "https://jobs.ashbyhq.com/livekit/application",
|
|
63
|
+
pageText: "hCaptcha challenge",
|
|
64
|
+
leaseHeld: true,
|
|
65
|
+
},
|
|
66
|
+
});
|
|
67
|
+
assert.equal(decision.action, "still_blocked");
|
|
68
|
+
assert.equal(decision.exitCode, RESUME_EXIT.STILL_BLOCKED);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test("decideResumeSubmit: tab drift and confirmation", () => {
|
|
72
|
+
const drift = decideResumeSubmit({
|
|
73
|
+
binding,
|
|
74
|
+
snapshot: {
|
|
75
|
+
pageUrl: "https://jobs.ashbyhq.com/livekit",
|
|
76
|
+
leaseHeld: true,
|
|
77
|
+
},
|
|
78
|
+
});
|
|
79
|
+
assert.equal(drift.action, "tab_drift");
|
|
80
|
+
assert.equal(drift.exitCode, RESUME_EXIT.TAB_OR_BINDING);
|
|
81
|
+
|
|
82
|
+
const confirmed = decideResumeSubmit({
|
|
83
|
+
binding,
|
|
84
|
+
snapshot: {
|
|
85
|
+
pageUrl: "https://jobs.ashbyhq.com/livekit/application",
|
|
86
|
+
matchedConfirmationSelectors: ["text=/thank you/i"],
|
|
87
|
+
leaseHeld: true,
|
|
88
|
+
},
|
|
89
|
+
});
|
|
90
|
+
assert.equal(confirmed.action, "submitted_confirmed");
|
|
91
|
+
assert.equal(confirmed.exitCode, RESUME_EXIT.SUBMITTED);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test("decideResumeSubmit: missing binding and ambiguous submit", () => {
|
|
95
|
+
assert.equal(decideResumeSubmit({
|
|
96
|
+
binding: null,
|
|
97
|
+
snapshot: { pageUrl: "https://jobs.ashbyhq.com/livekit/application" },
|
|
98
|
+
}).exitCode, RESUME_EXIT.TAB_OR_BINDING);
|
|
99
|
+
|
|
100
|
+
const ambiguous = decideResumeSubmit({
|
|
101
|
+
binding,
|
|
102
|
+
snapshot: {
|
|
103
|
+
pageUrl: "https://jobs.ashbyhq.com/livekit/application",
|
|
104
|
+
submitAlreadyClicked: true,
|
|
105
|
+
leaseHeld: true,
|
|
106
|
+
},
|
|
107
|
+
});
|
|
108
|
+
assert.equal(ambiguous.action, "submit_ambiguous");
|
|
109
|
+
assert.equal(ambiguous.exitCode, RESUME_EXIT.AWAITING_CONFIRM);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
test("buildSubmitProbePlan and checklist are actionable", () => {
|
|
113
|
+
const plan = buildSubmitProbePlan("https://jobs.ashbyhq.com/x/application");
|
|
114
|
+
assert.equal(plan.adapterId, "ashby");
|
|
115
|
+
assert.ok(plan.submitSelectors.includes('button[type="submit"]'));
|
|
116
|
+
const lines = resumeSubmitChecklist();
|
|
117
|
+
assert.ok(lines.some((l) => /DISPLAY=:99/i.test(l)));
|
|
118
|
+
assert.ok(lines.some((l) => /intent-confirm|ledger add/i.test(l)));
|
|
119
|
+
});
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import test from "node:test";
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
EXIT,
|
|
6
|
+
buildSignalPollUrl,
|
|
7
|
+
interpretPoll,
|
|
8
|
+
parseArgs,
|
|
9
|
+
pollAttentionSignal,
|
|
10
|
+
resolvePollConfig,
|
|
11
|
+
} from "../scripts/attention-runner-poll.mjs";
|
|
12
|
+
|
|
13
|
+
test("runner poll interprets resume/skip/abort with stable exit codes", () => {
|
|
14
|
+
assert.equal(interpretPoll({ resumeRequested: true, signal: "resume_requested" }).exitCode, EXIT.RESUME);
|
|
15
|
+
assert.equal(interpretPoll({ skipped: true, signal: "skipped" }).exitCode, EXIT.SKIPPED);
|
|
16
|
+
assert.equal(interpretPoll({ aborted: true, signal: "aborted" }).exitCode, EXIT.ABORTED);
|
|
17
|
+
assert.equal(interpretPoll({ signal: null, pending: false }).done, false);
|
|
18
|
+
assert.match(interpretPoll({ resumeRequested: true }).message, /visible confirmation/i);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
test("resolvePollConfig derives site origin from notify URL", () => {
|
|
22
|
+
const cfg = resolvePollConfig({
|
|
23
|
+
ATTENTION_NOTIFY_SECRET: "secret",
|
|
24
|
+
ATTENTION_NOTIFY_URL: "https://jobappagent.com/api/internal/attention-notify",
|
|
25
|
+
});
|
|
26
|
+
assert.equal(cfg.siteUrl, "https://jobappagent.com");
|
|
27
|
+
assert.equal(
|
|
28
|
+
buildSignalPollUrl(cfg.siteUrl, "attention-1"),
|
|
29
|
+
"https://jobappagent.com/api/internal/attention-signals/attention-1",
|
|
30
|
+
);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test("parseArgs reads attention id, interval, timeout, once", () => {
|
|
34
|
+
const args = parseArgs(["--attention-id", "attention-x", "--interval", "3", "--timeout", "30", "--once"]);
|
|
35
|
+
assert.equal(args.attentionId, "attention-x");
|
|
36
|
+
assert.equal(args.intervalSec, 3);
|
|
37
|
+
assert.equal(args.timeoutSec, 30);
|
|
38
|
+
assert.equal(args.once, true);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test("pollAttentionSignal returns resume exit code and next-action message", async () => {
|
|
42
|
+
let calls = 0;
|
|
43
|
+
const result = await pollAttentionSignal({
|
|
44
|
+
attentionId: "attention-1",
|
|
45
|
+
siteUrl: "https://jobappagent.com",
|
|
46
|
+
secret: "test-secret",
|
|
47
|
+
intervalSec: 1,
|
|
48
|
+
timeoutSec: 10,
|
|
49
|
+
log: () => {},
|
|
50
|
+
sleep: async () => {},
|
|
51
|
+
fetchImpl: async () => {
|
|
52
|
+
calls += 1;
|
|
53
|
+
if (calls === 1) {
|
|
54
|
+
return Response.json({
|
|
55
|
+
attentionId: "attention-1",
|
|
56
|
+
signal: null,
|
|
57
|
+
pending: false,
|
|
58
|
+
resumeRequested: false,
|
|
59
|
+
skipped: false,
|
|
60
|
+
aborted: false,
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
return Response.json({
|
|
64
|
+
attentionId: "attention-1",
|
|
65
|
+
signal: "resume_requested",
|
|
66
|
+
pending: true,
|
|
67
|
+
resumeRequested: true,
|
|
68
|
+
skipped: false,
|
|
69
|
+
aborted: false,
|
|
70
|
+
updatedAt: "2026-09-16T00:00:00.000Z",
|
|
71
|
+
});
|
|
72
|
+
},
|
|
73
|
+
});
|
|
74
|
+
assert.equal(result.ok, true);
|
|
75
|
+
assert.equal(result.exitCode, EXIT.RESUME);
|
|
76
|
+
assert.equal(result.action, "resume");
|
|
77
|
+
assert.match(result.message, /renew cloud lease/i);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test("pollAttentionSignal fails closed without secret or on 401", async () => {
|
|
81
|
+
const missing = await pollAttentionSignal({
|
|
82
|
+
attentionId: "attention-1",
|
|
83
|
+
siteUrl: "https://jobappagent.com",
|
|
84
|
+
secret: "",
|
|
85
|
+
log: () => {},
|
|
86
|
+
});
|
|
87
|
+
assert.equal(missing.exitCode, EXIT.ERROR);
|
|
88
|
+
|
|
89
|
+
const unauthorized = await pollAttentionSignal({
|
|
90
|
+
attentionId: "attention-1",
|
|
91
|
+
siteUrl: "https://jobappagent.com",
|
|
92
|
+
secret: "bad",
|
|
93
|
+
once: true,
|
|
94
|
+
log: () => {},
|
|
95
|
+
fetchImpl: async () => new Response("nope", { status: 401 }),
|
|
96
|
+
});
|
|
97
|
+
assert.equal(unauthorized.exitCode, EXIT.ERROR);
|
|
98
|
+
assert.match(unauthorized.error, /unauthorized/i);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
test("pollAttentionSignal exits skipped and aborted", async () => {
|
|
102
|
+
const skipped = await pollAttentionSignal({
|
|
103
|
+
attentionId: "attention-1",
|
|
104
|
+
siteUrl: "https://jobappagent.com",
|
|
105
|
+
secret: "s",
|
|
106
|
+
once: true,
|
|
107
|
+
log: () => {},
|
|
108
|
+
fetchImpl: async () => Response.json({
|
|
109
|
+
attentionId: "attention-1",
|
|
110
|
+
signal: "skipped",
|
|
111
|
+
pending: true,
|
|
112
|
+
resumeRequested: false,
|
|
113
|
+
skipped: true,
|
|
114
|
+
aborted: false,
|
|
115
|
+
}),
|
|
116
|
+
});
|
|
117
|
+
assert.equal(skipped.exitCode, EXIT.SKIPPED);
|
|
118
|
+
|
|
119
|
+
const aborted = await pollAttentionSignal({
|
|
120
|
+
attentionId: "attention-1",
|
|
121
|
+
siteUrl: "https://jobappagent.com",
|
|
122
|
+
secret: "s",
|
|
123
|
+
once: true,
|
|
124
|
+
log: () => {},
|
|
125
|
+
fetchImpl: async () => Response.json({
|
|
126
|
+
attentionId: "attention-1",
|
|
127
|
+
signal: "aborted",
|
|
128
|
+
pending: true,
|
|
129
|
+
resumeRequested: false,
|
|
130
|
+
skipped: false,
|
|
131
|
+
aborted: true,
|
|
132
|
+
}),
|
|
133
|
+
});
|
|
134
|
+
assert.equal(aborted.exitCode, EXIT.ABORTED);
|
|
135
|
+
});
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { initialOutreach, mutateOutreach } from '../../scripts/outreach-domain.mjs';
|
|
2
|
+
export const now = '2026-09-16T10:00:00.000Z';
|
|
3
|
+
export function assessment(id = 'opportunity-1', extra = {}) {
|
|
4
|
+
return { operationId: `assess-${id}`, id, company: { name: 'Example', domain: 'example.org', aliases: [] },
|
|
5
|
+
role: 'Staff Product Engineer', channel: 'linkedin', recipient: { account: 'https://www.linkedin.com/in/example-recruiter', aliases: [] },
|
|
6
|
+
source: { kind: 'hiring-post', url: 'https://example.org/jobs/1' },
|
|
7
|
+
qualification: { active: true, companyVerified: true, eligible: true, fit: true, affiliation: true, hiringInvolvement: true },
|
|
8
|
+
gateEvidence: { active: ['role'], companyVerified: ['role'], eligible: ['role'], fit: ['role', 'experience'], affiliation: ['role'], hiringInvolvement: ['role'] },
|
|
9
|
+
evidence: [{ id: 'role', kind: 'role', source: 'https://example.org/jobs/1', observedAt: now, text: 'Active eligible role; recruiter is hiring for this team.' },
|
|
10
|
+
{ id: 'experience', kind: 'candidate', source: 'canonical-resume', observedAt: now, text: 'Verified product engineering experience.' }],
|
|
11
|
+
ranking: { hiringSignal: 4, responsibility: 3, fit: 3, freshness: 2, relationship: 0 }, ...extra };
|
|
12
|
+
}
|
|
13
|
+
export function fixture() {
|
|
14
|
+
let state = initialOutreach();
|
|
15
|
+
const run = (action, input) => { const result = mutateOutreach(state, action, input, { now, actor: 'test-host' }); state = result.state; return result.result; };
|
|
16
|
+
run('policy-enable', { operationId: 'enable', timezone: 'Asia/Kolkata' });
|
|
17
|
+
run('assess', assessment());
|
|
18
|
+
run('draft', { operationId: 'draft-1', id: 'opportunity-1', text: 'Your product engineering role fits my experience. Would a brief conversation be useful?', claimRefs: ['experience'], purpose: 'initial' });
|
|
19
|
+
return { run, get state() { return state; } };
|
|
20
|
+
}
|
|
21
|
+
export const handoff = (extra = {}) => ({ operationId: 'handoff-1', id: 'opportunity-1', draftRevision: 1, qualificationRevision: 1,
|
|
22
|
+
selectedByUser: true, recheckedAt: now, history: { kind: 'user-reported', noPriorPitch: true, checkedAt: now }, ...extra });
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { readFile } from "node:fs/promises";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
import test from "node:test";
|
|
5
|
+
|
|
6
|
+
import {
|
|
7
|
+
evaluateNovncDisplayGuard,
|
|
8
|
+
inspectNovncTargetText,
|
|
9
|
+
NOVNC_GUARD_EXIT,
|
|
10
|
+
} from "../scripts/novnc-display-guard.mjs";
|
|
11
|
+
|
|
12
|
+
test("inspectNovncTargetText finds fill vs forbidden ports", () => {
|
|
13
|
+
const good = inspectNovncTargetText("ExecStart=/usr/bin/websockify --web=/usr/share/novnc 6080 localhost:5900");
|
|
14
|
+
assert.equal(good.websockifyTargets[0].port, 5900);
|
|
15
|
+
|
|
16
|
+
const bad = inspectNovncTargetText("ExecStart=websockify 6080 localhost:5901");
|
|
17
|
+
assert.equal(bad.websockifyTargets[0].port, 5901);
|
|
18
|
+
assert.ok(bad.forbiddenPortMentions.length >= 1);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
test("evaluateNovncDisplayGuard fails closed on 5901", () => {
|
|
22
|
+
const fail = evaluateNovncDisplayGuard({
|
|
23
|
+
text: "websockify 6080 127.0.0.1:5901",
|
|
24
|
+
});
|
|
25
|
+
assert.equal(fail.ok, false);
|
|
26
|
+
assert.ok(fail.errors.some((e) => /5901|TigerVNC/i.test(e)));
|
|
27
|
+
|
|
28
|
+
const ok = evaluateNovncDisplayGuard({
|
|
29
|
+
text: "websockify 6080 localhost:5900\nEnvironment=DISPLAY=:99",
|
|
30
|
+
});
|
|
31
|
+
assert.equal(ok.ok, true);
|
|
32
|
+
assert.equal(ok.targetPort, 5900);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test("evaluateNovncDisplayGuard accepts explicit target port", () => {
|
|
36
|
+
assert.equal(evaluateNovncDisplayGuard({ targetPort: 5900 }).ok, true);
|
|
37
|
+
assert.equal(evaluateNovncDisplayGuard({ targetPort: 5901 }).ok, false);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test("example novnc.service targets localhost:5900", async () => {
|
|
41
|
+
const path = fileURLToPath(new URL("../references/agent-box/novnc.service.example", import.meta.url));
|
|
42
|
+
const text = await readFile(path, "utf8");
|
|
43
|
+
const result = evaluateNovncDisplayGuard({ text });
|
|
44
|
+
assert.equal(result.ok, true);
|
|
45
|
+
assert.equal(result.targetPort, 5900);
|
|
46
|
+
assert.match(text, /localhost:5900/);
|
|
47
|
+
assert.match(text, /ExecStart=.*localhost:5900/);
|
|
48
|
+
assert.doesNotMatch(text.replace(/#.*$/gm, ""), /5901/);
|
|
49
|
+
assert.equal(NOVNC_GUARD_EXIT.FAIL, 1);
|
|
50
|
+
});
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import test from 'node:test';
|
|
3
|
+
import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
|
|
4
|
+
import { tmpdir } from 'node:os';
|
|
5
|
+
import { join } from 'node:path';
|
|
6
|
+
import { execFile } from 'node:child_process';
|
|
7
|
+
import { promisify } from 'node:util';
|
|
8
|
+
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
9
|
+
import { outreachCache, runOutreach } from '../scripts/outreach-cli.mjs';
|
|
10
|
+
import { migrateLegacyStateDir, legacyMacStateDir, resolveStateDir } from '../scripts/secret-store.mjs';
|
|
11
|
+
const exec = promisify(execFile);
|
|
12
|
+
const cli = fileURLToPath(new URL('../scripts/job-application.mjs', import.meta.url));
|
|
13
|
+
|
|
14
|
+
test('CLI outreach success and errors bypass telemetry, identity, and community network paths', async () => {
|
|
15
|
+
const dir = await mkdtemp(join(tmpdir(), 'outreach-cli-'));
|
|
16
|
+
try {
|
|
17
|
+
const trap = join(dir, 'network-trap.mjs');
|
|
18
|
+
await writeFile(trap, "import { appendFileSync } from 'node:fs'; globalThis.fetch = async () => { appendFileSync(process.env.NETWORK_MARKER, 'called'); throw new Error('NETWORK_TRAP'); };\n");
|
|
19
|
+
const run = args => exec(process.execPath, ['--import', pathToFileURL(trap).href, cli, 'outreach', ...args], { env: { ...process.env, NETWORK_MARKER: join(dir, 'network-called'), JOB_APPLICATION_AGENT_STATE_DIR: dir, JOB_APPLICATION_AGENT_CLOUD_CONFIG: join(dir, 'absent.json') } });
|
|
20
|
+
const result = await run(['policy', 'status']);
|
|
21
|
+
assert.equal(JSON.parse(result.stdout).enabled, false);
|
|
22
|
+
await assert.rejects(run(['invented']), error => !error.stderr.includes('NETWORK_TRAP') && /outreach/i.test(error.stderr));
|
|
23
|
+
for (const name of ['telemetry.json', 'source-sharing.json', 'telemetry-identity.json']) await assert.rejects(readFile(join(dir, name)), { code: 'ENOENT' });
|
|
24
|
+
await assert.rejects(readFile(join(dir, 'network-called')), { code: 'ENOENT' });
|
|
25
|
+
} finally { await rm(dir, { recursive: true, force: true }); }
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
test('late snapshots cannot undo mutation invalidation or a newer clear snapshot', async () => {
|
|
29
|
+
const dir = await mkdtemp(join(tmpdir(), 'outreach-cache-race-'));
|
|
30
|
+
try {
|
|
31
|
+
const { generation } = await outreachCache(dir, 'synthetic-binding', 'read');
|
|
32
|
+
await outreachCache(dir, 'synthetic-binding', 'invalidate');
|
|
33
|
+
const rejected = await outreachCache(dir, 'synthetic-binding', 'write', { generation, snapshot: { revision: 1, privateText: 'old' } });
|
|
34
|
+
assert.equal(rejected.snapshot, undefined);
|
|
35
|
+
await outreachCache(dir, 'synthetic-binding', 'write', { generation: rejected.generation, snapshot: { revision: 3, cleared: true } });
|
|
36
|
+
const current = await outreachCache(dir, 'synthetic-binding', 'write', { generation: rejected.generation, snapshot: { revision: 2, privateText: 'old' } });
|
|
37
|
+
assert.equal(current.snapshot, undefined);
|
|
38
|
+
assert.equal(current.generation, rejected.generation + 1);
|
|
39
|
+
assert.equal(JSON.stringify(current).includes('old'), false);
|
|
40
|
+
} finally { await rm(dir, { recursive: true, force: true }); }
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
test('outreach first-run migrates legacy state before creating its database', async () => {
|
|
45
|
+
const home = await mkdtemp(join(tmpdir(), 'outreach-legacy-'));
|
|
46
|
+
try {
|
|
47
|
+
const options = { home, env: {}, plat: 'darwin' };
|
|
48
|
+
const legacy = legacyMacStateDir(home), destination = resolveStateDir(options);
|
|
49
|
+
await mkdir(legacy, { recursive: true });
|
|
50
|
+
await writeFile(join(legacy, 'applications.ndjson'), '{"id":"legacy"}\n');
|
|
51
|
+
await runOutreach(['policy', 'status'], { stateDirectory: destination,
|
|
52
|
+
cloudClient: { config: async () => null }, migrate: directory => migrateLegacyStateDir(directory, options) });
|
|
53
|
+
assert.equal(await readFile(join(destination, 'applications.ndjson'), 'utf8'), '{"id":"legacy"}\n');
|
|
54
|
+
} finally { await rm(home, { recursive: true, force: true }); }
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test('a lower authoritative revision removes pre-restore sensitive cache before offline use', async () => {
|
|
58
|
+
const dir = await mkdtemp(join(tmpdir(), 'outreach-restored-cache-'));
|
|
59
|
+
try {
|
|
60
|
+
const binding = 'same-worker-and-token';
|
|
61
|
+
const { generation } = await outreachCache(dir, binding, 'read');
|
|
62
|
+
await outreachCache(dir, binding, 'write', { generation, snapshot: { revision: 50, privateText: 'pre-restore' } });
|
|
63
|
+
await outreachCache(dir, binding, 'write', { generation, snapshot: { revision: 3, items: [], policy: { recoveryBlocked: true } } });
|
|
64
|
+
const cache = await outreachCache(dir, binding, 'read');
|
|
65
|
+
assert.equal(cache.snapshot, undefined);
|
|
66
|
+
assert.equal((await readFile(join(dir, 'outreach-cloud-cache.json'), 'utf8')).includes('pre-restore'), false);
|
|
67
|
+
// A delayed pre-restore response cannot refill the invalidated cache.
|
|
68
|
+
await outreachCache(dir, binding, 'write', { generation, snapshot: { revision: 51, privateText: 'pre-restore' } });
|
|
69
|
+
assert.equal((await outreachCache(dir, binding, 'read')).snapshot, undefined);
|
|
70
|
+
await outreachCache(dir, binding, 'write', { generation: cache.generation, snapshot: { revision: 3, items: [] } });
|
|
71
|
+
assert.deepEqual((await outreachCache(dir, binding, 'read')).snapshot.items, []);
|
|
72
|
+
} finally { await rm(dir, { recursive: true, force: true }); }
|
|
73
|
+
});
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import test from 'node:test';
|
|
3
|
+
import { initialOutreach, mutateOutreach, readOutreach, businessDate } from '../scripts/outreach-domain.mjs';
|
|
4
|
+
|
|
5
|
+
import { assessment, fixture, handoff, now } from './fixtures/outreach.mjs';
|
|
6
|
+
|
|
7
|
+
test('qualified hiring posts can be drafted and handed off without an application or transmission', () => {
|
|
8
|
+
const f = fixture();
|
|
9
|
+
const result = f.run('handoff', handoff());
|
|
10
|
+
assert.equal(result.delivery, 'pending-handoff');
|
|
11
|
+
assert.match(result.copyableText, /product engineering/);
|
|
12
|
+
assert.equal(readOutreach(f.state, 'review', {}, now).sentVerified, 0);
|
|
13
|
+
assert.deepEqual(f.run('handoff', handoff()), result);
|
|
14
|
+
assert.throws(() => f.run('handoff', handoff({ draftRevision: 2 })), /operation.*content/i);
|
|
15
|
+
});
|
|
16
|
+
test('false qualification and missing candidate claim references block handoff or draft', () => {
|
|
17
|
+
const f = fixture();
|
|
18
|
+
assert.throws(() => f.run('draft', { operationId: 'bad', id: 'opportunity-1', text: 'Hello', claimRefs: ['invented'], purpose: 'initial' }), /claim/i);
|
|
19
|
+
f.run('assess', assessment('opportunity-1', { operationId: 'reassess', qualification: { active: true, companyVerified: true, eligible: false, fit: true, affiliation: true, hiringInvolvement: true } }));
|
|
20
|
+
assert.throws(() => f.run('handoff', handoff({ qualificationRevision: 2 })), /qualification/i);
|
|
21
|
+
});
|
|
22
|
+
test('company reservation blocks other channels and does not expire after uncertain handoff', () => {
|
|
23
|
+
const f = fixture(); f.run('handoff', handoff());
|
|
24
|
+
f.run('record', { operationId: 'uncertain', id: 'opportunity-1', type: 'uncertain', attemptId: 'handoff-1', occurredAt: now, evidence: 'User cannot confirm sending.' });
|
|
25
|
+
f.run('assess', assessment('second', { channel: 'x', recipient: { account: 'https://x.com/example_hiring', aliases: [] } }));
|
|
26
|
+
f.run('draft', { operationId: 'draft-2', id: 'second', text: 'Hello', claimRefs: [], purpose: 'initial' });
|
|
27
|
+
assert.throws(() => f.run('handoff', handoff({ operationId: 'handoff-2', id: 'second' })), /reserved|contact/i);
|
|
28
|
+
});
|
|
29
|
+
test('clear removes sensitive material, retains suppression, and refuses resurrection', () => {
|
|
30
|
+
const f = fixture(); f.run('handoff', handoff());
|
|
31
|
+
f.run('clear', { operationId: 'clear', ids: ['opportunity-1'] });
|
|
32
|
+
const serialized = JSON.stringify(f.state);
|
|
33
|
+
for (const privateText of ['example.org', 'example-recruiter', 'canonical-resume', 'brief conversation']) assert.equal(serialized.includes(privateText), false);
|
|
34
|
+
assert.throws(() => f.run('assess', assessment('opportunity-1', { operationId: 'resurrect' })), /cleared/i);
|
|
35
|
+
assert.equal(f.run('handoff', handoff()).cleared, true);
|
|
36
|
+
});
|
|
37
|
+
test('business dates use local calendar weekdays across weekends', () => {
|
|
38
|
+
assert.equal(businessDate('2026-09-18T23:30:00Z', 'Asia/Kolkata', 7), '2026-09-29');
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test('ranking does not exclude qualified contacts and aliases identify the same recipient', () => {
|
|
42
|
+
const f = fixture();
|
|
43
|
+
f.run('assess', assessment('opportunity-1', { operationId: 'low-rank', ranking: { hiringSignal: 0, responsibility: 0, fit: 0, freshness: 0, relationship: 0 } }));
|
|
44
|
+
f.run('draft', { operationId: 'new-draft', id: 'opportunity-1', text: 'Hello', claimRefs: [], purpose: 'initial' });
|
|
45
|
+
assert.equal(f.run('handoff', handoff({ draftRevision: 2, qualificationRevision: 2 })).score, 0);
|
|
46
|
+
f.run('assess', assessment('other-company', { company: { name: 'Second', domain: 'second.example', aliases: [] }, recipient: { account: 'https://linkedin.com/in/EXAMPLE-RECRUITER/?tracking=1', aliases: [] } }));
|
|
47
|
+
f.run('draft', { operationId: 'other-draft', id: 'other-company', text: 'Hello', claimRefs: [], purpose: 'initial' });
|
|
48
|
+
assert.throws(() => f.run('handoff', handoff({ operationId: 'other-handoff', id: 'other-company' })), /reserved/);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test('follow-up depends on actual send dates, and replies cancel it', () => {
|
|
52
|
+
const f = fixture(); f.run('handoff', handoff());
|
|
53
|
+
f.run('record', { operationId: 'sent', id: 'opportunity-1', attemptId: 'handoff-1', type: 'sent-user-reported', occurredAt: now, evidence: 'User reports manual send.' });
|
|
54
|
+
assert.equal(readOutreach(f.state, 'show', { id: 'opportunity-1' }, '2026-09-24T10:00:00Z').followupDue, false);
|
|
55
|
+
assert.equal(readOutreach(f.state, 'show', { id: 'opportunity-1' }, '2026-09-25T10:00:00Z').followupDue, true);
|
|
56
|
+
f.run('draft', { operationId: 'followup-draft', id: 'opportunity-1', text: 'Following up on the role.', claimRefs: [], purpose: 'follow-up' });
|
|
57
|
+
assert.equal(readOutreach(f.state, 'show', { id: 'opportunity-1' }, '2026-10-20T10:00:00Z').noResponse, false);
|
|
58
|
+
f.run('record', { operationId: 'reply', id: 'opportunity-1', type: 'screen-proposed', occurredAt: now, evidence: 'Recruiter proposed a discussion.' });
|
|
59
|
+
assert.equal(readOutreach(f.state, 'show', { id: 'opportunity-1' }, '2026-10-20T10:00:00Z').followupDue, false);
|
|
60
|
+
assert.equal(readOutreach(f.state, 'review', {}, now).outcomes['screen-scheduled'], 0);
|
|
61
|
+
assert.throws(() => f.run('record', { operationId: 'bad-schedule', id: 'opportunity-1', type: 'screen-scheduled', occurredAt: now, evidence: 'They mentioned a discussion.' }), /object/);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test('conflicting delivery evidence requires explicit correction; not-sent release can be corrected safely', () => {
|
|
65
|
+
const f = fixture(); f.run('handoff', handoff());
|
|
66
|
+
const record = (operationId, type, extra = {}) => f.run('record', { operationId, id: 'opportunity-1', attemptId: 'handoff-1', type, occurredAt: now, evidence: 'Synthetic observation.', ...extra });
|
|
67
|
+
record('uncertain', 'uncertain');
|
|
68
|
+
assert.equal(record('sent', 'sent-user-reported').delivery, 'conflict');
|
|
69
|
+
assert.equal(record('fixed', 'not-sent', { supersedes: ['uncertain', 'sent'] }).delivery, 'not-sent');
|
|
70
|
+
assert.equal(Object.keys(f.state.reservations).length, 0);
|
|
71
|
+
assert.equal(f.run('handoff', handoff()).copyableText, undefined);
|
|
72
|
+
record('later-proof', 'sent-verified', { supersedes: ['fixed'], messageRef: 'message-1' });
|
|
73
|
+
assert.equal(Object.keys(f.state.reservations).length, 1);
|
|
74
|
+
assert.equal(readOutreach(f.state, 'show', { id: 'opportunity-1' }, now).delivery, 'sent-verified');
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test('linked applications must match both company and role', () => {
|
|
78
|
+
const f = fixture();
|
|
79
|
+
const input = assessment('applied', { applicationId: 'app-1', source: { kind: 'application', url: 'https://example.org/jobs/1' } });
|
|
80
|
+
assert.throws(() => mutateOutreach(f.state, 'assess', input, { now, applications: [{ id: 'app-1', company: 'Wrong Company', role: input.role, status: 'submitted' }] }), /company/i);
|
|
81
|
+
const result = mutateOutreach(f.state, 'assess', input, { now, applications: [{ id: 'app-1', company: 'Example', role: input.role, status: 'submitted' }] });
|
|
82
|
+
assert.equal(result.result.eligible, true);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test('common fabricated application and commitment claims are rejected; disabled grants stop handoff', () => {
|
|
86
|
+
const f = fixture();
|
|
87
|
+
for (const [index, text] of ['I applied for this role.', 'I am available immediately.', 'I built AI agents.'].entries()) {
|
|
88
|
+
assert.throws(() => f.run('draft', { operationId: `invalid-${index}`, id: 'opportunity-1', text, claimRefs: [], purpose: 'initial' }), /claim|commitment|evidence/);
|
|
89
|
+
}
|
|
90
|
+
f.run('policy-disable', { operationId: 'disable' });
|
|
91
|
+
assert.throws(() => f.run('handoff', handoff()), /disabled/);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test('recipient and company suppression cannot be overridden by an exception', () => {
|
|
95
|
+
const f = fixture();
|
|
96
|
+
f.run('suppress', { operationId: 'stop', id: 'opportunity-1', scope: 'company', reason: 'Candidate requested no contact.' });
|
|
97
|
+
assert.throws(() => f.run('handoff', handoff({ exception: { approvedByUser: true, reason: 'Attempted bypass.' } })), /suppressed/);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
test('company suppression cancels due suggestions for other opportunities', () => {
|
|
101
|
+
const f = fixture(); f.run('handoff', handoff());
|
|
102
|
+
f.run('record', { operationId: 'sent', id: 'opportunity-1', attemptId: 'handoff-1', type: 'sent-user-reported', occurredAt: now, evidence: 'User reports sending.' });
|
|
103
|
+
f.run('assess', assessment('other'));
|
|
104
|
+
f.run('suppress', { operationId: 'stop-other', id: 'other', scope: 'company', reason: 'Company-wide stop.' });
|
|
105
|
+
assert.equal(readOutreach(f.state, 'show', { id: 'opportunity-1' }, '2026-10-01T10:00:00Z').followupDue, false);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
test('one follow-up can close as no response only after it was sent', () => {
|
|
109
|
+
const f = fixture(); f.run('handoff', handoff());
|
|
110
|
+
f.run('record', { operationId: 'initial-sent', id: 'opportunity-1', type: 'sent-user-reported', attemptId: 'handoff-1', occurredAt: now, evidence: 'User reports sending.' });
|
|
111
|
+
f.run('draft', { operationId: 'followup', id: 'opportunity-1', text: 'Following up on the role.', claimRefs: [], purpose: 'follow-up' });
|
|
112
|
+
const later = '2026-09-25T10:00:00Z';
|
|
113
|
+
let state = mutateOutreach(f.state, 'handoff', handoff({ operationId: 'followup-handoff', draftRevision: 2, recheckedAt: later, history: { kind: 'verified', noPriorPitch: false, checkedAt: later } }), { now: later }).state;
|
|
114
|
+
assert.equal(readOutreach(state, 'show', { id: 'opportunity-1' }, '2026-10-20T10:00:00Z').noResponse, false);
|
|
115
|
+
state = mutateOutreach(state, 'record', { operationId: 'followup-sent', id: 'opportunity-1', type: 'sent-user-reported', attemptId: 'followup-handoff', occurredAt: later, evidence: 'User reports follow-up.' }, { now: later }).state;
|
|
116
|
+
assert.equal(readOutreach(state, 'show', { id: 'opportunity-1' }, '2026-10-06T10:00:00Z').noResponse, true);
|
|
117
|
+
assert.equal(readOutreach(state, 'show', { id: 'opportunity-1' }, '2026-10-06T10:00:00Z').followupDue, false);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
test('progression corrections cannot supersede delivery evidence and bypass an unresolved reservation', () => {
|
|
121
|
+
const f = fixture(); f.run('handoff', handoff());
|
|
122
|
+
f.run('record', { operationId: 'sent', id: 'opportunity-1', type: 'sent-user-reported', attemptId: 'handoff-1', occurredAt: now, evidence: 'User reports sending.' });
|
|
123
|
+
assert.throws(() => f.run('record', { operationId: 'bad-correction', id: 'opportunity-1', type: 'replied', supersedes: ['sent'], occurredAt: now, evidence: 'Wrong category.' }), /category|delivery/i);
|
|
124
|
+
f.run('record', { operationId: 'uncertain-correction', id: 'opportunity-1', type: 'uncertain', attemptId: 'handoff-1', supersedes: ['sent'], occurredAt: now, evidence: 'Send could not be verified.' });
|
|
125
|
+
f.run('assess', assessment('second'));
|
|
126
|
+
f.run('draft', { operationId: 'second-draft', id: 'second', text: 'Hello', claimRefs: [], purpose: 'initial' });
|
|
127
|
+
assert.throws(() => f.run('handoff', handoff({ operationId: 'second-handoff', id: 'second', exception: { approvedByUser: true, reason: 'Try another contact.' } })), /unresolved/);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
test('valid opaque IDs may match Object.prototype property names', () => {
|
|
131
|
+
const f = fixture();
|
|
132
|
+
assert.throws(() => f.run('clear', { operationId: 'bad-clear', ids: ['constructor'] }), /not found/);
|
|
133
|
+
assert.equal(Object.hasOwn(f.state.reservations, 'clear-constructor'), false);
|
|
134
|
+
f.run('assess', assessment('constructor', { operationId: 'toString' }));
|
|
135
|
+
f.run('draft', { operationId: 'constructor', id: 'constructor', text: 'Hello', claimRefs: [], purpose: 'initial' });
|
|
136
|
+
assert.equal(readOutreach(f.state, 'show', { id: 'constructor' }, now).content.drafts.length, 1);
|
|
137
|
+
assert.equal(readOutreach(f.state, 'show', { id: 'constructor' }, now).cleared, false);
|
|
138
|
+
assert.equal(f.run('draft', { operationId: 'constructor', id: 'constructor', text: 'Hello', claimRefs: [], purpose: 'initial' }).cleared, false);
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
test('clearing another opportunity cannot overwrite a pending handoff reservation', () => {
|
|
142
|
+
const f = fixture();
|
|
143
|
+
f.run('handoff', handoff({ operationId: 'clear-other' }));
|
|
144
|
+
f.run('assess', assessment('other', { company: { name: 'Other', domain: 'other.org', aliases: [] }, recipient: { account: 'https://x.com/other', aliases: [] }, channel: 'x' }));
|
|
145
|
+
f.run('clear', { operationId: 'clear-op', ids: ['other'] });
|
|
146
|
+
assert.equal(f.state.reservations['clear-other'].pending, true);
|
|
147
|
+
f.run('assess', assessment('retry'));
|
|
148
|
+
f.run('draft', { operationId: 'retry-draft', id: 'retry', text: 'Hello', claimRefs: [], purpose: 'initial' });
|
|
149
|
+
assert.throws(() => f.run('handoff', handoff({ operationId: 'retry-handoff', id: 'retry' })), /unresolved/);
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
test('correcting rejection removes only its derived suppression', () => {
|
|
153
|
+
const f = fixture();
|
|
154
|
+
f.run('record', { operationId: 'rejection', id: 'opportunity-1', type: 'rejected', occurredAt: now, evidence: 'Mistaken rejection.' });
|
|
155
|
+
assert.equal(readOutreach(f.state, 'show', { id: 'opportunity-1' }, now).suppressed, true);
|
|
156
|
+
f.run('record', { operationId: 'correction', id: 'opportunity-1', type: 'replied', supersedes: ['rejection'], occurredAt: now, evidence: 'Actually a reply.' });
|
|
157
|
+
assert.equal(readOutreach(f.state, 'show', { id: 'opportunity-1' }, now).suppressed, false);
|
|
158
|
+
f.run('suppress', { operationId: 'explicit', id: 'opportunity-1', scope: 'recipient', reason: 'User requests stop.' });
|
|
159
|
+
f.run('record', { operationId: 'correction-2', id: 'opportunity-1', type: 'referred', supersedes: ['correction'], occurredAt: now, evidence: 'Referral completed.' });
|
|
160
|
+
assert.equal(readOutreach(f.state, 'show', { id: 'opportunity-1' }, now).suppressed, true);
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
test('company suppression does not suppress a recipient at an unrelated company', () => {
|
|
164
|
+
const f = fixture();
|
|
165
|
+
f.run('suppress', { operationId: 'stop-company', id: 'opportunity-1', scope: 'company', reason: 'Stop this company.' });
|
|
166
|
+
f.run('assess', assessment('new-company', { company: { name: 'Other', domain: 'other.org', aliases: [] } }));
|
|
167
|
+
assert.equal(readOutreach(f.state, 'show', { id: 'new-company' }, now).suppressed, false);
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
test('handoff retries recheck current linked application outcomes', () => {
|
|
171
|
+
const f = fixture();
|
|
172
|
+
const context = { now, applications: [{ id: 'app-1', company: 'Example', role: 'Staff Product Engineer', status: 'submitted' }] };
|
|
173
|
+
let state = mutateOutreach(f.state, 'assess', assessment('opportunity-1', { operationId: 'linked', applicationId: 'app-1' }), context).state;
|
|
174
|
+
state = mutateOutreach(state, 'draft', { operationId: 'linked-draft', id: 'opportunity-1', text: 'Hello', claimRefs: [], purpose: 'initial' }, context).state;
|
|
175
|
+
const input = handoff({ qualificationRevision: 2, draftRevision: 2 });
|
|
176
|
+
state = mutateOutreach(state, 'handoff', input, context).state;
|
|
177
|
+
assert.throws(() => mutateOutreach(state, 'handoff', input, { ...context, outcomes: [{ id: 'app-1', status: 'rejected' }] }), /hiring outcome/);
|
|
178
|
+
});
|
|
@@ -11,6 +11,8 @@ const forbiddenProperties = [
|
|
|
11
11
|
'agentResponse', 'jobDescription', 'formQuestion', 'draftedAnswer', 'note', 'password', 'mfa',
|
|
12
12
|
'captcha', 'legalAnswer', 'demographicAnswer', 'browserData', 'ipAddress', 'requestHeaders',
|
|
13
13
|
'userAgent', 'rawError',
|
|
14
|
+
'recipient', 'recipientAccount', 'message', 'sentText', 'claimRefs', 'evidence',
|
|
15
|
+
'outreachId', 'opportunityId', 'companyFingerprint', 'recipientFingerprint',
|
|
14
16
|
];
|
|
15
17
|
|
|
16
18
|
test('privacy audit rejects every prohibited free-form or identity property', () => {
|