Compare commits

...

8 Commits

Author SHA1 Message Date
dante01yoon
f43f86a42a test: harden strict ISO fraction parser coverage 2026-04-22 09:34:31 +09:00
dante01yoon
60ea24ba2c fix: remove redundant knip entries auto-discovered via package exports
The earlier '44410dd47 fix: declare knip package entrypoints' commit
became redundant after main's package.json exports fields are picked up
by knip's auto-discovery. treatConfigHintsAsErrors causes CI to fail
with 'Remove redundant entry pattern' for these 5 lines.
2026-04-21 10:02:54 +09:00
dante01yoon
d577c0d048 Merge remote-tracking branch 'origin/main' into fix/secrets-date-invalid
# Conflicts:
#	src/utils/dateTimeUtil.test.ts
2026-04-21 09:03:20 +09:00
dante01yoon
44410dd47f fix: declare knip package entrypoints 2026-04-21 07:52:48 +09:00
dante01yoon
db786e2d2b fix: normalize short ISO fractional seconds 2026-04-21 06:44:04 +09:00
Terry Jia
e199fc237d Merge branch 'main' into fix/secrets-date-invalid 2026-04-18 09:11:22 -04:00
dante01yoon
3277805d4e test: cover full fractional-second range and invalid last_used_at 2026-04-18 19:38:27 +09:00
dante01yoon
dc198fb844 fix: handle ISO timestamps with >3 fractional-second digits in Secrets panel
The backend emits RFC 3339 timestamps via Go's time.RFC3339Nano, which
produces variable fractional-second precision (e.g. "…55.6513Z"). The
ECMA-262 Date grammar only accepts exactly 3 digits, so stricter
parsers (older Safari, some WebViews) return Invalid Date, which
toLocaleDateString() stringifies as literal "Invalid Date".

Add parseIsoDateSafe() that trims the fractional portion to millisecond
precision before parsing and returns null for unparseable input. Use
it in SecretListItem and hide the Created / Last Used line when the
value is missing or invalid instead of rendering "Invalid Date".
2026-04-18 19:32:12 +09:00
4 changed files with 208 additions and 10 deletions

View File

