Skip to content

Commit f5e2528

Browse files
tofikwestclaude
andcommitted
fix(pentest): second cubic review pass — refund retry, ux + correctness
API: - refundOnTerminalFailure no longer swallows errors. Failures now propagate through handleWebhook → 5xx response → Maced redelivery → rolled-back creditRefundedAt allows retry. Previously a transient refund-tx blip would silently drop the customer's refund forever. - Removed dead grantInitialTrial method. Trial credits are granted via Prisma nested-create on Organization in the org-create server actions, atomic with org insert. The unused service method just invited future divergence; existing orgs were backfilled by the 20260427000000_pentest_credits migration. Added a comment in pentest-credits.service explaining where the trial actually fires. - Service spec updated (-1 dead test for the deleted method, total 19). Frontend: - AgentActivityLog: track open state in React so user collapse isn't fought by the next parent re-render. Was rendering as `<details open={defaultOpen}>` which forced the panel re-open every SWR poll. - OverviewPane: coverage and stale-target stats now derive from `completed` runs only, not the full runs list. A target with only failed scans is no longer counted as "covered" or "fresh." - AgentProgressGrid: clamp `done` to grid size so an out-of-sync Maced progress payload (`done > total`) doesn't render extra cells past the configured columns. - usePenetrationTestEvents: added the same final-revalidation pattern as usePenetrationTestIssues so the last events written during the completion phase aren't missed when polling stops. - StatusPill: unknown status values now render as "Unknown" instead of falling back to "Provisioning" (which would mislead users into thinking the run was still starting up). - RunningDetail useNewFindingHighlights: removed the timeout-cleanup that was canceling pending highlight removals when issues changed in <2s (during a 3s SWR poll, very common). Each batch now schedules its own fire-and-forget removal so they can't step on each other. - RunList: removed unused `overviewActive` prop (declared but never consumed) and stopped passing it from SplitView. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 23396c6 commit f5e2528

11 files changed

Lines changed: 109 additions & 102 deletions

File tree

apps/api/src/security-penetration-tests/pentest-credits.service.spec.ts

Lines changed: 0 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -78,23 +78,6 @@ describe('PentestCreditsService', () => {
7878
});
7979
});
8080

