Skip to content

feat: Generate timestamped filenames for graph exports: BED-7609#2388

Closed
klamping wants to merge 7 commits intoSpecterOps:BED-7609from
Mr-Holland-s-Opus:feat/better-export-filenames
Closed

feat: Generate timestamped filenames for graph exports: BED-7609#2388
klamping wants to merge 7 commits intoSpecterOps:BED-7609from
Mr-Holland-s-Opus:feat/better-export-filenames

Conversation

@klamping
Copy link
Copy Markdown
Contributor

@klamping klamping commented Feb 19, 2026

Description

Updates the exported filenames to use a generated, human friendly timestamp so that multiple file downloads are more cleanly organized/named.

Motivation and Context

Resolves #1879

How Has This Been Tested?

Via unit tests and manually on my local server instance

Screenshots (optional):

image

Types of changes

It simply updates the filename of the downloaded JSON, however, I think it qualifies as breaking, as if someone is basing some sort of script on the downloaded filename, it will break that script.

  • Breaking change (fix or feature that would cause existing functionality to change)

Checklist:

  • I have met the contributing prerequisites
  • I have ensured that related documentation is up-to-date
    • Open API docs
    • Code comments (GoDocs / JSDocs)
  • I have followed proper test practices
    • Added/updated tests to cover my changes
    • All new and existing tests passed

Summary by CodeRabbit

  • New Features

    • Graph export filenames now include a filesystem-safe UTC timestamp instead of a static name.
  • Tests

    • Added tests that verify generated export filenames and the download flow for graph exports.
  • Tests (Test Setup)

    • Test environment now ensures browser download APIs are available/mocked so export tests run reliably.

@github-actions
Copy link
Copy Markdown

github-actions Bot commented Feb 19, 2026

CLA Assistant Lite bot All contributors have signed the CLA ✍️ ✅

@klamping
Copy link
Copy Markdown
Contributor Author

I have read the CLA Document and I hereby sign the CLA

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented Feb 19, 2026

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Repository YAML (base), Organization UI (inherited)

Review profile: CHILL

Plan: Pro

Run ID: 706d287f-2c13-4f93-bbb5-350efb3f14b7

📥 Commits

Reviewing files that changed from the base of the PR and between ad5aad3 and 778ab35.

📒 Files selected for processing (1)
  • packages/javascript/bh-shared-ui/src/utils/exportGraphData.test.ts
🚧 Files skipped from review as they are similar to previous changes (1)
  • packages/javascript/bh-shared-ui/src/utils/exportGraphData.test.ts

📝 Walkthrough

Walkthrough

Replaces the static export filename with a UTC timestamp-based filesystem-safe name via a new helper, updates exportToJson to use it, adds tests for filename generation and JSON export behavior, ensures test environment provides URL.createObjectURL, and adds a new datetime format constant.

Changes

