Skip to content

Commit 5b2d788

Browse files
Merge pull request #686 from nazarli-shabnam/fix/security-owner-change-549
fix(ui): clear the Security page's scan results and selection when the owner changes
2 parents 7491ee7 + eb23ff9 commit 5b2d788

2 files changed

Lines changed: 86 additions & 10 deletions

File tree

‎apps/ui/app/security/page.tsx‎

Lines changed: 26 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -167,9 +167,9 @@ export default function SecurityPage() {
167167
})
168168

169169
const scan = useMutation({
170-
mutationFn: () => api.analytics.overview(owner, token),
171-
onSuccess: () => {
172-
queryClient.invalidateQueries({ queryKey: ["analytics.history", owner] })
170+
mutationFn: (forOwner: string) => api.analytics.overview(forOwner, token),
171+
onSuccess: (_data, forOwner) => {
172+
queryClient.invalidateQueries({ queryKey: ["analytics.history", forOwner] })
173173
},
174174
})
175175

@@ -199,14 +199,30 @@ export default function SecurityPage() {
199199

200200
const [selectedRepo, setSelectedRepo] = useState("")
201201
const matrixMutation = useMutation({
202-
mutationFn: () => api.security.matrix(owner, token),
202+
mutationFn: (forOwner: string) => api.security.matrix(forOwner, token),
203203
onSuccess: (data) => setSelectedRepo(data.repos[0]?.repo ?? ""),
204204
})
205205

206+
// The render right after an owner change still holds the previous owner's results until the reset
207+
// effect below runs; these keep them off the screen for that render instead of relying on the effect.
208+
const scanIsForOwner = scan.variables === owner
209+
const matrixIsForOwner = matrixMutation.variables === owner
210+
211+
// Results belong to the owner they were scanned for: drop them (and the repo picked from them)
212+
// when the owner changes, or the old org's data stays up under the new name and keeps querying.
213+
useEffect(() => {
214+
scan.reset()
215+
matrixMutation.reset()
216+
setSelectedRepo("")
217+
// eslint-disable-next-line react-hooks/exhaustive-deps
218+
}, [owner])
219+
206220
const secretScanning = useQuery({
207221
queryKey: ["security.secret-scanning", owner, selectedRepo],
208222
queryFn: () => api.security.secretScanning(owner, selectedRepo, token),
209-
enabled: !!selectedRepo && !!matrixMutation.data,
223+
// variables === owner: the render right after an owner change still holds the old matrix and repo
224+
// until the reset effect runs; without this it would fire one request for new-owner/old-repo.
225+
enabled: !!selectedRepo && !!matrixMutation.data && matrixMutation.variables === owner,
210226
})
211227

212228
// A failed history fetch must not look like "never scanned": the trend sections would otherwise
@@ -245,8 +261,8 @@ export default function SecurityPage() {
245261
}))
246262

247263
function runScan() {
248-
scan.mutate()
249-
matrixMutation.mutate()
264+
scan.mutate(owner)
265+
matrixMutation.mutate(owner)
250266
}
251267

