fix(stalker): close the abort window around the async prehash, and repair logins

Two review findings on the cancellation work:

- `getProfile()` and `performHandshake()` await `generatePrehash()`, so an
  abort landing during that await left the caller's pre-check stale and the
  request went out anyway — precisely the avoidable case where an abandoned
  candidate adopts the MAC's token behind a healthy one. The check now sits
  immediately before each dispatch, inside the API methods.
- Lazy repair called `discover()` without the playlist's stored credentials,
  so a login/password portal answered status 2 during confirmation and could
  never be repaired despite holding a working login.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Fable 5 committed 2026-08-03 18:41:55 +02:00
1 parent 2cf0ff8214
commit bdb4e555d6
4 files changed
+83 -8

No files matched your search

@@ -318,6 +318,25 @@ describe('StalkerAuthApi', () => {
expect(sendIpcEvent).not.toHaveBeenCalled();
});
it('rechecks the abort after the async prehash, not just before the call', async () => {
// getProfile awaits generatePrehash(); an abort landing during that
// await would otherwise still let the adopting request go out.
const abandon = new AbortController();
const digest = globalThis.crypto.subtle.digest as jest.Mock;
digest.mockImplementation(async () => {
abandon.abort();
return new Uint8Array(20).fill(1).buffer;
});
await expect(
api.getProfile(portalUrl, macAddress, 'TOKEN-1', {}, 'r1', {
signal: abandon.signal,
})
).rejects.toMatchObject({ name: 'StalkerAuthAbortedError' });
expect(sendIpcEvent).not.toHaveBeenCalled();
});
it('stops the status-2 login flow when abandoned mid-way', async () => {
const abandon = new AbortController();
sendIpcEvent
@@ -190,7 +190,8 @@ export class StalkerAuthApi {
portalUrl: string,
macAddress: string,
identity: StalkerPortalIdentity = {},
storedToken?: string
storedToken?: string,
signal?: AbortSignal
): Promise<StalkerHandshakeOutcome> {
const normalizedIdentity = normalizeStalkerPortalIdentity(identity);
const prehash = await generatePrehash(macAddress);
@@ -206,6 +207,10 @@ export class StalkerAuthApi {
};
try {
// Re-checked here rather than only at the call site: the prehash
// above is async, so an abort can land while it is being
// computed and the request would still go out.
assertNotAborted(signal);
const response =
await this.dataService.sendIpcEvent<StalkerHandshakeResponse>(
STALKER_REQUEST,
@@ -248,7 +253,11 @@ export class StalkerAuthApi {
token: string,
identity: StalkerPortalIdentity,
handshakeRandom: string,
options: { authSecondStep?: boolean; notValidToken?: boolean } = {}
options: {
authSecondStep?: boolean;
notValidToken?: boolean;
signal?: AbortSignal;
} = {}
): Promise<StalkerProfileResponse> {
const normalizedIdentity = normalizeStalkerPortalIdentity(identity);
@@ -296,6 +305,10 @@ export class StalkerAuthApi {
};
try {
// The last possible moment before the call that adopts the MAC's
// token portal-side — the prehash above is async, so the caller's
// pre-check can be stale by now.
assertNotAborted(options.signal);
const response =
await this.dataService.sendIpcEvent<StalkerProfileResponse>(
STALKER_REQUEST,
@@ -327,7 +340,8 @@ export class StalkerAuthApi {
macAddress: string,
token: string,
credentials: StalkerPortalCredentials,
identity: StalkerPortalIdentity = {}
identity: StalkerPortalIdentity = {},
signal?: AbortSignal
): Promise<boolean> {
const normalizedIdentity = normalizeStalkerPortalIdentity(identity);
const params: Record<string, string> = {
@@ -345,6 +359,7 @@ export class StalkerAuthApi {
};
try {
assertNotAborted(signal);
const response =
await this.dataService.sendIpcEvent<StalkerAuthConfirmationResponse>(
STALKER_REQUEST,
@@ -387,7 +402,8 @@ export class StalkerAuthApi {
portalUrl,
macAddress,
normalizedIdentity,
options.storedToken
options.storedToken,
options.signal
);
// An unchanged stored token is already an adopted session — the
@@ -414,7 +430,11 @@ export class StalkerAuthApi {
handshake.token,
normalizedIdentity,
handshake.random,
{ authSecondStep: false, notValidToken: handshake.notValid }
{
authSecondStep: false,
notValidToken: handshake.notValid,
signal: options.signal,
}
);
// The envelope the login flow actually settled on: after a status-2
@@ -472,7 +492,8 @@ export class StalkerAuthApi {
macAddress,
handshake.token,
{ username, password },
identity
identity,
options.signal
);
if (!accepted) {
throw new StalkerPortalError('login-rejected');
@@ -485,7 +506,11 @@ export class StalkerAuthApi {
handshake.token,
identity,
handshake.random,
{ authSecondStep: true, notValidToken: handshake.notValid }
{
authSecondStep: true,
notValidToken: handshake.notValid,
signal: options.signal,
}
);
settled = retried;
js = retried?.js;
@@ -282,6 +282,26 @@ describe('StalkerPortalRepairService', () => {
expect(writtenRow).toBeNull();
});
it("forwards the playlist's stored credentials to discovery", async () => {
// A login/password portal answers status 2 during confirmation;
// without the credentials the probe reports `login-required` and
// the source could never be repaired.
discover.mockResolvedValue({ status: 'unreachable' });
await service.repairPortal({
...MISCLASSIFIED,
username: 'user',
password: 'secret',
});
expect(discover).toHaveBeenCalledWith(
expect.any(String),
expect.any(String),
expect.any(Object),
{ credentials: { username: 'user', password: 'secret' } }
);
});
it('probes at most once per playlist per session', async () => {
discover.mockResolvedValue({ status: 'unreachable' });
@@ -336,7 +336,18 @@ export class StalkerPortalRepairService implements StalkerPortalRepairApi {
const outcome = await this.discovery.discover(
playlist.portalUrl ?? '',
playlist.macAddress ?? '',
getStalkerPortalIdentityFromPlaylist(playlist)
getStalkerPortalIdentityFromPlaylist(playlist),
{
// A login/password portal answers `get_profile` with status 2
// during confirmation. Without the stored credentials the
// probe reports `login-required` and such a source could
// never be repaired, even though the playlist holds a
// working login.
credentials: {
username: playlist.username,
password: playlist.password,
},
}
);
if (outcome.status !== 'resolved') {