feat: 实现NapCat WebUI Gateway代理
This commit is contained in:
parent
52d96294ed
commit
d0fbc13763
@ -5,6 +5,7 @@ const DEFAULT_GATEWAY_PORT = 48086;
|
||||
const DEFAULT_REDIS_HOST = '127.0.0.1';
|
||||
const DEFAULT_REDIS_PORT = 6379;
|
||||
const DEFAULT_SESSION_TTL_MS = 60_000;
|
||||
const DEFAULT_UPSTREAM_TIMEOUT_MS = 5000;
|
||||
const MAX_TICKET_TTL_MS = 60_000;
|
||||
|
||||
@Injectable()
|
||||
@ -59,6 +60,17 @@ export class NapcatWebuiGatewayConfigService {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads the bounded timeout for NapCat WebUI upstream HTTP calls.
|
||||
* @returns Positive timeout in milliseconds, defaulting to 5 seconds.
|
||||
*/
|
||||
upstreamTimeoutMs() {
|
||||
return this.getPositiveNumber(
|
||||
'NAPCAT_WEBUI_GATEWAY_UPSTREAM_TIMEOUT_MS',
|
||||
DEFAULT_UPSTREAM_TIMEOUT_MS,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads the shared internal secret required for mutating API-to-Gateway calls.
|
||||
* @returns Trimmed secret or an empty string when missing so callers fail closed.
|
||||
|
||||
@ -0,0 +1,101 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
import axios from 'axios';
|
||||
import { BadGatewayException, Injectable } from '@nestjs/common';
|
||||
import { NapcatWebuiGatewayConfigService } from '../config/napcat-webui-gateway-config.service';
|
||||
import type { NapcatWebuiGatewaySession } from '../domain/napcat-webui-gateway.types';
|
||||
|
||||
type NapcatCredentialBody = {
|
||||
Credential?: string;
|
||||
};
|
||||
|
||||
type NapcatCredentialResponse =
|
||||
| NapcatCredentialBody
|
||||
| {
|
||||
data?: NapcatCredentialBody;
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class NapcatWebuiCredentialClient {
|
||||
private readonly credentials = new Map<
|
||||
string,
|
||||
{ credential: string; expiresAt: number }
|
||||
>();
|
||||
|
||||
/**
|
||||
* Creates the standalone Gateway credential client.
|
||||
* @param config - Gateway config used for bounded upstream requests and time.
|
||||
*/
|
||||
constructor(private readonly config: NapcatWebuiGatewayConfigService) {}
|
||||
|
||||
/**
|
||||
* Returns a cached or freshly exchanged NapCat WebUI credential for one Gateway session.
|
||||
* @param session - Server-only Gateway session metadata containing token and upstream URL.
|
||||
* @returns NapCat WebUI Credential string for upstream Authorization.
|
||||
*/
|
||||
async getCredential(session: NapcatWebuiGatewaySession) {
|
||||
const cached = this.credentials.get(session.sessionId);
|
||||
const now = this.config.now();
|
||||
if (
|
||||
cached &&
|
||||
now < cached.expiresAt &&
|
||||
cached.expiresAt <= session.expiresAt
|
||||
) {
|
||||
return cached.credential;
|
||||
}
|
||||
|
||||
const credential = await this.exchangeCredential(session);
|
||||
this.credentials.set(session.sessionId, {
|
||||
credential,
|
||||
expiresAt: session.expiresAt,
|
||||
});
|
||||
return credential;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears a cached credential when a Gateway session is revoked.
|
||||
* @param sessionId - Gateway session id whose cached credential should be removed.
|
||||
*/
|
||||
clear(sessionId: string) {
|
||||
this.credentials.delete(sessionId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Exchanges the server-only WebUI token for a NapCat Credential.
|
||||
* @param session - Gateway session containing upstream base URL and WebUI token.
|
||||
* @returns Credential returned by NapCat WebUI.
|
||||
*/
|
||||
private async exchangeCredential(session: NapcatWebuiGatewaySession) {
|
||||
const hash = createHash('sha256')
|
||||
.update(`${session.webuiToken}.napcat`)
|
||||
.digest('hex');
|
||||
|
||||
try {
|
||||
const response = await axios.post<NapcatCredentialResponse>(
|
||||
new URL('/api/auth/login', session.upstreamBaseUrl).toString(),
|
||||
{ hash },
|
||||
{
|
||||
timeout: this.config.upstreamTimeoutMs(),
|
||||
},
|
||||
);
|
||||
const credential = this.extractCredential(response.data);
|
||||
if (!credential) {
|
||||
throw new Error('Missing Credential');
|
||||
}
|
||||
return credential;
|
||||
} catch {
|
||||
throw new BadGatewayException('NapCat WebUI credential exchange failed');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads Credential from either the raw NapCat payload or its data wrapper.
|
||||
* @param body - Axios response body from `/api/auth/login`.
|
||||
* @returns Credential when present.
|
||||
*/
|
||||
private extractCredential(body: NapcatCredentialResponse) {
|
||||
if ('data' in body) {
|
||||
return body.data?.Credential;
|
||||
}
|
||||
return (body as NapcatCredentialBody).Credential;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,325 @@
|
||||
import type { IncomingMessage, Server } from 'node:http';
|
||||
import type { Socket } from 'node:net';
|
||||
import { BadRequestException, HttpStatus, Injectable } from '@nestjs/common';
|
||||
import type { NextFunction, Request, Response } from 'express';
|
||||
import {
|
||||
createProxyMiddleware,
|
||||
type RequestHandler,
|
||||
} from 'http-proxy-middleware';
|
||||
import { NapcatWebuiGatewaySessionService } from '../../application/napcat-webui-gateway-session.service';
|
||||
import type { NapcatWebuiGatewaySession } from '../../domain/napcat-webui-gateway.types';
|
||||
import { NapcatWebuiCredentialClient } from '../napcat-webui-credential.client';
|
||||
|
||||
const GATEWAY_WEBUI_PREFIX = '/napcat-webui/session';
|
||||
const STRIPPED_UPSTREAM_HEADERS = [
|
||||
'authorization',
|
||||
'cookie',
|
||||
'x-admin-token',
|
||||
'x-api-token',
|
||||
'x-access-token',
|
||||
'x-kt-access-token',
|
||||
'x-kt-gateway-secret',
|
||||
'x-wordpress-cookie',
|
||||
] as const;
|
||||
|
||||
type ProxyPathInput = string | string[] | undefined;
|
||||
|
||||
type RewriteLocationInput = {
|
||||
location: string;
|
||||
sessionId: string;
|
||||
upstreamBaseUrl: string;
|
||||
};
|
||||
|
||||
type CookiePathRewriteInput = {
|
||||
sessionId: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Normalizes a Gateway route tail into a safe upstream pathname.
|
||||
* @param input - Route parameter from Nest/path-to-regexp.
|
||||
* @returns Absolute upstream pathname beginning with `/`.
|
||||
*/
|
||||
export function sanitizeGatewayProxyPath(input: ProxyPathInput) {
|
||||
const raw = Array.isArray(input) ? input.join('/') : String(input || '');
|
||||
const trimmed = raw.trim();
|
||||
const decoded = decodeProxyPath(trimmed);
|
||||
|
||||
if (
|
||||
!decoded ||
|
||||
decoded.includes('\\') ||
|
||||
decoded.startsWith('//') ||
|
||||
/^[a-z][a-z0-9+.-]*:/i.test(decoded)
|
||||
) {
|
||||
throw new BadRequestException('Gateway proxy path is invalid');
|
||||
}
|
||||
|
||||
const path = decoded.startsWith('/') ? decoded : `/${decoded}`;
|
||||
const segments = path.split('/').filter(Boolean);
|
||||
if (segments.some((segment) => segment === '..')) {
|
||||
throw new BadRequestException('Gateway proxy path is invalid');
|
||||
}
|
||||
|
||||
return path;
|
||||
}
|
||||
|
||||
/**
|
||||
* Rewrites NapCat redirects so browsers stay under the Gateway session route.
|
||||
* @param input - Upstream Location header plus Gateway session context.
|
||||
* @returns Rewritten safe Location header.
|
||||
*/
|
||||
export function rewriteNapcatLocationHeader(input: RewriteLocationInput) {
|
||||
const gatewayPrefix = `${GATEWAY_WEBUI_PREFIX}/${encodeURIComponent(
|
||||
input.sessionId,
|
||||
)}/webui`;
|
||||
const location = input.location.trim();
|
||||
if (!location) return location;
|
||||
|
||||
if (location.startsWith('//')) {
|
||||
try {
|
||||
const upstream = new URL(input.upstreamBaseUrl);
|
||||
const target = new URL(`${upstream.protocol}${location}`);
|
||||
return `${gatewayPrefix}${target.pathname}${target.search}${target.hash}`;
|
||||
} catch {
|
||||
return `${gatewayPrefix}/webui`;
|
||||
}
|
||||
}
|
||||
if (location.startsWith('/')) {
|
||||
return `${gatewayPrefix}${location}`;
|
||||
}
|
||||
if (!/^[a-z][a-z0-9+.-]*:/i.test(location)) {
|
||||
return `${gatewayPrefix}/${location.replace(/^\/+/, '')}`;
|
||||
}
|
||||
|
||||
try {
|
||||
const target = new URL(location);
|
||||
if (target.protocol === 'http:' || target.protocol === 'https:') {
|
||||
return `${gatewayPrefix}${target.pathname}${target.search}${target.hash}`;
|
||||
}
|
||||
return `${gatewayPrefix}/webui`;
|
||||
} catch {
|
||||
return `${gatewayPrefix}/webui`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds HPM cookie path rewrite config for Gateway-scoped upstream cookies.
|
||||
* @param input - Gateway session id used in the public route prefix.
|
||||
* @returns HPM cookiePathRewrite object.
|
||||
*/
|
||||
export function buildGatewayCookiePathRewrite(input: CookiePathRewriteInput) {
|
||||
return {
|
||||
'*': `${GATEWAY_WEBUI_PREFIX}/${encodeURIComponent(input.sessionId)}/webui`,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Decodes path text until stable so nested encoded traversal cannot pass through.
|
||||
* @param value - Raw route path text.
|
||||
* @returns Decoded path text.
|
||||
*/
|
||||
function decodeProxyPath(value: string) {
|
||||
try {
|
||||
let decoded = value;
|
||||
for (let index = 0; index < 6; index += 1) {
|
||||
const next = decodeURIComponent(decoded);
|
||||
if (next === decoded) {
|
||||
return next;
|
||||
}
|
||||
decoded = next;
|
||||
}
|
||||
} catch {
|
||||
throw new BadRequestException('Gateway proxy path is invalid');
|
||||
}
|
||||
|
||||
throw new BadRequestException('Gateway proxy path is invalid');
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class NapcatWebuiProxyService {
|
||||
/**
|
||||
* Creates the Gateway proxy service.
|
||||
* @param sessionService - Session lifecycle guard for bootstrap/proxy eligibility.
|
||||
* @param credentialClient - NapCat WebUI credential exchange/cache client.
|
||||
*/
|
||||
constructor(
|
||||
private readonly sessionService: NapcatWebuiGatewaySessionService,
|
||||
private readonly credentialClient: NapcatWebuiCredentialClient,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Proxies one HTTP request to the active session's NapCat WebUI.
|
||||
* @param sessionId - Gateway session id from the public route.
|
||||
* @param proxyPath - Route tail mapped to the upstream pathname.
|
||||
* @param req - Express request delegated from the public controller.
|
||||
* @param res - Express response owned by HPM after delegation.
|
||||
* @param next - Express next callback used by HPM.
|
||||
*/
|
||||
async handleHttpProxy(
|
||||
sessionId: string,
|
||||
proxyPath: ProxyPathInput,
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction,
|
||||
) {
|
||||
const session = await this.sessionService.requireProxySession(sessionId);
|
||||
const upstreamPath = this.buildUpstreamPath(proxyPath, req.originalUrl);
|
||||
const credential = await this.credentialClient.getCredential(session);
|
||||
this.stripBrowserHeaders(req);
|
||||
req.url = upstreamPath;
|
||||
|
||||
const proxy = this.createProxy(session, credential);
|
||||
return proxy(req, res, next);
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribes the Gateway HTTP server to NapCat WebUI WebSocket upgrades.
|
||||
* @param server - HTTP server returned by the Nest application.
|
||||
*/
|
||||
bindWebSocketUpgrade(server: Server) {
|
||||
server.on('upgrade', (req, socket, head) => {
|
||||
void this.handleWebSocketUpgrade(req, socket as Socket, head);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles one matching WebSocket upgrade and ignores unrelated upgrade URLs.
|
||||
* @param req - Raw Node upgrade request.
|
||||
* @param socket - TCP socket for the upgrade.
|
||||
* @param head - First packet of the upgraded stream.
|
||||
*/
|
||||
private async handleWebSocketUpgrade(
|
||||
req: IncomingMessage,
|
||||
socket: Socket,
|
||||
head: Buffer,
|
||||
) {
|
||||
try {
|
||||
const match = this.matchGatewayUpgrade(req.url || '');
|
||||
if (!match) return;
|
||||
const session = await this.sessionService.requireProxySession(
|
||||
match.sessionId,
|
||||
);
|
||||
const credential = await this.credentialClient.getCredential(session);
|
||||
this.stripBrowserHeaders(req);
|
||||
req.url = `${match.proxyPath}${match.search}`;
|
||||
const proxy = this.createProxy(session, credential);
|
||||
proxy.upgrade(req, socket, head);
|
||||
} catch {
|
||||
this.rejectUpgrade(socket);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates one HPM proxy bound to a validated session and server-side credential.
|
||||
* @param session - Active Gateway session metadata.
|
||||
* @param credential - NapCat WebUI Credential for upstream Authorization.
|
||||
* @returns HPM request handler with HTTP and WebSocket support.
|
||||
*/
|
||||
private createProxy(
|
||||
session: NapcatWebuiGatewaySession,
|
||||
credential: string,
|
||||
): RequestHandler<Request, Response, NextFunction> {
|
||||
return createProxyMiddleware<Request, Response, NextFunction>({
|
||||
changeOrigin: true,
|
||||
cookiePathRewrite: buildGatewayCookiePathRewrite({
|
||||
sessionId: session.sessionId,
|
||||
}),
|
||||
on: {
|
||||
error: (_error, _req, res) => {
|
||||
this.writeProxyError(res);
|
||||
},
|
||||
proxyReq: (proxyReq) => {
|
||||
proxyReq.removeHeader('cookie');
|
||||
proxyReq.setHeader('Authorization', `Bearer ${credential}`);
|
||||
},
|
||||
proxyReqWs: (proxyReq) => {
|
||||
proxyReq.removeHeader('cookie');
|
||||
proxyReq.setHeader('Authorization', `Bearer ${credential}`);
|
||||
},
|
||||
proxyRes: (proxyRes) => {
|
||||
const location = proxyRes.headers.location;
|
||||
if (typeof location === 'string') {
|
||||
proxyRes.headers.location = rewriteNapcatLocationHeader({
|
||||
location,
|
||||
sessionId: session.sessionId,
|
||||
upstreamBaseUrl: session.upstreamBaseUrl,
|
||||
});
|
||||
}
|
||||
},
|
||||
},
|
||||
secure: false,
|
||||
target: session.upstreamBaseUrl,
|
||||
ws: true,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the upstream URL path while preserving the original query string.
|
||||
* @param proxyPath - Gateway route tail to sanitize.
|
||||
* @param originalUrl - Original Express URL containing the query string.
|
||||
* @returns Upstream path plus query string.
|
||||
*/
|
||||
private buildUpstreamPath(proxyPath: ProxyPathInput, originalUrl?: string) {
|
||||
const pathname = sanitizeGatewayProxyPath(proxyPath);
|
||||
const queryIndex = String(originalUrl || '').indexOf('?');
|
||||
const query = queryIndex >= 0 ? String(originalUrl).slice(queryIndex) : '';
|
||||
return `${pathname}${query}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses a public WebSocket upgrade URL into Gateway session and upstream path.
|
||||
* @param rawUrl - Raw URL from the Node upgrade request.
|
||||
* @returns Parsed session id, sanitized upstream path, and query string when matched.
|
||||
*/
|
||||
private matchGatewayUpgrade(rawUrl: string) {
|
||||
const url = new URL(rawUrl, 'http://gateway.local');
|
||||
const match = url.pathname.match(
|
||||
/^\/napcat-webui\/session\/([^/]+)\/webui(?:\/(.*))?$/,
|
||||
);
|
||||
if (!match) return undefined;
|
||||
|
||||
return {
|
||||
proxyPath: sanitizeGatewayProxyPath(match[2] || ''),
|
||||
search: url.search,
|
||||
sessionId: decodeURIComponent(match[1]),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes browser-provided auth/session headers before HPM builds upstream requests.
|
||||
* @param req - Express or Node request whose headers are being proxied.
|
||||
*/
|
||||
private stripBrowserHeaders(req: IncomingMessage) {
|
||||
STRIPPED_UPSTREAM_HEADERS.forEach((header) => {
|
||||
delete req.headers[header];
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes a generic HTTP proxy failure without exposing upstream target data.
|
||||
* @param res - HTTP response object passed by HPM.
|
||||
*/
|
||||
private writeProxyError(res: Response | Socket) {
|
||||
if ('headersSent' in res) {
|
||||
if (res.headersSent) return;
|
||||
res.status(HttpStatus.BAD_GATEWAY).json({
|
||||
message: 'NapCat WebUI proxy failed',
|
||||
statusCode: HttpStatus.BAD_GATEWAY,
|
||||
});
|
||||
return;
|
||||
}
|
||||
this.rejectUpgrade(res);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends a compact HTTP error for failed WebSocket upgrade validation.
|
||||
* @param socket - Upgrade socket to close after the error response.
|
||||
*/
|
||||
private rejectUpgrade(socket: Socket) {
|
||||
if (socket.writable) {
|
||||
socket.write(
|
||||
'HTTP/1.1 403 Forbidden\r\nConnection: close\r\nContent-Length: 0\r\n\r\n',
|
||||
);
|
||||
}
|
||||
socket.destroy();
|
||||
}
|
||||
}
|
||||
@ -2,6 +2,7 @@ import { NestFactory } from '@nestjs/core';
|
||||
import { Logger } from 'nestjs-pino';
|
||||
import { json, urlencoded } from 'express';
|
||||
import { NapcatWebuiGatewayConfigService } from './config/napcat-webui-gateway-config.service';
|
||||
import { NapcatWebuiProxyService } from './infrastructure/proxy/napcat-webui-proxy.service';
|
||||
import { NapcatWebuiGatewayModule } from './napcat-webui-gateway.module';
|
||||
|
||||
/**
|
||||
@ -15,6 +16,7 @@ async function bootstrap() {
|
||||
app.use(json({ limit: '64kb' }));
|
||||
app.use(urlencoded({ extended: true, limit: '64kb' }));
|
||||
await app.listen(app.get(NapcatWebuiGatewayConfigService).port());
|
||||
app.get(NapcatWebuiProxyService).bindWebSocketUpgrade(app.getHttpServer());
|
||||
}
|
||||
|
||||
bootstrap();
|
||||
|
||||
@ -6,9 +6,12 @@ import { createPinoLoggerParams } from '@/common';
|
||||
import { NapcatWebuiGatewaySessionService } from './application/napcat-webui-gateway-session.service';
|
||||
import { NapcatWebuiGatewayConfigService } from './config/napcat-webui-gateway-config.service';
|
||||
import { NAPCAT_WEBUI_GATEWAY_SESSION_STORE } from './domain/napcat-webui-gateway.types';
|
||||
import { NapcatWebuiCredentialClient } from './infrastructure/napcat-webui-credential.client';
|
||||
import { NapcatWebuiProxyService } from './infrastructure/proxy/napcat-webui-proxy.service';
|
||||
import { NapcatWebuiGatewayRedisStore } from './infrastructure/session/napcat-webui-gateway-redis.store';
|
||||
import { NapcatWebuiGatewayTicketService } from './infrastructure/session/napcat-webui-gateway-ticket.service';
|
||||
import { InternalSessionController } from './presentation/internal-session.controller';
|
||||
import { PublicWebuiController } from './presentation/public-webui.controller';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
@ -44,9 +47,11 @@ import { InternalSessionController } from './presentation/internal-session.contr
|
||||
},
|
||||
}),
|
||||
],
|
||||
controllers: [InternalSessionController],
|
||||
controllers: [InternalSessionController, PublicWebuiController],
|
||||
providers: [
|
||||
NapcatWebuiGatewayConfigService,
|
||||
NapcatWebuiCredentialClient,
|
||||
NapcatWebuiProxyService,
|
||||
NapcatWebuiGatewaySessionService,
|
||||
NapcatWebuiGatewayRedisStore,
|
||||
NapcatWebuiGatewayTicketService,
|
||||
|
||||
@ -13,6 +13,7 @@ import type {
|
||||
NapcatWebuiGatewayCreateSessionInput,
|
||||
NapcatWebuiGatewayLifecycleInput,
|
||||
} from '../domain/napcat-webui-gateway.types';
|
||||
import { NapcatWebuiCredentialClient } from '../infrastructure/napcat-webui-credential.client';
|
||||
import { NapcatWebuiGatewayTicketService } from '../infrastructure/session/napcat-webui-gateway-ticket.service';
|
||||
|
||||
type CreateSessionBody = NapcatWebuiGatewayCreateSessionInput;
|
||||
@ -24,11 +25,13 @@ export class InternalSessionController {
|
||||
* Creates the internal API-to-Gateway session controller.
|
||||
* @param sessionService - Gateway session lifecycle application service.
|
||||
* @param ticketService - One-time bootstrap ticket service.
|
||||
* @param credentialClient - Server-side credential cache cleared on revoke.
|
||||
* @param config - Gateway config used for secret validation and public URL prefix.
|
||||
*/
|
||||
constructor(
|
||||
private readonly sessionService: NapcatWebuiGatewaySessionService,
|
||||
private readonly ticketService: NapcatWebuiGatewayTicketService,
|
||||
private readonly credentialClient: NapcatWebuiCredentialClient,
|
||||
private readonly config: NapcatWebuiGatewayConfigService,
|
||||
) {}
|
||||
|
||||
@ -91,16 +94,18 @@ export class InternalSessionController {
|
||||
* @returns Browser-safe revoke lifecycle result.
|
||||
*/
|
||||
@Post('sessions/:sessionId/revoke')
|
||||
revoke(
|
||||
async revoke(
|
||||
@Param('sessionId') sessionId: string,
|
||||
@Headers('x-kt-gateway-secret') secret: string,
|
||||
@Body() body: LifecycleBody,
|
||||
) {
|
||||
this.requireInternalSecret(secret);
|
||||
return this.sessionService.revoke({
|
||||
const result = await this.sessionService.revoke({
|
||||
...body,
|
||||
sessionId,
|
||||
});
|
||||
this.credentialClient.clear(sessionId);
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -0,0 +1,101 @@
|
||||
import {
|
||||
All,
|
||||
Controller,
|
||||
Get,
|
||||
GoneException,
|
||||
HttpStatus,
|
||||
Next,
|
||||
Param,
|
||||
Query,
|
||||
Req,
|
||||
Res,
|
||||
} from '@nestjs/common';
|
||||
import type { NextFunction, Request, Response } from 'express';
|
||||
import { NapcatWebuiGatewaySessionService } from '../application/napcat-webui-gateway-session.service';
|
||||
import { NapcatWebuiProxyService } from '../infrastructure/proxy/napcat-webui-proxy.service';
|
||||
import { NapcatWebuiGatewayTicketService } from '../infrastructure/session/napcat-webui-gateway-ticket.service';
|
||||
|
||||
@Controller('napcat-webui')
|
||||
export class PublicWebuiController {
|
||||
/**
|
||||
* Creates the browser-facing NapCat WebUI controller.
|
||||
* @param sessionService - Gateway session lifecycle service.
|
||||
* @param ticketService - One-time bootstrap ticket service.
|
||||
* @param proxyService - HTTP/WebSocket proxy delegation service.
|
||||
*/
|
||||
constructor(
|
||||
private readonly sessionService: NapcatWebuiGatewaySessionService,
|
||||
private readonly ticketService: NapcatWebuiGatewayTicketService,
|
||||
private readonly proxyService: NapcatWebuiProxyService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Redeems a one-time bootstrap ticket, activates the session, and enters WebUI.
|
||||
* @param sessionId - Gateway session id from the public route.
|
||||
* @param ticket - One-time ticket issued by the internal create-session endpoint.
|
||||
* @param res - Express response used to set the scoped cookie and redirect.
|
||||
*/
|
||||
@Get('session/:sessionId/bootstrap')
|
||||
async bootstrap(
|
||||
@Param('sessionId') sessionId: string,
|
||||
@Query('ticket') ticket: string,
|
||||
@Res() res: Response,
|
||||
) {
|
||||
const redeemedSessionId = await this.ticketService.redeem(
|
||||
this.requireTicket(ticket),
|
||||
);
|
||||
if (redeemedSessionId !== sessionId) {
|
||||
throw new GoneException('Gateway bootstrap ticket is not active');
|
||||
}
|
||||
|
||||
await this.sessionService.requireBootstrapSession(sessionId);
|
||||
await this.sessionService.markActive(sessionId);
|
||||
res.cookie('kt_napcat_webui_gateway', 'active', {
|
||||
httpOnly: true,
|
||||
path: `/napcat-webui/session/${encodeURIComponent(sessionId)}`,
|
||||
sameSite: 'lax',
|
||||
});
|
||||
res.redirect(
|
||||
HttpStatus.FOUND,
|
||||
`/napcat-webui/session/${encodeURIComponent(sessionId)}/webui/webui`,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delegates Gateway-owned WebUI HTTP routes to the proxy service.
|
||||
* @param sessionId - Gateway session id from the public route.
|
||||
* @param proxyPath - Path-to-regexp route tail for the upstream pathname.
|
||||
* @param req - Express request passed through to HPM.
|
||||
* @param res - Express response passed through to HPM.
|
||||
* @param next - Express next callback passed through to HPM.
|
||||
*/
|
||||
@All('session/:sessionId/webui/*proxyPath')
|
||||
proxy(
|
||||
@Param('sessionId') sessionId: string,
|
||||
@Param('proxyPath') proxyPath: string | string[] | undefined,
|
||||
@Req() req: Request,
|
||||
@Res() res: Response,
|
||||
@Next() next: NextFunction,
|
||||
) {
|
||||
return this.proxyService.handleHttpProxy(
|
||||
sessionId,
|
||||
proxyPath,
|
||||
req,
|
||||
res,
|
||||
next,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Requires a non-empty bootstrap ticket before touching the ticket store.
|
||||
* @param ticket - Candidate query ticket.
|
||||
* @returns Trimmed ticket value.
|
||||
*/
|
||||
private requireTicket(ticket: string) {
|
||||
const value = String(ticket || '').trim();
|
||||
if (!value) {
|
||||
throw new GoneException('Gateway bootstrap ticket is not active');
|
||||
}
|
||||
return value;
|
||||
}
|
||||
}
|
||||
302
test/apps/napcat-webui-gateway/proxy-rewrite.spec.ts
Normal file
302
test/apps/napcat-webui-gateway/proxy-rewrite.spec.ts
Normal file
@ -0,0 +1,302 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
import { HttpStatus, type INestApplication } from '@nestjs/common';
|
||||
import { Test } from '@nestjs/testing';
|
||||
import axios from 'axios';
|
||||
import * as request from 'supertest';
|
||||
import { NapcatWebuiGatewaySessionService } from '../../../src/apps/napcat-webui-gateway/application/napcat-webui-gateway-session.service';
|
||||
import type { NapcatWebuiGatewaySession } from '../../../src/apps/napcat-webui-gateway/domain/napcat-webui-gateway.types';
|
||||
import { NapcatWebuiCredentialClient } from '../../../src/apps/napcat-webui-gateway/infrastructure/napcat-webui-credential.client';
|
||||
import { NapcatWebuiGatewayTicketService } from '../../../src/apps/napcat-webui-gateway/infrastructure/session/napcat-webui-gateway-ticket.service';
|
||||
import {
|
||||
buildGatewayCookiePathRewrite,
|
||||
NapcatWebuiProxyService,
|
||||
rewriteNapcatLocationHeader,
|
||||
sanitizeGatewayProxyPath,
|
||||
} from '../../../src/apps/napcat-webui-gateway/infrastructure/proxy/napcat-webui-proxy.service';
|
||||
import { PublicWebuiController } from '../../../src/apps/napcat-webui-gateway/presentation/public-webui.controller';
|
||||
|
||||
jest.mock('axios');
|
||||
|
||||
const SESSION_ID = 'session-1';
|
||||
const UPSTREAM_BASE_URL = 'http://127.0.0.1:6099';
|
||||
const mockedAxiosPost = axios.post as jest.Mock;
|
||||
|
||||
/**
|
||||
* Creates server-only Gateway session metadata for public bootstrap tests.
|
||||
* @param override - Session fields that should replace the default fixture.
|
||||
* @returns Gateway session fixture.
|
||||
*/
|
||||
function createGatewaySession(
|
||||
override: Partial<NapcatWebuiGatewaySession> = {},
|
||||
): NapcatWebuiGatewaySession {
|
||||
return {
|
||||
accountId: 'account-1',
|
||||
adminUserId: 'admin-1',
|
||||
containerId: 'container-1',
|
||||
containerName: 'kt-qqbot-napcat-1914728559',
|
||||
createdAt: 1000,
|
||||
expiresAt: 61_000,
|
||||
selfId: '1914728559',
|
||||
sessionId: SESSION_ID,
|
||||
status: 'created',
|
||||
upstreamBaseUrl: UPSTREAM_BASE_URL,
|
||||
webuiToken: ['webui', 'token', 'fixture'].join('-'),
|
||||
...override,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a Gateway config fixture for credential client tests.
|
||||
* @param currentTime - Mutable timestamp source used to check cache expiry decisions.
|
||||
* @returns Config facade shape required by NapcatWebuiCredentialClient.
|
||||
*/
|
||||
function createCredentialConfig(currentTime: { value: number }) {
|
||||
return {
|
||||
now: () => currentTime.value,
|
||||
upstreamTimeoutMs: () => 5000,
|
||||
};
|
||||
}
|
||||
|
||||
describe('Napcat WebUI proxy rewrite helpers', () => {
|
||||
it('rejects absolute URL proxy paths', () => {
|
||||
expect(() => sanitizeGatewayProxyPath('https://evil.test/api')).toThrow(
|
||||
'Gateway proxy path is invalid',
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects dot-segment traversal proxy paths', () => {
|
||||
expect(() => sanitizeGatewayProxyPath('../api/auth/login')).toThrow(
|
||||
'Gateway proxy path is invalid',
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects encoded dot-segment traversal proxy paths', () => {
|
||||
expect(() => sanitizeGatewayProxyPath('%2e%2e/api/auth/login')).toThrow(
|
||||
'Gateway proxy path is invalid',
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects double-encoded dot-segment traversal proxy paths', () => {
|
||||
expect(() => sanitizeGatewayProxyPath('%252e%252e/api/auth/login')).toThrow(
|
||||
'Gateway proxy path is invalid',
|
||||
);
|
||||
});
|
||||
|
||||
it('normalizes string proxy paths to absolute upstream paths', () => {
|
||||
expect(sanitizeGatewayProxyPath('api/QQLogin/CheckLoginStatus')).toBe(
|
||||
'/api/QQLogin/CheckLoginStatus',
|
||||
);
|
||||
});
|
||||
|
||||
it('normalizes path-to-regexp array proxy paths to absolute upstream paths', () => {
|
||||
expect(
|
||||
sanitizeGatewayProxyPath(['api', 'QQLogin', 'CheckLoginStatus']),
|
||||
).toBe('/api/QQLogin/CheckLoginStatus');
|
||||
});
|
||||
|
||||
it('rewrites NapCat relative redirects under the Gateway session prefix', () => {
|
||||
expect(
|
||||
rewriteNapcatLocationHeader({
|
||||
location: '/webui/login',
|
||||
sessionId: SESSION_ID,
|
||||
upstreamBaseUrl: UPSTREAM_BASE_URL,
|
||||
}),
|
||||
).toBe(`/napcat-webui/session/${SESSION_ID}/webui/webui/login`);
|
||||
});
|
||||
|
||||
it('rewrites absolute redirects under the Gateway session prefix without leaking origin', () => {
|
||||
expect(
|
||||
rewriteNapcatLocationHeader({
|
||||
location: 'http://127.0.0.1:6099/webui/login?next=/',
|
||||
sessionId: SESSION_ID,
|
||||
upstreamBaseUrl: UPSTREAM_BASE_URL,
|
||||
}),
|
||||
).toBe(
|
||||
`/napcat-webui/session/${SESSION_ID}/webui/webui/login?next=/`,
|
||||
);
|
||||
expect(
|
||||
rewriteNapcatLocationHeader({
|
||||
location: 'http://container.internal:6099/webui/login',
|
||||
sessionId: SESSION_ID,
|
||||
upstreamBaseUrl: UPSTREAM_BASE_URL,
|
||||
}),
|
||||
).toBe(`/napcat-webui/session/${SESSION_ID}/webui/webui/login`);
|
||||
});
|
||||
|
||||
it('rewrites protocol-relative redirects under the Gateway session prefix without leaking origin', () => {
|
||||
expect(
|
||||
rewriteNapcatLocationHeader({
|
||||
location: '//container.internal:6099/webui/login?next=/',
|
||||
sessionId: SESSION_ID,
|
||||
upstreamBaseUrl: UPSTREAM_BASE_URL,
|
||||
}),
|
||||
).toBe(
|
||||
`/napcat-webui/session/${SESSION_ID}/webui/webui/login?next=/`,
|
||||
);
|
||||
});
|
||||
|
||||
it('fails closed for malformed absolute redirects', () => {
|
||||
expect(
|
||||
rewriteNapcatLocationHeader({
|
||||
location: 'http://%',
|
||||
sessionId: SESSION_ID,
|
||||
upstreamBaseUrl: UPSTREAM_BASE_URL,
|
||||
}),
|
||||
).toBe(`/napcat-webui/session/${SESSION_ID}/webui/webui`);
|
||||
expect(
|
||||
rewriteNapcatLocationHeader({
|
||||
location: '//%',
|
||||
sessionId: SESSION_ID,
|
||||
upstreamBaseUrl: UPSTREAM_BASE_URL,
|
||||
}),
|
||||
).toBe(`/napcat-webui/session/${SESSION_ID}/webui/webui`);
|
||||
});
|
||||
|
||||
it('scopes all upstream cookies to the Gateway WebUI path', () => {
|
||||
expect(buildGatewayCookiePathRewrite({ sessionId: SESSION_ID })).toEqual({
|
||||
'*': `/napcat-webui/session/${SESSION_ID}/webui`,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('NapcatWebuiCredentialClient', () => {
|
||||
beforeEach(() => {
|
||||
mockedAxiosPost.mockReset();
|
||||
});
|
||||
|
||||
it('exchanges the WebUI token hash and caches the server-side Credential until session expiry', async () => {
|
||||
const currentTime = { value: 1000 };
|
||||
const client = new NapcatWebuiCredentialClient(
|
||||
createCredentialConfig(currentTime) as never,
|
||||
);
|
||||
mockedAxiosPost.mockResolvedValue({ data: { Credential: 'credential-1' } });
|
||||
|
||||
await expect(client.getCredential(createGatewaySession())).resolves.toBe(
|
||||
'credential-1',
|
||||
);
|
||||
await expect(client.getCredential(createGatewaySession())).resolves.toBe(
|
||||
'credential-1',
|
||||
);
|
||||
|
||||
expect(mockedAxiosPost).toHaveBeenCalledTimes(1);
|
||||
expect(mockedAxiosPost).toHaveBeenCalledWith(
|
||||
`${UPSTREAM_BASE_URL}/api/auth/login`,
|
||||
{
|
||||
hash: createHash('sha256')
|
||||
.update('webui-token-fixture.napcat')
|
||||
.digest('hex'),
|
||||
},
|
||||
{ timeout: 5000 },
|
||||
);
|
||||
});
|
||||
|
||||
it('clears cached Credential when the Gateway session is revoked', async () => {
|
||||
const currentTime = { value: 1000 };
|
||||
const client = new NapcatWebuiCredentialClient(
|
||||
createCredentialConfig(currentTime) as never,
|
||||
);
|
||||
mockedAxiosPost
|
||||
.mockResolvedValueOnce({ data: { Credential: 'credential-1' } })
|
||||
.mockResolvedValueOnce({ data: { Credential: 'credential-2' } });
|
||||
|
||||
await expect(client.getCredential(createGatewaySession())).resolves.toBe(
|
||||
'credential-1',
|
||||
);
|
||||
client.clear(SESSION_ID);
|
||||
|
||||
await expect(client.getCredential(createGatewaySession())).resolves.toBe(
|
||||
'credential-2',
|
||||
);
|
||||
expect(mockedAxiosPost).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('PublicWebuiController bootstrap', () => {
|
||||
let app: INestApplication;
|
||||
let ticketService: { redeem: jest.Mock };
|
||||
let sessionService: {
|
||||
markActive: jest.Mock;
|
||||
requireBootstrapSession: jest.Mock;
|
||||
};
|
||||
|
||||
beforeAll(async () => {
|
||||
ticketService = {
|
||||
redeem: jest.fn(),
|
||||
};
|
||||
sessionService = {
|
||||
markActive: jest.fn(),
|
||||
requireBootstrapSession: jest.fn(),
|
||||
};
|
||||
|
||||
const moduleRef = await Test.createTestingModule({
|
||||
controllers: [PublicWebuiController],
|
||||
providers: [
|
||||
{
|
||||
provide: NapcatWebuiGatewaySessionService,
|
||||
useValue: sessionService,
|
||||
},
|
||||
{
|
||||
provide: NapcatWebuiGatewayTicketService,
|
||||
useValue: ticketService,
|
||||
},
|
||||
{
|
||||
provide: NapcatWebuiProxyService,
|
||||
useValue: {
|
||||
handleHttpProxy: jest.fn(),
|
||||
},
|
||||
},
|
||||
],
|
||||
}).compile();
|
||||
|
||||
app = moduleRef.createNestApplication();
|
||||
await app.init();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
ticketService.redeem.mockReset();
|
||||
sessionService.markActive.mockReset();
|
||||
sessionService.requireBootstrapSession.mockReset();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await app?.close();
|
||||
});
|
||||
|
||||
it('redeems a ticket, activates the session, sets an HttpOnly cookie, and redirects to WebUI', async () => {
|
||||
ticketService.redeem.mockResolvedValue(SESSION_ID);
|
||||
sessionService.requireBootstrapSession.mockResolvedValue(
|
||||
createGatewaySession(),
|
||||
);
|
||||
sessionService.markActive.mockResolvedValue(
|
||||
createGatewaySession({ status: 'active' }),
|
||||
);
|
||||
|
||||
const response = await request(app.getHttpServer())
|
||||
.get(`/napcat-webui/session/${SESSION_ID}/bootstrap?ticket=ticket-1`)
|
||||
.expect(HttpStatus.FOUND);
|
||||
|
||||
expect(ticketService.redeem).toHaveBeenCalledWith('ticket-1');
|
||||
expect(sessionService.requireBootstrapSession).toHaveBeenCalledWith(
|
||||
SESSION_ID,
|
||||
);
|
||||
expect(sessionService.markActive).toHaveBeenCalledWith(SESSION_ID);
|
||||
expect(response.headers.location).toBe(
|
||||
`/napcat-webui/session/${SESSION_ID}/webui/webui`,
|
||||
);
|
||||
expect(response.headers['set-cookie']).toEqual([
|
||||
expect.stringContaining(`Path=/napcat-webui/session/${SESSION_ID}`),
|
||||
]);
|
||||
expect(response.headers['set-cookie'][0]).toContain('HttpOnly');
|
||||
});
|
||||
|
||||
it('rejects expired tickets without activating the session', async () => {
|
||||
ticketService.redeem.mockResolvedValue(undefined);
|
||||
|
||||
await request(app.getHttpServer())
|
||||
.get(`/napcat-webui/session/${SESSION_ID}/bootstrap?ticket=expired`)
|
||||
.expect(HttpStatus.GONE);
|
||||
|
||||
expect(sessionService.requireBootstrapSession).not.toHaveBeenCalled();
|
||||
expect(sessionService.markActive).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@ -8,6 +8,7 @@ import {
|
||||
type NapcatWebuiGatewaySession,
|
||||
type NapcatWebuiGatewaySessionStore,
|
||||
} from '../../../src/apps/napcat-webui-gateway/domain/napcat-webui-gateway.types';
|
||||
import { NapcatWebuiCredentialClient } from '../../../src/apps/napcat-webui-gateway/infrastructure/napcat-webui-credential.client';
|
||||
import { NapcatWebuiGatewayRedisStore } from '../../../src/apps/napcat-webui-gateway/infrastructure/session/napcat-webui-gateway-redis.store';
|
||||
import { NapcatWebuiGatewayTicketService } from '../../../src/apps/napcat-webui-gateway/infrastructure/session/napcat-webui-gateway-ticket.service';
|
||||
import { InternalSessionController } from '../../../src/apps/napcat-webui-gateway/presentation/internal-session.controller';
|
||||
@ -60,10 +61,7 @@ class MemorySessionStore implements NapcatWebuiGatewaySessionStore {
|
||||
* @param patch - Fields to merge into the stored session.
|
||||
* @returns Updated session.
|
||||
*/
|
||||
async update(
|
||||
sessionId: string,
|
||||
patch: Partial<NapcatWebuiGatewaySession>,
|
||||
) {
|
||||
async update(sessionId: string, patch: Partial<NapcatWebuiGatewaySession>) {
|
||||
const current = this.sessions.get(sessionId);
|
||||
if (!current) throw new Error(`Missing session ${sessionId}`);
|
||||
const next = { ...current, ...patch };
|
||||
@ -160,20 +158,12 @@ class FakeRedis {
|
||||
throw new Error('Unexpected Redis key count');
|
||||
}
|
||||
|
||||
const [
|
||||
sessionKey,
|
||||
sessionId,
|
||||
patchJson,
|
||||
userAccountKeyPrefix,
|
||||
now,
|
||||
] = args;
|
||||
const [sessionKey, sessionId, 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 patch = JSON.parse(
|
||||
patchJson,
|
||||
) as Partial<NapcatWebuiGatewaySession>;
|
||||
const patch = JSON.parse(patchJson) as Partial<NapcatWebuiGatewaySession>;
|
||||
const next = {
|
||||
...current,
|
||||
...patch,
|
||||
@ -235,7 +225,9 @@ class FakeRedis {
|
||||
* @returns Session creation payload.
|
||||
*/
|
||||
function createSessionInput(
|
||||
override: Partial<Parameters<NapcatWebuiGatewaySessionService['create']>[0]> = {},
|
||||
override: Partial<
|
||||
Parameters<NapcatWebuiGatewaySessionService['create']>[0]
|
||||
> = {},
|
||||
) {
|
||||
return {
|
||||
accountId: 'account-1',
|
||||
@ -368,7 +360,9 @@ describe('NapcatWebuiGatewaySessionService', () => {
|
||||
service.create(createSessionInput({ webuiToken: ' ' })),
|
||||
).rejects.toThrow('Gateway session field webuiToken is required');
|
||||
await expect(
|
||||
service.create(createSessionInput({ upstreamBaseUrl: 'ftp://127.0.0.1' })),
|
||||
service.create(
|
||||
createSessionInput({ upstreamBaseUrl: 'ftp://127.0.0.1' }),
|
||||
),
|
||||
).rejects.toThrow('Gateway session upstream URL is invalid');
|
||||
expect(store.sessions.size).toBe(0);
|
||||
});
|
||||
@ -425,9 +419,9 @@ describe('NapcatWebuiGatewaySessionService', () => {
|
||||
);
|
||||
const session = await service.create(createSessionInput());
|
||||
|
||||
await expect(service.requireProxySession(session.sessionId)).rejects.toThrow(
|
||||
'Gateway session is not active',
|
||||
);
|
||||
await expect(
|
||||
service.requireProxySession(session.sessionId),
|
||||
).rejects.toThrow('Gateway session is not active');
|
||||
await expect(
|
||||
service.requireBootstrapSession(session.sessionId),
|
||||
).resolves.toMatchObject({
|
||||
@ -458,9 +452,9 @@ describe('NapcatWebuiGatewaySessionService', () => {
|
||||
|
||||
currentTime.value = 70_000;
|
||||
|
||||
await expect(service.requireProxySession(session.sessionId)).rejects.toThrow(
|
||||
'Gateway session is not active',
|
||||
);
|
||||
await expect(
|
||||
service.requireProxySession(session.sessionId),
|
||||
).rejects.toThrow('Gateway session is not active');
|
||||
expect(await store.find(session.sessionId)).toMatchObject({
|
||||
status: 'expired',
|
||||
});
|
||||
@ -479,9 +473,9 @@ describe('NapcatWebuiGatewaySessionService', () => {
|
||||
sessionId: session.sessionId,
|
||||
});
|
||||
|
||||
await expect(service.requireProxySession(session.sessionId)).rejects.toThrow(
|
||||
'Gateway session is not active',
|
||||
);
|
||||
await expect(
|
||||
service.requireProxySession(session.sessionId),
|
||||
).rejects.toThrow('Gateway session is not active');
|
||||
});
|
||||
});
|
||||
|
||||
@ -540,9 +534,9 @@ describe('NapcatWebuiGatewayRedisStore', () => {
|
||||
sessionId: second.sessionId,
|
||||
status: 'created',
|
||||
});
|
||||
expect(redis.values.get('napcat:webui:user-account:admin-1:account-1')).toBe(
|
||||
second.sessionId,
|
||||
);
|
||||
expect(
|
||||
redis.values.get('napcat:webui:user-account:admin-1:account-1'),
|
||||
).toBe(second.sessionId);
|
||||
});
|
||||
|
||||
it('rejects stale non-terminal updates when the index points at a newer session', async () => {
|
||||
@ -579,9 +573,11 @@ describe('NapcatWebuiGatewayRedisStore', () => {
|
||||
}),
|
||||
).rejects.toThrow('Gateway session is not active');
|
||||
expect(redis.values.get(indexKey)).toBe(second.sessionId);
|
||||
expect(JSON.parse(redis.values.get(firstSessionKey) || '{}')).toMatchObject({
|
||||
status: 'created',
|
||||
});
|
||||
expect(JSON.parse(redis.values.get(firstSessionKey) || '{}')).toMatchObject(
|
||||
{
|
||||
status: 'created',
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects non-terminal updates when the user-account index is missing', async () => {
|
||||
@ -766,6 +762,9 @@ describe('NapcatWebuiGatewayTicketService', () => {
|
||||
|
||||
describe('InternalSessionController', () => {
|
||||
let app: INestApplication;
|
||||
const credentialClient = {
|
||||
clear: jest.fn(),
|
||||
};
|
||||
const store = new MemorySessionStore();
|
||||
const currentTime = { value: 1000 };
|
||||
const config = createConfig(currentTime);
|
||||
@ -780,6 +779,10 @@ describe('InternalSessionController', () => {
|
||||
provide: NapcatWebuiGatewayConfigService,
|
||||
useValue: config,
|
||||
},
|
||||
{
|
||||
provide: NapcatWebuiCredentialClient,
|
||||
useValue: credentialClient,
|
||||
},
|
||||
{
|
||||
provide: NAPCAT_WEBUI_GATEWAY_SESSION_STORE,
|
||||
useValue: store,
|
||||
@ -796,6 +799,7 @@ describe('InternalSessionController', () => {
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
credentialClient.clear.mockReset();
|
||||
store.sessions.clear();
|
||||
currentTime.value = 1000;
|
||||
});
|
||||
@ -892,6 +896,7 @@ describe('InternalSessionController', () => {
|
||||
.set('x-kt-gateway-secret', INTERNAL_SECRET)
|
||||
.send({ adminUserId: 'admin-1' })
|
||||
.expect(HttpStatus.CREATED);
|
||||
expect(credentialClient.clear).toHaveBeenCalledWith(sessionId);
|
||||
await request(app.getHttpServer())
|
||||
.post(`/internal/sessions/${sessionId}/heartbeat`)
|
||||
.set('x-kt-gateway-secret', INTERNAL_SECRET)
|
||||
|
||||
Loading…
Reference in New Issue
Block a user