fix: 收紧NapCat WebUI代理会话边界
This commit is contained in:
parent
43b2c4c32e
commit
c9cc0d5e3a
@ -756,7 +756,7 @@ export interface NapcatWebuiGatewaySessionStore {
|
||||
|
||||
- [ ] **Step 6: Implement session service**
|
||||
|
||||
Create `src/apps/napcat-webui-gateway/application/napcat-webui-gateway-session.service.ts` with `create`, `markActive`, `heartbeat`, `revoke`, and `requireProxySession`. Every method needs JSDoc. Use `crypto.randomUUID()` for `sessionId`.
|
||||
Create `src/apps/napcat-webui-gateway/application/napcat-webui-gateway-session.service.ts` with `create`, `markActive`, `heartbeat`, `revoke`, `requireBootstrapSession`, and `requireProxySession`. Every method needs JSDoc. Use `crypto.randomUUID()` for `sessionId`.
|
||||
|
||||
The `create()` implementation must:
|
||||
|
||||
@ -773,7 +773,7 @@ if (older) {
|
||||
}
|
||||
```
|
||||
|
||||
The `heartbeat()` implementation must reject `revoked`, `expired`, `failed`, and missing sessions.
|
||||
The `heartbeat()` implementation must reject `revoked`, `expired`, `failed`, missing sessions, and sessions whose user/account index no longer points at the requested `sessionId`. `requireBootstrapSession()` accepts only non-terminal, non-expired, currently indexed sessions for one-time ticket bootstrap. `requireProxySession()` accepts only `active`, non-expired, currently indexed sessions.
|
||||
|
||||
- [ ] **Step 7: Implement Redis store and ticket service**
|
||||
|
||||
@ -1006,14 +1006,14 @@ Use `createProxyMiddleware` from `http-proxy-middleware` with:
|
||||
|
||||
Before proxying:
|
||||
|
||||
- resolve session by `sessionId`;
|
||||
- reject non-active/non-created sessions with 410;
|
||||
- resolve an active session by `sessionId` through `sessionService.requireProxySession(sessionId)`;
|
||||
- reject non-active, stale, terminal, expired, or missing sessions with 410;
|
||||
- redeem Credential through `NapcatWebuiCredentialClient`;
|
||||
- add `Authorization: Bearer ${credential}` upstream;
|
||||
- never forward API/Admin cookies upstream;
|
||||
- never allow the browser to override `target`.
|
||||
|
||||
On first successful upstream response, call `sessionService.markActive(sessionId)`.
|
||||
Do not mark sessions active from the proxy path; bootstrap activates the session before redirecting into the proxied WebUI.
|
||||
|
||||
For WebSocket upgrade, do not tunnel frames through MQTT and do not hand-roll a WebSocket bridge. Expose a method on `NapcatWebuiProxyService` and bind HPM's upgrade handler from `main.ts`:
|
||||
|
||||
@ -1046,9 +1046,9 @@ GET /napcat-webui/session/:sessionId/bootstrap
|
||||
ALL /napcat-webui/session/:sessionId/webui/*
|
||||
```
|
||||
|
||||
Bootstrap must redeem `ticket`, set an HttpOnly gateway cookie scoped to `/napcat-webui/session/:sessionId`, and redirect to `/napcat-webui/session/:sessionId/webui/webui`.
|
||||
Bootstrap must redeem `ticket`, validate the redeemed session through `sessionService.requireBootstrapSession(sessionId)`, call `sessionService.markActive(sessionId)`, set an HttpOnly gateway cookie scoped to `/napcat-webui/session/:sessionId`, and redirect to `/napcat-webui/session/:sessionId/webui/webui`.
|
||||
|
||||
Proxy route delegates to `NapcatWebuiProxyService`. The proxy service remains responsible for path sanitization, session validation, Credential injection, HPM `cookiePathRewrite`, HPM HTTP proxying, and HPM WebSocket upgrade handling.
|
||||
Proxy route delegates to `NapcatWebuiProxyService`. The proxy service remains responsible for path sanitization, active-only session validation, Credential injection, HPM `cookiePathRewrite`, HPM HTTP proxying, and HPM WebSocket upgrade handling.
|
||||
|
||||
- [ ] **Step 7: Run tests and typecheck**
|
||||
|
||||
|
||||
@ -302,7 +302,7 @@ pnpm --dir D:\MyFiles\KT\Node\kt-template-online-api jest --runTestsByPath test/
|
||||
|
||||
- [ ] **Step 6:实现 session service**
|
||||
|
||||
创建 `NapcatWebuiGatewaySessionService`,包含 `create`、`markActive`、`heartbeat`、`revoke`、`requireProxySession`。每个方法必须有 JSDoc。`create()` 必须撤销同用户同账号旧 session。
|
||||
创建 `NapcatWebuiGatewaySessionService`,包含 `create`、`markActive`、`heartbeat`、`revoke`、`requireBootstrapSession`、`requireProxySession`。每个方法必须有 JSDoc。`create()` 必须撤销同用户同账号旧 session。`requireBootstrapSession()` 只接受非终态、未过期且 user/account index 仍指向当前 `sessionId` 的 session;`requireProxySession()` 只接受 `active`、未过期且 index 仍指向当前 `sessionId` 的 session。
|
||||
|
||||
- [ ] **Step 7:实现 Redis store 和 ticket service**
|
||||
|
||||
@ -431,7 +431,7 @@ pnpm --dir D:\MyFiles\KT\Node\kt-template-online-api jest --runTestsByPath test/
|
||||
}
|
||||
```
|
||||
|
||||
代理前必须解析 session,拒绝非 active/created session,换取 Credential,注入 `Authorization: Bearer <credential>`,删除浏览器传入的 API/Admin cookies,不允许浏览器改变 target。
|
||||
代理前必须通过 `sessionService.requireProxySession(sessionId)` 解析 active session,拒绝非 active、stale、终态、过期或缺失 session,换取 Credential,注入 `Authorization: Bearer <credential>`,删除浏览器传入的 API/Admin cookies,不允许浏览器改变 target。Proxy 路径不要再调用 `markActive()`;session 必须在 bootstrap redirect 前完成激活。
|
||||
|
||||
WebSocket upgrade 必须仍走 `http-proxy-middleware`,不能通过 MQTT 搬运 WebUI 数据帧,也不要手写 WebSocket tunnel。`NapcatWebuiProxyService` 暴露 `bindWebSocketUpgrade(server)`,内部用 HPM 的 `proxy.upgrade(req, socket, head)`;`main.ts` 在 `app.listen()` 后调用:
|
||||
|
||||
@ -449,7 +449,7 @@ GET /napcat-webui/session/:sessionId/bootstrap
|
||||
ALL /napcat-webui/session/:sessionId/webui/*
|
||||
```
|
||||
|
||||
bootstrap 兑换一次性 ticket,设置 HttpOnly session cookie,跳转到 `/napcat-webui/session/:sessionId/webui/webui`。Proxy route 委托给 `NapcatWebuiProxyService`,由该服务统一负责 path sanitize、session 校验、Credential 注入、HPM `cookiePathRewrite`、HTTP proxy 和 WebSocket upgrade。
|
||||
bootstrap 兑换一次性 ticket,通过 `sessionService.requireBootstrapSession(sessionId)` 校验 bootstrap session,调用 `sessionService.markActive(sessionId)`,设置 HttpOnly session cookie,跳转到 `/napcat-webui/session/:sessionId/webui/webui`。Proxy route 委托给 `NapcatWebuiProxyService`,由该服务统一负责 path sanitize、active-only session 校验、Credential 注入、HPM `cookiePathRewrite`、HTTP proxy 和 WebSocket upgrade。
|
||||
|
||||
- [ ] **Step 7:运行测试和类型检查**
|
||||
|
||||
|
||||
@ -74,7 +74,7 @@ export class NapcatWebuiGatewaySessionService {
|
||||
* @returns Updated active session.
|
||||
*/
|
||||
async markActive(sessionId: string) {
|
||||
const session = await this.requireUsableSession(sessionId);
|
||||
const session = await this.requireBootstrapSession(sessionId);
|
||||
const now = this.config.now();
|
||||
|
||||
return this.updateSession(sessionId, {
|
||||
@ -97,7 +97,7 @@ export class NapcatWebuiGatewaySessionService {
|
||||
const now = this.config.now();
|
||||
const expiresAt = now + this.config.ttlMs();
|
||||
|
||||
await this.updateSession(input.sessionId, {
|
||||
const updated = await this.updateSession(input.sessionId, {
|
||||
clientIp: this.toOptionalText(input.clientIp) || session.clientIp,
|
||||
expiresAt,
|
||||
lastSeenAt: now,
|
||||
@ -106,7 +106,7 @@ export class NapcatWebuiGatewaySessionService {
|
||||
});
|
||||
|
||||
return {
|
||||
expiresAt,
|
||||
expiresAt: updated.expiresAt,
|
||||
sessionId: input.sessionId,
|
||||
status: 'active' as const,
|
||||
};
|
||||
@ -137,16 +137,30 @@ export class NapcatWebuiGatewaySessionService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads a session only when it is usable for later proxy handling.
|
||||
* @param sessionId - Gateway session id from a public Gateway route.
|
||||
* @returns Stored server-only session metadata for proxy setup.
|
||||
* Loads a session only when it is eligible for one-time bootstrap.
|
||||
* @param sessionId - Gateway session id redeemed from a bootstrap ticket.
|
||||
* @returns Indexed non-terminal session that may be marked active.
|
||||
*/
|
||||
async requireProxySession(sessionId: string) {
|
||||
async requireBootstrapSession(sessionId: string) {
|
||||
return this.requireUsableSession(sessionId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads and validates a non-terminal, non-expired session.
|
||||
* Loads a session only when it is active and usable for proxy handling.
|
||||
* @param sessionId - Gateway session id from a public Gateway route.
|
||||
* @returns Stored server-only session metadata for proxy setup.
|
||||
*/
|
||||
async requireProxySession(sessionId: string) {
|
||||
const session = await this.requireUsableSession(sessionId);
|
||||
if (session.status !== 'active') {
|
||||
throw new GoneException('Gateway session is not active');
|
||||
}
|
||||
|
||||
return session;
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads and validates a non-terminal, non-expired, currently indexed session.
|
||||
* @param sessionId - Gateway session id.
|
||||
* @returns Usable Gateway session.
|
||||
*/
|
||||
@ -160,6 +174,14 @@ export class NapcatWebuiGatewaySessionService {
|
||||
throw new GoneException('Gateway session is not active');
|
||||
}
|
||||
|
||||
const indexed = await this.store.findActiveByUserAndAccount(
|
||||
session.adminUserId,
|
||||
session.accountId,
|
||||
);
|
||||
if (!indexed || indexed.sessionId !== session.sessionId) {
|
||||
throw new GoneException('Gateway session is not active');
|
||||
}
|
||||
|
||||
return session;
|
||||
}
|
||||
|
||||
|
||||
@ -17,29 +17,59 @@ if not currentJson then
|
||||
end
|
||||
|
||||
local current = cjson.decode(currentJson)
|
||||
local next = cjson.decode(ARGV[2])
|
||||
local patch = cjson.decode(ARGV[2])
|
||||
local terminal = { expired = true, failed = true, revoked = true }
|
||||
local indexValue = redis.call("GET", KEYS[2])
|
||||
local next = {}
|
||||
|
||||
for key, value in pairs(current) do
|
||||
next[key] = value
|
||||
end
|
||||
for key, value in pairs(patch) do
|
||||
next[key] = value
|
||||
end
|
||||
|
||||
next["sessionId"] = ARGV[1]
|
||||
next["adminUserId"] = current["adminUserId"]
|
||||
next["accountId"] = current["accountId"]
|
||||
|
||||
if current["expiresAt"] and patch["expiresAt"] and tonumber(current["expiresAt"]) > tonumber(patch["expiresAt"]) then
|
||||
next["expiresAt"] = current["expiresAt"]
|
||||
end
|
||||
if current["lastSeenAt"] and patch["lastSeenAt"] and tonumber(current["lastSeenAt"]) > tonumber(patch["lastSeenAt"]) then
|
||||
next["lastSeenAt"] = current["lastSeenAt"]
|
||||
end
|
||||
if current["activeAt"] then
|
||||
next["activeAt"] = current["activeAt"]
|
||||
end
|
||||
if current["revokedAt"] then
|
||||
next["revokedAt"] = current["revokedAt"]
|
||||
end
|
||||
|
||||
local indexKey = ARGV[3] .. current["adminUserId"] .. ":" .. current["accountId"]
|
||||
local indexValue = redis.call("GET", indexKey)
|
||||
local now = tonumber(ARGV[4])
|
||||
local ttl = math.max(1, tonumber(next["expiresAt"]) - now)
|
||||
local nextJson = cjson.encode(next)
|
||||
|
||||
if terminal[current["status"]] and not terminal[next["status"]] then
|
||||
return {0, "Gateway session is not active"}
|
||||
end
|
||||
|
||||
if terminal[next["status"]] then
|
||||
redis.call("PSETEX", KEYS[1], ARGV[3], ARGV[2])
|
||||
redis.call("PSETEX", KEYS[1], ttl, nextJson)
|
||||
if indexValue == ARGV[1] then
|
||||
redis.call("DEL", KEYS[2])
|
||||
redis.call("DEL", indexKey)
|
||||
end
|
||||
return {1, ARGV[2]}
|
||||
return {1, nextJson}
|
||||
end
|
||||
|
||||
if indexValue and indexValue ~= ARGV[1] then
|
||||
return {0, "Gateway session is not active"}
|
||||
end
|
||||
|
||||
redis.call("PSETEX", KEYS[1], ARGV[3], ARGV[2])
|
||||
redis.call("SET", KEYS[2], ARGV[1], "PX", ARGV[4])
|
||||
return {1, ARGV[2]}
|
||||
redis.call("PSETEX", KEYS[1], ttl, nextJson)
|
||||
redis.call("SET", indexKey, ARGV[1], "PX", ttl)
|
||||
return {1, nextJson}
|
||||
`;
|
||||
|
||||
@Injectable()
|
||||
@ -107,21 +137,7 @@ export class NapcatWebuiGatewayRedisStore
|
||||
sessionId: string,
|
||||
patch: Partial<NapcatWebuiGatewaySession>,
|
||||
) {
|
||||
const current = await this.find(sessionId);
|
||||
if (!current) throw new Error('Gateway session is not active');
|
||||
|
||||
const next = {
|
||||
...current,
|
||||
...patch,
|
||||
sessionId,
|
||||
};
|
||||
if (this.isTerminal(current) && !this.isTerminal(next)) {
|
||||
throw new Error('Gateway terminal session cannot become active');
|
||||
}
|
||||
|
||||
await this.writeSessionAndIndexAtomically(next);
|
||||
|
||||
return next;
|
||||
return this.mergeSessionPatchAtomically(sessionId, patch);
|
||||
}
|
||||
|
||||
/**
|
||||
@ -178,26 +194,29 @@ export class NapcatWebuiGatewayRedisStore
|
||||
}
|
||||
|
||||
/**
|
||||
* Atomically writes session JSON and maintains the user/account index.
|
||||
* @param session - Already-merged Gateway session to commit.
|
||||
* Atomically merges a patch into current Redis JSON and maintains the index.
|
||||
* @param sessionId - Gateway session id to update.
|
||||
* @param patch - Partial session fields to merge inside Redis.
|
||||
* @returns Final merged session returned by the Lua script.
|
||||
*/
|
||||
private async writeSessionAndIndexAtomically(
|
||||
session: NapcatWebuiGatewaySession,
|
||||
private async mergeSessionPatchAtomically(
|
||||
sessionId: string,
|
||||
patch: Partial<NapcatWebuiGatewaySession>,
|
||||
) {
|
||||
const sessionJson = JSON.stringify(session);
|
||||
const result = (await this.redis.eval(
|
||||
UPDATE_SESSION_SCRIPT,
|
||||
2,
|
||||
this.sessionKey(session.sessionId),
|
||||
this.userAccountKey(session.adminUserId, session.accountId),
|
||||
session.sessionId,
|
||||
sessionJson,
|
||||
this.remainingTtlMs(session),
|
||||
this.remainingTtlMs(session),
|
||||
1,
|
||||
this.sessionKey(sessionId),
|
||||
sessionId,
|
||||
JSON.stringify(patch),
|
||||
USER_ACCOUNT_KEY_PREFIX,
|
||||
this.config.now(),
|
||||
)) as [number, string];
|
||||
if (!Array.isArray(result) || Number(result[0]) !== 1) {
|
||||
throw new Error(String(result?.[1] || 'Gateway session is not active'));
|
||||
}
|
||||
|
||||
return JSON.parse(result[1]) as NapcatWebuiGatewaySession;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -156,27 +156,52 @@ class FakeRedis {
|
||||
if (!script.includes('redis.call')) {
|
||||
throw new Error('Unexpected Redis script');
|
||||
}
|
||||
if (keyCount !== 2) {
|
||||
if (keyCount !== 1) {
|
||||
throw new Error('Unexpected Redis key count');
|
||||
}
|
||||
|
||||
const [
|
||||
sessionKey,
|
||||
indexKey,
|
||||
sessionId,
|
||||
nextSessionJson,
|
||||
sessionTtlMs,
|
||||
indexTtlMs,
|
||||
patchJson,
|
||||
userAccountKeyPrefix,
|
||||
now,
|
||||
] = args;
|
||||
const currentJson = this.values.get(sessionKey);
|
||||
if (!currentJson) return [0, 'Gateway session is not active'];
|
||||
|
||||
const current = JSON.parse(currentJson) as NapcatWebuiGatewaySession;
|
||||
const next = JSON.parse(nextSessionJson) as NapcatWebuiGatewaySession;
|
||||
const patch = JSON.parse(
|
||||
patchJson,
|
||||
) as Partial<NapcatWebuiGatewaySession>;
|
||||
const next = {
|
||||
...current,
|
||||
...patch,
|
||||
accountId: current.accountId,
|
||||
adminUserId: current.adminUserId,
|
||||
sessionId,
|
||||
} as NapcatWebuiGatewaySession;
|
||||
next.expiresAt = Math.max(current.expiresAt, patch.expiresAt || 0);
|
||||
if (current.lastSeenAt || patch.lastSeenAt) {
|
||||
next.lastSeenAt = Math.max(
|
||||
current.lastSeenAt || 0,
|
||||
patch.lastSeenAt || 0,
|
||||
);
|
||||
}
|
||||
if (current.activeAt) {
|
||||
next.activeAt = current.activeAt;
|
||||
}
|
||||
if (current.revokedAt) {
|
||||
next.revokedAt = current.revokedAt;
|
||||
}
|
||||
|
||||
const terminalStatuses = ['expired', 'failed', 'revoked'];
|
||||
const currentTerminal = terminalStatuses.includes(current.status);
|
||||
const nextTerminal = terminalStatuses.includes(next.status);
|
||||
const indexKey = `${userAccountKeyPrefix}${current.adminUserId}:${current.accountId}`;
|
||||
const indexValue = this.values.get(indexKey);
|
||||
const ttlMs = Math.max(1, next.expiresAt - Number(now));
|
||||
const nextSessionJson = JSON.stringify(next);
|
||||
|
||||
if (currentTerminal && !nextTerminal) {
|
||||
return [0, 'Gateway session is not active'];
|
||||
@ -184,7 +209,7 @@ class FakeRedis {
|
||||
|
||||
if (nextTerminal) {
|
||||
this.values.set(sessionKey, nextSessionJson);
|
||||
this.ttl.set(sessionKey, Number(sessionTtlMs));
|
||||
this.ttl.set(sessionKey, ttlMs);
|
||||
if (indexValue === sessionId) {
|
||||
this.values.delete(indexKey);
|
||||
this.ttl.delete(indexKey);
|
||||
@ -197,9 +222,9 @@ class FakeRedis {
|
||||
}
|
||||
|
||||
this.values.set(sessionKey, nextSessionJson);
|
||||
this.ttl.set(sessionKey, Number(sessionTtlMs));
|
||||
this.ttl.set(sessionKey, ttlMs);
|
||||
this.values.set(indexKey, sessionId);
|
||||
this.ttl.set(indexKey, Number(indexTtlMs));
|
||||
this.ttl.set(indexKey, ttlMs);
|
||||
return [1, nextSessionJson];
|
||||
}
|
||||
}
|
||||
@ -370,6 +395,37 @@ describe('NapcatWebuiGatewaySessionService', () => {
|
||||
).rejects.toThrow('Gateway session owner mismatch');
|
||||
});
|
||||
|
||||
it('keeps created sessions out of proxy access until bootstrap marks them active', async () => {
|
||||
const store = new MemorySessionStore();
|
||||
const currentTime = { value: 1000 };
|
||||
const service = new NapcatWebuiGatewaySessionService(
|
||||
store,
|
||||
createConfig(currentTime) as never,
|
||||
);
|
||||
const session = await service.create(createSessionInput());
|
||||
|
||||
await expect(service.requireProxySession(session.sessionId)).rejects.toThrow(
|
||||
'Gateway session is not active',
|
||||
);
|
||||
await expect(
|
||||
service.requireBootstrapSession(session.sessionId),
|
||||
).resolves.toMatchObject({
|
||||
sessionId: session.sessionId,
|
||||
status: 'created',
|
||||
});
|
||||
|
||||
currentTime.value = 5000;
|
||||
await service.markActive(session.sessionId);
|
||||
|
||||
await expect(
|
||||
service.requireProxySession(session.sessionId),
|
||||
).resolves.toMatchObject({
|
||||
activeAt: 5000,
|
||||
sessionId: session.sessionId,
|
||||
status: 'active',
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects expired proxy sessions and marks them expired', async () => {
|
||||
const store = new MemorySessionStore();
|
||||
const currentTime = { value: 1000 };
|
||||
@ -388,6 +444,24 @@ describe('NapcatWebuiGatewaySessionService', () => {
|
||||
status: 'expired',
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects revoked sessions for proxy access', async () => {
|
||||
const store = new MemorySessionStore();
|
||||
const service = new NapcatWebuiGatewaySessionService(
|
||||
store,
|
||||
createConfig({ value: 1000 }) as never,
|
||||
);
|
||||
const session = await service.create(createSessionInput());
|
||||
|
||||
await service.revoke({
|
||||
adminUserId: 'admin-1',
|
||||
sessionId: session.sessionId,
|
||||
});
|
||||
|
||||
await expect(service.requireProxySession(session.sessionId)).rejects.toThrow(
|
||||
'Gateway session is not active',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('NapcatWebuiGatewayRedisStore', () => {
|
||||
@ -521,6 +595,90 @@ describe('NapcatWebuiGatewayRedisStore', () => {
|
||||
sessionId: second.sessionId,
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects stale replaced sessions from bootstrap and proxy loaders', async () => {
|
||||
const redis = new FakeRedis();
|
||||
const config = createConfig({ value: 1000 });
|
||||
const store = new NapcatWebuiGatewayRedisStore(
|
||||
redis as never,
|
||||
config as never,
|
||||
);
|
||||
const service = new NapcatWebuiGatewaySessionService(
|
||||
store,
|
||||
config as never,
|
||||
);
|
||||
const first = await service.create(createSessionInput());
|
||||
const second = await service.create(createSessionInput());
|
||||
const firstSessionKey = `napcat:webui:session:${first.sessionId}`;
|
||||
const indexKey = 'napcat:webui:user-account:admin-1:account-1';
|
||||
|
||||
redis.values.set(
|
||||
firstSessionKey,
|
||||
JSON.stringify({
|
||||
...first,
|
||||
status: 'created',
|
||||
}),
|
||||
);
|
||||
redis.values.set(indexKey, second.sessionId);
|
||||
|
||||
await expect(
|
||||
service.requireBootstrapSession(first.sessionId),
|
||||
).rejects.toThrow('Gateway session is not active');
|
||||
|
||||
redis.values.set(
|
||||
firstSessionKey,
|
||||
JSON.stringify({
|
||||
...first,
|
||||
activeAt: 2000,
|
||||
lastSeenAt: 2000,
|
||||
status: 'active',
|
||||
}),
|
||||
);
|
||||
|
||||
await expect(service.requireProxySession(first.sessionId)).rejects.toThrow(
|
||||
'Gateway session is not active',
|
||||
);
|
||||
});
|
||||
|
||||
it('merges delayed lifecycle patches without reducing monotonic timestamps', async () => {
|
||||
const redis = new FakeRedis();
|
||||
const currentTime = { value: 1000 };
|
||||
const config = createConfig(currentTime);
|
||||
const store = new NapcatWebuiGatewayRedisStore(
|
||||
redis as never,
|
||||
config as never,
|
||||
);
|
||||
const service = new NapcatWebuiGatewaySessionService(
|
||||
store,
|
||||
config as never,
|
||||
);
|
||||
const session = await service.create(createSessionInput());
|
||||
|
||||
currentTime.value = 5000;
|
||||
await service.markActive(session.sessionId);
|
||||
|
||||
currentTime.value = 2000;
|
||||
const heartbeat = await service.heartbeat({
|
||||
adminUserId: 'admin-1',
|
||||
sessionId: session.sessionId,
|
||||
});
|
||||
const markActive = await service.markActive(session.sessionId);
|
||||
const stored = await store.find(session.sessionId);
|
||||
|
||||
expect(heartbeat.expiresAt).toBe(65_000);
|
||||
expect(markActive).toMatchObject({
|
||||
activeAt: 5000,
|
||||
expiresAt: 65_000,
|
||||
lastSeenAt: 5000,
|
||||
status: 'active',
|
||||
});
|
||||
expect(stored).toMatchObject({
|
||||
activeAt: 5000,
|
||||
expiresAt: 65_000,
|
||||
lastSeenAt: 5000,
|
||||
status: 'active',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('NapcatWebuiGatewayTicketService', () => {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user