@nietzsci/clavis 0.1.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.
- package/README.md +127 -0
- package/dist/index.d.mts +291 -0
- package/dist/index.d.ts +291 -0
- package/dist/index.js +662 -0
- package/dist/index.mjs +617 -0
- package/examples/browser-example.html +87 -0
- package/examples/node-example.ts +67 -0
- package/package.json +28 -0
- package/src/client.ts +594 -0
- package/src/crypto.ts +84 -0
- package/src/errors.ts +46 -0
- package/src/fingerprint.ts +46 -0
- package/src/hmac.ts +67 -0
- package/src/index.ts +33 -0
- package/src/storage.ts +90 -0
- package/src/types.ts +114 -0
- package/tests/sdk.test.ts +548 -0
- package/tsconfig.json +19 -0
|
@@ -0,0 +1,548 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
|
2
|
+
import nacl from "tweetnacl";
|
|
3
|
+
import { encodeBase64 } from "tweetnacl-util";
|
|
4
|
+
import crypto from "crypto";
|
|
5
|
+
import fs from "fs";
|
|
6
|
+
import path from "path";
|
|
7
|
+
import os from "os";
|
|
8
|
+
|
|
9
|
+
import { verifyOfflineToken } from "../src/crypto";
|
|
10
|
+
import { signRequest, hashIdentity } from "../src/hmac";
|
|
11
|
+
import { getMachineCode, getDeviceInfo } from "../src/fingerprint";
|
|
12
|
+
import { FileStorage } from "../src/storage";
|
|
13
|
+
import { Clavis } from "../src/client";
|
|
14
|
+
import { ClavisError } from "../src/errors";
|
|
15
|
+
|
|
16
|
+
// ============================================================
|
|
17
|
+
// 辅助:服务端签名函数(复刻自 src/lib/crypto.ts 的 signPayload)
|
|
18
|
+
// ============================================================
|
|
19
|
+
|
|
20
|
+
function toBase64Url(b64: string): string {
|
|
21
|
+
return b64.replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, "");
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function serverSignPayload(payload: object, privateKeyBase64: string): string {
|
|
25
|
+
const payloadJson = JSON.stringify(payload);
|
|
26
|
+
const encoder = new TextEncoder();
|
|
27
|
+
const messageBytes = encoder.encode(payloadJson);
|
|
28
|
+
const privateKeyBytes = Uint8Array.from(
|
|
29
|
+
Buffer.from(privateKeyBase64, "base64")
|
|
30
|
+
);
|
|
31
|
+
|
|
32
|
+
const signedMessage = nacl.sign(messageBytes, privateKeyBytes);
|
|
33
|
+
const signature = signedMessage.slice(0, 64);
|
|
34
|
+
|
|
35
|
+
const payloadBase64Url = toBase64Url(
|
|
36
|
+
Buffer.from(payloadJson).toString("base64")
|
|
37
|
+
);
|
|
38
|
+
const signatureBase64Url = toBase64Url(encodeBase64(signature));
|
|
39
|
+
|
|
40
|
+
return `${payloadBase64Url}.${signatureBase64Url}`;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// ============================================================
|
|
44
|
+
// 测试:Ed25519 离线验签
|
|
45
|
+
// ============================================================
|
|
46
|
+
|
|
47
|
+
describe("verifyOfflineToken", () => {
|
|
48
|
+
const keyPair = nacl.sign.keyPair();
|
|
49
|
+
const publicKeyBase64 = encodeBase64(keyPair.publicKey);
|
|
50
|
+
const privateKeyBase64 = encodeBase64(keyPair.secretKey);
|
|
51
|
+
|
|
52
|
+
const testPayload = {
|
|
53
|
+
app_id: "nc_test123",
|
|
54
|
+
license_id: "lic_001",
|
|
55
|
+
identity_hash: "abc123hash",
|
|
56
|
+
tier: "pro",
|
|
57
|
+
features: ["feature_a", "feature_b"],
|
|
58
|
+
issued_at: Math.floor(Date.now() / 1000),
|
|
59
|
+
expires_at: Math.floor(Date.now() / 1000) + 86400 * 30,
|
|
60
|
+
grace_until: Math.floor(Date.now() / 1000) + 86400 * 37,
|
|
61
|
+
token_version: 1,
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
it("应该正确验证服务端签发的 token", () => {
|
|
65
|
+
const token = serverSignPayload(testPayload, privateKeyBase64);
|
|
66
|
+
const result = verifyOfflineToken(token, publicKeyBase64);
|
|
67
|
+
|
|
68
|
+
expect(result).not.toBeNull();
|
|
69
|
+
expect(result!.app_id).toBe("nc_test123");
|
|
70
|
+
expect(result!.license_id).toBe("lic_001");
|
|
71
|
+
expect(result!.tier).toBe("pro");
|
|
72
|
+
expect(result!.features).toEqual(["feature_a", "feature_b"]);
|
|
73
|
+
expect(result!.token_version).toBe(1);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it("应该拒绝格式错误的 token", () => {
|
|
77
|
+
expect(verifyOfflineToken("invalid", publicKeyBase64)).toBeNull();
|
|
78
|
+
expect(verifyOfflineToken("a.b.c", publicKeyBase64)).toBeNull();
|
|
79
|
+
expect(verifyOfflineToken("", publicKeyBase64)).toBeNull();
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it("应该拒绝签名不匹配的 token", () => {
|
|
83
|
+
const token = serverSignPayload(testPayload, privateKeyBase64);
|
|
84
|
+
// 用不同的公钥验证
|
|
85
|
+
const otherKeyPair = nacl.sign.keyPair();
|
|
86
|
+
const otherPublicKey = encodeBase64(otherKeyPair.publicKey);
|
|
87
|
+
expect(verifyOfflineToken(token, otherPublicKey)).toBeNull();
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it("应该正确处理永久授权(expires_at = null)", () => {
|
|
91
|
+
const permanentPayload = { ...testPayload, expires_at: null };
|
|
92
|
+
const token = serverSignPayload(permanentPayload, privateKeyBase64);
|
|
93
|
+
const result = verifyOfflineToken(token, publicKeyBase64);
|
|
94
|
+
|
|
95
|
+
expect(result).not.toBeNull();
|
|
96
|
+
expect(result!.expires_at).toBeNull();
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it("应该正确处理空 features", () => {
|
|
100
|
+
const noFeaturePayload = { ...testPayload, features: [] };
|
|
101
|
+
const token = serverSignPayload(noFeaturePayload, privateKeyBase64);
|
|
102
|
+
const result = verifyOfflineToken(token, publicKeyBase64);
|
|
103
|
+
|
|
104
|
+
expect(result).not.toBeNull();
|
|
105
|
+
expect(result!.features).toEqual([]);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it("应该正确处理中文和特殊字符的 payload", () => {
|
|
109
|
+
const unicodePayload = {
|
|
110
|
+
...testPayload,
|
|
111
|
+
tier: "专业版",
|
|
112
|
+
features: ["功能A", "feature/special+chars="],
|
|
113
|
+
};
|
|
114
|
+
const token = serverSignPayload(unicodePayload, privateKeyBase64);
|
|
115
|
+
const result = verifyOfflineToken(token, publicKeyBase64);
|
|
116
|
+
|
|
117
|
+
expect(result).not.toBeNull();
|
|
118
|
+
expect(result!.tier).toBe("专业版");
|
|
119
|
+
expect(result!.features).toEqual(["功能A", "feature/special+chars="]);
|
|
120
|
+
});
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
// ============================================================
|
|
124
|
+
// 测试:HMAC 签名
|
|
125
|
+
// ============================================================
|
|
126
|
+
|
|
127
|
+
describe("signRequest", () => {
|
|
128
|
+
it("应该生成正确格式的签名头", () => {
|
|
129
|
+
const headers = signRequest({
|
|
130
|
+
method: "POST",
|
|
131
|
+
path: "/api/v1/sdk/activate",
|
|
132
|
+
body: '{"license_key":"ABC","identity_hash":"hash"}',
|
|
133
|
+
appId: "nc_test123",
|
|
134
|
+
appSecret: "secret123",
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
expect(headers["x-app-id"]).toBe("nc_test123");
|
|
138
|
+
expect(headers["x-timestamp"]).toMatch(/^\d+$/);
|
|
139
|
+
expect(headers["x-nonce"]).toMatch(
|
|
140
|
+
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/
|
|
141
|
+
);
|
|
142
|
+
expect(headers["x-signature"]).toMatch(/^[0-9a-f]{64}$/);
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it("相同参数应产生不同 nonce 和 signature", () => {
|
|
146
|
+
const params = {
|
|
147
|
+
method: "POST",
|
|
148
|
+
path: "/api/v1/sdk/activate",
|
|
149
|
+
body: "{}",
|
|
150
|
+
appId: "nc_test",
|
|
151
|
+
appSecret: "secret",
|
|
152
|
+
};
|
|
153
|
+
|
|
154
|
+
const h1 = signRequest(params);
|
|
155
|
+
const h2 = signRequest(params);
|
|
156
|
+
|
|
157
|
+
// nonce 不同 → signature 不同
|
|
158
|
+
expect(h1["x-nonce"]).not.toBe(h2["x-nonce"]);
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it("空 body 应正常签名", () => {
|
|
162
|
+
const headers = signRequest({
|
|
163
|
+
method: "GET",
|
|
164
|
+
path: "/api/v1/test",
|
|
165
|
+
body: "",
|
|
166
|
+
appId: "nc_test",
|
|
167
|
+
appSecret: "secret",
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
expect(headers["x-signature"]).toMatch(/^[0-9a-f]{64}$/);
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
it("签名结果应与手动计算一致", () => {
|
|
174
|
+
const method = "POST";
|
|
175
|
+
const urlPath = "/api/v1/sdk/activate";
|
|
176
|
+
const body = '{"test":true}';
|
|
177
|
+
const appSecret = "test-secret-key";
|
|
178
|
+
|
|
179
|
+
const headers = signRequest({
|
|
180
|
+
method,
|
|
181
|
+
path: urlPath,
|
|
182
|
+
body,
|
|
183
|
+
appId: "nc_verify",
|
|
184
|
+
appSecret,
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
// 手动重算
|
|
188
|
+
const bodyHash = crypto
|
|
189
|
+
.createHash("sha256")
|
|
190
|
+
.update(body)
|
|
191
|
+
.digest("hex");
|
|
192
|
+
const stringToSign = `${method}\n${urlPath}\n${headers["x-timestamp"]}\n${headers["x-nonce"]}\n${bodyHash}`;
|
|
193
|
+
const expectedSig = crypto
|
|
194
|
+
.createHmac("sha256", appSecret)
|
|
195
|
+
.update(stringToSign)
|
|
196
|
+
.digest("hex");
|
|
197
|
+
|
|
198
|
+
expect(headers["x-signature"]).toBe(expectedSig);
|
|
199
|
+
});
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
// ============================================================
|
|
203
|
+
// 测试:identity hash
|
|
204
|
+
// ============================================================
|
|
205
|
+
|
|
206
|
+
describe("hashIdentity", () => {
|
|
207
|
+
it("应该生成 HMAC-SHA256 哈希", () => {
|
|
208
|
+
const hash = hashIdentity("my-machine-code", "my-secret");
|
|
209
|
+
expect(hash).toMatch(/^[0-9a-f]{64}$/);
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
it("相同输入应产生相同输出", () => {
|
|
213
|
+
const h1 = hashIdentity("machine1", "secret");
|
|
214
|
+
const h2 = hashIdentity("machine1", "secret");
|
|
215
|
+
expect(h1).toBe(h2);
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
it("不同输入应产生不同输出", () => {
|
|
219
|
+
const h1 = hashIdentity("machine1", "secret");
|
|
220
|
+
const h2 = hashIdentity("machine2", "secret");
|
|
221
|
+
expect(h1).not.toBe(h2);
|
|
222
|
+
});
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
// ============================================================
|
|
226
|
+
// 测试:设备指纹
|
|
227
|
+
// ============================================================
|
|
228
|
+
|
|
229
|
+
describe("getMachineCode", () => {
|
|
230
|
+
it("格式应为 MC-XXXX-XXXX-XXXX-XXXX", () => {
|
|
231
|
+
const code = getMachineCode();
|
|
232
|
+
expect(code).toMatch(/^MC-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{4}$/);
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
it("同一台机器多次调用结果一致", () => {
|
|
236
|
+
const c1 = getMachineCode("my-secret");
|
|
237
|
+
const c2 = getMachineCode("my-secret");
|
|
238
|
+
expect(c1).toBe(c2);
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
it("不同 appSecret 产生不同机器码", () => {
|
|
242
|
+
const c1 = getMachineCode("secret-a");
|
|
243
|
+
const c2 = getMachineCode("secret-b");
|
|
244
|
+
expect(c1).not.toBe(c2);
|
|
245
|
+
});
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
describe("getDeviceInfo", () => {
|
|
249
|
+
it("应该返回完整的设备信息", () => {
|
|
250
|
+
const info = getDeviceInfo();
|
|
251
|
+
expect(info.os).toBeTruthy();
|
|
252
|
+
expect(info.osVersion).toBeTruthy();
|
|
253
|
+
expect(info.hostname).toBeTruthy();
|
|
254
|
+
expect(info.arch).toBeTruthy();
|
|
255
|
+
expect(typeof info.totalMemory).toBe("number");
|
|
256
|
+
expect(info.totalMemory).toBeGreaterThan(0);
|
|
257
|
+
});
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
// ============================================================
|
|
261
|
+
// 测试:FileStorage 加密/解密
|
|
262
|
+
// ============================================================
|
|
263
|
+
|
|
264
|
+
describe("FileStorage", () => {
|
|
265
|
+
const tmpDir = path.join(os.tmpdir(), `clavis-test-${Date.now()}`);
|
|
266
|
+
let storage: FileStorage;
|
|
267
|
+
|
|
268
|
+
beforeEach(() => {
|
|
269
|
+
fs.mkdirSync(tmpDir, { recursive: true });
|
|
270
|
+
storage = new FileStorage(tmpDir, "nc_test_app");
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
afterEach(() => {
|
|
274
|
+
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
it("应该正确加密保存和解密读取", () => {
|
|
278
|
+
const data = {
|
|
279
|
+
offlineToken: "some.token.here",
|
|
280
|
+
lastVerifiedAt: Math.floor(Date.now() / 1000),
|
|
281
|
+
tokenVersion: 1,
|
|
282
|
+
licenseKey: "AAAAA-BBBBB-CCCCC-DDDDD-EEEEE",
|
|
283
|
+
};
|
|
284
|
+
|
|
285
|
+
storage.save(data);
|
|
286
|
+
const loaded = storage.load();
|
|
287
|
+
|
|
288
|
+
expect(loaded).not.toBeNull();
|
|
289
|
+
expect(loaded!.offlineToken).toBe(data.offlineToken);
|
|
290
|
+
expect(loaded!.lastVerifiedAt).toBe(data.lastVerifiedAt);
|
|
291
|
+
expect(loaded!.tokenVersion).toBe(data.tokenVersion);
|
|
292
|
+
expect(loaded!.licenseKey).toBe(data.licenseKey);
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
it("不同 appId 不能解密对方的缓存", () => {
|
|
296
|
+
const storage1 = new FileStorage(tmpDir, "nc_app_1");
|
|
297
|
+
const storage2 = new FileStorage(tmpDir, "nc_app_2");
|
|
298
|
+
|
|
299
|
+
storage1.save({
|
|
300
|
+
offlineToken: "secret-data",
|
|
301
|
+
lastVerifiedAt: 100,
|
|
302
|
+
tokenVersion: 1,
|
|
303
|
+
licenseKey: "KEY",
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
// storage2 不能读 storage1 的文件(文件名不同)
|
|
307
|
+
const loaded = storage2.load();
|
|
308
|
+
expect(loaded).toBeNull();
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
it("无缓存文件时返回 null", () => {
|
|
312
|
+
expect(storage.load()).toBeNull();
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
it("clear 后应返回 null", () => {
|
|
316
|
+
storage.save({
|
|
317
|
+
offlineToken: "test",
|
|
318
|
+
lastVerifiedAt: 100,
|
|
319
|
+
tokenVersion: 1,
|
|
320
|
+
licenseKey: "KEY",
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
storage.clear();
|
|
324
|
+
expect(storage.load()).toBeNull();
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
it("文件被篡改后应返回 null", () => {
|
|
328
|
+
storage.save({
|
|
329
|
+
offlineToken: "test",
|
|
330
|
+
lastVerifiedAt: 100,
|
|
331
|
+
tokenVersion: 1,
|
|
332
|
+
licenseKey: "KEY",
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
// 篡改文件
|
|
336
|
+
const files = fs.readdirSync(tmpDir);
|
|
337
|
+
const cacheFile = files.find((f) => f.startsWith(".clavis_"));
|
|
338
|
+
if (cacheFile) {
|
|
339
|
+
const filePath = path.join(tmpDir, cacheFile);
|
|
340
|
+
const data = fs.readFileSync(filePath);
|
|
341
|
+
data[20] = data[20] ^ 0xff; // 翻转一个字节
|
|
342
|
+
fs.writeFileSync(filePath, data);
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
expect(storage.load()).toBeNull();
|
|
346
|
+
});
|
|
347
|
+
});
|
|
348
|
+
|
|
349
|
+
// ============================================================
|
|
350
|
+
// 测试:Clavis 类初始化
|
|
351
|
+
// ============================================================
|
|
352
|
+
|
|
353
|
+
describe("Clavis", () => {
|
|
354
|
+
it("应该用正确配置初始化", () => {
|
|
355
|
+
const clavis = new Clavis({
|
|
356
|
+
appId: "nc_test123",
|
|
357
|
+
appSecret: "secret-key-here",
|
|
358
|
+
publicKey: encodeBase64(nacl.sign.keyPair().publicKey),
|
|
359
|
+
});
|
|
360
|
+
|
|
361
|
+
expect(clavis).toBeInstanceOf(Clavis);
|
|
362
|
+
clavis.destroy();
|
|
363
|
+
});
|
|
364
|
+
|
|
365
|
+
it("缺少必填字段时应抛出 ClavisError", () => {
|
|
366
|
+
expect(
|
|
367
|
+
() =>
|
|
368
|
+
new Clavis({
|
|
369
|
+
appId: "",
|
|
370
|
+
appSecret: "secret",
|
|
371
|
+
publicKey: "key",
|
|
372
|
+
})
|
|
373
|
+
).toThrow(ClavisError);
|
|
374
|
+
|
|
375
|
+
expect(
|
|
376
|
+
() =>
|
|
377
|
+
new Clavis({
|
|
378
|
+
appId: "nc_test",
|
|
379
|
+
appSecret: "",
|
|
380
|
+
publicKey: "key",
|
|
381
|
+
})
|
|
382
|
+
).toThrow(ClavisError);
|
|
383
|
+
});
|
|
384
|
+
|
|
385
|
+
it("静态方法 getMachineCode 应可正常调用", () => {
|
|
386
|
+
const code = Clavis.getMachineCode();
|
|
387
|
+
expect(code).toMatch(/^MC-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{4}$/);
|
|
388
|
+
});
|
|
389
|
+
|
|
390
|
+
it("静态方法 getDeviceInfo 应可正常调用", () => {
|
|
391
|
+
const info = Clavis.getDeviceInfo();
|
|
392
|
+
expect(info.os).toBeTruthy();
|
|
393
|
+
});
|
|
394
|
+
|
|
395
|
+
describe("verifyOffline", () => {
|
|
396
|
+
const keyPair = nacl.sign.keyPair();
|
|
397
|
+
const publicKeyBase64 = encodeBase64(keyPair.publicKey);
|
|
398
|
+
const privateKeyBase64 = encodeBase64(keyPair.secretKey);
|
|
399
|
+
const tmpDir = path.join(os.tmpdir(), `clavis-offline-${Date.now()}`);
|
|
400
|
+
|
|
401
|
+
beforeEach(() => {
|
|
402
|
+
fs.mkdirSync(tmpDir, { recursive: true });
|
|
403
|
+
});
|
|
404
|
+
|
|
405
|
+
afterEach(() => {
|
|
406
|
+
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
407
|
+
});
|
|
408
|
+
|
|
409
|
+
it("无缓存时应返回 valid=false", () => {
|
|
410
|
+
const clavis = new Clavis({
|
|
411
|
+
appId: "nc_test",
|
|
412
|
+
appSecret: "secret",
|
|
413
|
+
publicKey: publicKeyBase64,
|
|
414
|
+
storagePath: tmpDir,
|
|
415
|
+
autoHeartbeat: false,
|
|
416
|
+
});
|
|
417
|
+
|
|
418
|
+
const status = clavis.verifyOffline();
|
|
419
|
+
expect(status.valid).toBe(false);
|
|
420
|
+
clavis.destroy();
|
|
421
|
+
});
|
|
422
|
+
|
|
423
|
+
it("有效 token 应返回 valid=true", () => {
|
|
424
|
+
const appId = "nc_test_offline";
|
|
425
|
+
const appSecret = "test-secret";
|
|
426
|
+
const identity = "machine-001";
|
|
427
|
+
const identityHash = hashIdentity(identity, appSecret);
|
|
428
|
+
|
|
429
|
+
const payload = {
|
|
430
|
+
app_id: appId,
|
|
431
|
+
license_id: "lic_001",
|
|
432
|
+
identity_hash: identityHash,
|
|
433
|
+
tier: "pro",
|
|
434
|
+
features: ["feat_a"],
|
|
435
|
+
issued_at: Math.floor(Date.now() / 1000),
|
|
436
|
+
expires_at: Math.floor(Date.now() / 1000) + 86400 * 30,
|
|
437
|
+
grace_until: Math.floor(Date.now() / 1000) + 86400 * 37,
|
|
438
|
+
token_version: 1,
|
|
439
|
+
};
|
|
440
|
+
|
|
441
|
+
const offlineToken = serverSignPayload(payload, privateKeyBase64);
|
|
442
|
+
|
|
443
|
+
// 写入缓存
|
|
444
|
+
const storage = new FileStorage(tmpDir, appId);
|
|
445
|
+
storage.save({
|
|
446
|
+
offlineToken,
|
|
447
|
+
lastVerifiedAt: Math.floor(Date.now() / 1000),
|
|
448
|
+
tokenVersion: 1,
|
|
449
|
+
licenseKey: "AAAAA-BBBBB-CCCCC-DDDDD-EEEEE",
|
|
450
|
+
});
|
|
451
|
+
|
|
452
|
+
const clavis = new Clavis({
|
|
453
|
+
appId,
|
|
454
|
+
appSecret,
|
|
455
|
+
publicKey: publicKeyBase64,
|
|
456
|
+
storagePath: tmpDir,
|
|
457
|
+
autoHeartbeat: false,
|
|
458
|
+
});
|
|
459
|
+
|
|
460
|
+
const status = clavis.verifyOffline(identity);
|
|
461
|
+
expect(status.valid).toBe(true);
|
|
462
|
+
expect(status.tier).toBe("pro");
|
|
463
|
+
expect(status.features).toEqual(["feat_a"]);
|
|
464
|
+
expect(status.isOffline).toBe(true);
|
|
465
|
+
expect(status.daysRemaining).toBeGreaterThan(0);
|
|
466
|
+
|
|
467
|
+
clavis.destroy();
|
|
468
|
+
});
|
|
469
|
+
|
|
470
|
+
it("过期且超过宽限期应返回 valid=false", () => {
|
|
471
|
+
const appId = "nc_expired";
|
|
472
|
+
const appSecret = "test-secret";
|
|
473
|
+
|
|
474
|
+
const payload = {
|
|
475
|
+
app_id: appId,
|
|
476
|
+
license_id: "lic_expired",
|
|
477
|
+
identity_hash: "hash",
|
|
478
|
+
tier: "standard",
|
|
479
|
+
features: [],
|
|
480
|
+
issued_at: Math.floor(Date.now() / 1000) - 86400 * 60,
|
|
481
|
+
expires_at: Math.floor(Date.now() / 1000) - 86400 * 10, // 10 天前过期
|
|
482
|
+
grace_until: Math.floor(Date.now() / 1000) - 86400 * 3, // 3 天前宽限也过了
|
|
483
|
+
token_version: 1,
|
|
484
|
+
};
|
|
485
|
+
|
|
486
|
+
const offlineToken = serverSignPayload(payload, privateKeyBase64);
|
|
487
|
+
|
|
488
|
+
const storage = new FileStorage(tmpDir, appId);
|
|
489
|
+
storage.save({
|
|
490
|
+
offlineToken,
|
|
491
|
+
lastVerifiedAt: Math.floor(Date.now() / 1000),
|
|
492
|
+
tokenVersion: 1,
|
|
493
|
+
licenseKey: "KEY",
|
|
494
|
+
});
|
|
495
|
+
|
|
496
|
+
const clavis = new Clavis({
|
|
497
|
+
appId,
|
|
498
|
+
appSecret,
|
|
499
|
+
publicKey: publicKeyBase64,
|
|
500
|
+
storagePath: tmpDir,
|
|
501
|
+
autoHeartbeat: false,
|
|
502
|
+
});
|
|
503
|
+
|
|
504
|
+
const status = clavis.verifyOffline();
|
|
505
|
+
expect(status.valid).toBe(false);
|
|
506
|
+
clavis.destroy();
|
|
507
|
+
});
|
|
508
|
+
|
|
509
|
+
it("hasFeature 应正确返回", () => {
|
|
510
|
+
const appId = "nc_feat_test";
|
|
511
|
+
|
|
512
|
+
const payload = {
|
|
513
|
+
app_id: appId,
|
|
514
|
+
license_id: "lic_feat",
|
|
515
|
+
identity_hash: "hash",
|
|
516
|
+
tier: "pro",
|
|
517
|
+
features: ["dark_mode", "export_pdf"],
|
|
518
|
+
issued_at: Math.floor(Date.now() / 1000),
|
|
519
|
+
expires_at: Math.floor(Date.now() / 1000) + 86400 * 30,
|
|
520
|
+
grace_until: Math.floor(Date.now() / 1000) + 86400 * 37,
|
|
521
|
+
token_version: 1,
|
|
522
|
+
};
|
|
523
|
+
|
|
524
|
+
const offlineToken = serverSignPayload(payload, privateKeyBase64);
|
|
525
|
+
const storage = new FileStorage(tmpDir, appId);
|
|
526
|
+
storage.save({
|
|
527
|
+
offlineToken,
|
|
528
|
+
lastVerifiedAt: Math.floor(Date.now() / 1000),
|
|
529
|
+
tokenVersion: 1,
|
|
530
|
+
licenseKey: "KEY",
|
|
531
|
+
});
|
|
532
|
+
|
|
533
|
+
const clavis = new Clavis({
|
|
534
|
+
appId,
|
|
535
|
+
appSecret: "secret",
|
|
536
|
+
publicKey: publicKeyBase64,
|
|
537
|
+
storagePath: tmpDir,
|
|
538
|
+
autoHeartbeat: false,
|
|
539
|
+
});
|
|
540
|
+
|
|
541
|
+
expect(clavis.hasFeature("dark_mode")).toBe(true);
|
|
542
|
+
expect(clavis.hasFeature("export_pdf")).toBe(true);
|
|
543
|
+
expect(clavis.hasFeature("not_exist")).toBe(false);
|
|
544
|
+
|
|
545
|
+
clavis.destroy();
|
|
546
|
+
});
|
|
547
|
+
});
|
|
548
|
+
});
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2020",
|
|
4
|
+
"module": "ESNext",
|
|
5
|
+
"moduleResolution": "bundler",
|
|
6
|
+
"declaration": true,
|
|
7
|
+
"declarationMap": true,
|
|
8
|
+
"sourceMap": true,
|
|
9
|
+
"outDir": "dist",
|
|
10
|
+
"strict": true,
|
|
11
|
+
"esModuleInterop": true,
|
|
12
|
+
"skipLibCheck": true,
|
|
13
|
+
"forceConsistentCasingInFileNames": true,
|
|
14
|
+
"resolveJsonModule": true,
|
|
15
|
+
"lib": ["ES2020"]
|
|
16
|
+
},
|
|
17
|
+
"include": ["src/**/*.ts"],
|
|
18
|
+
"exclude": ["node_modules", "dist", "tests"]
|
|
19
|
+
}
|