Skip to content

Commit cf2408e

Browse files
AshrafBen10bgagentkrokoko
authored
fix(cdk): normalize after cursor case + colon-truncation in approval-scope guard (#360)
* fix(cdk): normalize after cursor case and fix colon-truncation in approval-scope guard Two correctness bugs in the task API handlers: 1. get-task-events: a lower-case `after` ULID cursor passed validation (isValidUlid uppercases before matching) but was used verbatim in the DynamoDB key condition `event_id > :after`. Stored event_ids are upper-case Crockford Base32 and DDB compares raw bytes, so a lower-case cursor sorts after every stored id and the query returns zero events — a consumer using a contract-valid lower-case cursor silently skips the rest of the event stream. Fix: upper-case the cursor before the query. 2. create-task-core: the degenerate-pattern guard for bash_pattern:/ write_path: scopes used scope.split(':', 2)[1], which truncates the value at the next colon. A value whose leading colon-segment is short or wildcard-heavy (e.g. "ab:cdefgh") was truncated to a degenerate fragment and spuriously rejected with a 400. Fix: take everything after the first colon via slice(indexOf(':') + 1). Adds regression tests for both paths. * chore(cdk): apply eslint --fix formatting to create-task-core test Resolves the CI 'Fail build on mutation' step: the build runs eslint --fix, which reformats the 'returns 400 for invalid repo' test (splitting the arrow body onto its own line). The autofix was never committed, so a clean build tree diverged from the checkout. Commit the formatted file so the build is a no-op mutation-wise. --------- Co-authored-by: bgagent <bgagent@noreply.github.com> Co-authored-by: Alain Krok <alkrok@amazon.com>
1 parent ed7f9f4 commit cf2408e

4 files changed

Lines changed: 63 additions & 2 deletions

File tree

cdk/src/handlers/get-task-events.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -118,7 +118,16 @@ export async function handler(event: APIGatewayProxyEvent): Promise<APIGatewayPr
118118
);
119119
}
120120

121-
const afterValid = typeof afterRaw === 'string' && afterRaw.length === ULID_LENGTH ? afterRaw : undefined;
121+
// Normalize the cursor to upper-case before it reaches the DynamoDB key
122+
// condition. ``isValidUlid`` accepts lower-case callers (it uppercases
123+
// before matching), but stored ``event_id``s are upper-case Crockford
124+
// Base32. DynamoDB compares raw bytes, and lower-case ASCII sorts *after*
125+
// upper-case, so a lower-case cursor would be "greater than" every stored
126+
// id and ``event_id > :after`` would return zero rows — silently dropping
127+
// the rest of the event stream for a contract-valid input.
128+
const afterValid = typeof afterRaw === 'string' && afterRaw.length === ULID_LENGTH
129+
? afterRaw.toUpperCase()
130+
: undefined;
122131

123132
// 3b. ``desc`` combined with ``after`` makes no semantic sense: ``after``
124133
// is a forward-walking ULID cursor. Reject the combination rather than

cdk/src/handlers/shared/create-task-core.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -339,7 +339,11 @@ export async function createTaskCore(
339339
parseResult.scope.startsWith('bash_pattern:')
340340
|| parseResult.scope.startsWith('write_path:')
341341
) {
342-
const value = parseResult.scope.split(':', 2)[1] ?? '';
342+
// Take everything after the first colon — the value itself may
343+
// contain colons (e.g. ``bash_pattern:git log --format=%h:%s``), so a
344+
// ``split(':', 2)`` would truncate it and could turn a legitimate
345+
// pattern into a degenerate-looking fragment, producing a spurious 400.
346+
const value = parseResult.scope.slice(parseResult.scope.indexOf(':') + 1);
343347
if (isDegeneratePattern(value)) {
344348
return errorResponse(
345349
400,

cdk/test/handlers/get-task-events.test.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -269,6 +269,22 @@ describe('get-task-events handler', () => {
269269
expect(queryInput.ExclusiveStartKey).toBeUndefined();
270270
});
271271

272+
test('lower-case ?after is normalized to upper-case before the DDB query', async () => {
273+
// ``isValidUlid`` accepts lower-case callers, but stored event_ids are
274+
// upper-case Crockford Base32 and DDB compares raw bytes (lower-case sorts
275+
// after upper-case). Without normalization a lower-case cursor would be
276+
// "greater than" every stored id and silently return zero events.
277+
const event = makeEvent({ queryStringParameters: { after: VALID_AFTER.toLowerCase() } });
278+
await handler(event);
279+
280+
const queryInput = MockQueryCommand.mock.calls[0][0];
281+
expect(queryInput.KeyConditionExpression).toBe('task_id = :tid AND event_id > :after');
282+
expect(queryInput.ExpressionAttributeValues).toEqual({
283+
':tid': 'task-1',
284+
':after': VALID_AFTER, // upper-cased, not the lower-case input
285+
});
286+
});
287+
272288
test('both after and next_token → after wins + WARN logged', async () => {
273289
const stdoutWrite = jest.spyOn(process.stdout, 'write').mockImplementation(() => true);
274290
try {

cdk/test/handlers/shared/create-task-core.test.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,38 @@ describe('createTaskCore', () => {
114114
expect(mockLambdaSend).toHaveBeenCalledTimes(1);
115115
});
116116

117+
test('accepts an initial_approvals pattern whose value contains a colon', async () => {
118+
// Regression: the degenerate-pattern guard used split(':', 2)[1], which
119+
// truncated the value at the next colon. For "ab:cdefgh" that yields the
120+
// 2-char fragment "ab", which isDegeneratePattern flags as degenerate —
121+
// a spurious 400. The full value "ab:cdefgh" is not degenerate, so the
122+
// scope must be accepted.
123+
const result = await createTaskCore(
124+
{
125+
repo: 'org/repo',
126+
task_description: 'Fix the bug',
127+
initial_approvals: ['bash_pattern:ab:cdefgh'],
128+
} as any,
129+
makeContext(),
130+
'req-1',
131+
);
132+
expect(result.statusCode).toBe(201);
133+
});
134+
135+
test('still rejects a genuinely degenerate initial_approvals pattern', async () => {
136+
const result = await createTaskCore(
137+
{
138+
repo: 'org/repo',
139+
task_description: 'Fix the bug',
140+
initial_approvals: ['bash_pattern:*'],
141+
} as any,
142+
makeContext(),
143+
'req-1',
144+
);
145+
expect(result.statusCode).toBe(400);
146+
expect(JSON.parse(result.body).error.code).toBe('VALIDATION_ERROR');
147+
});
148+
117149
test('returns 400 for invalid repo', async () => {
118150
const result = await createTaskCore({ repo: 'invalid' } as any, makeContext(), 'req-1');
119151
expect(result.statusCode).toBe(400);

0 commit comments

Comments
 (0)