Skip to content

Commit e9404f1

Browse files
matt423claude
andcommitted
Refresh credentials before each handshake so reconnects don't expire
Signed configs are short-lived (the terminal server rejects configs older than a few minutes). A resume-on-return — or any auto-reconnect more than a few minutes into a session — reused the original signed config and was rejected with "Config expired", forcing a noisy retry. Add an optional async refreshCredentials() prop that the component awaits in the open handler, just before sending the auth payload, for both the primary and secondary (split-screen) terminals. Embedders return a fresh signed config; when absent or on error we fall back to the signedConfig/signature props, so behaviour is unchanged for consumers that don't supply it. DX-1379 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 443ac62 commit e9404f1

2 files changed

Lines changed: 280 additions & 103 deletions

File tree

packages/react-web-cli/src/AblyCliTerminal.inactivity.test.tsx

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -464,4 +464,122 @@ describe("DX-1379: inactivity pause & resume-on-return", () => {
464464
// Fresh session: no sessionId in the auth payload.
465465
expect(freshAuth?.sessionId).toBeUndefined();
466466
});
467+
468+
test("resume uses freshly-refreshed credentials for the handshake", async () => {
469+
const freshConfig = JSON.stringify({
470+
apiKey: "app.key:fresh",
471+
accessToken: "fresh-tok",
472+
timestamp: 2,
473+
});
474+
const refreshCredentials = vi
475+
.fn()
476+
.mockResolvedValue({ signedConfig: freshConfig, signature: "fresh-sig" });
477+
478+
await act(async () => {
479+
render(
480+
<AblyCliTerminal
481+
websocketUrl={WS_URL}
482+
signedConfig={SIGNED_CONFIG}
483+
signature={SIGNATURE}
484+
resumeOnReload
485+
inactivityTimeoutMs={INACTIVITY_MS}
486+
refreshCredentials={refreshCredentials}
487+
/>,
488+
);
489+
});
490+
await flush();
491+
const ws = sockets[0];
492+
await act(async () => {
493+
ws.fireOpen();
494+
});
495+
await flush();
496+
await act(async () => {
497+
ws.fireMessage(
498+
createControlMessage({ type: "hello", sessionId: "sess-4" }),
499+
);
500+
});
501+
await flush();
502+
503+
await act(async () => {
504+
setVisibility("hidden");
505+
});
506+
await flush();
507+
await act(async () => {
508+
vi.advanceTimersByTime(INACTIVITY_MS + 10);
509+
});
510+
await flush();
511+
512+
const socketsAfterPause = sockets.length;
513+
await act(async () => {
514+
setVisibility("visible");
515+
});
516+
await flush();
517+
await act(async () => {
518+
vi.advanceTimersByTime(50);
519+
});
520+
await flush();
521+
522+
const resumeSocket = sockets[socketsAfterPause];
523+
await act(async () => {
524+
resumeSocket.fireOpen();
525+
});
526+
await flush();
527+
528+
expect(refreshCredentials).toHaveBeenCalled();
529+
const authSend = resumeSocket.send.mock.calls
530+
.map(([raw]) => {
531+
try {
532+
return JSON.parse(raw as string);
533+
} catch {
534+
return null;
535+
}
536+
})
537+
.find((p) => p && p.config);
538+
// The handshake carries the refreshed signed config, not the stale prop.
539+
expect(authSend?.config).toBe(freshConfig);
540+
expect(authSend?.signature).toBe("fresh-sig");
541+
expect(authSend?.sessionId).toBe("sess-4");
542+
});
543+
544+
test.each([
545+
["returns null", vi.fn().mockResolvedValue(null)],
546+
["throws", vi.fn().mockRejectedValue(new Error("offline"))],
547+
])(
548+
"falls back to the prop credentials when refreshCredentials %s",
549+
async (_label, refreshCredentials) => {
550+
await act(async () => {
551+
render(
552+
<AblyCliTerminal
553+
websocketUrl={WS_URL}
554+
signedConfig={SIGNED_CONFIG}
555+
signature={SIGNATURE}
556+
resumeOnReload
557+
inactivityTimeoutMs={INACTIVITY_MS}
558+
refreshCredentials={refreshCredentials}
559+
/>,
560+
);
561+
});
562+
await flush();
563+
const ws = sockets[0];
564+
await act(async () => {
565+
ws.fireOpen();
566+
});
567+
await flush();
568+
569+
expect(refreshCredentials).toHaveBeenCalled();
570+
const authSend = ws.send.mock.calls
571+
.map(([raw]) => {
572+
try {
573+
return JSON.parse(raw as string);
574+
} catch {
575+
return null;
576+
}
577+
})
578+
.find((p) => p && p.config);
579+
// Refresh produced nothing usable, so the handshake still goes out using
580+
// the static prop config rather than failing/hanging.
581+
expect(authSend?.config).toBe(SIGNED_CONFIG);
582+
expect(authSend?.signature).toBe(SIGNATURE);
583+
},
584+
);
467585
});

0 commit comments

Comments
 (0)