mirror of
https://github.com/catlog22/Claude-Code-Workflow.git
synced 2026-02-10 02:24:35 +08:00
feat: add CLI session sharing functionality
- Implemented share token creation and revocation for CLI sessions. - Added a new page for viewing shared CLI sessions with SSE support. - Introduced hooks for fetching and managing CLI session shares. - Enhanced the IssueTerminalTab component to handle share tokens and display active shares. - Updated API routes to support fetching and revoking share tokens. - Added unit tests for the CLI session share manager and rate limiter. - Updated localization files to include new strings for sharing functionality.
This commit is contained in:
138
ccw/tests/cli-session-share.test.js
Normal file
138
ccw/tests/cli-session-share.test.js
Normal file
@@ -0,0 +1,138 @@
|
||||
/**
|
||||
* Unit tests for CLI session share tokens
|
||||
*/
|
||||
|
||||
import { afterEach, describe, it } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
const shareUrl = new URL('../dist/core/services/cli-session-share.js', import.meta.url).href;
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
let mod;
|
||||
|
||||
const originalNow = Date.now;
|
||||
|
||||
afterEach(() => {
|
||||
Date.now = originalNow;
|
||||
});
|
||||
|
||||
describe('CliSessionShareManager', async () => {
|
||||
mod = await import(shareUrl);
|
||||
|
||||
it('creates a token record with expected fields', () => {
|
||||
const mgr = new mod.CliSessionShareManager();
|
||||
const record = mgr.createToken({
|
||||
sessionKey: 's-1',
|
||||
projectRoot: 'D:\\\\Claude_dms3',
|
||||
mode: 'read',
|
||||
ttlMs: 10_000,
|
||||
});
|
||||
|
||||
assert.equal(record.sessionKey, 's-1');
|
||||
assert.equal(record.projectRoot, 'D:\\\\Claude_dms3');
|
||||
assert.equal(record.mode, 'read');
|
||||
assert.match(record.token, /^[A-Za-z0-9_-]+$/);
|
||||
assert.ok(record.expiresAt.endsWith('Z'));
|
||||
});
|
||||
|
||||
it('validates only when sessionKey matches', () => {
|
||||
const mgr = new mod.CliSessionShareManager();
|
||||
const record = mgr.createToken({
|
||||
sessionKey: 's-abc',
|
||||
projectRoot: '/tmp/project',
|
||||
mode: 'read',
|
||||
ttlMs: 60_000,
|
||||
});
|
||||
|
||||
assert.ok(mgr.validateToken(record.token, 's-abc'));
|
||||
assert.equal(mgr.validateToken(record.token, 's-wrong'), null);
|
||||
});
|
||||
|
||||
it('revokes tokens', () => {
|
||||
const mgr = new mod.CliSessionShareManager();
|
||||
const record = mgr.createToken({
|
||||
sessionKey: 's-1',
|
||||
projectRoot: '/tmp/project',
|
||||
mode: 'write',
|
||||
ttlMs: 60_000,
|
||||
});
|
||||
|
||||
assert.equal(mgr.revokeToken(record.token), true);
|
||||
assert.equal(mgr.revokeToken(record.token), false);
|
||||
assert.equal(mgr.validateToken(record.token, record.sessionKey), null);
|
||||
});
|
||||
|
||||
it('expires tokens and cleans up expired entries', () => {
|
||||
let now = 1_700_000_000_000;
|
||||
Date.now = () => now;
|
||||
|
||||
const mgr = new mod.CliSessionShareManager();
|
||||
const recordA = mgr.createToken({
|
||||
sessionKey: 's-a',
|
||||
projectRoot: '/tmp/project',
|
||||
mode: 'read',
|
||||
ttlMs: 1_000,
|
||||
});
|
||||
const recordB = mgr.createToken({
|
||||
sessionKey: 's-b',
|
||||
projectRoot: '/tmp/project',
|
||||
mode: 'read',
|
||||
ttlMs: 10_000,
|
||||
});
|
||||
|
||||
now += 1_001;
|
||||
|
||||
assert.equal(mgr.validateToken(recordA.token, recordA.sessionKey), null);
|
||||
assert.ok(mgr.validateToken(recordB.token, recordB.sessionKey));
|
||||
assert.equal(mgr.cleanupExpired(), 0);
|
||||
|
||||
now += 10_000;
|
||||
assert.equal(mgr.cleanupExpired(), 1);
|
||||
assert.equal(mgr.validateToken(recordB.token, recordB.sessionKey), null);
|
||||
});
|
||||
|
||||
it('clamps ttlMs to a minimum of 1000ms', () => {
|
||||
const fixedNow = 1_700_000_000_000;
|
||||
Date.now = () => fixedNow;
|
||||
|
||||
const mgr = new mod.CliSessionShareManager();
|
||||
const record = mgr.createToken({
|
||||
sessionKey: 's-1',
|
||||
projectRoot: '/tmp/project',
|
||||
mode: 'read',
|
||||
ttlMs: 1,
|
||||
});
|
||||
|
||||
assert.equal(record.expiresAt, new Date(fixedNow + 1_000).toISOString());
|
||||
});
|
||||
|
||||
it('lists tokens for a session (scoped to projectRoot) and sorts by expiresAt', () => {
|
||||
let now = 1_700_000_000_000;
|
||||
Date.now = () => now;
|
||||
|
||||
const mgr = new mod.CliSessionShareManager();
|
||||
const t1 = mgr.createToken({
|
||||
sessionKey: 's-1',
|
||||
projectRoot: '/p1',
|
||||
mode: 'read',
|
||||
ttlMs: 5_000,
|
||||
});
|
||||
const t2 = mgr.createToken({
|
||||
sessionKey: 's-1',
|
||||
projectRoot: '/p1',
|
||||
mode: 'write',
|
||||
ttlMs: 10_000,
|
||||
});
|
||||
mgr.createToken({ sessionKey: 's-1', projectRoot: '/p2', mode: 'read', ttlMs: 10_000 });
|
||||
mgr.createToken({ sessionKey: 's-2', projectRoot: '/p1', mode: 'read', ttlMs: 10_000 });
|
||||
|
||||
const list1 = mgr.listTokensForSession('s-1', '/p1');
|
||||
assert.equal(list1.length, 2);
|
||||
assert.equal(list1[0].token, t1.token);
|
||||
assert.equal(list1[1].token, t2.token);
|
||||
|
||||
now += 5_001;
|
||||
const list2 = mgr.listTokensForSession('s-1', '/p1');
|
||||
assert.deepEqual(list2.map(r => r.token), [t2.token]);
|
||||
});
|
||||
});
|
||||
71
ccw/tests/rate-limiter.test.js
Normal file
71
ccw/tests/rate-limiter.test.js
Normal file
@@ -0,0 +1,71 @@
|
||||
/**
|
||||
* Unit tests for RateLimiter
|
||||
*/
|
||||
|
||||
import { describe, it } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
const limiterUrl = new URL('../dist/core/services/rate-limiter.js', import.meta.url).href;
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
let mod;
|
||||
|
||||
describe('RateLimiter', async () => {
|
||||
mod = await import(limiterUrl);
|
||||
|
||||
it('enforces limits within a window', () => {
|
||||
const limiter = new mod.RateLimiter({ limit: 3, windowMs: 60_000 });
|
||||
|
||||
const r1 = limiter.consume('k');
|
||||
const r2 = limiter.consume('k');
|
||||
const r3 = limiter.consume('k');
|
||||
const r4 = limiter.consume('k');
|
||||
|
||||
assert.equal(r1.ok, true);
|
||||
assert.equal(r1.remaining, 2);
|
||||
assert.equal(typeof r1.resetAt, 'number');
|
||||
|
||||
assert.equal(r2.ok, true);
|
||||
assert.equal(r2.remaining, 1);
|
||||
assert.equal(r2.resetAt, r1.resetAt);
|
||||
|
||||
assert.equal(r3.ok, true);
|
||||
assert.equal(r3.remaining, 0);
|
||||
assert.equal(r3.resetAt, r1.resetAt);
|
||||
|
||||
assert.equal(r4.ok, false);
|
||||
assert.equal(r4.remaining, 0);
|
||||
assert.equal(r4.resetAt, r1.resetAt);
|
||||
});
|
||||
|
||||
it('handles costs and does not penalize impossible costs', () => {
|
||||
const limiter = new mod.RateLimiter({ limit: 3, windowMs: 60_000 });
|
||||
|
||||
// Cost is floored; negative cost becomes 0
|
||||
assert.equal(limiter.consume('k2', -5).ok, true);
|
||||
assert.equal(limiter.consume('k2').remaining, 2);
|
||||
|
||||
// Cost larger than limit returns not ok but bucket remains full.
|
||||
const tooMuch = limiter.consume('k3', 10);
|
||||
assert.equal(tooMuch.ok, false);
|
||||
assert.equal(tooMuch.remaining, 0);
|
||||
assert.equal(limiter.consume('k3').remaining, 2);
|
||||
});
|
||||
|
||||
it('resets after the window expires', async () => {
|
||||
const limiter = new mod.RateLimiter({ limit: 1, windowMs: 50 });
|
||||
|
||||
assert.equal(limiter.consume('k').ok, true);
|
||||
assert.equal(limiter.consume('k').ok, false);
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 70));
|
||||
|
||||
assert.equal(limiter.consume('k').ok, true);
|
||||
});
|
||||
|
||||
it('supports a limit of 0', () => {
|
||||
const limiter = new mod.RateLimiter({ limit: 0, windowMs: 1_000 });
|
||||
assert.equal(limiter.consume('k').ok, false);
|
||||
assert.equal(limiter.consume('k', 0).ok, true);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user