Cohort / File(s) Summary
Graph Export Utility
packages/javascript/bh-shared-ui/src/utils/exportGraphData.ts
Added getDefaultGraphExportFileName() producing bh-graph-YYYY-MM-DD_HH-MM-SS.json; exportToJson() now uses this generated filename instead of a hardcoded bh-graph.json.
Tests for Export Flow
packages/javascript/bh-shared-ui/src/utils/exportGraphData.test.ts
New test file: fixed UTC time assertion for filename format; mocks URL.createObjectURL, intercepts document.createElement to capture the anchor, and simulates click to verify download filename and that the object URL is used.
Test Environment Setup
packages/javascript/bh-shared-ui/src/setupTests.tsx
Adds a runtime guard to provide a mock window.URL.createObjectURL when undefined in the test environment.
Datetime Formats
packages/javascript/bh-shared-ui/src/utils/datetime.ts
Added LuxonFormat.DATETIME_FILESYSTEM_SAFE = 'yyyy-MM-dd_HH-mm-ss' for filesystem-safe UTC timestamp formatting.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and concisely describes the main change: adding timestamped filenames for graph exports with a reference to the tracking ticket BED-7609.
Description check ✅ Passed The description addresses the template requirements including a detailed explanation of changes, motivation (resolves issue #1879), testing approach, type of change (breaking), and checklist completion.
Linked Issues check ✅ Passed The PR implements the core requirement from issue #1879 by generating unique timestamped filenames (e.g., bh-graph-2026-02-19_23-29-46.json) instead of static 'bh-graph.json', addressing the download directory organization problem.
Out of Scope Changes check ✅ Passed All changes are scoped to the export filename functionality: adding a new DateTime format enum, creating a helper function for filename generation, updating the export function to use it, and adding comprehensive test coverage. No unrelated changes detected.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment

Tip

Try Coding Plans. Let us write the prompt for your AI agent so you can ship faster (with fewer bugs).
Share your feedback on Discord.


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (4)
packages/javascript/bh-shared-ui/src/utils/exportGraphData.test.ts (3)

5-15: Mock/timer cleanup is not failure-safe — move to afterEach

Both tests place cleanup (vi.useRealTimers(), createElementSpy.mockRestore(), global restorations) at the end of the test body. If any expect assertion throws, those cleanup calls are skipped and fake timers / mocked globals leak into subsequent tests, which can cause flaky failures that are hard to diagnose.

Prefer an afterEach hook (or pair with beforeEach) for all teardown:

♻️ Suggested structural refactor
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
-import { describe, expect, it, vi } from 'vitest';
 import * as exportUtils from './exportGraphData';

 describe('exportGraphData', () => {
+    beforeEach(() => {
+        vi.useFakeTimers();
+        vi.setSystemTime(new Date('2026-02-14T20:27:20.000Z'));
+    });
+
+    afterEach(() => {
+        vi.useRealTimers();
+        vi.restoreAllMocks();
+        vi.unstubAllGlobals();
+    });
+
     it('generates a human-friendly default filename for graph exports', () => {
-        vi.useFakeTimers();
-        vi.setSystemTime(new Date('2026-02-14T20:27:20.000Z'));
-
         const name = exportUtils.getDefaultGraphExportFileName();
         expect(name).toBe('bh-graph-2026-02-14_20-27-20.json');
-
-        vi.useRealTimers();
     });

     it('uses the default filename generator when exporting JSON', () => {
-        vi.useFakeTimers();
-        vi.setSystemTime(new Date('2026-02-14T20:27:20.000Z'));
         // ... rest of setup and assertions only
     });
 });

Also applies to: 17-62

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/javascript/bh-shared-ui/src/utils/exportGraphData.test.ts` around
lines 5 - 15, Current tests call vi.useRealTimers() and restore spies at the end
of each test body which can be skipped on failure; refactor by adding an
afterEach hook that calls vi.useRealTimers() and restores any mocks/spies (e.g.
createElementSpy.mockRestore()) so cleanup always runs, and keep
vi.useFakeTimers()/vi.setSystemTime(...) in the test or pair with beforeEach if
you prefer; apply the same afterEach teardown for other tests in the file (lines
~17-62) that currently perform manual cleanup.

49-50: Add a guard assertion before accessing createdAnchor

If the createElement spy fails to capture the anchor (e.g., spy not set up correctly), createdAnchor?.download silently evaluates to undefined, and the failure message will be a generic type mismatch rather than "anchor was never captured."

♻️ Proposed improvement
+        expect(createdAnchor).toBeDefined();
         expect(createdAnchor?.download).toBe('bh-graph-2026-02-14_20-27-20.json');
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/javascript/bh-shared-ui/src/utils/exportGraphData.test.ts` around
lines 49 - 50, Add a guard assertion to ensure the anchor was actually captured
before accessing its properties: check that the test variable createdAnchor (the
element captured from the createElement spy) is defined/non-null (or an instance
of HTMLAnchorElement) with an explicit expect(createdAnchor).toBeDefined() /
toBeTruthy() (or expect(createdAnchor).not.toBeNull()) immediately before the
existing expect(createdAnchor?.download)... assertion so failures clearly
indicate the anchor was never captured rather than producing a generic type
mismatch.

21-60: Prefer vi.stubGlobal / vi.spyOn over manual Object.defineProperty for global mocking

Object.defineProperty without configurable: true may create a non-reconfigurable property if window.URL.createObjectURL (or globalThis.MouseEvent) does not already exist in the test environment. The subsequent restore call (lines 53–55, 57–60) would then throw a TypeError: Cannot redefine property. Vitest's vi.stubGlobal stores the original value automatically and restores it via vi.unstubAllGlobals(). For URL.createObjectURL specifically, vi.stubGlobal("URL", { createObjectURL: () => "..." }) is a documented pattern.

♻️ Suggested idiomatic Vitest mocking
-        const originalCreateObjectURL = (window.URL as any).createObjectURL;
-        const createObjectUrlSpy = vi.fn(() => 'blob:mock');
-        Object.defineProperty(window.URL, 'createObjectURL', {
-            value: createObjectUrlSpy,
-            writable: true,
-        });
+        const createObjectUrlSpy = vi.fn(() => 'blob:mock');
+        vi.stubGlobal('URL', { ...window.URL, createObjectURL: createObjectUrlSpy });

-        const originalMouseEvent = globalThis.MouseEvent;
-        class MockMouseEvent extends Event {
+        class MockMouseEvent extends Event {
             constructor(type: string, _init?: any) {
                 super(type);
             }
         }
-        Object.defineProperty(globalThis, 'MouseEvent', {
-            value: MockMouseEvent,
-            writable: true,
-        });
+        vi.stubGlobal('MouseEvent', MockMouseEvent);

         exportUtils.exportToJson({ a: 1 });

         expect(createdAnchor?.download).toBe('bh-graph-2026-02-14_20-27-20.json');
         expect(createObjectUrlSpy).toHaveBeenCalled();

-        createElementSpy.mockRestore();
-        Object.defineProperty(window.URL, 'createObjectURL', {
-            value: originalCreateObjectURL,
-            writable: true,
-        });
-        Object.defineProperty(globalThis, 'MouseEvent', {
-            value: originalMouseEvent,
-            writable: true,
-        });
-        vi.useRealTimers();
         // cleanup handled by afterEach: vi.restoreAllMocks(), vi.unstubAllGlobals(), vi.useRealTimers()
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/javascript/bh-shared-ui/src/utils/exportGraphData.test.ts` around
lines 21 - 60, Replace manual Object.defineProperty mocks for
window.URL.createObjectURL and globalThis.MouseEvent with Vitest helpers: use
vi.stubGlobal('URL', { createObjectURL: createObjectUrlSpy }) or
vi.stubGlobal('URL', { createObjectURL: () => 'blob:mock' }) to stub
createObjectURL and use vi.stubGlobal('MouseEvent', MockMouseEvent) to stub
MouseEvent; keep the document.createElement spy (createElementSpy) but remove
the manual restoration of URL/MouseEvent and rely on vi.unstubAllGlobals() (or
vi.restoreAllMocks()/vi.resetAllMocks() as appropriate) to restore originals
after calling exportUtils.exportToJson({ a: 1 }) and the expectations. Ensure
createObjectUrlSpy and MockMouseEvent names remain so tests still assert
createObjectUrlSpy calls and createdAnchor behavior.
packages/javascript/bh-shared-ui/src/utils/exportGraphData.ts (1)

32-38: UTC timestamp in filename is correct; optional refactor for local time if preferred

The public API surface is properly configured — getDefaultGraphExportFileName is correctly re-exported through utils/index.ts and the main package index.ts, so downstream consumers can import it directly.

Regarding the timestamp: toISOString() always outputs UTC regardless of user timezone, so a 3:30 PM EST export will show _20-30-00 in the filename. This is unambiguous and fine for uniqueness, but may surprise users expecting local time. If local-time stamps are preferred, consider using new Date().toLocaleString(...) with explicit format tokens or a date library.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/javascript/bh-shared-ui/src/utils/exportGraphData.ts` around lines
32 - 38, The current getDefaultGraphExportFileName uses new Date().toISOString()
which yields a UTC timestamp; if you want local-time filenames instead, modify
getDefaultGraphExportFileName to format the current date/time in the local
timezone (e.g., produce YYYY-MM-DD_HH-mm-ss without milliseconds) rather than
using toISOString(); implement this by using Date methods or
Intl.DateTimeFormat/toLocaleString with explicit options (or a date lib) to
build the local date/time parts, replace the 'T' and ':' characters as done now,
and return the same `bh-graph-${safe}.json` pattern so callers of
getDefaultGraphExportFileName keep the same API.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@packages/javascript/bh-shared-ui/src/utils/exportGraphData.test.ts`:
- Around line 5-15: Current tests call vi.useRealTimers() and restore spies at
the end of each test body which can be skipped on failure; refactor by adding an
afterEach hook that calls vi.useRealTimers() and restores any mocks/spies (e.g.
createElementSpy.mockRestore()) so cleanup always runs, and keep
vi.useFakeTimers()/vi.setSystemTime(...) in the test or pair with beforeEach if
you prefer; apply the same afterEach teardown for other tests in the file (lines
~17-62) that currently perform manual cleanup.
- Around line 49-50: Add a guard assertion to ensure the anchor was actually
captured before accessing its properties: check that the test variable
createdAnchor (the element captured from the createElement spy) is
defined/non-null (or an instance of HTMLAnchorElement) with an explicit
expect(createdAnchor).toBeDefined() / toBeTruthy() (or
expect(createdAnchor).not.toBeNull()) immediately before the existing
expect(createdAnchor?.download)... assertion so failures clearly indicate the
anchor was never captured rather than producing a generic type mismatch.
- Around line 21-60: Replace manual Object.defineProperty mocks for
window.URL.createObjectURL and globalThis.MouseEvent with Vitest helpers: use
vi.stubGlobal('URL', { createObjectURL: createObjectUrlSpy }) or
vi.stubGlobal('URL', { createObjectURL: () => 'blob:mock' }) to stub
createObjectURL and use vi.stubGlobal('MouseEvent', MockMouseEvent) to stub
MouseEvent; keep the document.createElement spy (createElementSpy) but remove
the manual restoration of URL/MouseEvent and rely on vi.unstubAllGlobals() (or
vi.restoreAllMocks()/vi.resetAllMocks() as appropriate) to restore originals
after calling exportUtils.exportToJson({ a: 1 }) and the expectations. Ensure
createObjectUrlSpy and MockMouseEvent names remain so tests still assert
createObjectUrlSpy calls and createdAnchor behavior.

In `@packages/javascript/bh-shared-ui/src/utils/exportGraphData.ts`:
- Around line 32-38: The current getDefaultGraphExportFileName uses new
Date().toISOString() which yields a UTC timestamp; if you want local-time
filenames instead, modify getDefaultGraphExportFileName to format the current
date/time in the local timezone (e.g., produce YYYY-MM-DD_HH-mm-ss without
milliseconds) rather than using toISOString(); implement this by using Date
methods or Intl.DateTimeFormat/toLocaleString with explicit options (or a date
lib) to build the local date/time parts, replace the 'T' and ':' characters as
done now, and return the same `bh-graph-${safe}.json` pattern so callers of
getDefaultGraphExportFileName keep the same API.

Comment thread packages/javascript/bh-shared-ui/src/utils/exportGraphData.ts
@klamping klamping changed the title Better Export Filenames feat: Generate timestamped filenames for graph exports Mar 5, 2026
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (1)
packages/javascript/bh-shared-ui/src/utils/exportGraphData.ts (1)

20-33: Consider revoking the object URL to prevent memory leaks.

The createObjectURL call creates a blob URL that persists until the page is unloaded or explicitly revoked. While browsers will eventually clean these up, it's best practice to revoke them after use, especially if exports happen frequently.

♻️ Suggested improvement
 export const downloadFile = ({ data, fileName, fileType }: { data: any; fileName: string; fileType: string }) => {
     const blob = new Blob([data], { type: fileType });
     // create an anchor tag and dispatch a click event on it to trigger download
     const a = document.createElement('a');
     a.download = fileName;
-    a.href = window.URL.createObjectURL(blob);
+    const url = window.URL.createObjectURL(blob);
+    a.href = url;
     const clickEvent = new MouseEvent('click', {
         view: window,
         bubbles: true,
         cancelable: true,
     });
     a.dispatchEvent(clickEvent);
     a.remove();
+    window.URL.revokeObjectURL(url);
 };
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/javascript/bh-shared-ui/src/utils/exportGraphData.ts` around lines
20 - 33, The downloadFile function currently creates a blob URL via
window.URL.createObjectURL but never revokes it; update downloadFile to store
the created URL in a variable (e.g., const url =
window.URL.createObjectURL(blob)), set a.href = url, append the anchor to the
document (document.body.appendChild(a)) if needed, trigger the click (a.click()
or dispatchEvent), then call window.URL.revokeObjectURL(url) after the click
(either immediately or in a short setTimeout/after a click handler) and finally
remove the anchor (a.remove()); this ensures blob URLs are cleaned up and
prevents memory leaks.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@packages/javascript/bh-shared-ui/src/utils/exportGraphData.ts`:
- Around line 20-33: The downloadFile function currently creates a blob URL via
window.URL.createObjectURL but never revokes it; update downloadFile to store
the created URL in a variable (e.g., const url =
window.URL.createObjectURL(blob)), set a.href = url, append the anchor to the
document (document.body.appendChild(a)) if needed, trigger the click (a.click()
or dispatchEvent), then call window.URL.revokeObjectURL(url) after the click
(either immediately or in a short setTimeout/after a click handler) and finally
remove the anchor (a.remove()); this ensures blob URLs are cleaned up and
prevents memory leaks.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository YAML (base), Organization UI (inherited)

Review profile: CHILL

Plan: Pro

Run ID: 93e793f5-19f0-46d9-95a9-b3e34d3e47bb

📥 Commits

Reviewing files that changed from the base of the PR and between 2c5b676 and ad5aad3.

📒 Files selected for processing (4)
  • packages/javascript/bh-shared-ui/src/setupTests.tsx
  • packages/javascript/bh-shared-ui/src/utils/datetime.ts
  • packages/javascript/bh-shared-ui/src/utils/exportGraphData.test.ts
  • packages/javascript/bh-shared-ui/src/utils/exportGraphData.ts

@klamping
Copy link
Copy Markdown
Contributor Author

klamping commented Mar 5, 2026

Fyi, did a quick sanity check to validate this works locally after the changes. Still looking good

image

@rvazarkar
Copy link
Copy Markdown
Contributor

Hey, this PR looks good, but will need to fix the failing CI checks before we can merge. Looks like theres some static code analysis warnings as well as some failing tests.

@rvazarkar rvazarkar changed the title feat: Generate timestamped filenames for graph exports feat: Generate timestamped filenames for graph exports: BED-7609 Mar 9, 2026
@rvazarkar
Copy link
Copy Markdown
Contributor

Internal ticket for tracking: https://specterops.atlassian.net/browse/BED-7609

@klamping
Copy link
Copy Markdown
Contributor Author

klamping commented Mar 9, 2026

Lint issues should be fixed now. Will keep an eye on the build

@wes-mil
Copy link
Copy Markdown
Contributor

wes-mil commented Mar 10, 2026

@klamping Not exactly sure what's going wrong, but I suspect you may need to merge main again

@klamping
Copy link
Copy Markdown
Contributor Author

@wes-mil Yeah, that was a very vague error in the test run. Merged in the latest and will see how this next run does.

@wes-mil
Copy link
Copy Markdown
Contributor

wes-mil commented Mar 10, 2026

@klamping Going to try and debug what's going on here, I apologize for the hiccups with our CLA check

@klamping
Copy link
Copy Markdown
Contributor Author

@wes-mil Thanks... but I bet it's because two accounts made git commits... So mine has the CLA signed, but not the 'klamping-tester' one. Let me squash these commits as 'klamping' and see if that fixes the issue.

@wes-mil
Copy link
Copy Markdown
Contributor

wes-mil commented Mar 10, 2026

@klamping It's actually an issue on our end, because of some heightened security measures we had to disable certain workflows for external contributors.

We'll be able to get around this by merging your PR into a PR of my own, and then merging into main. I do still need you to squash commit under your main profile because all commits into BloodHound are required to be tied to a verified commit!

@wes-mil wes-mil changed the base branch from main to BED-7609 March 10, 2026 19:05
@wes-mil
Copy link
Copy Markdown
Contributor

wes-mil commented Mar 10, 2026

@klamping You should be able to squash and merge into my branch now! I'll be sure to preserve the commit when merging into main 😄

Sorry forgot that you cannot merge, if you could squash your commits down I will merge it into my branch and then author the merge into the product preserving commit history (so you're credited!)

@klamping
Copy link
Copy Markdown
Contributor Author

@wes-mil I've been having issues trying to get the squash to work with the fact that I've merged in changes from main already... along with working from a different fork. I took the easier route of just creating a new branch and pulled in my changes for it, and pushed it up with a single commit (effectively squashing all the commits). It's here:
https://github.com/Mr-Holland-s-Opus/BloodHound/tree/feat/timestamped-export-filenames

Would you like me to open a new PR with this single commit and just copy over the initial info from this one?

@wes-mil
Copy link
Copy Markdown
Contributor

wes-mil commented Mar 10, 2026

@klamping a new PR works just fine! One of our engineers just fixed the CLA check, so if your branch is up to date with main you can point your PR at main

@wes-mil wes-mil deleted the branch SpecterOps:BED-7609 March 11, 2026 00:29
@wes-mil wes-mil closed this Mar 11, 2026
@github-actions github-actions Bot locked and limited conversation to collaborators Mar 11, 2026
@wes-mil
Copy link
Copy Markdown
Contributor

wes-mil commented Mar 11, 2026

Closed in favor of duplicate #2489

Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Feature: Better export filenames

5 participants