@datacules/agent-identity-store-vault 0.4.0 → 0.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/package.json +2 -2
- package/src/vault.test.ts +173 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@datacules/agent-identity-store-vault",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.0",
|
|
4
4
|
"private": false,
|
|
5
5
|
"description": "HashiCorp Vault KV v2 credential store for @datacules/agent-identity",
|
|
6
6
|
"main": "./dist/cjs/index.js",
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
"type-check": "tsc --noEmit"
|
|
12
12
|
},
|
|
13
13
|
"peerDependencies": {
|
|
14
|
-
"@datacules/agent-identity": "^0.
|
|
14
|
+
"@datacules/agent-identity": "^0.6.0"
|
|
15
15
|
},
|
|
16
16
|
"devDependencies": {
|
|
17
17
|
"@datacules/agent-identity": "*",
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
2
|
+
import { VaultCredentialStore } from './index';
|
|
3
|
+
|
|
4
|
+
// All HTTP calls are mocked via vi.stubGlobal('fetch', ...).
|
|
5
|
+
// No live HashiCorp Vault instance is required to run these tests.
|
|
6
|
+
const mockFetch = vi.fn();
|
|
7
|
+
vi.stubGlobal('fetch', mockFetch);
|
|
8
|
+
|
|
9
|
+
const VAULT_ADDR = 'http://vault:8200';
|
|
10
|
+
const TOKEN = 'root-token';
|
|
11
|
+
|
|
12
|
+
const CRED = {
|
|
13
|
+
id: 'cred-linear',
|
|
14
|
+
kind: 'fixed' as const,
|
|
15
|
+
name: 'Linear Service Account',
|
|
16
|
+
scope: 'read:all',
|
|
17
|
+
status: 'active' as const,
|
|
18
|
+
ref: 'linear-service-account-slot',
|
|
19
|
+
provider: 'openai' as const,
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
function makeStore() {
|
|
23
|
+
return new VaultCredentialStore({ address: VAULT_ADDR, token: TOKEN });
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function jsonOk(data: unknown): Response {
|
|
27
|
+
return { ok: true, json: async () => data } as unknown as Response;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const CRED_VAULT_RESPONSE = { data: { data: CRED } };
|
|
31
|
+
|
|
32
|
+
describe('VaultCredentialStore', () => {
|
|
33
|
+
beforeEach(() => vi.clearAllMocks());
|
|
34
|
+
|
|
35
|
+
// ── findByRef() ────────────────────────────────────────────────────────────
|
|
36
|
+
|
|
37
|
+
describe('findByRef()', () => {
|
|
38
|
+
it('returns the active credential on a 200 Vault KV v2 response', async () => {
|
|
39
|
+
mockFetch.mockResolvedValueOnce(jsonOk(CRED_VAULT_RESPONSE));
|
|
40
|
+
const result = await makeStore().findByRef(CRED.ref);
|
|
41
|
+
expect(result).toMatchObject({ id: 'cred-linear', status: 'active' });
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it('sends the X-Vault-Token header with the configured token', async () => {
|
|
45
|
+
mockFetch.mockResolvedValueOnce(jsonOk(CRED_VAULT_RESPONSE));
|
|
46
|
+
await makeStore().findByRef(CRED.ref);
|
|
47
|
+
expect(mockFetch).toHaveBeenCalledWith(
|
|
48
|
+
`${VAULT_ADDR}/v1/secret/data/agent-identity/${CRED.ref}`,
|
|
49
|
+
expect.objectContaining({
|
|
50
|
+
headers: expect.objectContaining({ 'X-Vault-Token': TOKEN }),
|
|
51
|
+
})
|
|
52
|
+
);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it('returns null when the credential status is not active', async () => {
|
|
56
|
+
mockFetch.mockResolvedValueOnce(
|
|
57
|
+
jsonOk({ data: { data: { ...CRED, status: 'revoked' } } })
|
|
58
|
+
);
|
|
59
|
+
expect(await makeStore().findByRef(CRED.ref)).toBeNull();
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it('returns null on a non-ok Vault response (e.g. 404)', async () => {
|
|
63
|
+
mockFetch.mockResolvedValueOnce({ ok: false } as Response);
|
|
64
|
+
expect(await makeStore().findByRef('unknown-ref')).toBeNull();
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it('returns null and does not throw when fetch throws a network error', async () => {
|
|
68
|
+
mockFetch.mockRejectedValueOnce(new Error('ECONNREFUSED'));
|
|
69
|
+
await expect(makeStore().findByRef(CRED.ref)).resolves.toBeNull();
|
|
70
|
+
});
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
// ── listActive() ──────────────────────────────────────────────────────────
|
|
74
|
+
|
|
75
|
+
describe('listActive()', () => {
|
|
76
|
+
it('returns all active credentials via metadata LIST then individual GETs', async () => {
|
|
77
|
+
// First call: metadata list
|
|
78
|
+
mockFetch.mockResolvedValueOnce(
|
|
79
|
+
jsonOk({ data: { keys: [CRED.ref] } })
|
|
80
|
+
);
|
|
81
|
+
// Second call: individual credential GET
|
|
82
|
+
mockFetch.mockResolvedValueOnce(jsonOk(CRED_VAULT_RESPONSE));
|
|
83
|
+
const result = await makeStore().listActive();
|
|
84
|
+
expect(result).toHaveLength(1);
|
|
85
|
+
expect(result[0]).toMatchObject({ id: 'cred-linear', status: 'active' });
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it('returns an empty array when the metadata list response is not ok', async () => {
|
|
89
|
+
mockFetch.mockResolvedValueOnce({ ok: false } as Response);
|
|
90
|
+
expect(await makeStore().listActive()).toEqual([]);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it('returns an empty array when fetch throws on the metadata call', async () => {
|
|
94
|
+
mockFetch.mockRejectedValueOnce(new Error('Vault unreachable'));
|
|
95
|
+
expect(await makeStore().listActive()).toEqual([]);
|
|
96
|
+
});
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
// ── listByKind() ──────────────────────────────────────────────────────────
|
|
100
|
+
|
|
101
|
+
describe('listByKind()', () => {
|
|
102
|
+
it('returns only credentials matching the requested kind', async () => {
|
|
103
|
+
mockFetch.mockResolvedValueOnce(jsonOk({ data: { keys: [CRED.ref] } }));
|
|
104
|
+
mockFetch.mockResolvedValueOnce(jsonOk(CRED_VAULT_RESPONSE)); // kind: 'fixed'
|
|
105
|
+
const result = await makeStore().listByKind('fixed');
|
|
106
|
+
expect(result).toHaveLength(1);
|
|
107
|
+
expect(result[0].kind).toBe('fixed');
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it('returns an empty array when no credentials match the requested kind', async () => {
|
|
111
|
+
mockFetch.mockResolvedValueOnce(jsonOk({ data: { keys: [CRED.ref] } }));
|
|
112
|
+
mockFetch.mockResolvedValueOnce(jsonOk(CRED_VAULT_RESPONSE)); // kind: 'fixed'
|
|
113
|
+
// Asking for 'user-delegated' — the fixed credential should not appear
|
|
114
|
+
const result = await makeStore().listByKind('user-delegated');
|
|
115
|
+
expect(result).toHaveLength(0);
|
|
116
|
+
});
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
// ── reserve() ─────────────────────────────────────────────────────────────
|
|
120
|
+
|
|
121
|
+
describe('reserve()', () => {
|
|
122
|
+
it('returns true and writes the lock when no prior lock exists (read → 404)', async () => {
|
|
123
|
+
mockFetch.mockResolvedValueOnce({ ok: false } as Response); // read → not found
|
|
124
|
+
mockFetch.mockResolvedValueOnce({ ok: true } as Response); // write → success
|
|
125
|
+
expect(await makeStore().reserve(CRED.ref, 'mig-1', 300)).toBe(true);
|
|
126
|
+
expect(mockFetch).toHaveBeenCalledTimes(2);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it('returns false when the lock is held by a different migration within TTL', async () => {
|
|
130
|
+
const expiresAt = Math.floor(Date.now() / 1000) + 9999;
|
|
131
|
+
mockFetch.mockResolvedValueOnce(
|
|
132
|
+
jsonOk({ data: { data: { migrationId: 'other-mig', expiresAt } } })
|
|
133
|
+
);
|
|
134
|
+
expect(await makeStore().reserve(CRED.ref, 'mig-1', 300)).toBe(false);
|
|
135
|
+
expect(mockFetch).toHaveBeenCalledOnce(); // only the read — no write attempted
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it('returns true when the same migration re-acquires its own active lock', async () => {
|
|
139
|
+
const expiresAt = Math.floor(Date.now() / 1000) + 9999;
|
|
140
|
+
mockFetch.mockResolvedValueOnce(
|
|
141
|
+
jsonOk({ data: { data: { migrationId: 'mig-1', expiresAt } } })
|
|
142
|
+
);
|
|
143
|
+
mockFetch.mockResolvedValueOnce({ ok: true } as Response);
|
|
144
|
+
expect(await makeStore().reserve(CRED.ref, 'mig-1', 300)).toBe(true);
|
|
145
|
+
});
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
// ── release() ─────────────────────────────────────────────────────────────
|
|
149
|
+
|
|
150
|
+
describe('release()', () => {
|
|
151
|
+
it('issues a DELETE request when the migrationId matches the stored lock', async () => {
|
|
152
|
+
mockFetch.mockResolvedValueOnce(
|
|
153
|
+
jsonOk({ data: { data: { migrationId: 'mig-1' } } })
|
|
154
|
+
);
|
|
155
|
+
mockFetch.mockResolvedValueOnce({ ok: true } as Response); // DELETE
|
|
156
|
+
await makeStore().release(CRED.ref, 'mig-1');
|
|
157
|
+
expect(mockFetch).toHaveBeenCalledTimes(2);
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
it('makes only one fetch (the read) when the migrationId does not match', async () => {
|
|
161
|
+
mockFetch.mockResolvedValueOnce(
|
|
162
|
+
jsonOk({ data: { data: { migrationId: 'other-mig' } } })
|
|
163
|
+
);
|
|
164
|
+
await makeStore().release(CRED.ref, 'mig-1');
|
|
165
|
+
expect(mockFetch).toHaveBeenCalledOnce();
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
it('resolves without throwing when the lock is already gone (fetch throws)', async () => {
|
|
169
|
+
mockFetch.mockRejectedValueOnce(new Error('404 Not Found'));
|
|
170
|
+
await expect(makeStore().release(CRED.ref, 'mig-1')).resolves.not.toThrow();
|
|
171
|
+
});
|
|
172
|
+
});
|
|
173
|
+
});
|