mirror of
https://github.com/supermemoryai/supermemory.git
synced 2026-08-28 05:25:33 +00:00
End-to-end tests that drive the deployed Supermemory MCP over streamable HTTP (no mocks): - discovery: handshake + tool/resource/prompt listing - identity: whoAmI, listProjects - memory: save -> recall round-trip, profile variants, forget, container scoping - graph/resources/prompts: memory-graph, fetch-graph-data, profile/projects, context - oauth: protected-resource discovery chain, dynamic client registration, token-endpoint negatives, and a real refresh -> access token round-trip - auth: GET / info, 401 on missing/invalid token - root-scope: x-sm-project scoping behavior Tests skip without SUPERMEMORY_API_KEY (OAuth tier-D skips without its token env vars) so CI is safe without secrets. Adds vitest + a test:e2e script and documents running + the OAuth flow in apps/mcp/README.md.
126 lines
4.1 KiB
TypeScript
126 lines
4.1 KiB
TypeScript
import { afterAll, beforeAll, describe, expect, it } from "vitest"
|
||
import {
|
||
authServerMetadata,
|
||
type AuthServerMetadata,
|
||
callTool,
|
||
connect,
|
||
exchangeRefreshToken,
|
||
OAUTH_CLIENT_ID,
|
||
OAUTH_REFRESH_TOKEN,
|
||
registerClient,
|
||
type Session,
|
||
textOf,
|
||
} from "./helpers"
|
||
|
||
// Tiers A–C exercise the real OAuth protocol wiring with no secrets and no browser.
|
||
describe("MCP — OAuth protocol (no secrets)", () => {
|
||
let meta: AuthServerMetadata
|
||
|
||
beforeAll(async () => {
|
||
meta = (await authServerMetadata()).metadata
|
||
})
|
||
|
||
// Tier A — the discovery chain a client walks from a 401 to the auth server.
|
||
it("discovers the authorization server from protected-resource metadata", () => {
|
||
expect(meta.authorization_endpoint).toMatch(/\/authorize$/)
|
||
expect(meta.token_endpoint).toMatch(/\/token$/)
|
||
expect(meta.registration_endpoint).toMatch(/\/register$/)
|
||
})
|
||
|
||
it("advertises PKCE S256 and the authorization_code + refresh_token grants", () => {
|
||
expect(meta.code_challenge_methods_supported).toContain("S256")
|
||
expect(meta.response_types_supported).toContain("code")
|
||
expect(meta.grant_types_supported).toContain("authorization_code")
|
||
expect(meta.grant_types_supported).toContain("refresh_token")
|
||
})
|
||
|
||
// Tier B — Dynamic Client Registration, the first authenticated-flow step.
|
||
it("issues a client_id via dynamic client registration", async () => {
|
||
const { status, body } = await registerClient(meta.registration_endpoint)
|
||
expect(status).toBe(201)
|
||
expect(body.client_id).toBeTruthy()
|
||
expect(body.grant_types).toContain("refresh_token")
|
||
})
|
||
|
||
// Tier C — token endpoint rejects forged grants with proper OAuth errors.
|
||
it("rejects a bogus refresh_token with invalid_grant", async () => {
|
||
const { status, body } = await exchangeRefreshToken(
|
||
meta.token_endpoint,
|
||
"bogus_rt_for_e2e",
|
||
"bogus_client",
|
||
)
|
||
expect(status).toBe(401)
|
||
expect(body.error).toBe("invalid_grant")
|
||
})
|
||
|
||
it("rejects a bogus authorization code with invalid_grant", async () => {
|
||
const res = await fetch(meta.token_endpoint, {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||
body: new URLSearchParams({
|
||
grant_type: "authorization_code",
|
||
code: "bogus_code",
|
||
client_id: "bogus",
|
||
code_verifier: "abc",
|
||
redirect_uri: "http://localhost:8765/callback",
|
||
}),
|
||
})
|
||
expect(res.status).toBe(401)
|
||
expect(((await res.json()) as { error?: string }).error).toBe(
|
||
"invalid_grant",
|
||
)
|
||
})
|
||
|
||
it("redirects an unauthenticated authorize request to login", async () => {
|
||
const url = new URL(meta.authorization_endpoint)
|
||
url.search = new URLSearchParams({
|
||
response_type: "code",
|
||
client_id: "any",
|
||
redirect_uri: "http://localhost:8765/callback",
|
||
code_challenge: "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM",
|
||
code_challenge_method: "S256",
|
||
scope: "openid profile email offline_access",
|
||
state: "xyz",
|
||
}).toString()
|
||
const res = await fetch(url, { redirect: "manual" })
|
||
expect(res.status).toBe(302)
|
||
expect(res.headers.get("location")).toMatch(/\/login/)
|
||
})
|
||
})
|
||
|
||
// Tier D — real OAuth token through /mcp, exercising validateOAuthToken (not the sm_ branch); needs a seeded refresh token.
|
||
describe.skipIf(!OAUTH_REFRESH_TOKEN || !OAUTH_CLIENT_ID)(
|
||
"MCP — real OAuth token round-trip",
|
||
() => {
|
||
let s: Session
|
||
let accessToken: string
|
||
|
||
beforeAll(async () => {
|
||
const { metadata } = await authServerMetadata()
|
||
const { status, body } = await exchangeRefreshToken(
|
||
metadata.token_endpoint,
|
||
OAUTH_REFRESH_TOKEN as string,
|
||
OAUTH_CLIENT_ID as string,
|
||
)
|
||
expect(status, `refresh exchange failed: ${JSON.stringify(body)}`).toBe(
|
||
200,
|
||
)
|
||
expect(body.access_token).toBeTruthy()
|
||
accessToken = body.access_token as string
|
||
})
|
||
afterAll(async () => {
|
||
await s?.close()
|
||
})
|
||
|
||
it("mints an OAuth access token that is not an sm_ API key", () => {
|
||
expect(accessToken.startsWith("sm_")).toBe(false)
|
||
})
|
||
|
||
it("connects to /mcp with the OAuth token and resolves identity", async () => {
|
||
s = await connect({ token: accessToken })
|
||
const res = await callTool(s.client, "whoAmI")
|
||
expect(res.isError).toBeFalsy()
|
||
expect(JSON.parse(textOf(res)).userId).toBeTruthy()
|
||
})
|
||
},
|
||
)
|