From 4272c2bf3acd23c01e8c25e7e5286a6db5c17044 Mon Sep 17 00:00:00 2001 From: 4gray Date: Sat, 1 Aug 2026 23:23:08 +0200 Subject: [PATCH] fix(stalker): harden account-info portal detection and expiry math Review round four (Codex P2s on #1330): - Fall back to the URL rule when isFullStalkerPortal is undefined: a playlist restored from an older backup carries no flag once the one-shot metadata migration has run, and it would then be sent down the unauthenticated legacy path and labelled a legacy panel. - Parse a bare YYYY-MM-DD expiry as a local calendar date. Date.parse reads it as UTC midnight, which renders as the previous day west of UTC and shifts the days-left boundary; timestamps carrying a time or offset keep standard parsing. - Decide expiry from the raw timestamp, not the rounded counter: an expiry that passed less than a day ago ceil's to 0/-0, so the hero stat claimed "0 days left" on a dead subscription. Co-Authored-By: Claude Opus 5 --- .../lib/stalker-account-info.service.spec.ts | 53 +++++++++++++++++-- .../src/lib/stalker-account-info.service.ts | 43 ++++++++++++++- .../stalker-account-info.component.spec.ts | 17 ++++++ .../stalker-account-info.component.ts | 24 ++++++--- 4 files changed, 124 insertions(+), 13 deletions(-) diff --git a/libs/portal/stalker/data-access/src/lib/stalker-account-info.service.spec.ts b/libs/portal/stalker/data-access/src/lib/stalker-account-info.service.spec.ts index a93e750b8..43276a96d 100644 --- a/libs/portal/stalker/data-access/src/lib/stalker-account-info.service.spec.ts +++ b/libs/portal/stalker/data-access/src/lib/stalker-account-info.service.spec.ts @@ -121,7 +121,7 @@ describe('StalkerAccountInfoService', () => { ); expect(snapshot).toEqual({ login: 'Fallback Name', - expireDate: Math.round(Date.parse('2026-10-01') / 1000), + expireDate: Math.round(new Date(2026, 9, 1).getTime() / 1000), tariffPlanName: 'Basic 30', status: 1, mac: '00:1A:79:00:00:01', @@ -171,6 +171,36 @@ describe('StalkerAccountInfoService', () => { ); }); + it('treats a stalker_portal URL as a full portal when the flag is absent', async () => { + // Restored older backups persist `undefined` for the flag after the + // one-shot metadata migration already ran. + stalkerSession.refreshAccountProfile.mockResolvedValue({ + login: 'restored-user', + }); + + const snapshot = await service.fetchAccountInfo({ + ...portalPlaylist, + portalUrl: 'http://portal.example/stalker_portal/c/', + isFullStalkerPortal: undefined, + } as PlaylistMeta); + + expect(stalkerSession.refreshAccountProfile).toHaveBeenCalled(); + expect(dataService.sendIpcEvent).not.toHaveBeenCalled(); + expect(snapshot?.login).toBe('restored-user'); + }); + + it('keeps portal.php panels on the legacy path when the flag is absent', async () => { + dataService.sendIpcEvent.mockResolvedValue({ js: { login: 'legacy' } }); + + await service.fetchAccountInfo({ + ...portalPlaylist, + isFullStalkerPortal: undefined, + } as PlaylistMeta); + + expect(dataService.sendIpcEvent).toHaveBeenCalled(); + expect(stalkerSession.refreshAccountProfile).not.toHaveBeenCalled(); + }); + it('returns null when get_main_info yields no usable facts', async () => { dataService.sendIpcEvent.mockResolvedValue({ js: {} }); @@ -199,7 +229,7 @@ describe('normalizeStoredStalkerAccountInfo', () => { }) ).toEqual({ login: 'user-1', - expireDate: Math.round(Date.parse('2026-10-01') / 1000), + expireDate: Math.round(new Date(2026, 9, 1).getTime() / 1000), tariffPlanName: 'Premium', status: 1, }); @@ -239,7 +269,24 @@ describe('parseStalkerDate', () => { expect(parseStalkerDate('1790000000')).toBe(1_790_000_000); expect(parseStalkerDate(1_790_000_000_000)).toBe(1_790_000_000); expect(parseStalkerDate('2026-10-01')).toBe( - Math.round(Date.parse('2026-10-01') / 1000) + Math.round(new Date(2026, 9, 1).getTime() / 1000) + ); + }); + + it('reads a bare date as a local calendar day, not UTC midnight', () => { + // Date.parse('2026-10-01') is UTC midnight, which renders as + // September 30 anywhere west of UTC. + const parsed = parseStalkerDate('2026-10-01') as number; + const rendered = new Date(parsed * 1000); + + expect(rendered.getFullYear()).toBe(2026); + expect(rendered.getMonth()).toBe(9); + expect(rendered.getDate()).toBe(1); + }); + + it('keeps timestamps that carry a time or offset on standard parsing', () => { + expect(parseStalkerDate('2026-10-01T12:00:00Z')).toBe( + Math.round(Date.parse('2026-10-01T12:00:00Z') / 1000) ); }); diff --git a/libs/portal/stalker/data-access/src/lib/stalker-account-info.service.ts b/libs/portal/stalker/data-access/src/lib/stalker-account-info.service.ts index 1dcedb193..347223eb8 100644 --- a/libs/portal/stalker/data-access/src/lib/stalker-account-info.service.ts +++ b/libs/portal/stalker/data-access/src/lib/stalker-account-info.service.ts @@ -71,7 +71,7 @@ export class StalkerAccountInfoService { return null; } - if (playlist.isFullStalkerPortal) { + if (isFullStalkerPortalPlaylist(playlist)) { return this.fetchViaProfile(playlist); } @@ -146,6 +146,27 @@ export class StalkerAccountInfoService { } } +/** + * Whether a playlist should use the full `/stalker_portal/` flow. + * + * The persisted flag is authoritative when present, but a playlist + * restored from an older backup can carry `undefined` after the one-shot + * metadata migration has already run — fall back to the same URL rule + * that migration uses (`withExplicitLegacyStalkerPortalFlag` in + * PlaylistsService) rather than mislabelling it as a legacy panel. + */ +export function isFullStalkerPortalPlaylist(playlist: PlaylistMeta): boolean { + if (playlist.isFullStalkerPortal !== undefined) { + return Boolean(playlist.isFullStalkerPortal); + } + + const portalUrl = playlist.portalUrl ?? playlist.url ?? ''; + return ( + portalUrl.includes('/stalker_portal') || + portalUrl.includes('/server/load.php') + ); +} + function normalizeSnapshot( snapshot: StalkerAccountSnapshot ): StalkerAccountSnapshot | null { @@ -208,7 +229,25 @@ export function parseStalkerDate( : Math.round(numeric); } - const parsed = Date.parse(String(value).trim()); + const text = String(value).trim(); + + // A bare `YYYY-MM-DD` is a calendar date, but Date.parse reads it as + // UTC midnight — rendered locally that shows the previous day west of + // UTC and moves the days-left boundary. Build it in local time instead. + const dateOnly = /^(\d{4})-(\d{2})-(\d{2})$/.exec(text); + if (dateOnly) { + const [, year, month, day] = dateOnly; + const local = new Date( + Number(year), + Number(month) - 1, + Number(day) + ).getTime(); + return Number.isFinite(local) && local > 0 + ? Math.round(local / 1000) + : undefined; + } + + const parsed = Date.parse(text); if (!Number.isFinite(parsed) || parsed <= 0) { return undefined; } diff --git a/libs/portal/stalker/feature/src/lib/stalker-account-info/stalker-account-info.component.spec.ts b/libs/portal/stalker/feature/src/lib/stalker-account-info/stalker-account-info.component.spec.ts index 007a78478..7ec911d01 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-account-info/stalker-account-info.component.spec.ts +++ b/libs/portal/stalker/feature/src/lib/stalker-account-info/stalker-account-info.component.spec.ts @@ -181,6 +181,23 @@ describe('StalkerAccountInfoComponent', () => { expect(component.snapshotSource()).toBe('fresh'); }); + it('reports an expiry that passed hours ago as expired, not "0 days left"', async () => { + accountInfoService.fetchAccountInfo.mockResolvedValue({ + ...freshSnapshot, + expireDate: Math.floor(Date.now() / 1000) - 3600, + }); + + await createComponent(); + + // Math.ceil of a fraction of a day rounds up to 0/-0, so the + // counter must not decide expiry. + expect(component.daysLeft()).toBe(-0); + expect(component.hasExpired()).toBe(true); + expect(component.heroStats()[0].labelKey).toBe( + 'STALKER.ACCOUNT_INFO.EXPIRED' + ); + }); + it('renders no status pill for unknown status values', async () => { accountInfoService.fetchAccountInfo.mockResolvedValue({ ...freshSnapshot, diff --git a/libs/portal/stalker/feature/src/lib/stalker-account-info/stalker-account-info.component.ts b/libs/portal/stalker/feature/src/lib/stalker-account-info/stalker-account-info.component.ts index d2f83e64a..e7561eb7e 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-account-info/stalker-account-info.component.ts +++ b/libs/portal/stalker/feature/src/lib/stalker-account-info/stalker-account-info.component.ts @@ -93,6 +93,15 @@ export class StalkerAccountInfoComponent { } return Math.ceil((expiry.getTime() - Date.now()) / DAY_IN_MS); }); + /** + * Read from the raw timestamp, not daysLeft: an expiry that passed + * less than 24h ago rounds up to 0 (or -0), which is not negative, so + * the counter would claim "0 days left" on a dead subscription. + */ + readonly hasExpired = computed(() => { + const expiry = this.expiryDate(); + return expiry ? expiry.getTime() <= Date.now() : false; + }); readonly expiresSoon = computed(() => { const daysLeft = this.daysLeft(); return daysLeft !== null && daysLeft <= 7; @@ -103,16 +112,15 @@ export class StalkerAccountInfoComponent { const daysLeft = this.daysLeft(); if (daysLeft !== null) { + const expired = this.hasExpired(); stats.push({ icon: 'timer', - labelKey: - daysLeft < 0 - ? 'STALKER.ACCOUNT_INFO.EXPIRED' - : 'STALKER.ACCOUNT_INFO.DAYS_LEFT', - value: - daysLeft < 0 - ? this.formatDate(this.expiryDate()) - : String(daysLeft), + labelKey: expired + ? 'STALKER.ACCOUNT_INFO.EXPIRED' + : 'STALKER.ACCOUNT_INFO.DAYS_LEFT', + value: expired + ? this.formatDate(this.expiryDate()) + : String(daysLeft), warning: this.expiresSoon(), }); }