Merge branch 'next' into develop

This commit is contained in:
nocobase[bot]
2026-05-19 03:03:34 +00:00
2 changed files with 128 additions and 12 deletions
@@ -0,0 +1,95 @@
/**
* This file is part of the NocoBase (R) project.
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
* Authors: NocoBase Team.
*
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
* For more information, please refer to: https://www.nocobase.com/agreement.
*/
import { MockServer, createMockServer } from '@nocobase/test';
// Regression coverage for the `ws:message:auth:token` handler in
// plugin-auth's server entry. The handler is registered with `app.on`, which
// is Node's sync EventEmitter — any rejection thrown inside the async
// listener becomes an unhandled promise rejection and (under Node's default
// policy) crashes the process. These cases exercise the failure paths that
// were known to crash before the handler grew defensive try/catch coverage.
type RemoveTagPayload = { clientId: string; tagKey: string };
function nextRemoveTag(app: MockServer): Promise<RemoveTagPayload> {
return new Promise((resolve) => {
app.once('ws:removeTag', resolve);
});
}
function withTimeout<T>(promise: Promise<T>, ms: number, label: string): Promise<T> {
return Promise.race([
promise,
new Promise<T>((_resolve, reject) => setTimeout(() => reject(new Error(`${label} timed out after ${ms}ms`)), ms)),
]);
}
describe('ws:message:auth:token handler', () => {
let app: MockServer;
beforeEach(async () => {
app = await createMockServer({
plugins: ['field-sort', 'users', 'auth'],
});
});
afterEach(async () => {
await app.destroy();
});
it('removes the userId tag when the payload has no token', async () => {
const removeTag = nextRemoveTag(app);
app.emit('ws:message:auth:token', {
clientId: 'client-no-token',
payload: { token: '' },
});
const result = await withTimeout(removeTag, 2000, 'ws:removeTag for empty token');
expect(result.clientId).toBe('client-no-token');
expect(result.tagKey).toBe('userId');
});
it('removes the userId tag when the authenticator name is not registered in the DB', async () => {
// No authenticator row was seeded with this name, so `authManager.get`
// throws `Authenticator [no-such-authenticator] is not found.`. Before
// the fix this rejection escaped the async listener and crashed Node.
const removeTag = nextRemoveTag(app);
app.emit('ws:message:auth:token', {
clientId: 'client-bad-authenticator',
payload: { token: 'irrelevant', authenticator: 'no-such-authenticator' },
});
const result = await withTimeout(removeTag, 2000, 'ws:removeTag for missing authenticator');
expect(result.clientId).toBe('client-bad-authenticator');
expect(result.tagKey).toBe('userId');
});
it('removes the userId tag when the authenticator references an unregistered authType', async () => {
// Seed an authenticator whose `authType` (`CAS`) no plugin in this app
// has registered — this is the production-observed shape: a DB row left
// over from when an auth plugin was previously enabled. Resolving it
// throws `AuthType [CAS] is not found.` from AuthManager.
await app.db.getRepository('authenticators').create({
values: {
name: 'stranded-cas',
authType: 'CAS',
enabled: true,
options: {},
},
});
const removeTag = nextRemoveTag(app);
app.emit('ws:message:auth:token', {
clientId: 'client-unregistered-authtype',
payload: { token: 'irrelevant', authenticator: 'stranded-cas' },
});
const result = await withTimeout(removeTag, 2000, 'ws:removeTag for unregistered authType');
expect(result.clientId).toBe('client-unregistered-authtype');
expect(result.tagKey).toBe('userId');
});
});
@@ -150,18 +150,39 @@ export class PluginAuthServer extends Plugin {
return;
}
const auth = await this.app.authManager.get(payload.authenticator || 'basic', {
getBearerToken: () => payload.token,
app: this.app,
db: this.app.db,
cache: this.app.cache,
logger: this.app.logger,
log: this.app.log,
throw: (...args) => {
throw new Error(...args);
},
t: this.app.i18n.t,
} as any);
// `app.emit` is Node's sync EventEmitter, so any rejection thrown
// inside this async listener becomes an unhandled promise rejection —
// under Node 22's default policy that crashes the entire process.
// Resolving the authenticator can fail for legitimate runtime reasons
// (the auth-type plugin is disabled or still loading during boot, the
// authenticator row was deleted, etc.); none of those should take the
// server down. Wrap the lookup, log, drop the connection's userId tag
// so the client is treated as unauthenticated, and return.
let auth;
try {
auth = await this.app.authManager.get(payload.authenticator || 'basic', {
getBearerToken: () => payload.token,
app: this.app,
db: this.app.db,
cache: this.app.cache,
logger: this.app.logger,
log: this.app.log,
throw: (...args) => {
throw new Error(...args);
},
t: this.app.i18n.t,
} as any);
} catch (error) {
this.app.logger.warn('ws:message:auth:token authenticator resolve failed', {
authenticator: payload.authenticator,
error: error instanceof Error ? error.message : String(error),
});
this.app.emit(`ws:removeTag`, {
clientId,
tagKey: 'userId',
});
return;
}
let user: Model;
try {