Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
14 commits
Select commit Hold shift + click to select a range
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
4 changes: 4 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -343,3 +343,7 @@ REALUNIT_W2W_GAS_LOW_BALANCE_THRESHOLD=0.05
REQUEST_KNOWN_IPS=

CRON_JOB_DELAY=

# Optional: connection string to a throwaway Postgres for the specs that exercise real SQL.
# Unset, the "(real Postgres)" describe blocks skip. Existing convention across the repo.
MIGRATION_TEST_PG=
5 changes: 5 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,11 @@ RUN echo "$GIT_COMMIT" > dist/version.txt

FROM node:20-alpine

# Process must run in UTC. Columns such as `created` are `timestamp without time
# zone`; the Postgres driver serializes JS Date in the process-local wall-clock
# and Postgres drops the offset — non-UTC shifts stored values and day buckets.
ENV TZ=UTC

# tini as PID 1: forwards SIGTERM to node so stops behave exactly as they did
# under npm (immediate exit), without npm's 5-line error block on every stop.
# Bare node as PID 1 would IGNORE SIGTERM (no handler + PID-1 semantics) and
Expand Down
16 changes: 8 additions & 8 deletions docs/coverage-gate.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ the other.
| Gate | Config | Scope | Question it answers |
| ---------------- | ------------------------------ | ------------------------------------------ | -------------------------------------------------------- |
| Frick gate | `jest.frick.config.js` | 10 Frick files, run by 10 Frick specs only | Do _these specs alone_ fully cover _these files_? |
| Coverage ratchet | `jest.coverage-gate.config.js` | 439 files, whole suite | Has coverage regressed anywhere it was already complete? |
| Coverage ratchet | `jest.coverage-gate.config.js` | 445 files, whole suite | Has coverage regressed anywhere it was already complete? |

## What the ratchet is, and what it is not

