Skip to content

Commit af06e6b

Browse files
committed
fix: defensive timestamp parsing in transformFault
Accept unix-seconds numbers, ISO 8601 strings, and fall back to the current time for zero, negative, missing, or otherwise invalid values instead of letting `new Date()` produce "Invalid Date" in the UI. Widen `RawFaultItem.first_occurred` to `number | string | null | undefined` so the type reflects the shapes the transform actually handles and the fallback branches are not unreachable at the type level.
1 parent c526d15 commit af06e6b

2 files changed

Lines changed: 77 additions & 4 deletions

File tree

src/lib/transforms.test.ts

Lines changed: 53 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212
// See the License for the specific language governing permissions and
1313
// limitations under the License.
1414

15-
import { describe, it, expect } from 'vitest';
15+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
1616
import {
1717
unwrapItems,
1818
transformFault,
@@ -89,6 +89,58 @@ describe('transformFault', () => {
8989
expect(result.timestamp).toBe(new Date(1700000000 * 1000).toISOString());
9090
});
9191

92+
describe('timestamp defensive parsing', () => {
93+
// All fallback branches must return an ISO string close to "now".
94+
// Asserting recency (not just "doesn't throw") actually verifies the
95+
// fallback fired and produced a sane value.
96+
const expectRecent = (iso: string) => {
97+
const ts = new Date(iso).getTime();
98+
const now = Date.now();
99+
expect(ts).toBeGreaterThan(now - 5000);
100+
expect(ts).toBeLessThanOrEqual(now);
101+
};
102+
103+
// Suppress the dev-tools breadcrumb console.warn in fallback cases.
104+
let warnSpy: ReturnType<typeof vi.spyOn>;
105+
beforeEach(() => {
106+
warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
107+
});
108+
afterEach(() => {
109+
warnSpy.mockRestore();
110+
});
111+
112+
it('falls back to current time when first_occurred is 0', () => {
113+
const result = transformFault(makeFaultInput({ first_occurred: 0 }));
114+
expectRecent(result.timestamp);
115+
expect(warnSpy).toHaveBeenCalledTimes(1);
116+
});
117+
118+
it('falls back to current time when first_occurred is negative', () => {
119+
const result = transformFault(makeFaultInput({ first_occurred: -1 }));
120+
expectRecent(result.timestamp);
121+
expect(warnSpy).toHaveBeenCalledTimes(1);
122+
});
123+
124+
it('parses ISO 8601 string first_occurred', () => {
125+
const iso = '2026-04-13T10:00:00.000Z';
126+
const result = transformFault(makeFaultInput({ first_occurred: iso }));
127+
expect(result.timestamp).toBe(iso);
128+
expect(warnSpy).not.toHaveBeenCalled();
129+
});
130+
131+
it('falls back to current time when first_occurred is an invalid string', () => {
132+
const result = transformFault(makeFaultInput({ first_occurred: 'not-a-date' }));
133+
expectRecent(result.timestamp);
134+
expect(warnSpy).toHaveBeenCalledTimes(1);
135+
});
136+
137+
it('falls back to current time when first_occurred is missing', () => {
138+
const result = transformFault(makeFaultInput({ first_occurred: undefined }));
139+
expectRecent(result.timestamp);
140+
expect(warnSpy).toHaveBeenCalledTimes(1);
141+
});
142+
});
143+
92144
it('defaults entity_type to "app" when not in raw data', () => {
93145
const result = transformFault(makeFaultInput());
94146
expect(result.entity_type).toBe('app');

src/lib/transforms.ts

Lines changed: 24 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -64,8 +64,10 @@ export interface RawFaultItem {
6464
severity: number;
6565
severity_label: string;
6666
status: string;
67-
first_occurred: number;
68-
last_occurred?: number;
67+
/** Accepted as unix seconds (number), ISO 8601 string, or missing/invalid;
68+
* `transformFault` normalises all of these to an ISO timestamp. */
69+
first_occurred: number | string | null | undefined;
70+
last_occurred?: number | string | null;
6971
occurrence_count?: number;
7072
reporting_sources?: string[];
7173
/** Entity type if provided by the gateway (not currently included in
@@ -127,7 +129,26 @@ export function transformFault(apiFault: RawFaultItem): Fault {
127129
message: apiFault.description,
128130
severity,
129131
status,
130-
timestamp: new Date(apiFault.first_occurred * 1000).toISOString(),
132+
timestamp: (() => {
133+
try {
134+
if (typeof apiFault.first_occurred === 'number' && apiFault.first_occurred > 0) {
135+
return new Date(apiFault.first_occurred * 1000).toISOString();
136+
}
137+
if (typeof apiFault.first_occurred === 'string') {
138+
const parsed = new Date(apiFault.first_occurred);
139+
if (!Number.isNaN(parsed.getTime())) {
140+
return parsed.toISOString();
141+
}
142+
}
143+
} catch {
144+
// fall through to the warn + fallback below
145+
}
146+
// Log a breadcrumb so operators correlating with syslog can tell
147+
// the timestamp was fabricated by the UI and not reported by the
148+
// gateway. Dev tools only; fallback keeps rendering alive.
149+
console.warn('[transformFault] invalid first_occurred, falling back to now:', apiFault.first_occurred);
150+
return new Date().toISOString();
151+
})(),
131152
entity_id,
132153
entity_type,
133154
parameters: {

0 commit comments

Comments
 (0)