File size: 8,546 Bytes
4e23b01
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
import {
  chmodSync,
  existsSync,
  mkdtempSync,
  readFileSync,
  rmSync,
  statSync,
  writeFileSync,
} from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';

import { afterEach, beforeEach, describe, expect, it } from 'vitest';

import {
  PrivateFileTooPermissiveError,
  readPrivateFile,
  writePrivateFile,
} from '../src/services/auth/privateFiles';
import {
  loadOrCreateServerToken,
  rotateServerToken,
} from '../src/services/auth/persistentToken';
import { createTokenStore } from '../src/services/auth/tokenStore';
import { createAuthTokenService } from '../src/services/auth/authTokenService';
import { resolvePasswordHash, verifyPassword } from '../src/services/auth/password';

let tmpDir: string;

beforeEach(() => {
  tmpDir = mkdtempSync(join(tmpdir(), 'kimi-server-v2-auth-token-'));
});

afterEach(() => {
  rmSync(tmpDir, { recursive: true, force: true });
});

describe('privateFiles', () => {
  it.skipIf(process.platform === 'win32')('writes a file with mode 0600', async () => {
    const p = join(tmpDir, 'secret');
    await writePrivateFile(p, 'hello');
    expect(statSync(p).mode & 0o777).toBe(0o600);
  });

  it.skipIf(process.platform === 'win32')('creates an absent parent dir with mode 0700', async () => {
    const p = join(tmpDir, 'nested', 'dir', 'secret');
    await writePrivateFile(p, 'hello');
    expect(statSync(join(tmpDir, 'nested', 'dir')).mode & 0o777).toBe(0o700);
  });

  it('round-trips string content through readPrivateFile', async () => {
    const p = join(tmpDir, 'secret');
    await writePrivateFile(p, 's3cr3t-value');
    const buf = await readPrivateFile(p);
    expect(buf.toString('utf8')).toBe('s3cr3t-value');
  });

  it('round-trips Buffer content through readPrivateFile', async () => {
    const p = join(tmpDir, 'bin');
    const data = Buffer.from([0, 1, 2, 254, 255]);
    await writePrivateFile(p, data);
    const buf = await readPrivateFile(p);
    expect(buf.equals(data)).toBe(true);
  });

  it.skipIf(process.platform === 'win32')('readPrivateFile throws on a 0644 file', async () => {
    const p = join(tmpDir, 'leaky');
    writeFileSync(p, 'x', { mode: 0o644 });
    chmodSync(p, 0o644);
    await expect(readPrivateFile(p)).rejects.toThrowError(PrivateFileTooPermissiveError);
  });
});

describe('tokenStore', () => {
  it('returns the same token from repeated getToken() calls', async () => {
    const store = await createTokenStore(join(tmpDir, 'home'));
    expect(store.getToken()).toBe(store.getToken());
    await store.dispose();
  });

  it('produces different tokens for different home dirs', async () => {
    const a = await createTokenStore(join(tmpDir, 'home-a'));
    const b = await createTokenStore(join(tmpDir, 'home-b'));
    expect(a.getToken()).not.toBe(b.getToken());
    await a.dispose();
    await b.dispose();
  });

  it('reuses the same persistent token across stores in one home dir', async () => {
    const home = join(tmpDir, 'home');
    const a = await createTokenStore(home);
    const token = a.getToken();
    await a.dispose();
    const b = await createTokenStore(home);
    expect(b.getToken()).toBe(token);
    await b.dispose();
  });

  it.skipIf(process.platform === 'win32')('writes the token file with mode 0600 at server.token', async () => {
    const home = join(tmpDir, 'home');
    const store = await createTokenStore(home);
    expect(store.tokenPath).toBe(join(home, 'server.token'));
    expect(statSync(store.tokenPath).mode & 0o777).toBe(0o600);
    await store.dispose();
  });

  it('isValid accepts the token and rejects wrong / empty / same-length candidates', async () => {
    const store = await createTokenStore(join(tmpDir, 'home'));
    const token = store.getToken();
    expect(store.isValid(token)).toBe(true);
    expect(store.isValid('wrong')).toBe(false);
    expect(store.isValid('')).toBe(false);

    const other = await createTokenStore(join(tmpDir, 'home-other'));
    expect(other.getToken().length).toBe(token.length);
    expect(store.isValid(other.getToken())).toBe(false);
    await store.dispose();
    await other.dispose();
  });

  it('dispose() keeps the persistent token file on disk', async () => {
    const store = await createTokenStore(join(tmpDir, 'home'));
    expect(existsSync(store.tokenPath)).toBe(true);
    await store.dispose();
    expect(existsSync(store.tokenPath)).toBe(true);
  });

  it('re-reads the token after the file is rewritten (live rotation)', async () => {
    const home = join(tmpDir, 'home');
    const store = await createTokenStore(home);
    const original = store.getToken();
    const rotated = 'r'.repeat(original.length);
    await writePrivateFile(store.tokenPath, rotated);

    expect(store.getToken()).toBe(rotated);
    expect(store.isValid(rotated)).toBe(true);
    expect(store.isValid(original)).toBe(false);
    await store.dispose();
  });
});