Expand All @@ -25,8 +25,8 @@ It is a **regression gate**, not a statement about test quality:
ratchet only protects files already on the list, and that list grows by hand (see "How the list
grows"). That is the price of the threshold approach.

Of the 439 pinned files, **246 carry real logic** (they have functions and/or branches) and
**193 are purely declarative today** (NestJS modules, constant files with neither). The two groups
Of the 445 pinned files, **251 carry real logic** (they have functions and/or branches) and
**194 are purely declarative today** (NestJS modules, constant files with neither). The two groups
are kept visibly separate in the config so the count is not mistaken for test depth.

Pinning the declarative ones is deliberate and not vacuous. Istanbul reports a metric with a total
Expand Down Expand Up @@ -161,8 +161,8 @@ deleting them would be a separate cleanup.

| Class | Files | Meaning |
| -------- | ----- | ----------------------------------------------- |
| Complete | 422 | Pinned by the ratchet at that commit |
| Partial | 1,057 | Some coverage, below 100 on at least one metric |
| Complete | 445 | Pinned by the ratchet at that commit |
| Partial | 1,034 | Some coverage, below 100 on at least one metric |
| None | 127 | No coverage at all |

Totals: statements 59.61%, branches 42.64%, functions 34.21%, lines 59.97%.
Expand All @@ -175,7 +175,7 @@ six under `subdomains/generic/admin` have no coverage at all.
## How the list grows

Any PR may add files to `coverageThreshold` once they reach 100%.
`jest.coverage-gate.config.js` holds the 439 paths in two arrays, `PINNED_LOGIC` (logic-carrying
`jest.coverage-gate.config.js` holds the 445 paths in two arrays, `PINNED_LOGIC` (logic-carrying
files) and `PINNED_DECLARATIVE` (purely declarative files), from which `coverageThreshold` is
generated. Adding a file means appending its path to the matching array, not writing out a
`coverageThreshold` object entry by hand.
Expand Down Expand Up @@ -211,8 +211,8 @@ To regenerate the full picture, run the gate and read `coverage-gate/coverage-su
below 100, the expected response is to extend the tests. Unpinning is an explicit decision that
belongs in the PR description, not a silent edit.

That rule stays hard for the 246 logic-carrying files. A foreseeable friction case is different:
when one of the 193 purely declarative files (a NestJS module, a constants file) first gains
That rule stays hard for the 251 logic-carrying files. A foreseeable friction case is different:
when one of the 194 purely declarative files (a NestJS module, a constants file) first gains
executable logic — for example a `useFactory` on a module — the function metric jumps from 0/0 to
0/N and the gate turns red. Tests remain the preferred fix, but unpinning that one file is an
allowed outcome if the PR description names and justifies it (not as a silent edit). For
Expand Down
6 changes: 6 additions & 0 deletions jest.coverage-gate.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ const PINNED_LOGIC = [
'src/integration/scorechain/exceptions/scorechain-object-not-found.exception.ts',
'src/integration/sift/dto/sift.dto.ts',
'src/polyfills.ts',
'src/process-timezone.ts',
'src/shared/auth/allow-tfa-pending.decorator.ts',
'src/shared/auth/exceptions/staff-kyc-required.exception.ts',
'src/shared/auth/get-jwt.decorator.ts',
Expand Down Expand Up @@ -118,6 +119,10 @@ const PINNED_LOGIC = [
'src/subdomains/core/payment-link/entities/payment-link.config.ts',
'src/subdomains/core/payment-link/enums/index.ts',
'src/subdomains/core/payment-link/enums/merchant.enum.ts',
'src/subdomains/core/statistic/partner-statistic-rate-limit.guard.ts',
'src/subdomains/core/statistic/partner-statistic.controller.ts',
'src/subdomains/core/statistic/partner-statistic.enum.ts',
'src/subdomains/core/statistic/partner-statistic.suppression.ts',
'src/subdomains/core/trading/enums/index.ts',
'src/subdomains/generic/forwarding/controllers/lnurld-forward.controller.ts',
'src/subdomains/generic/forwarding/controllers/lnurlw-forward.controller.ts',
Expand Down Expand Up @@ -402,6 +407,7 @@ const PINNED_DECLARATIVE = [
'src/subdomains/core/sell-crypto/route/dto/sell.dto.ts',
'src/subdomains/core/sell-crypto/route/dto/unsigned-tx.dto.ts',
'src/subdomains/core/sell-crypto/route/dto/update-sell.dto.ts',
'src/subdomains/core/statistic/dto/partner-statistic.dto.ts',
'src/subdomains/generic/gs/dto/support-data.dto.ts',
'src/subdomains/generic/kyc/dto/input/kyc-query.dto.ts',
'src/subdomains/generic/kyc/dto/input/update-kyc-step.dto.ts',
Expand Down
151 changes: 151 additions & 0 deletions src/__tests__/process-timezone.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
import { DfxLogger } from 'src/shared/services/dfx-logger';
import { checkProcessTimezone, isProcessTimezoneUtcYearRound } from '../process-timezone';

/** London-like: UTC in winter, BST (UTC+1 → offset -60) in summer. */
function londonLikeOffset(date: Date): number {
// Anchors use month 0 (Jan) and 6 (Jul); treat Jan–Mar as winter.
return date.getUTCMonth() < 6 ? 0 : -60;
}

describe('isProcessTimezoneUtcYearRound', () => {
it('is true only when both January and July offsets are 0', () => {
expect(isProcessTimezoneUtcYearRound(() => 0)).toBe(true);
expect(isProcessTimezoneUtcYearRound(londonLikeOffset)).toBe(false);
expect(isProcessTimezoneUtcYearRound(() => -60)).toBe(false);
});
});

describe('checkProcessTimezone', () => {
let warnSpy: jest.SpyInstance;
let infoSpy: jest.SpyInstance;

beforeEach(() => {
warnSpy = jest.spyOn(DfxLogger.prototype, 'warn').mockImplementation();
infoSpy = jest.spyOn(DfxLogger.prototype, 'info').mockImplementation();
});

afterEach(() => {
warnSpy.mockRestore();
infoSpy.mockRestore();
jest.restoreAllMocks();
});

it('logs OK when January and July offsets are both UTC (0)', () => {
checkProcessTimezone({
getTimezoneOffset: () => 0,
getTimeZoneName: () => 'UTC',
});

expect(infoSpy).toHaveBeenCalledTimes(1);
expect(infoSpy.mock.calls[0][0]).toContain('UTC');
expect(infoSpy.mock.calls[0][0]).toMatch(/january=0.*july=0|july=0.*january=0/s);
expect(warnSpy).not.toHaveBeenCalled();
});

it('accepts a permanent zero-offset zone (Atlantic/Reykjavik) and logs OK', () => {
checkProcessTimezone({
getTimezoneOffset: () => 0,
getTimeZoneName: () => 'Atlantic/Reykjavik',
});

expect(infoSpy).toHaveBeenCalledTimes(1);
expect(infoSpy.mock.calls[0][0]).toContain('Atlantic/Reykjavik');
expect(warnSpy).not.toHaveBeenCalled();
});

it('warns (does not throw) for a DST zone that is UTC-offset only in winter (e.g. Europe/London)', () => {
expect(() =>
checkProcessTimezone({
getTimezoneOffset: londonLikeOffset,
getTimeZoneName: () => 'Europe/London',
}),
).not.toThrow();

expect(warnSpy).toHaveBeenCalledTimes(1);
expect(warnSpy.mock.calls[0][0]).toContain('Europe/London');
expect(warnSpy.mock.calls[0][0]).toMatch(/january=0.*july=-60|july=-60.*january=0/s);
expect(warnSpy.mock.calls[0][0]).toMatch(/must be UTC/i);
expect(infoSpy).not.toHaveBeenCalled();
});

it('warns (does not throw) when either seasonal offset is non-zero and names the zone', () => {
checkProcessTimezone({
getTimezoneOffset: () => -120,
getTimeZoneName: () => 'Europe/Zurich',
});

expect(warnSpy).toHaveBeenCalledTimes(1);
expect(warnSpy.mock.calls[0][0]).toContain('Europe/Zurich');
expect(warnSpy.mock.calls[0][0]).toMatch(/must be UTC/i);

warnSpy.mockClear();

checkProcessTimezone({
getTimezoneOffset: () => -60,
getTimeZoneName: () => 'Europe/Berlin',
});

expect(warnSpy).toHaveBeenCalledTimes(1);
expect(warnSpy.mock.calls[0][0]).toMatch(/Europe\/Berlin.*must be UTC|must be UTC.*Europe\/Berlin/s);
expect(infoSpy).not.toHaveBeenCalled();
});

it('samples January and July anchors via Date#getTimezoneOffset when not overridden', () => {
const seenMonths: number[] = [];
const spy = jest.spyOn(Date.prototype, 'getTimezoneOffset').mockImplementation(function (this: Date) {
seenMonths.push(this.getUTCMonth());
return 0;
});

checkProcessTimezone({ getTimeZoneName: () => 'UTC' });

expect(spy).toHaveBeenCalled();
expect(seenMonths).toEqual([0, 6]);
expect(infoSpy).toHaveBeenCalledTimes(1);
expect(warnSpy).not.toHaveBeenCalled();
});

it('warns via the production default path when Date#getTimezoneOffset is seasonal (London-like)', () => {
jest.spyOn(Date.prototype, 'getTimezoneOffset').mockImplementation(function (this: Date) {
return this.getUTCMonth() < 6 ? 0 : -60;
});

checkProcessTimezone({
getTimeZoneName: () => 'Europe/London',
});

expect(warnSpy).toHaveBeenCalledTimes(1);
expect(warnSpy.mock.calls[0][0]).toMatch(
/Europe\/London.*january=0.*july=-60|january=0.*july=-60.*Europe\/London/s,
);
});

it('uses the default Intl zone name and default logger when neither is injected', () => {
const resolved = Intl.DateTimeFormat().resolvedOptions().timeZone ?? 'unknown';

checkProcessTimezone({
getTimezoneOffset: () => -60,
});

expect(warnSpy).toHaveBeenCalledTimes(1);
expect(warnSpy.mock.calls[0][0]).toMatch(new RegExp(resolved.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')));
});

it('falls back to "unknown" in the message when the zone name is missing', () => {
checkProcessTimezone({
getTimezoneOffset: () => -60,
getTimeZoneName: () => undefined as unknown as string,
});

expect(warnSpy).toHaveBeenCalledTimes(1);
expect(warnSpy.mock.calls[0][0]).toMatch(/timezone "unknown"/);
});

it('accepts a no-arg call when the process offset is UTC year-round and logs OK', () => {
jest.spyOn(Date.prototype, 'getTimezoneOffset').mockReturnValue(0);

expect(() => checkProcessTimezone()).not.toThrow();
expect(infoSpy).toHaveBeenCalledTimes(1);
expect(warnSpy).not.toHaveBeenCalled();
});
});
15 changes: 14 additions & 1 deletion src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import { join } from 'path';
import { getVerifiedIp } from './shared/utils/ip.util';
import { AppModule } from './app.module';
import { Config, Environment } from './config/config';
import { checkProcessTimezone } from './process-timezone';
import { ApiExceptionFilter } from './shared/filters/exception.filter';
import { apiTraceMiddleware, maskUrl } from './shared/middlewares/api-trace.middleware';
import { DetailedValidationPipe } from './shared/pipes/detailed-validation.pipe';
Expand Down Expand Up @@ -46,6 +47,11 @@ process.on('uncaughtException', (error) => {
});

async function bootstrap() {
// Log process timezone at boot (warn if not UTC year-round; never throws — see
// process-timezone.ts and Dockerfile ENV TZ=UTC). Must run before NestFactory.create
// — TypeORM connects during app creation.
checkProcessTimezone();

// Observability is initialized in src/tracing.ts (imported above): the
// OpenTelemetry SDK auto-instruments HTTP/DB/NestJS and exports traces via
// OTLP. 4xx-not-a-failure is handled there in the HTTP response hook.
Expand Down Expand Up @@ -157,4 +163,11 @@ function runSeed(): void {
}
}

void bootstrap();
// Catch boot failures so they surface as a clear "Bootstrap failed" log with
// exit 1, not as an unhandled rejection that the uncaughtException handler
// reports as a generic crash (and Spark-error heuristic).
void bootstrap().catch((error) => {
const logger = new DfxLogger('Bootstrap');
logger.error('Bootstrap failed:', error instanceof Error ? error : new Error(String(error)));
process.exit(1);
});
72 changes: 72 additions & 0 deletions src/process-timezone.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import { DfxLogger } from './shared/services/dfx-logger';

/**
* Fixed mid-month noon-UTC anchors for seasonal offset sampling.
* Mid-month avoids DST transition days; a fixed year keeps the check deterministic.
* Both must be 0 for a process that is UTC (or permanently zero-offset, e.g. Atlantic/Reykjavik)
* year-round — a single `new Date().getTimezoneOffset()` would accept Europe/London in winter.
*/
const JANUARY_OFFSET_ANCHOR = new Date(Date.UTC(2024, 0, 15, 12, 0, 0));
const JULY_OFFSET_ANCHOR = new Date(Date.UTC(2024, 6, 15, 12, 0, 0));

export type CheckProcessTimezoneDeps = {
logger?: DfxLogger;
/**
* Override for tests; production uses `Date#getTimezoneOffset` on fixed Jan/Jul anchors.
* Takes the date so tests can simulate seasonal offsets (e.g. London winter vs summer).
*/
getTimezoneOffset?: (date: Date) => number;
/** Override for tests; production uses Intl resolved zone name. */
getTimeZoneName?: () => string;
};

/**
* True when the process wall-clock is UTC-offset year-round (Jan and Jul anchors both 0).
* A single `new Date().getTimezoneOffset()` would accept Europe/London in winter — use this
* (or `checkProcessTimezone`) whenever a gate depends on process TZ being UTC for
* `timestamp without time zone` serialization.
*/
export function isProcessTimezoneUtcYearRound(
getTimezoneOffset: (date: Date) => number = (date) => date.getTimezoneOffset(),
): boolean {
return getTimezoneOffset(JANUARY_OFFSET_ANCHOR) === 0 && getTimezoneOffset(JULY_OFFSET_ANCHOR) === 0;
}

/**
* Logs the process timezone at boot. Warns when the Node process is not UTC year-round;
* never throws — deploy start commands may set `TZ` outside this repo, and a hard abort
* would block deploys without a confirmed host-side guarantee.
*
* Sibling of `assertValidStorageCombo` (config.ts) for runtime prerequisites, but advisory
* only: columns like `created` are `timestamp without time zone` and the Postgres driver
* serializes JS `Date` in the process-local wall-clock.
*
* Must run before NestFactory.create — TypeORM connects during app creation.
*
* Offset is checked at January and July anchors, not "today": zones with seasonal
* UTC-offset (Europe/London in winter) must not look fine just because boot landed in
* the zero-offset half of the year. For serialization only the offset matters —
* Atlantic/Reykjavik (permanently 0) is treated as OK.
*/
export function checkProcessTimezone(deps: CheckProcessTimezoneDeps = {}): void {
const getOffset = deps.getTimezoneOffset ?? ((date: Date) => date.getTimezoneOffset());
const januaryOffset = getOffset(JANUARY_OFFSET_ANCHOR);
const julyOffset = getOffset(JULY_OFFSET_ANCHOR);
// The 'unknown' fallback feeds the diagnostic message only — never a decision. The offset
// already decides; Intl can return undefined on exotic ICU builds and must not mask that.
const timeZone = (deps.getTimeZoneName ?? (() => Intl.DateTimeFormat().resolvedOptions().timeZone))() ?? 'unknown';
const logger = deps.logger ?? new DfxLogger('ProcessTimezone');

// Same predicate as isProcessTimezoneUtcYearRound — sample once so log offsets match the decision.
if (januaryOffset === 0 && julyOffset === 0) {
logger.info(`Process timezone OK: "${timeZone}" (getTimezoneOffset january=${januaryOffset}, july=${julyOffset}).`);
return;
}

logger.warn(
`Process timezone must be UTC: columns like \`created\` are timestamp without time zone and the ` +
`Postgres driver serializes JS Date in the process-local wall-clock (Postgres then drops the offset). ` +
`Found timezone "${timeZone}" (getTimezoneOffset january=${januaryOffset}, july=${julyOffset}). ` +
`Set ENV TZ=UTC.`,
);
}
Loading