@@ -105,6 +105,52 @@ describe('SecretListItem', () => {
expect(screen.queryByText(/secrets\.lastUsed/)).not.toBeInTheDocument()
})
it('renders created date for ISO string with 4-digit fractional seconds', () => {
const secret = createMockSecret({
created_at: '2026-04-18T10:04:55.6513Z'
})
renderComponent({ secret })
expect(screen.getByText(/secrets\.createdAt/)).toBeInTheDocument()
expect(screen.queryByText(/Invalid Date/)).not.toBeInTheDocument()
})
it('renders created date for ISO string with 1-digit fractional seconds', () => {
const secret = createMockSecret({
created_at: '2026-04-18T10:04:55.6Z'
})
renderComponent({ secret })
expect(screen.getByText(/secrets\.createdAt/)).toBeInTheDocument()
expect(screen.queryByText(/Invalid Date/)).not.toBeInTheDocument()
})
it('hides created line when the timestamp is unparseable', () => {
const secret = createMockSecret({ created_at: 'not-a-date' })
renderComponent({ secret })
expect(screen.queryByText(/secrets\.createdAt/)).not.toBeInTheDocument()
expect(screen.queryByText(/Invalid Date/)).not.toBeInTheDocument()
})
it('hides last used line when the timestamp is unparseable', () => {
const secret = createMockSecret({ last_used_at: 'not-a-date' })
renderComponent({ secret })
expect(screen.queryByText(/secrets\.lastUsed/)).not.toBeInTheDocument()
expect(screen.queryByText(/Invalid Date/)).not.toBeInTheDocument()
})
it('renders last used date for 4-digit fractional seconds', () => {
const secret = createMockSecret({
last_used_at: '2026-04-18T11:00:00.6513Z'
})
renderComponent({ secret })
expect(screen.getByText(/secrets\.lastUsed/)).toBeInTheDocument()
expect(screen.queryByText(/Invalid Date/)).not.toBeInTheDocument()
})
})
describe('loading state', () => {

View File

@@ -19,8 +19,10 @@
</span>
</div>
<div class="flex gap-3 text-xs text-muted">
<span>{{ $t('secrets.createdAt', { date: createdDate }) }}</span>
<span v-if="secret.last_used_at">
<span v-if="createdDate">
{{ $t('secrets.createdAt', { date: createdDate }) }}
</span>
<span v-if="lastUsedDate">
{{ $t('secrets.lastUsed', { date: lastUsedDate }) }}
</span>
</div>
@@ -57,6 +59,7 @@
import { computed } from 'vue'
import Button from '@/components/ui/button/Button.vue'
import { parseIsoDateSafe } from '@/utils/dateTimeUtil'
import { getProviderLabel, getProviderLogo } from '../providers'
import type { SecretMetadata } from '../types'
@@ -79,13 +82,11 @@ const emit = defineEmits<{
const providerLabel = computed(() => getProviderLabel(secret.provider))
const providerLogo = computed(() => getProviderLogo(secret.provider))
function formatDateString(dateString: string): string {
const date = new Date(dateString)
return date.toLocaleDateString()
function formatIsoDate(iso: string | undefined | null): string {
const date = parseIsoDateSafe(iso)
return date ? date.toLocaleDateString() : ''
}
const createdDate = computed(() => formatDateString(secret.created_at))
const lastUsedDate = computed(() =>
secret.last_used_at ? formatDateString(secret.last_used_at) : ''
)
const createdDate = computed(() => formatIsoDate(secret.created_at))
const lastUsedDate = computed(() => formatIsoDate(secret.last_used_at))
</script>

View File

@@ -5,9 +5,137 @@ import {
formatClockTime,
formatShortMonthDay,
isToday,
isYesterday
isYesterday,
parseIsoDateSafe
} from './dateTimeUtil'
const isoFractionalSecondsPattern = /\.(\d+)(?=Z|[+-]\d{2}:?\d{2}|$)/
function withStrictMillisecondParser<T>(
run: (normalizedValues: string[]) => T
): T {
const RealDate = Date
const normalizedValues: string[] = []
class StrictDate extends RealDate {
constructor(value?: string | number | Date) {
if (arguments.length === 0) {
super()
return
}
if (typeof value === 'string') {
normalizedValues.push(value)
const fractionalSeconds = value.match(isoFractionalSecondsPattern)?.[1]
if (fractionalSeconds && fractionalSeconds.length !== 3) {
super(Number.NaN)
return
}
}
super(value as string | number)
}
}
vi.stubGlobal('Date', StrictDate as DateConstructor)
try {
return run(normalizedValues)
} finally {
vi.unstubAllGlobals()
}
}
describe('parseIsoDateSafe', () => {
it('parses standard ISO 8601 with millisecond precision', () => {
const date = parseIsoDateSafe('2026-04-18T10:04:55.651Z')
expect(date?.toISOString()).toBe('2026-04-18T10:04:55.651Z')
})
it('normalizes fractional seconds longer than 3 digits', () => {
withStrictMillisecondParser((normalizedValues) => {
const date = parseIsoDateSafe('2026-04-18T10:04:55.6513Z')
expect(date?.toISOString()).toBe('2026-04-18T10:04:55.651Z')
expect(normalizedValues).toEqual(['2026-04-18T10:04:55.651Z'])
})
})
it('handles fractional seconds without timezone suffix', () => {
withStrictMillisecondParser((normalizedValues) => {
const date = parseIsoDateSafe('2026-04-18T10:04:55.6513')
expect(date).not.toBeNull()
expect(Number.isNaN(date!.getTime())).toBe(false)
expect(normalizedValues).toEqual(['2026-04-18T10:04:55.651'])
})
})
it('handles offset timezones with long fractional seconds', () => {
withStrictMillisecondParser((normalizedValues) => {
const date = parseIsoDateSafe('2026-04-18T10:04:55.6513+09:00')
expect(date?.toISOString()).toBe('2026-04-18T01:04:55.651Z')
expect(normalizedValues).toEqual(['2026-04-18T10:04:55.651+09:00'])
})
})
it('handles negative-offset timezones with long fractional seconds', () => {
withStrictMillisecondParser((normalizedValues) => {
const date = parseIsoDateSafe('2026-04-18T10:04:55.987654-05:00')
expect(date?.toISOString()).toBe('2026-04-18T15:04:55.987Z')
expect(normalizedValues).toEqual(['2026-04-18T10:04:55.987-05:00'])
})
})
it('handles the full 9-digit nanosecond precision Go can emit', () => {
withStrictMillisecondParser((normalizedValues) => {
const date = parseIsoDateSafe('2026-04-18T10:04:55.123456789Z')
expect(date?.toISOString()).toBe('2026-04-18T10:04:55.123Z')
expect(normalizedValues).toEqual(['2026-04-18T10:04:55.123Z'])
})
})
it('passes through timestamps without any fractional seconds', () => {
const date = parseIsoDateSafe('2026-04-18T10:04:55Z')
expect(date?.toISOString()).toBe('2026-04-18T10:04:55.000Z')
})
it('preserves an all-zero 3-digit fractional', () => {
const date = parseIsoDateSafe('2026-04-18T10:04:55.000Z')
expect(date?.toISOString()).toBe('2026-04-18T10:04:55.000Z')
})
it('normalizes 1- and 2-digit fractionals for strict parsers', () => {
withStrictMillisecondParser((normalizedValues) => {
expect(parseIsoDateSafe('2026-04-18T10:04:55.6Z')?.toISOString()).toBe(
'2026-04-18T10:04:55.600Z'
)
expect(parseIsoDateSafe('2026-04-18T10:04:55.65Z')?.toISOString()).toBe(
'2026-04-18T10:04:55.650Z'
)
expect(normalizedValues).toEqual([
'2026-04-18T10:04:55.600Z',
'2026-04-18T10:04:55.650Z'
])
})
})
it('returns null for empty string', () => {
expect(parseIsoDateSafe('')).toBeNull()
})
it('returns null for null', () => {
expect(parseIsoDateSafe(null)).toBeNull()
})
it('returns null for undefined', () => {
expect(parseIsoDateSafe(undefined)).toBeNull()
})
it('returns null for unparseable input', () => {
expect(parseIsoDateSafe('not-a-date')).toBeNull()
})
})
describe('dateKey', () => {
it('returns YYYY-MM-DD for a given timestamp', () => {
// 2024-03-15 in UTC

View File

@@ -1,3 +1,26 @@
const isoFractionalSecondsPattern = /\.(\d+)(?=Z|[+-]\d{2}:?\d{2}|$)/
/**
* Parse an ISO 8601 date string tolerantly. Some JavaScript Date parsers reject
* fractional seconds unless they are exactly 3 digits, which turns values like
* "2026-04-18T10:04:55.6Z" or "2026-04-18T10:04:55.6513Z" into Invalid Date.
* This helper normalizes the fractional portion to millisecond precision first.
*
* @returns Parsed Date, or null if the string is missing or unparseable.
*/
export function parseIsoDateSafe(
input: string | null | undefined
): Date | null {
if (!input) return null
const normalized = input.replace(
isoFractionalSecondsPattern,
(_, fractionalSeconds: string) =>
`.${fractionalSeconds.slice(0, 3).padEnd(3, '0')}`
)
const date = new Date(normalized)
return Number.isNaN(date.getTime()) ? null : date
}
/**
* Return a local date key in YYYY-MM-DD format for grouping.
*