Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 23 additions & 2 deletions frontend/src/__tests__/utils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,8 +58,10 @@ describe('formatCurrency', () => {

describe('formatDate', () => {
test('renders canonical "Mon DD, YYYY" form regardless of browser locale', () => {
// 2024-03-15 → "Mar 15, 2024" in en-US short-month format. The check
// is locale-invariant because formatDate forces en-US.
// 2024-03-15 → "Mar 15, 2024" in en-US short-month format. The check is
// locale-invariant (formatDate forces en-US) and timezone-invariant: a
// bare "YYYY-MM-DD" is parsed as local midnight, so the displayed day
// stays 15 even in browsers west of UTC (previously rendered "Mar 14").
expect(formatDate('2024-03-15')).toBe('Mar 15, 2024');
});

Expand All @@ -75,8 +77,18 @@ describe('formatDate', () => {

test('returns empty string for invalid date', () => {
expect(formatDate('not-a-date')).toBe('');
// Digit-shaped but out-of-range: must not silently roll over (month 13,
// day 45) into a valid date — parseDateInput round-trips and rejects it.
expect(formatDate('2024-13-45')).toBe('');
});

test('does not shift a bare calendar day across the day boundary', () => {
// A date-only string is the intended calendar day; it must render the
// same day regardless of the runner's timezone (regression for the
// UTC-midnight off-by-one that showed "Mar 14" west of UTC).
expect(formatDate('2024-01-01')).toBe('Jan 1, 2024');
expect(formatDate('2024-12-31')).toBe('Dec 31, 2024');
});
});

describe('formatDateTime', () => {
Expand Down Expand Up @@ -135,6 +147,15 @@ describe('getDateParts', () => {

test('returns zeros for invalid date', () => {
expect(getDateParts('invalid')).toEqual({ day: 0, month: '' });
// Out-of-range digits must be rejected, not rolled over.
expect(getDateParts('2024-13-45')).toEqual({ day: 0, month: '' });
});

test('keeps a bare calendar day stable across timezones', () => {
// Regression: a date-only string parsed as UTC midnight previously
// reported the previous day's date west of UTC.
expect(getDateParts('2024-03-15').day).toBe(15);
expect(getDateParts('2024-01-01').day).toBe(1);
});
});

Expand Down
41 changes: 39 additions & 2 deletions frontend/src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,43 @@ export function formatCurrency(
})}`;
}

/**
* Parse a date input into a Date, treating bare calendar dates correctly.
*
* A date-only ISO string ("2024-03-15") is parsed by `new Date()` as UTC
* midnight; rendering it with a locale formatter then shifts it into the
* browser's local timezone, so a user west of UTC sees the *previous* day
* ("Mar 14") for a date the backend meant as a plain calendar day (e.g. a
* purchase's scheduled_date or a reservation end_date). Parse bare
* "YYYY-MM-DD" values as local midnight instead so the displayed day matches
* the intended calendar day in every timezone. Strings carrying a time/zone
* component ("2024-03-15T14:30:00Z") and Date objects are left untouched.
*/
function parseDateInput(date: string | Date): Date {
if (typeof date === 'string') {
const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(date);
if (m) {
const year = Number(m[1]);
const month = Number(m[2]); // 1-based as written
const day = Number(m[3]);
// Local midnight for the given calendar day (Date month is 0-based).
const d = new Date(year, month - 1, day);
// Reject out-of-range components (e.g. "2024-13-45"): the Date
// constructor silently rolls them over instead of producing NaN, so
// verify the round-trip and return an Invalid Date if it doesn't match.
if (
d.getFullYear() !== year ||
d.getMonth() !== month - 1 ||
d.getDate() !== day
) {
return new Date(NaN);
}
return d;
}
}
return new Date(date);
}

/**
* Canonical date-only format used everywhere in the UI: "Apr 17, 2026".
* The en-US locale + `month: 'short'` removes ambiguity from pure numeric
Expand All @@ -46,7 +83,7 @@ export function formatCurrency(
*/
export function formatDate(date: string | Date | null | undefined): string {
if (!date) return '';
const d = new Date(date);
const d = parseDateInput(date);
if (isNaN(d.getTime())) return '';
return d.toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric' });
}
Expand Down Expand Up @@ -111,7 +148,7 @@ export interface DateParts {
*/
export function getDateParts(date: string | Date | null | undefined): DateParts {
if (!date) return { day: 0, month: '' };
const d = new Date(date);
const d = parseDateInput(date);
if (isNaN(d.getTime())) return { day: 0, month: '' };
return {
day: d.getDate(),
Expand Down
19 changes: 19 additions & 0 deletions internal/auth/test_helpers.go
Original file line number Diff line number Diff line change
Expand Up @@ -217,6 +217,25 @@ var _ EmailSenderInterface = (*MockEmailSender)(nil)
// calling a real key-generation path.
var testCSRFKey = []byte("test-csrf-key-32-bytes-padded---")

// TestCSRFKey returns the fixed 32-byte CSRF key shared by test services.
// Pass it as ServiceConfig.CSRFKey so a service built via NewService derives
// CSRF tokens deterministically (no ephemeral random key), letting tests in
// other packages reproduce the expected token with DeriveTestCSRFToken.
func TestCSRFKey() []byte {
// Return a copy so callers cannot mutate the package-level key.
key := make([]byte, len(testCSRFKey))
copy(key, testCSRFKey)
return key
}

// DeriveTestCSRFToken returns the CSRF token a service configured with
// TestCSRFKey expects for the given raw session token. It reuses the
// production derivation (HMAC-SHA256(key, rawSessionToken)) so tests assert
// the real contract rather than duplicating the crypto.
func DeriveTestCSRFToken(rawSessionToken string) string {
return deriveCSRFToken(testCSRFKey, rawSessionToken)
}

// newTestService returns a minimal Service with fast bcrypt for unit tests.
// It has no store or email sender — use createTestService() for tests that need mocks.
func newTestService() *Service {
Expand Down
13 changes: 12 additions & 1 deletion internal/server/adapter_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,11 @@ func createMockAuthService(t *testing.T) (*authServiceAdapter, *auth.MockStore)
service := auth.NewService(auth.ServiceConfig{
Store: mockStore,
SessionDuration: time.Hour,
// Pin a deterministic CSRF key so CSRF-token derivation is
// reproducible and hermetic. Without this, NewService generates a
// random ephemeral key, so any test asserting a specific derived CSRF
// token (TestAuthServiceAdapter_ValidateCSRFToken) cannot reproduce it.
CSRFKey: auth.TestCSRFKey(),
})
adapter := newAuthServiceAdapter(service)
return adapter, mockStore
Expand Down Expand Up @@ -238,7 +243,13 @@ func TestAuthServiceAdapter_ValidateCSRFToken(t *testing.T) {
ExpiresAt: time.Now().Add(time.Hour),
}, nil)

err := adapter.ValidateCSRFToken(ctx, "session-token", "csrf-token")
// ValidateCSRFToken recomputes the expected token as
// HMAC-SHA256(csrfKey, rawSessionToken); it never reads the stored
// CSRFToken column. Derive the matching token from the same fixed key the
// service was built with (auth.TestCSRFKey) so the assertion is hermetic
// and does not depend on an ambient CSRFKey from the environment.
csrfToken := auth.DeriveTestCSRFToken("session-token")
err := adapter.ValidateCSRFToken(ctx, "session-token", csrfToken)
require.NoError(t, err)
}

Expand Down
Loading