mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 13:52:20 +00:00
test(01-01): specify transactional refresh rotation
- require one transaction for rotation and successor insertion - pin CAS-loss revocation and insertion rollback semantics
This commit is contained in:
1 parent
3f4afb26a4
commit
38501f6574
2 files changed
+172
-4
No files matched your search
@@ -7,6 +7,20 @@ import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
process.env.API_TOKEN_HMAC_KEY = "x".repeat(48);
|
||||
process.env.APP_URL = "https://health.example";
|
||||
|
||||
const { transactionClient, transaction } = vi.hoisted(() => {
|
||||
const transactionClient = { marker: "mcp-refresh-transaction" };
|
||||
return {
|
||||
transactionClient,
|
||||
transaction: vi.fn(
|
||||
async (callback: (tx: typeof transactionClient) => unknown) =>
|
||||
callback(transactionClient),
|
||||
),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: { $transaction: transaction },
|
||||
}));
|
||||
vi.mock("@/lib/logging/background", () => ({
|
||||
withBackgroundEvent: (_n: string, fn: () => unknown) => fn(),
|
||||
}));
|
||||
@@ -51,7 +65,7 @@ import {
|
||||
createConnection,
|
||||
rotateConnection,
|
||||
} from "@/lib/mcp/oauth/connections";
|
||||
import { signArtifact } from "@/lib/mcp/oauth/artifacts";
|
||||
import { signArtifact, verifyArtifact } from "@/lib/mcp/oauth/artifacts";
|
||||
import { s256Challenge } from "@/lib/mcp/oauth/pkce";
|
||||
|
||||
const CLIENT_ID = "https://app.example/client.json";
|
||||
@@ -95,6 +109,10 @@ beforeEach(() => {
|
||||
vi.mocked(isApiGloballyEnabled).mockResolvedValue(true);
|
||||
vi.mocked(createConnection).mockResolvedValue("conn-1");
|
||||
vi.mocked(rotateConnection).mockResolvedValue({ ok: true });
|
||||
transaction.mockImplementation(
|
||||
async (callback: (tx: typeof transactionClient) => unknown) =>
|
||||
callback(transactionClient),
|
||||
);
|
||||
});
|
||||
|
||||
function refreshToken(overrides: Record<string, unknown> = {}): string {
|
||||
@@ -217,10 +235,11 @@ describe("authorization_code grant", () => {
|
||||
|
||||
describe("refresh_token grant", () => {
|
||||
it("rotates: issues a new access + refresh pair against a live connection", async () => {
|
||||
const presented = refreshToken({ jti: "presented-jti" });
|
||||
const res = await POST(
|
||||
tokenReq({
|
||||
grant_type: "refresh_token",
|
||||
refresh_token: refreshToken(),
|
||||
refresh_token: presented,
|
||||
client_id: CLIENT_ID,
|
||||
}) as never,
|
||||
);
|
||||
@@ -228,9 +247,86 @@ describe("refresh_token grant", () => {
|
||||
const body = await res.json();
|
||||
expect(body.access_token).toMatch(/^hlk_/);
|
||||
expect(body.refresh_token).toMatch(/^hlrt_/);
|
||||
expect(rotateConnection).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ connectionId: "conn-1", clientId: CLIENT_ID }),
|
||||
expect(vi.mocked(rotateConnection).mock.calls[0]?.[0]).toEqual(
|
||||
expect.objectContaining({
|
||||
connectionId: "conn-1",
|
||||
clientId: CLIENT_ID,
|
||||
presentedJti: "presented-jti",
|
||||
}),
|
||||
);
|
||||
|
||||
const successor = verifyArtifact<{
|
||||
cid: string;
|
||||
jti: string;
|
||||
sub: string;
|
||||
}>("refreshToken", body.refresh_token);
|
||||
expect(successor.ok).toBe(true);
|
||||
if (successor.ok) {
|
||||
expect(successor.claims.cid).toBe("conn-1");
|
||||
expect(successor.claims.sub).toBe("user-1");
|
||||
expect(successor.claims.jti).not.toBe("presented-jti");
|
||||
}
|
||||
});
|
||||
|
||||
it("rotates and inserts the successor access token inside one transaction", async () => {
|
||||
const res = await POST(
|
||||
tokenReq({
|
||||
grant_type: "refresh_token",
|
||||
refresh_token: refreshToken({ jti: "transactional-jti" }),
|
||||
client_id: CLIENT_ID,
|
||||
}) as never,
|
||||
);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(transaction).toHaveBeenCalledTimes(1);
|
||||
expect(rotateConnection).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ presentedJti: "transactional-jti" }),
|
||||
transactionClient,
|
||||
);
|
||||
expect(issueApiToken).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
userId: "user-1",
|
||||
mcpConnectionId: "conn-1",
|
||||
}),
|
||||
transactionClient,
|
||||
);
|
||||
});
|
||||
|
||||
it("rolls back JTI advancement and access revocation when successor insertion fails", async () => {
|
||||
const state = { currentJti: "rollback-jti", accessRevoked: false };
|
||||
vi.mocked(rotateConnection).mockImplementationOnce(async (args) => {
|
||||
state.currentJti = args.newJti;
|
||||
state.accessRevoked = true;
|
||||
return { ok: true };
|
||||
});
|
||||
vi.mocked(issueApiToken).mockRejectedValueOnce(
|
||||
new Error("injected successor insertion failure"),
|
||||
);
|
||||
transaction.mockImplementationOnce(async (callback) => {
|
||||
const snapshot = { ...state };
|
||||
try {
|
||||
return await callback(transactionClient);
|
||||
} catch (error) {
|
||||
Object.assign(state, snapshot);
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
|
||||
await expect(
|
||||
POST(
|
||||
tokenReq({
|
||||
grant_type: "refresh_token",
|
||||
refresh_token: refreshToken({ jti: "rollback-jti" }),
|
||||
client_id: CLIENT_ID,
|
||||
}) as never,
|
||||
),
|
||||
).rejects.toThrow("injected successor insertion failure");
|
||||
|
||||
expect(transaction).toHaveBeenCalledTimes(1);
|
||||
expect(state).toEqual({
|
||||
currentJti: "rollback-jti",
|
||||
accessRevoked: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects a refresh whose connection was revoked (H2 — settings revoke)", async () => {
|
||||
|
||||
@@ -37,6 +37,18 @@ const CONN = {
|
||||
revokedAt: null as Date | null,
|
||||
};
|
||||
|
||||
const TX = {
|
||||
mcpOAuthConnection: {
|
||||
create: vi.fn(),
|
||||
findUnique: vi.fn(),
|
||||
updateMany: vi.fn(),
|
||||
findMany: vi.fn(),
|
||||
},
|
||||
apiToken: {
|
||||
updateMany: vi.fn(),
|
||||
},
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(prisma.apiToken.updateMany).mockResolvedValue({
|
||||
@@ -45,6 +57,8 @@ beforeEach(() => {
|
||||
vi.mocked(prisma.mcpOAuthConnection.updateMany).mockResolvedValue({
|
||||
count: 1,
|
||||
} as never);
|
||||
TX.mcpOAuthConnection.updateMany.mockResolvedValue({ count: 1 });
|
||||
TX.apiToken.updateMany.mockResolvedValue({ count: 0 });
|
||||
});
|
||||
|
||||
describe("createConnection", () => {
|
||||
@@ -96,6 +110,33 @@ describe("rotateConnection — happy path", () => {
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("uses the supplied transaction client for the entire rotation", async () => {
|
||||
TX.mcpOAuthConnection.findUnique.mockResolvedValue({ ...CONN });
|
||||
|
||||
const out = await rotateConnection(
|
||||
{
|
||||
connectionId: "conn-1",
|
||||
presentedJti: "jti-current",
|
||||
newJti: "jti-next",
|
||||
clientId: CONN.clientId,
|
||||
userId: "user-1",
|
||||
},
|
||||
TX as never,
|
||||
);
|
||||
|
||||
expect(out.ok).toBe(true);
|
||||
expect(TX.mcpOAuthConnection.findUnique).toHaveBeenCalledTimes(1);
|
||||
expect(TX.mcpOAuthConnection.updateMany).toHaveBeenCalledTimes(1);
|
||||
expect(TX.apiToken.updateMany).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: { mcpConnectionId: "conn-1", revoked: false },
|
||||
}),
|
||||
);
|
||||
expect(prisma.mcpOAuthConnection.findUnique).not.toHaveBeenCalled();
|
||||
expect(prisma.mcpOAuthConnection.updateMany).not.toHaveBeenCalled();
|
||||
expect(prisma.apiToken.updateMany).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("rotateConnection — revoked connection stops refresh", () => {
|
||||
@@ -158,6 +199,37 @@ describe("rotateConnection — reuse detection revokes the family", () => {
|
||||
});
|
||||
expect(out).toEqual({ ok: false, reason: "not_found" });
|
||||
});
|
||||
|
||||
it("revokes the family and linked access tokens when the CAS contender loses", async () => {
|
||||
vi.mocked(prisma.mcpOAuthConnection.findUnique).mockResolvedValue({
|
||||
...CONN,
|
||||
} as never);
|
||||
vi.mocked(prisma.mcpOAuthConnection.updateMany)
|
||||
.mockResolvedValueOnce({ count: 0 } as never)
|
||||
.mockResolvedValueOnce({ count: 1 } as never);
|
||||
|
||||
const out = await rotateConnection({
|
||||
connectionId: "conn-1",
|
||||
presentedJti: "jti-current",
|
||||
newJti: "jti-loser",
|
||||
clientId: CONN.clientId,
|
||||
userId: "user-1",
|
||||
});
|
||||
|
||||
expect(out).toEqual({ ok: false, reason: "reuse_detected" });
|
||||
expect(prisma.mcpOAuthConnection.updateMany).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({
|
||||
where: { id: "conn-1", revokedAt: null },
|
||||
data: expect.objectContaining({ revokedAt: expect.any(Date) }),
|
||||
}),
|
||||
);
|
||||
expect(prisma.apiToken.updateMany).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: { mcpConnectionId: "conn-1", revoked: false },
|
||||
data: { revoked: true },
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("revokeConnectionForUser — settings revoke kills the chain", () => {
|
||||
|
||||
Reference in new issue
Block a user