@datacules/agent-identity-store-vault 0.5.0 → 0.7.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 (2) hide show
  1. package/package.json +2 -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.5.0",
3
+ "version": "0.7.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.1.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
+ });