Skip to content

Commit d63f766

Browse files
authored
test(cli): cover offline cache, credentials, secret refs, ignore files (#734)
1 parent bf200a6 commit d63f766

7 files changed

Lines changed: 468 additions & 2 deletions

File tree

.changeset/cli-test-coverage.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@shelve/cli": patch
3+
---
4+
5+
Add test coverage for the v5 additions — encrypted offline cache (roundtrip / TTL / token rotation / tampering), OS-keychain credentials with XDG file fallback and legacy `~/.shelve` migration, agent-ignore files, `shelve://` secret references, and `parseDuration`. Along the way, `CredentialsService` now creates `$XDG_CONFIG_HOME` on demand so writes no longer fail on freshly provisioned machines.

packages/cli/src/services/credentials.ts

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
1-
import { chmodSync, existsSync, readFileSync, unlinkSync } from 'node:fs'
1+
import { chmodSync, existsSync, mkdirSync, readFileSync, unlinkSync } from 'node:fs'
22
import { homedir } from 'node:os'
3-
import { join } from 'node:path'
3+
import { dirname, join } from 'node:path'
44
import { readUserConfig, writeUserConfig } from 'rc9'
55
import consola from 'consola'
66
import { DEBUG } from '../constants'
@@ -59,6 +59,16 @@ function chmod600(path: string): void {
5959
}
6060
}
6161

62+
/**
63+
* rc9 does not mkdir the target directory, so `writeUserConfig` would fail on
64+
* a freshly provisioned machine where `$XDG_CONFIG_HOME` (or `~/.config`)
65+
* does not yet exist. Make sure the parent exists before handing off to rc9.
66+
*/
67+
function ensureConfigDir(): void {
68+
const dir = dirname(configPath())
69+
if (!existsSync(dir)) mkdirSync(dir, { recursive: true, mode: 0o700 })
70+
}
71+
6272
/**
6373
* Older versions of the CLI stored credentials at `~/.shelve`. rc9 has since
6474
* deprecated `readUser`/`writeUser` in favor of XDG-compliant
@@ -76,6 +86,7 @@ function migrateLegacyConfig(): void {
7686
const legacy = parseLegacyRc(readFileSync(LEGACY_RC_PATH, 'utf-8'))
7787
if (Object.keys(legacy).length === 0) return
7888

89+
ensureConfigDir()
7990
writeUserConfig(legacy, RC_FILENAME)
8091
chmod600(configPath())
8192
unlinkSync(LEGACY_RC_PATH)
@@ -104,6 +115,8 @@ export class CredentialsService {
104115
const factory = await loadKeyring()
105116
const account = accountFor(url)
106117

118+
ensureConfigDir()
119+
107120
if (factory) {
108121
try {
109122
const entry = factory(KEYRING_SERVICE, account)
@@ -152,6 +165,7 @@ export class CredentialsService {
152165
entry.deletePassword()
153166
} catch { /* ignore */ }
154167
}
168+
ensureConfigDir()
155169
writeUserConfig({}, RC_FILENAME)
156170
}
157171

