Skip to content

Commit 419465e

Browse files
committed
Added some initial test coverage
1 parent d1162be commit 419465e

File tree

2 files changed

+322
-0
lines changed

2 files changed

+322
-0
lines changed

src/client/sse.test.ts

Lines changed: 176 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { JSONRPCMessage } from "../types.js";
44
import { SSEClientTransport } from "./sse.js";
55
import { OAuthClientProvider, UnauthorizedError } from "./auth.js";
66
import { OAuthTokens } from "../shared/auth.js";
7+
import { InvalidClientError, InvalidGrantError, UnauthorizedClientError } from "../server/auth/errors.js";
78

89
describe("SSEClientTransport", () => {
910
let server: Server;
@@ -310,6 +311,7 @@ describe("SSEClientTransport", () => {
310311
redirectToAuthorization: jest.fn(),
311312
saveCodeVerifier: jest.fn(),
312313
codeVerifier: jest.fn(),
314+
invalidateCredentials: jest.fn(),
313315
};
314316
});
315317

@@ -721,5 +723,179 @@ describe("SSEClientTransport", () => {
721723
await expect(() => transport.start()).rejects.toThrow(UnauthorizedError);
722724
expect(mockAuthProvider.redirectToAuthorization).toHaveBeenCalled();
723725
});
726+
727+
it("invalidates all credentials on InvalidClientError during token refresh", async () => {
728+
// Mock tokens() to return token with refresh token
729+
mockAuthProvider.tokens.mockResolvedValue({
730+
access_token: "expired-token",
731+
token_type: "Bearer",
732+
refresh_token: "refresh-token"
733+
});
734+
735+
// Create server that returns InvalidClientError on token refresh
736+
await server.close();
737+
738+
server = createServer((req, res) => {
739+
lastServerRequest = req;
740+
741+
// Handle OAuth metadata discovery
742+
if (req.url === "/.well-known/oauth-authorization-server" && req.method === "GET") {
743+
res.writeHead(200, { 'Content-Type': 'application/json' });
744+
res.end(JSON.stringify({
745+
issuer: baseUrl.href,
746+
authorization_endpoint: `${baseUrl.href}authorize`,
747+
token_endpoint: `${baseUrl.href}token`,
748+
response_types_supported: ["code"],
749+
code_challenge_methods_supported: ["S256"],
750+
}));
751+
return;
752+
}
753+
754+
if (req.url === "/token" && req.method === "POST") {
755+
// Handle token refresh request - return InvalidClientError
756+
const error = new InvalidClientError("Client authentication failed");
757+
res.writeHead(400, { 'Content-Type': 'application/json' })
758+
.end(JSON.stringify(error.toResponseObject()));
759+
return;
760+
}
761+
762+
if (req.url !== "/") {
763+
res.writeHead(404).end();
764+
return;
765+
}
766+
res.writeHead(401).end();
767+
});
768+
769+
await new Promise<void>(resolve => {
770+
server.listen(0, "127.0.0.1", () => {
771+
const addr = server.address() as AddressInfo;
772+
baseUrl = new URL(`http://127.0.0.1:${addr.port}`);
773+
resolve();
774+
});
775+
});
776+
777+
transport = new SSEClientTransport(baseUrl, {
778+
authProvider: mockAuthProvider,
779+
});
780+
781+
await expect(() => transport.start()).rejects.toThrow(InvalidClientError);
782+
expect(mockAuthProvider.invalidateCredentials).toHaveBeenCalledWith('all');
783+
});
784+
785+
it("invalidates all credentials on UnauthorizedClientError during token refresh", async () => {
786+
// Mock tokens() to return token with refresh token
787+
mockAuthProvider.tokens.mockResolvedValue({
788+
access_token: "expired-token",
789+
token_type: "Bearer",
790+
refresh_token: "refresh-token"
791+
});
792+
793+
// Create server that returns UnauthorizedClientError on token refresh
794+
await server.close();
795+
796+
server = createServer((req, res) => {
797+
lastServerRequest = req;
798+
799+
// Handle OAuth metadata discovery
800+
if (req.url === "/.well-known/oauth-authorization-server" && req.method === "GET") {
801+
res.writeHead(200, { 'Content-Type': 'application/json' });
802+
res.end(JSON.stringify({
803+
issuer: baseUrl.href,
804+
authorization_endpoint: `${baseUrl.href}authorize`,
805+
token_endpoint: `${baseUrl.href}token`,
806+
response_types_supported: ["code"],
807+
code_challenge_methods_supported: ["S256"],
808+
}));
809+
return;
810+
}
811+
812+
if (req.url === "/token" && req.method === "POST") {
813+
// Handle token refresh request - return UnauthorizedClientError
814+
const error = new UnauthorizedClientError("Client not authorized");
815+
res.writeHead(400, { 'Content-Type': 'application/json' })
816+
.end(JSON.stringify(error.toResponseObject()));
817+
return;
818+
}
819+
820+
if (req.url !== "/") {
821+
res.writeHead(404).end();
822+
return;
823+
}
824+
res.writeHead(401).end();
825+
});
826+
827+
await new Promise<void>(resolve => {
828+
server.listen(0, "127.0.0.1", () => {
829+
const addr = server.address() as AddressInfo;
830+
baseUrl = new URL(`http://127.0.0.1:${addr.port}`);
831+
resolve();
832+
});
833+
});
834+
835+
transport = new SSEClientTransport(baseUrl, {
836+
authProvider: mockAuthProvider,
837+
});
838+
839+
await expect(() => transport.start()).rejects.toThrow(UnauthorizedClientError);
840+
expect(mockAuthProvider.invalidateCredentials).toHaveBeenCalledWith('all');
841+
});
842+
843+
it("invalidates tokens on InvalidGrantError during token refresh", async () => {
844+
// Mock tokens() to return token with refresh token
845+
mockAuthProvider.tokens.mockResolvedValue({
846+
access_token: "expired-token",
847+
token_type: "Bearer",
848+
refresh_token: "refresh-token"
849+
});
850+
851+
// Create server that returns InvalidGrantError on token refresh
852+
await server.close();
853+
854+
server = createServer((req, res) => {
855+
lastServerRequest = req;
856+
857+
// Handle OAuth metadata discovery
858+
if (req.url === "/.well-known/oauth-authorization-server" && req.method === "GET") {
859+
res.writeHead(200, { 'Content-Type': 'application/json' });
860+
res.end(JSON.stringify({
861+
issuer: baseUrl.href,
862+
authorization_endpoint: `${baseUrl.href}authorize`,
863+
token_endpoint: `${baseUrl.href}token`,
864+
response_types_supported: ["code"],
865+
code_challenge_methods_supported: ["S256"],
866+
}));
867+
return;
868+
}
869+
870+
if (req.url === "/token" && req.method === "POST") {
871+
// Handle token refresh request - return InvalidGrantError
872+
const error = new InvalidGrantError("Invalid refresh token");
873+
res.writeHead(400, { 'Content-Type': 'application/json' })
874+
.end(JSON.stringify(error.toResponseObject()));
875+
return;
876+
}
877+
878+
if (req.url !== "/") {
879+
res.writeHead(404).end();
880+
return;
881+
}
882+
res.writeHead(401).end();
883+
});
884+
885+
await new Promise<void>(resolve => {
886+
server.listen(0, "127.0.0.1", () => {
887+
const addr = server.address() as AddressInfo;
888+
baseUrl = new URL(`http://127.0.0.1:${addr.port}`);
889+
resolve();
890+
});
891+
});
892+
893+
transport = new SSEClientTransport(baseUrl, {
894+
authProvider: mockAuthProvider,
895+
});
896+
897+
await expect(() => transport.start()).rejects.toThrow(InvalidGrantError);
898+
expect(mockAuthProvider.invalidateCredentials).toHaveBeenCalledWith('tokens');
899+
});
724900
});
725901
});

