Skip to content

Commit bc27dc4

Browse files
committed
test(cli): end-to-end setup regression for the second-workspace webhook-secret clobber (#612 review B2 / #611 AC#3)
Scott's re-review approved with one should-fix follow-up (B2): the safety seam (readExistingWebhookSecret fail-closed) and the pure decision (resolveWebhookSecretAction) were unit-tested, but nothing drove the `setup` ACTION end-to-end to prove issue #611's AC#3 against the final PutSecretValue payload — linear.ts's secretAction branch + mirror-back guard were uncovered. New test drives `bgagent linear setup demo --no-browser` through parseAsync with the OAuth flow mocked (awaitOauthCallback echoes the wizard's generated state, scraped from the printed auth URL; exchangeAuthorizationCode + fetch + SecretsManager + CFN + DynamoDB stubbed), for the second-workspace re-run: - prior per-workspace bundle carries lin_wh_thisWorkspace, - stack-wide holds a DIFFERENT lin_wh_otherWorkspace (the #611 clobber source). Asserts the final per-workspace PutSecretValue payload keeps lin_wh_thisWorkspace (preserve, folded into the initial bundle — never the stack-wide other), that the stack-wide ARN is never overwritten by setup, and that the registry row is still written (setup ran to completion). A refactor that re-broke the wiring now fails a test, not just inspection. Partial-mocks linear-oauth (keeps the real readExistingWebhookSecret / resolveWebhookSecretAction under test; stubs only exchangeAuthorizationCode). cli gate green: 621 tests, compile + eslint clean.
1 parent 1c0135c commit bc27dc4

1 file changed

Lines changed: 261 additions & 0 deletions

File tree

Lines changed: 261 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,261 @@
1+
/**
2+
* MIT No Attribution
3+
*
4+
* Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
5+
*
6+
* Permission is hereby granted, free of charge, to any person obtaining a copy of
7+
* the Software without restriction, including without limitation the rights to
8+
* use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
9+
* the Software, and to permit persons to whom the Software is furnished to do so.
10+
*
11+
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
12+
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
13+
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
14+
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
15+
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
16+
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
17+
* SOFTWARE.
18+
*/
19+
20+
// #612 review B2 (issue #611 AC#3): end-to-end `linear setup` regression test
21+
// for the SECOND-WORKSPACE RE-RUN case. The pure decision
22+
// (`resolveWebhookSecretAction`) and the fail-closed pre-read
23+
// (`readExistingWebhookSecret`) are unit-tested in linear-oauth.test.ts; this
24+
// file closes the remaining gap by driving the `setup` ACTION through
25+
// `parseAsync` (with a mocked OAuth flow) and asserting the FINAL per-workspace
26+
// `PutSecretValue` payload — i.e. a re-run keeps THIS workspace's `lin_wh_`
27+
// secret and never clobbers it with the stack-wide (other workspace's) one.
28+
// Covers linear.ts's secretAction branch + mirror-back guard.
29+
import {
30+
CreateSecretCommand,
31+
GetSecretValueCommand,
32+
PutSecretValueCommand,
33+
ResourceExistsException,
34+
ResourceNotFoundException,
35+
} from '@aws-sdk/client-secrets-manager';
36+
import { PutCommand } from '@aws-sdk/lib-dynamodb';
37+
import { makeLinearCommand } from '../../src/commands/linear';
38+
import * as configMod from '../../src/config';
39+
40+
// ─── Mocks: external I/O the setup wizard touches before the secret logic ───
41+
42+
// OAuth callback server — never bind a real localhost socket. Resolves with the
43+
// SAME `state` the wizard generated: we capture it from the authorization URL
44+
// the wizard prints under --no-browser (see captureState below).
45+
let capturedState = '';
46+
// Resolve LAZILY: the wizard starts this promise (linear.ts) BEFORE it prints
47+
// the authorization URL we scrape `state` from, so read capturedState after a
48+
// macrotask — by which point the synchronous URL print has run.
49+
const awaitOauthCallbackMock = jest.fn(
50+
() =>
51+
new Promise((resolve) =>
52+
setTimeout(
53+
() => resolve({ kind: 'direct-oauth' as const, code: 'auth-code', state: capturedState }),
54+
0,
55+
),
56+
),
57+
);
58+
jest.mock('../../src/oauth-callback-server', () => ({
59+
awaitOauthCallback: (...args: unknown[]) => awaitOauthCallbackMock(...args),
60+
CALLBACK_URL: 'http://localhost:8080/oauth/callback',
61+
}));
62+
63+
// linear-oauth: PARTIAL mock — keep the real code under test
64+
// (readExistingWebhookSecret + resolveWebhookSecretAction + name/expiry/PKCE/URL
65+
// helpers), stub only the network OAuth-code exchange.
66+
const exchangeAuthorizationCodeMock = jest.fn();
67+
jest.mock('../../src/linear-oauth', () => {
68+
const actual = jest.requireActual('../../src/linear-oauth');
69+
return {
70+
...actual,
71+
exchangeAuthorizationCode: (...args: unknown[]) => exchangeAuthorizationCodeMock(...args),
72+
};
73+
});
74+
75+
const smSend = jest.fn();
76+
jest.mock('@aws-sdk/client-secrets-manager', () => {
77+
const actual = jest.requireActual('@aws-sdk/client-secrets-manager');
78+
return { ...actual, SecretsManagerClient: jest.fn(() => ({ send: smSend })) };
79+
});
80+
81+
const cfnSend = jest.fn();
82+
jest.mock('@aws-sdk/client-cloudformation', () => {
83+
const actual = jest.requireActual('@aws-sdk/client-cloudformation');
84+
return { ...actual, CloudFormationClient: jest.fn(() => ({ send: cfnSend })) };
85+
});
86+
87+
const ddbSend = jest.fn();
88+
jest.mock('@aws-sdk/lib-dynamodb', () => {
89+
const actual = jest.requireActual('@aws-sdk/lib-dynamodb');
90+
return {
91+
...actual,
92+
DynamoDBDocumentClient: { from: jest.fn(() => ({ send: ddbSend })) },
93+
};
94+
});
95+
jest.mock('@aws-sdk/client-dynamodb', () => {
96+
const actual = jest.requireActual('@aws-sdk/client-dynamodb');
97+
return { ...actual, DynamoDBClient: jest.fn(() => ({})) };
98+
});
99+
100+
// A syntactically-valid id_token whose payload carries a `sub` claim, so
101+
// extractCognitoSub() (JWT base64url decode) resolves without real creds.
102+
const FAKE_ID_TOKEN = `x.${Buffer.from(JSON.stringify({ sub: 'sub-abc' })).toString('base64url')}.y`;
103+
104+
const WEBHOOK_ARN = 'arn:aws:secretsmanager:us-west-2:111122223333:secret:LinearWebhookSecret-abc';
105+
const PER_WORKSPACE_NAME = 'bgagent-linear-oauth-demo';
106+
const THIS_WS_SECRET = 'lin_wh_thisWorkspace';
107+
const OTHER_WS_SECRET = 'lin_wh_otherWorkspace';
108+
109+
/** The prior per-workspace OAuth bundle for `demo` — already carries ITS OWN
110+
* webhook signing secret (the value a re-run must preserve, not clobber). */
111+
function priorWorkspaceBundle(): string {
112+
return JSON.stringify({
113+
access_token: 'old-access',
114+
refresh_token: 'old-refresh',
115+
expires_at: '2026-06-17T01:00:00.000Z',
116+
scope: 'read write',
117+
client_id: 'client-id',
118+
client_secret: 'client-secret',
119+
workspace_id: 'org-demo',
120+
workspace_slug: 'demo',
121+
installed_at: '2026-06-01T00:00:00.000Z',
122+
updated_at: '2026-06-01T00:00:00.000Z',
123+
installed_by_platform_user_id: 'sub-abc',
124+
webhook_signing_secret: THIS_WS_SECRET,
125+
});
126+
}
127+
128+
describe('linear setup — second-workspace re-run preserves the per-workspace webhook secret (#611/#612 B2)', () => {
129+
let fetchMock: jest.Mock;
130+
const originalFetch = global.fetch;
131+
132+
beforeEach(() => {
133+
capturedState = '';
134+
awaitOauthCallbackMock.mockClear();
135+
exchangeAuthorizationCodeMock.mockReset();
136+
smSend.mockReset();
137+
cfnSend.mockReset();
138+
ddbSend.mockReset();
139+
140+
cfnSend.mockResolvedValue({
141+
Stacks: [{
142+
Outputs: [
143+
{ OutputKey: 'LinearWorkspaceRegistryTableName', OutputValue: 'registry-table' },
144+
{ OutputKey: 'LinearUserMappingTableName', OutputValue: 'user-mapping-table' },
145+
{ OutputKey: 'LinearWebhookSecretArn', OutputValue: WEBHOOK_ARN },
146+
],
147+
}],
148+
});
149+
150+
exchangeAuthorizationCodeMock.mockResolvedValue({
151+
access_token: 'new-access',
152+
refresh_token: 'new-refresh',
153+
expires_in: 3600,
154+
scope: 'read write',
155+
});
156+
157+
// Linear GraphQL (identity + team keys + self-link picker) all go through
158+
// fetch — benign viewer/org, empty team + member lists.
159+
fetchMock = jest.fn(async () => ({
160+
ok: true,
161+
status: 200,
162+
json: async () => ({
163+
data: {
164+
viewer: { id: 'viewer-1', name: 'Admin', email: 'admin@demo.test' },
165+
organization: { id: 'org-demo', name: 'Demo', urlKey: 'demo' },
166+
teams: { nodes: [] },
167+
users: { nodes: [] },
168+
},
169+
}),
170+
}));
171+
global.fetch = fetchMock as unknown as typeof fetch;
172+
173+
ddbSend.mockResolvedValue({});
174+
});
175+
176+
afterEach(() => {
177+
global.fetch = originalFetch;
178+
});
179+
180+
test('the final per-workspace PutSecretValue keeps THIS workspace secret, not the stack-wide other', async () => {
181+
// SM.send routing by command + target:
182+
// 1. pre-read GetSecretValue(per-workspace bundle) → prior bundle carrying
183+
// lin_wh_thisWorkspace (the value a re-run must preserve).
184+
// 2. upsertOauthSecret: CreateSecret → ResourceExists → PutSecretValue
185+
// (per-workspace) ← the write we assert.
186+
// 3. isWebhookSecretConfigured GetSecretValue(stack-wide ARN) → lin_wh_other
187+
// (a DIFFERENT workspace's secret — the clobber source #611 lifted in).
188+
smSend.mockImplementation((cmd: unknown) => {
189+
if (cmd instanceof GetSecretValueCommand) {
190+
const id = cmd.input.SecretId;
191+
if (id === PER_WORKSPACE_NAME) return Promise.resolve({ SecretString: priorWorkspaceBundle() });
192+
if (id === WEBHOOK_ARN) return Promise.resolve({ SecretString: OTHER_WS_SECRET });
193+
return Promise.reject(new ResourceNotFoundException({ message: 'no', $metadata: {} }));
194+
}
195+
if (cmd instanceof CreateSecretCommand) {
196+
return Promise.reject(new ResourceExistsException({ message: 'exists', $metadata: {} }));
197+
}
198+
if (cmd instanceof PutSecretValueCommand) {
199+
return Promise.resolve({ ARN: `arn:${cmd.input.SecretId}` });
200+
}
201+
return Promise.resolve({});
202+
});
203+
204+
const cfgSpy = jest.spyOn(configMod, 'loadConfig').mockReturnValue(
205+
{ region: 'us-west-2', api_url: 'https://api.example.test' } as ReturnType<typeof configMod.loadConfig>,
206+
);
207+
const credSpy = jest.spyOn(configMod, 'loadCredentials').mockReturnValue(
208+
{ id_token: FAKE_ID_TOKEN } as ReturnType<typeof configMod.loadCredentials>,
209+
);
210+
// Under --no-browser the wizard prints the authorization URL (which carries
211+
// ?state=…). Capture it so awaitOauthCallback echoes the SAME state and the
212+
// wizard's state check passes.
213+
const logSpy = jest.spyOn(console, 'log').mockImplementation((...args: unknown[]) => {
214+
const line = args.map(String).join(' ');
215+
const m = line.match(/[?&]state=([^&\s]+)/);
216+
if (m) capturedState = decodeURIComponent(m[1]);
217+
});
218+
try {
219+
const program = makeLinearCommand();
220+
await program.parseAsync([
221+
'node', 'bgagent', 'setup', 'demo',
222+
'--client-id', 'cid', '--client-secret', 'csecret', '--no-browser',
223+
]);
224+
} finally {
225+
cfgSpy.mockRestore();
226+
credSpy.mockRestore();
227+
logSpy.mockRestore();
228+
}
229+
230+
// Sanity: the wizard actually reached the OAuth callback (state captured).
231+
expect(capturedState).not.toBe('');
232+
expect(awaitOauthCallbackMock).toHaveBeenCalled();
233+
234+
// Every per-workspace PutSecretValue payload must carry THIS workspace's
235+
// secret and NEVER the stack-wide other's — the #611 clobber regression.
236+
const perWsPuts = smSend.mock.calls
237+
.map((c) => c[0])
238+
.filter((cmd): cmd is InstanceType<typeof PutSecretValueCommand> =>
239+
cmd instanceof PutSecretValueCommand && cmd.input.SecretId === PER_WORKSPACE_NAME);
240+
241+
expect(perWsPuts.length).toBeGreaterThanOrEqual(1);
242+
for (const put of perWsPuts) {
243+
const bundle = JSON.parse(String(put.input.SecretString)) as { webhook_signing_secret?: string };
244+
expect(bundle.webhook_signing_secret).toBe(THIS_WS_SECRET);
245+
expect(bundle.webhook_signing_secret).not.toBe(OTHER_WS_SECRET);
246+
}
247+
248+
// The stack-wide ARN is never overwritten by setup (rotation is
249+
// update-webhook-secret's job) — no PutSecretValue targets it.
250+
const stackWidePuts = smSend.mock.calls
251+
.map((c) => c[0])
252+
.filter((cmd) => cmd instanceof PutSecretValueCommand && cmd.input.SecretId === WEBHOOK_ARN);
253+
expect(stackWidePuts).toHaveLength(0);
254+
255+
// The registry row was still written (setup completed end-to-end).
256+
const registryPut = ddbSend.mock.calls
257+
.map((c) => c[0])
258+
.find((cmd) => cmd instanceof PutCommand && cmd.input.TableName === 'registry-table');
259+
expect(registryPut).toBeDefined();
260+
});
261+
});

0 commit comments

Comments
 (0)