packages/cli/test/cache.test.ts

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
import { mkdtempSync, readFileSync, writeFileSync, rmSync, utimesSync, existsSync } from 'node:fs'
2+
import { tmpdir } from 'node:os'
3+
import { join } from 'node:path'
4+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
5+
import type { CacheKeyInput } from '../src/services/cache'
6+
7+
type CacheModule = typeof import('../src/services/cache')
8+
9+
// `src/services/cache.ts` captures `homedir()` and `CACHE_DIR` at module load
10+
// time. We therefore need to mutate HOME and then dynamically import the
11+
// module so the cache lands under the test sandbox instead of the real user
12+
// home directory.
13+
describe('CacheService', () => {
14+
const originalHome = process.env.HOME
15+
const originalUserProfile = process.env.USERPROFILE
16+
let sandbox: string
17+
let cache: CacheModule
18+
19+
const input: CacheKeyInput = {
20+
url: 'https://shelve.cloud',
21+
teamSlug: 'acme',
22+
projectName: 'web',
23+
environmentName: 'production',
24+
}
25+
const token = 'she_live_'.concat('a'.repeat(40))
26+
const variables = [
27+
{ key: 'DATABASE_URL', value: 'postgres://prod' },
28+
{ key: 'API_KEY', value: 'sk-abc' },
29+
]
30+
31+
beforeEach(async () => {
32+
sandbox = mkdtempSync(join(tmpdir(), 'shelve-cache-'))
33+
process.env.HOME = sandbox
34+
process.env.USERPROFILE = sandbox
35+
vi.resetModules()
36+
cache = await import('../src/services/cache')
37+
})
38+
39+
afterEach(() => {
40+
rmSync(sandbox, { recursive: true, force: true })
41+
process.env.HOME = originalHome
42+
process.env.USERPROFILE = originalUserProfile
43+
})
44+
45+
it('writes and reads back variables with the same token', () => {
46+
cache.CacheService.write(input, token, variables)
47+
const read = cache.CacheService.read(input, token, 60_000)
48+
expect(read).toEqual(variables)
49+
})
50+
51+
it('returns null when the file does not exist', () => {
52+
expect(cache.CacheService.read(input, token, 60_000)).toBeNull()
53+
})
54+
55+
it('refuses to write without a token', () => {
56+
cache.CacheService.write(input, '', variables)
57+
expect(cache.CacheService.read(input, token, 60_000)).toBeNull()
58+
})
59+
60+
it('returns null when reading without a token', () => {
61+
cache.CacheService.write(input, token, variables)
62+
expect(cache.CacheService.read(input, '', 60_000)).toBeNull()
63+
})
64+
65+
it('fails decryption (returns null) when the token changes', () => {
66+
cache.CacheService.write(input, token, variables)
67+
const tampered = cache.CacheService.read(input, 'she_live_'.concat('b'.repeat(40)), 60_000)
68+
expect(tampered).toBeNull()
69+
})
70+
71+
it('respects TTL: stale entries are not returned', () => {
72+
cache.CacheService.write(input, token, variables)
73+
const file = cache.cacheFilePath(input)
74+
const past = (Date.now() - 60 * 60 * 1000) / 1000
75+
utimesSync(file, past, past)
76+
expect(cache.CacheService.read(input, token, 60_000)).toBeNull()
77+
})
78+
79+
it('returns the payload when ttlMs is 0 (no expiry)', () => {
80+
cache.CacheService.write(input, token, variables)
81+
const file = cache.cacheFilePath(input)
82+
const past = (Date.now() - 365 * 24 * 60 * 60 * 1000) / 1000
83+
utimesSync(file, past, past)
84+
const read = cache.CacheService.read(input, token, 0)
85+
expect(read).toEqual(variables)
86+
})
87+
88+
it('returns null when the ciphertext is tampered with', () => {
89+
cache.CacheService.write(input, token, variables)
90+
const file = cache.cacheFilePath(input)
91+
expect(existsSync(file)).toBe(true)
92+
const blob = readFileSync(file)
93+
blob[blob.length - 1] = (blob[blob.length - 1] ?? 0) ^ 0xff
94+
writeFileSync(file, blob)
95+
expect(cache.CacheService.read(input, token, 60_000)).toBeNull()
96+
})
97+
98+
it('produces different cache keys for different envs', () => {
99+
const a = cache.cacheFilePath(input)
100+
const b = cache.cacheFilePath({ ...input, environmentName: 'staging' })
101+
expect(a).not.toBe(b)
102+
})
103+
104+
it('ignores trailing slashes in the base URL for the cache key', () => {
105+
const a = cache.cacheFilePath(input)
106+
const b = cache.cacheFilePath({ ...input, url: 'https://shelve.cloud/' })
107+
expect(a).toBe(b)
108+
})
109+
})
Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
import { mkdtempSync, existsSync, writeFileSync, rmSync, readFileSync } from 'node:fs'
2+
import { tmpdir } from 'node:os'
3+
import { join } from 'node:path'
4+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
5+
6+
// Force the keyring import to throw so CredentialsService falls back to the
7+
// file-based storage. This keeps the test hermetic (no OS keychain touched)
8+
// while still exercising the migration + file paths.
9+
vi.mock('@napi-rs/keyring', () => {
10+
throw new Error('keyring unavailable in tests')
11+
})
12+
13+
type CredentialsModule = typeof import('../src/services/credentials')
14+
15+
describe('CredentialsService (file fallback)', () => {
16+
const originalHome = process.env.HOME
17+
const originalXdg = process.env.XDG_CONFIG_HOME
18+
const originalUserProfile = process.env.USERPROFILE
19+
let sandbox: string
20+
21+
beforeEach(() => {
22+
sandbox = mkdtempSync(join(tmpdir(), 'shelve-creds-'))
23+
process.env.HOME = sandbox
24+
process.env.USERPROFILE = sandbox
25+
process.env.XDG_CONFIG_HOME = join(sandbox, '.config')
26+
vi.resetModules()
27+
})
28+
29+
afterEach(() => {
30+
rmSync(sandbox, { recursive: true, force: true })
31+
process.env.HOME = originalHome
32+
process.env.USERPROFILE = originalUserProfile
33+
process.env.XDG_CONFIG_HOME = originalXdg
34+
})
35+
36+
async function loadModule(): Promise<CredentialsModule> {
37+
return await import('../src/services/credentials')
38+
}
39+
40+
it('writes and reads back a token via the file fallback', async () => {
41+
const creds = await loadModule()
42+
await creds.CredentialsService.writeToken('https://shelve.cloud', 'she_live_token', {
43+
email: 'u@example.com',
44+
username: 'u',
45+
})
46+
47+
const token = await creds.CredentialsService.readToken('https://shelve.cloud')
48+
expect(token).toBe('she_live_token')
49+
50+
const meta = creds.CredentialsService.readMeta()
51+
expect(meta.email).toBe('u@example.com')
52+
expect(meta.username).toBe('u')
53+
expect(meta.storage).toBe('file')
54+
})
55+
56+
it('writes the config under XDG_CONFIG_HOME', async () => {
57+
const creds = await loadModule()
58+
await creds.CredentialsService.writeToken('https://shelve.cloud', 'tkn', {
59+
email: 'a@b.co',
60+
username: 'ab',
61+
})
62+
expect(existsSync(join(sandbox, '.config', '.shelve'))).toBe(true)
63+
})
64+
65+
it('clearToken removes the stored token', async () => {
66+
const creds = await loadModule()
67+
await creds.CredentialsService.writeToken('https://shelve.cloud', 'tkn', {
68+
email: 'a@b.co',
69+
username: 'ab',
70+
})
71+
await creds.CredentialsService.clearToken('https://shelve.cloud')
72+
const after = await creds.CredentialsService.readToken('https://shelve.cloud')
73+
expect(after).toBeUndefined()
74+
})
75+
76+
it('migrates a legacy ~/.shelve file on first read and deletes it', async () => {
77+
const legacyPath = join(sandbox, '.shelve')
78+
writeFileSync(
79+
legacyPath,
80+
['token=legacy_tkn', 'email=legacy@example.com', 'username=legacy', 'url=https://shelve.cloud'].join('\n'),
81+
'utf-8'
82+
)
83+
84+
const creds = await loadModule()
85+
const token = await creds.CredentialsService.readToken('https://shelve.cloud')
86+
expect(token).toBe('legacy_tkn')
87+
expect(existsSync(legacyPath)).toBe(false)
88+
89+
const xdgFile = join(sandbox, '.config', '.shelve')
90+
expect(existsSync(xdgFile)).toBe(true)
91+
const content = readFileSync(xdgFile, 'utf-8')
92+
expect(content).toContain('legacy_tkn')
93+
expect(content).toContain('legacy@example.com')
94+
})
95+
96+
it('does not migrate when the XDG file already holds data', async () => {
97+
const creds = await loadModule()
98+
await creds.CredentialsService.writeToken('https://shelve.cloud', 'new_tkn', {
99+
email: 'new@example.com',
100+
username: 'new',
101+
})
102+
103+
writeFileSync(join(sandbox, '.shelve'), 'token=legacy_tkn\n', 'utf-8')
104+
105+
const token = await creds.CredentialsService.readToken('https://shelve.cloud')
106+
expect(token).toBe('new_tkn')
107+
})
108+
})

