@voxgig/sdkgen 4.5.2 → 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.
Files changed (176) hide show
  1. package/bin/voxgig-sdkgen +1 -1
  2. package/dist/action/check.js +15 -0
  3. package/dist/action/check.js.map +1 -1
  4. package/dist/cmp/AgentGuide.js +1 -1
  5. package/dist/cmp/AgentGuide.js.map +1 -1
  6. package/dist/cmp/AgentGuideContent.d.ts +1 -1
  7. package/dist/cmp/AgentGuideContent.js +8 -2
  8. package/dist/cmp/AgentGuideContent.js.map +1 -1
  9. package/dist/cmp/Feature.js +17 -0
  10. package/dist/cmp/Feature.js.map +1 -1
  11. package/dist/cmp/FeatureDocs.d.ts +1 -1
  12. package/dist/cmp/FeatureDocs.js +6 -1
  13. package/dist/cmp/FeatureDocs.js.map +1 -1
  14. package/dist/cmp/ReadmeExplanation.js +3 -1
  15. package/dist/cmp/ReadmeExplanation.js.map +1 -1
  16. package/dist/cmp/ReadmeFeatures.js +1 -1
  17. package/dist/cmp/ReadmeFeatures.js.map +1 -1
  18. package/dist/cmp/ReadmeRefFeatures.js +1 -1
  19. package/dist/cmp/ReadmeRefFeatures.js.map +1 -1
  20. package/dist/cmp/ReadmeTop.js +1 -1
  21. package/dist/helpers/applicability.d.ts +6 -0
  22. package/dist/helpers/applicability.js +103 -0
  23. package/dist/helpers/applicability.js.map +1 -0
  24. package/dist/helpers/collectDeps.js +4 -2
  25. package/dist/helpers/collectDeps.js.map +1 -1
  26. package/dist/helpers/serverVars.d.ts +2 -1
  27. package/dist/helpers/serverVars.js +17 -0
  28. package/dist/helpers/serverVars.js.map +1 -1
  29. package/dist/sdkgen.d.ts +3 -2
  30. package/dist/sdkgen.js +9 -2
  31. package/dist/sdkgen.js.map +1 -1
  32. package/dist/tsconfig.tsbuildinfo +1 -1
  33. package/dist/utility.js +7 -1
  34. package/dist/utility.js.map +1 -1
  35. package/model/sdkgen.aon +39 -0
  36. package/package.json +1 -1
  37. package/project/.sdk/model/feature/feature-index.aon +1 -0
  38. package/project/.sdk/model/feature/secrets.aon +118 -0
  39. package/project/.sdk/model/target/ts.aon +8 -1
  40. package/project/.sdk/src/cmp/c/Config_c.ts +5 -1
  41. package/project/.sdk/src/cmp/cpp/Config_cpp.ts +5 -1
  42. package/project/.sdk/src/cmp/csharp/Config_csharp.ts +5 -1
  43. package/project/.sdk/src/cmp/dart/Config_dart.ts +5 -1
  44. package/project/.sdk/src/cmp/elixir/Config_elixir.ts +5 -1
  45. package/project/.sdk/src/cmp/elixir/Main_elixir.ts +5 -1
  46. package/project/.sdk/src/cmp/go/Config_go.ts +5 -1
  47. package/project/.sdk/src/cmp/go/Main_go.ts +6 -2
  48. package/project/.sdk/src/cmp/go/ReadmeQuick_go.ts +16 -3
  49. package/project/.sdk/src/cmp/go/ReadmeTopQuick_go.ts +16 -3
  50. package/project/.sdk/src/cmp/go/TestDirect_go.ts +28 -4
  51. package/project/.sdk/src/cmp/go/TestEntity_go.ts +43 -10
  52. package/project/.sdk/src/cmp/go/fragment/Main.fragment.go +5 -5
  53. package/project/.sdk/src/cmp/java/Config_java.ts +22 -1
  54. package/project/.sdk/src/cmp/java/Main_java.ts +5 -1
  55. package/project/.sdk/src/cmp/java/ReadmeQuick_java.ts +17 -3
  56. package/project/.sdk/src/cmp/java/ReadmeTopQuick_java.ts +17 -3
  57. package/project/.sdk/src/cmp/java/TestDirect_java.ts +23 -4
  58. package/project/.sdk/src/cmp/java/TestEntity_java.ts +29 -4
  59. package/project/.sdk/src/cmp/js/Config_js.ts +5 -1
  60. package/project/.sdk/src/cmp/js/Main_js.ts +5 -1
  61. package/project/.sdk/src/cmp/js/Package_js.ts +4 -1
  62. package/project/.sdk/src/cmp/kotlin/Config_kotlin.ts +5 -1
  63. package/project/.sdk/src/cmp/lean/Config_lean.ts +5 -1
  64. package/project/.sdk/src/cmp/lua/Config_lua.ts +5 -1
  65. package/project/.sdk/src/cmp/lua/Main_lua.ts +5 -1
  66. package/project/.sdk/src/cmp/lua/Package_lua.ts +4 -1
  67. package/project/.sdk/src/cmp/ocaml/Config_ocaml.ts +5 -1
  68. package/project/.sdk/src/cmp/perl/Main_perl.ts +5 -1
  69. package/project/.sdk/src/cmp/php/Config_php.ts +5 -1
  70. package/project/.sdk/src/cmp/php/Main_php.ts +5 -1
  71. package/project/.sdk/src/cmp/py/Config_py.ts +5 -1
  72. package/project/.sdk/src/cmp/py/Main_py.ts +5 -1
  73. package/project/.sdk/src/cmp/py/ReadmeQuick_py.ts +16 -3
  74. package/project/.sdk/src/cmp/py/ReadmeTopQuick_py.ts +16 -3
  75. package/project/.sdk/src/cmp/py/TestDirect_py.ts +24 -5
  76. package/project/.sdk/src/cmp/py/TestEntity_py.ts +23 -3
  77. package/project/.sdk/src/cmp/rb/Config_rb.ts +5 -1
  78. package/project/.sdk/src/cmp/rb/Main_rb.ts +5 -1
  79. package/project/.sdk/src/cmp/rust/Config_rust.ts +5 -1
  80. package/project/.sdk/src/cmp/rust/Main_rust.ts +5 -1
  81. package/project/.sdk/src/cmp/scala/Config_scala.ts +5 -1
  82. package/project/.sdk/src/cmp/scala/Main_scala.ts +5 -1
  83. package/project/.sdk/src/cmp/swift/Config_swift.ts +5 -1
  84. package/project/.sdk/src/cmp/ts/Config_ts.ts +5 -1
  85. package/project/.sdk/src/cmp/ts/Main_ts.ts +57 -1
  86. package/project/.sdk/src/cmp/ts/Package_ts.ts +4 -1
  87. package/project/.sdk/src/cmp/ts/ReadmeModel_ts.ts +15 -3
  88. package/project/.sdk/src/cmp/ts/ReadmeQuick_ts.ts +18 -5
  89. package/project/.sdk/src/cmp/ts/ReadmeTopQuick_ts.ts +19 -5
  90. package/project/.sdk/src/cmp/ts/TestDirect_ts.ts +67 -7
  91. package/project/.sdk/src/cmp/ts/TestEntity_ts.ts +27 -4
  92. package/project/.sdk/src/cmp/ts/fragment/Direct.test.fragment.ts +9 -2
  93. package/project/.sdk/src/cmp/ts/fragment/Entity.test.fragment.ts +9 -2
  94. package/project/.sdk/src/cmp/ts/fragment/Main.fragment.ts +7 -0
  95. package/project/.sdk/src/cmp/zig/Config_zig.ts +5 -1
  96. package/project/.sdk/tm/c/utility/make_options.c +22 -0
  97. package/project/.sdk/tm/clojure/src/sdk/core.clj +18 -1
  98. package/project/.sdk/tm/cpp/utility/pipeline.hpp +23 -0
  99. package/project/.sdk/tm/csharp/test/CustomUtilityTest.cs +15 -0
  100. package/project/.sdk/tm/csharp/utility/MakeOptions.cs +23 -0
  101. package/project/.sdk/tm/dart/lib/utility/MakeOptionsUtility.dart +22 -0
  102. package/project/.sdk/tm/elixir/lib/projectname/utility.ex +19 -0
  103. package/project/.sdk/tm/go/core/context.go +1 -1
  104. package/project/.sdk/tm/go/feature/cost_feature.go +1 -1
  105. package/project/.sdk/tm/go/feature/paging_feature.go +5 -5
  106. package/project/.sdk/tm/go/feature/test_feature.go +12 -5
  107. package/project/.sdk/tm/go/test/custom_utility_test.go +15 -0
  108. package/project/.sdk/tm/go/test/feature_corpus_test.go +7 -0
  109. package/project/.sdk/tm/go/test/runner_test.go +46 -0
  110. package/project/.sdk/tm/go/test/struct_runner_test.go +7 -7
  111. package/project/.sdk/tm/go/test/struct_utility_test.go +63 -27
  112. package/project/.sdk/tm/go/utility/fetcher.go +2 -2
  113. package/project/.sdk/tm/go/utility/make_options.go +24 -5
  114. package/project/.sdk/tm/go/utility/make_point.go +1 -1
  115. package/project/.sdk/tm/go/utility/make_spec.go +17 -1
  116. package/project/.sdk/tm/go/utility/prepare_auth.go +1 -1
  117. package/project/.sdk/tm/go/utility/struct/voxgigstruct.go +370 -307
  118. package/project/.sdk/tm/go/utility/transform_request.go +29 -1
  119. package/project/.sdk/tm/go/utility/transform_response.go +20 -1
  120. package/project/.sdk/tm/java/test/CustomUtilityTest.java +10 -0
  121. package/project/.sdk/tm/java/test/FeatureCorpusTest.java +7 -0
  122. package/project/.sdk/tm/java/test/PrimaryUtilityTest.java +6 -0
  123. package/project/.sdk/tm/java/test/RunnerSupport.java +43 -0
  124. package/project/.sdk/tm/java/utility/MakeOptions.java +84 -1
  125. package/project/.sdk/tm/js/src/utility/MakeOptionsUtility.js +24 -0
  126. package/project/.sdk/tm/js/src/utility/StructUtility.js +2076 -1837
  127. package/project/.sdk/tm/js/test/feature/Corpus.test.js +9 -0
  128. package/project/.sdk/tm/kotlin/utility/MakeOptions.kt +21 -0
  129. package/project/.sdk/tm/lean/src/SdkUtility.lean +12 -1
  130. package/project/.sdk/tm/ocaml/sdk_runtime.ml +11 -0
  131. package/project/.sdk/tm/perl/utility/make_options.pm +18 -0
  132. package/project/.sdk/tm/php/test/FeatureCorpusTest.php +10 -1
  133. package/project/.sdk/tm/php/utility/MakeOptions.php +24 -0
  134. package/project/.sdk/tm/py/pkg/utility/make_options.py +20 -0
  135. package/project/.sdk/tm/py/test/runner.py +32 -0
  136. package/project/.sdk/tm/py/test/test_feature_corpus.py +10 -1
  137. package/project/.sdk/tm/rb/test/feature_corpus_test.rb +9 -1
  138. package/project/.sdk/tm/rb/utility/make_options.rb +17 -0
  139. package/project/.sdk/tm/rust/utility/make_options.rs +25 -0
  140. package/project/.sdk/tm/scala/utility/Make.scala +17 -0
  141. package/project/.sdk/tm/swift/Sources/ProjectNameSDK/utility/MakeOptions.swift +21 -0
  142. package/project/.sdk/tm/ts/src/feature/secrets/SecretsFeature.ts +419 -0
  143. package/project/.sdk/tm/ts/src/feature/secrets/sekreto/Providers.ts +1221 -0
  144. package/project/.sdk/tm/ts/src/feature/secrets/sekreto/Sekreto.ts +381 -0
  145. package/project/.sdk/tm/ts/src/feature/secrets/sekreto/Sigv4.ts +151 -0
  146. package/project/.sdk/tm/ts/src/feature/secrets/sekreto/index.ts +42 -0
  147. package/project/.sdk/tm/ts/src/utility/MakeOptionsUtility.ts +18 -0
  148. package/project/.sdk/tm/ts/src/utility/StructUtility.ts +1321 -710
  149. package/project/.sdk/tm/ts/test/feature/Corpus.test.ts +9 -0
  150. package/project/.sdk/tm/ts/test/feature/secrets/Secrets.test.ts +764 -0
  151. package/project/.sdk/tm/ts/test/omni.test.ts +84 -0
  152. package/project/.sdk/tm/ts/test/omni.ts +86 -0
  153. package/project/.sdk/tm/ts/test/utility/PrimaryUtility.test.ts +6 -2
  154. package/project/.sdk/tm/ts/test/utility/StructUtility.test.ts +2 -2
  155. package/project/.sdk/tm/ts/test/utility.ts +122 -0
  156. package/project/.sdk/tm/ts/test/vendor/omni/Runner.ts +692 -0
  157. package/project/.sdk/tm/ts/test/vendor/omni/Util.ts +238 -0
  158. package/project/.sdk/tm/ts/test/vendor/omni/compat.ts +188 -0
  159. package/project/.sdk/tm/ts/test/vendor/omni/index.ts +38 -0
  160. package/project/.sdk/tm/zig/core/utility.zig +23 -0
  161. package/project/sdkgen-package.json +2 -1
  162. package/src/action/check.ts +18 -0
  163. package/src/cmp/AgentGuide.ts +1 -1
  164. package/src/cmp/AgentGuideContent.ts +9 -2
  165. package/src/cmp/Feature.ts +19 -0
  166. package/src/cmp/FeatureDocs.ts +7 -1
  167. package/src/cmp/ReadmeExplanation.ts +4 -1
  168. package/src/cmp/ReadmeFeatures.ts +1 -1
  169. package/src/cmp/ReadmeRefFeatures.ts +1 -1
  170. package/src/cmp/ReadmeTop.ts +1 -1
  171. package/src/helpers/applicability.ts +123 -0
  172. package/src/helpers/collectDeps.ts +5 -1
  173. package/src/helpers/serverVars.ts +19 -0
  174. package/src/sdkgen.ts +9 -1
  175. package/src/utility.ts +10 -1
  176. 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
+ })