@enactprotocol/secrets 2.0.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.
@@ -0,0 +1,217 @@
1
+ import { beforeEach, describe, expect, it, mock } from "bun:test";
2
+ import { KEYRING_SERVICE } from "../../src/types";
3
+ import { mockKeyring, resetMockKeyring, seedMockKeyring } from "../mocks/keyring";
4
+
5
+ // Mock the keyring module before importing
6
+ mock.module("../../src/keyring", () => ({
7
+ getSecret: async (namespace: string, name: string) => {
8
+ const account = `${namespace}:${name}`;
9
+ return mockKeyring.getPassword(KEYRING_SERVICE, account);
10
+ },
11
+ }));
12
+
13
+ // Import after mocking
14
+ const { getSecretObject, getSecretObjects, parseSecretOverride, parseSecretOverrides } =
15
+ await import("../../src/dagger/secret-object");
16
+
17
+ describe("dagger/secret-object", () => {
18
+ const testService = KEYRING_SERVICE;
19
+
20
+ beforeEach(() => {
21
+ resetMockKeyring();
22
+ });
23
+
24
+ describe("getSecretObject", () => {
25
+ it("should get secret from keyring", async () => {
26
+ seedMockKeyring(testService, [
27
+ { namespace: "alice/api", name: "API_TOKEN", value: "keyring-token" },
28
+ ]);
29
+
30
+ const result = await getSecretObject("alice/api", "API_TOKEN");
31
+
32
+ expect(result.name).toBe("API_TOKEN");
33
+ expect(result.value).toBe("keyring-token");
34
+ expect(result.source).toBe("keyring");
35
+ expect(result.namespace).toBe("alice/api");
36
+ });
37
+
38
+ it("should use override URI when provided", async () => {
39
+ process.env.TEST_OVERRIDE_VAR = "override-value";
40
+
41
+ const result = await getSecretObject("alice/api", "API_TOKEN", {
42
+ overrideUri: "env://TEST_OVERRIDE_VAR",
43
+ });
44
+
45
+ expect(result.name).toBe("API_TOKEN");
46
+ expect(result.value).toBe("override-value");
47
+ expect(result.source).toBe("override");
48
+ expect(result.overrideUri).toBe("env://TEST_OVERRIDE_VAR");
49
+
50
+ process.env.TEST_OVERRIDE_VAR = undefined;
51
+ });
52
+
53
+ it("should prefer override URI over keyring", async () => {
54
+ process.env.TEST_PRIORITY_VAR = "override-wins";
55
+ seedMockKeyring(testService, [
56
+ { namespace: "alice/api", name: "API_TOKEN", value: "keyring-value" },
57
+ ]);
58
+
59
+ const result = await getSecretObject("alice/api", "API_TOKEN", {
60
+ overrideUri: "env://TEST_PRIORITY_VAR",
61
+ });
62
+
63
+ expect(result.value).toBe("override-wins");
64
+ expect(result.source).toBe("override");
65
+
66
+ process.env.TEST_PRIORITY_VAR = undefined;
67
+ });
68
+
69
+ it("should throw for missing secret without override", async () => {
70
+ await expect(getSecretObject("alice/api", "NONEXISTENT_SECRET")).rejects.toThrow(
71
+ /Secret.*not found/
72
+ );
73
+ });
74
+
75
+ it("should use namespace inheritance to find secrets", async () => {
76
+ seedMockKeyring(testService, [
77
+ { namespace: "alice", name: "SHARED_KEY", value: "root-level" },
78
+ ]);
79
+
80
+ const result = await getSecretObject("alice/api/slack", "SHARED_KEY");
81
+
82
+ expect(result.value).toBe("root-level");
83
+ expect(result.namespace).toBe("alice");
84
+ });
85
+ });
86
+
87
+ describe("getSecretObjects", () => {
88
+ it("should get multiple secrets from keyring", async () => {
89
+ seedMockKeyring(testService, [
90
+ { namespace: "alice/api", name: "KEY1", value: "value1" },
91
+ { namespace: "alice/api", name: "KEY2", value: "value2" },
92
+ ]);
93
+
94
+ const results = await getSecretObjects("alice/api", {
95
+ KEY1: undefined,
96
+ KEY2: undefined,
97
+ });
98
+
99
+ expect(results.size).toBe(2);
100
+ expect(results.get("KEY1")?.value).toBe("value1");
101
+ expect(results.get("KEY2")?.value).toBe("value2");
102
+ });
103
+
104
+ it("should support mixed keyring and override", async () => {
105
+ process.env.TEST_MIXED_VAR = "from-env";
106
+ seedMockKeyring(testService, [
107
+ { namespace: "alice/api", name: "KEY1", value: "from-keyring" },
108
+ ]);
109
+
110
+ const results = await getSecretObjects("alice/api", {
111
+ KEY1: undefined,
112
+ KEY2: "env://TEST_MIXED_VAR",
113
+ });
114
+
115
+ expect(results.get("KEY1")?.value).toBe("from-keyring");
116
+ expect(results.get("KEY1")?.source).toBe("keyring");
117
+ expect(results.get("KEY2")?.value).toBe("from-env");
118
+ expect(results.get("KEY2")?.source).toBe("override");
119
+
120
+ process.env.TEST_MIXED_VAR = undefined;
121
+ });
122
+
123
+ it("should return empty map for empty input", async () => {
124
+ const results = await getSecretObjects("alice/api", {});
125
+
126
+ expect(results.size).toBe(0);
127
+ });
128
+ });
129
+
130
+ describe("parseSecretOverride", () => {
131
+ it("should parse valid override", () => {
132
+ const result = parseSecretOverride("API_TOKEN=env://MY_TOKEN");
133
+
134
+ expect(result).not.toBeNull();
135
+ expect(result?.name).toBe("API_TOKEN");
136
+ expect(result?.uri).toBe("env://MY_TOKEN");
137
+ });
138
+
139
+ it("should parse override with file:// URI", () => {
140
+ const result = parseSecretOverride("SECRET_FILE=file:///etc/secret");
141
+
142
+ expect(result?.name).toBe("SECRET_FILE");
143
+ expect(result?.uri).toBe("file:///etc/secret");
144
+ });
145
+
146
+ it("should parse override with cmd:// URI", () => {
147
+ const result = parseSecretOverride("DYNAMIC=cmd://echo hello");
148
+
149
+ expect(result?.name).toBe("DYNAMIC");
150
+ expect(result?.uri).toBe("cmd://echo hello");
151
+ });
152
+
153
+ it("should return null for missing =", () => {
154
+ const result = parseSecretOverride("API_TOKEN");
155
+
156
+ expect(result).toBeNull();
157
+ });
158
+
159
+ it("should return null for invalid URI", () => {
160
+ const result = parseSecretOverride("API_TOKEN=not-a-uri");
161
+
162
+ expect(result).toBeNull();
163
+ });
164
+
165
+ it("should return null for empty name", () => {
166
+ const result = parseSecretOverride("=env://TOKEN");
167
+
168
+ expect(result).toBeNull();
169
+ });
170
+
171
+ it("should handle whitespace", () => {
172
+ const result = parseSecretOverride(" API_TOKEN = env://TOKEN ");
173
+
174
+ expect(result?.name).toBe("API_TOKEN");
175
+ expect(result?.uri).toBe("env://TOKEN");
176
+ });
177
+ });
178
+
179
+ describe("parseSecretOverrides", () => {
180
+ it("should parse multiple overrides", () => {
181
+ const result = parseSecretOverrides([
182
+ "KEY1=env://VAR1",
183
+ "KEY2=file:///etc/secret",
184
+ "KEY3=cmd://echo test",
185
+ ]);
186
+
187
+ expect(result.KEY1).toBe("env://VAR1");
188
+ expect(result.KEY2).toBe("file:///etc/secret");
189
+ expect(result.KEY3).toBe("cmd://echo test");
190
+ });
191
+
192
+ it("should skip invalid overrides", () => {
193
+ const result = parseSecretOverrides([
194
+ "KEY1=env://VAR1",
195
+ "INVALID",
196
+ "KEY2=not-a-uri",
197
+ "KEY3=file:///valid",
198
+ ]);
199
+
200
+ expect(Object.keys(result)).toHaveLength(2);
201
+ expect(result.KEY1).toBe("env://VAR1");
202
+ expect(result.KEY3).toBe("file:///valid");
203
+ });
204
+
205
+ it("should return empty object for empty input", () => {
206
+ const result = parseSecretOverrides([]);
207
+
208
+ expect(result).toEqual({});
209
+ });
210
+
211
+ it("should handle duplicates (last wins)", () => {
212
+ const result = parseSecretOverrides(["KEY=env://VAR1", "KEY=env://VAR2"]);
213
+
214
+ expect(result.KEY).toBe("env://VAR2");
215
+ });
216
+ });
217
+ });
@@ -0,0 +1,194 @@
1
+ import { afterEach, beforeEach, describe, expect, it } from "bun:test";
2
+ import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import {
6
+ getSupportedSchemes,
7
+ isSecretUri,
8
+ parseSecretUri,
9
+ resolveSecretUri,
10
+ } from "../../src/dagger/uri-parser";
11
+
12
+ describe("dagger/uri-parser", () => {
13
+ const testDir = join(tmpdir(), `enact-uri-test-${Date.now()}`);
14
+
15
+ beforeEach(() => {
16
+ mkdirSync(testDir, { recursive: true });
17
+ });
18
+
19
+ afterEach(() => {
20
+ if (existsSync(testDir)) {
21
+ rmSync(testDir, { recursive: true });
22
+ }
23
+ });
24
+
25
+ describe("parseSecretUri", () => {
26
+ it("should parse env:// URI", () => {
27
+ const result = parseSecretUri("env://API_KEY");
28
+
29
+ expect(result.scheme).toBe("env");
30
+ expect(result.value).toBe("API_KEY");
31
+ expect(result.original).toBe("env://API_KEY");
32
+ });
33
+
34
+ it("should parse file:// URI", () => {
35
+ const result = parseSecretUri("file:///path/to/secret.txt");
36
+
37
+ expect(result.scheme).toBe("file");
38
+ expect(result.value).toBe("/path/to/secret.txt");
39
+ });
40
+
41
+ it("should parse cmd:// URI", () => {
42
+ const result = parseSecretUri("cmd://echo hello");
43
+
44
+ expect(result.scheme).toBe("cmd");
45
+ expect(result.value).toBe("echo hello");
46
+ });
47
+
48
+ it("should parse op:// URI (1Password)", () => {
49
+ const result = parseSecretUri("op://Private/API Token/credential");
50
+
51
+ expect(result.scheme).toBe("op");
52
+ expect(result.value).toBe("Private/API Token/credential");
53
+ });
54
+
55
+ it("should parse vault:// URI", () => {
56
+ const result = parseSecretUri("vault://secret/data/myapp#api_key");
57
+
58
+ expect(result.scheme).toBe("vault");
59
+ expect(result.value).toBe("secret/data/myapp#api_key");
60
+ });
61
+
62
+ it("should throw for invalid URI format", () => {
63
+ expect(() => parseSecretUri("not-a-uri")).toThrow(/Invalid secret URI format/);
64
+ });
65
+
66
+ it("should throw for missing scheme", () => {
67
+ expect(() => parseSecretUri("://value")).toThrow(/Invalid secret URI format/);
68
+ });
69
+
70
+ it("should throw for missing value", () => {
71
+ expect(() => parseSecretUri("env://")).toThrow(/Invalid secret URI format/);
72
+ });
73
+
74
+ it("should throw for invalid scheme", () => {
75
+ expect(() => parseSecretUri("http://example.com")).toThrow(/Invalid secret URI scheme/);
76
+ });
77
+ });
78
+
79
+ describe("isSecretUri", () => {
80
+ it("should return true for valid env:// URI", () => {
81
+ expect(isSecretUri("env://API_KEY")).toBe(true);
82
+ });
83
+
84
+ it("should return true for valid file:// URI", () => {
85
+ expect(isSecretUri("file:///etc/secret")).toBe(true);
86
+ });
87
+
88
+ it("should return true for valid cmd:// URI", () => {
89
+ expect(isSecretUri("cmd://cat /etc/secret")).toBe(true);
90
+ });
91
+
92
+ it("should return true for valid op:// URI", () => {
93
+ expect(isSecretUri("op://Vault/Item/Field")).toBe(true);
94
+ });
95
+
96
+ it("should return true for valid vault:// URI", () => {
97
+ expect(isSecretUri("vault://secret/path")).toBe(true);
98
+ });
99
+
100
+ it("should return false for plain string", () => {
101
+ expect(isSecretUri("just-a-value")).toBe(false);
102
+ });
103
+
104
+ it("should return false for invalid scheme", () => {
105
+ expect(isSecretUri("http://example.com")).toBe(false);
106
+ });
107
+
108
+ it("should return false for malformed URI", () => {
109
+ expect(isSecretUri("env:API_KEY")).toBe(false);
110
+ });
111
+ });
112
+
113
+ describe("resolveSecretUri", () => {
114
+ describe("env:// scheme", () => {
115
+ it("should resolve environment variable", async () => {
116
+ process.env.TEST_SECRET_VAR = "secret-value";
117
+
118
+ const result = await resolveSecretUri("env://TEST_SECRET_VAR");
119
+
120
+ expect(result).toBe("secret-value");
121
+
122
+ process.env.TEST_SECRET_VAR = undefined;
123
+ });
124
+
125
+ it("should throw for undefined environment variable", async () => {
126
+ await expect(resolveSecretUri("env://NONEXISTENT_VAR_12345")).rejects.toThrow(
127
+ /Environment variable.*is not set/
128
+ );
129
+ });
130
+ });
131
+
132
+ describe("file:// scheme", () => {
133
+ it("should read file contents", async () => {
134
+ const secretFile = join(testDir, "secret.txt");
135
+ writeFileSync(secretFile, "file-secret-value\n");
136
+
137
+ const result = await resolveSecretUri(`file://${secretFile}`);
138
+
139
+ expect(result).toBe("file-secret-value");
140
+ });
141
+
142
+ it("should trim whitespace from file contents", async () => {
143
+ const secretFile = join(testDir, "secret-ws.txt");
144
+ writeFileSync(secretFile, " secret \n\n");
145
+
146
+ const result = await resolveSecretUri(`file://${secretFile}`);
147
+
148
+ expect(result).toBe("secret");
149
+ });
150
+
151
+ it("should throw for non-existent file", async () => {
152
+ await expect(resolveSecretUri(`file://${testDir}/nonexistent.txt`)).rejects.toThrow(
153
+ /File not found/
154
+ );
155
+ });
156
+ });
157
+
158
+ describe("cmd:// scheme", () => {
159
+ it("should execute command and return output", async () => {
160
+ const result = await resolveSecretUri("cmd://echo test-output");
161
+
162
+ expect(result).toBe("test-output");
163
+ });
164
+
165
+ it("should trim command output", async () => {
166
+ const result = await resolveSecretUri("cmd://printf ' value '");
167
+
168
+ expect(result).toBe("value");
169
+ });
170
+
171
+ it("should throw for failed command", async () => {
172
+ await expect(
173
+ resolveSecretUri("cmd://false") // 'false' command always fails
174
+ ).rejects.toThrow(/Command failed/);
175
+ });
176
+ });
177
+
178
+ // Note: op:// and vault:// tests would require those tools to be installed
179
+ // They're tested in integration tests with mocks
180
+ });
181
+
182
+ describe("getSupportedSchemes", () => {
183
+ it("should return all supported schemes", () => {
184
+ const schemes = getSupportedSchemes();
185
+
186
+ expect(schemes).toContain("env");
187
+ expect(schemes).toContain("file");
188
+ expect(schemes).toContain("cmd");
189
+ expect(schemes).toContain("op");
190
+ expect(schemes).toContain("vault");
191
+ expect(schemes).toHaveLength(5);
192
+ });
193
+ });
194
+ });
@@ -0,0 +1,220 @@
1
+ import { afterEach, beforeEach, describe, expect, it } from "bun:test";
2
+ import { existsSync, mkdirSync, rmSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import {
6
+ deleteEnv,
7
+ getEnv,
8
+ getEnvValue,
9
+ hasLocalEnv,
10
+ listEnv,
11
+ resolveAllEnv,
12
+ resolveToolEnv,
13
+ setEnv,
14
+ } from "../../src/env/manager";
15
+ import { setLocalEnvVar } from "../../src/env/writer";
16
+
17
+ describe("env/manager", () => {
18
+ const testDir = join(tmpdir(), `enact-manager-test-${Date.now()}`);
19
+ const localEnvDir = join(testDir, ".enact");
20
+ const localEnvPath = join(localEnvDir, ".env");
21
+
22
+ beforeEach(() => {
23
+ mkdirSync(testDir, { recursive: true });
24
+ });
25
+
26
+ afterEach(() => {
27
+ if (existsSync(testDir)) {
28
+ rmSync(testDir, { recursive: true });
29
+ }
30
+ });
31
+
32
+ describe("setEnv", () => {
33
+ it("should set local env var when scope is local", () => {
34
+ setEnv("LOCAL_VAR", "local-value", "local", testDir);
35
+
36
+ expect(existsSync(localEnvPath)).toBe(true);
37
+ const result = getEnv("LOCAL_VAR", undefined, testDir);
38
+ expect(result?.value).toBe("local-value");
39
+ expect(result?.source).toBe("local");
40
+ });
41
+
42
+ // Note: Testing global env would require mocking homedir() or using temp home
43
+ });
44
+
45
+ describe("getEnv", () => {
46
+ it("should return local value with local source", () => {
47
+ setLocalEnvVar("KEY", "local-value", testDir);
48
+
49
+ const result = getEnv("KEY", undefined, testDir);
50
+
51
+ expect(result).not.toBeNull();
52
+ expect(result?.value).toBe("local-value");
53
+ expect(result?.source).toBe("local");
54
+ expect(result?.filePath).toContain(".enact");
55
+ });
56
+
57
+ it("should return default value when not found", () => {
58
+ const result = getEnv("NONEXISTENT", "default-value", testDir);
59
+
60
+ expect(result?.value).toBe("default-value");
61
+ expect(result?.source).toBe("default");
62
+ });
63
+
64
+ it("should return null when not found and no default", () => {
65
+ const result = getEnv("NONEXISTENT", undefined, testDir);
66
+
67
+ expect(result).toBeNull();
68
+ });
69
+
70
+ it("should include key in result", () => {
71
+ setLocalEnvVar("MY_KEY", "value", testDir);
72
+
73
+ const result = getEnv("MY_KEY", undefined, testDir);
74
+
75
+ expect(result?.key).toBe("MY_KEY");
76
+ });
77
+ });
78
+
79
+ describe("getEnvValue", () => {
80
+ it("should return just the value", () => {
81
+ setLocalEnvVar("KEY", "the-value", testDir);
82
+
83
+ const value = getEnvValue("KEY", undefined, testDir);
84
+
85
+ expect(value).toBe("the-value");
86
+ });
87
+
88
+ it("should return default if not found", () => {
89
+ const value = getEnvValue("NONEXISTENT", "default", testDir);
90
+
91
+ expect(value).toBe("default");
92
+ });
93
+
94
+ it("should return undefined if not found and no default", () => {
95
+ const value = getEnvValue("NONEXISTENT", undefined, testDir);
96
+
97
+ expect(value).toBeUndefined();
98
+ });
99
+ });
100
+
101
+ describe("deleteEnv", () => {
102
+ it("should delete local env var", () => {
103
+ setLocalEnvVar("TO_DELETE", "value", testDir);
104
+ expect(getEnvValue("TO_DELETE", undefined, testDir)).toBe("value");
105
+
106
+ const deleted = deleteEnv("TO_DELETE", "local", testDir);
107
+
108
+ expect(deleted).toBe(true);
109
+ expect(getEnvValue("TO_DELETE", undefined, testDir)).toBeUndefined();
110
+ });
111
+
112
+ it("should return false if var doesn't exist", () => {
113
+ // Create the file first with some other var
114
+ setLocalEnvVar("OTHER", "value", testDir);
115
+
116
+ const deleted = deleteEnv("NONEXISTENT", "local", testDir);
117
+
118
+ expect(deleted).toBe(false);
119
+ });
120
+ });
121
+
122
+ describe("listEnv", () => {
123
+ it("should list local env vars", () => {
124
+ setLocalEnvVar("KEY1", "value1", testDir);
125
+ setLocalEnvVar("KEY2", "value2", testDir);
126
+
127
+ const vars = listEnv("local", testDir);
128
+
129
+ expect(vars).toHaveLength(2);
130
+ expect(vars.find((v) => v.key === "KEY1")?.value).toBe("value1");
131
+ expect(vars.find((v) => v.key === "KEY2")?.value).toBe("value2");
132
+ expect(vars.every((v) => v.source === "local")).toBe(true);
133
+ });
134
+
135
+ it("should return empty array if no vars", () => {
136
+ const vars = listEnv("local", testDir);
137
+
138
+ expect(vars).toEqual([]);
139
+ });
140
+ });
141
+
142
+ describe("resolveAllEnv", () => {
143
+ it("should merge defaults with local vars", () => {
144
+ const defaults = { KEY1: "default1", KEY2: "default2" };
145
+ setLocalEnvVar("KEY2", "local2", testDir);
146
+
147
+ const resolved = resolveAllEnv(defaults, testDir);
148
+
149
+ expect(resolved.get("KEY1")?.value).toBe("default1");
150
+ expect(resolved.get("KEY1")?.source).toBe("default");
151
+ expect(resolved.get("KEY2")?.value).toBe("local2");
152
+ expect(resolved.get("KEY2")?.source).toBe("local");
153
+ });
154
+
155
+ it("should work with empty defaults", () => {
156
+ setLocalEnvVar("KEY", "value", testDir);
157
+
158
+ const resolved = resolveAllEnv({}, testDir);
159
+
160
+ expect(resolved.get("KEY")?.value).toBe("value");
161
+ });
162
+ });
163
+
164
+ describe("resolveToolEnv", () => {
165
+ it("should resolve env vars from tool declarations", () => {
166
+ setLocalEnvVar("API_URL", "https://api.example.com", testDir);
167
+
168
+ const declarations = {
169
+ API_URL: { description: "API endpoint" },
170
+ TIMEOUT: { description: "Timeout in ms", default: "5000" },
171
+ };
172
+
173
+ const { resolved, missing } = resolveToolEnv(declarations, testDir);
174
+
175
+ expect(missing).toEqual([]);
176
+ expect(resolved.get("API_URL")?.value).toBe("https://api.example.com");
177
+ expect(resolved.get("TIMEOUT")?.value).toBe("5000");
178
+ expect(resolved.get("TIMEOUT")?.source).toBe("default");
179
+ });
180
+
181
+ it("should report missing required vars", () => {
182
+ const declarations = {
183
+ REQUIRED_VAR: { description: "This is required" },
184
+ OPTIONAL_VAR: { description: "This has default", default: "default" },
185
+ };
186
+
187
+ const { resolved, missing } = resolveToolEnv(declarations, testDir);
188
+
189
+ expect(missing).toEqual(["REQUIRED_VAR"]);
190
+ expect(resolved.get("OPTIONAL_VAR")?.value).toBe("default");
191
+ });
192
+
193
+ it("should skip secret declarations", () => {
194
+ setLocalEnvVar("SECRET_KEY", "should-not-be-used", testDir);
195
+
196
+ const declarations = {
197
+ SECRET_KEY: { description: "API secret", secret: true },
198
+ NORMAL_KEY: { description: "Normal key", default: "default" },
199
+ };
200
+
201
+ const { resolved, missing } = resolveToolEnv(declarations, testDir);
202
+
203
+ expect(resolved.has("SECRET_KEY")).toBe(false);
204
+ expect(resolved.get("NORMAL_KEY")?.value).toBe("default");
205
+ expect(missing).toEqual([]);
206
+ });
207
+ });
208
+
209
+ describe("hasLocalEnv", () => {
210
+ it("should return true if local .env exists", () => {
211
+ setLocalEnvVar("KEY", "value", testDir);
212
+
213
+ expect(hasLocalEnv(testDir)).toBe(true);
214
+ });
215
+
216
+ it("should return false if local .env doesn't exist", () => {
217
+ expect(hasLocalEnv(testDir)).toBe(false);
218
+ });
219
+ });
220
+ });