describe('persistentToken', () => {
  it('loadOrCreateServerToken generates once and reuses thereafter', async () => {
    const home = join(tmpDir, 'home');
    const a = await loadOrCreateServerToken(home);
    const b = await loadOrCreateServerToken(home);
    expect(a).toBe(b);
  });

  it.skipIf(process.platform === 'win32')('writes server.token with mode 0600', async () => {
    const home = join(tmpDir, 'home');
    await loadOrCreateServerToken(home);
    expect(statSync(join(home, 'server.token')).mode & 0o777).toBe(0o600);
  });

  it('rotateServerToken writes a new, different token to server.token', async () => {
    const home = join(tmpDir, 'home');
    const original = await loadOrCreateServerToken(home);
    const rotated = await rotateServerToken(home);
    expect(rotated).not.toBe(original);
    expect(readFileSync(join(home, 'server.token'), 'utf8').trim()).toBe(rotated);
  });
});

describe('password', () => {
  it('resolvePasswordHash returns undefined when env is unset or empty', async () => {
    expect(await resolvePasswordHash({})).toBeUndefined();
    expect(await resolvePasswordHash({ KIMI_CODE_PASSWORD: '' })).toBeUndefined();
  });

  it('hashes a set password with bcrypt and verifies correctly', async () => {
    const passwordHash = await resolvePasswordHash({
      KIMI_CODE_PASSWORD: 'correct-horse-battery-staple',
    });
    expect(passwordHash?.startsWith('$2')).toBe(true);
    expect(await verifyPassword('correct-horse-battery-staple', passwordHash)).toBe(true);
    expect(await verifyPassword('wrong-password', passwordHash)).toBe(false);
  });

  it('verifyPassword returns false when the hash is undefined', async () => {
    expect(await verifyPassword('anything', undefined)).toBe(false);
  });
});

describe('createAuthTokenService', () => {
  it('getToken() returns the tokenStore token', async () => {
    const store = await createTokenStore(join(tmpDir, 'home'));
    const svc = createAuthTokenService({ tokenStore: store, passwordHash: undefined });
    expect(svc.getToken()).toBe(store.getToken());
    await store.dispose();
  });

  it('isValid accepts the token', async () => {
    const store = await createTokenStore(join(tmpDir, 'home'));
    const svc = createAuthTokenService({ tokenStore: store, passwordHash: undefined });
    expect(await svc.isValid(store.getToken())).toBe(true);
    await store.dispose();
  });

  it('isValid accepts the password when a hash is configured', async () => {
    const store = await createTokenStore(join(tmpDir, 'home'));
    const passwordHash = await resolvePasswordHash({
      KIMI_CODE_PASSWORD: 'correct horse battery staple',
    });
    const svc = createAuthTokenService({ tokenStore: store, passwordHash });
    expect(await svc.isValid('correct horse battery staple')).toBe(true);
    await store.dispose();
  });

  it('isValid rejects a wrong candidate', async () => {
    const store = await createTokenStore(join(tmpDir, 'home'));
    const passwordHash = await resolvePasswordHash({
      KIMI_CODE_PASSWORD: 'correct horse battery staple',
    });
    const svc = createAuthTokenService({ tokenStore: store, passwordHash });
    expect(await svc.isValid('wrong')).toBe(false);
    await store.dispose();
  });

  it('isValid accepts only the token when passwordHash is undefined', async () => {
    const store = await createTokenStore(join(tmpDir, 'home'));
    const svc = createAuthTokenService({ tokenStore: store, passwordHash: undefined });
    expect(await svc.isValid(store.getToken())).toBe(true);
    expect(await svc.isValid('any-password')).toBe(false);
    await store.dispose();
  });
});