| ... | @@ -1,6 +1,5 @@ | ... | @@ -1,6 +1,5 @@ |
| 1 | import { describe, expect, it } from "@effect/vitest"; | 1 | import { describe, expect, it } from "@effect/vitest"; |
| 2 | import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; | 2 | import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; |
| 3 | import * as NodeServices from "@effect/platform-node/NodeServices"; | | |
| 4 | import * as Effect from "effect/Effect"; | 3 | import * as Effect from "effect/Effect"; |
| 5 | import * as Layer from "effect/Layer"; | 4 | import * as Layer from "effect/Layer"; |
| 6 | import * as Redacted from "effect/Redacted"; | 5 | import * as Redacted from "effect/Redacted"; |
| ... | @@ -24,16 +23,20 @@ function harness(respond: (request: HttpClientRequest.HttpClientRequest) => Resp | ... | @@ -24,16 +23,20 @@ function harness(respond: (request: HttpClientRequest.HttpClientRequest) => Resp |
| 24 | const requests: Array<HttpClientRequest.HttpClientRequest> = []; | 23 | const requests: Array<HttpClientRequest.HttpClientRequest> = []; |
| 25 | let tokens = ["first", "second"]; | 24 | let tokens = ["first", "second"]; |
| 26 | let invalidations = 0; | 25 | let invalidations = 0; |
| | 26 | let credentialReads = 0; |
| 27 | const credentials = Layer.succeed( | 27 | const credentials = Layer.succeed( |
| 28 | GitHubCredentials.GitHubCredentials, | 28 | GitHubCredentials.GitHubCredentials, |
| 29 | GitHubCredentials.GitHubCredentials.of({ | 29 | GitHubCredentials.GitHubCredentials.of({ |
| 30 | get: (host) => | 30 | get: (host) => |
| 31 | Effect.sync(() => ({ | 31 | Effect.sync(() => { |
| 32 | host, | 32 | credentialReads++; |
| 33 | token: Redacted.make(tokens[0]!), | 33 | return { |
| 34 | source: "gh" as const, | 34 | host, |
| 35 | fingerprint: `${host}:${tokens[0]}`, | 35 | token: Redacted.make(tokens[0]!), |
| 36 | })), | 36 | source: "gh" as const, |
| | 37 | fingerprint: `${host}:${tokens[0]}`, |
| | 38 | }; |
| | 39 | }), |
| 37 | invalidate: () => | 40 | invalidate: () => |
| 38 | Effect.sync(() => { | 41 | Effect.sync(() => { |
| 39 | invalidations++; | 42 | invalidations++; |
| ... | @@ -53,7 +56,12 @@ function harness(respond: (request: HttpClientRequest.HttpClientRequest) => Resp | ... | @@ -53,7 +56,12 @@ function harness(respond: (request: HttpClientRequest.HttpClientRequest) => Resp |
| 53 | Layer.provideMerge(GitHubQuota.layer), | 56 | Layer.provideMerge(GitHubQuota.layer), |
| 54 | Layer.provideMerge(SourceControlRateLimit.layer), | 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 | const json = (body: unknown, init?: ResponseInit) => | 67 | const json = (body: unknown, init?: ResponseInit) => |
| ... | @@ -119,6 +127,34 @@ describe("GitHubApi", () => { | ... | @@ -119,6 +127,34 @@ describe("GitHubApi", () => { |
| 119 | }).pipe(Effect.provide(layer)); | 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 | it.effect( | 158 | it.effect( |
| 123 | "traces the operation, path, query and quota, never the query string or variables", | 159 | "traces the operation, path, query and quota, never the query string or variables", |
| 124 | () => { | 160 | () => { |