refactor(auth): split VaultBaseAuth.__authToken into separate promise/token fields - #123
Conversation
…ields `VaultBaseAuth.__authToken` held either a Promise (login in flight) or a resolved AuthToken, discriminated at each use site with `instanceof`. Overloading one field with two types made the auth state machine hard to read and easy to break when touched. Split it into two clearly-typed fields: - `__pendingLogin: Promise<AuthToken>|null` — the in-flight login - `__authToken: AuthToken|null` — the resolved token `getAuthToken()` now checks the pending login first (preserving the single-flight behaviour for concurrent callers), then the cached-token and expiry paths, so no `instanceof` discrimination is needed. The renewal-timer wiring is unchanged. No behaviour change: public API and semantics are identical. Adds tests pinning the single-flight invariant and the post-failure state reset. Closes #120 Signed-off-by: kurok <22548029+kurok@users.noreply.github.com>
…nreleased Signed-off-by: kurok <22548029+kurok@users.noreply.github.com>
118f023 to
7768999
Compare
…leased The 'Update branch' merge of master (9403563) auto-merged without a conflict but placed this note inside the '# 2.1.1 Release notes (2026-07-31)' section. 2.1.1 is a dependency-only release and does not contain this refactor, so the note is restored to a fresh '# Unreleased' heading above it. Signed-off-by: yuriyryabikov <22548029+kurok@users.noreply.github.com>
Local verification: 119 differential cases, this branch vs published
|
| Suite | Cases | vs published 2.1.0 |
|---|---|---|
| Auth state machine (mocked transport, fake clocks) | 82 | 5 behaviour diffs, 8 internal-field-only |
| End-to-end against live Vault 1.13.3 | 10 | 0 diffs |
| Consumer use cases (modelled on real downstream code) | 27 | 0 diffs |
Each suite is a single file run unchanged against both trees, emitting structured JSON per case; the two outputs are diffed mechanically, with observable behaviour recorded separately from private-field shape. State-machine suites ran on Node 26/24 (the first batch also on 22), consumer suite on 26/24, live-Vault suite on 26 — identical results on every combination.
All 5 behaviour diffs, in full:
F1/F2/F3—_authenticate()resolving something that is not anAuthToken. Swappinginstanceof AuthTokenfor!== nullmeans expiry is now evaluated on whatever was returned: a duck-typed object with noisExpired()throwsTypeError, and a duck-typed expired object now triggers reauth instead of being served forever. Unreachable through the public API —__getAuthProvideris a closed switch over four types, and all four resolve via_getTokenEntity→AuthToken.fromResponse. Where it is reachable (someone subclassingVaultBaseAuthdirectly), this branch is the more correct of the two.E8/E11— the one genuine change. When the renewal timer completes while a re-login is in flight,2.1.0has the timer'sthis.__authToken = authTokenwrite clobber the in-flight promise held in that same field, so a caller arriving afterwards is served the renewed token instead of joining the login. With the fields split,__pendingLoginis untouched and single-flight holds. If the re-login never settles,2.1.0resolves that caller from the renewal while this branch keeps waiting on the login. Reachable only with a renewable token whose renew request outlives half its TTL, and both versions hand the caller a valid token.
So "No behavior change" is very slightly too strong — worth one line in the description about the renew-during-re-login case, which this branch arguably improves, since single-flight is now genuinely preserved rather than accidentally broken by a timer write.
The new tests earn their place
Mutation testing — each mutation applied to the source, then that tree's own test:unit run:
| mutation | 2.1.0 suite (306 tests) |
this branch (308 tests) |
|---|---|---|
| remove single-flight | SURVIVED | caught |
| drop the failure reset | caught | caught (2 tests) |
never clear __pendingLogin |
— | caught (3 tests) |
restore instanceof AuthToken |
— | survived |
2.1.0 had no test pinning single-flight; the new coalesces concurrent callers onto a single in-flight login test catches its removal. The one surviving mutation is exactly the instanceof → !== null guard, i.e. the F1–F3 edge that nothing reachable can produce.
Both new tests also pass against 2.1.0 once their four __pendingLogin/__authToken assertions are stripped — so they pin the new representation and a behaviour that was already there, rather than testing the refactor into existence.
Downstream consumers
I catalogued every internal repository that imports this library (20 call sites across 17 repos) and wrote 27 cases modelled directly on that code rather than invented:
- the
boot()/get()/clear()instance registry, includingclear()cancelling an armed refresh timer; fillNodeConfig()— by far the dominant entry point — including the reject-not-hang path that consumers wire toprocess.exit(1);- concurrent
Promise.allread/list bursts on a freshly constructed client, plus the nested list-then-read-every-key shape, plus a detached boundreadhanded straight to.map()(so it receives(path, index, array)); read('sys/mounts')for KV-version detection, and the permission-denied fallback where a 403 there must leave auth usable for subsequent reads;- KV2 via both
enginesandkv.autoDetect, and the two monkey-patched-readidioms found in the wild (Object.create(lease)prototype delegation, and a bare{ getData }replacement); - all four auth types as they are actually configured, including
iamwith statically supplied credentials, a custom mount andiam_server_id_header_value, andkubernetesreading its JWT fromtokenPath; Lease.getValue/getData/isRenewable, dynamic leased secrets, and renewal firing during a long-lived process.
27/27 identical between 2.1.0 and this branch. Every burst still performs exactly one login, fillNodeConfig populates identically, and renewal still extends expiry mid-process while reads keep succeeding.
Repo gates
npm run lint clean, test:unit 306 → 308 passing, coverage gate green with VaultBaseAuth.js improving (96.7 → 96.96 statements, 96.87 → 97.05 branches), test:e2e:kv2 10/10 — all on both trees × 3 Node versions.
One pre-existing failure, identical on both trees over 5 fresh-container runs each: test:e2e → should renew token if needed. That test's token has a 2s TTL, and on my machine Node's global fetch stalls ~2.5s on a request issued after a ~1s idle gap, so renew-self reaches Vault after the token has expired and gets a 403. It reproduces against a plain node:http server with no Docker and no Vault involved, on Node 22/24/26 alike, while curl is always ~4ms — environmental, not this library. Separately, that suite is not idempotent against a reused dev-mode Vault (it asserts the full key list of secret/ and re-mounts ssh/), which is worth its own issue.
Verdict
No regression reachable from any consumer pattern in use; the single-flight invariant is now actually pinned by tests where it previously was not; coverage up. The only substantive note is the E8/E11 wording in the description.
Summary
Closes #120 — the one "consider"-tier item from the 2026-07 audit backlog (#111) that was intentionally left out of #119.
VaultBaseAuth.__authTokenheld either aPromise(login in flight) or a resolvedAuthToken, discriminated at each use site withinstanceof. Overloading one field with two types made the auth state machine hard to read and easy to get subtly wrong when touched.It is now two clearly-typed fields:
__pendingLoginPromise<AuthToken> | null__authTokenAuthToken | nullgetAuthToken()reads as a straight state machine: pending login → cached token → expiry/reauth → start a login. Noinstanceofdiscrimination anywhere.No behavior change — public API and semantics are identical. Specifically preserved:
Promise.resolve(this.__authToken)unwrapping a stored promise; now an explicit early return).__setupTokenRefreshTimer/__renewTokenuntouched.AuthTokenExpiredError.The only observable difference is one
debuglog line: the in-flight case now logslogin already in flightinstead oftoken already exist, which is what was actually happening.Changes
src/auth/VaultBaseAuth.js— split the field, restructuregetAuthToken()into explicit early returns, document both fields with JSDoc types.test/auth.base.test.mjs— two new tests pinning the invariants the refactor had to keep: concurrent callers coalesce onto a single login (and the pending slot clears on resolve), and a failed login resets both fields.CHANGELOG.md— entry under# Unreleased.Type of change
Checklist
npm run lint && npm testpasses locally — 308 unit tests pass, lint clean,npm run coveragegate green (VaultBaseAuth.js96.96% stmts / 97.05% branches). E2E not run (needs a live Vault).# UnreleasedinCHANGELOG.md— internal-refactor entry, matching the convention used for Refactor: unify the 4x copy-pasted request pipeline in VaultClient #110Signed-off-by:trailer (git commit -s)