@datacules/agent-identity-store-aws 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.
Files changed (2) hide show
  1. package/package.json +2 -2
  2. package/src/aws.test.ts +196 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@datacules/agent-identity-store-aws",
3
- "version": "0.4.0",
3
+ "version": "0.6.0",
4
4
  "private": false,
5
5
  "description": "AWS Secrets Manager + DynamoDB credential store for @datacules/agent-identity",
6
6
  "main": "./dist/cjs/index.js",
@@ -15,7 +15,7 @@
15
15
  "@aws-sdk/client-dynamodb": "^3.600.0"
16
16
  },
17
17
  "peerDependencies": {
18
- "@datacules/agent-identity": "^0.1.0"
18
+ "@datacules/agent-identity": "^0.6.0"
19
19
  },
20
20
  "devDependencies": {
21
21
  "@datacules/agent-identity": "*",
@@ -0,0 +1,196 @@
1
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
2
+
3
+ // Mock AWS SDK modules — constructors return objects with vi.fn() send methods.
4
+ // vi.mock() calls are hoisted before imports by Vitest.
5
+ vi.mock('@aws-sdk/client-secrets-manager', () => ({
6
+ SecretsManagerClient: vi.fn(() => ({ send: vi.fn() })),
7
+ GetSecretValueCommand: vi.fn((input: unknown) => input),
8
+ ListSecretsCommand: vi.fn((input: unknown) => input),
9
+ }));
10
+
11
+ vi.mock('@aws-sdk/client-dynamodb', () => ({
12
+ DynamoDBClient: vi.fn(() => ({ send: vi.fn() })),
13
+ PutItemCommand: vi.fn((input: unknown) => input),
14
+ DeleteItemCommand: vi.fn((input: unknown) => input),
15
+ }));
16
+
17
+ import { AwsCredentialStore } from './index.js';
18
+ import type { Credential } from '@datacules/agent-identity';
19
+
20
+ const makeCred = (overrides: Partial<Credential> = {}): Credential => ({
21
+ id: 'cred-openai',
22
+ kind: 'fixed',
23
+ name: 'OpenAI API Key',
24
+ scope: 'global',
25
+ status: 'active',
26
+ provider: 'openai',
27
+ ref: 'openai-prod-slot',
28
+ ...overrides,
29
+ });
30
+
31
+ describe('AwsCredentialStore', () => {
32
+ let store: AwsCredentialStore;
33
+ let smSend: ReturnType<typeof vi.fn>;
34
+ let dynamoSend: ReturnType<typeof vi.fn>;
35
+
36
+ beforeEach(() => {
37
+ vi.clearAllMocks();
38
+ store = new AwsCredentialStore({ region: 'us-east-1', locksTable: 'test-locks' });
39
+ // Access mock send functions injected by the mocked constructors
40
+ smSend = (store as any).sm.send as ReturnType<typeof vi.fn>;
41
+ dynamoSend = (store as any).dynamo.send as ReturnType<typeof vi.fn>;
42
+ });
43
+
44
+ // ─── findByRef() ────────────────────────────────────────────────────────────
45
+
46
+ describe('findByRef()', () => {
47
+ it('returns active credential when SM returns active SecretString', async () => {
48
+ const cred = makeCred();
49
+ smSend.mockResolvedValue({ SecretString: JSON.stringify(cred) });
50
+ const result = await store.findByRef('openai-prod-slot');
51
+ expect(result).toEqual(cred);
52
+ });
53
+
54
+ it('returns null when credential status is not active', async () => {
55
+ const cred = makeCred({ status: 'pending' });
56
+ smSend.mockResolvedValue({ SecretString: JSON.stringify(cred) });
57
+ expect(await store.findByRef('openai-prod-slot')).toBeNull();
58
+ });
59
+
60
+ it('returns null when SecretString is absent on the SM response', async () => {
61
+ smSend.mockResolvedValue({ SecretString: undefined });
62
+ expect(await store.findByRef('openai-prod-slot')).toBeNull();
63
+ });
64
+
65
+ it('returns null without throwing when SM send throws', async () => {
66
+ smSend.mockRejectedValue(new Error('ResourceNotFoundException'));
67
+ expect(await store.findByRef('missing-ref')).toBeNull();
68
+ });
69
+
70
+ it('sends GetSecretValueCommand with the correct SecretId', async () => {
71
+ const cred = makeCred();
72
+ smSend.mockResolvedValue({ SecretString: JSON.stringify(cred) });
73
+ await store.findByRef('my-secret-ref');
74
+ expect(smSend).toHaveBeenCalledWith(
75
+ expect.objectContaining({ SecretId: 'my-secret-ref' })
76
+ );
77
+ });
78
+ });
79
+
80
+ // ─── listActive() ───────────────────────────────────────────────────────────
81
+
82
+ describe('listActive()', () => {
83
+ it('returns credentials where tag value is active, parsed from Description', async () => {
84
+ const cred = makeCred();
85
+ smSend.mockResolvedValue({
86
+ SecretList: [
87
+ {
88
+ Tags: [{ Key: 'agent-identity-status', Value: 'active' }],
89
+ Description: JSON.stringify(cred),
90
+ },
91
+ ],
92
+ });
93
+ const results = await store.listActive();
94
+ expect(results).toHaveLength(1);
95
+ expect(results[0]).toEqual(cred);
96
+ });
97
+
98
+ it('skips secrets where the agent-identity-status tag value is not active', async () => {
99
+ smSend.mockResolvedValue({
100
+ SecretList: [
101
+ {
102
+ Tags: [{ Key: 'agent-identity-status', Value: 'revoked' }],
103
+ Description: JSON.stringify(makeCred({ status: 'revoked' })),
104
+ },
105
+ ],
106
+ });
107
+ expect(await store.listActive()).toHaveLength(0);
108
+ });
109
+
110
+ it('returns empty array when SecretList is undefined', async () => {
111
+ smSend.mockResolvedValue({ SecretList: undefined });
112
+ expect(await store.listActive()).toEqual([]);
113
+ });
114
+
115
+ it('skips secrets with malformed Description JSON without throwing', async () => {
116
+ smSend.mockResolvedValue({
117
+ SecretList: [
118
+ {
119
+ Tags: [{ Key: 'agent-identity-status', Value: 'active' }],
120
+ Description: 'not-valid-json',
121
+ },
122
+ ],
123
+ });
124
+ expect(await store.listActive()).toHaveLength(0);
125
+ });
126
+ });
127
+
128
+ // ─── listByKind() ───────────────────────────────────────────────────────────
129
+
130
+ describe('listByKind()', () => {
131
+ it('returns only credentials matching the requested kind', async () => {
132
+ const fixed = makeCred({ kind: 'fixed', id: 'cred-fixed' });
133
+ const delegated = makeCred({ kind: 'user-delegated', id: 'cred-user', ref: 'user-slot' });
134
+ smSend.mockResolvedValue({
135
+ SecretList: [
136
+ { Tags: [{ Key: 'agent-identity-status', Value: 'active' }], Description: JSON.stringify(fixed) },
137
+ { Tags: [{ Key: 'agent-identity-status', Value: 'active' }], Description: JSON.stringify(delegated) },
138
+ ],
139
+ });
140
+ const result = await store.listByKind('fixed');
141
+ expect(result).toHaveLength(1);
142
+ expect(result[0].kind).toBe('fixed');
143
+ });
144
+
145
+ it('returns empty array when no credentials match the requested kind', async () => {
146
+ const fixed = makeCred({ kind: 'fixed' });
147
+ smSend.mockResolvedValue({
148
+ SecretList: [
149
+ { Tags: [{ Key: 'agent-identity-status', Value: 'active' }], Description: JSON.stringify(fixed) },
150
+ ],
151
+ });
152
+ expect(await store.listByKind('user-delegated')).toHaveLength(0);
153
+ });
154
+ });
155
+
156
+ // ─── reserve() ──────────────────────────────────────────────────────────────
157
+
158
+ describe('reserve()', () => {
159
+ it('returns true when DynamoDB PutItem succeeds (no conflicting lock)', async () => {
160
+ dynamoSend.mockResolvedValue({});
161
+ expect(await store.reserve('cred-ref', 'migration-1', 300)).toBe(true);
162
+ });
163
+
164
+ it('returns false when DynamoDB throws ConditionalCheckFailedException', async () => {
165
+ dynamoSend.mockRejectedValue(new Error('ConditionalCheckFailedException'));
166
+ expect(await store.reserve('cred-ref', 'migration-2', 300)).toBe(false);
167
+ });
168
+
169
+ it('sends PutItemCommand to the configured locksTable name', async () => {
170
+ dynamoSend.mockResolvedValue({});
171
+ await store.reserve('my-ref', 'mig-id', 600);
172
+ expect(dynamoSend).toHaveBeenCalledWith(
173
+ expect.objectContaining({ TableName: 'test-locks' })
174
+ );
175
+ });
176
+ });
177
+
178
+ // ─── release() ──────────────────────────────────────────────────────────────
179
+
180
+ describe('release()', () => {
181
+ it('issues a DeleteItemCommand with the correct ref key', async () => {
182
+ dynamoSend.mockResolvedValue({});
183
+ await store.release('cred-ref', 'migration-1');
184
+ expect(dynamoSend).toHaveBeenCalledWith(
185
+ expect.objectContaining({
186
+ Key: { ref: { S: 'cred-ref' } },
187
+ })
188
+ );
189
+ });
190
+
191
+ it('resolves without throwing when DeleteItem throws (idempotent release)', async () => {
192
+ dynamoSend.mockRejectedValue(new Error('ConditionalCheckFailedException'));
193
+ await expect(store.release('cred-ref', 'migration-x')).resolves.toBeUndefined();
194
+ });
195
+ });
196
+ });