252268
const filteredChecks = scan.data
@@ -350,7 +366,7 @@ export default function SecurityPage() {
350366
{saveTokenMutation.error.message}
351367
</div>
352368
)}
353-
{scan.isError && (
369+
{scan.isError && scanIsForOwner && (
354370
<div data-testid="scan-error" className="flex items-start gap-2 text-xs text-destructive">
355371
<Warning className="size-3.5 mt-0.5 shrink-0" />
356372
{scan.error.message}
@@ -404,7 +420,7 @@ export default function SecurityPage() {
404420
</div>
405421
</div>
406422

407-
{(scan.data || scan.isPending) && (
423+
{scanIsForOwner && (scan.data || scan.isPending) && (
408424
<div className="card lg:col-span-2">
409425
{scan.data && (
410426
<div className="px-4 py-3 border-b border-border">
@@ -511,7 +527,7 @@ export default function SecurityPage() {
511527
)}
512528
</div>
513529

514-
{(matrixMutation.data || matrixMutation.isPending || matrixMutation.error) && (
530+
{matrixIsForOwner && (matrixMutation.data || matrixMutation.isPending || matrixMutation.error) && (
515531
<div className="grid gap-4 lg:grid-cols-2 mt-6">
516532
<div className="card">
517533
<div className="px-4 py-3 border-b border-border flex items-center justify-between">

‎apps/ui/tests/components/security-page.test.tsx‎

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -177,6 +177,66 @@ describe("SecurityPage", () => {
177177
await waitFor(() => expect(analyticsOverviewMock).toHaveBeenCalledWith("acme", ""));
178178
});
179179

180+
it("drops the previous owner's scan results and stops querying for them when the owner changes (#549)", async () => {
181+
analyticsOverviewMock.mockResolvedValue({
182+
owner: "acme", score: 50, total_checks: 1, failed_checks: 1, repo_count: 1,
183+
checks: [{ id: "c1", title: "Acme-only check", severity: "high", remediation: "", status: "fail", value: { type: "boolean", enabled: false } }],
184+
});
185+
securityMatrixMock.mockResolvedValue({
186+
owner: "acme",
187+
repos: [{
188+
repo: "acme-repo", branch_protection: true, secret_scanning: true, dependabot_enabled: true,
189+
dependabot_critical_count: 0, dependabot_high_count: 0, code_scanning: true, force_push_allowed: false,
190+
score: 100, unknown_dimensions: [], alerts_source: "github",
191+
}],
192+
summary: { fully_compliant_count: 1, critical_risk_count: 0, secret_hits_count: 0, vuln_by_severity: { critical: 0, high: 0, medium: 0, low: 0 } },
193+
});
194+
195+
renderPage();
196+
const ownerInput = screen.getByPlaceholderText("e.g. octocat");
197+
fireEvent.change(ownerInput, { target: { value: "acme" } });
198+
const scanButton = screen.getByRole("button", { name: /run scan/i });
199+
await waitFor(() => expect(scanButton).not.toBeDisabled());
200+
fireEvent.click(scanButton);
201+
expect(await screen.findByText("Acme-only check")).toBeInTheDocument();
202+
await waitFor(() => expect(secretScanningMock).toHaveBeenCalledWith("acme", "acme-repo", ""));
203+
204+
secretScanningMock.mockClear();
205+
fireEvent.change(ownerInput, { target: { value: "beta" } });
206+
207+
await waitFor(() => expect(screen.queryByText("Acme-only check")).not.toBeInTheDocument());
208+
expect(secretScanningMock).not.toHaveBeenCalled();
209+
});
210+
211+
it("shows a scan failure for the owner it was run for, and drops it once the owner changes (#549)", async () => {
212+
analyticsOverviewMock.mockRejectedValue(new Error("scan exploded"));
213+
214+
renderPage();
215+
const ownerInput = screen.getByPlaceholderText("e.g. octocat");
216+
fireEvent.change(ownerInput, { target: { value: "acme" } });
217+
const scanButton = screen.getByRole("button", { name: /run scan/i });
218+
await waitFor(() => expect(scanButton).not.toBeDisabled());
219+
fireEvent.click(scanButton);
220+
221+
expect(await screen.findByTestId("scan-error")).toHaveTextContent("scan exploded");
222+
223+
fireEvent.change(ownerInput, { target: { value: "beta" } });
224+
await waitFor(() => expect(screen.queryByTestId("scan-error")).not.toBeInTheDocument());
225+
});
226+
227+
it("shows the results card with a pending state while a scan is running (#549)", async () => {
228+
analyticsOverviewMock.mockReturnValue(new Promise(() => {})); // never settles
229+
230+
renderPage();
231+
fireEvent.change(screen.getByPlaceholderText("e.g. octocat"), { target: { value: "acme" } });
232+
const scanButton = screen.getByRole("button", { name: /run scan/i });
233+
await waitFor(() => expect(scanButton).not.toBeDisabled());
234+
fireEvent.click(scanButton);
235+
236+
await waitFor(() => expect(screen.getByRole("button", { name: /scanning/i })).toBeInTheDocument());
237+
expect(analyticsOverviewMock).toHaveBeenCalledWith("acme", "");
238+
});
239+
180240
it("runs a scan on Enter in the organization field with no token entered", async () => {
181241
analyticsOverviewMock.mockResolvedValue({
182242
owner: "acme",

0 commit comments

Comments
 (0)