packages/cli/test/duration.test.ts

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
import { describe, expect, it } from 'vitest'
2+
import { parseDuration } from '../src/utils/duration'
3+
4+
describe('parseDuration', () => {
5+
const DEFAULT = 12345
6+
7+
it('returns the default for null/undefined/empty', () => {
8+
expect(parseDuration(undefined, DEFAULT)).toBe(DEFAULT)
9+
expect(parseDuration(null, DEFAULT)).toBe(DEFAULT)
10+
expect(parseDuration('', DEFAULT)).toBe(DEFAULT)
11+
})
12+
13+
it('accepts a positive finite number (ms)', () => {
14+
expect(parseDuration(500, DEFAULT)).toBe(500)
15+
expect(parseDuration(0, DEFAULT)).toBe(0)
16+
})
17+
18+
it('falls back to default for negative or non-finite numbers', () => {
19+
expect(parseDuration(-1, DEFAULT)).toBe(DEFAULT)
20+
expect(parseDuration(Number.NaN, DEFAULT)).toBe(DEFAULT)
21+
expect(parseDuration(Number.POSITIVE_INFINITY, DEFAULT)).toBe(DEFAULT)
22+
})
23+
24+
it('parses unit suffixes', () => {
25+
expect(parseDuration('500ms', DEFAULT)).toBe(500)
26+
expect(parseDuration('30s', DEFAULT)).toBe(30_000)
27+
expect(parseDuration('15m', DEFAULT)).toBe(15 * 60_000)
28+
expect(parseDuration('2h', DEFAULT)).toBe(2 * 3_600_000)
29+
expect(parseDuration('7d', DEFAULT)).toBe(7 * 86_400_000)
30+
})
31+
32+
it('treats a bare number string as milliseconds', () => {
33+
expect(parseDuration('250', DEFAULT)).toBe(250)
34+
})
35+
36+
it('is case-insensitive and tolerates whitespace', () => {
37+
expect(parseDuration(' 1H ', DEFAULT)).toBe(3_600_000)
38+
expect(parseDuration('10S', DEFAULT)).toBe(10_000)
39+
})
40+
41+
it('accepts decimal values', () => {
42+
expect(parseDuration('1.5h', DEFAULT)).toBe(Math.floor(1.5 * 3_600_000))
43+
})
44+
45+
it('returns the default for garbage input', () => {
46+
expect(parseDuration('soon', DEFAULT)).toBe(DEFAULT)
47+
expect(parseDuration('5x', DEFAULT)).toBe(DEFAULT)
48+
expect(parseDuration('--5s', DEFAULT)).toBe(DEFAULT)
49+
})
50+
})

0 commit comments

Comments
 (0)