|
1 | 1 | import { afterEach, describe, expect, test } from "bun:test"; |
2 | | -import { withMockedModule } from "../../tests/helpers/mock-module.js"; |
3 | | - |
4 | | -// getValidCodexToken/getValidXaiToken hit the real home-level auth store and |
5 | | -// refresh endpoints; stub the session layer so this test only exercises the |
6 | | -// scope probe's own HTTP call and status classification. Other suites |
7 | | -// (tests/unit/codex-session.test.ts) import the real modules directly, so the |
8 | | -// mocks must be torn down after this file's tests run rather than leaking |
9 | | -// into the rest of the bun test process. |
10 | | -await withMockedModule( |
11 | | - import.meta.resolve("./codex/session.js"), |
12 | | - (real: typeof import("./codex/session.js")) => ({ |
13 | | - ...real, |
14 | | - getValidCodexToken: async () => ({ access: "codex-token", accountId: "acct-1" }), |
15 | | - }), |
16 | | -); |
17 | | -await withMockedModule( |
18 | | - import.meta.resolve("./xai/session.js"), |
19 | | - (real: typeof import("./xai/session.js")) => ({ |
20 | | - ...real, |
21 | | - getValidXaiToken: async () => ({ access: "xai-token" }), |
22 | | - }), |
23 | | -); |
24 | | - |
25 | | -const { checkOAuthProviderScope } = await import("./oauth-scope-check.js"); |
| 2 | + |
| 3 | +import { checkOAuthProviderScope } from "./oauth-scope-check.js"; |
26 | 4 |
|
27 | 5 | const originalFetch = global.fetch; |
| 6 | +const codexTokens = { |
| 7 | + access: "staged-codex-token", |
| 8 | + refresh: "codex-refresh", |
| 9 | + expiresAt: Date.now() + 3_600_000, |
| 10 | + accountId: "acct-staged", |
| 11 | +}; |
| 12 | +const xaiTokens = { |
| 13 | + access: "staged-xai-token", |
| 14 | + refresh: "xai-refresh", |
| 15 | + expiresAt: Date.now() + 3_600_000, |
| 16 | +}; |
28 | 17 |
|
29 | | -function stubFetch(impl: (url: string) => Response | Promise<Response>): void { |
30 | | - global.fetch = (async (input: RequestInfo | URL) => impl(String(input))) as typeof fetch; |
| 18 | +function stubFetch(impl: (url: string, init?: RequestInit) => Response | Promise<Response>): void { |
| 19 | + global.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => |
| 20 | + impl(String(input), init)) as typeof fetch; |
31 | 21 | } |
32 | 22 |
|
33 | 23 | describe("checkOAuthProviderScope", () => { |
34 | 24 | afterEach(() => { |
35 | 25 | global.fetch = originalFetch; |
36 | 26 | }); |
37 | 27 |
|
38 | | - test("codex: ok when the catalog call succeeds", async () => { |
39 | | - stubFetch(() => new Response(JSON.stringify({ models: ["gpt-5"] }), { status: 200 })); |
40 | | - const result = await checkOAuthProviderScope("codex", "work"); |
| 28 | + test("codex: builds the probe from staged tokens", async () => { |
| 29 | + stubFetch((_url, init) => { |
| 30 | + expect(init?.headers).toMatchObject({ |
| 31 | + authorization: "Bearer staged-codex-token", |
| 32 | + "chatgpt-account-id": "acct-staged", |
| 33 | + }); |
| 34 | + return new Response(JSON.stringify({ models: ["gpt-5"] }), { status: 200 }); |
| 35 | + }); |
| 36 | + const result = await checkOAuthProviderScope("codex", codexTokens); |
| 37 | + expect(result.status).toBe("ok"); |
| 38 | + }); |
| 39 | + |
| 40 | + test("codex: refreshes expired staged tokens before classifying the probe", async () => { |
| 41 | + const expired = { ...codexTokens, expiresAt: 0 }; |
| 42 | + const requests: string[] = []; |
| 43 | + stubFetch((url, init) => { |
| 44 | + requests.push(url); |
| 45 | + if (url.includes("/oauth/token")) { |
| 46 | + return new Response(JSON.stringify({ access_token: "refreshed-codex", expires_in: 3600 }), { |
| 47 | + status: 200, |
| 48 | + headers: { "content-type": "application/json" }, |
| 49 | + }); |
| 50 | + } |
| 51 | + expect(init?.headers).toMatchObject({ |
| 52 | + authorization: "Bearer refreshed-codex", |
| 53 | + "chatgpt-account-id": "acct-staged", |
| 54 | + }); |
| 55 | + return new Response(JSON.stringify({ models: ["gpt-5"] }), { status: 200 }); |
| 56 | + }); |
| 57 | + |
| 58 | + const result = await checkOAuthProviderScope("codex", expired); |
| 59 | + |
41 | 60 | expect(result.status).toBe("ok"); |
| 61 | + expect(requests).toHaveLength(2); |
| 62 | + expect(expired.access).toBe("refreshed-codex"); |
| 63 | + }); |
| 64 | + |
| 65 | + test("codex: blocks a definitive staged refresh rejection", async () => { |
| 66 | + const expired = { ...codexTokens, expiresAt: 0 }; |
| 67 | + stubFetch(() => new Response(JSON.stringify({ error: "invalid_grant" }), { status: 400 })); |
| 68 | + |
| 69 | + const result = await checkOAuthProviderScope("codex", expired); |
| 70 | + |
| 71 | + expect(result.status).toBe("blocked"); |
| 72 | + if (result.status === "blocked") { |
| 73 | + expect(result.message).toMatch(/expired|revoked/i); |
| 74 | + } |
| 75 | + }); |
| 76 | + |
| 77 | + test("codex: reports a transient staged refresh failure as unavailable", async () => { |
| 78 | + const expired = { ...codexTokens, expiresAt: 0 }; |
| 79 | + stubFetch(() => { |
| 80 | + throw new Error("network down"); |
| 81 | + }); |
| 82 | + |
| 83 | + const result = await checkOAuthProviderScope("codex", expired); |
| 84 | + |
| 85 | + expect(result.status).toBe("unavailable"); |
42 | 86 | }); |
43 | 87 |
|
44 | | - test("codex: insufficient-scope on a definitive 403", async () => { |
| 88 | + test("codex: blocks a definitive 403 without surfacing the raw body", async () => { |
45 | 89 | stubFetch(() => new Response("forbidden", { status: 403 })); |
46 | | - const result = await checkOAuthProviderScope("codex", "work"); |
47 | | - expect(result.status).toBe("insufficient-scope"); |
48 | | - if (result.status === "insufficient-scope") { |
| 90 | + const result = await checkOAuthProviderScope("codex", codexTokens); |
| 91 | + expect(result.status).toBe("blocked"); |
| 92 | + if (result.status === "blocked") { |
49 | 93 | expect(result.message).toMatch(/reconnect/i); |
50 | | - // Must never surface the raw response body. |
51 | 94 | expect(result.message).not.toContain("forbidden"); |
52 | 95 | } |
53 | 96 | }); |
54 | 97 |
|
55 | | - test("codex: insufficient-scope on a definitive 401", async () => { |
| 98 | + test("codex: blocks a definitive 401", async () => { |
56 | 99 | stubFetch(() => new Response("nope", { status: 401 })); |
57 | | - const result = await checkOAuthProviderScope("codex", "work"); |
58 | | - expect(result.status).toBe("insufficient-scope"); |
| 100 | + const result = await checkOAuthProviderScope("codex", codexTokens); |
| 101 | + expect(result.status).toBe("blocked"); |
59 | 102 | }); |
60 | 103 |
|
61 | 104 | test("codex: unavailable on a network failure, not blocked", async () => { |
62 | 105 | stubFetch(() => { |
63 | 106 | throw new Error("fetch failed"); |
64 | 107 | }); |
65 | | - const result = await checkOAuthProviderScope("codex", "work"); |
| 108 | + const result = await checkOAuthProviderScope("codex", codexTokens); |
66 | 109 | expect(result.status).toBe("unavailable"); |
67 | 110 | }); |
68 | 111 |
|
69 | 112 | test("codex: unavailable (not scope failure) on a 500", async () => { |
70 | 113 | stubFetch(() => new Response("boom", { status: 500 })); |
71 | | - const result = await checkOAuthProviderScope("codex", "work"); |
| 114 | + const result = await checkOAuthProviderScope("codex", codexTokens); |
72 | 115 | expect(result.status).toBe("unavailable"); |
73 | 116 | }); |
74 | 117 |
|
75 | | - test("xai: ok when the models call succeeds", async () => { |
76 | | - stubFetch(() => new Response(JSON.stringify({ data: [] }), { status: 200 })); |
77 | | - const result = await checkOAuthProviderScope("xai", "personal"); |
| 118 | + test("xai: builds the probe from staged tokens", async () => { |
| 119 | + stubFetch((_url, init) => { |
| 120 | + expect(init?.headers).toMatchObject({ authorization: "Bearer staged-xai-token" }); |
| 121 | + return new Response(JSON.stringify({ data: [] }), { status: 200 }); |
| 122 | + }); |
| 123 | + const result = await checkOAuthProviderScope("xai", xaiTokens); |
| 124 | + expect(result.status).toBe("ok"); |
| 125 | + }); |
| 126 | + |
| 127 | + test("xai: refreshes expired staged tokens before classifying the probe", async () => { |
| 128 | + const expired = { ...xaiTokens, expiresAt: 0 }; |
| 129 | + const requests: string[] = []; |
| 130 | + stubFetch((url, init) => { |
| 131 | + requests.push(url); |
| 132 | + if (url.includes("/oauth2/token")) { |
| 133 | + return new Response(JSON.stringify({ access_token: "refreshed-xai", expires_in: 3600 }), { |
| 134 | + status: 200, |
| 135 | + headers: { "content-type": "application/json" }, |
| 136 | + }); |
| 137 | + } |
| 138 | + expect(init?.headers).toMatchObject({ authorization: "Bearer refreshed-xai" }); |
| 139 | + return new Response(JSON.stringify({ data: [] }), { status: 200 }); |
| 140 | + }); |
| 141 | + |
| 142 | + const result = await checkOAuthProviderScope("xai", expired); |
| 143 | + |
78 | 144 | expect(result.status).toBe("ok"); |
| 145 | + expect(requests).toHaveLength(2); |
| 146 | + expect(expired.access).toBe("refreshed-xai"); |
| 147 | + }); |
| 148 | + |
| 149 | + test("xai: blocks a definitive staged refresh rejection", async () => { |
| 150 | + const expired = { ...xaiTokens, expiresAt: 0 }; |
| 151 | + stubFetch(() => new Response(JSON.stringify({ error: "revoked" }), { status: 401 })); |
| 152 | + |
| 153 | + const result = await checkOAuthProviderScope("xai", expired); |
| 154 | + |
| 155 | + expect(result.status).toBe("blocked"); |
| 156 | + if (result.status === "blocked") { |
| 157 | + expect(result.message).toMatch(/expired|revoked/i); |
| 158 | + } |
| 159 | + }); |
| 160 | + |
| 161 | + test("xai: reports a transient staged refresh failure as unavailable", async () => { |
| 162 | + const expired = { ...xaiTokens, expiresAt: 0 }; |
| 163 | + stubFetch(() => { |
| 164 | + throw new DOMException("The operation timed out.", "TimeoutError"); |
| 165 | + }); |
| 166 | + |
| 167 | + const result = await checkOAuthProviderScope("xai", expired); |
| 168 | + |
| 169 | + expect(result.status).toBe("unavailable"); |
79 | 170 | }); |
80 | 171 |
|
81 | | - test("xai: insufficient-scope on a definitive 403", async () => { |
| 172 | + test("xai: blocks a definitive 403", async () => { |
82 | 173 | stubFetch(() => new Response("forbidden", { status: 403 })); |
83 | | - const result = await checkOAuthProviderScope("xai", "personal"); |
84 | | - expect(result.status).toBe("insufficient-scope"); |
| 174 | + const result = await checkOAuthProviderScope("xai", xaiTokens); |
| 175 | + expect(result.status).toBe("blocked"); |
85 | 176 | }); |
86 | 177 |
|
87 | 178 | test("xai: unavailable on a timeout-style abort", async () => { |
88 | 179 | stubFetch(() => { |
89 | 180 | throw new DOMException("The operation timed out.", "TimeoutError"); |
90 | 181 | }); |
91 | | - const result = await checkOAuthProviderScope("xai", "personal"); |
| 182 | + const result = await checkOAuthProviderScope("xai", xaiTokens); |
92 | 183 | expect(result.status).toBe("unavailable"); |
93 | 184 | }); |
94 | 185 | }); |
0 commit comments