mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-10 10:06:15 -08:00
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:
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;
|
||||
}
|
||||
|
||||
+17
@@ -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,
|
||||
|
||||
+16
-8
@@ -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(),
|
||||
});
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user