| ... | ... | @@ -1,6 +1,5 @@ |
| 1 | 1 | import { describe, expect, it } from "@effect/vitest"; |
| 2 | 2 | import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; |
| 3 | | import * as NodeServices from "@effect/platform-node/NodeServices"; |
| 4 | 3 | import * as Effect from "effect/Effect"; |
| 5 | 4 | import * as Layer from "effect/Layer"; |
| 6 | 5 | import * as Redacted from "effect/Redacted"; |
| ... | ... | @@ -24,16 +23,20 @@ function harness(respond: (request: HttpClientRequest.HttpClientRequest) => Resp |
| 24 | 23 | const requests: Array<HttpClientRequest.HttpClientRequest> = []; |
| 25 | 24 | let tokens = ["first", "second"]; |
| 26 | 25 | let invalidations = 0; |
| 26 | let credentialReads = 0; |
| 27 | 27 | const credentials = Layer.succeed( |
| 28 | 28 | GitHubCredentials.GitHubCredentials, |
| 29 | 29 | GitHubCredentials.GitHubCredentials.of({ |
| 30 | 30 | get: (host) => |
| 31 | | Effect.sync(() => ({ |
| 32 | | host, |
| 33 | | token: Redacted.make(tokens[0]!), |
| 34 | | source: "gh" as const, |
| 35 | | fingerprint: `${host}:${tokens[0]}`, |
| 36 | | })), |
| 31 | Effect.sync(() => { |
| 32 | credentialReads++; |
| 33 | return { |
| 34 | host, |
| 35 | token: Redacted.make(tokens[0]!), |
| 36 | source: "gh" as const, |
| 37 | fingerprint: `${host}:${tokens[0]}`, |
| 38 | }; |
| 39 | }), |
| 37 | 40 | invalidate: () => |
| 38 | 41 | Effect.sync(() => { |
| 39 | 42 | invalidations++; |
| ... | ... | @@ -53,7 +56,12 @@ function harness(respond: (request: HttpClientRequest.HttpClientRequest) => Resp |
| 53 | 56 | Layer.provideMerge(GitHubQuota.layer), |
| 54 | 57 | Layer.provideMerge(SourceControlRateLimit.layer), |
| 55 | 58 | ); |
| 56 | | return { layer, requests, invalidations: () => invalidations }; |
| 59 | return { |
| 60 | layer, |
| 61 | requests, |
| 62 | invalidations: () => invalidations, |
| 63 | credentialReads: () => credentialReads, |
| 64 | }; |
| 57 | 65 | } |
| 58 | 66 | |
| 59 | 67 | const json = (body: unknown, init?: ResponseInit) => |
| ... | ... | @@ -119,6 +127,34 @@ describe("GitHubApi", () => { |
| 119 | 127 | }).pipe(Effect.provide(layer)); |
| 120 | 128 | }); |
| 121 | 129 | |
| 130 | it.effect("resolves GraphQL credentials once per request and refreshes after refusal", () => { |
| 131 | const { layer, requests, invalidations, credentialReads } = harness((request) => |
| 132 | request.headers.authorization === "Bearer first" |
| 133 | ? new Response(null, { status: 401 }) |
| 134 | : json({ data: { viewer: { login: "julius" } } }), |
| 135 | ); |
| 136 | return Effect.gen(function* () { |
| 137 | const api = yield* GitHubApi.GitHubApi; |
| 138 | const input = { |
| 139 | host: "github.com", |
| 140 | operation: "viewer", |
| 141 | query: "query { viewer { login } }", |
| 142 | }; |
| 143 | const refused = yield* Effect.flip(api.graphql(input)); |
| 144 | expect(refused._tag).toBe("GitHubApiAuthenticationError"); |
| 145 | expect(credentialReads()).toBe(1); |
| 146 | expect(invalidations()).toBe(1); |
| 147 | |
| 148 | const body = yield* api.graphql(input); |
| 149 | expect(body).toContain('"login":"julius"'); |
| 150 | expect(credentialReads()).toBe(2); |
| 151 | expect(requests.map((request) => request.headers.authorization)).toEqual([ |
| 152 | "Bearer first", |
| 153 | "Bearer second", |
| 154 | ]); |
| 155 | }).pipe(Effect.provide(layer)); |
| 156 | }); |
| 157 | |
| 122 | 158 | it.effect( |
| 123 | 159 | "traces the operation, path, query and quota, never the query string or variables", |
| 124 | 160 | () => { |