@voxgig/sdkgen 4.5.3 → 4.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/voxgig-sdkgen +1 -1
- package/dist/action/check.js +15 -0
- package/dist/action/check.js.map +1 -1
- package/dist/cmp/AgentGuide.js +1 -1
- package/dist/cmp/AgentGuide.js.map +1 -1
- package/dist/cmp/AgentGuideContent.d.ts +1 -1
- package/dist/cmp/AgentGuideContent.js +8 -2
- package/dist/cmp/AgentGuideContent.js.map +1 -1
- package/dist/cmp/Feature.js +17 -0
- package/dist/cmp/Feature.js.map +1 -1
- package/dist/cmp/FeatureDocs.d.ts +1 -1
- package/dist/cmp/FeatureDocs.js +6 -1
- package/dist/cmp/FeatureDocs.js.map +1 -1
- package/dist/cmp/ReadmeExplanation.js +3 -1
- package/dist/cmp/ReadmeExplanation.js.map +1 -1
- package/dist/cmp/ReadmeFeatures.js +1 -1
- package/dist/cmp/ReadmeFeatures.js.map +1 -1
- package/dist/cmp/ReadmeRefFeatures.js +1 -1
- package/dist/cmp/ReadmeRefFeatures.js.map +1 -1
- package/dist/helpers/applicability.d.ts +6 -0
- package/dist/helpers/applicability.js +103 -0
- package/dist/helpers/applicability.js.map +1 -0
- package/dist/helpers/collectDeps.js +4 -2
- package/dist/helpers/collectDeps.js.map +1 -1
- package/dist/helpers/serverVars.d.ts +2 -1
- package/dist/helpers/serverVars.js +17 -0
- package/dist/helpers/serverVars.js.map +1 -1
- package/dist/sdkgen.d.ts +3 -2
- package/dist/sdkgen.js +9 -2
- package/dist/sdkgen.js.map +1 -1
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/dist/utility.js +7 -1
- package/dist/utility.js.map +1 -1
- package/model/sdkgen.aon +39 -0
- package/package.json +1 -1
- package/project/.sdk/model/feature/feature-index.aon +1 -0
- package/project/.sdk/model/feature/secrets.aon +118 -0
- package/project/.sdk/model/target/ts.aon +8 -1
- package/project/.sdk/src/cmp/c/Config_c.ts +5 -1
- package/project/.sdk/src/cmp/cpp/Config_cpp.ts +5 -1
- package/project/.sdk/src/cmp/csharp/Config_csharp.ts +5 -1
- package/project/.sdk/src/cmp/dart/Config_dart.ts +5 -1
- package/project/.sdk/src/cmp/elixir/Config_elixir.ts +5 -1
- package/project/.sdk/src/cmp/elixir/Main_elixir.ts +5 -1
- package/project/.sdk/src/cmp/go/Config_go.ts +5 -1
- package/project/.sdk/src/cmp/go/Main_go.ts +6 -2
- package/project/.sdk/src/cmp/go/ReadmeQuick_go.ts +16 -3
- package/project/.sdk/src/cmp/go/ReadmeTopQuick_go.ts +16 -3
- package/project/.sdk/src/cmp/go/TestDirect_go.ts +28 -4
- package/project/.sdk/src/cmp/go/TestEntity_go.ts +43 -10
- package/project/.sdk/src/cmp/go/fragment/Main.fragment.go +5 -5
- package/project/.sdk/src/cmp/java/Config_java.ts +22 -1
- package/project/.sdk/src/cmp/java/Main_java.ts +5 -1
- package/project/.sdk/src/cmp/java/ReadmeQuick_java.ts +17 -3
- package/project/.sdk/src/cmp/java/ReadmeTopQuick_java.ts +17 -3
- package/project/.sdk/src/cmp/java/TestDirect_java.ts +23 -4
- package/project/.sdk/src/cmp/java/TestEntity_java.ts +29 -4
- package/project/.sdk/src/cmp/js/Config_js.ts +5 -1
- package/project/.sdk/src/cmp/js/Main_js.ts +5 -1
- package/project/.sdk/src/cmp/js/Package_js.ts +4 -1
- package/project/.sdk/src/cmp/kotlin/Config_kotlin.ts +5 -1
- package/project/.sdk/src/cmp/lean/Config_lean.ts +5 -1
- package/project/.sdk/src/cmp/lua/Config_lua.ts +5 -1
- package/project/.sdk/src/cmp/lua/Main_lua.ts +5 -1
- package/project/.sdk/src/cmp/lua/Package_lua.ts +4 -1
- package/project/.sdk/src/cmp/ocaml/Config_ocaml.ts +5 -1
- package/project/.sdk/src/cmp/perl/Main_perl.ts +5 -1
- package/project/.sdk/src/cmp/php/Config_php.ts +5 -1
- package/project/.sdk/src/cmp/php/Main_php.ts +5 -1
- package/project/.sdk/src/cmp/py/Config_py.ts +5 -1
- package/project/.sdk/src/cmp/py/Main_py.ts +5 -1
- package/project/.sdk/src/cmp/py/ReadmeQuick_py.ts +16 -3
- package/project/.sdk/src/cmp/py/ReadmeTopQuick_py.ts +16 -3
- package/project/.sdk/src/cmp/py/TestDirect_py.ts +24 -5
- package/project/.sdk/src/cmp/py/TestEntity_py.ts +23 -3
- package/project/.sdk/src/cmp/rb/Config_rb.ts +5 -1
- package/project/.sdk/src/cmp/rb/Main_rb.ts +5 -1
- package/project/.sdk/src/cmp/rust/Config_rust.ts +5 -1
- package/project/.sdk/src/cmp/rust/Main_rust.ts +5 -1
- package/project/.sdk/src/cmp/scala/Config_scala.ts +5 -1
- package/project/.sdk/src/cmp/scala/Main_scala.ts +5 -1
- package/project/.sdk/src/cmp/swift/Config_swift.ts +5 -1
- package/project/.sdk/src/cmp/ts/Config_ts.ts +5 -1
- package/project/.sdk/src/cmp/ts/Main_ts.ts +57 -1
- package/project/.sdk/src/cmp/ts/Package_ts.ts +4 -1
- package/project/.sdk/src/cmp/ts/ReadmeModel_ts.ts +15 -3
- package/project/.sdk/src/cmp/ts/ReadmeQuick_ts.ts +18 -5
- package/project/.sdk/src/cmp/ts/ReadmeTopQuick_ts.ts +19 -5
- package/project/.sdk/src/cmp/ts/TestDirect_ts.ts +67 -7
- package/project/.sdk/src/cmp/ts/TestEntity_ts.ts +27 -4
- package/project/.sdk/src/cmp/ts/fragment/Direct.test.fragment.ts +9 -2
- package/project/.sdk/src/cmp/ts/fragment/Entity.test.fragment.ts +9 -2
- package/project/.sdk/src/cmp/ts/fragment/Main.fragment.ts +7 -0
- package/project/.sdk/src/cmp/zig/Config_zig.ts +5 -1
- package/project/.sdk/tm/c/utility/make_options.c +22 -0
- package/project/.sdk/tm/clojure/src/sdk/core.clj +18 -1
- package/project/.sdk/tm/cpp/utility/pipeline.hpp +23 -0
- package/project/.sdk/tm/csharp/test/CustomUtilityTest.cs +15 -0
- package/project/.sdk/tm/csharp/utility/MakeOptions.cs +23 -0
- package/project/.sdk/tm/dart/lib/utility/MakeOptionsUtility.dart +22 -0
- package/project/.sdk/tm/elixir/lib/projectname/utility.ex +19 -0
- package/project/.sdk/tm/go/core/context.go +1 -1
- package/project/.sdk/tm/go/feature/cost_feature.go +1 -1
- package/project/.sdk/tm/go/feature/paging_feature.go +5 -5
- package/project/.sdk/tm/go/feature/test_feature.go +12 -5
- package/project/.sdk/tm/go/test/custom_utility_test.go +15 -0
- package/project/.sdk/tm/go/test/feature_corpus_test.go +7 -0
- package/project/.sdk/tm/go/test/runner_test.go +46 -0
- package/project/.sdk/tm/go/test/struct_runner_test.go +7 -7
- package/project/.sdk/tm/go/test/struct_utility_test.go +63 -27
- package/project/.sdk/tm/go/utility/fetcher.go +2 -2
- package/project/.sdk/tm/go/utility/make_options.go +24 -5
- package/project/.sdk/tm/go/utility/make_point.go +1 -1
- package/project/.sdk/tm/go/utility/make_spec.go +17 -1
- package/project/.sdk/tm/go/utility/prepare_auth.go +1 -1
- package/project/.sdk/tm/go/utility/struct/voxgigstruct.go +370 -307
- package/project/.sdk/tm/go/utility/transform_request.go +29 -1
- package/project/.sdk/tm/go/utility/transform_response.go +20 -1
- package/project/.sdk/tm/java/test/CustomUtilityTest.java +10 -0
- package/project/.sdk/tm/java/test/FeatureCorpusTest.java +7 -0
- package/project/.sdk/tm/java/test/PrimaryUtilityTest.java +6 -0
- package/project/.sdk/tm/java/test/RunnerSupport.java +43 -0
- package/project/.sdk/tm/java/utility/MakeOptions.java +84 -1
- package/project/.sdk/tm/js/src/utility/MakeOptionsUtility.js +24 -0
- package/project/.sdk/tm/js/src/utility/StructUtility.js +2076 -1837
- package/project/.sdk/tm/js/test/feature/Corpus.test.js +9 -0
- package/project/.sdk/tm/kotlin/utility/MakeOptions.kt +21 -0
- package/project/.sdk/tm/lean/src/SdkUtility.lean +12 -1
- package/project/.sdk/tm/ocaml/sdk_runtime.ml +11 -0
- package/project/.sdk/tm/perl/utility/make_options.pm +18 -0
- package/project/.sdk/tm/php/test/FeatureCorpusTest.php +10 -1
- package/project/.sdk/tm/php/utility/MakeOptions.php +24 -0
- package/project/.sdk/tm/py/pkg/utility/make_options.py +20 -0
- package/project/.sdk/tm/py/test/runner.py +32 -0
- package/project/.sdk/tm/py/test/test_feature_corpus.py +10 -1
- package/project/.sdk/tm/rb/test/feature_corpus_test.rb +9 -1
- package/project/.sdk/tm/rb/utility/make_options.rb +17 -0
- package/project/.sdk/tm/rust/utility/make_options.rs +25 -0
- package/project/.sdk/tm/scala/utility/Make.scala +17 -0
- package/project/.sdk/tm/swift/Sources/ProjectNameSDK/utility/MakeOptions.swift +21 -0
- package/project/.sdk/tm/ts/src/feature/secrets/SecretsFeature.ts +419 -0
- package/project/.sdk/tm/ts/src/feature/secrets/sekreto/Providers.ts +1221 -0
- package/project/.sdk/tm/ts/src/feature/secrets/sekreto/Sekreto.ts +381 -0
- package/project/.sdk/tm/ts/src/feature/secrets/sekreto/Sigv4.ts +151 -0
- package/project/.sdk/tm/ts/src/feature/secrets/sekreto/index.ts +42 -0
- package/project/.sdk/tm/ts/src/utility/MakeOptionsUtility.ts +18 -0
- package/project/.sdk/tm/ts/src/utility/StructUtility.ts +1321 -710
- package/project/.sdk/tm/ts/test/feature/Corpus.test.ts +9 -0
- package/project/.sdk/tm/ts/test/feature/secrets/Secrets.test.ts +764 -0
- package/project/.sdk/tm/ts/test/omni.test.ts +84 -0
- package/project/.sdk/tm/ts/test/omni.ts +86 -0
- package/project/.sdk/tm/ts/test/utility/PrimaryUtility.test.ts +6 -2
- package/project/.sdk/tm/ts/test/utility/StructUtility.test.ts +2 -2
- package/project/.sdk/tm/ts/test/utility.ts +122 -0
- package/project/.sdk/tm/ts/test/vendor/omni/Runner.ts +692 -0
- package/project/.sdk/tm/ts/test/vendor/omni/Util.ts +238 -0
- package/project/.sdk/tm/ts/test/vendor/omni/compat.ts +188 -0
- package/project/.sdk/tm/ts/test/vendor/omni/index.ts +38 -0
- package/project/.sdk/tm/zig/core/utility.zig +23 -0
- package/project/sdkgen-package.json +2 -1
- package/src/action/check.ts +18 -0
- package/src/cmp/AgentGuide.ts +1 -1
- package/src/cmp/AgentGuideContent.ts +9 -2
- package/src/cmp/Feature.ts +19 -0
- package/src/cmp/FeatureDocs.ts +7 -1
- package/src/cmp/ReadmeExplanation.ts +4 -1
- package/src/cmp/ReadmeFeatures.ts +1 -1
- package/src/cmp/ReadmeRefFeatures.ts +1 -1
- package/src/helpers/applicability.ts +123 -0
- package/src/helpers/collectDeps.ts +5 -1
- package/src/helpers/serverVars.ts +19 -0
- package/src/sdkgen.ts +9 -1
- package/src/utility.ts +10 -1
- package/project/.sdk/tm/ts/test/runner.ts +0 -425
|
@@ -0,0 +1,764 @@
|
|
|
1
|
+
// Behavioural tests for the secrets feature (vendored @voxgig/sekreto).
|
|
2
|
+
//
|
|
3
|
+
// The contract under test: the `apikey` OPTION keeps its exact old meaning
|
|
4
|
+
// and always wins, because SecretsFeature places it FIRST in the provider
|
|
5
|
+
// chain (a `memory` store named `options`) — explicit-beats-lookup falls
|
|
6
|
+
// out of sekreto's first-hit rule rather than from special-case logic.
|
|
7
|
+
// With the feature inactive nothing changes at all. With it active and the
|
|
8
|
+
// option unset, the chain (env, dotenv, a custom provider, a vault)
|
|
9
|
+
// supplies the credential instead.
|
|
10
|
+
//
|
|
11
|
+
// This file lives in the `feature/` container on purpose: `target add`
|
|
12
|
+
// trims it, along with the feature source and the vendored library, for a
|
|
13
|
+
// project whose model does not select `secrets`.
|
|
14
|
+
|
|
15
|
+
import { test, describe, beforeEach } from 'node:test'
|
|
16
|
+
import assert from 'node:assert'
|
|
17
|
+
|
|
18
|
+
import { SDK } from '../../utility/index'
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
const ENVPREFIX = 'PROJECTENV_TEST_SECRETS_'
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
// prepare() returns the fetchdef the transport would receive — the closest
|
|
25
|
+
// observable point to the wire for header assertions — and it awaits
|
|
26
|
+
// secrets resolution itself, because it bypasses the feature hook pipeline.
|
|
27
|
+
async function prepared(sdkopts: any): Promise<any> {
|
|
28
|
+
const sdk = (SDK as any).test({}, sdkopts)
|
|
29
|
+
const fetchdef = await sdk.prepare({ path: '/' })
|
|
30
|
+
assert.ok(!(fetchdef instanceof Error), String(fetchdef))
|
|
31
|
+
return { sdk, fetchdef }
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
// The Authorization header carries the SPEC's credential prefix, which a
|
|
36
|
+
// TEMPLATE cannot know: an OpenAPI `http`/`bearer` scheme gives
|
|
37
|
+
// `Bearer <token>`, an apiKey scheme the raw token. So assert on the
|
|
38
|
+
// CREDENTIAL and let the prefix be whatever this SDK's API declares —
|
|
39
|
+
// pinning the whole header value passes only for a prefix-less API, and
|
|
40
|
+
// this file ships to every project that selects the feature.
|
|
41
|
+
function credentialIs(header: any, token: string) {
|
|
42
|
+
const got = String(null == header ? '' : header)
|
|
43
|
+
assert.ok(got === token || got.endsWith(' ' + token),
|
|
44
|
+
'expected the Authorization header to carry ' + token + ', got: ' + got)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
// An env chain, the shape most of these tests use.
|
|
49
|
+
function envchain(extra?: any): any {
|
|
50
|
+
return {
|
|
51
|
+
feature: {
|
|
52
|
+
secrets: Object.assign({
|
|
53
|
+
active: true,
|
|
54
|
+
providers: [{ kind: 'env', prefix: ENVPREFIX }],
|
|
55
|
+
}, extra || {}),
|
|
56
|
+
},
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
describe('secrets', () => {
|
|
62
|
+
|
|
63
|
+
beforeEach(() => {
|
|
64
|
+
delete process.env[ENVPREFIX + 'APIKEY']
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
test('inactive: apikey option behaves exactly as before', async () => {
|
|
69
|
+
const { sdk, fetchdef } = await prepared({ apikey: 'OPTKEY01' })
|
|
70
|
+
credentialIs(fetchdef.headers['authorization'], 'OPTKEY01')
|
|
71
|
+
|
|
72
|
+
// No feature, no instance: the accessor is the only way in.
|
|
73
|
+
assert.equal(sdk.secrets(), undefined)
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
test('inactive: no apikey means no authorization header', async () => {
|
|
78
|
+
const { fetchdef } = await prepared({})
|
|
79
|
+
assert.equal(fetchdef.headers['authorization'], undefined)
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
test('active: apikey option still wins over the chain', async () => {
|
|
84
|
+
process.env[ENVPREFIX + 'APIKEY'] = 'ENVKEY01'
|
|
85
|
+
const { sdk, fetchdef } = await prepared(
|
|
86
|
+
Object.assign({ apikey: 'OPTKEY01' }, envchain()))
|
|
87
|
+
|
|
88
|
+
credentialIs(fetchdef.headers['authorization'], 'OPTKEY01')
|
|
89
|
+
|
|
90
|
+
// The explicit option is a real store, not a special case: a directed
|
|
91
|
+
// read names it like any other.
|
|
92
|
+
assert.equal(await sdk.secrets().getfrom('options', 'apikey'), 'OPTKEY01')
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
// The three ways an apikey can be "not given", pinned together because
|
|
97
|
+
// they are easy to conflate and only one of them is a suppression.
|
|
98
|
+
//
|
|
99
|
+
// makeOptions normalises an omitted apikey to '' before features
|
|
100
|
+
// initialise, so by init time omitted and explicit-empty are
|
|
101
|
+
// indistinguishable — both defer to the chain, deliberately.
|
|
102
|
+
|
|
103
|
+
test('active: an OMITTED apikey defers to the chain', async () => {
|
|
104
|
+
process.env[ENVPREFIX + 'APIKEY'] = 'ENVKEY01'
|
|
105
|
+
const { fetchdef } = await prepared(envchain())
|
|
106
|
+
credentialIs(fetchdef.headers['authorization'], 'ENVKEY01')
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
test('active: an explicitly EMPTY apikey also defers to the chain', async () => {
|
|
111
|
+
process.env[ENVPREFIX + 'APIKEY'] = 'ENVKEY01'
|
|
112
|
+
const { fetchdef } = await prepared(
|
|
113
|
+
Object.assign({ apikey: '' }, envchain()))
|
|
114
|
+
credentialIs(fetchdef.headers['authorization'], 'ENVKEY01')
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
// `auth: null` — the documented way to disable auth outright, which
|
|
119
|
+
// prepareAuth honours before it ever reads the apikey.
|
|
120
|
+
//
|
|
121
|
+
// This needs an explicit guard because struct 0.3.2 nearly removed it in
|
|
122
|
+
// silence. Under 0.0.10, getprop returned a stored null as null, so
|
|
123
|
+
// validate saw `auth: null` as a non-map and REJECTED it for any SDK
|
|
124
|
+
// whose optspec supplies an `auth` default. Under 0.3.2 getprop treats a
|
|
125
|
+
// stored null as "no value", so the default fires instead and the
|
|
126
|
+
// suppression became "use default auth" — transmitting a credential the
|
|
127
|
+
// caller explicitly asked not to send. makeOptions now captures
|
|
128
|
+
// suppliedness BEFORE validate and restores the null after it.
|
|
129
|
+
//
|
|
130
|
+
// No corpus entry could catch this: corpus nulls travel as the
|
|
131
|
+
// '__NULL__' string, so real-JSON-null semantics are invisible to every
|
|
132
|
+
// port's shared fixtures.
|
|
133
|
+
test('active: auth null suppresses the credential, chain or no chain',
|
|
134
|
+
async () => {
|
|
135
|
+
process.env[ENVPREFIX + 'APIKEY'] = 'ENVKEY01'
|
|
136
|
+
const { sdk, fetchdef } = await prepared(
|
|
137
|
+
Object.assign({ auth: null }, envchain()))
|
|
138
|
+
|
|
139
|
+
// Nothing on the wire, even though the chain would have resolved.
|
|
140
|
+
assert.equal(fetchdef.headers['authorization'], undefined)
|
|
141
|
+
|
|
142
|
+
// The suppression survives option validation rather than being
|
|
143
|
+
// replaced by the optspec's default auth map.
|
|
144
|
+
assert.equal(sdk.options().auth, null)
|
|
145
|
+
})
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
test('active: auth null suppresses an EXPLICIT apikey too', async () => {
|
|
149
|
+
const { fetchdef } = await prepared(
|
|
150
|
+
Object.assign({ apikey: 'OPTKEY01', auth: null }, envchain()))
|
|
151
|
+
assert.equal(fetchdef.headers['authorization'], undefined)
|
|
152
|
+
})
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
test('active: custom provider objects are accepted verbatim', async () => {
|
|
156
|
+
const asked: string[] = []
|
|
157
|
+
const { fetchdef } = await prepared({
|
|
158
|
+
feature: {
|
|
159
|
+
secrets: {
|
|
160
|
+
active: true,
|
|
161
|
+
providers: [{
|
|
162
|
+
lookup(name: string) { asked.push(name); return 'CUSTOM01' },
|
|
163
|
+
describe() { return 'custom:test' },
|
|
164
|
+
}],
|
|
165
|
+
},
|
|
166
|
+
},
|
|
167
|
+
})
|
|
168
|
+
credentialIs(fetchdef.headers['authorization'], 'CUSTOM01')
|
|
169
|
+
assert.deepEqual(asked, ['apikey'])
|
|
170
|
+
})
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
test('active: a miss everywhere leaves the header off', async () => {
|
|
174
|
+
const { sdk, fetchdef } = await prepared(envchain())
|
|
175
|
+
assert.equal(fetchdef.headers['authorization'], undefined)
|
|
176
|
+
assert.equal(sdk.options().apikey, '')
|
|
177
|
+
})
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
// sekreto's miss-vs-error invariant: a MISS falls through to the next
|
|
181
|
+
// provider, an ERROR does not. A broken vault must never degrade into an
|
|
182
|
+
// unauthenticated request.
|
|
183
|
+
//
|
|
184
|
+
// On the DIRECT path the error is RETURNED, not thrown: _rawRequest
|
|
185
|
+
// awaits prepare() outside its try, and direct()/graphql() are documented
|
|
186
|
+
// to return a value or an Error and never reject.
|
|
187
|
+
test('active: a provider ERROR is returned by prepare, not thrown',
|
|
188
|
+
async () => {
|
|
189
|
+
const sdk = (SDK as any).test({}, {
|
|
190
|
+
feature: {
|
|
191
|
+
secrets: {
|
|
192
|
+
active: true,
|
|
193
|
+
providers: [{
|
|
194
|
+
lookup(_name: string): string { throw new Error('vault unreachable') },
|
|
195
|
+
describe() { return 'broken:test' },
|
|
196
|
+
}],
|
|
197
|
+
},
|
|
198
|
+
},
|
|
199
|
+
})
|
|
200
|
+
|
|
201
|
+
const out = await sdk.prepare({ path: '/' })
|
|
202
|
+
|
|
203
|
+
assert.ok(out instanceof Error, 'expected an Error value, got ' + String(out))
|
|
204
|
+
assert.match(String(out.message), /vault unreachable/)
|
|
205
|
+
})
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
// A settled promise must not be held forever. Holding a REJECTED one
|
|
209
|
+
// meant a transient vault outage poisoned the client permanently: every
|
|
210
|
+
// later operation kept failing with the original error long after the
|
|
211
|
+
// vault recovered.
|
|
212
|
+
test('active: a provider recovers after a transient failure', async () => {
|
|
213
|
+
let calls = 0
|
|
214
|
+
const sdk = (SDK as any).test({}, {
|
|
215
|
+
feature: {
|
|
216
|
+
secrets: {
|
|
217
|
+
active: true,
|
|
218
|
+
providers: [{
|
|
219
|
+
lookup(_name: string): string {
|
|
220
|
+
calls++
|
|
221
|
+
if (1 === calls) throw new Error('vault unreachable')
|
|
222
|
+
return 'RECOVERED01'
|
|
223
|
+
},
|
|
224
|
+
describe() { return 'flaky:test' },
|
|
225
|
+
}],
|
|
226
|
+
},
|
|
227
|
+
},
|
|
228
|
+
})
|
|
229
|
+
|
|
230
|
+
const first = await sdk.prepare({ path: '/' })
|
|
231
|
+
assert.ok(first instanceof Error, 'first attempt should surface the outage')
|
|
232
|
+
|
|
233
|
+
const second = await sdk.prepare({ path: '/' })
|
|
234
|
+
assert.ok(!(second instanceof Error), 'second attempt should recover, got ' + String(second))
|
|
235
|
+
credentialIs(second.headers['authorization'], 'RECOVERED01')
|
|
236
|
+
})
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
// `cache: false` is documented as "every resolve() asks the chain again".
|
|
240
|
+
// Caching the settled promise made that a lie.
|
|
241
|
+
test('active: cache false asks the chain on every resolve', async () => {
|
|
242
|
+
let calls = 0
|
|
243
|
+
const sdk = (SDK as any).test({}, {
|
|
244
|
+
feature: {
|
|
245
|
+
secrets: {
|
|
246
|
+
active: true,
|
|
247
|
+
cache: false,
|
|
248
|
+
providers: [{
|
|
249
|
+
lookup(_name: string): string { calls++; return 'KEY' + calls },
|
|
250
|
+
describe() { return 'counting:test' },
|
|
251
|
+
}],
|
|
252
|
+
},
|
|
253
|
+
},
|
|
254
|
+
})
|
|
255
|
+
|
|
256
|
+
await sdk.prepare({ path: '/' })
|
|
257
|
+
await sdk.prepare({ path: '/' })
|
|
258
|
+
|
|
259
|
+
assert.ok(1 < calls, 'the chain was asked once and cached, despite cache: false')
|
|
260
|
+
})
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
test('active: secret name is configurable', async () => {
|
|
264
|
+
process.env[ENVPREFIX + 'API_TOKEN'] = 'TOKKEY01'
|
|
265
|
+
try {
|
|
266
|
+
const { fetchdef } = await prepared(envchain({ name: 'api.token' }))
|
|
267
|
+
credentialIs(fetchdef.headers['authorization'], 'TOKKEY01')
|
|
268
|
+
}
|
|
269
|
+
finally {
|
|
270
|
+
delete process.env[ENVPREFIX + 'API_TOKEN']
|
|
271
|
+
}
|
|
272
|
+
})
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
test('active: sekreto is live for arbitrary secrets and redaction', async () => {
|
|
276
|
+
const { sdk } = await prepared({
|
|
277
|
+
feature: {
|
|
278
|
+
secrets: {
|
|
279
|
+
active: true,
|
|
280
|
+
providers: [{ kind: 'memory', values: { DB_PASSWORD: 'dbpass01' } }],
|
|
281
|
+
},
|
|
282
|
+
},
|
|
283
|
+
})
|
|
284
|
+
|
|
285
|
+
const secrets = sdk.secrets()
|
|
286
|
+
assert.equal(await secrets.get('db.password'), 'dbpass01')
|
|
287
|
+
assert.equal(
|
|
288
|
+
secrets.redact('the password is dbpass01, keep it safe'),
|
|
289
|
+
'the password is [redacted], keep it safe')
|
|
290
|
+
})
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
// The bridge that makes the whole design work: resolution is async, the
|
|
294
|
+
// auth header is built synchronously, and entity ops await
|
|
295
|
+
// featureHook('PreSpec') before makeSpec — so that is where the lookup
|
|
296
|
+
// happens and still lands in time.
|
|
297
|
+
//
|
|
298
|
+
// The entity is discovered from the SDK's own config rather than named,
|
|
299
|
+
// because this file is a TEMPLATE: it ships to every project, and no
|
|
300
|
+
// project's entity names are known here.
|
|
301
|
+
test('active: entity ops resolve via the PreSpec hook', async () => {
|
|
302
|
+
process.env[ENVPREFIX + 'APIKEY'] = 'ENVKEY02'
|
|
303
|
+
|
|
304
|
+
const sdk = (SDK as any).test({}, envchain())
|
|
305
|
+
|
|
306
|
+
const names = Object.keys(sdk.options().entity || {})
|
|
307
|
+
if (0 === names.length) {
|
|
308
|
+
// An SDK with no entities has no PreSpec path to exercise.
|
|
309
|
+
return
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
// Before any op, nothing has been resolved.
|
|
313
|
+
assert.equal(sdk.options().apikey, '')
|
|
314
|
+
|
|
315
|
+
const name = names[0]
|
|
316
|
+
const accessor = name.charAt(0).toUpperCase() + name.slice(1)
|
|
317
|
+
|
|
318
|
+
// The op itself may fail (no seeded data, no live API) — irrelevant
|
|
319
|
+
// here. What matters is that the awaited PreSpec hook ran and the
|
|
320
|
+
// credential reached the live options before the spec was built.
|
|
321
|
+
try {
|
|
322
|
+
await sdk[accessor]().list()
|
|
323
|
+
}
|
|
324
|
+
catch (_err) { }
|
|
325
|
+
|
|
326
|
+
assert.equal(sdk.options().apikey, 'ENVKEY02',
|
|
327
|
+
'the entity op did not resolve the secret through PreSpec')
|
|
328
|
+
})
|
|
329
|
+
|
|
330
|
+
})
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
// ACCESS-TOKEN EXCHANGE.
|
|
334
|
+
//
|
|
335
|
+
// The shape these tests pin: what the chain resolves is a REFRESH token,
|
|
336
|
+
// which is POSTed to a token endpoint for a short-lived ACCESS token; the
|
|
337
|
+
// access token is what the Authorization header carries; and when the API
|
|
338
|
+
// answers 401 the client buys another and tries the same request again,
|
|
339
|
+
// once.
|
|
340
|
+
//
|
|
341
|
+
// A LIVE client throughout, with `system.fetch` stubbed. Test mode is
|
|
342
|
+
// deliberately excluded here — it buys nothing (see SecretsFeature._buy),
|
|
343
|
+
// which is the subject of its own test at the end.
|
|
344
|
+
describe('secrets exchange', () => {
|
|
345
|
+
|
|
346
|
+
const BASE = 'http://exchange.test/api'
|
|
347
|
+
const REFRESH = 'REFRESH01'
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
beforeEach(() => {
|
|
351
|
+
delete process.env[ENVPREFIX + 'REFRESH_TOKEN']
|
|
352
|
+
})
|
|
353
|
+
|
|
354
|
+
|
|
355
|
+
// A stub standing in for both endpoints the flow touches: the token
|
|
356
|
+
// endpoint, and everything else.
|
|
357
|
+
//
|
|
358
|
+
// `apiStatus` is a SCRIPT — one status per API call, the last repeating —
|
|
359
|
+
// so a case can say "401 then 200" without counting calls itself.
|
|
360
|
+
function stubfetch(opts?: any) {
|
|
361
|
+
const o = opts || {}
|
|
362
|
+
const tokens: string[] = o.tokens || ['ACCESS01', 'ACCESS02', 'ACCESS03']
|
|
363
|
+
const apiStatus: number[] = o.apiStatus || [200]
|
|
364
|
+
|
|
365
|
+
const calls: any[] = []
|
|
366
|
+
let issued = 0
|
|
367
|
+
let apicall = -1
|
|
368
|
+
|
|
369
|
+
const fetch = async (url: string, init: any) => {
|
|
370
|
+
calls.push({ url, init, auth: (init.headers || {})['authorization'] })
|
|
371
|
+
|
|
372
|
+
if (url.endsWith('/' + (o.path || 'auth/token'))) {
|
|
373
|
+
if (true === o.tokenfails) {
|
|
374
|
+
return { status: 401, json: async () => ({ error: 'nope' }), headers: {} }
|
|
375
|
+
}
|
|
376
|
+
const body: any = {}
|
|
377
|
+
body[o.response || 'access_token'] = tokens[Math.min(issued, tokens.length - 1)]
|
|
378
|
+
issued++
|
|
379
|
+
return { status: 200, json: async () => body, headers: {} }
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
apicall++
|
|
383
|
+
const status = apiStatus[Math.min(apicall, apiStatus.length - 1)]
|
|
384
|
+
return { status, json: async () => ({ ok: status < 400 }), headers: {} }
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
return {
|
|
388
|
+
fetch,
|
|
389
|
+
calls,
|
|
390
|
+
// Only the calls that went to the API, in order.
|
|
391
|
+
api: () => calls.filter((c: any) => !c.url.endsWith('/' + (o.path || 'auth/token'))),
|
|
392
|
+
token: () => calls.filter((c: any) => c.url.endsWith('/' + (o.path || 'auth/token'))),
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
|
|
397
|
+
// Same rule as credentialIs, reading the header off a recorded call.
|
|
398
|
+
function authIs(call: any, token: string) {
|
|
399
|
+
credentialIs(null == call ? undefined : call.auth, token)
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
|
|
403
|
+
function exchangeSdk(stub: any, extra?: any): any {
|
|
404
|
+
return new (SDK as any)({
|
|
405
|
+
base: BASE,
|
|
406
|
+
system: { fetch: stub.fetch },
|
|
407
|
+
feature: {
|
|
408
|
+
secrets: Object.assign({
|
|
409
|
+
active: true,
|
|
410
|
+
name: 'refresh_token',
|
|
411
|
+
providers: [{ kind: 'env', prefix: ENVPREFIX }],
|
|
412
|
+
exchange: { active: true },
|
|
413
|
+
}, extra || {}),
|
|
414
|
+
},
|
|
415
|
+
})
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
|
|
419
|
+
test('the refresh token buys an access token, and the request carries it', async () => {
|
|
420
|
+
process.env[ENVPREFIX + 'REFRESH_TOKEN'] = REFRESH
|
|
421
|
+
|
|
422
|
+
const stub = stubfetch()
|
|
423
|
+
const sdk = exchangeSdk(stub)
|
|
424
|
+
|
|
425
|
+
await sdk.direct({ path: '/thing' })
|
|
426
|
+
|
|
427
|
+
assert.equal(stub.token().length, 1, 'expected exactly one token purchase')
|
|
428
|
+
assert.deepEqual(
|
|
429
|
+
JSON.parse(stub.token()[0].init.body), { refresh_token: REFRESH },
|
|
430
|
+
'the refresh token is sent in the request field')
|
|
431
|
+
|
|
432
|
+
assert.equal(stub.api().length, 1)
|
|
433
|
+
authIs(stub.api()[0], 'ACCESS01')
|
|
434
|
+
})
|
|
435
|
+
|
|
436
|
+
|
|
437
|
+
test('an explicit exchange.refresh wins over the chain', async () => {
|
|
438
|
+
process.env[ENVPREFIX + 'REFRESH_TOKEN'] = 'FROMCHAIN'
|
|
439
|
+
|
|
440
|
+
const stub = stubfetch()
|
|
441
|
+
const sdk = exchangeSdk(stub, { exchange: { active: true, refresh: 'EXPLICIT01' } })
|
|
442
|
+
|
|
443
|
+
await sdk.direct({ path: '/thing' })
|
|
444
|
+
|
|
445
|
+
assert.deepEqual(
|
|
446
|
+
JSON.parse(stub.token()[0].init.body), { refresh_token: 'EXPLICIT01' })
|
|
447
|
+
})
|
|
448
|
+
|
|
449
|
+
|
|
450
|
+
test('one purchase serves many requests', async () => {
|
|
451
|
+
process.env[ENVPREFIX + 'REFRESH_TOKEN'] = REFRESH
|
|
452
|
+
|
|
453
|
+
const stub = stubfetch()
|
|
454
|
+
const sdk = exchangeSdk(stub)
|
|
455
|
+
|
|
456
|
+
await sdk.direct({ path: '/one' })
|
|
457
|
+
await sdk.direct({ path: '/two' })
|
|
458
|
+
await sdk.direct({ path: '/three' })
|
|
459
|
+
|
|
460
|
+
assert.equal(stub.token().length, 1,
|
|
461
|
+
'a token still working must not be re-bought')
|
|
462
|
+
assert.equal(stub.api().length, 3)
|
|
463
|
+
})
|
|
464
|
+
|
|
465
|
+
|
|
466
|
+
test('concurrent first requests share ONE purchase', async () => {
|
|
467
|
+
process.env[ENVPREFIX + 'REFRESH_TOKEN'] = REFRESH
|
|
468
|
+
|
|
469
|
+
const stub = stubfetch()
|
|
470
|
+
const sdk = exchangeSdk(stub)
|
|
471
|
+
|
|
472
|
+
await Promise.all([
|
|
473
|
+
sdk.direct({ path: '/a' }),
|
|
474
|
+
sdk.direct({ path: '/b' }),
|
|
475
|
+
sdk.direct({ path: '/c' }),
|
|
476
|
+
])
|
|
477
|
+
|
|
478
|
+
assert.equal(stub.token().length, 1,
|
|
479
|
+
'four operations at once must not open four token requests')
|
|
480
|
+
})
|
|
481
|
+
|
|
482
|
+
|
|
483
|
+
test('a 401 buys another token and retries the SAME request', async () => {
|
|
484
|
+
process.env[ENVPREFIX + 'REFRESH_TOKEN'] = REFRESH
|
|
485
|
+
|
|
486
|
+
// First API call is refused, the retry succeeds.
|
|
487
|
+
const stub = stubfetch({ apiStatus: [401, 200] })
|
|
488
|
+
const sdk = exchangeSdk(stub)
|
|
489
|
+
|
|
490
|
+
const res = await sdk.direct({ path: '/thing' })
|
|
491
|
+
|
|
492
|
+
assert.equal(stub.token().length, 2, 'expected a second token purchase')
|
|
493
|
+
assert.equal(stub.api().length, 2, 'expected the request to be retried')
|
|
494
|
+
|
|
495
|
+
authIs(stub.api()[0], 'ACCESS01')
|
|
496
|
+
// The retry must carry the NEW token, not the spent one.
|
|
497
|
+
authIs(stub.api()[1], 'ACCESS02')
|
|
498
|
+
|
|
499
|
+
assert.equal(res.ok, true, 'the caller sees the successful retry')
|
|
500
|
+
})
|
|
501
|
+
|
|
502
|
+
|
|
503
|
+
test('the retry happens once, not in a loop', async () => {
|
|
504
|
+
process.env[ENVPREFIX + 'REFRESH_TOKEN'] = REFRESH
|
|
505
|
+
|
|
506
|
+
// Every API call is refused: a second 401 on a token bought moments ago
|
|
507
|
+
// is a real failure, and spinning on it would hang instead of failing.
|
|
508
|
+
const stub = stubfetch({ apiStatus: [401] })
|
|
509
|
+
const sdk = exchangeSdk(stub)
|
|
510
|
+
|
|
511
|
+
await sdk.direct({ path: '/thing' })
|
|
512
|
+
|
|
513
|
+
assert.equal(stub.api().length, 2, 'exactly one retry')
|
|
514
|
+
assert.equal(stub.token().length, 2)
|
|
515
|
+
})
|
|
516
|
+
|
|
517
|
+
|
|
518
|
+
test('a status outside exchange.statuses is not an expiry', async () => {
|
|
519
|
+
process.env[ENVPREFIX + 'REFRESH_TOKEN'] = REFRESH
|
|
520
|
+
|
|
521
|
+
const stub = stubfetch({ apiStatus: [403] })
|
|
522
|
+
const sdk = exchangeSdk(stub)
|
|
523
|
+
|
|
524
|
+
await sdk.direct({ path: '/thing' })
|
|
525
|
+
|
|
526
|
+
assert.equal(stub.api().length, 1, '403 is not in the default statuses')
|
|
527
|
+
assert.equal(stub.token().length, 1)
|
|
528
|
+
})
|
|
529
|
+
|
|
530
|
+
|
|
531
|
+
test('exchange.statuses is configurable', async () => {
|
|
532
|
+
process.env[ENVPREFIX + 'REFRESH_TOKEN'] = REFRESH
|
|
533
|
+
|
|
534
|
+
const stub = stubfetch({ apiStatus: [403, 200] })
|
|
535
|
+
const sdk = exchangeSdk(stub,
|
|
536
|
+
{ exchange: { active: true, statuses: [403] } })
|
|
537
|
+
|
|
538
|
+
await sdk.direct({ path: '/thing' })
|
|
539
|
+
|
|
540
|
+
assert.equal(stub.api().length, 2, '403 was declared an expiry')
|
|
541
|
+
})
|
|
542
|
+
|
|
543
|
+
|
|
544
|
+
test('the request and response field names are configurable', async () => {
|
|
545
|
+
process.env[ENVPREFIX + 'REFRESH_TOKEN'] = REFRESH
|
|
546
|
+
|
|
547
|
+
const stub = stubfetch({ response: 'token', path: 'oauth/grant' })
|
|
548
|
+
const sdk = exchangeSdk(stub, {
|
|
549
|
+
exchange: {
|
|
550
|
+
active: true,
|
|
551
|
+
path: 'oauth/grant',
|
|
552
|
+
request: 'grant',
|
|
553
|
+
response: 'token',
|
|
554
|
+
},
|
|
555
|
+
})
|
|
556
|
+
|
|
557
|
+
await sdk.direct({ path: '/thing' })
|
|
558
|
+
|
|
559
|
+
assert.equal(stub.token().length, 1)
|
|
560
|
+
assert.ok(stub.token()[0].url.endsWith('/oauth/grant'),
|
|
561
|
+
'the token endpoint is relative to base: ' + stub.token()[0].url)
|
|
562
|
+
assert.deepEqual(JSON.parse(stub.token()[0].init.body), { grant: REFRESH })
|
|
563
|
+
authIs(stub.api()[0], 'ACCESS01')
|
|
564
|
+
})
|
|
565
|
+
|
|
566
|
+
|
|
567
|
+
test('an explicit apikey is spent before anything is bought', async () => {
|
|
568
|
+
process.env[ENVPREFIX + 'REFRESH_TOKEN'] = REFRESH
|
|
569
|
+
|
|
570
|
+
// A caller who already holds an access token should use it; expiry is
|
|
571
|
+
// what moves them onto the exchange, and the API is what says so.
|
|
572
|
+
const stub = stubfetch({ apiStatus: [200] })
|
|
573
|
+
const sdk = new (SDK as any)({
|
|
574
|
+
base: BASE,
|
|
575
|
+
apikey: 'HELDTOKEN01',
|
|
576
|
+
system: { fetch: stub.fetch },
|
|
577
|
+
feature: {
|
|
578
|
+
secrets: {
|
|
579
|
+
active: true,
|
|
580
|
+
name: 'refresh_token',
|
|
581
|
+
providers: [{ kind: 'env', prefix: ENVPREFIX }],
|
|
582
|
+
exchange: { active: true },
|
|
583
|
+
},
|
|
584
|
+
},
|
|
585
|
+
})
|
|
586
|
+
|
|
587
|
+
await sdk.direct({ path: '/thing' })
|
|
588
|
+
|
|
589
|
+
assert.equal(stub.token().length, 0, 'nothing needed buying')
|
|
590
|
+
authIs(stub.api()[0], 'HELDTOKEN01')
|
|
591
|
+
})
|
|
592
|
+
|
|
593
|
+
|
|
594
|
+
test('a held apikey that has expired falls through to the exchange', async () => {
|
|
595
|
+
process.env[ENVPREFIX + 'REFRESH_TOKEN'] = REFRESH
|
|
596
|
+
|
|
597
|
+
const stub = stubfetch({ apiStatus: [401, 200] })
|
|
598
|
+
const sdk = new (SDK as any)({
|
|
599
|
+
base: BASE,
|
|
600
|
+
apikey: 'STALETOKEN01',
|
|
601
|
+
system: { fetch: stub.fetch },
|
|
602
|
+
feature: {
|
|
603
|
+
secrets: {
|
|
604
|
+
active: true,
|
|
605
|
+
name: 'refresh_token',
|
|
606
|
+
providers: [{ kind: 'env', prefix: ENVPREFIX }],
|
|
607
|
+
exchange: { active: true },
|
|
608
|
+
},
|
|
609
|
+
},
|
|
610
|
+
})
|
|
611
|
+
|
|
612
|
+
await sdk.direct({ path: '/thing' })
|
|
613
|
+
|
|
614
|
+
authIs(stub.api()[0], 'STALETOKEN01')
|
|
615
|
+
authIs(stub.api()[1], 'ACCESS01')
|
|
616
|
+
})
|
|
617
|
+
|
|
618
|
+
|
|
619
|
+
test('no refresh token anywhere is an error, not an unauthenticated call', async () => {
|
|
620
|
+
const stub = stubfetch()
|
|
621
|
+
const sdk = exchangeSdk(stub)
|
|
622
|
+
|
|
623
|
+
const res = await sdk.direct({ path: '/thing' })
|
|
624
|
+
|
|
625
|
+
assert.ok(res instanceof Error || (res && false === res.ok),
|
|
626
|
+
'expected a failure, got: ' + JSON.stringify(res))
|
|
627
|
+
assert.equal(stub.api().length, 0,
|
|
628
|
+
'a request must not go out unauthenticated because the chain was empty')
|
|
629
|
+
})
|
|
630
|
+
|
|
631
|
+
|
|
632
|
+
test('a failing token endpoint surfaces the API refusal, not a spin', async () => {
|
|
633
|
+
process.env[ENVPREFIX + 'REFRESH_TOKEN'] = REFRESH
|
|
634
|
+
|
|
635
|
+
// The first purchase succeeds; the second (after the 401) does not.
|
|
636
|
+
const stub = stubfetch({ apiStatus: [401] })
|
|
637
|
+
const failing = {
|
|
638
|
+
...stub,
|
|
639
|
+
fetch: async (url: string, init: any) => {
|
|
640
|
+
const res: any = await stub.fetch(url, init)
|
|
641
|
+
return url.endsWith('/auth/token') && 1 < stub.token().length
|
|
642
|
+
? { status: 500, json: async () => ({}), headers: {} }
|
|
643
|
+
: res
|
|
644
|
+
},
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
const sdk = exchangeSdk(failing)
|
|
648
|
+
const res = await sdk.direct({ path: '/thing' })
|
|
649
|
+
|
|
650
|
+
assert.ok(null != res, 'the caller got an answer rather than a hang')
|
|
651
|
+
assert.equal(stub.api().length, 1, 'no retry after a failed purchase')
|
|
652
|
+
})
|
|
653
|
+
|
|
654
|
+
|
|
655
|
+
test('auth: null suppresses the credential, refusal or not', async () => {
|
|
656
|
+
process.env[ENVPREFIX + 'REFRESH_TOKEN'] = REFRESH
|
|
657
|
+
|
|
658
|
+
// `auth: null` is the documented way to send no credential at all.
|
|
659
|
+
// A refusal of a deliberately unauthenticated request is not an
|
|
660
|
+
// expired token: buying one and retrying would transmit exactly the
|
|
661
|
+
// credential the caller suppressed.
|
|
662
|
+
const stub = stubfetch({ apiStatus: [401] })
|
|
663
|
+
const sdk = new (SDK as any)({
|
|
664
|
+
base: BASE,
|
|
665
|
+
auth: null,
|
|
666
|
+
system: { fetch: stub.fetch },
|
|
667
|
+
feature: {
|
|
668
|
+
secrets: {
|
|
669
|
+
active: true,
|
|
670
|
+
name: 'refresh_token',
|
|
671
|
+
providers: [{ kind: 'env', prefix: ENVPREFIX }],
|
|
672
|
+
exchange: { active: true },
|
|
673
|
+
},
|
|
674
|
+
},
|
|
675
|
+
})
|
|
676
|
+
|
|
677
|
+
await sdk.direct({ path: '/thing' })
|
|
678
|
+
|
|
679
|
+
assert.equal(stub.api().length, 1, 'a suppressed request must not be retried')
|
|
680
|
+
assert.equal(stub.api()[0].auth, undefined,
|
|
681
|
+
'no credential may be sent when auth is suppressed')
|
|
682
|
+
})
|
|
683
|
+
|
|
684
|
+
|
|
685
|
+
test('a token another request already bought is spent, not re-bought', async () => {
|
|
686
|
+
process.env[ENVPREFIX + 'REFRESH_TOKEN'] = REFRESH
|
|
687
|
+
|
|
688
|
+
// STAGGERED 401s: the case the shared in-flight purchase does NOT
|
|
689
|
+
// cover. Two requests go out on the same token; the first is refused,
|
|
690
|
+
// buys a new one and clears the shared promise; only then is the
|
|
691
|
+
// second refused. Buying again there is a wasted exchange, and on a
|
|
692
|
+
// provider that invalidates the previous credential on issuance it
|
|
693
|
+
// breaks the first request's own retry.
|
|
694
|
+
//
|
|
695
|
+
// Driven through the transport wrapper directly, because the race is
|
|
696
|
+
// in WHEN the refusal arrives relative to another request's refresh,
|
|
697
|
+
// and that is not something two ordinary calls can be made to stage.
|
|
698
|
+
const stub = stubfetch()
|
|
699
|
+
const sdk = exchangeSdk(stub)
|
|
700
|
+
|
|
701
|
+
await sdk.direct({ path: '/warmup' })
|
|
702
|
+
const bought = stub.token().length
|
|
703
|
+
|
|
704
|
+
const feature = sdk._secrets
|
|
705
|
+
const fetchdef: any = { headers: { authorization: 'Bearer STALE01' } }
|
|
706
|
+
|
|
707
|
+
sdk._options.apikey = 'STALE01'
|
|
708
|
+
|
|
709
|
+
let calls = 0
|
|
710
|
+
const inner = async () => {
|
|
711
|
+
calls++
|
|
712
|
+
if (1 === calls) {
|
|
713
|
+
// While this request was in flight, another one refreshed.
|
|
714
|
+
sdk._options.apikey = 'ACCESS09'
|
|
715
|
+
return { status: 401, json: async () => ({}), headers: {} }
|
|
716
|
+
}
|
|
717
|
+
return { status: 200, json: async () => ({ ok: true }), headers: {} }
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
const res: any = await feature._withRefresh({}, BASE + '/two', fetchdef, inner)
|
|
721
|
+
|
|
722
|
+
assert.equal(res.status, 200)
|
|
723
|
+
assert.equal(calls, 2, 'the request was retried once')
|
|
724
|
+
assert.equal(stub.token().length, bought,
|
|
725
|
+
'the retry must reuse the token another request already bought')
|
|
726
|
+
authIs({ auth: fetchdef.headers.authorization }, 'ACCESS09')
|
|
727
|
+
})
|
|
728
|
+
|
|
729
|
+
|
|
730
|
+
test('exchange off leaves the feature exactly as it was', async () => {
|
|
731
|
+
process.env[ENVPREFIX + 'APIKEY'] = 'PLAINKEY01'
|
|
732
|
+
|
|
733
|
+
const { fetchdef } = await prepared(envchain())
|
|
734
|
+
|
|
735
|
+
credentialIs(fetchdef.headers['authorization'], 'PLAINKEY01')
|
|
736
|
+
delete process.env[ENVPREFIX + 'APIKEY']
|
|
737
|
+
})
|
|
738
|
+
|
|
739
|
+
|
|
740
|
+
test('test mode buys nothing and needs no token endpoint', async () => {
|
|
741
|
+
process.env[ENVPREFIX + 'REFRESH_TOKEN'] = REFRESH
|
|
742
|
+
|
|
743
|
+
const stub = stubfetch()
|
|
744
|
+
const sdk = (SDK as any).test({}, {
|
|
745
|
+
base: BASE,
|
|
746
|
+
system: { fetch: stub.fetch },
|
|
747
|
+
feature: {
|
|
748
|
+
secrets: {
|
|
749
|
+
active: true,
|
|
750
|
+
name: 'refresh_token',
|
|
751
|
+
providers: [{ kind: 'env', prefix: ENVPREFIX }],
|
|
752
|
+
exchange: { active: true },
|
|
753
|
+
},
|
|
754
|
+
},
|
|
755
|
+
})
|
|
756
|
+
|
|
757
|
+
const fetchdef = await sdk.prepare({ path: '/' })
|
|
758
|
+
|
|
759
|
+
assert.equal(stub.calls.length, 0, 'test mode must not do IO')
|
|
760
|
+
// A deterministic placeholder, so offline suites need no configuration.
|
|
761
|
+
credentialIs(fetchdef.headers['authorization'], 'test-access_token')
|
|
762
|
+
})
|
|
763
|
+
|
|
764
|
+
})
|