Files
bun.sh/test/js/bun/secrets.test.ts
Jarred Sumner 707fc4c3a2 Introduce Bun.secrets API (#21973)
This PR adds `Bun.secrets`, a new API for securely storing and
retrieving credentials using the operating system's native credential
storage locally. This helps developers avoid storing sensitive data in
plaintext config files.

```javascript
// Store a GitHub token securely
await Bun.secrets.set({
  service: "my-cli-tool",
  name: "github-token",
  value: "ghp_xxxxxxxxxxxxxxxxxxxx"
});

// Retrieve it when needed
const token = await Bun.secrets.get({
  service: "my-cli-tool",
  name: "github-token"
});

// Use with fallback to environment variable
const apiKey = await Bun.secrets.get({
  service: "my-app",
  name: "api-key"
}) || process.env.API_KEY;
```

Marking this as a draft because Linux and Windows have not been manually
tested yet. This API is only really meant for local development usecases
right now, but it would be nice if in the future to support adapters for
production or CI usecases.

### Core API
- `Bun.secrets.get({ service, name })` - Retrieve a stored credential
- `Bun.secrets.set({ service, name, value })` - Store or update a
credential
- `Bun.secrets.delete({ service, name })` - Delete a stored credential

### Platform Support
- **macOS**: Uses Keychain Services via Security.framework
- **Linux**: Uses libsecret (works with GNOME Keyring, KWallet, etc.)
- **Windows**: Uses Windows Credential Manager via advapi32.dll

### Implementation Highlights
- Non-blocking - all operations run on the threadpool
- Dynamic loading - no hard dependencies on system libraries
- Sensitive data is zeroed after use
- Consistent API across all platforms

## Use Cases

This API is particularly useful for:
- CLI tools that need to store authentication tokens
- Development tools that manage API keys
- Any tool that currently stores credentials in `~/.npmrc`,
`~/.aws/credentials` or in environment variables that're globally loaded

## Testing

Comprehensive test suite included with coverage for:
- Basic CRUD operations
- Empty strings and special characters
- Unicode support
- Concurrent operations
- Error handling

All tests pass on macOS. Linux and Windows implementations are complete
but would benefit from additional platform testing.

## Documentation

- Complete API documentation in `docs/api/secrets.md`
- TypeScript definitions with detailed JSDoc comments and examples

---------

Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
Co-authored-by: Claude Bot <claude-bot@bun.sh>
Co-authored-by: Claude <noreply@anthropic.com>
2025-08-23 06:57:00 -07:00

302 lines
9.8 KiB
TypeScript

import { expect, test } from "bun:test";
import { isCI, isMacOS, isWindows } from "harness";
// Helper to determine if we should use unrestricted keychain access
// This is needed for macOS CI environments where user interaction is not available
function shouldUseUnrestrictedAccess(): boolean {
return isMacOS && isCI;
}
// Setup keyring environment for Linux CI
test.todoIf(isCI && !isWindows)("Bun.secrets API", async () => {
const testService = "bun-test-service-" + Date.now();
const testUser = "test-name-" + Math.random();
const testPassword = "super-secret-value-123!@#";
const updatedPassword = "new-value-456$%^";
// Clean up any existing value first
await Bun.secrets.delete({ service: testService, name: testUser });
// Test 1: GET non-existent credential should return null
{
const result = await Bun.secrets.get({ service: testService, name: testUser });
expect(result).toBeNull();
}
// Test 2: DELETE non-existent credential should return false
{
const result = await Bun.secrets.delete({ service: testService, name: testUser });
expect(result).toBe(false);
}
// Test 3: SET new credential
{
await Bun.secrets.set({
service: testService,
name: testUser,
value: testPassword,
...(shouldUseUnrestrictedAccess() && { allowUnrestrictedAccess: true }),
});
const retrieved = await Bun.secrets.get({ service: testService, name: testUser });
expect(retrieved).toBe(testPassword);
}
// Test 4: SET existing credential (should replace)
{
await Bun.secrets.set({
service: testService,
name: testUser,
value: updatedPassword,
...(shouldUseUnrestrictedAccess() && { allowUnrestrictedAccess: true }),
});
const retrieved = await Bun.secrets.get({ service: testService, name: testUser });
expect(retrieved).toBe(updatedPassword);
expect(retrieved).not.toBe(testPassword);
}
// Test 5: DELETE existing credential should return true
{
const result = await Bun.secrets.delete({ service: testService, name: testUser });
expect(result).toBe(true);
}
// Test 6: GET after DELETE should return null
{
const result = await Bun.secrets.get({ service: testService, name: testUser });
expect(result).toBeNull();
}
// Test 7: DELETE after DELETE should return false
{
const result = await Bun.secrets.delete({ service: testService, name: testUser });
expect(result).toBe(false);
}
// Test 8: SET after DELETE should work
{
await Bun.secrets.set({
service: testService,
name: testUser,
value: testPassword,
...(shouldUseUnrestrictedAccess() && { allowUnrestrictedAccess: true }),
});
const retrieved = await Bun.secrets.get({ service: testService, name: testUser });
expect(retrieved).toBe(testPassword);
}
// Test 9: Verify multiple operations work correctly
{
// Set, get, delete, verify cycle
await Bun.secrets.set({
service: testService,
name: testUser,
value: testPassword,
...(shouldUseUnrestrictedAccess() && { allowUnrestrictedAccess: true }),
});
expect(await Bun.secrets.get({ service: testService, name: testUser })).toBe(testPassword);
expect(await Bun.secrets.delete({ service: testService, name: testUser })).toBe(true);
expect(await Bun.secrets.get({ service: testService, name: testUser })).toBeNull();
}
// Test 10: Empty string deletes credential
{
// Set a credential first
await Bun.secrets.set({
service: testService,
name: testUser,
value: testPassword,
...(shouldUseUnrestrictedAccess() && { allowUnrestrictedAccess: true }),
});
expect(await Bun.secrets.get({ service: testService, name: testUser })).toBe(testPassword);
// Empty string should delete it
await Bun.secrets.set({
service: testService,
name: testUser,
value: "",
...(shouldUseUnrestrictedAccess() && { allowUnrestrictedAccess: true }),
});
expect(await Bun.secrets.get({ service: testService, name: testUser })).toBeNull();
// Empty string on non-existent credential should not error
await Bun.secrets.set({
service: testService + "-empty",
name: testUser,
value: "",
...(shouldUseUnrestrictedAccess() && { allowUnrestrictedAccess: true }),
});
expect(await Bun.secrets.get({ service: testService + "-empty", name: testUser })).toBeNull();
}
// Clean up
await Bun.secrets.delete({ service: testService, name: testUser });
});
test.todoIf(isCI && !isWindows)("Bun.secrets error handling", async () => {
// Test invalid arguments
// Test 1: GET with missing options
try {
// @ts-expect-error - testing invalid input
await Bun.secrets.get();
expect.unreachable("Should have thrown");
} catch (error) {
expect(error.message).toContain("secrets.get requires an options object");
}
// Test 2: GET with non-object options
try {
// @ts-expect-error - testing invalid input
await Bun.secrets.get("not an object");
expect.unreachable("Should have thrown");
} catch (error) {
expect(error.message).toContain("Expected options to be an object");
}
// Test 3: GET with missing service
try {
// @ts-expect-error - testing invalid input
await Bun.secrets.get({ name: "test" });
expect.unreachable("Should have thrown");
} catch (error) {
expect(error.message).toContain("Expected service and name to be strings");
}
// Test 4: GET with missing name
try {
// @ts-expect-error - testing invalid input
await Bun.secrets.get({ service: "test" });
expect.unreachable("Should have thrown");
} catch (error) {
expect(error.message).toContain("Expected service and name to be strings");
}
// Test 5: SET with missing value
try {
// @ts-expect-error - testing invalid input
await Bun.secrets.set({ service: "test", name: "test" });
// This should work without error - just needs a value
// But if it does work, the value will be undefined which is an error
} catch (error) {
expect(error.message).toContain("Expected 'value' to be a string");
}
// Test 6: SET with non-string value (not null/undefined)
try {
// @ts-expect-error - testing invalid input
await Bun.secrets.set({ service: "test", name: "test", value: 123 });
expect.unreachable("Should have thrown");
} catch (error) {
expect(error.message).toContain("Expected 'value' to be a string");
}
// Test 7: DELETE with missing options
try {
// @ts-expect-error - testing invalid input
await Bun.secrets.delete();
expect.unreachable("Should have thrown");
} catch (error) {
expect(error.message).toContain("requires an options object");
}
});
test.todoIf(isCI && !isWindows)("Bun.secrets handles empty strings as delete", async () => {
const testService = "bun-test-empty-" + Date.now();
const testUser = "test-name-empty";
// First, set a real credential
await Bun.secrets.set({
service: testService,
name: testUser,
value: "test-password",
...(shouldUseUnrestrictedAccess() && { allowUnrestrictedAccess: true }),
});
let result = await Bun.secrets.get({ service: testService, name: testUser });
expect(result).toBe("test-password");
// Test that empty string deletes the credential
await Bun.secrets.set({
service: testService,
name: testUser,
value: "",
...(shouldUseUnrestrictedAccess() && { allowUnrestrictedAccess: true }),
});
result = await Bun.secrets.get({ service: testService, name: testUser });
expect(result).toBeNull(); // Should be null since credential was deleted
// Test that setting empty string on non-existent credential doesn't error
await Bun.secrets.set({
service: testService + "-nonexistent",
name: testUser,
value: "",
...(shouldUseUnrestrictedAccess() && { allowUnrestrictedAccess: true }),
});
result = await Bun.secrets.get({ service: testService + "-nonexistent", name: testUser });
expect(result).toBeNull();
});
test.todoIf(isCI && !isWindows)("Bun.secrets handles special characters", async () => {
const testService = "bun-test-special-" + Date.now();
const testUser = "name@example.com";
const testPassword = "p@$$w0rd!#$%^&*()_+-=[]{}|;':\",./<>?`~\n\t\r";
await Bun.secrets.set({
service: testService,
name: testUser,
value: testPassword,
...(shouldUseUnrestrictedAccess() && { allowUnrestrictedAccess: true }),
});
const result = await Bun.secrets.get({ service: testService, name: testUser });
expect(result).toBe(testPassword);
// Clean up
await Bun.secrets.delete({ service: testService, name: testUser });
});
test.todoIf(isCI && !isWindows)("Bun.secrets handles unicode", async () => {
const testService = "bun-test-unicode-" + Date.now();
const testUser = "用户";
const testPassword = "密码🔒🔑 emoji and 中文";
await Bun.secrets.set({
service: testService,
name: testUser,
value: testPassword,
...(shouldUseUnrestrictedAccess() && { allowUnrestrictedAccess: true }),
});
const result = await Bun.secrets.get({ service: testService, name: testUser });
expect(result).toBe(testPassword);
// Clean up
await Bun.secrets.delete({ service: testService, name: testUser });
});
test.todoIf(isCI && !isWindows)("Bun.secrets handles concurrent operations", async () => {
const promises: Promise<void>[] = [];
const count = 10;
// Create multiple credentials concurrently
for (let i = 0; i < count; i++) {
const service = `bun-concurrent-${Date.now()}-${i}`;
const name = `name-${i}`;
const value = `value-${i}`;
promises.push(
Bun.secrets
.set({ service, name, value: value })
.then(() => Bun.secrets.get({ service, name }))
.then(retrieved => {
expect(retrieved).toBe(value);
return Bun.secrets.delete({ service, name });
})
.then(deleted => {
expect(deleted).toBe(true);
}),
);
}
await Promise.all(promises);
});