src/client/streamableHttp.test.ts

Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { StreamableHTTPClientTransport, StreamableHTTPReconnectionOptions } from "./streamableHttp.js";
22
import { OAuthClientProvider, UnauthorizedError } from "./auth.js";
33
import { JSONRPCMessage } from "../types.js";
4+
import { InvalidClientError, InvalidGrantError, UnauthorizedClientError } from "../server/auth/errors.js";
45

56

67
describe("StreamableHTTPClientTransport", () => {
@@ -17,6 +18,7 @@ describe("StreamableHTTPClientTransport", () => {
1718
redirectToAuthorization: jest.fn(),
1819
saveCodeVerifier: jest.fn(),
1920
codeVerifier: jest.fn(),
21+
invalidateCredentials: jest.fn(),
2022
};
2123
transport = new StreamableHTTPClientTransport(new URL("http://localhost:1234/mcp"), { authProvider: mockAuthProvider });
2224
jest.spyOn(global, "fetch");
@@ -532,4 +534,148 @@ describe("StreamableHTTPClientTransport", () => {
532534
await expect(transport.send(message)).rejects.toThrow(UnauthorizedError);
533535
expect(mockAuthProvider.redirectToAuthorization.mock.calls).toHaveLength(1);
534536
});
537+
538+
it("invalidates all credentials on InvalidClientError during auth", async () => {
539+
const message: JSONRPCMessage = {
540+
jsonrpc: "2.0",
541+
method: "test",
542+
params: {},
543+
id: "test-id"
544+
};
545+
546+
mockAuthProvider.tokens.mockResolvedValue({
547+
access_token: "test-token",
548+
token_type: "Bearer",
549+
refresh_token: "test-refresh"
550+
});
551+
552+
(global.fetch as jest.Mock)
553+
.mockResolvedValueOnce({
554+
ok: false,
555+
status: 401,
556+
statusText: "Unauthorized",
557+
headers: new Headers()
558+
})
559+
// OAuth metadata discovery
560+
.mockResolvedValueOnce({
561+
ok: true,
562+
status: 200,
563+
json: async () => ({
564+
issuer: "http://localhost:1234",
565+
authorization_endpoint: "http://localhost:1234/authorize",
566+
token_endpoint: "http://localhost:1234/token",
567+
response_types_supported: ["code"],
568+
code_challenge_methods_supported: ["S256"],
569+
}),
570+
})
571+
// Token refresh fails with InvalidClientError
572+
.mockResolvedValueOnce(Response.json(
573+
new InvalidClientError("Client authentication failed").toResponseObject(),
574+
{ status: 400 }
575+
))
576+
// Fallback should fail to complete the flow
577+
.mockResolvedValue({
578+
ok: false,
579+
status: 404
580+
});
581+
582+
await expect(transport.send(message)).rejects.toThrow(UnauthorizedError);
583+
expect(mockAuthProvider.invalidateCredentials).toHaveBeenCalledWith('all');
584+
});
585+
586+
it("invalidates all credentials on UnauthorizedClientError during auth", async () => {
587+
const message: JSONRPCMessage = {
588+
jsonrpc: "2.0",
589+
method: "test",
590+
params: {},
591+
id: "test-id"
592+
};
593+
594+
mockAuthProvider.tokens.mockResolvedValue({
595+
access_token: "test-token",
596+
token_type: "Bearer",
597+
refresh_token: "test-refresh"
598+
});
599+
600+
(global.fetch as jest.Mock)
601+
.mockResolvedValueOnce({
602+
ok: false,
603+
status: 401,
604+
statusText: "Unauthorized",
605+
headers: new Headers()
606+
})
607+
// OAuth metadata discovery
608+
.mockResolvedValueOnce({
609+
ok: true,
610+
status: 200,
611+
json: async () => ({
612+
issuer: "http://localhost:1234",
613+
authorization_endpoint: "http://localhost:1234/authorize",
614+
token_endpoint: "http://localhost:1234/token",
615+
response_types_supported: ["code"],
616+
code_challenge_methods_supported: ["S256"],
617+
}),
618+
})
619+
// Token refresh fails with UnauthorizedClientError
620+
.mockResolvedValueOnce(Response.json(
621+
new UnauthorizedClientError("Client not authorized").toResponseObject(),
622+
{ status: 400 }
623+
))
624+
// Fallback should fail to complete the flow
625+
.mockResolvedValue({
626+
ok: false,
627+
status: 404
628+
});
629+
630+
await expect(transport.send(message)).rejects.toThrow(UnauthorizedError);
631+
expect(mockAuthProvider.invalidateCredentials).toHaveBeenCalledWith('all');
632+
});
633+
634+
it("invalidates tokens on InvalidGrantError during auth", async () => {
635+
const message: JSONRPCMessage = {
636+
jsonrpc: "2.0",
637+
method: "test",
638+
params: {},
639+
id: "test-id"
640+
};
641+
642+
mockAuthProvider.tokens.mockResolvedValue({
643+
access_token: "test-token",
644+
token_type: "Bearer",
645+
refresh_token: "test-refresh"
646+
});
647+
648+
(global.fetch as jest.Mock)
649+
.mockResolvedValueOnce({
650+
ok: false,
651+
status: 401,
652+
statusText: "Unauthorized",
653+
headers: new Headers()
654+
})
655+
// OAuth metadata discovery
656+
.mockResolvedValueOnce({
657+
ok: true,
658+
status: 200,
659+
json: async () => ({
660+
issuer: "http://localhost:1234",
661+
authorization_endpoint: "http://localhost:1234/authorize",
662+
token_endpoint: "http://localhost:1234/token",
663+
response_types_supported: ["code"],
664+
code_challenge_methods_supported: ["S256"],
665+
}),
666+
})
667+
// Token refresh fails with InvalidGrantError
668+
.mockResolvedValueOnce(Response.json(
669+
new InvalidGrantError("Invalid refresh token").toResponseObject(),
670+
{ status: 400 }
671+
))
672+
// Fallback should fail to complete the flow
673+
.mockResolvedValue({
674+
ok: false,
675+
status: 404
676+
});
677+
678+
await expect(transport.send(message)).rejects.toThrow(UnauthorizedError);
679+
expect(mockAuthProvider.invalidateCredentials).toHaveBeenCalledWith('tokens');
680+
});
535681
});

0 commit comments

Comments
 (0)