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 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Opus 5 committed 2026-08-02 09:16:00 +02:00
1 parent 8cb45ba9de
commit 4272c2bf3a
4 files changed
+124 -13

No files matched your search

@@ -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)
);
});
@@ -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;
}
@@ -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,
@@ -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(),
});
}