81-
describe('grantInitialTrial', () => {
82-
it('upserts with create-side defaults and update-side no-op', async () => {
83-
mockedDb.pentestCredits.upsert.mockResolvedValue({});
84-
await service.grantInitialTrial('org_1');
85-
expect(mockedDb.pentestCredits.upsert).toHaveBeenCalledWith({
86-
where: { organizationId: 'org_1' },
87-
create: {
88-
organizationId: 'org_1',
89-
balance: 1,
90-
totalGranted: 1,
91-
lastGrantSource: 'trial',
92-
},
93-
update: {},
94-
});
95-
});
96-
});
97-
9881
describe('grant', () => {
9982
it('rejects non-positive amounts', async () => {
10083
await expect(service.grant('org_1', 0, 'topup')).rejects.toThrow();

apps/api/src/security-penetration-tests/pentest-credits.service.ts

Lines changed: 9 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -75,28 +75,15 @@ export class PentestCreditsService {
7575
};
7676
}
7777

78-
/**
79-
* Grant the initial trial credit when an org is created. Idempotent —
80-
* safe to call repeatedly; subsequent calls no-op so we never accidentally
81-
* top a user up just by re-running the org-create hook.
82-
*/
83-
async grantInitialTrial(organizationId: string): Promise<void> {
84-
const amount = this.initialTrialAmount;
85-
await db.pentestCredits.upsert({
86-
where: { organizationId },
87-
create: {
88-
organizationId,
89-
balance: amount,
90-
totalGranted: amount,
91-
lastGrantSource: 'trial',
92-
},
93-
// Already exists — leave it alone. Idempotent by design.
94-
update: {},
95-
});
96-
this.logger.log(
97-
`[Credits] grantInitialTrial org=${organizationId} amount=${amount}`,
98-
);
99-
}
78+
// Initial trial credits are granted via Prisma's nested-create on
79+
// `Organization` in the org-create server actions
80+
// (apps/app/src/app/(app)/setup/actions/create-organization*.ts) so
81+
// the credit row is inserted in the same transaction as the
82+
// organization itself — no chance of an org existing without its
83+
// trial credit. Existing orgs were backfilled by migration
84+
// `20260427000000_pentest_credits`. There is intentionally no
85+
// service-side `grantInitialTrial` method; if a manual re-grant is
86+
// ever needed, it goes through the platform-admin grant flow.
10087

10188
/**
10289
* Add credits from any source. Used for v2 Stripe webhooks (subscription

apps/api/src/security-penetration-tests/security-penetration-tests.service.ts

Lines changed: 44 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -494,7 +494,10 @@ export class SecurityPenetrationTestsService {
494494
// Refund the credit on terminal failure events. The user paid for a
495495
// run that didn't deliver value, so they shouldn't lose the credit.
496496
// Idempotent via the run row's `creditRefundedAt` column — webhook
497-
// redelivery cannot double-credit.
497+
// redelivery cannot double-credit. If the refund transaction fails
498+
// (e.g. transient DB blip), the error propagates so this handler
499+
// returns 5xx and Maced redelivers the webhook — without that, the
500+
// customer would silently lose their credit.
498501
if (event.type === 'pentest.failed' || event.type === 'pentest.cancelled') {
499502
await this.refundOnTerminalFailure(event.data.pentestId, event.type);
500503
}
@@ -591,56 +594,50 @@ export class SecurityPenetrationTestsService {
591594
// wallet write would leave `creditRefundedAt` set with no actual
592595
// refund, and webhook redelivery would short-circuit forever — the
593596
// customer never gets their credit back.
594-
try {
595-
await db.$transaction(async (tx) => {
596-
const claimed = await tx.securityPenetrationTestRun.updateMany({
597-
where: { providerRunId, creditRefundedAt: null },
598-
data: { creditRefundedAt: new Date() },
599-
});
597+
//
598+
// Errors are NOT swallowed here — they propagate to handleWebhook
599+
// → Maced sees 5xx → redelivers the webhook. On the redelivery
600+
// the rolled-back `creditRefundedAt` is null again, so the claim
601+
// re-fires and the refund is retried.
602+
await db.$transaction(async (tx) => {
603+
const claimed = await tx.securityPenetrationTestRun.updateMany({
604+
where: { providerRunId, creditRefundedAt: null },
605+
data: { creditRefundedAt: new Date() },
606+
});
600607

601-
if (claimed.count === 0) {
602-
// Either we don't own this run (orphan from a fast-click race
603-
// — ownership row never persisted) OR the credit has already
604-
// been refunded. Either way: do nothing further.
605-
this.logger.log(
606-
`[Webhook] ${eventType} refund skipped run=${providerRunId} (no ownership row or already refunded)`,
607-
);
608-
return;
609-
}
610-
611-
const run = await tx.securityPenetrationTestRun.findUnique({
612-
where: { providerRunId },
613-
select: { organizationId: true },
614-
});
615-
if (!run) {
616-
// Vanishingly rare race; abort the transaction so the claim
617-
// rolls back. Webhook redelivery will retry.
618-
throw new Error(
619-
`Run row vanished after claim for ${providerRunId}`,
620-
);
621-
}
622-
623-
// Pass the tx client through so the wallet write happens in
624-
// the same transaction as the claim. If this throws (or the
625-
// caller's outer try/catch surfaces an error), the claim is
626-
// undone.
627-
await this.credits.refund(
628-
run.organizationId,
629-
providerRunId,
630-
eventType,
631-
tx,
608+
if (claimed.count === 0) {
609+
// Either we don't own this run (orphan from a fast-click race
610+
// — ownership row never persisted) OR the credit has already
611+
// been refunded. Either way: do nothing further.
612+
this.logger.log(
613+
`[Webhook] ${eventType} refund skipped run=${providerRunId} (no ownership row or already refunded)`,
632614
);
615+
return;
616+
}
617+
618+
const run = await tx.securityPenetrationTestRun.findUnique({
619+
where: { providerRunId },
620+
select: { organizationId: true },
633621
});
634-
} catch (error) {
635-
// Log and let the webhook retry. The transaction rolled back, so
636-
// `creditRefundedAt` is null again — a redelivered webhook will
637-
// re-attempt the refund.
638-
this.logger.error(
639-
`[Webhook] ${eventType} refund transaction failed run=${providerRunId}: ${
640-
error instanceof Error ? error.message : String(error)
641-
}`,
622+
if (!run) {
623+
// Vanishingly rare race; abort the transaction so the claim
624+
// rolls back. Webhook redelivery will retry.
625+
throw new Error(
626+
`Run row vanished after claim for ${providerRunId}`,
627+
);
628+
}
629+
630+
// Pass the tx client through so the wallet write happens in
631+
// the same transaction as the claim. If this throws, the claim
632+
// is undone and the error propagates up to handleWebhook → Maced
633+
// sees 5xx and redelivers, allowing retry.
634+
await this.credits.refund(
635+
run.organizationId,
636+
providerRunId,
637+
eventType,
638+
tx,
642639
);
643-
}
640+
});
644641
}
645642

646643
private formatDurationMs(ms: number): string {

apps/app/src/app/(app)/[orgId]/security/penetration-tests/_components/AgentActivityLog.tsx

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
'use client';
22

3+
import { useState } from 'react';
34
import { cn } from '@trycompai/design-system/cn';
45
import type { PentestAgentEvent } from '@/lib/security/penetration-tests-client';
56

@@ -46,14 +47,20 @@ export function AgentActivityLog({
4647
events,
4748
defaultOpen = true,
4849
}: AgentActivityLogProps) {
50+
// Track open state ourselves so the user can collapse the details
51+
// panel without React re-forcing it open on the next render. With
52+
// `open={defaultOpen}` as a controlled prop the user's collapse
53+
// would be undone on every parent re-render (e.g. SWR poll).
54+
const [open, setOpen] = useState(defaultOpen);
4955
const recent = [...events]
5056
.filter(isCustomerVisible)
5157
.sort((a, b) => b.timestamp - a.timestamp)
5258
.slice(0, 200);
5359

5460
return (
5561
<details
56-
open={defaultOpen}
62+
open={open}
63+
onToggle={(e) => setOpen((e.currentTarget as HTMLDetailsElement).open)}
5764
className="overflow-hidden rounded-[var(--radius)] border border-border"
5865
>
5966
<summary className="flex cursor-pointer items-center justify-between gap-3 px-4 py-3 text-sm hover:bg-muted">

apps/app/src/app/(app)/[orgId]/security/penetration-tests/_components/AgentProgressGrid.tsx

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -22,8 +22,13 @@ export function AgentProgressGrid({
2222
className,
2323
}: AgentProgressGridProps) {
2424
const count = Math.max(total, 1);
25-
const running = done < count ? 1 : 0;
26-
const pending = Math.max(count - done - running, 0);
25+
// Clamp `done` to the grid size — if Maced ever reports done > total
26+
// (rare, but possible when their progress payload is briefly out of
27+
// sync), we'd otherwise render extra cells past the configured columns
28+
// and break the visual width.
29+
const doneClamped = Math.min(Math.max(done, 0), count);
30+
const running = doneClamped < count ? 1 : 0;
31+
const pending = Math.max(count - doneClamped - running, 0);
2732

2833
return (
2934
<div
@@ -32,9 +37,9 @@ export function AgentProgressGrid({
3237
className,
3338
)}
3439
style={{ gridTemplateColumns: `repeat(${count}, minmax(0, 1fr))` }}
35-
aria-label={`Agents: ${done} of ${count} complete`}
40+
aria-label={`Agents: ${doneClamped} of ${count} complete`}
3641
>
37-
{Array.from({ length: done }).map((_, i) => (
42+
{Array.from({ length: doneClamped }).map((_, i) => (
3843
<span key={`done-${i}`} className="bg-primary" />
3944
))}
4045
{running > 0 ? (

apps/app/src/app/(app)/[orgId]/security/penetration-tests/_components/OverviewPane.tsx

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -128,12 +128,17 @@ function PostureOverview({
128128
onDownloadMarkdown,
129129
onDownloadPdf,
130130
}: PostureOverviewProps) {
131-
const targets = uniqueTargets(runs);
131+
// Coverage and stale-target stats use ONLY completed runs — a target
132+
// that's only ever had failed/cancelled scans isn't truly "covered,"
133+
// and a target whose latest scan failed shouldn't reset the staleness
134+
// clock. The full `runs` list is only used for the recent activity
135+
// sidebar elsewhere.
136+
const targets = uniqueTargets(completed);
132137
const lastScan = mostRecent(completed);
133138
const avgDuration = avgDurationMs(completed);
134139
const scansLast30d = countWithin(completed, 30 * 24 * 60 * 60 * 1000);
135140
const recentScans = sortByUpdatedDesc(completed).slice(0, 6);
136-
const staleTargets = computeStaleTargets(runs, targets);
141+
const staleTargets = computeStaleTargets(completed, targets);
137142

138143
return (
139144
<div className="min-h-0 overflow-y-auto">

apps/app/src/app/(app)/[orgId]/security/penetration-tests/_components/RunList.tsx

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,6 @@ interface RunListProps {
1414
runs: PentestRun[];
1515
selectedRunId: string | null;
1616
onCreateClick: () => void;
17-
/** When true, highlights an implicit "Overview" state (no selection). */
18-
overviewActive?: boolean;
1917
/** Spendable credit balance — drives the "X runs left" badge. */
2018
balance?: number;
2119
/** True when the user has used their initial trial (balance 0, totalGranted > 0). */

apps/app/src/app/(app)/[orgId]/security/penetration-tests/_components/RunningDetail.tsx

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -157,15 +157,20 @@ function useNewFindingHighlights(
157157
return next;
158158
});
159159

160-
const timer = window.setTimeout(() => {
160+
// Schedule per-batch removal independently — fire and forget.
161+
// Don't `clearTimeout` on cleanup: if `issues` changes in <2s
162+
// (very common during a live scan polling at 3s), the cleanup
163+
// would cancel the pending removal and the highlight class would
164+
// stick to those rows forever. Each scheduled removal targets only
165+
// the IDs from its batch, so multiple in-flight timers can't
166+
// step on each other.
167+
window.setTimeout(() => {
161168
setHighlighted((prev) => {
162169
const next = new Set(prev);
163170
for (const id of newlyLanded) next.delete(id);
164171
return next;
165172
});
166173
}, 2000);
167-
168-
return () => window.clearTimeout(timer);
169174
}, [issues]);
170175

171176
return highlighted;

apps/app/src/app/(app)/[orgId]/security/penetration-tests/_components/SplitView.tsx

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -159,7 +159,6 @@ export function SplitView({
159159
runs={reports as PentestRun[]}
160160
selectedRunId={selectedRunId}
161161
onCreateClick={goToCreate}
162-
overviewActive={selectedRunId === null && !isCreateMode}
163162
balance={balance}
164163
trialUsed={trialUsed}
165164
/>

apps/app/src/app/(app)/[orgId]/security/penetration-tests/_components/StatusPill.tsx

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -59,10 +59,18 @@ const STATUS_CONFIG: Record<
5959
},
6060
};
6161

62-
const CONFIG_DEFAULT = STATUS_CONFIG.provisioning;
62+
// Fallback for status values we don't know how to render. Better to
63+
// show "Unknown" than to silently render an unrelated status (e.g.
64+
// previously this defaulted to "Provisioning", which would mislead
65+
// users into thinking the scan was still starting up).
66+
const CONFIG_UNKNOWN: { label: string; dotClass: string; textClass: string } = {
67+
label: 'Unknown',
68+
dotClass: 'bg-muted-foreground',
69+
textClass: 'text-muted-foreground',
70+
};
6371

6472
export function StatusPill({ status, className }: StatusPillProps) {
65-
const config = STATUS_CONFIG[status as StatusKind] ?? CONFIG_DEFAULT;
73+
const config = STATUS_CONFIG[status as StatusKind] ?? CONFIG_UNKNOWN;
6674

6775
return (
6876
<span

0 commit comments

Comments
 (0)