Showing the changes to e2e (2 of 13 files). Show all
Unified · Split
| @@ -62,12 +62,45 @@ const githubShapedIntegrationSpec = { · 61 unmodified lines | |||
| 62 | ], | 62 | ], |
| 63 | } as const; | 63 | } as const; |
| 64 | 64 | ||
| 65 | const googleShapedIntegrationSpec = (scopes: readonly string[]) => ({ | ||
| 66 | spec: { | ||
| 67 | kind: "blob" as const, | ||
| 68 | value: JSON.stringify({ | ||
| 69 | openapi: "3.0.3", | ||
| 70 | info: { title: "Google-shaped API", version: "1.0.0" }, | ||
| 71 | paths: { | ||
| 72 | "/resource": { | ||
| 73 | get: { | ||
| 74 | operationId: "getResource", | ||
| 75 | tags: ["default"], | ||
| 76 | responses: { "200": { description: "a Google resource" } }, | ||
| 77 | }, | ||
| 78 | }, | ||
| 79 | }, | ||
| 80 | }), | ||
| 81 | }, | ||
| 82 | baseUrl: "https://www.googleapis.com", | ||
| 83 | authenticationTemplate: [ | ||
| 84 | { | ||
| 85 | slug: "oauth", | ||
| 86 | kind: "oauth2" as const, | ||
| 87 | authorizationUrl: "https://accounts.google.com/o/oauth2/v2/auth", | ||
| 88 | tokenUrl: "https://oauth2.googleapis.com/token", | ||
| 89 | scopes, | ||
| 90 | }, | ||
| 91 | ], | ||
| 92 | }); | ||
| 93 | |||
| 65 | scenario( | 94 | scenario( |
| 66 | "First-party OAuth · the host-declared GitHub app is listed and drives the authorize redirect", | 95 | "First-party OAuth · the host-declared GitHub app is listed and drives the authorize redirect", |
| 67 | {}, | 96 | {}, |
| 68 | Effect.scoped( | 97 | Effect.scoped( |
| 69 | Effect.gen(function* () { | 98 | Effect.gen(function* () { |
| 70 | const target = yield* Target; | 99 | const target = yield* Target; |
| 100 | // First-party registrations are a cloud-host capability. This scenario | ||
| 101 | // intentionally does not apply to self-host, whose operator supplies | ||
| 102 | // their own OAuth apps through its existing registration flow. | ||
| 103 | if (target.name !== "cloud") return; | ||
| 71 | const { client: makeApiClient } = yield* Api; | 104 | const { client: makeApiClient } = yield* Api; |
| 72 | const identity = yield* target.newIdentity(); | 105 | const identity = yield* target.newIdentity(); |
| 73 | const client = yield* makeApiClient(api, identity); | 106 | const client = yield* makeApiClient(api, identity); |
| @@ -133,3 +166,87 @@ scenario( · 59 unmodified lines | |||
| 133 | }), | 166 | }), |
| 134 | ), | 167 | ), |
| 135 | ); | 168 | ); |
| 169 | |||
| 170 | scenario( | ||
| 171 | "First-party OAuth · Google offers Calendar and Sheets but refuses Gmail scopes", | ||
| 172 | {}, | ||
| 173 | Effect.scoped( | ||
| 174 | Effect.gen(function* () { | ||
| 175 | const target = yield* Target; | ||
| 176 | if (target.name !== "cloud") return; | ||
| 177 | const { client: makeApiClient } = yield* Api; | ||
| 178 | const identity = yield* target.newIdentity(); | ||
| 179 | const client = yield* makeApiClient(api, identity); | ||
| 180 | |||
| 181 | const clients = yield* client.oauth.listClients(); | ||
| 182 | const google = clients.find((candidate) => String(candidate.slug) === "first-party:google"); | ||
| 183 | expect(google, "the env-declared first-party Google app is listed").toBeDefined(); | ||
| 184 | expect(google?.origin.kind).toBe("first_party"); | ||
| 185 | if (google?.origin.kind !== "first_party") return; | ||
| 186 | expect(google.origin.allowedScopes).toContain("https://www.googleapis.com/auth/calendar"); | ||
| 187 | expect(google.origin.allowedScopes).toContain("https://www.googleapis.com/auth/spreadsheets"); | ||
| 188 | expect(google.origin.allowedScopes).not.toContain("https://mail.google.com/"); | ||
| 189 | expect(google.origin.allowedScopes).not.toContain("https://www.googleapis.com/auth/drive"); | ||
| 190 | |||
| 191 | const calendar = IntegrationSlug.make(unique("google_calendar")); | ||
| 192 | yield* client.openapi.addSpec({ | ||
| 193 | payload: { | ||
| 194 | ...googleShapedIntegrationSpec([ | ||
| 195 | "openid", | ||
| 196 | "email", | ||
| 197 | "profile", | ||
| 198 | "https://www.googleapis.com/auth/calendar", | ||
| 199 | ]), | ||
| 200 | slug: calendar, | ||
| 201 | }, | ||
| 202 | }); | ||
| 203 | const started = yield* client.oauth.start({ | ||
| 204 | payload: { | ||
| 205 | client: OAuthClientSlug.make("first-party:google"), | ||
| 206 | clientOwner: "org", | ||
| 207 | owner: "org", | ||
| 208 | name: ConnectionName.make("calendar"), | ||
| 209 | integration: calendar, | ||
| 210 | template: AuthTemplateSlug.make("oauth"), | ||
| 211 | }, | ||
| 212 | }); | ||
| 213 | expect(started.status).toBe("redirect"); | ||
| 214 | const authorizationUrl = started.status === "redirect" ? started.authorizationUrl : ""; | ||
| 215 | const authorize = new URL(authorizationUrl); | ||
| 216 | expect(authorize.origin + authorize.pathname).toBe( | ||
| 217 | "https://accounts.google.com/o/oauth2/v2/auth", | ||
| 218 | ); | ||
| 219 | expect(authorize.searchParams.get("client_id")).toBe("e2e-first-party-google"); | ||
| 220 | expect(authorize.searchParams.get("access_type")).toBe("offline"); | ||
| 221 | expect(new Set(authorize.searchParams.get("scope")?.split(" ") ?? [])).toEqual( | ||
| 222 | new Set(["openid", "email", "profile", "https://www.googleapis.com/auth/calendar"]), | ||
| 223 | ); | ||
| 224 | |||
| 225 | const gmail = IntegrationSlug.make(unique("google_gmail")); | ||
| 226 | yield* client.openapi.addSpec({ | ||
| 227 | payload: { | ||
| 228 | ...googleShapedIntegrationSpec([ | ||
| 229 | "openid", | ||
| 230 | "email", | ||
| 231 | "profile", | ||
| 232 | "https://mail.google.com/", | ||
| 233 | ]), | ||
| 234 | slug: gmail, | ||
| 235 | }, | ||
| 236 | }); | ||
| 237 | const blocked = yield* client.oauth | ||
| 238 | .start({ | ||
| 239 | payload: { | ||
| 240 | client: OAuthClientSlug.make("first-party:google"), | ||
| 241 | clientOwner: "org", | ||
| 242 | owner: "org", | ||
| 243 | name: ConnectionName.make("gmail"), | ||
| 244 | integration: gmail, | ||
| 245 | template: AuthTemplateSlug.make("oauth"), | ||
| 246 | }, | ||
| 247 | }) | ||
| 248 | .pipe(Effect.flip); | ||
| 249 | expect(blocked).toBeDefined(); | ||
| 250 | }), | ||
| 251 | ), | ||
| 252 | ); | ||
| @@ -104,6 +104,11 @@ export const bootCloud = async (options: CloudBootOptions): Promise<CloudBooted> · 103 unmodified lines | |||
| 104 | // complete authorize → token dance instead of stopping at github.com. | 104 | // complete authorize → token dance instead of stopping at github.com. |
| 105 | FIRST_PARTY_GITHUB_AUTHORIZE_URL: process.env.FIRST_PARTY_GITHUB_AUTHORIZE_URL, | 105 | FIRST_PARTY_GITHUB_AUTHORIZE_URL: process.env.FIRST_PARTY_GITHUB_AUTHORIZE_URL, |
| 106 | FIRST_PARTY_GITHUB_TOKEN_URL: process.env.FIRST_PARTY_GITHUB_TOKEN_URL, | 106 | FIRST_PARTY_GITHUB_TOKEN_URL: process.env.FIRST_PARTY_GITHUB_TOKEN_URL, |
| 107 | // Fake Google registration for redirect-only first-party coverage. The | ||
| 108 | // scenario never visits Google or exchanges a code; it proves cloud env, | ||
| 109 | // scope policy, and authorization URL construction end to end. | ||
| 110 | FIRST_PARTY_GOOGLE_CLIENT_ID: "e2e-first-party-google", | ||
| 111 | FIRST_PARTY_GOOGLE_CLIENT_SECRET: "e2e-first-party-google-secret", | ||
| 107 | // Shrink the per-org hourly execution cap (prod default 1000) to a number | 112 | // Shrink the per-org hourly execution cap (prod default 1000) to a number |
| 108 | // the rate-limit-backstop scenario can actually exhaust with real | 113 | // the rate-limit-backstop scenario can actually exhaust with real |
| 109 | // executions — but see execution-limits.ts: it must stay above every other | 114 | // executions — but see execution-limits.ts: it must stay above every other |